Compare commits

..

26 Commits

Author SHA1 Message Date
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
72 changed files with 6466 additions and 6192 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

@@ -14,14 +14,6 @@
//! (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`.
//!
//! **Extension points (§5.5).** A node may opt a module name OUT of sealing
//! by declaring it an extension point: an importer on that node's chain then
//! resolves the name against the *inheriting app's* effective view
//! (`App(cx.app_id)`), with the declared default body as fallback — so each
//! descendant app supplies its own impl. The "is this an extension point"
//! check keys on the importer's `origin` (trusted), so only the declaring
//! parent can open the inversion; a descendant can't hijack a sealed import.
//!
//! Three runtime invariants are enforced:
//!
//! 1. **Lexical isolation** — `ModuleSource::resolve` is keyed by the
@@ -385,53 +377,40 @@ impl ModuleResolver for PicloudModuleResolver {
let origin = importer_source
.and_then(decode_origin)
.unwrap_or(self.default_origin);
// Extension-point inversion (§5.5): if `path`'s nearest declaration on
// the importer's chain is an extension point (not a concrete module),
// resolve it against the INHERITING app's effective view instead of the
// sealed lexical chain — each descendant app supplies its own impl,
// falling back to the declared default body. The decision keys on
// `origin` (the trusted importer node), so only a declaration on the
// importer's own ancestry can open this; a descendant can never make a
// parent's sealed `import` dynamic, and a non-ext-point import never
// consults `App(cx.app_id)`. All branches funnel any backend error into
// the single redaction arm below.
let lookup_result: Result<Option<picloud_shared::ModuleScript>, ModuleSourceError> =
(|| {
let ep = tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve_extension_point(origin, path))
})?;
let Some(ep) = ep else {
// Sealed lexical resolution (the default, unchanged).
return tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve(origin, path))
});
};
let app_origin = ScriptOwner::App(self.cx.app_id);
if let Some(m) = tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve(app_origin, path))
})? {
return Ok(Some(m));
}
match ep.default_script_id {
// The default body is a module declared at the node that
// opened the ext point, i.e. on the importer's (`origin`)
// chain — resolve it there, not against the app.
Some(id) => tokio::task::block_in_place(|| {
handle.block_on(self.source.resolve_by_id(origin, id))
}),
None => Ok(None),
}
})();
// §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:

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

@@ -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

@@ -34,22 +34,6 @@ impl ModuleSource for FailingSource {
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
}
async fn resolve_extension_point(
&self,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
_script_id: picloud_shared::ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Err(ModuleSourceError::Backend(SENTINEL.to_string()))
}
}
/// `MakeWriter` that appends to a shared buffer.

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;
@@ -95,30 +95,6 @@ impl ModuleSource for CountingModuleSource {
.get(&(app_id, name.to_string()))
.cloned())
}
async fn resolve_extension_point(
&self,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
// This flat fake has no extension points; the inversion is covered by
// `LexicalModuleSource` below and the Postgres journey tests.
Ok(None)
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(self
.table
.lock()
.await
.values()
.find(|m| m.script_id == script_id)
.cloned())
}
}
fn services_with(modules: Arc<dyn ModuleSource>) -> Services {
@@ -647,10 +623,6 @@ fn validate_endpoint_skips_dynamic_imports_in_imports_list() {
#[derive(Default)]
struct LexicalModuleSource {
table: Mutex<HashMap<(ScriptOwner, String), ModuleScript>>,
/// Extension-point declarations, keyed by exact declaring owner →
/// optional default module id. Flat (exact-owner) — the chain-walk +
/// shadowing tie-break is covered by the Postgres journey tests.
ext_points: Mutex<HashMap<(ScriptOwner, String), Option<ScriptId>>>,
}
impl LexicalModuleSource {
@@ -658,16 +630,15 @@ impl LexicalModuleSource {
Arc::new(Self::default())
}
async fn put(self: &Arc<Self>, owner: ScriptOwner, name: &str, source: &str) -> ScriptId {
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)),
};
let script_id = ScriptId::new();
self.table.lock().await.insert(
(owner, name.to_string()),
ModuleScript {
script_id,
script_id: ScriptId::new(),
app_id,
group_id,
name: name.to_string(),
@@ -675,21 +646,6 @@ impl LexicalModuleSource {
updated_at: Utc::now(),
},
);
script_id
}
/// Declare `name` an extension point at `owner`, with an optional default
/// module body id.
async fn put_ext_point(
self: &Arc<Self>,
owner: ScriptOwner,
name: &str,
default_script_id: Option<ScriptId>,
) {
self.ext_points
.lock()
.await
.insert((owner, name.to_string()), default_script_id);
}
}
@@ -707,35 +663,6 @@ impl ModuleSource for LexicalModuleSource {
.get(&(origin, name.to_string()))
.cloned())
}
async fn resolve_extension_point(
&self,
origin: ScriptOwner,
name: &str,
) -> Result<Option<picloud_shared::ExtPointResolution>, ModuleSourceError> {
Ok(self
.ext_points
.lock()
.await
.get(&(origin, name.to_string()))
.map(|default_script_id| picloud_shared::ExtPointResolution {
default_script_id: *default_script_id,
}))
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(self
.table
.lock()
.await
.values()
.find(|m| m.script_id == script_id)
.cloned())
}
}
fn req_with_owner(app_id: AppId, owner: ScriptOwner) -> ExecRequest {
@@ -811,172 +738,148 @@ async fn lexical_entry_origin_selects_app_module() {
}
// ---------------------------------------------------------------------------
// Extension points (§5.5) — the opt-in resolution inversion. A group declares
// a module name an extension point; an importer on the group's chain resolves
// it against the INHERITING APP, not the sealed lexical chain.
// §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.
// ---------------------------------------------------------------------------
/// A group script importing a declared extension point resolves it against the
/// executing app's own module — each tenant supplies its own body.
#[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 ext_point_resolves_to_app_module() {
let source = LexicalModuleSource::new();
async fn extension_point_resolves_app_override() {
let source = PolicyModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// The group opens "theme" as an extension point (no default).
source.mark_ep("theme").await;
// App provides its own `theme`; group has none.
source
.put_ext_point(ScriptOwner::Group(group), "theme", None)
.await;
// The app provides its own "theme".
source
.put(
ScriptOwner::App(app),
"theme",
r#"fn name() { "app-theme" }"#,
)
.put(ScriptOwner::App(app), "theme", r#"fn color() { "red" }"#)
.await;
let engine = engine_with(source.clone());
// Entry runs as the inherited GROUP script (default_origin = group), but
// the executing app is `app` (cx.app_id).
// Entry runs as the inherited GROUP endpoint importing the EP `theme`.
let resp = engine
.execute(
r#"import "theme" as t; t::name()"#,
r#"import "theme" as t; t::color()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(
resp.body,
serde_json::json!("app-theme"),
"extension point must resolve to the inheriting app's module"
);
assert_eq!(resp.body, serde_json::json!("red"));
}
/// When the app provides no module for the extension point, the declared
/// default body is used.
/// An extension point with no provider for the app is a hard error.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_falls_back_to_default() {
let source = LexicalModuleSource::new();
async fn extension_point_without_provider_errors() {
let source = PolicyModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// The group's default "theme" body, registered as a group module.
let default_id = source
.put(
ScriptOwner::Group(group),
"default-theme",
r#"fn name() { "default-theme" }"#,
)
.await;
source
.put_ext_point(ScriptOwner::Group(group), "theme", Some(default_id))
.await;
// The app provides NO "theme".
let engine = engine_with(source.clone());
let resp = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(resp.body, serde_json::json!("default-theme"));
}
/// An extension point with neither an app provider nor a default is a clean
/// module-not-found at import time.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_no_provider_no_default_errors() {
let source = LexicalModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
source
.put_ext_point(ScriptOwner::Group(group), "theme", None)
.await;
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::name()"#,
r#"import "theme" as t; t::color()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect_err("ext point with no provider/default should fail");
.expect_err("missing provider must error");
let msg = format!("{err:?}").to_lowercase();
assert!(
msg.contains("module") || msg.contains("not found") || msg.contains("theme"),
"expected module-not-found-flavoured error, got {msg}"
msg.contains("no provider") || msg.contains("extension point"),
"expected a no-provider error, got {msg}"
);
}
/// A NORMAL (non-extension-point) group import is NOT hijacked by a leaf app's
/// module of the same name — the inversion only fires for declared extension
/// points, so the trust boundary holds.
/// A non-extension-point name still resolves lexically from the origin.
#[tokio::test(flavor = "multi_thread")]
async fn sealed_import_not_hijacked_by_leaf_when_not_ext_point() {
let source = LexicalModuleSource::new();
async fn non_extension_point_stays_lexical() {
let source = PolicyModuleSource::new();
let app = AppId::new();
let group = GroupId::new();
// "auth" is a concrete group module — NOT an extension point.
source
.put(
ScriptOwner::Group(group),
"auth",
r#"fn who() { "group-auth" }"#,
)
.await;
// The app has a trap "auth" that must NOT be selected.
source
.put(ScriptOwner::App(app), "auth", r#"fn who() { "leaf-trap" }"#)
.put(ScriptOwner::Group(group), "util", r#"fn v() { 7 }"#)
.await;
let engine = engine_with(source.clone());
let resp = engine
.execute(
r#"import "auth" as a; a::who()"#,
r#"import "util" as u; u::v()"#,
req_with_owner(app, ScriptOwner::Group(group)),
)
.expect("should execute");
assert_eq!(
resp.body,
serde_json::json!("group-auth"),
"a non-ext-point import must seal to the group, never the leaf's trap"
);
}
/// Two apps inheriting the same extension point get their OWN bodies — the
/// id-keyed module cache does not bleed one tenant's body into another.
#[tokio::test(flavor = "multi_thread")]
async fn ext_point_two_apps_get_distinct_bodies() {
let source = LexicalModuleSource::new();
let app1 = AppId::new();
let app2 = AppId::new();
let group = GroupId::new();
source
.put_ext_point(ScriptOwner::Group(group), "theme", None)
.await;
source
.put(ScriptOwner::App(app1), "theme", r#"fn name() { "one" }"#)
.await;
source
.put(ScriptOwner::App(app2), "theme", r#"fn name() { "two" }"#)
.await;
let engine = engine_with(source.clone());
let r1 = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app1, ScriptOwner::Group(group)),
)
.expect("app1 executes");
let r2 = engine
.execute(
r#"import "theme" as t; t::name()"#,
req_with_owner(app2, ScriptOwner::Group(group)),
)
.expect("app2 executes");
assert_eq!(r1.body, serde_json::json!("one"));
assert_eq!(
r2.body,
serde_json::json!("two"),
"no cross-tenant cache bleed"
);
assert_eq!(resp.body, serde_json::json!(7));
}

View File

@@ -1,49 +1,40 @@
-- Phase 4b+1 (v1.2 Hierarchies): extension points (§5.5).
-- §5.5 (v1.2 Hierarchies): extension-point markers — opt-in module polymorphism.
--
-- An extension point opts a MODULE NAME into dynamic, per-tenant resolution.
-- Normally a group/parent script's `import "x"` resolves sealed-lexically
-- against the importer's own defining node — a leaf app cannot shadow it
-- (the trust boundary). When a node declares `x` an extension point, an
-- importer whose defining node is on that node's chain instead resolves `x`
-- against the INHERITING app's effective view, so each descendant app may
-- supply its own `x`. Only the declaring (parent) node can open the hole; a
-- descendant cannot make one of its parent's sealed imports dynamic, because
-- the declaration must live on the importer's own ancestry.
-- 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.
--
-- Owner-polymorphic exactly like vars (0048): exactly one of group_id/app_id.
-- Optional `default_script_id` is the fallback module body used when the
-- inheriting app provides no module of `name`.
-- 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(),
-- Polymorphic owner: exactly one FK set (real FKs 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,
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,
-- Optional fallback module (a module owned by the declaring node). SET
-- NULL on delete so deleting the module can never block — the fallback is
-- then absent and a non-providing app errors at import time (the next plan
-- shows the now-null default as an Update). A valid manifest apply can't
-- reach this: validation rejects a kept ext point whose default names a
-- module not in the bundle.
default_script_id UUID REFERENCES scripts(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- One declaration per (owner, name), case-insensitive to match the module
-- name indexes (0050). Partial because the owner is split across two columns.
-- 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;
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;
CREATE INDEX extension_points_default_script_idx
ON extension_points (default_script_id) WHERE default_script_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

@@ -1,39 +0,0 @@
-- Phase 5 / M3 (v1.2 Hierarchies): multi-repo single-owner ownership (§7).
--
-- A group node is authoritatively MANAGED by at most one project-root (the
-- repo whose manifest declares it as something it applies, not merely
-- references). `groups.owner_project` has carried this seam since 0047 as an
-- inert, FK-less UUID. M3 makes it a real reference: a `projects` table keyed
-- by a stable, gitignored project key the CLI mints in `.picloud/`, and a
-- foreign key from `groups.owner_project` to it.
--
-- Ownership is recorded at GROUP granularity; apps inherit their owning project
-- from their group (apps are never claimed directly). A NULL `owner_project`
-- means the node is UI/API-owned — no manifest fights it (§7.5). First apply
-- claims; a second project's apply to an owned node is refused unless
-- `--takeover` (group-admin gated). Ownership ⟂ RBAC: owning the manifest does
-- NOT grant write — the actor still needs the usual group capabilities (§7.4).
--
-- ON DELETE SET NULL: deleting a project row (not something the platform does
-- today) reverts its nodes to UI-owned rather than cascading away real groups.
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- Stable, opaque key minted by `pic init` and persisted (gitignored) in the
-- repo's `.picloud/`. The CLI presents it on every tree apply; the server
-- maps it to this row (upsert-by-key), so the same repo keeps the same
-- project identity across clones/CI without committing any server id.
key TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Promote the inert `groups.owner_project` (0047:38) to a real FK now that the
-- referent exists. Existing rows are all NULL (no projects yet), so this adds
-- no validation burden.
ALTER TABLE groups
ADD CONSTRAINT groups_owner_project_fkey
FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL;
-- The ownership reconcile reads "which groups does project P own?" (to find
-- owned-but-undeclared nodes for structural prune) and "who owns group G?".
CREATE INDEX groups_owner_project_idx ON groups (owner_project);

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

@@ -1,49 +0,0 @@
-- Phase 5 / M4a (v1.2 Hierarchies): ROUTE templates (§4.5).
--
-- Triggers and routes are app-scoped (never group-inherited — §5.1). At high
-- tenant cardinality that means 100 apps × N routes = 100N hand declarations.
-- A TEMPLATE fixes that: declare a route ONCE on a group, and the apply engine
-- fans it out into one concrete per-`app_id` row for every descendant app. This
-- is INSTANTIATION, not inheritance — expanded rows stay app-owned (the
-- isolation boundary is unchanged); the template is just a stamp.
--
-- Placeholders in template fields are a small, inert, documented set resolved
-- per descendant at expansion: `{app_slug}`, `{env}`, `{var:NAME}` (the app's
-- effective var). Provenance: each expanded `routes` row carries `from_template`
-- (the template id it was stamped from), so re-apply diffs idempotently and
-- `--prune` removes expansions WITHOUT touching a hand-declared route of the
-- same identity.
--
-- (Trigger templates reuse this engine in a follow-up: `trigger_templates` +
-- `triggers.from_template`.)
-- Route templates — one per (group, name). Mirrors the `routes` column shape
-- minus `app_id` (filled per descendant) plus `script_name` (resolved to the
-- nearest inherited endpoint at expansion, like declarative route bindings).
CREATE TABLE route_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
-- Explicit identity/upsert key, unique per group (case-insensitive).
name TEXT NOT NULL,
script_name TEXT NOT NULL,
method TEXT,
host_kind TEXT NOT NULL CHECK (host_kind IN ('any', 'strict', 'wildcard')),
host TEXT NOT NULL DEFAULT '',
host_param_name TEXT,
path_kind TEXT NOT NULL CHECK (path_kind IN ('exact', 'prefix', 'param')),
path TEXT NOT NULL,
dispatch_mode TEXT NOT NULL DEFAULT 'sync' CHECK (dispatch_mode IN ('sync', 'async')),
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX route_templates_group_name_idx
ON route_templates (group_id, LOWER(name));
-- Provenance on the expanded rows. NULL = hand-declared (the app's own
-- manifest); non-NULL = stamped from this template, managed by template
-- expansion/prune. No FK: the apply engine owns the expansion lifecycle (it
-- deletes orphaned expansions explicitly), and a dangling id after a raw
-- template delete is harmless (treated as "template gone → prune the row").
ALTER TABLE routes ADD COLUMN from_template UUID;
CREATE INDEX routes_from_template_idx ON routes (app_id, from_template);

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

@@ -1,33 +0,0 @@
-- Phase 5 / M4b (v1.2 Hierarchies): TRIGGER templates (§4.5).
--
-- The trigger half of templates (M4a shipped routes). A `[group]` manifest
-- declares `[[trigger_templates.<kind>]]`; the apply fans each one out into a
-- concrete per-`app_id` trigger on every descendant app, reusing the same
-- expansion engine, placeholder set (`{app_slug}`/`{env}`/`{var:NAME}`), and
-- `from_template` provenance as routes.
--
-- A trigger's parameters vary by kind (collection_glob/ops, schedule/timezone,
-- topic_pattern, queue_name, inbound_secret_ref, …), so the template stores the
-- whole `BundleTrigger` wire object as `spec` JSONB (kind tag included). The
-- expansion resolves placeholders in the spec's string leaves, then rebuilds the
-- typed trigger and inserts it through the normal trigger path (email secrets
-- are resolved + re-sealed per app, exactly like a hand-declared email trigger).
CREATE TABLE trigger_templates (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
-- Identity/upsert key, unique per group (case-insensitive).
name TEXT NOT NULL,
-- The full `BundleTrigger` wire object (internally tagged by `kind`), with
-- placeholders left unresolved. Rebuilt + resolved per descendant at apply.
spec JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX trigger_templates_group_name_idx
ON trigger_templates (group_id, LOWER(name));
-- Provenance on expanded triggers (mirrors routes.from_template, 0053). NULL =
-- hand-declared; non-NULL = stamped from this template, managed by expansion.
ALTER TABLE triggers ADD COLUMN from_template UUID;
CREATE INDEX triggers_from_template_idx ON triggers (app_id, from_template);

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

@@ -88,7 +88,6 @@ async fn seed_into(
method: None,
dispatch_mode: picloud_shared::DispatchMode::Sync,
enabled: true,
from_template: None,
})
.await?;

View File

@@ -17,8 +17,8 @@ use serde_json::json;
use crate::app_repo::AppRepository;
use crate::apply_service::{
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, ExtPointView,
NodeKind, PlanResult, TreeBundle, TreePlanResult,
ApplyError, ApplyOwner, ApplyReport, ApplyService, Bundle, BundleTrigger, CollectionInfo,
ExtensionPointInfo, NodeKind, PlanResult, TreeBundle, TreePlanResult,
};
use crate::authz::{require, AuthzDenied, Capability};
use crate::group_repo::GroupRepository;
@@ -30,39 +30,27 @@ pub fn apply_router(service: ApplyService) -> Router {
.route("/apps/{id}/apply", post(apply_handler))
.route("/groups/{id}/plan", post(group_plan_handler))
.route("/groups/{id}/apply", post(group_apply_handler))
.route("/apps/{id}/extension-points", get(app_ext_points_handler))
.route(
"/groups/{id}/extension-points",
get(group_ext_points_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))
.with_state(service)
}
/// `GET .../apps/{id}/extension-points` — list the app's OWN extension-point
/// declarations (for `pic pull`). Read-only; viewer-tier (`AppRead`).
async fn app_ext_points_handler(
/// 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<ExtPointView>>, 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)?;
Ok(Json(
svc.list_extension_points(ApplyOwner::App(app_id)).await?,
))
}
/// `GET .../groups/{id}/extension-points` — list the group's OWN extension-point
/// declarations. Read-only; viewer-tier (`GroupScriptsRead`).
async fn group_ext_points_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Path(id_or_slug): Path<String>,
) -> Result<Json<Vec<ExtPointView>>, ApplyError> {
) -> Result<Json<Vec<CollectionInfo>>, ApplyError> {
let group_id = resolve_group_id(svc.groups.as_ref(), &id_or_slug).await?;
require(
svc.authz.as_ref(),
@@ -71,10 +59,43 @@ async fn group_ext_points_handler(
)
.await
.map_err(map_authz)?;
Ok(Json(
svc.list_extension_points(ApplyOwner::Group(group_id))
.await?,
))
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)]
@@ -178,16 +199,6 @@ async fn group_apply_handler(
// must hold the relevant read/write caps for EVERY node touched.
// ----------------------------------------------------------------------------
#[derive(Deserialize)]
struct TreePlanRequest {
bundle: TreeBundle,
/// Stable project key from the repo's `.picloud/` link state (§7, M3).
/// Lets `plan` surface ownership conflicts and prune candidates. Optional
/// for legacy callers (then no ownership diagnosis is reported).
#[serde(default)]
project_key: Option<String>,
}
#[derive(Deserialize)]
struct TreeApplyRequest {
bundle: TreeBundle,
@@ -195,34 +206,15 @@ struct TreeApplyRequest {
prune: bool,
#[serde(default)]
expected_token: Option<String>,
/// Stable project key (§7, M3): the apply claims unclaimed declared groups
/// for this project and refuses ones another project owns.
#[serde(default)]
project_key: Option<String>,
/// Take over declared groups owned by another project (group-admin gated).
#[serde(default)]
allow_takeover: bool,
/// Selected environment (§4.5, M4a): the value substituted for the `{env}`
/// placeholder when expanding route templates. The CLI passes `--env`.
#[serde(default)]
env: Option<String>,
/// Environments the actor explicitly approved this apply for (§4.2, M5).
/// A confirm-required env (per the bundle's `[project]` policy) is refused
/// unless it appears here; `--yes` alone does NOT add it.
#[serde(default)]
approved_envs: Vec<String>,
}
async fn tree_plan_handler(
State(svc): State<ApplyService>,
Extension(principal): Extension<Principal>,
Json(req): Json<TreePlanRequest>,
Json(bundle): Json<TreeBundle>,
) -> Result<Json<TreePlanResult>, ApplyError> {
authz_tree(&svc, &principal, &req.bundle, None, false).await?;
Ok(Json(
svc.plan_tree(&req.bundle, req.project_key.as_deref())
.await?,
))
authz_tree(&svc, &principal, &bundle, None).await?;
Ok(Json(svc.plan_tree(&bundle).await?))
}
async fn tree_apply_handler(
@@ -230,31 +222,13 @@ async fn tree_apply_handler(
Extension(principal): Extension<Principal>,
Json(req): Json<TreeApplyRequest>,
) -> Result<Json<ApplyReport>, ApplyError> {
authz_tree(
&svc,
&principal,
&req.bundle,
Some(req.prune),
req.allow_takeover,
)
.await?;
enforce_env_approval(
&svc,
&principal,
&req.bundle,
req.env.as_deref(),
&req.approved_envs,
)
.await?;
authz_tree(&svc, &principal, &req.bundle, Some(req.prune)).await?;
let report = svc
.apply_tree(
&req.bundle,
req.prune,
&principal,
principal.user_id,
req.expected_token.as_deref(),
req.project_key.as_deref(),
req.allow_takeover,
req.env.as_deref(),
)
.await?;
Ok(Json(report))
@@ -263,13 +237,11 @@ async fn tree_apply_handler(
/// 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.
#[allow(clippy::too_many_lines)]
async fn authz_tree(
svc: &ApplyService,
principal: &Principal,
bundle: &TreeBundle,
apply_prune: Option<bool>,
allow_takeover: bool,
) -> Result<(), ApplyError> {
for node in &bundle.nodes {
match node.kind {
@@ -279,42 +251,6 @@ async fn authz_tree(
Some(prune) => {
require_app_node_writes(svc, principal, app_id, &node.bundle, prune)
.await?;
// Template expansion (§4.5) writes routes/triggers INTO
// this app, so the actor needs the matching write cap
// when the app's chain carries templates — even if the
// app itself declares none. Without this, expanding into
// an app a principal can't write would slip the gate.
let recv = app_receives_template_expansions(svc, app_id, bundle).await?;
if recv.routes {
require(
svc.authz.as_ref(),
principal,
Capability::AppWriteRoute(app_id),
)
.await
.map_err(map_authz)?;
}
if recv.triggers {
require(
svc.authz.as_ref(),
principal,
Capability::AppManageTriggers(app_id),
)
.await
.map_err(map_authz)?;
}
// Email-trigger expansion resolves + seals the recipient
// app's secret server-side — same `AppSecretsRead` gate a
// hand-declared email trigger requires.
if recv.email {
require(
svc.authz.as_ref(),
principal,
Capability::AppSecretsRead(app_id),
)
.await
.map_err(map_authz)?;
}
}
None => {
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
@@ -324,109 +260,11 @@ async fn authz_tree(
}
}
NodeKind::Group => {
let existing = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let Some(g) = existing else {
// To-create group (M2): mirror the interactive create gate
// (`groups_api::create_group`). A root-level group (no
// resolvable parent) needs `InstanceCreateGroup`; a subgroup
// under an EXISTING parent needs only `GroupAdmin(parent)`.
// A parent that is itself to-create has no id yet → fall
// back to `InstanceCreateGroup`.
let parent = match &node.parent_slug {
Some(p) => svc
.groups
.get_by_slug(p)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?,
None => None,
};
match parent {
Some(pg) => {
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(pg.id))
.await
.map_err(map_authz)?;
}
None => {
require(
svc.authz.as_ref(),
principal,
Capability::InstanceCreateGroup,
)
.await
.map_err(map_authz)?;
}
}
continue;
};
let group_id = g.id;
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?;
// Takeover gate (§7.4): seizing a group that another
// project owns needs GroupAdmin specifically — a second,
// independent gate on top of the write caps. We gate it
// for any already-claimed node under `--takeover` (a
// superset of genuinely-contested, never laxer); claiming
// an unclaimed node, or applying without `--takeover`,
// does not require admin. The authoritative owner rewrite
// happens in-tx in `apply_tree` (which knows our project).
if allow_takeover
&& crate::group_repo::get_group_owner(&svc.pool, group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.is_some()
{
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(group_id),
)
.await
.map_err(map_authz)?;
}
// Reparent: an explicit parent differing from the live
// one needs GroupAdmin at the group, the SOURCE parent,
// and the DESTINATION parent — mirroring the interactive
// `reparent_group` (§5.6 triply-gated).
if let Some(parent) = &node.parent_slug {
if let Some(pg) = svc
.groups
.get_by_slug(parent)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
if Some(pg.id) != g.parent_id {
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(group_id),
)
.await
.map_err(map_authz)?;
if let Some(src) = g.parent_id {
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(src),
)
.await
.map_err(map_authz)?;
}
require(
svc.authz.as_ref(),
principal,
Capability::GroupAdmin(pg.id),
)
.await
.map_err(map_authz)?;
}
}
}
}
None => {
require(
@@ -441,71 +279,6 @@ async fn authz_tree(
}
}
}
// NOTE (§4.5, M7): templates also fan out to descendant apps NOT declared in
// this bundle (the cross-repo subtree). Those per-recipient write caps are
// gated AUTHORITATIVELY IN-TRANSACTION in `apply_tree` Phase B2 — against the
// post-reparent app set, each app already locked — so the checked set is by
// construction the written set (no pre-tx TOCTOU, and a reparent that moves
// apps under a templated group in the same apply can't slip the gate).
Ok(())
}
/// Per-env approval gate (§4.2, §6, M5). If the apply selects a confirm-required
/// environment (per the bundle's `[project]` policy, re-derived here — the CLI's
/// enforcement is convenience, this is authoritative), it must be explicitly
/// approved via `approved_envs` (`pic apply --approve <env>`); a blanket `--yes`
/// does NOT satisfy it. An approved gated apply additionally requires ADMIN
/// authority on every declared node — the "override a gate" capability (§4.2),
/// a deliberate step up from the editor-level write caps an ordinary apply
/// needs — and is audited.
async fn enforce_env_approval(
svc: &ApplyService,
principal: &Principal,
bundle: &TreeBundle,
env: Option<&str>,
approved_envs: &[String],
) -> Result<(), ApplyError> {
let (Some(policy), Some(env)) = (&bundle.project, env) else {
return Ok(());
};
if !policy.confirm_required(env) {
return Ok(());
}
if !approved_envs.iter().any(|e| e == env) {
return Err(ApplyError::ApprovalRequired(env.to_string()));
}
// Approved: require admin authority on every declared node.
for node in &bundle.nodes {
match node.kind {
NodeKind::App => {
let app_id = resolve_app_id(svc.apps.as_ref(), &node.slug).await?;
require(svc.authz.as_ref(), principal, Capability::AppAdmin(app_id))
.await
.map_err(map_authz)?;
}
NodeKind::Group => {
// A to-create group has no id yet; its creation already required
// InstanceCreateGroup / GroupAdmin(parent) in `authz_tree`.
if let Some(g) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
require(svc.authz.as_ref(), principal, Capability::GroupAdmin(g.id))
.await
.map_err(map_authz)?;
}
}
}
}
tracing::info!(
user_id = ?principal.user_id,
env = %env,
nodes = bundle.nodes.len(),
"apply: approved gated-environment apply (audit)"
);
Ok(())
}
@@ -522,11 +295,7 @@ async fn require_app_node_writes(
require(svc.authz.as_ref(), principal, Capability::AppRead(app_id))
.await
.map_err(map_authz)?;
// Extension points are module-resolution declarations, so they gate on the
// same script-write tier as modules — without this, an ext-point-only
// bundle (no `default`, so no script) would pass with viewer-tier `AppRead`
// and let a reader open the §5.5 import trust boundary.
if prune || !bundle.scripts.is_empty() || !bundle.extension_points.is_empty() {
if prune || !bundle.scripts.is_empty() {
require(
svc.authz.as_ref(),
principal,
@@ -586,14 +355,7 @@ async fn require_group_node_writes(
bundle: &Bundle,
prune: bool,
) -> Result<(), ApplyError> {
// Extension points and route/trigger templates gate on the script-write
// tier (see the app variant) — all are code-binding declarations.
if prune
|| !bundle.scripts.is_empty()
|| !bundle.extension_points.is_empty()
|| !bundle.route_templates.is_empty()
|| !bundle.trigger_templates.is_empty()
{
if prune || !bundle.scripts.is_empty() {
require(
svc.authz.as_ref(),
principal,
@@ -614,95 +376,6 @@ async fn require_group_node_writes(
Ok(())
}
/// What template expansion (§4.5) will write into `app_id` during this tree
/// apply. Returns `Recipient { routes, triggers, email }` — whether the app's
/// ancestor chain carries a route / trigger / EMAIL-trigger template (committed,
/// or declared by a group node in THIS bundle). Drives the `AppWriteRoute` /
/// `AppManageTriggers` / `AppSecretsRead` gates for recipients (email expansion
/// resolves + seals the recipient app's secret server-side, like a hand-declared
/// email trigger).
#[derive(Default)]
struct Recipient {
routes: bool,
triggers: bool,
email: bool,
}
fn spec_is_email(spec: &serde_json::Value) -> bool {
spec.get("kind").and_then(serde_json::Value::as_str) == Some("email")
}
async fn app_receives_template_expansions(
svc: &ApplyService,
app_id: AppId,
bundle: &TreeBundle,
) -> Result<Recipient, ApplyError> {
let Some(app) = svc
.apps
.get_by_id(app_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
else {
return Ok(Recipient::default());
};
let ancestors = svc
.groups
.ancestors(app.group_id)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?;
let ancestor_ids: std::collections::HashSet<GroupId> = ancestors.iter().map(|g| g.id).collect();
let mut r = Recipient::default();
// Committed templates on any ancestor group.
for gid in &ancestor_ids {
if !r.routes
&& !crate::template_repo::list_for_group(&svc.pool, *gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
.is_empty()
{
r.routes = true;
}
for tt in crate::template_repo::list_triggers_for_group(&svc.pool, *gid)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
r.triggers = true;
r.email = r.email || spec_is_email(&tt.spec);
}
}
// Templates newly declared by a group node in this bundle that is an ancestor
// of the app (existing groups only — a to-create group has no apps).
for node in &bundle.nodes {
if node.kind != NodeKind::Group {
continue;
}
let declares_routes = !node.bundle.route_templates.is_empty();
let declares_triggers = !node.bundle.trigger_templates.is_empty();
if !declares_routes && !declares_triggers {
continue;
}
if let Some(g) = svc
.groups
.get_by_slug(&node.slug)
.await
.map_err(|e| ApplyError::Backend(e.to_string()))?
{
if ancestor_ids.contains(&g.id) {
r.routes = r.routes || declares_routes;
r.triggers = r.triggers || declares_triggers;
r.email = r.email
|| node
.bundle
.trigger_templates
.iter()
.any(|t| spec_is_email(&t.spec));
}
}
}
Ok(r)
}
/// Resolve a slug-or-id path param to a `GroupId`, mapping miss → 404.
async fn resolve_group_id(
groups: &dyn GroupRepository,
@@ -750,9 +423,6 @@ impl IntoResponse for ApplyError {
json!({ "error": self.to_string() }),
),
Self::StateMoved => (StatusCode::CONFLICT, json!({ "error": self.to_string() })),
Self::OwnershipConflict(_) | Self::ApprovalRequired(_) => {
(StatusCode::CONFLICT, json!({ "error": self.to_string() }))
}
Self::Forbidden => (StatusCode::FORBIDDEN, json!({ "error": self.to_string() })),
Self::AuthzRepo(e) => {
tracing::error!(error = %e, "apply authz repo error");

File diff suppressed because it is too large Load Diff

View File

@@ -142,6 +142,30 @@ pub enum Capability {
/// 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),
/// 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`+.
@@ -208,7 +232,13 @@ impl Capability {
| Self::GroupSecretsRead(_)
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsRead(_)
| Self::GroupScriptsWrite(_) => None,
| Self::GroupScriptsWrite(_)
| Self::GroupKvRead(_)
| Self::GroupKvWrite(_)
| Self::GroupDocsRead(_)
| Self::GroupDocsWrite(_)
| Self::GroupFilesRead(_)
| Self::GroupFilesWrite(_) => None,
Self::AppRead(id)
| Self::AppWriteScript(id)
| Self::AppWriteRoute(id)
@@ -260,7 +290,10 @@ impl Capability {
| Self::AppVarsRead(_)
| Self::GroupRead(_)
| Self::GroupVarsRead(_)
| Self::GroupScriptsRead(_) => Scope::ScriptRead,
| Self::GroupScriptsRead(_)
| Self::GroupKvRead(_)
| Self::GroupDocsRead(_)
| Self::GroupFilesRead(_) => Scope::ScriptRead,
Self::AppWriteScript(_)
| Self::AppKvWrite(_)
| Self::AppDocsWrite(_)
@@ -279,6 +312,9 @@ impl Capability {
// the admin tier below.
| Self::GroupSecretsWrite(_)
| Self::GroupScriptsWrite(_)
| Self::GroupKvWrite(_)
| Self::GroupDocsWrite(_)
| Self::GroupFilesWrite(_)
| Self::AppInvoke(_) => Scope::ScriptWrite,
Self::AppWriteRoute(_) => Scope::RouteWrite,
Self::AppManageDomains(_) => Scope::DomainManage,
@@ -441,6 +477,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
// ----------------------------------------------------------------------------
@@ -466,7 +530,13 @@ async fn role_grants(
| Capability::GroupSecretsRead(g)
| Capability::GroupSecretsWrite(g)
| Capability::GroupScriptsRead(g)
| Capability::GroupScriptsWrite(g) => {
| Capability::GroupScriptsWrite(g)
| Capability::GroupKvRead(g)
| Capability::GroupKvWrite(g)
| Capability::GroupDocsRead(g)
| Capability::GroupDocsWrite(g)
| Capability::GroupFilesRead(g)
| Capability::GroupFilesWrite(g) => {
group_member_grants(repo, principal.user_id, cap, g).await
}
// Creating a root-level group is an instance act — members
@@ -526,15 +596,21 @@ 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 {
// viewer+ reads group metadata, config vars, and scripts.
// viewer+ reads group metadata, config vars, scripts, and shared KV.
Capability::GroupRead(_)
| Capability::GroupVarsRead(_)
| Capability::GroupScriptsRead(_) => true,
// editor+ writes config vars/secrets/scripts.
| 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::GroupScriptsWrite(_)
| Capability::GroupKvWrite(_)
| Capability::GroupDocsWrite(_)
| Capability::GroupFilesWrite(_) => {
matches!(role, AppRole::Editor | AppRole::AppAdmin)
}
// group_admin manages the group + reads secret VALUES (the
@@ -1215,6 +1291,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

@@ -226,7 +226,17 @@ pub async fn fetch_var_candidates(
pool: &PgPool,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
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)
@@ -234,39 +244,6 @@ pub async fn fetch_var_candidates(
Ok(rows.into_iter().map(Into::into).collect())
}
/// Transaction-scoped twin of [`fetch_var_candidates`]: runs the identical
/// `CHAIN_LEVELS_CTE` query against an open transaction, so it sees vars
/// **written earlier in the same transaction** (not just committed state).
/// Used by template expansion so a `{var:NAME}` placeholder resolves against
/// a var set in the *same* `pic apply` rather than landing one apply late.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn fetch_var_candidates_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
app_id: AppId,
) -> Result<Vec<Candidate>, sqlx::Error> {
let sql = format!("{CHAIN_LEVELS_CTE} {VAR_CANDIDATES_TAIL}");
let rows = sqlx::query_as::<_, VarCandidateRow>(&sql)
.bind(app_id.into_inner())
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// The `SELECT … FROM chain …` tail appended after [`CHAIN_LEVELS_CTE`] to
/// pull env-eligible `vars` candidates, nearest-first. Shared by the pool and
/// transaction variants so the two can't drift.
const VAR_CANDIDATES_TAIL: &str = "\
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";
/// The masked, resolved view of one inherited secret for `config/effective`:
/// which owner/level/scope supplies it — **never** the value.
#[derive(Debug, Clone)]

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,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,284 @@
//! 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>;
}
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 })
}
}
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,483 @@
//! `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, SdkCallCx,
};
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,
}
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())
}
#[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 {
repo,
resolver,
authz,
max_value_bytes,
}
}
/// 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?;
Ok(self.repo.create(group_id, collection, data).await?.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).await? {
Some(_) => 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?;
Ok(self.repo.delete(group_id, collection, id).await?.is_some())
}
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,399 @@
//! 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>;
}
/// 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 })
}
}
#[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,492 @@
//! `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, SdkCallCx,
};
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,
}
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,
}
}
/// 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?;
Ok(self.repo.create(group_id, collection, new).await?.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 { .. }) => 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);
};
Ok(self
.repo
.delete(group_id, collection, uuid)
.await?
.is_some())
}
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,222 @@
//! 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>;
}
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 })
}
}
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,419 @@
//! `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`).
//!
//! No event emission in the MVP: a write to a shared collection has no single
//! app to attribute a trigger to (the "group trigger has no app to watch"
//! problem). Documented as a deferral in docs §11.6.
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{GroupId, GroupKvError, GroupKvService, KvListPage, SdkCallCx};
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,
}
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())
}
#[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 {
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()))
}
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?;
self.repo.set(group_id, collection, key, 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?;
Ok(self.repo.delete(group_id, collection, key).await?.is_some())
}
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,
})
}
}
/// 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 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

@@ -14,7 +14,7 @@
//! future CLI/orchestrator can detect structural drift (§6).
use async_trait::async_trait;
use picloud_shared::{AppId, Group, GroupId};
use picloud_shared::{Group, GroupId};
use sqlx::PgPool;
use uuid::Uuid;
@@ -191,11 +191,27 @@ impl GroupRepository for PostgresGroupRepository {
description: Option<&str>,
parent_id: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
let mut tx = self.pool.begin().await?;
// Interactive create: UI/API-owned (no project claim — §7.5).
let g = create_group_tx(&mut tx, slug, name, description, parent_id, None).await?;
tx.commit().await?;
Ok(g)
let res = sqlx::query_as::<_, GroupRow>(&format!(
"INSERT INTO groups (slug, name, description, parent_id) \
VALUES ($1, $2, $3, $4) \
RETURNING {GROUP_COLS}"
))
.bind(slug)
.bind(name)
.bind(description)
.bind(parent_id.map(GroupId::into_inner))
.fetch_one(&self.pool)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
GroupRepositoryError::Conflict("parent group does not exist".into()),
),
Err(e) => Err(e.into()),
}
}
async fn rename(
@@ -231,344 +247,98 @@ impl GroupRepository for PostgresGroupRepository {
) -> Result<Group, GroupRepositoryError> {
let mut tx = self.pool.begin().await?;
// Coarse structural lock: serialize all structural mutations so the
// cycle guard + parent write can't race a concurrent reparent.
acquire_structural_lock_tx(&mut tx).await?;
let g = reparent_group_tx(&mut tx, id, new_parent).await?;
tx.commit().await?;
Ok(g)
}
// cycle guard + parent write can't interleave with a concurrent
// reparent and race into a cycle.
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut *tx)
.await?;
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
let mut tx = self.pool.begin().await?;
delete_group_tx(&mut tx, id).await?;
tx.commit().await?;
Ok(())
}
}
// ----------------------------------------------------------------------------
// Transaction-aware structural mutations (Phase 5+ declarative tree apply).
//
// The declarative `apply_tree` reconciles a whole subtree in ONE transaction,
// so group create/reparent/delete must run inside the caller's tx (not on the
// pool) to stay all-or-nothing. These free functions hold the SQL; the trait
// methods above delegate to them (begin → fn → commit) so there is one SQL
// definition each. The caller takes [`acquire_structural_lock_tx`] ONCE before
// the structure phase, so the cycle guard + parent writes serialize against
// concurrent reparents exactly as the single-shot `reparent` does.
// ----------------------------------------------------------------------------
/// Take the coarse structural lock inside the caller's transaction. Held until
/// the tx commits/rolls back. Call once before any `*_group_tx` mutation.
pub(crate) async fn acquire_structural_lock_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(), GroupRepositoryError> {
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(GROUP_STRUCTURAL_LOCK_KEY)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Insert a group inside the caller's tx. Maps unique/FK violations to a clean
/// conflict. (The structural lock is not required for a pure insert, but the
/// apply path holds it for the whole phase anyway.)
pub(crate) async fn create_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
slug: &str,
name: &str,
description: Option<&str>,
parent_id: Option<GroupId>,
owner_project: Option<Uuid>,
) -> Result<Group, GroupRepositoryError> {
let res = sqlx::query_as::<_, GroupRow>(&format!(
"INSERT INTO groups (slug, name, description, parent_id, owner_project) \
VALUES ($1, $2, $3, $4, $5) RETURNING {GROUP_COLS}"
))
.bind(slug)
.bind(name)
.bind(description)
.bind(parent_id.map(GroupId::into_inner))
.bind(owner_project)
.fetch_one(&mut **tx)
.await;
match res {
Ok(row) => Ok(row.into()),
Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(
GroupRepositoryError::Conflict(format!("slug {slug:?} is already in use")),
),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => Err(
GroupRepositoryError::Conflict("parent group does not exist".into()),
),
Err(e) => Err(e.into()),
}
}
/// Reparent a group inside the caller's tx. Runs the ancestor-walk cycle guard
/// against the IN-TX tree (so it sees parent writes made earlier in the same
/// apply). The caller must already hold [`acquire_structural_lock_tx`].
pub(crate) async fn reparent_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: GroupId,
new_parent: Option<GroupId>,
) -> Result<Group, GroupRepositoryError> {
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"a group cannot be its own parent".into(),
));
}
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
if let Some(parent) = new_parent {
if parent == id {
return Err(GroupRepositoryError::Conflict(
"cannot reparent a group beneath one of its own descendants".into(),
"a group cannot be its own parent".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut **tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
None => {
// Cycle guard: walk from the destination up to the root; if we
// reach `id`, the move would place `id` beneath itself.
let mut cursor = Some(parent);
let mut hops = 0u32;
while let Some(node) = cursor {
if node == id {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
"cannot reparent a group beneath one of its own descendants".into(),
));
}
hops += 1;
if hops > 64 {
return Err(GroupRepositoryError::Conflict(
"group ancestry exceeds the maximum depth".into(),
));
}
let parent_of: Option<(Option<Uuid>,)> =
sqlx::query_as("SELECT parent_id FROM groups WHERE id = $1")
.bind(node.into_inner())
.fetch_optional(&mut *tx)
.await?;
match parent_of {
Some((p,)) => cursor = p.map(GroupId::from),
// Destination parent doesn't exist.
None => {
return Err(GroupRepositoryError::Conflict(
"destination parent group does not exist".into(),
));
}
}
}
}
}
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET parent_id = $2, structure_version = structure_version + 1, \
updated_at = NOW() WHERE id = $1 RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut **tx)
.await?;
row.map(Into::into)
.ok_or(GroupRepositoryError::NotFound(id))
}
/// Delete an empty group inside the caller's tx (delete = RESTRICT). Refused
/// with a clean conflict if it still has child groups or apps.
pub(crate) async fn delete_group_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
id: GroupId,
) -> Result<(), GroupRepositoryError> {
let counts: (i64, i64) = sqlx::query_as(
"SELECT (SELECT COUNT(*) FROM groups WHERE parent_id = $1), \
(SELECT COUNT(*) FROM apps WHERE group_id = $1)",
)
.bind(id.into_inner())
.fetch_one(&mut **tx)
.await?;
if counts.0 != 0 || counts.1 != 0 {
return Err(GroupRepositoryError::Conflict(format!(
"group still has {} subgroup(s) and {} app(s); move or delete them first",
counts.0, counts.1
)));
}
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
let row = sqlx::query_as::<_, GroupRow>(&format!(
"UPDATE groups SET \
parent_id = $2, \
structure_version = structure_version + 1, \
updated_at = NOW() \
WHERE id = $1 \
RETURNING {GROUP_COLS}"
))
.bind(id.into_inner())
.execute(&mut **tx)
.await;
match res {
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
Err(GroupRepositoryError::Conflict(
"group still has descendants; move or delete them first".into(),
))
.bind(new_parent.map(GroupId::into_inner))
.fetch_optional(&mut *tx)
.await?;
let Some(row) = row else {
return Err(GroupRepositoryError::NotFound(id));
};
tx.commit().await?;
Ok(row.into())
}
async fn delete(&self, id: GroupId) -> Result<(), GroupRepositoryError> {
// Pre-check for a clean message; the FK RESTRICT is the real guard.
let counts = self.child_counts(id).await?;
if !counts.is_empty() {
return Err(GroupRepositoryError::Conflict(format!(
"group still has {} subgroup(s) and {} app(s); move or delete them first",
counts.subgroups, counts.apps
)));
}
let res = sqlx::query("DELETE FROM groups WHERE id = $1")
.bind(id.into_inner())
.execute(&self.pool)
.await;
match res {
Ok(r) if r.rows_affected() == 0 => Err(GroupRepositoryError::NotFound(id)),
Ok(_) => Ok(()),
Err(sqlx::Error::Database(e)) if e.is_foreign_key_violation() => {
// Lost a race with a concurrent child insert.
Err(GroupRepositoryError::Conflict(
"group still has descendants; move or delete them first".into(),
))
}
Err(e) => Err(e.into()),
}
Err(e) => Err(e.into()),
}
}
// ----------------------------------------------------------------------------
// Project ownership (§7, M3). A `projects` row is keyed by the stable, opaque
// key the CLI mints in `.picloud/`; `groups.owner_project` references it. These
// helpers are free functions (pool + tx variants) so the apply path can claim
// ownership inside the single apply transaction (first-commit-wins), while the
// read-only plan path can surface conflicts without writing.
// ----------------------------------------------------------------------------
/// Resolve a project key to its id, if the project has ever been persisted.
/// `None` means no apply has claimed under this key yet (so `plan` treats every
/// node as claimable). Read-only — used by the plan path.
pub(crate) async fn get_project_id_by_key(
pool: &PgPool,
key: &str,
) -> Result<Option<Uuid>, GroupRepositoryError> {
let row: Option<(Uuid,)> = sqlx::query_as("SELECT id FROM projects WHERE key = $1")
.bind(key)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.0))
}
/// Upsert the project keyed by `key` inside the apply tx and return its id.
/// Idempotent: re-applying the same repo reuses the same project identity.
pub(crate) async fn upsert_project_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
key: &str,
) -> Result<Uuid, GroupRepositoryError> {
let row: (Uuid,) = sqlx::query_as(
"INSERT INTO projects (key) VALUES ($1) \
ON CONFLICT (key) DO UPDATE SET key = EXCLUDED.key RETURNING id",
)
.bind(key)
.fetch_one(&mut **tx)
.await?;
Ok(row.0)
}
/// The project that owns `group_id`, as `(project_id, project_key)`. `None` when
/// the node is UI/API-owned (no claim). Read-only — used by plan + the in-tx
/// authoritative re-check. Joins through `projects` so the caller gets the
/// human-facing key for a conflict message.
pub(crate) async fn get_group_owner(
pool: &PgPool,
group_id: GroupId,
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT p.id, p.key FROM groups g \
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
)
.bind(group_id.into_inner())
.fetch_optional(pool)
.await?;
Ok(row)
}
/// The same owner lookup, but inside the apply tx — the authoritative read that
/// the claim/conflict decision keys on (so a concurrent claim that committed
/// after `plan` is seen under this tx's per-node advisory lock).
pub(crate) async fn get_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
) -> Result<Option<(Uuid, String)>, GroupRepositoryError> {
let row: Option<(Uuid, String)> = sqlx::query_as(
"SELECT p.id, p.key FROM groups g \
JOIN projects p ON p.id = g.owner_project WHERE g.id = $1",
)
.bind(group_id.into_inner())
.fetch_optional(&mut **tx)
.await?;
Ok(row)
}
/// Stamp (claim or take over) `group_id`'s owning project inside the apply tx.
/// Bumps `structure_version` so an ownership flip trips a bound plan token.
pub(crate) async fn set_group_owner_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
project_id: Uuid,
) -> Result<(), GroupRepositoryError> {
let res = sqlx::query(
"UPDATE groups SET owner_project = $2, \
structure_version = structure_version + 1, updated_at = NOW() WHERE id = $1",
)
.bind(group_id.into_inner())
.bind(project_id)
.execute(&mut **tx)
.await?;
if res.rows_affected() == 0 {
return Err(GroupRepositoryError::NotFound(group_id));
}
Ok(())
}
/// Every group `(id, slug)` owned by `project_id` — the candidate set for
/// structural prune (those absent from the manifest are the ones to delete).
/// Read-only; the apply path filters and deletes leaf-first under `delete_tx`.
pub(crate) async fn list_owned_groups(
pool: &PgPool,
project_id: Uuid,
) -> Result<Vec<(GroupId, String)>, GroupRepositoryError> {
let rows: Vec<(Uuid, String)> =
sqlx::query_as("SELECT id, slug FROM groups WHERE owner_project = $1")
.bind(project_id)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|(id, slug)| (id.into(), slug))
.collect())
}
/// The same owned-group enumeration inside the apply tx, with each node's
/// `parent_id` so the prune can delete leaf-first (children before parents).
pub(crate) async fn list_owned_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
project_id: Uuid,
) -> Result<Vec<(GroupId, String, Option<GroupId>)>, GroupRepositoryError> {
let rows: Vec<(Uuid, String, Option<Uuid>)> =
sqlx::query_as("SELECT id, slug, parent_id FROM groups WHERE owner_project = $1")
.bind(project_id)
.fetch_all(&mut **tx)
.await?;
Ok(rows
.into_iter()
.map(|(id, slug, parent)| (id.into(), slug, parent.map(GroupId::from)))
.collect())
}
/// Recursive descendant-app query, the inverse of the ancestor `CHAIN_LEVELS_CTE`
/// in `config_resolver`: walk `groups.parent_id` DOWN from `$1` to collect the
/// whole subtree of group ids, then every app whose `group_id` falls in it.
/// Ordered by `app_id` so callers lock/iterate deterministically. Depth-bounded
/// `< 64` as a runaway guard (the cycle guard already forbids real cycles).
const DESCENDANT_APPS_SQL: &str = "\
WITH RECURSIVE subtree AS ( \
SELECT id, 0 AS depth FROM groups WHERE id = $1 \
UNION ALL \
SELECT g.id, s.depth + 1 FROM groups g JOIN subtree s ON g.parent_id = s.id \
WHERE s.depth < 64 \
) \
SELECT a.id FROM apps a WHERE a.group_id IN (SELECT id FROM subtree) ORDER BY a.id";
/// Every app in the subtree rooted at `group_id` (the group itself and all
/// descendant groups), `app_id`-ordered. The read-only pool variant — used by
/// `plan` to size a template's true blast radius across the whole DB subtree,
/// not just the apps present in the current apply.
///
/// # Errors
/// Propagates sqlx errors.
pub async fn descendant_app_ids(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<AppId>, GroupRepositoryError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
}
/// The same subtree enumeration inside the apply tx, so groups/apps created
/// earlier in this transaction (M2 structural reconcile) are included when
/// fanning a template out to its descendants (§4.5, M7).
pub(crate) async fn descendant_app_ids_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
) -> Result<Vec<AppId>, GroupRepositoryError> {
let rows: Vec<(Uuid,)> = sqlx::query_as(DESCENDANT_APPS_SQL)
.bind(group_id.into_inner())
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(|(id,)| id.into()).collect())
}
#[derive(sqlx::FromRow)]
struct GroupRow {
id: Uuid,

View File

@@ -97,10 +97,25 @@ async fn create_group_script(
)
.await?;
// Phase 4b: group modules + imports are allowed. Imports resolve
// lexically from this group's chain at runtime (§5.5); the recorded
// edges feed the declarative dangling-import plan check.
let validated = s.validator.validate(&input.source)?;
// 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()))?;

View File

@@ -44,11 +44,19 @@ 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_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_repo;
pub mod group_scripts_api;
@@ -80,7 +88,6 @@ pub mod secrets_api;
pub mod secrets_repo;
pub mod secrets_service;
pub mod ssrf;
pub mod template_repo;
pub mod topic_repo;
pub mod topics_api;
pub mod trigger_config;
@@ -176,6 +183,13 @@ 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,

View File

@@ -1,19 +1,24 @@
//! `PostgresModuleSource` — the Postgres-backed `ModuleSource` impl.
//!
//! Resolution is **lexical** (§5.5): 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). 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::{
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptId, ScriptOwner,
AppId, ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, ScriptOwner,
};
use sqlx::PgPool;
@@ -99,91 +104,72 @@ impl ModuleSource for PostgresModuleSource {
Ok(row.map(Into::into))
}
async fn resolve_extension_point(
async fn resolve_policy(
&self,
origin: ScriptOwner,
inheriting_app: AppId,
name: &str,
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
// Find the nearest extension-point declaration of `name` on `origin`'s
// chain that is NOT shadowed by a concrete module of the same name at
// depth <= its own (tie → concrete wins). The NOT EXISTS encodes that
// shadowing rule; ORDER BY depth ASC LIMIT 1 picks the nearest
// surviving declaration. An App origin can see ext points declared on
// an ancestor group (or on the app itself); a Group origin only walks
// groups — same trust boundary as `resolve`.
let (cte, ep_join, mod_join) = match origin {
ScriptOwner::App(_) => (
CHAIN_LEVELS_CTE,
"(e.group_id = c.group_owner OR e.app_id = c.app_owner)",
"(s.app_id = c2.app_owner OR s.group_id = c2.group_owner)",
) -> 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(_) => (
GROUP_CHAIN_LEVELS_CTE,
"e.group_id = c.group_owner",
"s.group_id = c2.group_owner",
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 query = format!(
"{cte} \
SELECT e.default_script_id \
FROM extension_points e \
JOIN chain c ON {ep_join} \
WHERE LOWER(e.name) = LOWER($2) \
AND NOT EXISTS ( \
SELECT 1 FROM scripts s \
JOIN chain c2 ON {mod_join} \
WHERE s.kind = 'module' AND LOWER(s.name) = LOWER($2) \
AND c2.depth <= c.depth \
) \
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<(Option<uuid::Uuid>,)> = sqlx::query_as(&query)
let (ep_depth, mod_depth): (Option<i32>, Option<i32>) = sqlx::query_as(&query)
.bind(root)
.bind(name)
.fetch_optional(&self.pool)
.fetch_one(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
Ok(row.map(|(default_script_id,)| ExtPointResolution {
default_script_id: default_script_id.map(Into::into),
}))
}
async fn resolve_by_id(
&self,
origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
// Load a module body by id (the ext-point fallback), but ONLY if it is
// owned by a node on `origin`'s chain — the same trust boundary as
// `resolve`. This keeps the resolver self-defending: a `default_script_id`
// that ever pointed off-chain resolves to None instead of executing
// cross-tenant code. Endpoints are never importable (kind filter).
let (cte, join) = match origin {
ScriptOwner::App(_) => (
CHAIN_LEVELS_CTE,
"(s.app_id = c.app_owner OR s.group_id = c.group_owner)",
),
ScriptOwner::Group(_) => (GROUP_CHAIN_LEVELS_CTE, "s.group_id = c.group_owner"),
// 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,
};
let root = match origin {
ScriptOwner::App(a) => a.into_inner(),
ScriptOwner::Group(g) => g.into_inner(),
};
let row: Option<ModuleRow> = sqlx::query_as(&format!(
"{cte} \
SELECT s.id, s.app_id, s.group_id, s.name, s.source, s.updated_at \
FROM scripts s JOIN chain c ON {join} \
WHERE s.id = $2 AND s.kind = 'module' LIMIT 1"
))
.bind(root)
.bind(script_id.into_inner())
.fetch_optional(&self.pool)
.await
.map_err(|e| ModuleSourceError::Backend(e.to_string()))?;
Ok(row.map(Into::into))
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

@@ -241,8 +241,6 @@ async fn create_route<RR: RouteRepository, SR: ScriptRepository>(
dispatch_mode: input.dispatch_mode,
// Routes are created active; toggling is a dedicated path.
enabled: true,
// Hand-declared via the interactive API — not a template expansion.
from_template: None,
})
.await?;
refresh_table(&state).await?;

View File

@@ -23,10 +23,6 @@ pub struct NewRoute {
pub dispatch_mode: DispatchMode,
/// Three-state lifecycle (§4.3). Create active by default.
pub enabled: bool,
/// Provenance (§4.5, M4): the route-template id this row was expanded from,
/// or `None` for a hand-declared route. Defaults `None` everywhere except
/// template expansion.
pub from_template: Option<Uuid>,
}
#[async_trait]
@@ -179,8 +175,8 @@ pub(crate) async fn insert_route_tx(
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, from_template \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \
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",
)
@@ -194,7 +190,6 @@ pub(crate) async fn insert_route_tx(
.bind(input.method.as_deref())
.bind(input.dispatch_mode.as_str())
.bind(input.enabled)
.bind(input.from_template)
.fetch_one(&mut **tx)
.await;
match res {

View File

@@ -1,318 +0,0 @@
//! CRUD over `route_templates` (§4.5, M4a) — route declarations owned by a
//! group that the apply engine fans out into one concrete `routes` row per
//! descendant app. Mirrors the tx-accepting free-function pattern of
//! `route_repo`/`group_repo`: pool variants for the read/diff path, `_tx`
//! variants for the transactional apply (upsert/delete and chain expansion).
use picloud_shared::{DispatchMode, HostKind, PathKind};
use sqlx::PgPool;
use uuid::Uuid;
use crate::apply_service::RouteTemplateSpec;
use crate::group_repo::GroupRepositoryError as Error;
use picloud_shared::GroupId;
/// A persisted route template, decoded with its enum fields parsed — ready for
/// the diff (by name) and for expansion (synthesizing a concrete route).
#[derive(Debug, Clone)]
pub struct RouteTemplate {
pub id: Uuid,
pub group_id: GroupId,
pub name: String,
pub script_name: String,
pub method: Option<String>,
pub host_kind: HostKind,
pub host: String,
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
pub dispatch_mode: DispatchMode,
pub enabled: bool,
}
#[derive(sqlx::FromRow)]
struct RouteTemplateRow {
id: Uuid,
group_id: Uuid,
name: String,
script_name: String,
method: Option<String>,
host_kind: String,
host: String,
host_param_name: Option<String>,
path_kind: String,
path: String,
dispatch_mode: String,
enabled: bool,
}
impl From<RouteTemplateRow> for RouteTemplate {
fn from(r: RouteTemplateRow) -> Self {
Self {
id: r.id,
group_id: r.group_id.into(),
name: r.name,
script_name: r.script_name,
method: r.method,
host_kind: match r.host_kind.as_str() {
"strict" => HostKind::Strict,
"wildcard" => HostKind::Wildcard,
_ => HostKind::Any,
},
host: r.host,
host_param_name: r.host_param_name,
path_kind: match r.path_kind.as_str() {
"prefix" => PathKind::Prefix,
"param" => PathKind::Param,
_ => PathKind::Exact,
},
path: r.path,
dispatch_mode: DispatchMode::from_wire(&r.dispatch_mode).unwrap_or(DispatchMode::Sync),
enabled: r.enabled,
}
}
}
const COLS: &str = "id, group_id, name, script_name, method, host_kind, host, \
host_param_name, path_kind, path, dispatch_mode, enabled";
const fn host_kind_str(k: HostKind) -> &'static str {
match k {
HostKind::Any => "any",
HostKind::Strict => "strict",
HostKind::Wildcard => "wildcard",
}
}
const fn path_kind_str(k: PathKind) -> &'static str {
match k {
PathKind::Exact => "exact",
PathKind::Prefix => "prefix",
PathKind::Param => "param",
}
}
/// A group's OWN route templates (read/diff path).
pub(crate) async fn list_for_group(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<RouteTemplate>, Error> {
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
"SELECT {COLS} FROM route_templates WHERE group_id = $1 ORDER BY LOWER(name)"
))
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Every route template owned by any group in `group_ids` (expansion path,
/// in-tx so it sees templates just reconciled earlier in this apply).
pub(crate) async fn list_for_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_ids: &[GroupId],
) -> Result<Vec<RouteTemplate>, Error> {
if group_ids.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
let rows = sqlx::query_as::<_, RouteTemplateRow>(&format!(
"SELECT {COLS} FROM route_templates WHERE group_id = ANY($1) ORDER BY LOWER(name)"
))
.bind(&ids)
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Insert a new route template (diff Op::Create).
pub(crate) async fn insert_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
spec: &RouteTemplateSpec,
) -> Result<(), Error> {
let r = &spec.route;
sqlx::query(
"INSERT INTO route_templates ( \
group_id, name, script_name, method, host_kind, host, \
host_param_name, path_kind, path, dispatch_mode, enabled \
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
)
.bind(group_id.into_inner())
.bind(&spec.name)
.bind(&r.script)
.bind(r.method.as_deref())
.bind(host_kind_str(r.host_kind))
.bind(&r.host)
.bind(r.host_param_name.as_deref())
.bind(path_kind_str(r.path_kind))
.bind(&r.path)
.bind(r.dispatch_mode.as_str())
.bind(r.enabled)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Replace an existing route template's fields, keyed by `(group, lower(name))`
/// (diff Op::Update).
pub(crate) async fn update_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
spec: &RouteTemplateSpec,
) -> Result<(), Error> {
let r = &spec.route;
sqlx::query(
"UPDATE route_templates SET \
script_name = $3, method = $4, host_kind = $5, host = $6, \
host_param_name = $7, path_kind = $8, path = $9, dispatch_mode = $10, \
enabled = $11, updated_at = NOW() \
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
)
.bind(group_id.into_inner())
.bind(&spec.name)
.bind(&r.script)
.bind(r.method.as_deref())
.bind(host_kind_str(r.host_kind))
.bind(&r.host)
.bind(r.host_param_name.as_deref())
.bind(path_kind_str(r.path_kind))
.bind(&r.path)
.bind(r.dispatch_mode.as_str())
.bind(r.enabled)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a route template by name (diff Op::Delete under `--prune`).
pub(crate) async fn delete_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
) -> Result<(), Error> {
sqlx::query("DELETE FROM route_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
.bind(group_id.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
Ok(())
}
// ----------------------------------------------------------------------------
// Trigger templates (§4.5, M4b). The kind-specific params vary, so the whole
// `BundleTrigger` wire object is stored as `spec` JSONB (placeholders
// unresolved); the apply rebuilds + resolves it per descendant.
// ----------------------------------------------------------------------------
/// A persisted trigger template: name + the raw (unresolved) `BundleTrigger`
/// wire object.
#[derive(Debug, Clone)]
pub struct TriggerTemplate {
pub id: Uuid,
pub group_id: GroupId,
pub name: String,
pub spec: serde_json::Value,
}
#[derive(sqlx::FromRow)]
struct TriggerTemplateRow {
id: Uuid,
group_id: Uuid,
name: String,
spec: serde_json::Value,
}
impl From<TriggerTemplateRow> for TriggerTemplate {
fn from(r: TriggerTemplateRow) -> Self {
Self {
id: r.id,
group_id: r.group_id.into(),
name: r.name,
spec: r.spec,
}
}
}
/// A group's OWN trigger templates (read/diff path).
pub(crate) async fn list_triggers_for_group(
pool: &PgPool,
group_id: GroupId,
) -> Result<Vec<TriggerTemplate>, Error> {
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
"SELECT id, group_id, name, spec FROM trigger_templates \
WHERE group_id = $1 ORDER BY LOWER(name)",
)
.bind(group_id.into_inner())
.fetch_all(pool)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Every trigger template owned by any group in `group_ids` (expansion path).
pub(crate) async fn list_triggers_for_groups_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_ids: &[GroupId],
) -> Result<Vec<TriggerTemplate>, Error> {
if group_ids.is_empty() {
return Ok(Vec::new());
}
let ids: Vec<Uuid> = group_ids.iter().map(|g| g.into_inner()).collect();
let rows = sqlx::query_as::<_, TriggerTemplateRow>(
"SELECT id, group_id, name, spec FROM trigger_templates \
WHERE group_id = ANY($1) ORDER BY LOWER(name)",
)
.bind(&ids)
.fetch_all(&mut **tx)
.await?;
Ok(rows.into_iter().map(Into::into).collect())
}
/// Insert a new trigger template (diff Op::Create).
pub(crate) async fn insert_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
spec: &serde_json::Value,
) -> Result<(), Error> {
sqlx::query("INSERT INTO trigger_templates (group_id, name, spec) VALUES ($1, $2, $3)")
.bind(group_id.into_inner())
.bind(name)
.bind(spec)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Replace a trigger template's spec, keyed by `(group, lower(name))` (Update).
pub(crate) async fn update_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
spec: &serde_json::Value,
) -> Result<(), Error> {
sqlx::query(
"UPDATE trigger_templates SET spec = $3, updated_at = NOW() \
WHERE group_id = $1 AND LOWER(name) = LOWER($2)",
)
.bind(group_id.into_inner())
.bind(name)
.bind(spec)
.execute(&mut **tx)
.await?;
Ok(())
}
/// Delete a trigger template by name (Delete under `--prune`).
pub(crate) async fn delete_trigger_tx(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
group_id: GroupId,
name: &str,
) -> Result<(), Error> {
sqlx::query("DELETE FROM trigger_templates WHERE group_id = $1 AND LOWER(name) = LOWER($2)")
.bind(group_id.into_inner())
.bind(name)
.execute(&mut **tx)
.await?;
Ok(())
}

View File

@@ -519,7 +519,6 @@ pub(crate) async fn insert_trigger_tx(
retry_backoff: BackoffShape,
retry_base_ms: u32,
details: &TriggerDetails,
from_template: Option<Uuid>,
) -> Result<TriggerId, TriggerRepoError> {
let kind = match details {
TriggerDetails::Kv { .. } => "kv",
@@ -563,8 +562,8 @@ pub(crate) async fn insert_trigger_tx(
"INSERT INTO triggers ( \
app_id, script_id, kind, enabled, dispatch_mode, \
retry_max_attempts, retry_backoff, retry_base_ms, \
registered_by_principal, from_template \
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8, $9) RETURNING id",
registered_by_principal \
) VALUES ($1, $2, $3, TRUE, $4, $5, $6, $7, $8) RETURNING id",
)
.bind(app_id.into_inner())
.bind(script_id.into_inner())
@@ -574,7 +573,6 @@ 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(from_template)
.fetch_one(&mut **tx)
.await?;
let tid = row.0;
@@ -681,19 +679,17 @@ pub(crate) async fn insert_email_trigger_tx(
registered_by: AdminUserId,
inbound_secret_encrypted: &[u8],
inbound_secret_nonce: &[u8],
from_template: Option<Uuid>,
) -> Result<TriggerId, TriggerRepoError> {
let row: (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, from_template \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3, $4) RETURNING id",
registered_by_principal \
) VALUES ($1, $2, 'email', TRUE, 'async', 3, 'exponential', 1000, $3) RETURNING id",
)
.bind(app_id.into_inner())
.bind(script_id.into_inner())
.bind(registered_by.into_inner())
.bind(from_template)
.fetch_one(&mut **tx)
.await?;
sqlx::query(

View File

@@ -203,7 +203,6 @@ table: extension_points
group_id: uuid NULL
app_id: uuid NULL
name: text NOT NULL
default_script_id: uuid NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
@@ -223,6 +222,42 @@ 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
@@ -270,11 +305,6 @@ table: outbox
claimed_by: text NULL
created_at: timestamp with time zone NOT NULL default=now()
table: projects
id: uuid NOT NULL default=gen_random_uuid()
key: text NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
table: pubsub_trigger_details
trigger_id: uuid NOT NULL
topic_pattern: text NOT NULL
@@ -298,22 +328,6 @@ table: queue_trigger_details
visibility_timeout_secs: integer NOT NULL default=30
last_fired_at: timestamp with time zone NULL
table: route_templates
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NOT NULL
name: text NOT NULL
script_name: text NOT NULL
method: text NULL
host_kind: text NOT NULL
host: text NOT NULL default=''::text
host_param_name: text NULL
path_kind: text NOT NULL
path: text NOT NULL
dispatch_mode: text NOT NULL default='sync'::text
enabled: boolean NOT NULL default=true
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: routes
id: uuid NOT NULL default=gen_random_uuid()
script_id: uuid NOT NULL
@@ -327,7 +341,6 @@ table: routes
app_id: uuid NOT NULL
dispatch_mode: text NOT NULL default='sync'::text
enabled: boolean NOT NULL default=true
from_template: uuid NULL
table: script_imports
app_id: uuid NOT NULL
@@ -370,14 +383,6 @@ table: topics
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: trigger_templates
id: uuid NOT NULL default=gen_random_uuid()
group_id: uuid NOT NULL
name: text NOT NULL
spec: jsonb NOT NULL
created_at: timestamp with time zone NOT NULL default=now()
updated_at: timestamp with time zone NOT NULL default=now()
table: triggers
id: uuid NOT NULL default=gen_random_uuid()
app_id: uuid NOT NULL
@@ -392,7 +397,6 @@ table: triggers
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
from_template: uuid NULL
table: vars
id: uuid NOT NULL default=gen_random_uuid()
@@ -506,7 +510,6 @@ indexes on execution_logs:
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_default_script_idx: public.extension_points USING btree (default_script_id) WHERE (default_script_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)
@@ -518,12 +521,31 @@ 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 groups:
groups_owner_project_idx: public.groups USING btree (owner_project)
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)
@@ -540,10 +562,6 @@ indexes on outbox:
idx_outbox_due: public.outbox USING btree (next_attempt_at) WHERE (claimed_at IS NULL)
outbox_pkey: public.outbox USING btree (id)
indexes on projects:
projects_key_key: public.projects USING btree (key)
projects_pkey: public.projects USING btree (id)
indexes on pubsub_trigger_details:
pubsub_trigger_details_pkey: public.pubsub_trigger_details USING btree (trigger_id)
@@ -557,13 +575,8 @@ indexes on queue_trigger_details:
idx_queue_trigger_details_queue_name: public.queue_trigger_details USING btree (queue_name)
queue_trigger_details_pkey: public.queue_trigger_details USING btree (trigger_id)
indexes on route_templates:
route_templates_group_name_idx: public.route_templates USING btree (group_id, lower(name))
route_templates_pkey: public.route_templates USING btree (id)
indexes on routes:
routes_app_id_idx: public.routes USING btree (app_id)
routes_from_template_idx: public.routes USING btree (app_id, from_template)
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)
@@ -591,16 +604,11 @@ indexes on secrets:
indexes on topics:
topics_pkey: public.topics USING btree (app_id, name)
indexes on trigger_templates:
trigger_templates_group_name_idx: public.trigger_templates USING btree (group_id, lower(name))
trigger_templates_pkey: public.trigger_templates USING btree (id)
indexes on triggers:
idx_triggers_app_kind_enabled: public.triggers USING btree (app_id, kind) WHERE (enabled = true)
idx_triggers_app_pubsub_enabled: public.triggers USING btree (app_id, kind) WHERE ((enabled = true) AND (kind = 'pubsub'::text))
idx_triggers_kind_enabled: public.triggers USING btree (kind) WHERE (enabled = true)
triggers_app_name_uniq: public.triggers USING btree (app_id, name)
triggers_from_template_idx: public.triggers USING btree (app_id, from_template)
triggers_pkey: public.triggers USING btree (id)
indexes on vars:
@@ -722,7 +730,6 @@ constraints on execution_logs:
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_default_script_id_fkey: FOREIGN KEY (default_script_id) REFERENCES scripts(id) ON DELETE SET NULL
[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)
@@ -734,6 +741,25 @@ 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])))
[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
@@ -741,7 +767,6 @@ constraints on group_members:
[PRIMARY KEY] group_members_pkey: PRIMARY KEY (group_id, user_id)
constraints on groups:
[FOREIGN KEY] groups_owner_project_fkey: FOREIGN KEY (owner_project) REFERENCES projects(id) ON DELETE SET NULL
[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)
@@ -759,10 +784,6 @@ constraints on outbox:
[FOREIGN KEY] outbox_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] outbox_pkey: PRIMARY KEY (id)
constraints on projects:
[PRIMARY KEY] projects_pkey: PRIMARY KEY (id)
[UNIQUE] projects_key_key: UNIQUE (key)
constraints on pubsub_trigger_details:
[FOREIGN KEY] pubsub_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] pubsub_trigger_details_pkey: PRIMARY KEY (trigger_id)
@@ -775,13 +796,6 @@ constraints on queue_trigger_details:
[FOREIGN KEY] queue_trigger_details_trigger_id_fkey: FOREIGN KEY (trigger_id) REFERENCES triggers(id) ON DELETE CASCADE
[PRIMARY KEY] queue_trigger_details_pkey: PRIMARY KEY (trigger_id)
constraints on route_templates:
[CHECK] route_templates_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] route_templates_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
[CHECK] route_templates_path_kind_check: CHECK ((path_kind = ANY (ARRAY['exact'::text, 'prefix'::text, 'param'::text])))
[FOREIGN KEY] route_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] route_templates_pkey: PRIMARY KEY (id)
constraints on routes:
[CHECK] routes_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] routes_host_kind_check: CHECK ((host_kind = ANY (ARRAY['any'::text, 'strict'::text, 'wildcard'::text])))
@@ -816,10 +830,6 @@ constraints on topics:
[FOREIGN KEY] topics_app_id_fkey: FOREIGN KEY (app_id) REFERENCES apps(id) ON DELETE CASCADE
[PRIMARY KEY] topics_pkey: PRIMARY KEY (app_id, name)
constraints on trigger_templates:
[FOREIGN KEY] trigger_templates_group_id_fkey: FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
[PRIMARY KEY] trigger_templates_pkey: PRIMARY KEY (id)
constraints on triggers:
[CHECK] triggers_dispatch_mode_check: CHECK ((dispatch_mode = ANY (ARRAY['sync'::text, 'async'::text])))
[CHECK] triggers_kind_check: CHECK ((kind = ANY (ARRAY['kv'::text, 'dead_letter'::text, 'docs'::text, 'cron'::text, 'files'::text, 'pubsub'::text, 'email'::text, 'queue'::text])))
@@ -887,6 +897,7 @@ constraints on vars:
0049: group secrets
0050: group scripts
0051: extension points
0052: owner project
0053: templates
0054: trigger templates
0052: group collections
0053: group kv entries
0054: group docs
0055: group files

View File

@@ -34,7 +34,6 @@ toml = "0.8"
directories = "5"
rpassword = "7"
anyhow = "1"
uuid = { version = "1", features = ["v4"] }
[dev-dependencies]
assert_cmd = "2"

View File

@@ -123,19 +123,6 @@ impl Client {
decode(resp).await
}
/// `GET /api/v1/admin/apps/{id}/extension-points` — the app's OWN
/// extension-point declarations (for `pic pull`).
pub async fn extension_points_list(&self, ident: &str) -> Result<Vec<ExtPointDto>> {
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/apps/{}/extension-points", seg(ident)),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/scripts` — every script the caller can see
/// (server filters by membership for `Member`). Lets `pic scripts ls`
/// (no `--app`) collapse what used to be an N+1 per-app walk into a
@@ -1242,48 +1229,27 @@ impl Client {
}
/// `POST /api/v1/admin/tree/plan` — diff a whole project tree (Phase 5).
/// `project_key` (§7, M3) lets the server report ownership conflicts and
/// structural-prune candidates for this repo.
pub async fn plan_tree(
&self,
bundle: &serde_json::Value,
project_key: Option<&str>,
) -> Result<TreePlanDto> {
let body = serde_json::json!({
"bundle": bundle,
"project_key": project_key,
});
pub async fn plan_tree(&self, bundle: &serde_json::Value) -> Result<TreePlanDto> {
let resp = self
.request(Method::POST, "/api/v1/admin/tree/plan")
.json(&body)
.json(bundle)
.send()
.await?;
decode(resp).await
}
/// `POST /api/v1/admin/tree/apply` — reconcile a whole project tree in one
/// transaction (Phase 5). `project_key`/`allow_takeover` drive ownership
/// (§7, M3): the apply claims unclaimed declared groups for this project and
/// refuses (or, with `allow_takeover`, seizes) ones another project owns.
#[allow(clippy::too_many_arguments)]
/// transaction (Phase 5).
pub async fn apply_tree(
&self,
bundle: &serde_json::Value,
prune: bool,
expected_token: Option<&str>,
project_key: Option<&str>,
allow_takeover: bool,
env: Option<&str>,
approved_envs: &[String],
) -> Result<ApplyReportDto> {
let body = serde_json::json!({
"bundle": bundle,
"prune": prune,
"expected_token": expected_token,
"project_key": project_key,
"allow_takeover": allow_takeover,
"env": env,
"approved_envs": approved_envs,
});
let resp = self
.request(Method::POST, "/api/v1/admin/tree/apply")
@@ -1291,11 +1257,12 @@ impl Client {
.send()
.await?;
if resp.status() == reqwest::StatusCode::CONFLICT {
// 409 covers both bound-plan staleness AND an ownership conflict; the
// server's message is self-explanatory for each, so surface it raw.
let body = resp.text().await.unwrap_or_default();
let msg = parse_error_body(&body).unwrap_or(body);
return Err(anyhow!("{msg}"));
return Err(anyhow!(
"{msg}\nThe project tree changed since your last `pic plan`. Re-run \
`pic plan --dir` to review, then `pic apply --dir` — or add `--force`."
));
}
decode(resp).await
}
@@ -1338,6 +1305,57 @@ impl NodeKind {
}
}
/// One row of the §5.5 extension-point report.
#[derive(Debug, Deserialize)]
pub struct ExtensionPointInfoDto {
pub name: String,
#[serde(default)]
pub declared_here: bool,
#[serde(default)]
pub provider: Option<String>,
}
impl Client {
/// `GET /api/v1/admin/{apps|groups}/{ident}/extension-points`.
pub async fn extension_points_list(
&self,
kind: NodeKind,
ident: &str,
) -> Result<Vec<ExtensionPointInfoDto>> {
let ident = seg(ident);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/{}/{ident}/extension-points", kind.path()),
)
.send()
.await?;
decode(resp).await
}
/// `GET /api/v1/admin/groups/{ident}/collections` (§11.6). Group-only —
/// shared collections are declared on groups.
pub async fn collections_list(&self, group_ident: &str) -> Result<Vec<CollectionInfoDto>> {
let ident = seg(group_ident);
let resp = self
.request(
Method::GET,
&format!("/api/v1/admin/groups/{ident}/collections"),
)
.send()
.await?;
decode(resp).await
}
}
/// One row of the §11.6 shared-collection report.
#[derive(Debug, Deserialize)]
pub struct CollectionInfoDto {
pub name: String,
#[serde(default)]
pub kind: String,
}
// ---------- DTOs (CLI-local, wire-shape-matched) ----------
/// Response of `POST .../plan`: per-resource diffs grouped by kind.
@@ -1355,6 +1373,8 @@ pub struct PlanDto {
pub vars: Vec<ChangeDto>,
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
#[serde(default)]
pub collections: Vec<ChangeDto>,
/// Fingerprint of the live state this plan was computed against; carried
/// in `.picloud/` and replayed to `apply` for the bound-plan check.
#[serde(default)]
@@ -1369,14 +1389,6 @@ pub struct ChangeDto {
pub detail: Option<String>,
}
/// One of an owner's OWN extension-point declarations (`pic pull`).
#[derive(Debug, Deserialize)]
pub struct ExtPointDto {
pub name: String,
#[serde(default)]
pub default: Option<String>,
}
/// Response of `POST .../tree/plan` (Phase 5): a per-node diff + one combined
/// bound-plan token for the whole subtree.
#[derive(Debug, Deserialize)]
@@ -1385,33 +1397,6 @@ pub struct TreePlanDto {
pub nodes: Vec<NodePlanDto>,
#[serde(default)]
pub state_token: String,
/// Declared groups owned by another project (§7, M3) — surfaced so CI sees
/// the conflict before `apply` refuses it.
#[serde(default)]
pub conflicts: Vec<OwnershipConflictDto>,
/// Slugs of owned groups absent from the manifest — what `--prune` deletes.
#[serde(default)]
pub structural_prunes: Vec<String>,
/// Route-template fan-out per declaring group (§4.5, M4a).
#[serde(default)]
pub template_blast_radius: Vec<TemplateBlastRadiusDto>,
/// Environments the project marks confirm-required (§4.2, M5).
#[serde(default)]
pub approvals_required: Vec<String>,
}
/// A single ownership conflict (`pic plan --dir`).
#[derive(Debug, Deserialize)]
pub struct OwnershipConflictDto {
pub slug: String,
pub owner_key: String,
}
/// One group's route-template blast radius (`pic plan --dir`).
#[derive(Debug, Deserialize)]
pub struct TemplateBlastRadiusDto {
pub group: String,
pub affected_apps: usize,
}
#[derive(Debug, Deserialize)]
@@ -1431,9 +1416,7 @@ pub struct NodePlanDto {
#[serde(default)]
pub extension_points: Vec<ChangeDto>,
#[serde(default)]
pub route_templates: Vec<ChangeDto>,
#[serde(default)]
pub trigger_templates: Vec<ChangeDto>,
pub collections: Vec<ChangeDto>,
}
/// Response of `POST .../apply`: counts of what changed.
@@ -1464,31 +1447,11 @@ pub struct ApplyReportDto {
#[serde(default)]
pub extension_points_created: u32,
#[serde(default)]
pub extension_points_updated: u32,
#[serde(default)]
pub extension_points_deleted: u32,
#[serde(default)]
pub groups_created: u32,
pub collections_created: u32,
#[serde(default)]
pub groups_reparented: u32,
#[serde(default)]
pub groups_claimed: u32,
#[serde(default)]
pub groups_taken_over: u32,
#[serde(default)]
pub groups_pruned: u32,
#[serde(default)]
pub route_templates_created: u32,
#[serde(default)]
pub route_templates_updated: u32,
#[serde(default)]
pub route_templates_deleted: u32,
#[serde(default)]
pub trigger_templates_created: u32,
#[serde(default)]
pub trigger_templates_updated: u32,
#[serde(default)]
pub trigger_templates_deleted: u32,
pub collections_deleted: u32,
#[serde(default)]
pub warnings: Vec<String>,
}

View File

@@ -26,26 +26,6 @@ pub async fn run(
let client = Client::from_creds(&creds)?;
let manifest = Manifest::load_with_env(manifest_path, env)?;
// Env approval gating (§4.2, M5) is a project-tree (`--dir`) feature: the
// admin-gated, audited per-env approval is enforced server-side only on the
// tree apply path. Single-node `apply --file` cannot provide that guarantee,
// so rather than silently bypass a confirm-required env, refuse and direct
// the user to `--dir` (which carries the policy + `--approve` to the server).
if let (Some(env), Some(project)) = (env, &manifest.project) {
if project
.environments
.iter()
.any(|e| e.name == env && e.confirm)
{
anyhow::bail!(
"environment `{env}` is confirm-required by this project's [project] policy; \
single-node `pic apply --file` cannot provide the admin-gated, audited approval. \
Use `pic apply --dir <root> --env {env} --approve {env}` instead."
);
}
}
let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() {
@@ -106,24 +86,19 @@ pub async fn run(
),
)
.field(
"extension-points",
"extension_points",
format!(
"+{} ~{} -{}",
report.extension_points_created,
report.extension_points_updated,
report.extension_points_deleted
"+{} -{}",
report.extension_points_created, report.extension_points_deleted
),
)
.field(
"collections",
format!(
"+{} -{}",
report.collections_created, report.collections_deleted
),
);
// Structural changes only happen on a tree apply; omit the line otherwise.
if report.groups_created > 0 || report.groups_reparented > 0 {
block.field(
"groups",
format!(
"+{} reparented {}",
report.groups_created, report.groups_reparented
),
);
}
for w in &report.warnings {
block.field("warning", w.clone());
}
@@ -133,32 +108,22 @@ pub async fn run(
/// `pic apply --dir <root>` (Phase 5): reconcile a whole project tree — every
/// `picloud.toml` under `root` — in ONE atomic server transaction.
#[allow(clippy::too_many_arguments)]
pub async fn run_tree(
dir: &Path,
prune: bool,
yes: bool,
force: bool,
takeover: bool,
env: Option<&str>,
approve: &[String],
mode: OutputMode,
) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, node_count, envs) = crate::discover::build_tree(dir, env)?;
let (bundle, node_count) = crate::discover::build_tree(dir, env)?;
if prune && !yes {
confirm_prune(&format!("{node_count} node(s) under {}", dir.display()))?;
}
// Per-env approval gate (§4.2, M5): if the selected env is confirm-required,
// it needs an explicit `--approve <env>` (a TTY may confirm interactively;
// `--yes` does NOT cover it). The server re-checks this authoritatively — the
// local check just fails fast with a clear message. `approved` is the set
// sent on the wire.
let approved = resolve_approvals(env, &envs, approve)?;
let token_key = crate::cmds::plan::TREE_TOKEN_KEY;
let expected_token = if force {
None
@@ -168,21 +133,8 @@ pub async fn run_tree(
.map(|l| l.state_token)
};
// This repo's stable project key (§7, M3): the server claims declared
// groups for it and refuses ones another project owns (unless --takeover).
let project_key = crate::linkstate::ensure_project_key(dir)
.context("establishing project identity in .picloud/")?;
let report = client
.apply_tree(
&bundle,
prune,
expected_token.as_deref(),
Some(&project_key),
takeover,
env,
&approved,
)
.apply_tree(&bundle, prune, expected_token.as_deref())
.await?;
crate::linkstate::clear_plan(dir, token_key);
@@ -215,68 +167,19 @@ pub async fn run_tree(
),
)
.field(
"extension-points",
"extension_points",
format!(
"+{} ~{} -{}",
report.extension_points_created,
report.extension_points_updated,
report.extension_points_deleted
"+{} -{}",
report.extension_points_created, report.extension_points_deleted
),
)
.field(
"collections",
format!(
"+{} -{}",
report.collections_created, report.collections_deleted
),
);
// Structural changes only happen on a tree apply; omit the line otherwise.
if report.groups_created > 0
|| report.groups_reparented > 0
|| report.groups_pruned > 0
|| report.groups_claimed > 0
|| report.groups_taken_over > 0
{
block.field(
"groups",
format!(
"+{} reparented {} pruned {}",
report.groups_created, report.groups_reparented, report.groups_pruned
),
);
if report.groups_claimed > 0 || report.groups_taken_over > 0 {
block.field(
"ownership",
format!(
"claimed {} taken-over {}",
report.groups_claimed, report.groups_taken_over
),
);
}
}
// Route templates (§4.5, M4a) — the template rows; their per-app route
// expansions are folded into the `routes` line above.
if report.route_templates_created > 0
|| report.route_templates_updated > 0
|| report.route_templates_deleted > 0
{
block.field(
"route-templates",
format!(
"+{} ~{} -{}",
report.route_templates_created,
report.route_templates_updated,
report.route_templates_deleted
),
);
}
if report.trigger_templates_created > 0
|| report.trigger_templates_updated > 0
|| report.trigger_templates_deleted > 0
{
block.field(
"trigger-templates",
format!(
"+{} ~{} -{}",
report.trigger_templates_created,
report.trigger_templates_updated,
report.trigger_templates_deleted
),
);
}
for w in &report.warnings {
block.field("warning", w.clone());
}
@@ -284,49 +187,6 @@ pub async fn run_tree(
Ok(())
}
/// Resolve the set of environments the actor approves for this apply (§4.2, M5).
/// If the selected `env` is confirm-required (per the root manifest's
/// `[project]` policy) it must be approved — via `--approve <env>`, or an
/// interactive TTY confirmation that requires re-typing the env name. A blanket
/// `--yes` does NOT satisfy it. Returns the full approved set to send on the
/// wire (the server re-checks authoritatively). A non-gated env passes through.
fn resolve_approvals(
env: Option<&str>,
policy: &[crate::manifest::ManifestEnvironment],
approve: &[String],
) -> Result<Vec<String>> {
let mut approved: Vec<String> = approve.to_vec();
let Some(env) = env else {
return Ok(approved); // no env selected → nothing env-specific to gate
};
let gated = policy.iter().any(|e| e.name == env && e.confirm);
if !gated || approved.iter().any(|e| e == env) {
return Ok(approved);
}
// Gated and not pre-approved: prompt on a TTY, else refuse (CI must pass
// --approve explicitly; --yes is deliberately insufficient).
if std::io::stdin().is_terminal() {
eprint!(
"environment `{env}` requires explicit approval to apply. \
Type the environment name to approve, or anything else to abort: "
);
std::io::stderr().flush().ok();
let mut answer = String::new();
std::io::stdin()
.read_line(&mut answer)
.context("read approval")?;
if answer.trim() == env {
approved.push(env.to_string());
return Ok(approved);
}
anyhow::bail!("aborted: environment `{env}` not approved");
}
anyhow::bail!(
"environment `{env}` requires explicit approval: re-run with `--approve {env}` \
(a blanket `--yes` does not cover a confirm-required environment)"
);
}
/// `--prune` deletes live scripts/routes/triggers absent from the manifest —
/// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes`
/// for non-interactive/CI use. Refuse a non-interactive prune without `--yes`
@@ -341,8 +201,7 @@ fn confirm_prune(slug: &str) -> Result<()> {
}
eprint!(
"apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \
absent from the manifest — and, for a tree apply, entire owned groups absent \
from the manifest. This cannot be undone. Continue? [y/N] "
absent from the manifest. This cannot be undone. Continue? [y/N] "
);
std::io::stderr().flush().ok();
let mut answer = String::new();

View File

@@ -0,0 +1,23 @@
//! `pic collections ls --group <g>` — read-only view of a group's §11.6 shared
//! KV collections. Group-only (shared collections are owned by groups);
//! authoring is declarative via the `[group]` manifest `collections = [...]`.
//! Scripts read/write them with `kv::shared_collection("name")`.
use anyhow::Result;
use crate::client::Client;
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(group: &str, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let items = client.collections_list(group).await?;
let mut table = Table::new(["name", "kind"]);
for c in &items {
table.row([c.name.clone(), c.kind.clone()]);
}
table.print(mode);
Ok(())
}

View File

@@ -0,0 +1,48 @@
//! `pic extension-points ls --app|--group` — read-only view of a node's
//! §5.5 extension points. For an app, each EP visible on its chain is shown
//! with its resolved provider (`app override` / `inherited default` / `unset`);
//! for a group, its own declared names. Authoring is declarative only (the
//! manifest `extension_points = [...]`).
use anyhow::{bail, Result};
use crate::client::{Client, NodeKind};
use crate::config;
use crate::output::{OutputMode, Table};
pub async fn ls(app: Option<&str>, group: Option<&str>, mode: OutputMode) -> Result<()> {
let (kind, ident) = match (app, group) {
(Some(a), None) => (NodeKind::App, a),
(None, Some(g)) => (NodeKind::Group, g),
_ => bail!("`extension-points ls` requires exactly one of --app or --group"),
};
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let items = client.extension_points_list(kind, ident).await?;
match kind {
NodeKind::App => {
let mut table = Table::new(["name", "source", "provider"]);
for ep in &items {
table.row([
ep.name.clone(),
if ep.declared_here {
"declared".into()
} else {
"inherited".into()
},
ep.provider.clone().unwrap_or_else(|| "unset".into()),
]);
}
table.print(mode);
}
NodeKind::Group => {
let mut table = Table::new(["name"]);
for ep in &items {
table.row([ep.name.clone()]);
}
table.print(mode);
}
}
Ok(())
}

View File

@@ -87,9 +87,6 @@ pub fn run(
}
fs::write(&manifest_path, body).with_context(|| format!("writing {MANIFEST_FILE}"))?;
ensure_gitignored(dir)?;
// Mint the repo's stable project identity (§7, M3) so the first tree apply
// claims its groups under a key that survives clones/CI. Gitignored.
crate::linkstate::ensure_project_key(dir).context("minting project key in .picloud/")?;
let mut block = KvBlock::new();
block
@@ -116,9 +113,9 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
slug: slug.to_string(),
name: name.to_string(),
description: None,
extension_points: Vec::new(),
}),
group: None,
project: None,
scripts: vec![ManifestScript {
name: "hello".into(),
file: "scripts/hello.rhai".into(),
@@ -143,9 +140,6 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest {
triggers: crate::manifest::ManifestTriggers::default(),
secrets: crate::manifest::ManifestSecrets::default(),
vars: std::collections::BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
trigger_templates: Vec::new(),
}
}

View File

@@ -3,8 +3,10 @@ pub mod api_keys;
pub mod apply;
pub mod apps;
pub mod apps_domains;
pub mod collections;
pub mod config;
pub mod dead_letters;
pub mod extension_points;
pub mod files;
pub mod groups;
pub mod init;

View File

@@ -49,11 +49,8 @@ pub async fn run(manifest_path: &Path, env: Option<&str>, mode: OutputMode) -> R
pub async fn run_tree(dir: &Path, env: Option<&str>, mode: OutputMode) -> Result<()> {
let creds = config::resolve()?;
let client = Client::from_creds(&creds)?;
let (bundle, _count, _envs) = crate::discover::build_tree(dir, env)?;
// Mint/read this repo's project key so the server can report ownership
// (§7, M3). Best-effort: a read-only dir still plans (ownership unreported).
let project_key = crate::linkstate::ensure_project_key(dir).ok();
let plan = client.plan_tree(&bundle, project_key.as_deref()).await?;
let (bundle, _count) = crate::discover::build_tree(dir, env)?;
let plan = client.plan_tree(&bundle).await?;
if !plan.state_token.is_empty() {
if let Err(e) = crate::linkstate::write_plan(dir, TREE_TOKEN_KEY, &plan.state_token) {
eprintln!("warning: could not record plan state for `pic apply`: {e}");
@@ -67,15 +64,14 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
let mut table = Table::new(["node", "kind", "op", "resource", "detail"]);
for n in &plan.nodes {
let node = format!("{}:{}", n.kind, n.slug);
let groups: [(&str, &Vec<ChangeDto>); 8] = [
let groups: [(&str, &Vec<ChangeDto>); 7] = [
("script", &n.scripts),
("route", &n.routes),
("trigger", &n.triggers),
("secret", &n.secrets),
("var", &n.vars),
("extension-point", &n.extension_points),
("route-template", &n.route_templates),
("trigger-template", &n.trigger_templates),
("extension_point", &n.extension_points),
("collection", &n.collections),
];
for (rk, changes) in groups {
for c in changes {
@@ -90,35 +86,6 @@ fn render_tree(plan: &TreePlanDto, mode: OutputMode) {
}
}
table.print(mode);
// Ownership diagnostics (§7, M3) — surfaced to stderr in non-JSON output
// (JSON consumers read `conflicts`/`structural_prunes` from the payload).
if mode != OutputMode::Json {
if !plan.conflicts.is_empty() {
eprintln!("\nownership conflicts (apply blocked without --takeover):");
for c in &plan.conflicts {
eprintln!(" - {} is owned by project {}", c.slug, c.owner_key);
}
}
if !plan.structural_prunes.is_empty() {
eprintln!("\ngroups absent from the manifest (deleted only with --prune):");
for slug in &plan.structural_prunes {
eprintln!(" - {slug}");
}
}
if !plan.template_blast_radius.is_empty() {
eprintln!("\nroute-template blast radius (every descendant app in the DB subtree):");
for b in &plan.template_blast_radius {
eprintln!(" - {}{} app(s)", b.group, b.affected_apps);
}
}
if !plan.approvals_required.is_empty() {
eprintln!("\nenvironments requiring explicit approval (apply with --approve <env>):");
for e in &plan.approvals_required {
eprintln!(" - {e}");
}
}
}
}
/// Assemble the wire bundle: scripts carry inlined source (read from
@@ -189,48 +156,18 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result<Value> {
);
}
// Extension points: name + optional default module. The server's
// `ExtPointSpec` deserializes this shape directly.
let extension_points: Vec<Value> = manifest
.extension_points
.iter()
.map(|e| {
let mut o = Map::new();
o.insert("name".into(), json!(e.name));
if let Some(d) = &e.default {
o.insert("default".into(), json!(d));
}
Value::Object(o)
})
.collect();
// Route templates (§4.5, M4a): name + the flattened route fields the
// server's `RouteTemplateSpec` (name + `#[serde(flatten)] BundleRoute`)
// deserializes. Group manifests only; the server rejects them on an app.
let route_templates = manifest
.route_templates
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
// Trigger templates (§4.5, M4b): name + the flattened trigger spec
// (kind/script/params) — the server's `TriggerTemplateSpec` deserializes
// `{ name, <BundleTrigger> }`. Group manifests only.
let trigger_templates = manifest
.trigger_templates
.iter()
.map(serde_json::to_value)
.collect::<Result<Vec<_>, _>>()?;
Ok(json!({
"scripts": scripts,
"routes": routes,
"triggers": triggers,
"secrets": manifest.secrets.names,
"vars": vars,
"extension_points": extension_points,
"route_templates": route_templates,
"trigger_templates": trigger_templates,
"extension_points": manifest.extension_points(),
"collections": manifest
.collections()
.into_iter()
.map(|(name, kind)| json!({ "name": name, "kind": kind }))
.collect::<Vec<_>>(),
}))
}
@@ -245,13 +182,14 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result<Value> {
fn render(plan: &PlanDto, mode: OutputMode) {
let mut table = Table::new(["kind", "op", "resource", "detail"]);
let groups: [(&str, &Vec<ChangeDto>); 6] = [
let groups: [(&str, &Vec<ChangeDto>); 7] = [
("script", &plan.scripts),
("route", &plan.routes),
("trigger", &plan.triggers),
("secret", &plan.secrets),
("var", &plan.vars),
("extension-point", &plan.extension_points),
("extension_point", &plan.extension_points),
("collection", &plan.collections),
];
for (kind, changes) in groups {
for c in changes {

View File

@@ -18,8 +18,8 @@ use crate::client::{Client, VarOwnerArg};
use crate::config;
use crate::manifest::{
CronTriggerSpec, DocsTriggerSpec, FilesTriggerSpec, KvTriggerSpec, Manifest, ManifestApp,
ManifestExtensionPoint, ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers,
PubsubTriggerSpec, QueueTriggerSpec, MANIFEST_FILE,
ManifestRoute, ManifestScript, ManifestSecrets, ManifestTriggers, PubsubTriggerSpec,
QueueTriggerSpec, MANIFEST_FILE,
};
use crate::output::{KvBlock, OutputMode};
@@ -49,6 +49,15 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
.secrets_list(VarOwnerArg::App(app_ident), None)
.await?
.secrets;
// The app's OWN declared extension points (§5.5) — inherited ones belong to
// the ancestor group's manifest, so filter to `declared_here`.
let extension_points: Vec<String> = client
.extension_points_list(crate::client::NodeKind::App, app_ident)
.await?
.into_iter()
.filter(|ep| ep.declared_here)
.map(|ep| ep.name)
.collect();
// App's OWN env-agnostic vars become the manifest `[vars]` block. Inherited
// group vars and tombstones are excluded (pull exports own rows only, §4.6);
// a value TOML can't represent (e.g. JSON null) is skipped with a warning.
@@ -210,26 +219,14 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
eprintln!("warning: skipping {s} trigger — not yet representable in the manifest");
}
// Extension points (§5.5): the app's own declarations, exported so a
// re-applied pulled manifest doesn't prune them.
let extension_points: Vec<ManifestExtensionPoint> = client
.extension_points_list(app_ident)
.await?
.into_iter()
.map(|e| ManifestExtensionPoint {
name: e.name,
default: e.default,
})
.collect();
let manifest = Manifest {
app: Some(ManifestApp {
slug: app.app.slug.clone(),
name: app.app.name.clone(),
description: app.app.description.clone(),
extension_points,
}),
group: None,
project: None,
scripts: manifest_scripts,
routes,
triggers: manifest_triggers,
@@ -237,10 +234,6 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) ->
names: secrets.iter().map(|s| s.name.clone()).collect(),
},
vars: manifest_vars,
extension_points,
// `pull` is app-scoped; templates are group-owned (§4.5).
route_templates: Vec::new(),
trigger_templates: Vec::new(),
};
std::fs::write(&manifest_path, manifest.to_toml()?)

View File

@@ -4,7 +4,7 @@
//! the server resolves ancestry from its own group tree, so the on-disk
//! nesting is organizational, not authoritative here.
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
@@ -45,15 +45,10 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
Ok(())
}
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}], project }`)
/// from every manifest under `root`. Returns the JSON, the node count, and the
/// root manifest's `[project]` environment policy (M5, empty if none). Rejects a
/// tree that names the same (kind, slug) twice, or declares `[project]` anywhere
/// but the root manifest.
pub fn build_tree(
root: &Path,
env: Option<&str>,
) -> Result<(Value, usize, Vec<crate::manifest::ManifestEnvironment>)> {
/// Build the wire tree bundle (`{ nodes: [{kind, slug, bundle}] }`) from every
/// manifest under `root`. Returns the JSON plus the node count. Rejects a tree
/// that names the same (kind, slug) twice.
pub fn build_tree(root: &Path, env: Option<&str>) -> Result<(Value, usize)> {
let paths = find_manifests(root)?;
if paths.is_empty() {
bail!(
@@ -61,91 +56,20 @@ pub fn build_tree(
root.display()
);
}
let root_manifest = root.join(MANIFEST_FILE);
// First pass: load every manifest and index its DIRECTORY → (slug, is_group)
// so a group node's parent can be inferred from the enclosing directory.
let mut loaded: Vec<(PathBuf, Manifest)> = Vec::with_capacity(paths.len());
let mut group_dirs: HashMap<PathBuf, String> = HashMap::new();
let mut project: Option<crate::manifest::ManifestProject> = None;
let mut nodes = Vec::with_capacity(paths.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
for path in &paths {
let manifest = Manifest::load_with_env(path, env)
.with_context(|| format!("loading {}", path.display()))?;
// `[project]` is project-level — valid only on the tree's root manifest.
if let Some(p) = &manifest.project {
if path == &root_manifest {
project = Some(p.clone());
} else {
bail!(
"{} declares [project], which is only valid on the root manifest ({})",
path.display(),
root_manifest.display()
);
}
}
let dir = path
.parent()
.unwrap_or_else(|| Path::new("."))
.to_path_buf();
if manifest.is_group() {
group_dirs.insert(dir.clone(), manifest.slug().to_string());
}
loaded.push((dir, manifest));
}
let mut nodes = Vec::with_capacity(loaded.len());
let mut seen: HashSet<(String, String)> = HashSet::new();
for (dir, manifest) in &loaded {
let bundle = build_bundle(manifest, dir)?;
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let bundle = build_bundle(&manifest, base_dir)?;
let kind = if manifest.is_group() { "group" } else { "app" };
let slug = manifest.slug().to_string();
if !seen.insert((kind.to_string(), slug.clone())) {
bail!("project tree declares {kind} `{slug}` more than once");
}
let mut node = json!({ "kind": kind, "slug": slug, "bundle": bundle });
// Group nodes carry their parent (explicit `[group] parent`, else the
// nearest ancestor directory's group) + display name, so the server can
// create/reparent them (M2). App nodes ignore both server-side.
if let Some(g) = &manifest.group {
let parent = g
.parent
.clone()
.or_else(|| nearest_ancestor_group(dir, &group_dirs));
let obj = node.as_object_mut().expect("json object");
obj.insert("name".into(), json!(g.name));
if let Some(p) = parent {
obj.insert("parent_slug".into(), json!(p));
}
}
nodes.push(node);
nodes.push(json!({ "kind": kind, "slug": slug, "bundle": bundle }));
}
let count = nodes.len();
let envs = project
.as_ref()
.map(|p| p.environments.clone())
.unwrap_or_default();
let mut bundle = json!({ "nodes": nodes });
if let Some(p) = &project {
bundle.as_object_mut().expect("json object").insert(
"project".into(),
json!({
"environments": p.environments.iter()
.map(|e| json!({ "name": e.name, "confirm": e.confirm }))
.collect::<Vec<_>>(),
}),
);
}
Ok((bundle, count, envs))
}
/// The slug of the nearest ancestor directory (above `dir`) that holds a group
/// manifest — the directory-derived parent for a nested group node.
fn nearest_ancestor_group(dir: &Path, group_dirs: &HashMap<PathBuf, String>) -> Option<String> {
let mut cur = dir.parent();
while let Some(d) = cur {
if let Some(slug) = group_dirs.get(d) {
return Some(slug.clone());
}
cur = d.parent();
}
None
Ok((json!({ "nodes": nodes }), count))
}

View File

@@ -13,59 +13,6 @@ use serde::{Deserialize, Serialize};
const DIR: &str = ".picloud";
const PLAN_FILE: &str = "plan.json";
const PROJECT_FILE: &str = "project.json";
/// The repo's stable project identity (§7, M3): a random, opaque key minted on
/// first need and persisted (gitignored) in `.picloud/`. It is the server-side
/// ownership handle — the same repo keeps the same project across clones/CI
/// because the key travels in the working tree, never in a committed file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectLink {
pub key: String,
}
fn project_path(base: &Path) -> PathBuf {
base.join(DIR).join(PROJECT_FILE)
}
/// Read the project key, if one has been minted under `base/.picloud/`.
#[must_use]
pub fn read_project_key(base: &Path) -> Option<String> {
let body = fs::read(project_path(base)).ok()?;
serde_json::from_slice::<ProjectLink>(&body)
.ok()
.map(|l| l.key)
}
/// Read the project key, minting and persisting a fresh one if absent. Used by
/// the tree `plan`/`apply` paths so a repo that never ran `pic init` still gets
/// a stable ownership identity on first use. The new key is a random v4 UUID.
pub fn ensure_project_key(base: &Path) -> Result<String> {
if let Some(k) = read_project_key(base) {
return Ok(k);
}
let key = uuid::Uuid::new_v4().to_string();
write_project_key(base, &key)?;
Ok(key)
}
/// Persist `key` as the project identity, creating `.picloud/` (self-ignored)
/// if needed. Idempotent overwrite — callers usually go through
/// [`ensure_project_key`].
pub fn write_project_key(base: &Path, key: &str) -> Result<()> {
let dir = base.join(DIR);
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let ignore = dir.join(".gitignore");
if !ignore.exists() {
fs::write(&ignore, "*\n").context("writing .picloud/.gitignore")?;
}
let body = serde_json::to_vec_pretty(&ProjectLink {
key: key.to_string(),
})
.context("encoding .picloud/project.json")?;
fs::write(project_path(base), body).context("writing .picloud/project.json")?;
Ok(())
}
/// The recorded result of the last `pic plan`, scoped to the app it was for.
#[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -153,6 +153,24 @@ enum Cmd {
cmd: VarsCmd,
},
/// Extension points (§5.5) — read-only view of the module names a node
/// marks as overridable. Authored declaratively via the manifest
/// `extension_points = [...]`; this lists them + (for an app) each one's
/// resolved provider.
ExtensionPoints {
#[command(subcommand)]
cmd: ExtensionPointsCmd,
},
/// Shared group collections (§11.6) — read-only view of the KV collection
/// names a group offers as cross-app-shared. Authored declaratively via the
/// `[group]` manifest `collections = [...]`; scripts read/write them with
/// `kv::shared_collection("name")`.
Collections {
#[command(subcommand)]
cmd: CollectionsCmd,
},
/// Files inspection — list a collection's blobs, download bytes, or
/// delete a file. Read + delete only; writes go through scripts.
Files {
@@ -220,19 +238,10 @@ struct ApplyArgs {
/// since the last `pic plan`).
#[arg(long)]
force: bool,
/// Tree apply only (`--dir`): seize declared groups currently owned by
/// another project (§7). Requires group-admin on each contested node.
#[arg(long, requires = "dir")]
takeover: bool,
/// Merge the `picloud.<env>.toml` overlay (per-env slug + secrets) on
/// top of the base manifest before applying.
#[arg(long)]
env: Option<String>,
/// Tree apply only (`--dir`): explicitly approve applying to a
/// confirm-required environment (per the root manifest's `[project]`
/// policy, §4.2). Repeatable. A blanket `--yes` does NOT cover it.
#[arg(long = "approve", requires = "dir")]
approve: Vec<String>,
}
#[derive(Args)]
@@ -546,6 +555,28 @@ enum DomainsCmd {
Rm { app: String, domain_id: String },
}
#[derive(Subcommand)]
enum ExtensionPointsCmd {
/// List a node's extension points. `--app` shows every EP visible on the
/// app's chain with its resolved provider; `--group` shows the group's own.
Ls {
#[arg(long)]
app: Option<String>,
#[arg(long, conflicts_with = "app")]
group: Option<String>,
},
}
#[derive(Subcommand)]
enum CollectionsCmd {
/// List the shared collections a group declares (§11.6). Group-only —
/// shared collections are owned by groups, not apps.
Ls {
#[arg(long)]
group: String,
},
}
#[derive(Subcommand)]
enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; with `--group`, a
@@ -1355,9 +1386,7 @@ async fn main() -> ExitCode {
args.prune,
args.yes,
args.force,
args.takeover,
args.env.as_deref(),
&args.approve,
mode,
)
.await
@@ -1972,6 +2001,12 @@ async fn main() -> ExitCode {
env,
},
} => cmds::vars::rm(group.as_deref(), app.as_deref(), &key, env.as_deref()).await,
Cmd::ExtensionPoints {
cmd: ExtensionPointsCmd::Ls { app, group },
} => cmds::extension_points::ls(app.as_deref(), group.as_deref(), mode).await,
Cmd::Collections {
cmd: CollectionsCmd::Ls { group },
} => cmds::collections::ls(&group, mode).await,
Cmd::Files {
cmd:
FilesCmd::Ls {

View File

@@ -29,6 +29,7 @@ use serde::{Deserialize, Serialize};
pub const MANIFEST_FILE: &str = "picloud.toml";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
/// An app node declares `[app]`; a group node declares `[group]` (Phase 5).
/// Exactly one is present (enforced by [`Manifest::parse`]).
@@ -36,10 +37,6 @@ pub struct Manifest {
pub app: Option<ManifestApp>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group: Option<ManifestGroup>,
/// `[project]` — project-level policy (M5), consumed only by `apply --dir`
/// and only on the tree's ROOT manifest (enforced in `build_tree`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<ManifestProject>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub scripts: Vec<ManifestScript>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
@@ -53,21 +50,6 @@ pub struct Manifest {
/// The overlay merges per-env, last-write-wins by key.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub vars: BTreeMap<String, toml::Value>,
/// `[[extension_points]]` — module names this node opens for per-tenant
/// resolution (§5.5). Allowed on both app and group manifests.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extension_points: Vec<ManifestExtensionPoint>,
/// `[[route_templates]]` — GROUP-only route declarations that fan out into a
/// concrete route on every descendant app at apply time (§4.5, M4a). String
/// fields may carry `{app_slug}`/`{env}`/`{var:NAME}` placeholders.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub route_templates: Vec<ManifestRouteTemplate>,
/// `[[trigger_templates]]` — GROUP-only trigger declarations that fan out per
/// descendant app (§4.5, M4b). Each entry is `name = …`, `kind = "cron"|…`,
/// `script = …`, plus the kind's params (string fields may carry
/// placeholders). Kept loosely-typed so one block covers all seven kinds.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub trigger_templates: Vec<ManifestTriggerTemplate>,
}
impl Manifest {
@@ -118,6 +100,33 @@ impl Manifest {
self.group.is_some()
}
/// This node's declared extension-point names (§5.5), from whichever of
/// `[app]` / `[group]` is present.
#[must_use]
pub fn extension_points(&self) -> &[String] {
match (&self.app, &self.group) {
(Some(a), _) => &a.extension_points,
(_, Some(g)) => &g.extension_points,
_ => &[],
}
}
/// This node's declared shared group collections (§11.6), normalized to
/// `(name, kind)` pairs. Authored on `[group]` nodes only — an `[app]`
/// manifest carrying `collections` is a hard parse error (`ManifestApp` has
/// no such field + `deny_unknown_fields`).
#[must_use]
pub fn collections(&self) -> Vec<(String, String)> {
match &self.group {
Some(g) => g
.collections
.iter()
.map(CollectionDecl::normalized)
.collect(),
None => Vec::new(),
}
}
/// Load and parse the manifest at `path`.
pub fn load(path: &Path) -> Result<Self> {
let body =
@@ -217,47 +226,90 @@ pub struct OverlayApp {
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestApp {
pub slug: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// `extension_points = ["theme", …]` (§5.5) — module names this node marks
/// as provided/overridable by descendants. Name-only, like `[secrets]`; the
/// optional default body is a co-located `[[scripts]]` module of the same
/// name. A key of the `[app]` table so TOML can't mis-nest it.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extension_points: Vec<String>,
}
/// A `[group]` node (Phase 5/M2): a group's own declarative content — its
/// scripts and `[vars]`. With M2, `pic apply --dir` also creates the group if
/// it doesn't exist and reparents it under `parent`. When `parent` is omitted,
/// the parent is inferred from the enclosing directory's group manifest (or the
/// instance root for a top-level group).
/// A `[group]` node (Phase 5): a group's own declarative content — its scripts
/// and `[vars]`. The group must already exist on the server (created with
/// `pic groups create`); the manifest reconciles its content, not the tree
/// shape. In a nested project the parent is inferred from the directory tree.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestGroup {
pub slug: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Explicit parent group slug (M2). Overrides the directory-derived parent.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent: Option<String>,
}
/// `[project]` — project-level policy (§4.2/§6, M5). Valid ONLY on the root
/// manifest of a `pic apply --dir` tree; rejected elsewhere by [`build_tree`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestProject {
/// `[[project.environments]]` — per-env apply policy.
/// See [`ManifestApp::extension_points`]. A key of the `[group]` table.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub environments: Vec<ManifestEnvironment>,
pub extension_points: Vec<String>,
/// `collections = [...]` (§11.6) — shared collections this group offers as
/// cross-app-shared (read by any app in the subtree, written by an
/// authenticated editor+). Each entry is either a bare string (a `kv`
/// collection) or a `{ name, kind }` table (`kind` ∈ `kv`/`docs`).
/// Group-only: there is no `collections` key on `[app]`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub collections: Vec<CollectionDecl>,
}
/// One `[[project.environments]]` entry. `confirm = true` gates applying to this
/// env behind an explicit `pic apply --approve <name>` (M5).
/// The store kind of a shared collection (§11.6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CollectionKind {
Kv,
Docs,
Files,
}
impl CollectionKind {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Kv => "kv",
Self::Docs => "docs",
Self::Files => "files",
}
}
}
/// One `collections` entry: a bare string (`kv` shorthand) or a `{ name, kind }`
/// table. Untagged so the shipped `collections = ["catalog"]` form keeps working
/// alongside `{ name = "articles", kind = "docs" }`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManifestEnvironment {
pub name: String,
#[serde(default)]
pub confirm: bool,
#[serde(untagged)]
pub enum CollectionDecl {
Name(String),
Full {
name: String,
#[serde(default = "kv_kind")]
kind: CollectionKind,
},
}
fn kv_kind() -> CollectionKind {
CollectionKind::Kv
}
impl CollectionDecl {
/// `(name, kind-as-string)` — a bare string normalizes to `kind = "kv"`.
#[must_use]
pub fn normalized(&self) -> (String, String) {
match self {
Self::Name(n) => (n.clone(), "kv".to_string()),
Self::Full { name, kind } => (name.clone(), kind.as_str().to_string()),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@@ -310,43 +362,6 @@ pub struct ManifestRoute {
pub enabled: bool,
}
/// `[[route_templates]]` — a route declared once on a group, fanned out per
/// descendant app (§4.5, M4a). Same shape as [`ManifestRoute`] plus a `name`
/// (the per-group identity/upsert key).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestRouteTemplate {
/// Per-group template name (identity/upsert key).
pub name: String,
pub script: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub method: Option<String>,
pub host_kind: HostKind,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub host: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_param_name: Option<String>,
pub path_kind: PathKind,
pub path: String,
#[serde(default, skip_serializing_if = "is_sync")]
pub dispatch_mode: DispatchMode,
#[serde(
default = "picloud_shared::default_true",
skip_serializing_if = "is_true"
)]
pub enabled: bool,
}
/// `[[trigger_templates]]` — a trigger declared once on a group, fanned out per
/// descendant app (§4.5, M4b). `name` is the per-group identity; the remaining
/// keys (`kind`, `script`, kind params) are kept as a flattened table so one
/// block covers every kind, matching the server's `{ name, <BundleTrigger> }`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggerTemplate {
pub name: String,
#[serde(flatten)]
pub spec: toml::Value,
}
/// Triggers grouped by kind (arrays-of-tables: `[[triggers.cron]]`, …).
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ManifestTriggers {
@@ -477,17 +492,6 @@ impl ManifestSecrets {
}
}
/// `[[extension_points]]` — a module name opened for per-tenant resolution
/// (§5.5). A parent/group script importing `name` resolves it against the
/// inheriting app's module instead of the sealed lexical chain; `default`
/// names a local module used when an app provides none.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ManifestExtensionPoint {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<String>,
}
// ---- serde skip/default helpers ----
fn is_endpoint(kind: &ScriptKind) -> bool {
@@ -517,9 +521,9 @@ mod tests {
slug: "blog".into(),
name: "My Blog".into(),
description: Some("demo".into()),
extension_points: vec!["theme".into()],
}),
group: None,
project: None,
scripts: vec![
ManifestScript {
name: "create-post".into(),
@@ -580,12 +584,6 @@ mod tests {
("region".to_string(), toml::Value::String("eu".into())),
("max-retries".to_string(), toml::Value::Integer(3)),
]),
extension_points: vec![ManifestExtensionPoint {
name: "theme".into(),
default: Some("default-theme".into()),
}],
route_templates: Vec::new(),
trigger_templates: Vec::new(),
}
}
@@ -682,17 +680,14 @@ mod tests {
slug: "x".into(),
name: "X".into(),
description: None,
extension_points: vec![],
}),
group: None,
project: None,
scripts: vec![],
routes: vec![],
triggers: ManifestTriggers::default(),
secrets: ManifestSecrets::default(),
vars: BTreeMap::new(),
extension_points: Vec::new(),
route_templates: Vec::new(),
trigger_templates: Vec::new(),
};
let text = m.to_toml().unwrap();
assert!(!text.contains("[[scripts]]"), "got:\n{text}");
@@ -704,6 +699,56 @@ mod tests {
assert_eq!(m, Manifest::parse(&text).unwrap());
}
#[test]
fn rejects_misplaced_or_unknown_keys() {
// §5.5 footgun guard: `extension_points` is an `[app]`/`[group]` node
// key. Placed at top level (before any table header) TOML reads it as a
// root key — `deny_unknown_fields` must reject it loudly rather than
// silently drop it (the silent-drop bug that bit in C5).
let misplaced = "extension_points = [\"theme\"]\n[app]\nslug = \"x\"\nname = \"X\"\n";
assert!(
Manifest::parse(misplaced).is_err(),
"a top-level extension_points must be rejected, not silently ignored"
);
// A typo'd key inside [app] is likewise a hard error, not a drop.
let typo = "[app]\nslug = \"x\"\nname = \"X\"\nextention_points = [\"theme\"]\n";
assert!(
Manifest::parse(typo).is_err(),
"an unknown key in [app] must be rejected"
);
// The correct node-key placement still parses.
let ok = "[app]\nslug = \"x\"\nname = \"X\"\nextension_points = [\"theme\"]\n";
let m = Manifest::parse(ok).expect("node-key extension_points must parse");
assert_eq!(m.extension_points(), ["theme"]);
}
#[test]
fn collections_string_or_table_normalizes_kind() {
// §11.6: a bare string is a `kv` collection (the shipped form); a
// `{ name, kind }` table sets an explicit kind. Both coexist.
let m = Manifest::parse(
"[group]\nslug = \"acme\"\nname = \"ACME\"\n\n\
collections = [\"catalog\", { name = \"articles\", kind = \"docs\" }, \
{ name = \"assets\", kind = \"files\" }]\n",
)
.expect("string-or-table collections must parse");
assert_eq!(
m.collections(),
vec![
("catalog".to_string(), "kv".to_string()),
("articles".to_string(), "docs".to_string()),
("assets".to_string(), "files".to_string()),
]
);
// An app manifest carrying `collections` is rejected (no such field +
// deny_unknown_fields).
let app = "[app]\nslug = \"x\"\nname = \"X\"\ncollections = [\"catalog\"]\n";
assert!(
Manifest::parse(app).is_err(),
"collections on [app] must be rejected"
);
}
#[test]
fn group_manifest_parses_and_rejects_app_only_blocks() {
// A [group] node: scripts + vars, no [app].
@@ -731,23 +776,6 @@ mod tests {
.expect_err("both [app] and [group]");
}
#[test]
fn project_block_parses_environment_policy() {
let m = Manifest::parse(
"[app]\nslug = \"a\"\nname = \"A\"\n\n\
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
[[project.environments]]\nname = \"staging\"\n",
)
.expect("manifest with [project] parses");
let p = m.project.expect("project present");
assert_eq!(p.environments.len(), 2);
assert_eq!(p.environments[0].name, "production");
assert!(p.environments[0].confirm);
// `confirm` defaults to false when omitted.
assert_eq!(p.environments[1].name, "staging");
assert!(!p.environments[1].confirm);
}
#[test]
fn overlay_vars_override_base_per_key() {
let mut base = sample();

View File

@@ -1,169 +0,0 @@
//! M5 per-env approval gating (§4.2, §6) via `pic apply --dir`:
//! * a root manifest `[project]` block marks an environment confirm-required,
//! * applying to that env WITHOUT `--approve` is refused (a blanket `--yes`
//! does not cover it) — refused non-interactively at the CLI,
//! * `--approve <env>` (as an admin) lets it through,
//! * a non-gated environment applies with plain `--yes`,
//! * approving a gated apply needs ADMIN authority on the node — a non-admin
//! editor with `--approve` is refused server-side (403).
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
use crate::common::member;
/// A single-app project dir whose root manifest declares a `[project]` policy:
/// `production` is confirm-required, `staging` is not.
fn project_dir(app: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
// A `[vars]` entry so the app bundle has write-requiring content: an `editor`
// member must hold (and exercise) AppVarsWrite to pass authz_tree — which
// makes the admin-gate test prove the approval gate is ABOVE editor-write,
// not merely "any non-admin is refused". (Vars cascade-delete with the app,
// unlike scripts which are ON DELETE RESTRICT, so AppGuard teardown is clean.)
fs::write(
dir.path().join("picloud.toml"),
format!(
"[app]\nslug = \"{app}\"\nname = \"Gated App\"\n\n\
[[project.environments]]\nname = \"production\"\nconfirm = true\n\n\
[[project.environments]]\nname = \"staging\"\nconfirm = false\n\n\
[vars]\nregion = \"eu\"\n"
),
)
.unwrap();
// `--env <e>` requires the overlay file to exist; empty overlays keep the
// base slug (same app across envs — we're testing the gate, not env routing).
fs::write(dir.path().join("picloud.production.toml"), "").unwrap();
fs::write(dir.path().join("picloud.staging.toml"), "").unwrap();
dir
}
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
let mut cmd = common::pic_as(env);
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
cmd.output().expect("apply --dir")
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn confirm_required_env_needs_explicit_approval() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("appr-g");
let app = common::unique_slug("appr-a");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
let dir = project_dir(&app);
// --- production is confirm-required: --yes alone is refused. ---
let out = apply(&env, dir.path(), &["--env", "production", "--yes"]);
assert!(
!out.status.success(),
"production apply without --approve must be refused"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("approve"),
"refusal should mention --approve:\n{err}"
);
// --- with --approve production, it applies. ---
let ok = apply(
&env,
dir.path(),
&["--env", "production", "--approve", "production"],
);
assert!(
ok.status.success(),
"approved production apply should succeed: {}",
String::from_utf8_lossy(&ok.stderr)
);
// --- staging is NOT gated: plain --yes applies. ---
let ok2 = apply(&env, dir.path(), &["--env", "staging", "--yes"]);
assert!(
ok2.status.success(),
"non-gated staging apply should succeed: {}",
String::from_utf8_lossy(&ok2.stderr)
);
// --- single-node `apply --file` to a gated env is refused (no silent
// bypass): the admin-gated approval is a `--dir` feature. ---
let single = common::pic_as(&env)
.args(["apply", "--file"])
.arg(dir.path().join("picloud.toml"))
.args(["--env", "production", "--yes"])
.output()
.expect("apply --file");
assert!(
!single.status.success(),
"single-node apply to a confirm-required env must be refused"
);
let serr = String::from_utf8_lossy(&single.stderr).to_lowercase();
assert!(
serr.contains("confirm-required") || serr.contains("--dir"),
"single-node refusal should point at --dir:\n{serr}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn approving_a_gated_apply_requires_admin() {
// §4.2: approving a confirm-required env is admin-gated — a second gate on
// top of the editor-level write caps an ordinary apply needs. An editor who
// CAN write the app still cannot approve a gated apply.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("appr2-g");
let app = common::unique_slug("appr2-a");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app);
common::pic_as(&env)
.args(["apps", "create", &app, "--group", &group])
.assert()
.success();
// A member with `editor` (write) on the app — enough for an ordinary apply,
// not enough to approve a gated environment.
let m = member::member_user(fx, &common::unique_username("appr"));
member::grant_membership(fx, &app, &m.id, "editor");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
let dir = project_dir(&app);
let out = apply(
&member_env,
dir.path(),
&["--env", "production", "--approve", "production"],
);
assert!(
!out.status.success(),
"a non-admin editor must not be able to approve a gated apply"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("forbidden") || err.contains("403"),
"approval denial should be an authz error:\n{err}"
);
}

View File

@@ -16,9 +16,9 @@ mod common;
mod admins;
mod api_keys;
mod apply;
mod approval;
mod apps;
mod auth;
mod collections;
mod config;
mod dead_letters;
mod email_queue;
@@ -33,7 +33,6 @@ mod init;
mod invoke;
mod logs;
mod output;
mod ownership;
mod plan;
mod prune;
mod pull;
@@ -42,8 +41,6 @@ mod routes;
mod scripts;
mod secrets;
mod staleness;
mod templates;
mod tree;
mod tree_shape;
mod triggers;
mod vars;

View File

@@ -0,0 +1,558 @@
//! §11.6 shared group collections, end to end via `pic`:
//! * a group declares a shared KV collection `catalog`; two apps under it
//! share one store — an authenticated write in app A is read back in app B
//! (cross-app data sharing, the deliberate inverse of per-app isolation),
//! * an app under a SIBLING group naming `catalog` gets CollectionNotShared
//! (the ancestry walk is the isolation boundary),
//! * `pic collections ls --group` lists the declared name; re-plan is NoOp.
//!
//! The anonymous-write-fails-closed half of the trust model is unit-tested in
//! `group_kv_service` (a journey invoke always carries the admin principal).
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard};
fn manifest_dir() -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).expect("scripts dir");
dir
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn shared_collection_is_read_write_across_the_subtree() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("coll-grp");
let sibling = common::unique_slug("coll-sib");
let app_a = common::unique_slug("coll-a");
let app_b = common::unique_slug("coll-b");
let foreign = common::unique_slug("coll-f");
let _g = GroupGuard::new(&env.url, &env.token, &group);
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
common::pic_as(&env)
.args(["groups", "create", &sibling])
.assert()
.success();
// The group declares `catalog` a shared collection (declarative apply).
let dir = manifest_dir();
let gmanifest =
format!("[group]\nslug = \"{group}\"\nname = \"Coll\"\n\ncollections = [\"catalog\"]\n");
let gpath = dir.path().join("group.toml");
fs::write(&gpath, &gmanifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&gpath)
.assert()
.success();
// `collections ls --group` shows the declared name.
let ls = String::from_utf8(
common::pic_as(&env)
.args(["collections", "ls", "--group", &group])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
ls.contains("catalog"),
"ls should list the collection:\n{ls}"
);
// Re-apply is a no-op (the marker already exists).
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&gpath)
.assert()
.success();
// Two apps under the group; one under a sibling group.
let _a = AppGuard::new(&env.url, &env.token, &app_a);
let _b = AppGuard::new(&env.url, &env.token, &app_b);
let _f = AppGuard::new(&env.url, &env.token, &foreign);
common::pic_as(&env)
.args(["apps", "create", &app_a, "--group", &group])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "create", &app_b, "--group", &group])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "create", &foreign, "--group", &sibling])
.assert()
.success();
// App A writes to the shared collection (authenticated invoke → admin
// principal, so the editor+ write gate is satisfied).
fs::write(
dir.path().join("scripts/writer.rhai"),
r#"kv::shared_collection("catalog").set("sku-1", #{ price: 9 }); "ok""#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/writer.rhai"))
.args(["--app", &app_a, "--name", "writer"])
.assert()
.success();
let writer_id = app_script_id(&env, &app_a, "writer");
common::pic_as(&env)
.args(["scripts", "invoke", &writer_id])
.assert()
.success();
// App B reads the SAME store back — cross-app sharing.
fs::write(
dir.path().join("scripts/reader.rhai"),
r#"kv::shared_collection("catalog").get("sku-1")"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/reader.rhai"))
.args(["--app", &app_b, "--name", "reader"])
.assert()
.success();
let reader_id = app_script_id(&env, &app_b, "reader");
assert_eq!(
invoke_body(&env, &reader_id),
serde_json::json!({ "price": 9 }),
"an app in the subtree reads what another app wrote to the shared collection"
);
// The foreign app (sibling subtree) names `catalog` but it does not resolve
// on its chain — CollectionNotShared. The invoke fails.
fs::write(
dir.path().join("scripts/foreign.rhai"),
r#"kv::shared_collection("catalog").get("sku-1")"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/foreign.rhai"))
.args(["--app", &foreign, "--name", "peek"])
.assert()
.success();
let foreign_id = app_script_id(&env, &foreign, "peek");
let out = common::pic_as(&env)
.args(["scripts", "invoke", &foreign_id])
.output()
.expect("invoke");
assert!(
!out.status.success(),
"a foreign app must not resolve another subtree's shared collection"
);
}
/// Nearest-ancestor-group-wins (§11.6, the CoW-shadowing guarantee). A parent
/// AND a child group both declare `catalog`; an app under the child must bind
/// the CHILD's store, not the parent's. Discriminated by a second app directly
/// under the parent: it resolves to the PARENT's store, so it must NOT see what
/// the deep app wrote. If the resolver's `ORDER BY depth LIMIT 1` regressed to
/// the farther group, the shallow app would see the deep app's value.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn nearest_declaring_group_wins() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let root = common::unique_slug("nw-root");
let team = common::unique_slug("nw-team");
let deep = common::unique_slug("nw-deep");
let shallow = common::unique_slug("nw-shallow");
let _gr = GroupGuard::new(&env.url, &env.token, &root);
let _gt = GroupGuard::new(&env.url, &env.token, &team);
common::pic_as(&env)
.args(["groups", "create", &root])
.assert()
.success();
common::pic_as(&env)
.args(["groups", "create", &team, "--parent", &root])
.assert()
.success();
// BOTH groups declare `catalog` (two distinct stores, same name).
let dir = manifest_dir();
for g in [&root, &team] {
let m = format!("[group]\nslug = \"{g}\"\nname = \"NW\"\n\ncollections = [\"catalog\"]\n");
let p = dir.path().join(format!("{g}.toml"));
fs::write(&p, &m).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&p)
.assert()
.success();
}
// `deep` under team, `shallow` directly under root.
let _ad = AppGuard::new(&env.url, &env.token, &deep);
let _as = AppGuard::new(&env.url, &env.token, &shallow);
common::pic_as(&env)
.args(["apps", "create", &deep, "--group", &team])
.assert()
.success();
common::pic_as(&env)
.args(["apps", "create", &shallow, "--group", &root])
.assert()
.success();
// The deep app writes — must land in TEAM's store (nearest declarer).
fs::write(
dir.path().join("scripts/w.rhai"),
r#"kv::shared_collection("catalog").set("k", "team-value"); "ok""#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/w.rhai"))
.args(["--app", &deep, "--name", "w"])
.assert()
.success();
common::pic_as(&env)
.args(["scripts", "invoke", &app_script_id(&env, &deep, "w")])
.assert()
.success();
// The deep app reads its own write back (team's store).
fs::write(
dir.path().join("scripts/r.rhai"),
r#"kv::shared_collection("catalog").get("k")"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/r.rhai"))
.args(["--app", &deep, "--name", "r"])
.assert()
.success();
assert_eq!(
invoke_body(&env, &app_script_id(&env, &deep, "r")),
serde_json::json!("team-value"),
"the deep app reads back its own write to the nearest (team) store"
);
// The shallow app reads catalog → ROOT's store, which is empty. Seeing
// `team-value` here would mean the write leaked to the farther group.
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/r.rhai"))
.args(["--app", &shallow, "--name", "r"])
.assert()
.success();
assert_eq!(
invoke_body(&env, &app_script_id(&env, &shallow, "r")),
serde_json::Value::Null,
"the shallow app resolves the ROOT store (empty) — nearest-wins kept the \
deep write in the team store"
);
}
/// §11.6 docs slice: a group declares a `docs`-kind shared collection (via the
/// string-or-table manifest, mixed with a `kv` one); an authenticated app A
/// `create`s a document and app B `find`s it back across the subtree; a
/// sibling-subtree app gets `CollectionNotShared`; `collections ls` shows both
/// kinds.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn shared_docs_collection_is_read_write_across_the_subtree() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("dcoll-grp");
let sibling = common::unique_slug("dcoll-sib");
let app_a = common::unique_slug("dcoll-a");
let app_b = common::unique_slug("dcoll-b");
let foreign = common::unique_slug("dcoll-f");
let _g = GroupGuard::new(&env.url, &env.token, &group);
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
common::pic_as(&env)
.args(["groups", "create", &sibling])
.assert()
.success();
// The group declares a kv AND a docs collection (string-or-table form).
let dir = manifest_dir();
let gmanifest = format!(
"[group]\nslug = \"{group}\"\nname = \"DColl\"\n\n\
collections = [\"catalog\", {{ name = \"articles\", kind = \"docs\" }}]\n"
);
let gpath = dir.path().join("group.toml");
fs::write(&gpath, &gmanifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&gpath)
.assert()
.success();
// `collections ls` shows both names with their kinds.
let ls = String::from_utf8(
common::pic_as(&env)
.args(["collections", "ls", "--group", &group])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
ls.contains("articles") && ls.contains("docs") && ls.contains("catalog"),
"ls should list both kinds:\n{ls}"
);
// Re-apply is a no-op (both markers already exist).
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&gpath)
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app_a);
let _b = AppGuard::new(&env.url, &env.token, &app_b);
let _f = AppGuard::new(&env.url, &env.token, &foreign);
for (app, grp) in [(&app_a, &group), (&app_b, &group), (&foreign, &sibling)] {
common::pic_as(&env)
.args(["apps", "create", app, "--group", grp])
.assert()
.success();
}
// App A creates a document in the shared docs collection (authenticated).
fs::write(
dir.path().join("scripts/dwriter.rhai"),
r#"docs::shared_collection("articles").create(#{ t: "hi" })"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/dwriter.rhai"))
.args(["--app", &app_a, "--name", "dwriter"])
.assert()
.success();
common::pic_as(&env)
.args(["scripts", "invoke", &app_script_id(&env, &app_a, "dwriter")])
.assert()
.success();
// App B finds the SAME doc back — cross-app docs sharing + the find DSL.
fs::write(
dir.path().join("scripts/dreader.rhai"),
r#"docs::shared_collection("articles").find(#{ t: "hi" })[0].data.t"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/dreader.rhai"))
.args(["--app", &app_b, "--name", "dreader"])
.assert()
.success();
assert_eq!(
invoke_body(&env, &app_script_id(&env, &app_b, "dreader")),
serde_json::json!("hi"),
"app B finds the document app A created in the shared docs collection"
);
// The foreign app (sibling subtree) → CollectionNotShared.
fs::write(
dir.path().join("scripts/dpeek.rhai"),
r#"docs::shared_collection("articles").find(#{})"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/dpeek.rhai"))
.args(["--app", &foreign, "--name", "dpeek"])
.assert()
.success();
let out = common::pic_as(&env)
.args(["scripts", "invoke", &app_script_id(&env, &foreign, "dpeek")])
.output()
.expect("invoke");
assert!(
!out.status.success(),
"a foreign app must not resolve another subtree's shared docs collection"
);
}
/// §11.6 files slice: a group declares a `files`-kind shared collection (mixed
/// with kv + docs in one string-or-table manifest); an authenticated app A
/// `create`s a blob and app B `list`s + `get`s the SAME bytes back across the
/// subtree (the group-keyed disk path under `files/groups/<group_id>/...`); a
/// sibling-subtree app gets `CollectionNotShared`; `collections ls` shows all
/// three kinds.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn shared_files_collection_is_read_write_across_the_subtree() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("fcoll-grp");
let sibling = common::unique_slug("fcoll-sib");
let app_a = common::unique_slug("fcoll-a");
let app_b = common::unique_slug("fcoll-b");
let foreign = common::unique_slug("fcoll-f");
let _g = GroupGuard::new(&env.url, &env.token, &group);
let _gs = GroupGuard::new(&env.url, &env.token, &sibling);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
common::pic_as(&env)
.args(["groups", "create", &sibling])
.assert()
.success();
// The group declares kv + docs + files collections in one manifest.
let dir = manifest_dir();
let gmanifest = format!(
"[group]\nslug = \"{group}\"\nname = \"FColl\"\n\n\
collections = [\"catalog\", {{ name = \"articles\", kind = \"docs\" }}, \
{{ name = \"assets\", kind = \"files\" }}]\n"
);
let gpath = dir.path().join("group.toml");
fs::write(&gpath, &gmanifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&gpath)
.assert()
.success();
// `collections ls` shows all three kinds.
let ls = String::from_utf8(
common::pic_as(&env)
.args(["collections", "ls", "--group", &group])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
ls.contains("assets") && ls.contains("files"),
"ls should list the files collection:\n{ls}"
);
// Re-apply is a no-op (all three markers already exist).
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&gpath)
.assert()
.success();
let _a = AppGuard::new(&env.url, &env.token, &app_a);
let _b = AppGuard::new(&env.url, &env.token, &app_b);
let _f = AppGuard::new(&env.url, &env.token, &foreign);
for (app, grp) in [(&app_a, &group), (&app_b, &group), (&foreign, &sibling)] {
common::pic_as(&env)
.args(["apps", "create", app, "--group", grp])
.assert()
.success();
}
// App A creates a blob in the shared files collection (authenticated).
// base64("hi") = "aGk=".
fs::write(
dir.path().join("scripts/fwriter.rhai"),
r#"files::shared_collection("assets").create(#{ name: "a.txt", content_type: "text/plain", data: base64::decode("aGk=") })"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/fwriter.rhai"))
.args(["--app", &app_a, "--name", "fwriter"])
.assert()
.success();
common::pic_as(&env)
.args(["scripts", "invoke", &app_script_id(&env, &app_a, "fwriter")])
.assert()
.success();
// App B lists the shared collection and reads the SAME bytes back —
// cross-app blob sharing via the group-keyed disk path.
fs::write(
dir.path().join("scripts/freader.rhai"),
r#"let c = files::shared_collection("assets"); let p = c.list(); base64::encode(c.get(p.files[0].id))"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/freader.rhai"))
.args(["--app", &app_b, "--name", "freader"])
.assert()
.success();
assert_eq!(
invoke_body(&env, &app_script_id(&env, &app_b, "freader")),
serde_json::json!("aGk="),
"app B reads the blob app A wrote to the shared files collection"
);
// The foreign app (sibling subtree) → CollectionNotShared.
fs::write(
dir.path().join("scripts/fpeek.rhai"),
r#"files::shared_collection("assets").list()"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/fpeek.rhai"))
.args(["--app", &foreign, "--name", "fpeek"])
.assert()
.success();
let out = common::pic_as(&env)
.args(["scripts", "invoke", &app_script_id(&env, &foreign, "fpeek")])
.output()
.expect("invoke");
assert!(
!out.status.success(),
"a foreign app must not resolve another subtree's shared files collection"
);
}
/// Id of the named script in an app (`pic scripts ls --app <a>`).
fn app_script_id(env: &common::TestEnv, app: &str, name: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--app", app])
.output()
.expect("scripts ls");
let table = String::from_utf8(ls.stdout).unwrap();
table
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&name))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("script `{name}` not found:\n{table}"))
}
fn invoke_body(env: &common::TestEnv, id: &str) -> serde_json::Value {
let out = common::pic_as(env)
.args(["scripts", "invoke", id])
.output()
.expect("scripts invoke");
assert!(
out.status.success(),
"invoke failed: {}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("invoke body is JSON")
}

View File

@@ -78,26 +78,6 @@ pub fn grant_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str)
);
}
/// `POST /api/v1/admin/groups/{slug}/members` — grant `role` on a group.
pub fn grant_group_membership(fx: &Fixture, group_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();
let resp = client
.post(format!(
"{}/api/v1/admin/groups/{}/members",
fx.url, group_slug
))
.bearer_auth(&fx.admin_token)
.json(&json!({ "user_id": user_id, "role": role }))
.send()
.expect("grant group membership");
assert!(
resp.status().is_success(),
"grant group membership failed: {} {}",
resp.status(),
resp.text().unwrap_or_default(),
);
}
/// `PATCH /api/v1/admin/apps/{slug}/members/{user_id}` — promote/demote.
pub fn update_membership(fx: &Fixture, app_slug: &str, user_id: &str, role: &str) {
let client = reqwest::blocking::Client::new();

View File

@@ -1,12 +1,10 @@
//! Extension points (§5.5) end-to-end via `pic apply` + `invoke`:
//! * a GROUP declares a module name `theme` an extension point (with a
//! default body) and a group endpoint `render` that imports it,
//! * an app under the group provides its OWN `theme`; invoking the inherited
//! `render` in that app's context resolves the APP's `theme` (the
//! inversion) — NOT sealed to the group,
//! * an app that provides no `theme` falls back to the group's default body,
//! * `pull` round-trips the declaration (a re-applied pulled manifest is a
//! no-op, so `--prune` never silently drops it).
//! §5.5 opt-in extension points, end to end via `pic`:
//! * a group marks a module `theme` an extension point (default body) and an
//! endpoint `render` imports it; an app inherits → uses the DEFAULT,
//! * the app provides its OWN `theme` module → `render` now binds the app's
//! (DYNAMIC override — the inverse of the Phase 4b sealed/lexical import),
//! * `pic extension-points ls --app` shows the resolved provider,
//! * an inherited extension point with NO provider is a `pic plan` error.
use std::fs;
@@ -23,16 +21,14 @@ fn manifest_dir() -> TempDir {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn extension_point_resolves_per_app_with_default_fallback() {
fn extension_point_default_then_app_override() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("ep-grp");
let app_a = common::unique_slug("ep-a");
let app_b = common::unique_slug("ep-b");
let child = common::unique_slug("ep-child");
// GroupGuard first so it drops LAST (after apps + group scripts).
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
@@ -40,171 +36,163 @@ fn extension_point_resolves_per_app_with_default_fallback() {
.success();
let dir = manifest_dir();
// --- Group manifest: a default `theme` body, a `render` endpoint that
// imports `theme`, and the `[[extension_points]]` declaration. ---
// Group default `theme` module + a `render` endpoint importing it.
fs::write(
dir.path().join("scripts/defaulttheme.rhai"),
r#"fn name() { "default" }"#,
dir.path().join("scripts/theme.rhai"),
r#"fn color() { "blue" }"#,
)
.unwrap();
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/theme.rhai"))
.args(["--group", &group, "--name", "theme", "--kind", "module"])
.assert()
.success();
fs::write(
dir.path().join("scripts/render.rhai"),
r#"import "theme" as t; t::name()"#,
r#"import "theme" as t; t::color()"#,
)
.unwrap();
let group_manifest = format!(
"[group]\nslug = \"{group}\"\nname = \"EP Group\"\n\n\
[[scripts]]\nname = \"defaulttheme\"\nfile = \"scripts/defaulttheme.rhai\"\nkind = \"module\"\n\n\
[[scripts]]\nname = \"render\"\nfile = \"scripts/render.rhai\"\n\n\
[[extension_points]]\nname = \"theme\"\ndefault = \"defaulttheme\"\n"
common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/render.rhai"))
.args(["--group", &group, "--name", "render"])
.assert()
.success();
let _gt = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "theme"),
);
let group_path = dir.path().join("group.toml");
fs::write(&group_path, &group_manifest).unwrap();
let out = common::pic_as(&env)
.args(["apply", "--file"])
.arg(&group_path)
.output()
.expect("group apply");
assert!(
out.status.success(),
"group apply failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Group scripts block group deletion (RESTRICT) — guard them.
let _gr = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "render"),
);
let _gd = ScriptGuard::new(
&env.url,
&env.token,
&group_script_id(&env, &group, "defaulttheme"),
);
// --- App A under the group: provides its OWN `theme` + a caller that
// invokes the inherited `render`. ---
let _ga = AppGuard::new(&env.url, &env.token, &app_a);
// Mark `theme` an extension point at the group (declarative apply).
let gmanifest =
format!("[group]\nslug = \"{group}\"\nname = \"EP\"\n\nextension_points = [\"theme\"]\n");
let gpath = dir.path().join("group.toml");
fs::write(&gpath, &gmanifest).unwrap();
common::pic_as(&env)
.args(["apps", "create", &app_a, "--group", &group])
.args(["apply", "--file"])
.arg(&gpath)
.assert()
.success();
// App under the group; a `caller` invokes the inherited `render`.
let _child = AppGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["apps", "create", &child, "--group", &group])
.assert()
.success();
fs::write(
dir.path().join("scripts/theme-a.rhai"),
r#"fn name() { "app-a" }"#,
)
.unwrap();
fs::write(
dir.path().join("scripts/caller.rhai"),
r#"invoke("render", #{})"#,
)
.unwrap();
let app_a_manifest = format!(
"[app]\nslug = \"{app_a}\"\nname = \"EP A\"\n\n\
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme-a.rhai\"\nkind = \"module\"\n\n\
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
);
let app_a_path = dir.path().join("a.toml");
fs::write(&app_a_path, &app_a_manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&app_a_path)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/caller.rhai"))
.args(["--app", &child, "--name", "caller"])
.assert()
.success();
let caller_id = app_script_id(&env, &child, "caller");
// The inherited `render` imports the ext point `theme`; in App A's context
// it resolves App A's OWN `theme`.
let caller_a = app_script_id(&env, &app_a, "caller");
// Default: with no app override, `render`'s EP import falls back to the
// group's default body → "blue".
assert_eq!(
invoke_body(&env, &caller_a),
serde_json::json!("app-a"),
"extension point must resolve the inheriting app's module"
invoke_body(&env, &caller_id),
serde_json::json!("blue"),
"an inherited extension point resolves the group's default body"
);
// --- App B under the group: provides NO `theme` → render falls back to the
// group's default body. ---
let _gb = AppGuard::new(&env.url, &env.token, &app_b);
// DYNAMIC OVERRIDE: the app provides its own `theme`. Because `theme` is an
// extension point, the group endpoint's import now binds the APP's module —
// the exact inverse of a Phase 4b lexical (sealed) import.
fs::write(
dir.path().join("scripts/apptheme.rhai"),
r#"fn color() { "red" }"#,
)
.unwrap();
common::pic_as(&env)
.args(["apps", "create", &app_b, "--group", &group])
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/apptheme.rhai"))
.args(["--app", &child, "--name", "theme", "--kind", "module"])
.assert()
.success();
let app_b_manifest = format!(
"[app]\nslug = \"{app_b}\"\nname = \"EP B\"\n\n\
[[scripts]]\nname = \"caller\"\nfile = \"scripts/caller.rhai\"\n"
);
let app_b_path = dir.path().join("b.toml");
fs::write(&app_b_path, &app_b_manifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&app_b_path)
.assert()
.success();
let caller_b = app_script_id(&env, &app_b, "caller");
assert_eq!(
invoke_body(&env, &caller_b),
serde_json::json!("default"),
"an app providing no module must fall back to the ext point's default"
invoke_body(&env, &caller_id),
serde_json::json!("red"),
"the app must override an extension point the group declared"
);
// `extension-points ls --app` shows the resolved provider.
let ls = String::from_utf8(
common::pic_as(&env)
.args(["extension-points", "ls", "--app", &child])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
ls.contains("theme") && ls.contains("app override"),
"ls should report the app override:\n{ls}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn extension_point_round_trips_through_pull() {
fn extension_point_without_provider_is_rejected_by_plan() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let app = common::unique_slug("ep-pull");
let group = common::unique_slug("epn-grp");
let child = common::unique_slug("epn-child");
let _a = AppGuard::new(&env.url, &env.token, &app);
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["apps", "create", &app])
.args(["groups", "create", &group])
.assert()
.success();
// Apply an app manifest that declares an extension point with a default.
// Group declares an EP `ghost` with NO default body (body-less).
let dir = manifest_dir();
fs::write(
dir.path().join("scripts/theme.rhai"),
r#"fn name() { "x" }"#,
)
.unwrap();
let manifest = format!(
"[app]\nslug = \"{app}\"\nname = \"EP Pull\"\n\n\
[[scripts]]\nname = \"theme\"\nfile = \"scripts/theme.rhai\"\nkind = \"module\"\n\n\
[[extension_points]]\nname = \"theme_slot\"\ndefault = \"theme\"\n"
);
let manifest_path = dir.path().join("picloud.toml");
fs::write(&manifest_path, &manifest).unwrap();
let gmanifest =
format!("[group]\nslug = \"{group}\"\nname = \"EPN\"\n\nextension_points = [\"ghost\"]\n");
let gpath = dir.path().join("group.toml");
fs::write(&gpath, &gmanifest).unwrap();
common::pic_as(&env)
.args(["apply", "--file"])
.arg(&manifest_path)
.arg(&gpath)
.assert()
.success();
// Pull into a fresh dir, then re-plan: the extension point must round-trip
// (no Create/Delete), so `--prune` would never drop it.
let pulled = manifest_dir();
// App under the group provides no `ghost` → any plan for it is refused.
let _child = AppGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["pull", &app, "--dir"])
.arg(pulled.path())
.args(["apps", "create", &child, "--group", &group])
.assert()
.success();
let toml = fs::read_to_string(pulled.path().join("picloud.toml")).unwrap();
assert!(
toml.contains("theme_slot"),
"pulled manifest must export the extension point:\n{toml}"
);
let plan = common::pic_as(&env)
let amanifest = format!("[app]\nslug = \"{child}\"\nname = \"EPN child\"\n");
let apath = dir.path().join("picloud.toml");
fs::write(&apath, &amanifest).unwrap();
let out = common::pic_as(&env)
.args(["plan", "--file"])
.arg(pulled.path().join("picloud.toml"))
.arg(&apath)
.output()
.expect("plan");
let stdout = String::from_utf8_lossy(&plan.stdout);
assert!(
!stdout.to_lowercase().contains("create") && !stdout.to_lowercase().contains("delete"),
"re-planning a pulled manifest must be a no-op:\n{stdout}"
!out.status.success(),
"an inherited extension point with no provider must fail plan"
);
let stderr = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
stderr.contains("ghost") || stderr.contains("no provider") || stderr.contains("extension"),
"plan error should name the unprovided extension point:\n{stderr}"
);
}

View File

@@ -137,6 +137,37 @@ fn group_module_is_lexically_sealed_under_inheritance() {
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn reserved_group_module_name_is_rejected() {
// Parity with app modules (api.rs): a group `module` named after a built-in
// SDK namespace is refused at create — not silently accepted to fail later.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("gm-rsv");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let dir = manifest_dir();
fs::write(dir.path().join("scripts/kv.rhai"), r#"fn x() { 1 }"#).unwrap();
let out = common::pic_as(&env)
.args(["scripts", "deploy"])
.arg(dir.path().join("scripts/kv.rhai"))
.args(["--group", &group, "--name", "kv", "--kind", "module"])
.output()
.expect("deploy");
assert!(
!out.status.success(),
"a reserved module name must be rejected for a group module too"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn dangling_import_is_rejected_by_plan() {

View File

@@ -1,207 +0,0 @@
//! M3 multi-repo single-owner ownership (§7) via `pic apply --dir`:
//! * the first repo to apply a group CLAIMS it (its `.picloud/` project key
//! becomes the owner),
//! * a SECOND repo (different project key) applying the same group is
//! REJECTED with an ownership conflict,
//! * `--takeover` lets the second repo seize it (admin-gated; the fixture
//! token is instance owner), flipping ownership — proven by the first repo
//! now being the one rejected,
//! * `--prune` deletes an owned, now-undeclared, empty group; a group owned
//! by another repo is never touched.
//!
//! Each project lives in its own `TempDir`, so `pic` mints a distinct project
//! key per repo (`.picloud/project.json`) — exactly the two-repo topology §7
//! governs.
use std::fs;
use std::path::Path;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::GroupGuard;
use crate::common::member;
/// A single-group project dir: group `slug` under `parent`, no scripts (so a
/// structural prune can delete it — a group holding scripts is RESTRICT-pinned).
fn group_dir(slug: &str, parent: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{slug}\"\nname = \"Owned Group\"\nparent = \"{parent}\"\n"),
)
.unwrap();
dir
}
fn apply(env: &common::TestEnv, dir: &Path, extra: &[&str]) -> std::process::Output {
let mut cmd = common::pic_as(env);
cmd.args(["apply", "--dir"]).arg(dir).args(extra);
cmd.output().expect("apply --dir")
}
fn ls_groups(env: &common::TestEnv) -> String {
String::from_utf8(
common::pic_as(env)
.args(["groups", "ls"])
.output()
.expect("groups ls")
.stdout,
)
.unwrap()
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn first_repo_claims_second_is_rejected_then_takeover_flips_ownership() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("own-g");
let _g = GroupGuard::new(&env.url, &env.token, &slug);
// Repo A claims the group on first apply (it does not pre-exist → created
// AND claimed for project A in one tx).
let repo_a = group_dir(&slug, "root");
let a1 = apply(&env, repo_a.path(), &[]);
assert!(
a1.status.success(),
"repo A claim apply failed: {}",
String::from_utf8_lossy(&a1.stderr)
);
assert!(
ls_groups(&env).contains(&slug),
"group should exist after claim"
);
// Repo B (a different dir → a different project key) is rejected: the group
// is owned by project A.
let repo_b = group_dir(&slug, "root");
let b1 = apply(&env, repo_b.path(), &[]);
assert!(
!b1.status.success(),
"repo B apply must be rejected (group owned by A)"
);
let b1_err = String::from_utf8_lossy(&b1.stderr).to_lowercase();
assert!(
b1_err.contains("owned by another project") || b1_err.contains("takeover"),
"conflict message should name the ownership clash:\n{b1_err}"
);
// Repo B with --takeover succeeds (the fixture token is instance owner, so
// it holds GroupAdmin on every node) and flips ownership to project B.
let b2 = apply(&env, repo_b.path(), &["--takeover"]);
assert!(
b2.status.success(),
"repo B --takeover should succeed: {}",
String::from_utf8_lossy(&b2.stderr)
);
// Proof the flip took: repo A — formerly the owner — is now the one
// rejected without --takeover.
let a2 = apply(&env, repo_a.path(), &[]);
assert!(
!a2.status.success(),
"after takeover, repo A must now be rejected (B owns the group)"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn takeover_without_group_admin_is_forbidden() {
// §7.4 — ownership ⟂ RBAC: `--takeover` needs GroupAdmin specifically, a
// second gate beyond the write caps. A non-admin member (editor) of the
// contested group cannot seize it for their repo.
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let slug = common::unique_slug("own-ta");
let _g = GroupGuard::new(&env.url, &env.token, &slug);
// Admin (repo A) claims the group.
let repo_a = group_dir(&slug, "root");
assert!(
apply(&env, repo_a.path(), &[]).status.success(),
"admin claim apply should succeed"
);
// A member with `editor` (not group_admin) on the group.
let m = member::member_user(fx, &common::unique_username("ta"));
member::grant_group_membership(fx, &slug, &m.id, "editor");
let member_env = common::custom_env(&fx.url, &m.token);
common::seed_credentials(&member_env, &m.username);
// The member's repo B (different project key) attempts a takeover → 403.
let repo_b = group_dir(&slug, "root");
let out = apply(&member_env, repo_b.path(), &["--takeover"]);
assert!(
!out.status.success(),
"non-admin --takeover must be forbidden"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("forbidden") || err.contains("403"),
"takeover denial should be an authz error:\n{err}"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn prune_deletes_owned_undeclared_group_only() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("own-par");
let child = common::unique_slug("own-child");
// Drop order: child (registered last → dropped first), then parent.
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
let _gc = GroupGuard::new(&env.url, &env.token, &child);
// Repo declares parent + child (both empty); apply claims both.
let dir = TempDir::new().expect("tempdir");
fs::write(
dir.path().join("picloud.toml"),
format!("[group]\nslug = \"{parent}\"\nname = \"Parent\"\nparent = \"root\"\n"),
)
.unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(
dir.path().join("sub/picloud.toml"),
format!("[group]\nslug = \"{child}\"\nname = \"Child\"\nparent = \"{parent}\"\n"),
)
.unwrap();
let out = apply(&env, dir.path(), &[]);
assert!(
out.status.success(),
"initial claim apply failed: {}",
String::from_utf8_lossy(&out.stderr)
);
let groups = ls_groups(&env);
assert!(
groups.contains(&parent) && groups.contains(&child),
"both groups should exist"
);
// Drop the child manifest from the SAME repo (keeping its project key) so
// the child becomes owned-but-undeclared, then apply --prune: the empty
// child is deleted; the parent (still declared) is kept.
fs::remove_dir_all(dir.path().join("sub")).unwrap();
let out2 = apply(&env, dir.path(), &["--prune", "--yes"]);
assert!(
out2.status.success(),
"prune apply failed: {}",
String::from_utf8_lossy(&out2.stderr)
);
let groups = ls_groups(&env);
assert!(
groups.contains(&parent),
"parent must survive prune:\n{groups}"
);
assert!(
!groups.contains(&child),
"owned, undeclared, empty child must be pruned:\n{groups}"
);
}

View File

@@ -1,562 +0,0 @@
//! M4a route templates (§4.5) via `pic apply --dir`:
//! * a group declares ONE route template (`/t/{app_slug}`) bound to its
//! inherited script; the apply fans it out into a concrete route on every
//! descendant app, with `{app_slug}` resolved per app,
//! * re-apply is idempotent (no duplicate expansions — provenance),
//! * removing the template + `--prune` reaps the expansions,
//! * an expansion colliding with a hand-declared route is a hard error,
//! * `pic plan --dir` reports the blast radius.
use std::fs;
use postgres::{Client as PgClient, NoTls};
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{AppGuard, GroupGuard, ScriptGuard};
/// Template-expanded routes (`from_template IS NOT NULL`) for the two app slugs
/// under test, as `(path, id)` — read straight from Postgres since the route
/// admin endpoint only lists app-owned-script routes (a group script isn't
/// addressable there). Scoped to `/t/<a>` and `/t/<b>` so parallel tests don't
/// interfere.
fn expansion_rows(a: &str, b: &str) -> Vec<(String, String)> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = [format!("/t/{a}"), format!("/t/{b}")];
let rows = pg
.query(
"SELECT path, id::text FROM routes \
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
&[&&want[..]],
)
.expect("query expansions");
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
}
/// A tree: a root group declaring a `shared` endpoint + a `greet` route template
/// `/t/{app_slug}`, and `extra_app_toml` appended to app `a`'s manifest. Apps
/// must pre-exist under the group (created in the test before applying).
fn tree_dir(group: &str, a: &str, b: &str, template: &str, extra_app_a: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(
dir.path().join("scripts/shared.rhai"),
r#""hi from template""#,
)
.unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{template}"
),
)
.unwrap();
for (slug, extra) in [(a, extra_app_a), (b, "")] {
fs::create_dir_all(dir.path().join(slug)).unwrap();
fs::write(
dir.path().join(slug).join("picloud.toml"),
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n{extra}"),
)
.unwrap();
}
dir
}
const TEMPLATE: &str = "[[route_templates]]\nname = \"greet\"\nscript = \"shared\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/t/{app_slug}\"\n";
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn route_template_fans_out_per_app_idempotently_then_prunes() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tpl-g");
let a = common::unique_slug("tpl-a");
let b = common::unique_slug("tpl-b");
// group ← (app a, app b). Guards drop apps before the group (RESTRICT).
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// --- Apply: the template fans out to both apps. ---
let dir = tree_dir(&group, &a, &b, TEMPLATE, "");
// Plan reports the blast radius (both descendant apps).
let plan = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.expect("plan --dir");
let plan_err = String::from_utf8_lossy(&plan.stderr);
assert!(
plan_err.contains("blast radius") && plan_err.contains("2 app(s)"),
"plan should report the 2-app blast radius:\n{plan_err}"
);
// --- Apply: the template fans out to both apps, one route each. ---
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
// The group script must drop before its group at teardown (RESTRICT).
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
let first = expansion_rows(&a, &b);
let paths: Vec<&str> = first.iter().map(|(p, _)| p.as_str()).collect();
assert_eq!(
paths,
vec![format!("/t/{a}").as_str(), format!("/t/{b}").as_str()],
"each app gets its own `{{app_slug}}`-resolved route"
);
// --- Idempotent re-apply: same rows, same ids (no churn — provenance). ---
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let second = expansion_rows(&a, &b);
assert_eq!(
first, second,
"re-apply must not churn expansions (stable ids)"
);
// --- Remove the template + --prune: expansions are reaped. Rewrite the
// SAME repo's group manifest (a fresh dir would be a different project and
// conflict on the owned group, §7). ---
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"Tmpl Group\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n"
),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.args(["--prune", "--yes"])
.assert()
.success();
assert!(
expansion_rows(&a, &b).is_empty(),
"removing the template must reap its expansions"
);
}
/// M6 (§4.5): a `{var:NAME}` placeholder resolves against a var set in the SAME
/// apply (in-transaction), so a var and a template referencing it converge in
/// one `pic apply`. Before M6 the var (written earlier in the tx) was invisible
/// to expansion — the route would only pick it up on a *second* apply.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn route_template_var_resolves_in_same_apply() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tplv-g");
let a = common::unique_slug("tplv-a");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
common::pic_as(&env)
.args(["apps", "create", &a, "--group", &group])
.assert()
.success();
// The group sets `region` AND declares a template referencing it — one
// manifest, one apply.
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), r#""hi""#).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
[vars]\nregion = \"eu\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n\
[[route_templates]]\nname = \"geo\"\nscript = \"shared\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/tv/{{app_slug}}/{{var:region}}\"\n"
),
)
.unwrap();
fs::create_dir_all(dir.path().join(&a)).unwrap();
fs::write(
dir.path().join(&a).join("picloud.toml"),
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
// The expanded route resolved `{var:region}` → `eu` in THIS apply.
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = format!("/tv/{a}/eu");
let rows = pg
.query(
"SELECT path FROM routes WHERE from_template IS NOT NULL AND path = $1",
&[&want],
)
.expect("query");
assert_eq!(
rows.len(),
1,
"`{{var:region}}` must resolve to `eu` in the same apply (expected path {want})"
);
}
/// Template-expanded routes (`from_template IS NOT NULL`) whose path is one of
/// `paths`, as `path` strings — for asserting which descendant apps received an
/// expansion regardless of which apply declared them.
fn expansion_paths(paths: &[String]) -> Vec<String> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let rows = pg
.query(
"SELECT path FROM routes \
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
&[&paths],
)
.expect("query expansion paths");
rows.iter().map(|r| r.get(0)).collect()
}
/// `(path, id)` of template-expanded routes among `paths` — for asserting an
/// expansion is STABLE (same row id) across a re-apply, i.e. not churned.
fn expansion_rows_for(paths: &[String]) -> Vec<(String, String)> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let rows = pg
.query(
"SELECT path, id::text FROM routes \
WHERE from_template IS NOT NULL AND path = ANY($1) ORDER BY path",
&[&paths],
)
.expect("query expansion rows");
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
}
/// M7 (§4.5): a route template fans out to EVERY descendant app in the DB
/// subtree, not only the app nodes present in the apply. An app created under
/// the group out-of-band (absent from the manifest) still receives the
/// expansion on the next apply, and removing the template reaps it there too.
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn route_template_reaches_out_of_tree_descendant() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("xrepo-g");
let sg = common::unique_slug("xrepo-sg"); // subgroup (depth-2)
let a = common::unique_slug("xrepo-a");
let c = common::unique_slug("xrepo-c"); // out-of-tree descendant (depth-1)
let d = common::unique_slug("xrepo-d"); // out-of-tree descendant (depth-2)
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
common::pic_as(&env)
.args(["apps", "create", &a, "--group", &group])
.assert()
.success();
// Apply a tree of just (group ← app a) with an `/x/{app_slug}` template.
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), r#""hi""#).unwrap();
let group_toml = |with_template: bool| {
let tmpl = if with_template {
"\n[[route_templates]]\nname = \"x\"\nscript = \"shared\"\n\
host_kind = \"any\"\npath_kind = \"exact\"\npath = \"/x/{app_slug}\"\n"
} else {
""
};
format!(
"[group]\nslug = \"{group}\"\nname = \"G\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n{tmpl}"
)
};
fs::write(dir.path().join("picloud.toml"), group_toml(true)).unwrap();
fs::create_dir_all(dir.path().join(&a)).unwrap();
fs::write(
dir.path().join(&a).join("picloud.toml"),
format!("[app]\nslug = \"{a}\"\nname = \"App\"\n"),
)
.unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
assert_eq!(
expansion_paths(&[format!("/x/{a}")]).len(),
1,
"in-tree app a gets its expansion"
);
// Create app c (direct child) and a nested subgroup ← app d (depth-2),
// all OUTSIDE the manifest tree, to exercise the recursive descendant CTE.
let _ac = AppGuard::new(&env.url, &env.token, &c);
common::pic_as(&env)
.args(["apps", "create", &c, "--group", &group])
.assert()
.success();
let _sg = GroupGuard::new(&env.url, &env.token, &sg);
common::pic_as(&env)
.args(["groups", "create", &sg, "--parent", &group])
.assert()
.success();
let _ad = AppGuard::new(&env.url, &env.token, &d);
common::pic_as(&env)
.args(["apps", "create", &d, "--group", &sg])
.assert()
.success();
// Re-apply the same tree (still only group + a). M7: c (depth-1) AND d
// (depth-2, under the subgroup) both receive the expansion. `--force`
// waives the bound token (the out-of-band creates bumped structure version).
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.arg("--force")
.assert()
.success();
let want = [format!("/x/{c}"), format!("/x/{d}")];
assert_eq!(
expansion_paths(&want),
want.to_vec(),
"out-of-tree descendants at depth 1 AND 2 receive expansions"
);
// Idempotent re-apply: descendants must not churn (same row ids — provenance
// by `from_template`, not delete+recreate), same as the in-tree path.
let before = expansion_rows_for(&want);
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.arg("--force")
.assert()
.success();
assert_eq!(
before,
expansion_rows_for(&want),
"re-apply must not churn descendant expansions (stable ids)"
);
// Remove the template + prune: every descendant's expansion is reaped,
// in-tree or not, at any depth.
fs::write(dir.path().join("picloud.toml"), group_toml(false)).unwrap();
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.args(["--prune", "--yes", "--force"])
.assert()
.success();
assert!(
expansion_paths(&[format!("/x/{a}"), format!("/x/{c}"), format!("/x/{d}")]).is_empty(),
"removing the template reaps expansions on ALL descendants, any depth"
);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn expansion_colliding_with_hand_declared_route_is_rejected() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("tplc-g");
let a = common::unique_slug("tplc-a");
let b = common::unique_slug("tplc-b");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// App `a` hand-declares the EXACT route the template would expand to (the
// template path resolves to `/t/<a>` for app a). That collision is a hard
// error — neither side silently wins.
let collide = format!(
"\n[[routes]]\nscript = \"shared\"\nhost_kind = \"any\"\n\
path_kind = \"exact\"\npath = \"/t/{a}\"\n"
);
let dir = tree_dir(&group, &a, &b, TEMPLATE, &collide);
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("apply --dir");
assert!(
!out.status.success(),
"an expansion colliding with a hand-declared route must be rejected"
);
let err = String::from_utf8_lossy(&out.stderr).to_lowercase();
assert!(
err.contains("template") && (err.contains("hand") || err.contains("declare")),
"error should name the template/hand-declared collision:\n{err}"
);
}
/// Template-expanded kv triggers for the two apps, as `(collection_glob, id)`,
/// read straight from Postgres (a group-script trigger isn't listable via the
/// app trigger API). Scoped to the test's two slugs.
fn trigger_rows(a: &str, b: &str) -> Vec<(String, String)> {
let url = std::env::var("DATABASE_URL").expect("DATABASE_URL");
let mut pg = PgClient::connect(&url, NoTls).expect("pg connect");
let want = [format!("{a}-data"), format!("{b}-data")];
let rows = pg
.query(
"SELECT d.collection_glob, t.id::text FROM triggers t \
JOIN kv_trigger_details d ON d.trigger_id = t.id \
WHERE t.from_template IS NOT NULL AND d.collection_glob = ANY($1) \
ORDER BY d.collection_glob",
&[&&want[..]],
)
.expect("query trigger expansions");
rows.iter().map(|r| (r.get(0), r.get(1))).collect()
}
const TRIGGER_TEMPLATE: &str = "[[trigger_templates]]\nname = \"ev\"\nkind = \"kv\"\n\
script = \"shared\"\ncollection_glob = \"{app_slug}-data\"\n\
ops = [\"insert\"]\n";
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn trigger_template_fans_out_per_app_idempotently_then_prunes() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let group = common::unique_slug("ttpl-g");
let a = common::unique_slug("ttpl-a");
let b = common::unique_slug("ttpl-b");
let _g = GroupGuard::new(&env.url, &env.token, &group);
common::pic_as(&env)
.args(["groups", "create", &group])
.assert()
.success();
let _aa = AppGuard::new(&env.url, &env.token, &a);
let _ab = AppGuard::new(&env.url, &env.token, &b);
for slug in [&a, &b] {
common::pic_as(&env)
.args(["apps", "create", slug, "--group", &group])
.assert()
.success();
}
// group manifest: a `shared` script + a kv trigger template using {app_slug}.
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/shared.rhai"), r#""x""#).unwrap();
let write_group = |with_tmpl: bool| {
let tmpl = if with_tmpl { TRIGGER_TEMPLATE } else { "" };
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{group}\"\nname = \"TT\"\n\n\
[[scripts]]\nname = \"shared\"\nfile = \"scripts/shared.rhai\"\n\n{tmpl}"
),
)
.unwrap();
};
write_group(true);
for slug in [&a, &b] {
fs::create_dir_all(dir.path().join(slug)).unwrap();
fs::write(
dir.path().join(slug).join("picloud.toml"),
format!("[app]\nslug = \"{slug}\"\nname = \"App\"\n"),
)
.unwrap();
}
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
let _gs = ScriptGuard::new(&env.url, &env.token, &group_script_id(&env, &group));
let first = trigger_rows(&a, &b);
assert_eq!(
first.iter().map(|(g, _)| g.as_str()).collect::<Vec<_>>(),
vec![format!("{a}-data").as_str(), format!("{b}-data").as_str()],
"each app gets its own {{app_slug}}-resolved kv trigger"
);
// Idempotent re-apply: same rows, same ids.
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.assert()
.success();
assert_eq!(
trigger_rows(&a, &b),
first,
"re-apply must not churn triggers"
);
// Remove the template + --prune: trigger expansions reaped.
write_group(false);
common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.args(["--prune", "--yes"])
.assert()
.success();
assert!(
trigger_rows(&a, &b).is_empty(),
"removing the trigger template must reap its expansions"
);
}
fn group_script_id(env: &common::TestEnv, group: &str) -> String {
let ls = common::pic_as(env)
.args(["scripts", "ls", "--group", group])
.output()
.expect("scripts ls --group");
common::parse_first_id(std::str::from_utf8(&ls.stdout).unwrap())
.expect("group should have one script")
}

View File

@@ -1,123 +0,0 @@
//! M2 declarative tree SHAPE via `pic apply --dir`:
//! * a group manifest for a group that does NOT exist yet is CREATED under
//! its declared parent, with its content, in one atomic apply,
//! * re-applying the same tree is a no-op (the group now exists),
//! * changing the declared parent REPARENTS the group on the next apply.
use std::fs;
use tempfile::TempDir;
use crate::common;
use crate::common::cleanup::{GroupGuard, ScriptGuard};
/// Write a single-group project dir declaring group `slug` under `parent` with
/// one endpoint script.
fn group_dir(slug: &str, parent: &str) -> TempDir {
let dir = TempDir::new().expect("tempdir");
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hello.rhai"), r#""hi""#).unwrap();
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{slug}\"\nname = \"Child Group\"\nparent = \"{parent}\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
),
)
.unwrap();
dir
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[test]
fn tree_apply_creates_then_reparents_a_group() {
let Some(fx) = common::fixture_or_skip() else {
return;
};
let env = common::admin_env(fx);
let parent = common::unique_slug("ts-par");
let child = common::unique_slug("ts-child");
// Guards drop in reverse order: parent (last) ← child (mid) ← script (first).
let _gp = GroupGuard::new(&env.url, &env.token, &parent);
let _gc = GroupGuard::new(&env.url, &env.token, &child);
common::pic_as(&env)
.args(["groups", "create", &parent])
.assert()
.success();
// --- Create: the child group does NOT exist; apply --dir creates it. ---
let dir = group_dir(&child, &parent);
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir.path())
.output()
.expect("apply --dir");
assert!(
out.status.success(),
"tree apply (create) failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// The group now exists with its script.
let groups = ls(&env, &["groups", "ls"]);
assert!(groups.contains(&child), "created group missing:\n{groups}");
let scripts = ls(&env, &["scripts", "ls", "--group", &child]);
let script_id = scripts
.lines()
.map(common::cells)
.find(|c| c.get(2) == Some(&"hello"))
.and_then(|c| c.first().map(|s| (*s).to_string()))
.unwrap_or_else(|| panic!("group script `hello` should exist:\n{scripts}"));
let _gs = ScriptGuard::new(&env.url, &env.token, &script_id);
// Re-applying the same tree is a no-op (group + content unchanged).
let plan_out = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir.path())
.output()
.expect("plan --dir");
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
assert!(
!plan_txt.contains("create") && !plan_txt.contains("delete"),
"re-plan of an applied tree must be a no-op:\n{plan_txt}"
);
// --- Reparent: move the child under the instance root. Rewrite the SAME
// repo's manifest (M3: a stable `.picloud/` project key per repo — a fresh
// dir would be a different project and conflict on the owned group). ---
fs::write(
dir.path().join("picloud.toml"),
format!(
"[group]\nslug = \"{child}\"\nname = \"Child Group\"\nparent = \"root\"\n\n\
[[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n"
),
)
.unwrap();
let dir2 = &dir;
let out = common::pic_as(&env)
.args(["apply", "--dir"])
.arg(dir2.path())
.output()
.expect("apply --dir reparent");
assert!(
out.status.success(),
"tree apply (reparent) failed: {}",
String::from_utf8_lossy(&out.stderr)
);
// Re-plan is a no-op now that the parent matches.
let plan_out = common::pic_as(&env)
.args(["plan", "--dir"])
.arg(dir2.path())
.output()
.expect("plan --dir after reparent");
let plan_txt = String::from_utf8_lossy(&plan_out.stdout).to_lowercase();
assert!(
!plan_txt.contains("create") && !plan_txt.contains("delete"),
"re-plan after reparent must be a no-op:\n{plan_txt}"
);
}
fn ls(env: &common::TestEnv, args: &[&str]) -> String {
String::from_utf8(common::pic_as(env).args(args).output().expect("ls").stdout).unwrap()
}

View File

@@ -19,22 +19,23 @@ use picloud_manager_core::{
AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService,
AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher,
DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig,
FilesServiceImpl, FsFilesRepo, GroupMembersRepository, GroupRepository, GroupsState,
HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl,
OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository,
PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository,
PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo,
PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository,
PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo,
PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo,
PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupMembersRepository,
PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo,
PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo,
PostgresTriggerRepo, PostgresVarsRepo, PrincipalResolver, PubsubServiceImpl,
RealtimeAuthorityImpl, RepoResolver, RouteAdminState, RouteRepository, SandboxCeiling,
ScriptRepository, SecretsConfig, SecretsServiceImpl, SecretsState, SubscriberTokenConfig,
TopicRepo, TopicsState, TriggerConfig, TriggerRepo, TriggersState, UsersServiceConfig,
UsersServiceImpl, VarsApiState, VarsServiceImpl,
FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl,
GroupKvServiceImpl, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig,
HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter,
OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository,
PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository,
PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo,
PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo,
PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo,
PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository,
PostgresExecutionLogSink, PostgresGroupCollectionResolver, PostgresGroupDocsRepo,
PostgresGroupKvRepo, PostgresGroupMembersRepository, PostgresGroupRepository, PostgresKvRepo,
PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository,
PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo,
PrincipalResolver, PubsubServiceImpl, RealtimeAuthorityImpl, RepoResolver, RouteAdminState,
RouteRepository, SandboxCeiling, ScriptRepository, SecretsConfig, SecretsServiceImpl,
SecretsState, SubscriberTokenConfig, TopicRepo, TopicsState, TriggerConfig, TriggerRepo,
TriggersState, UsersServiceConfig, UsersServiceImpl, VarsApiState, VarsServiceImpl,
};
use picloud_orchestrator_core::realtime::DEFAULT_GC_INTERVAL_SECS;
use picloud_orchestrator_core::routing::{AppDomainTable, RouteTable};
@@ -158,6 +159,23 @@ pub async fn build_app(
events.clone(),
picloud_manager_core::kv_service::kv_max_value_bytes_from_env(),
));
// §11.6 shared group collections (KV): a separate store keyed by the owning
// group, resolved from cx.app_id's chain. Reuses the KV value-size cap.
let group_kv: Arc<dyn picloud_shared::GroupKvService> =
Arc::new(GroupKvServiceImpl::with_max_value_bytes(
Arc::new(PostgresGroupKvRepo::new(pool.clone())),
Arc::new(PostgresGroupCollectionResolver::new(pool.clone())),
authz.clone(),
picloud_manager_core::kv_service::kv_max_value_bytes_from_env(),
));
// §11.6 shared group collections (docs): group-keyed group_docs store.
let group_docs: Arc<dyn picloud_shared::GroupDocsService> =
Arc::new(GroupDocsServiceImpl::with_max_value_bytes(
Arc::new(PostgresGroupDocsRepo::new(pool.clone())),
Arc::new(PostgresGroupCollectionResolver::new(pool.clone())),
authz.clone(),
picloud_manager_core::docs_service::docs_max_value_bytes_from_env(),
));
let docs: Arc<dyn DocsService> = Arc::new(DocsServiceImpl::with_max_value_bytes(
docs_repo,
authz.clone(),
@@ -196,6 +214,16 @@ pub async fn build_app(
events.clone(),
files_max_size,
));
// §11.6 shared group collections (files): group-keyed blobs under
// `<root>/files/groups/<group_id>/...`, resolved from cx.app_id's chain.
// Shares the per-file size cap; no events (no app to attribute a trigger to).
let group_files: Arc<dyn picloud_shared::GroupFilesService> =
Arc::new(GroupFilesServiceImpl::new(
Arc::new(FsGroupFilesRepo::new(pool.clone(), files_root.clone())),
Arc::new(PostgresGroupCollectionResolver::new(pool.clone())),
authz.clone(),
files_max_size,
));
// v1.1.6 realtime: the in-process broadcaster is shared between the
// publish path (PubsubServiceImpl fans out to SSE subscribers after
// the durable outbox fan-out) and the SSE endpoint (subscribe side).
@@ -340,7 +368,10 @@ pub async fn build_app(
queue,
invoke,
vars,
);
)
.with_group_kv(group_kv)
.with_group_docs(group_docs)
.with_group_files(group_files);
// v1.1.9: keep the invoke depth bound aligned with the dispatcher's
// trigger-depth bound (same counter under the hood).
let engine_limits = Limits {

View File

@@ -0,0 +1,181 @@
//! `GroupDocsService` — the §11.6 shared group-collection DOCS contract.
//!
//! The cross-app-sharing counterpart to [`crate::DocsService`], analogous to
//! [`crate::GroupKvService`]. A script reaches it via the explicit
//! `docs::shared_collection("name")` handle. The owner of the data is not
//! `cx.app_id`: the impl resolves the **owning group** by walking the calling
//! app's ancestor chain for the nearest group that declares the collection
//! shared with `kind='docs'`. That ancestry walk is the isolation boundary —
//! the trait surface takes **no** group id (owner derivation stays in the impl).
//!
//! Trust model (§11.6): **reads are open** to any subtree script (anonymous
//! public HTTP included); **writes require an authenticated principal** with
//! editor+ on the owning group.
use async_trait::async_trait;
use thiserror::Error;
use crate::{DocId, DocRow, DocsListPage, SdkCallCx};
#[async_trait]
pub trait GroupDocsService: Send + Sync {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
data: serde_json::Value,
) -> Result<DocId, GroupDocsError>;
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: DocId,
) -> Result<Option<DocRow>, GroupDocsError>;
async fn find(
&self,
cx: &SdkCallCx,
collection: &str,
filter: serde_json::Value,
) -> Result<Vec<DocRow>, GroupDocsError>;
async fn find_one(
&self,
cx: &SdkCallCx,
collection: &str,
filter: serde_json::Value,
) -> Result<Option<DocRow>, GroupDocsError>;
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: DocId,
data: serde_json::Value,
) -> Result<(), GroupDocsError>;
async fn delete(
&self,
cx: &SdkCallCx,
collection: &str,
id: DocId,
) -> Result<bool, GroupDocsError>;
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<DocsListPage, GroupDocsError>;
}
/// Stub for test bundles that don't exercise shared docs collections.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopGroupDocsService;
#[async_trait]
impl GroupDocsService for NoopGroupDocsService {
async fn create(
&self,
_cx: &SdkCallCx,
_collection: &str,
_data: serde_json::Value,
) -> Result<DocId, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn get(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: DocId,
) -> Result<Option<DocRow>, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn find(
&self,
_cx: &SdkCallCx,
_collection: &str,
_filter: serde_json::Value,
) -> Result<Vec<DocRow>, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn find_one(
&self,
_cx: &SdkCallCx,
_collection: &str,
_filter: serde_json::Value,
) -> Result<Option<DocRow>, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn update(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: DocId,
_data: serde_json::Value,
) -> Result<(), GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn delete(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: DocId,
) -> Result<bool, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
async fn list(
&self,
_cx: &SdkCallCx,
_collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<DocsListPage, GroupDocsError> {
Err(GroupDocsError::Backend("group docs is not wired in".into()))
}
}
/// Failure modes surfaced to the Rhai bridge. Mirrors [`crate::DocsError`] plus
/// the §11.6 `CollectionNotShared` boundary.
#[derive(Debug, Error)]
pub enum GroupDocsError {
#[error("collection name must not be empty")]
InvalidCollection,
/// No group on the calling app's ancestor chain declares this collection
/// shared with `kind='docs'` — the structural "not shared with you"
/// boundary (also the outcome for a foreign app).
#[error("collection {0:?} is not a shared group docs collection for this app")]
CollectionNotShared(String),
#[error("document data must be a JSON object")]
InvalidData,
#[error("invalid filter: {0}")]
InvalidFilter(String),
#[error("unsupported operator: {0}")]
UnsupportedOperator(String),
#[error("document not found")]
NotFound,
/// For reads this is only raised when `cx.principal.is_some()`; for WRITES
/// it is also raised when the principal is absent (writes fail closed).
#[error("forbidden")]
Forbidden,
#[error("group docs: data too large ({actual} bytes; limit {limit})")]
ValueTooLarge { limit: usize, actual: usize },
#[error("group docs backend error: {0}")]
Backend(String),
}

View File

@@ -0,0 +1,203 @@
//! `GroupFilesService` — the §11.6 shared group-collection FILES contract.
//!
//! The cross-app-sharing counterpart to [`crate::FilesService`], analogous to
//! [`crate::GroupKvService`] / [`crate::GroupDocsService`]. A script reaches it
//! via the explicit `files::shared_collection("name")` handle. The owner of the
//! data is not `cx.app_id`: the impl resolves the **owning group** by walking the
//! calling app's ancestor chain for the nearest group that declares the
//! collection shared with `kind='files'`. That ancestry walk is the isolation
//! boundary — the trait surface takes **no** group id (owner derivation stays in
//! the impl).
//!
//! Trust model (§11.6): **reads are open** to any subtree script (anonymous
//! public HTTP included); **writes require an authenticated principal** with
//! editor+ on the owning group. Same shape as group KV/docs.
use async_trait::async_trait;
use thiserror::Error;
use uuid::Uuid;
use crate::{FileMeta, FileUpdate, FilesListPage, NewFile, SdkCallCx};
#[async_trait]
pub trait GroupFilesService: Send + Sync {
async fn create(
&self,
cx: &SdkCallCx,
collection: &str,
new: NewFile,
) -> Result<Uuid, GroupFilesError>;
async fn head(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<FileMeta>, GroupFilesError>;
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<Option<Vec<u8>>, GroupFilesError>;
async fn update(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
upd: FileUpdate,
) -> Result<(), GroupFilesError>;
async fn delete(
&self,
cx: &SdkCallCx,
collection: &str,
id: &str,
) -> Result<bool, GroupFilesError>;
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<FilesListPage, GroupFilesError>;
}
/// Stub for test bundles that don't exercise shared files collections.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopGroupFilesService;
#[async_trait]
impl GroupFilesService for NoopGroupFilesService {
async fn create(
&self,
_cx: &SdkCallCx,
_collection: &str,
_new: NewFile,
) -> Result<Uuid, GroupFilesError> {
Err(GroupFilesError::Backend(
"group files is not wired in".into(),
))
}
async fn head(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: &str,
) -> Result<Option<FileMeta>, GroupFilesError> {
Err(GroupFilesError::Backend(
"group files is not wired in".into(),
))
}
async fn get(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: &str,
) -> Result<Option<Vec<u8>>, GroupFilesError> {
Err(GroupFilesError::Backend(
"group files is not wired in".into(),
))
}
async fn update(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: &str,
_upd: FileUpdate,
) -> Result<(), GroupFilesError> {
Err(GroupFilesError::Backend(
"group files is not wired in".into(),
))
}
async fn delete(
&self,
_cx: &SdkCallCx,
_collection: &str,
_id: &str,
) -> Result<bool, GroupFilesError> {
Err(GroupFilesError::Backend(
"group files is not wired in".into(),
))
}
async fn list(
&self,
_cx: &SdkCallCx,
_collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<FilesListPage, GroupFilesError> {
Err(GroupFilesError::Backend(
"group files is not wired in".into(),
))
}
}
/// Failure modes surfaced to the Rhai bridge. Mirrors [`crate::FilesError`] plus
/// the §11.6 `CollectionNotShared` boundary.
#[derive(Debug, Error)]
pub enum GroupFilesError {
#[error("invalid collection name: {0}")]
InvalidCollection(String),
/// No group on the calling app's ancestor chain declares this collection
/// shared with `kind='files'` — the structural "not shared with you"
/// boundary (also the outcome for a foreign app).
#[error("collection {0:?} is not a shared group files collection for this app")]
CollectionNotShared(String),
#[error("missing required field: {0}")]
MissingField(&'static str),
#[error("file too large: {size} bytes exceeds limit of {limit} bytes")]
TooLarge { size: usize, limit: usize },
#[error("file name too long: {0} bytes exceeds 255")]
NameTooLong(usize),
#[error("content_type too long: {0} bytes exceeds 127")]
ContentTypeTooLong(usize),
#[error("file not found")]
NotFound,
#[error("file content corrupted (checksum mismatch)")]
Corrupted,
/// For reads this is only raised when `cx.principal.is_some()`; for WRITES
/// it is also raised when the principal is absent (writes fail closed).
#[error("forbidden")]
Forbidden,
#[error("group files backend error: {0}")]
Backend(String),
}
/// Map the shared [`crate::FilesError`] — returned by `validate_collection`
/// (re-exported as `validate_files_collection`) and the `NewFile`/`FileUpdate`
/// validators that the group-files service reuses — onto the group error. Lives
/// here (not in manager-core) so the orphan rule is satisfied.
impl From<crate::FilesError> for GroupFilesError {
fn from(e: crate::FilesError) -> Self {
use crate::FilesError as F;
match e {
F::InvalidCollection(c) => Self::InvalidCollection(c),
F::MissingField(f) => Self::MissingField(f),
F::TooLarge { size, limit } => Self::TooLarge { size, limit },
F::NameTooLong(n) => Self::NameTooLong(n),
F::ContentTypeTooLong(n) => Self::ContentTypeTooLong(n),
F::NotFound => Self::NotFound,
F::Corrupted => Self::Corrupted,
F::Forbidden => Self::Forbidden,
F::Backend(s) => Self::Backend(s),
}
}
}

View File

@@ -0,0 +1,141 @@
//! `GroupKvService` — the §11.6 shared group-collection KV contract.
//!
//! The cross-app-sharing counterpart to [`crate::KvService`]. A script reaches
//! it via the explicit `kv::shared_collection("name")` handle (distinct from the
//! app-private `kv::collection("name")`). Unlike `KvService`, the *owner* of
//! the data is not `cx.app_id`: the impl resolves the **owning group** by
//! walking the calling app's ancestor chain for the nearest group that declares
//! the collection group-shared. That ancestry walk is the isolation boundary —
//! a foreign app's chain never contains the owning group, so the name does not
//! resolve. The trait surface therefore takes **no** group id (same discipline
//! as `KvService` omitting `app_id`): owner derivation stays inside the impl.
//!
//! Trust model (§11.6): **reads are open** to any script in the subtree
//! (anonymous public HTTP included — the operator opted in by declaring the
//! collection); **writes require an authenticated principal** with editor+ on
//! the owning group.
use async_trait::async_trait;
use thiserror::Error;
use crate::{KvListPage, SdkCallCx};
#[async_trait]
pub trait GroupKvService: Send + Sync {
async fn get(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<Option<serde_json::Value>, GroupKvError>;
async fn set(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
value: serde_json::Value,
) -> Result<(), GroupKvError>;
async fn delete(
&self,
cx: &SdkCallCx,
collection: &str,
key: &str,
) -> Result<bool, GroupKvError>;
async fn has(&self, cx: &SdkCallCx, collection: &str, key: &str) -> Result<bool, GroupKvError>;
async fn list(
&self,
cx: &SdkCallCx,
collection: &str,
cursor: Option<&str>,
limit: u32,
) -> Result<KvListPage, GroupKvError>;
}
/// Stub for test bundles that don't exercise shared collections. Every call
/// errors so accidental use surfaces clearly.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopGroupKvService;
#[async_trait]
impl GroupKvService for NoopGroupKvService {
async fn get(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
) -> Result<Option<serde_json::Value>, GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
async fn set(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
_value: serde_json::Value,
) -> Result<(), GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
async fn delete(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
) -> Result<bool, GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
async fn has(
&self,
_cx: &SdkCallCx,
_collection: &str,
_key: &str,
) -> Result<bool, GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
async fn list(
&self,
_cx: &SdkCallCx,
_collection: &str,
_cursor: Option<&str>,
_limit: u32,
) -> Result<KvListPage, GroupKvError> {
Err(GroupKvError::Backend("group kv is not wired in".into()))
}
}
/// Failure modes surfaced to the Rhai bridge.
#[derive(Debug, Error)]
pub enum GroupKvError {
/// Empty collection name; rejected at the SDK boundary.
#[error("collection name must not be empty")]
InvalidCollection,
/// No group on the calling app's ancestor chain declares this collection
/// group-shared — the structural "not shared with you" boundary. Also the
/// outcome for a foreign app naming another subtree's collection.
#[error("collection {0:?} is not a shared group collection for this app")]
CollectionNotShared(String),
/// Caller lacked the required capability. For reads this is only raised
/// when `cx.principal.is_some()`; for WRITES it is also raised when the
/// principal is absent (writes fail closed — shared mutation always needs
/// an authenticated editor+ on the owning group).
#[error("forbidden")]
Forbidden,
/// JSON-encoded value exceeded the per-value `max_value_bytes` cap
/// (`PICLOUD_KV_MAX_VALUE_BYTES`).
#[error("group kv: value too large ({actual} bytes; limit {limit})")]
ValueTooLarge { limit: usize, actual: usize },
/// Anything else — Postgres unavailable, serialization failure, etc.
#[error("group kv backend error: {0}")]
Backend(String),
}

View File

@@ -16,6 +16,9 @@ pub mod exec_summary;
pub mod execution_log;
pub mod files;
pub mod group;
pub mod group_docs;
pub mod group_files;
pub mod group_kv;
pub mod http;
pub mod ids;
pub mod inbox;
@@ -65,6 +68,9 @@ pub use files::{
SAFE_RENDER_FALLBACK,
};
pub use group::Group;
pub use group_docs::{GroupDocsError, GroupDocsService, NoopGroupDocsService};
pub use group_files::{GroupFilesError, GroupFilesService, NoopGroupFilesService};
pub use group_kv::{GroupKvError, GroupKvService, NoopGroupKvService};
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
pub use ids::{
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,
@@ -77,7 +83,7 @@ pub use invoke::{InvokeError, InvokeService, InvokeTarget, NoopInvokeService, Re
pub use kv::{KvError, KvListPage, KvService, NoopKvService};
pub use log_sink::{ExecutionLogSink, LogSinkError};
pub use modules::{
ExtPointResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
ModuleResolution, ModuleScript, ModuleSource, ModuleSourceError, NoopModuleSource,
};
pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, OutboxWriterError};
pub use pubsub::{

View File

@@ -62,15 +62,17 @@ impl ModuleScript {
}
}
/// Outcome of an extension-point lookup (§5.5). Present when `name` is
/// declared an extension point on `origin`'s chain *and* is not shadowed by
/// a nearer concrete module of the same name — i.e. the nearest declaration
/// of `name` is the extension point, so resolution inverts to the inheriting
/// app. `default_script_id` is the optional fallback module body to use when
/// the app provides no module of `name`.
#[derive(Debug, Clone)]
pub struct ExtPointResolution {
pub default_script_id: Option<ScriptId>,
/// Outcome of [`ModuleSource::resolve_policy`] — the §5.5 lexical-vs-dynamic
/// decision the resolver acts on.
#[derive(Debug)]
pub enum ModuleResolution {
/// Bind this module (lexical, or an extension point's resolved provider).
Module(ModuleScript),
/// The name is an extension point, but no provider exists for the
/// inheriting app (no app override and no default body) — a hard error.
NoProvider,
/// No declaration of the name anywhere on the chain → module-not-found.
NotFound,
}
/// Lookup contract used by `PicloudModuleResolver`. `resolve` walks the
@@ -92,31 +94,27 @@ pub trait ModuleSource: Send + Sync {
name: &str,
) -> Result<Option<ModuleScript>, ModuleSourceError>;
/// Extension-point check (§5.5). Returns `Some` iff the **nearest**
/// declaration of `name` on `origin`'s chain is an extension point (not
/// a concrete module). On a tie in depth a concrete module wins (so a
/// nearer/peer real module shadows the extension-point declaration). The
/// `origin` is the trusted importer node, so the inversion can only be
/// opened by a declaration on the importer's own ancestry — a descendant
/// can never make a parent's sealed import dynamic.
async fn resolve_extension_point(
/// §5.5 extension-point-aware resolution. Decides lexical vs dynamic by
/// the **nearest declaration** of `name` walking up `origin`'s chain: a
/// concrete module → lexical (`resolve(origin, …)`); an extension-point
/// marker → dynamic, resolved against the **inheriting app**
/// (`resolve(App(inheriting_app), …)`) so the app can override, falling
/// back to the default body up-chain; the EP wins on a depth tie.
///
/// The default impl is lexical-only (no extension points) — the Postgres
/// source overrides it. Test fakes that don't exercise EPs inherit this.
async fn resolve_policy(
&self,
origin: ScriptOwner,
inheriting_app: AppId,
name: &str,
) -> Result<Option<ExtPointResolution>, ModuleSourceError>;
/// Load the extension-point fallback module `script_id`, but ONLY if it is
/// a `kind = 'module'` script reachable on `origin`'s chain. The `origin`
/// is the trusted importer node; constraining the lookup to its chain makes
/// the resolver self-defending — a `default_script_id` that (through a bug
/// or DB-direct write) pointed at another tenant's module resolves to
/// `None` rather than executing cross-tenant code. Returns `None` if the id
/// is absent, names an endpoint, or is off-chain.
async fn resolve_by_id(
&self,
origin: ScriptOwner,
script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError>;
) -> Result<ModuleResolution, ModuleSourceError> {
let _ = inheriting_app;
Ok(match self.resolve(origin, name).await? {
Some(m) => ModuleResolution::Module(m),
None => ModuleResolution::NotFound,
})
}
}
/// Failure modes surfaced from `ModuleSource::resolve`. "Not found" is
@@ -146,20 +144,4 @@ impl ModuleSource for NoopModuleSource {
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(None)
}
async fn resolve_extension_point(
&self,
_origin: ScriptOwner,
_name: &str,
) -> Result<Option<ExtPointResolution>, ModuleSourceError> {
Ok(None)
}
async fn resolve_by_id(
&self,
_origin: ScriptOwner,
_script_id: ScriptId,
) -> Result<Option<ModuleScript>, ModuleSourceError> {
Ok(None)
}
}

View File

@@ -20,12 +20,13 @@
use std::sync::Arc;
use crate::{
DeadLetterService, DocsService, EmailService, FilesService, HttpService, InvokeService,
KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService,
NoopEventEmitter, NoopFilesService, NoopHttpService, NoopInvokeService, NoopKvService,
NoopModuleSource, NoopPubsubService, NoopQueueService, NoopSecretsService, NoopUsersService,
NoopVarsService, PubsubService, QueueService, SecretsService, ServiceEventEmitter,
UsersService, VarsService,
DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService,
GroupFilesService, GroupKvService, HttpService, InvokeService, KvService, ModuleSource,
NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService,
NoopGroupDocsService, NoopGroupFilesService, NoopGroupKvService, NoopHttpService,
NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService,
NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService, QueueService,
SecretsService, ServiceEventEmitter, UsersService, VarsService,
};
/// SDK service bundle. See module docs for the lifecycle and the v1.1.x
@@ -114,6 +115,24 @@ pub struct Services {
/// (§3). Backed by `config_resolver` over Postgres in the picloud
/// binary; `NoopVarsService` in tests that don't read config.
pub vars: Arc<dyn VarsService>,
/// Shared cross-app group collections (§11.6). Scripts get
/// `kv::shared_collection(name)` — read/write KV scoped to the nearest ancestor
/// group that declares the collection shared. Backed by Postgres in the
/// picloud binary (wired via [`Services::with_group_kv`]); defaults to
/// `NoopGroupKvService` so test bundles that don't exercise sharing build
/// unchanged.
pub group_kv: Arc<dyn GroupKvService>,
/// Shared cross-app group DOCS collections (§11.6). Scripts get
/// `docs::shared_collection(name)`. Wired via [`Services::with_group_docs`];
/// defaults to `NoopGroupDocsService`.
pub group_docs: Arc<dyn GroupDocsService>,
/// Shared cross-app group FILES collections (§11.6). Scripts get
/// `files::shared_collection(name)`. Wired via [`Services::with_group_files`];
/// defaults to `NoopGroupFilesService`.
pub group_files: Arc<dyn GroupFilesService>,
}
impl Services {
@@ -153,9 +172,37 @@ impl Services {
queue,
invoke,
vars,
// §11.6 group collections default to noop; the picloud binary opts
// in via `with_group_kv`. Keeps the ~14 `Services::new` call sites
// (mostly tests) unchanged — a field addition, not a param.
group_kv: Arc::new(NoopGroupKvService),
group_docs: Arc::new(NoopGroupDocsService),
group_files: Arc::new(NoopGroupFilesService),
}
}
/// Set the §11.6 shared group-KV service (the picloud binary wires the
/// Postgres-backed impl; tests leave the noop default).
#[must_use]
pub fn with_group_kv(mut self, group_kv: Arc<dyn GroupKvService>) -> Self {
self.group_kv = group_kv;
self
}
/// Set the §11.6 shared group-DOCS service (picloud binary; tests leave noop).
#[must_use]
pub fn with_group_docs(mut self, group_docs: Arc<dyn GroupDocsService>) -> Self {
self.group_docs = group_docs;
self
}
/// Set the §11.6 shared group-FILES service (picloud binary; tests leave noop).
#[must_use]
pub fn with_group_files(mut self, group_files: Arc<dyn GroupFilesService>) -> Self {
self.group_files = group_files;
self
}
/// All-noop bundle for tests that build an `Engine` but don't
/// exercise the stateful services. Returns the same shape as
/// `Services::new` so callers can't accidentally rely on a stub

View File

@@ -375,54 +375,11 @@ Two distinct constraints:
identity token (collection / queue / topic; for cron/email/dead-letter fall back to `{kind}-{n}`),
sanitized to the kebab regex, with `-{n}` (discovery order) guaranteeing per-app uniqueness.
> **Ergonomic debt (being paid down):** because triggers/routes don't inherit, 100 tenant apps each
> needing the same declaration = 100× the declarations. The fix is group trigger/route **templates**
> that fan out per descendant (a *template/instantiation* mechanism, not inheritance).
>
> **Status: ✅ shipped — routes (M4a) AND triggers (M4b), 2026-06-28.** Migrations `0053_templates.sql`
> (`route_templates` + `routes.from_template`) and `0054_trigger_templates.sql` (`trigger_templates` +
> `triggers.from_template`). A `[group]` manifest declares `[[route_templates]]` and/or
> `[[trigger_templates]]` (the latter a flat block: `name`, `kind`, `script`, + kind params, covering
> all seven kinds); `pic apply --dir` reconciles the template rows
> (group-only — an app declaring one is a 422) and, in tree Phase B, **fans each template out into one
> concrete `routes`/`triggers` row per descendant app node** in the apply, resolving the script binding
> nearest-owner-wins and substituting an inert placeholder set — `{app_slug}`, `{env}` (from the
> apply's selected env), `{var:NAME}` (the app's effective var; an unknown var/placeholder is a hard
> error). For triggers the placeholders resolve in every string leaf of the spec, then the typed
> trigger is rebuilt and inserted through the normal path (email secrets are resolved + re-sealed per
> app — the value never travels in a manifest). Expansion is **idempotent via `from_template`** (routes
> diff by identity create/update-as-replace/delete; triggers key one-per-template-per-app and compare
> semantic identity, so re-apply doesn't churn and a removed/disabled template reaps its expansions
> under `--prune`); an expansion colliding with a hand-declared route/trigger, or two templates
> colliding, is a hard error. `plan` reports the **blast radius** (descendant app count) and the
> `state_token` folds each template, so an edit between plan and apply trips `StateMoved`. Authz:
> declaring a template needs `GroupScriptsWrite`; an app that *receives* expansions needs
> `AppWriteRoute` / `AppManageTriggers` (and `AppSecretsRead` for an email-trigger template, which
> seals the recipient's secret) — gated even when the app declares none of its own. Each RESOLVED
> expansion is validated with the same rules its hand-declared counterpart gets — routes:
> reserved-path rejection + structural path/host parse + host-claim; triggers: the per-kind shape
> check (cron schedule, queue timeout, …) — so a `{var:…}`-injected reserved path, unclaimed strict
> host, or malformed schedule/timeout can't slip in. **Scope:** expansion targets **every descendant app
> in the DB subtree** (M7, 2026-06-28), not only the app nodes present in the apply — a template change at
> group N fans out to `subtree(N)` across repos, and removing a template reaps its expansions on those
> descendants too (the apply locks each extra descendant in `app_id` order). The actor must hold the
> per-recipient write cap (`AppWriteRoute`/`AppManageTriggers`/`AppSecretsRead`) on each descendant; the
> hierarchy-aware `effective_app_role` makes a group admin/editor implicitly authorized on its subtree, so
> this is sound rather than an escalation, and a narrowly-scoped principal is refused (naming the app).
> `{var:NAME}` resolves **in-transaction** (M6, 2026-06-28) — a var set in the *same* apply is visible to
> the template immediately, so var + template converge in one apply. A
> trigger template whose only change is a non-identity field (e.g. dispatch_mode) is a no-op on
> re-apply, mirroring the existing trigger-identity limitation (recreate to change those).
>
> **Known limitations (both "one more apply", no data risk):** (1) Template fan-out resolves against a
> node's **committed** ancestry — `apply` computes app chains before the transaction's structural
> reconcile — so a template gained or lost by a **reparent performed in the *same* apply** takes effect
> on the next apply (the same shape as "reparenting into a freshly-created group needs a second apply").
> (2) The `{env}` placeholder resolves to the apply's `--env` for **declared** apps but to the app's own
> `apps.environment` column for **cross-repo descendants**; if a template uses `{env}` and those two
> disagree, that descendant's expansion can churn between applies (and a descendant with a NULL
> `environment` + an `{env}` template aborts the apply, naming the app). Set the descendant's environment,
> or avoid `{env}` in templates fanned across heterogeneous-env subtrees.
> **Ergonomic debt (accepted, watch it):** because triggers don't inherit, 100 tenant apps each
> needing the same 5 triggers = 500 declarations. The fix is group trigger/route **templates** that
> fan out per descendant (a *template/instantiation* mechanism, not inheritance) — deferred, but it
> bites early if tenant cardinality is high. Pressure-test against real tenant counts before
> committing to the narrow-inheritance choice (§5.1).
### 4.6 Secrets & `pull`
@@ -596,17 +553,20 @@ trust inversion). The rule:
> owner via `AST::set_source(encode(owner))` before `eval_ast_as_new` — the lexical chaining), and the
> cache is re-keyed by resolved `ScriptId`. Group modules + group-script imports are now allowed
> (`group_scripts_api`), and the single-node apply runs the §5.5 dangling-import `plan` check.
>
> **Extension points (✅ shipped, 2026-06-26).** The opt-in dynamic-resolution branch landed: migration
> `0051` adds an owner-polymorphic `extension_points` table (optional `default_script_id` fallback);
> `ModuleSource` gained `resolve_extension_point(origin, name)` (Some only when the NEAREST declaration
> of `name` on the importer's chain is an extension point — a peer/nearer concrete module shadows it)
> and a chain-constrained `resolve_by_id(origin, id)`. The resolver checks the importer's chain first
> and, on a hit, resolves against `App(cx.app_id)` (the inheriting app), falling back to the default
> body. The decision keys on the trusted importer `origin`, so only the declaring parent can open the
> inversion — a descendant can't make a parent's sealed import dynamic or hijack one. Declared via
> `[[extension_points]] name = "..." default = "<local module>"` (app or group manifests); reconciled
> like `vars`, gated on the script-write capability, exported by `pic pull`.
> **Resolved (extension points ✅) — §5.5 is complete.** Opt-in polymorphism shipped: an extension
> point is a **pure marker** `(owner, name)` (migration 0051 `extension_points`, owner-polymorphic +
> CASCADE — structurally a `secrets` name), authored declaratively as an `[app]`/`[group]` manifest key
> `extension_points = [...]` (a *node key*, so TOML can't mis-nest it); the optional **default body is a
> co-located `kind=module` script**. The resolver's `ModuleSource::resolve_policy(origin, app, name)`
> applies **nearest-declaration-kind-wins** over the importing node's chain (one query: min EP depth vs
> min module depth, EP wins a tie): a concrete module → lexical; an EP marker → **dynamic**, resolved
> against the inheriting app (its override, else the default body up-chain) — `NoProvider` is a hard
> error. Reconcile mirrors `secrets` (name-only diff/prune/state-token); the single-node apply adds the
> **no-provider `plan` check** (every EP visible to an app must have a provider); read-only
> `pic extension-points ls` + `pull` round-trip complete the surface. **Authoring is declarative only**
> (no imperative `add/rm`). Verified e2e: a group default resolves for an inheriting app, and the app
> can **override** it — the deliberate inverse of the Phase 4b sealed/lexical import.
### 5.6 Tree lifecycle: delete, reparent, rename
@@ -677,26 +637,6 @@ non-orphaning:
## 7. Ownership of shared nodes
> **Status: ✅ shipped (M3, 2026-06-26).** Migration `0052_owner_project.sql` adds a `projects(id, key
> UNIQUE)` table and promotes the inert `groups.owner_project` (0047) to a real FK. The CLI mints a
> stable, gitignored project key in `.picloud/project.json` (`pic init`, or lazily on first tree
> plan/apply) and presents it on every tree request. **First apply claims** each declared group for the
> project (NULL owner → stamped, in-tx, first-commit-wins under the per-node advisory lock). A second
> project applying an owned group is **refused** (`pic plan --dir` surfaces the conflict for CI;
> `pic apply --dir` returns 409) unless `pic apply --dir --takeover`, which is **GroupAdmin-gated** per
> contested node (ownership ⟂ RBAC: two independent gates, §7.4) and flips the owner. With ownership in
> place, `--prune` now also reaps **structural** nodes: a group this project owns that the manifest no
> longer declares is deleted **leaf-first** (RESTRICT-guarded, so a still-populated group aborts the
> apply rather than orphaning data) — scoped to project-owned nodes, so one repo never reaps another's
> or a UI-owned (unclaimed) node. The bound-plan token folds each declared group's owner key, so a
> claim/takeover by another repo between plan and apply trips `StateMoved`. **Attach-point ceiling**
> (§7.2) is implicit: a manifest only ever writes the nodes it declares; an ancestor parent is
> referenced read-only, never claimed or written. The GroupAdmin takeover gate is enforced **twice**:
> in `authz_tree` (pre-tx) and again **in-tx** at the ownership decision (so `--force`, which waives the
> staleness token, can't open a takeover-without-admin window). The attacker-supplied project key is
> length/charset-validated server-side before use. Journey coverage: claim → conflict → takeover → flip
> → prune, plus a non-admin `--takeover` → 403.
**Single-owner-per-node, ceilinged by the attach point.**
1. Each group node is owned by exactly one **project-root** (the repo that *manages* it — contains
@@ -1061,8 +1001,8 @@ Resolved items now live inline next to their topic. What genuinely remains:
> can't shadow it; verified e2e). Mechanism: owner-polymorphic `ModuleScript`, origin-rooted
> `ModuleSource::resolve`, `ExecRequest.script_owner` threaded from every dispatch + `invoke()` site,
> `_source`-driven lexical chaining in the resolver (cache re-keyed by `ScriptId`), and the
> single-node dangling-import `plan` check. **Opt-in extension points shipped (§5.5, 2026-06-26).**
> Still deferred: **invoke-by-id** staying app-scoped (inheritance is by-name only); CoW
> single-node dangling-import `plan` check. Still deferred: **opt-in extension points** (the only
> remaining §5.5 piece) and **invoke-by-id** staying app-scoped (inheritance is by-name only); CoW
> is **redefine-in-app + re-apply** (no live auto-rebinding). Sharp edge to track: `routes.script_id`
> is `ON DELETE CASCADE`, so deleting a
> group script removes descendant apps' bound routes (within group-editor authority; the *group*
@@ -1070,22 +1010,14 @@ Resolved items now live inline next to their topic. What genuinely remains:
5. **Project tool maps onto groups.** Nested manifests, attach point, single-owner, server-computed
tree plan, per-env approval gating.
> **Status (Phase 5): ✅ shipped — nested tree apply, tree shape, AND multi-repo ownership.** A
> directory tree of `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as
> ONE server-computed plan in ONE Postgres transaction. **Declarative group create/reparent shipped
> (M2, 2026-06-26)** — a manifest now owns the tree *shape*, not just node content. **Multi-repo
> single-owner / takeover + structural prune shipped (§7, M3, 2026-06-26)** — see §7 below.
> **Per-env approval-policy gating shipped (`[project.environments]`, M5, 2026-06-28)** — the root
> manifest declares which environments are confirm-required; `pic apply --dir --env <e>` to such an
> env needs an explicit `--approve <e>` (a blanket `--yes` does NOT cover it), the approval is
> admin-gated (the actor needs admin on every declared node, not just write) and audited, and the
> policy folds into the bound-plan token. The server re-derives the policy from the bundle — the CLI's
> check is convenience, the server is authoritative. Env overlays (`picloud.<env>.toml`) already exist.
> Gating is a **`--dir` (project-tree) feature**: single-node `pic apply --file --env <e>` cannot
> carry the admin-gated approval, so it **refuses** a confirm-required env and directs the user to
> `--dir` (rather than silently bypassing the gate). Like `--takeover`/blast-radius, the policy lives
> in the manifest (not persisted server-side), so whoever controls desired state can weaken it — an
> accepted trust boundary, not a privilege bug.
> **Status (Phase 5): ✅ shipped — single-repo nested tree apply, atomic.** A directory tree of
> `picloud.toml` manifests (each declaring an `[app]` or `[group]` node) applies as ONE
> server-computed plan in ONE Postgres transaction. Per the §11.1 review, the **multi-repo
> single-owner / attach-point / takeover** layer (§7) is **deferred** for the solo-dev / single-repo
> start, as is **per-env approval-policy gating** (`[project.environments]`); env overlays
> (`picloud.<env>.toml`) already exist. Groups must **pre-exist** (`pic groups create`) — a manifest
> owns each node's *content*, not the tree *shape* (declarative group create/reparent is a later
> add).
>
> Shipped surface:
> - **Engine:** the reconcile engine generalized from app-only to an `ApplyOwner { App | Group }`
@@ -1112,6 +1044,39 @@ Resolved items now live inline next to their topic. What genuinely remains:
real shared-scope authz model. Optionally, trigger/route **templates** (§4.5) if cardinality
demands.
> **Shipped — §11.6 KV + DOCS + FILES slices (full shared read/write).** A group declares a
> collection group-shared (`[group]` manifest `collections = [...]`, owner-polymorphic marker table
> `0052_group_collections` with a `kind` discriminator); the data lives in a per-kind store keyed by
> the owning `group_id` (NOT app — a shared row belongs to the group): `0053_group_kv_entries`
> (`kind='kv'`), `0054_group_docs` (`kind='docs'`, the queryable-JSON store), and `0055_group_files`
> (`kind='files'`, the blob store — metadata in Postgres, bytes on disk under
> `<root>/files/groups/<group_id>/...`, a `groups/` infix disjoint from the per-app `files/<app_id>/`
> subtree so the existing recursive orphan sweeper covers both). Scripts read/write via the
> **explicit** `kv::shared_collection("catalog")` / `docs::shared_collection("articles")` /
> `files::shared_collection("assets")` handles (distinct `GroupKvHandle`/`GroupDocsHandle`/
> `GroupFilesHandle`; `shared` alone is a Rhai reserved word, hence `shared_collection`). The service
> resolves the owning group from `cx.app_id`'s ancestor chain **filtered by kind**, nearest-group-wins
> — **that walk is the isolation boundary**: a foreign app's chain never contains the owning group, so
> the name returns `CollectionNotShared`; a `kv`, a `docs`, and a `files` collection of the same name
> are distinct stores. **Trust model:** reads are open to any subtree script (anonymous public HTTP
> included — the declaration *is* the grant); **writes require an authenticated editor+** on the
> owning group (`GroupKvWrite`/`GroupDocsWrite`/`GroupFilesWrite`, fail closed on an anonymous
> principal). The docs slice reuses the `docs_filter` DSL — `build_find_query` was generalized on its
> owner column (`docs`/`app_id` vs `group_docs`/`group_id`), both compile-time literals, so the find
> SQL has one source; the files slice likewise generalized the atomic-write + checksum-on-read path
> helpers on an owner-relative dir, so the security-sensitive disk mechanics have one source.
> Declarative authoring uses the **string-or-table** form: `collections = ["catalog", { name =
> "articles", kind = "docs" }, { name = "assets", kind = "files" }]` (bare string = `kv`). CASCADE on
> group delete; an app delete leaves the shared data. Live- + journey-validated for all three kinds
> (app A writes, app B reads/finds/gets the same bytes; a sibling-subtree app gets
> `CollectionNotShared`).
>
> **Deferred (documented gaps):** write-triggers/events on shared collections (the "group trigger
> has no app to watch" problem); **topics/queue** shared collections (trigger-centric, same gap);
> per-group total-size quotas + write-rate limits; CAS/`set_if`; an operator admin API for shared
> blobs (scripts use the SDK; `pic collections ls` shows the marker — matches KV/docs); app-declared
> collections. Multi-node tree-apply leans on the runtime backstop for no-op edges, as elsewhere.
### 11.1 Re-sequencing review (post-Phase-3)
A review after Phase 3 shipped surfaced three adjustments to the 4→6 plan; carried here as decisions
@@ -1133,10 +1098,10 @@ to take, not yet taken:
- *Trigger/route templates (§4.5):* bundled into Phase 6 as "much later," but the per-app binding
tax bites in Phase 5 at high tenant cardinality (100 tenants × 5 triggers = 500 declarations).
Decide against **actual** target tenant counts before defaulting it late.
- *Multi-repo ownership (§7):* ~~the single-owner / attach-point / takeover machinery is substantial
and only earns its keep with a **second** managing repo~~ — **shipped (M3, 2026-06-26)**; see the
§7 status note. Project-keyed single-owner claim, GroupAdmin-gated `--takeover`, and project-scoped
structural prune all landed.
- *Multi-repo ownership (§7):* the single-owner / attach-point / takeover machinery is substantial
and only earns its keep with a **second** managing repo. For a solo-dev / single-repo start, a
"one repo owns the whole subtree" model covers the near term; confirm the multi-repo need exists
before building the ownership layer.
---
@@ -1150,7 +1115,3 @@ to take, not yet taken:
§11.3. The remaining contract work here is only for script-body inheritance, Phase 4.)*
- The **full manifest schema** spelling every block (scripts, routes, the 8 trigger kinds, storage
config, env-scoped vars, secret-refs, domains, `[project.environments]` + confirm policy).
> `[project]` (M5, root manifest only) shipped: `[[project.environments]]` entries carry
> `name` + `confirm` (default `false`). A `confirm = true` env is gated — `pic apply --dir --env <name>`
> requires `--approve <name>` (admin-gated + audited; `--yes` is insufficient). The block is rejected on
> any non-root manifest. Remaining unspecified: storage config, domains, the full secret-ref shape.

View File

@@ -74,6 +74,44 @@ collection, id)` for docs. Collections are **mandatory**, not optional
— even single-collection apps name their collection. The service layer
rejects requests with empty collection names.
### Shared group collections (§11.6)
`kv::collection(name)` is **app-private** — scoped to `cx.app_id`, the
isolation boundary. A separate, explicit handle reaches a **group-shared**
KV collection that every app in a group's subtree reads (and an
authenticated editor+ writes):
```rhai
let cat = kv::shared_collection("catalog"); // resolves the nearest
cat.set("sku-1", #{ price: 9 }); // ancestor group that
let p = cat.get("sku-1"); // declares "catalog"
```
The name is deliberately distinct from `kv::collection` — a script opts
into shared scope explicitly (implicit shadowing would silently redirect a
write across apps). (`kv::shared` would be ideal but `shared` is a Rhai
reserved word, hence `shared_collection`.) The owning group is resolved
from `cx.app_id`'s ancestor chain — that walk is the isolation boundary;
an app outside the owning group's subtree gets a "not a shared collection"
error, never another subtree's data. A collection is made shared
declaratively (`[group]` manifest `collections = [...]`), never from a
script. **Reads** are open to any subtree script (including anonymous
public endpoints — declaring the collection *is* the grant); **writes**
require an authenticated principal with editor+ on the owning group.
**Docs and files too.** The same handle exists for the queryable-JSON store
and the blob store: `docs::shared_collection("articles")` returns a shared-docs
handle with the full `create`/`get`/`find`/`find_one`/`update`/`delete`/`list`
surface, and `files::shared_collection("assets")` returns a shared-files handle
with `create`/`head`/`get`/`update`/`delete`/`list` (Blob in/out) — both with
the same trust model + isolation boundary as the KV one. A `[group]` declares a
collection's store with a kind: `collections = ["catalog", { name =
"articles", kind = "docs" }, { name = "assets", kind = "files" }]` — a bare
string is `kv`, a `{ name, kind }` table sets it (`kv`/`docs`/`files`). A `kv`,
a `docs`, and a `files` collection of the same name are distinct stores; group
blobs live on disk under `<root>/files/groups/<group_id>/...`. (Topics/queue
shared collections are not yet implemented.)
## Error convention
- **Throw on failure.** `widgets.set("k", "v")` throws a Rhai runtime