Commit Graph

30 Commits

Author SHA1 Message Date
MechaCat02
8725939172 feat(dashboard): group tree, detail page, and members tab
SvelteKit UI for Phase-2 groups, mirroring the apps/users patterns:
- api.ts: Group/GroupDetail/GroupMember types + an api.groups client
  (list/get/create/update/reparent/delete + nested members CRUD); optional
  group selector wired into app create.
- routes/groups: a collapsible tree overview (assembled from parent_id)
  with a New-group form, and a detail page with breadcrumb, subgroups/apps
  lists, a rename form (no slug field — slug is frozen), a reparent
  control, a delete button that surfaces the 409 RESTRICT message, and a
  Members tab reusing RoleChip/ConfirmModal/ActionMenu.
- +layout.svelte: a Groups nav entry beside Apps.

npm run check: 0 errors; npm run build: success.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 20:15:44 +02:00
MechaCat02
b0f7b72dd6 fix(review): close 5 follow-up gaps from Stage 6 audit re-review
- F-T-003 actually closed: clients/typescript/src/subscribe.ts now
  bounds the 401-refresh loop at 3 consecutive refusals, surfaces an
  onError describing the loop, and resets on any successful stream
  open. New test covers the cap. The Stage 2 dashboard fix only
  addressed adminRequest in dashboard/src/lib/api.ts; the originally-
  cited TS client file was untouched.

- users/invitations subtab dropped the redundant api.apps.get fetch
  the other 5 subtabs already shed in Stage 6.

- Queue visibility-timeout validator pulled out as
  validate_queue_visibility_timeout, with two thresholds: hard-reject
  below MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30s — catches typos), warn-
  log between MIN and SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS (300s) so
  operators see when their visibility is below the dispatcher's
  per-message executor budget. Stage 6 only had the hard floor; the
  reviewer caught that a 60s handler still races a 30s visibility
  even after the floor. Four new unit tests cover none/above-safe/
  between/below-min.

- pic dead-letters count subcommand: cheap headless probe for
  unresolved DL totals, parallels the dashboard's badge query.

- pic dead-letters replay now has a happy-path integration test
  (replay_against_real_dl_row_succeeds): inserts a synthetic DL row
  directly via the rust-postgres sync driver (added as dev-dep),
  drives `pic dead-letters replay`, asserts the row is resolved with
  reason=replayed and count drops back to 0. Plus a count smoke test.

All 75 CLI integration tests + 16 TS client tests + 4 new
visibility-timeout unit tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 17:20:34 +02:00
MechaCat02
05ed9b00bb fix(stage-6): dashboard hardening + audit Lows cherry-pick
Closes 4 dashboard hardening findings and 5 of the Lows from the audit.

Dashboard hardening:
- Subtabs no longer re-fetch the app via api.apps.get on every page
  load. users/files/dead-letters drop the fetch outright (the variable
  was set but never read); queues + queues/[name] now consume the
  layout's AppContext via getContext for the page title. The layout's
  reloadApp() owns the historical-slug redirect — subtab-local redirect
  blocks are removed so there's no race.
- The global :global(details > summary::before) chevron is now scoped
  to details.chevron. The script editor's "Advanced sandbox" details
  and the inbound-email-shape help-text both opt in; the script
  exec-list logs no longer inherit a spurious chevron.
- deriveTab now matches the path segment by anchored ===, so a future
  /apps/<slug>/queues-archived route wouldn't activate the queues tab.

Lows cherry-pick:
- ExecError gains Serialize/Deserialize derives + a snake_case tag so
  RemoteExecutorClient (cluster mode v1.3+) can round-trip the variant.
- triggers_api rejects queue triggers whose visibility_timeout_secs is
  below the dispatcher's per-message executor budget; with no minimum
  the reclaim task races the handler and the queue silently
  double-delivers. Existing test using 5s updated to 30s.
- New migration 0040: execution_logs.script_id cascade switched from
  ON DELETE CASCADE to ON DELETE SET NULL so deleting a script no
  longer wipes the forensic history that motivated the delete.
- New migration 0041: dead_letters composite index on
  (app_id, created_at DESC) so the "list all" dashboard view stops
  falling back to seqscan + sort when unresolved=false.
- Schema snapshot re-blessed.

Deferred to v1.2: the ExecRequest principal serde(skip) marker
(documented in-place; the cluster-mode PR will introduce the wire-safe
snapshot at that point) and the `pic --help` mention of
`picloud admin reset-password` (one-line follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-10 21:50:58 +02:00
MechaCat02
bce44769dd feat(dashboard): unified design system + persistent per-app tab bar
Addresses two interrelated UX complaints:

1. Browser-default controls leaking through the dark theme
   (native <select> chevrons, OS checkbox/radio look, date-picker
   icons, <details> triangle marker, number input spinners).
2. Subroute pages dropping the main app's tab bar in favor of a back
   link, breaking navigation continuity across users/files/queues/
   dead-letters/queues-[name].

Design tokens (dashboard/src/routes/+layout.svelte):

- Token vocabulary expanded with 18 new variables covering
  text-strong, accent + accent-fg, danger/success/warning bg/fg/border
  triplets, bg-elevated-hover, radii (sm/md/lg/pill), shadow-elev-2,
  and z scale (popover/modal/toast). 7 alias tokens (--muted,
  --link, --text, --color-error, --color-border, --chip-bg,
  --code-bg) absorb the orphan references the F-U-004 remediation
  partially renamed.
- Global :global(...) resets for <select>, <input type='checkbox'>,
  <input type='radio'>, <input type='number'>, <input type='date'>,
  and <details>/<summary> ensure native controls track the dark
  palette out of the box. No per-page edits needed.

Tab consistency:

- New dashboard/src/lib/AppTabBar.svelte renders all 11 per-app
  tabs (Scripts, Domains, Members, Triggers, Topics, Secrets,
  Settings, Users, Files, Queues, Dead letters) as <a> links with
  an active highlight derived from the URL. Tabs that switch
  in-page panels go to ?tab=<id>; tabs that switch routes go to
  the subroute. Admin-only tabs are hidden when canAdmin is false.
- New dashboard/src/routes/apps/[slug]/+layout.svelte loads the
  app once, handles the historical-slug redirect, exposes the
  shared app + canAdmin + canWrite + dead-letter-count state via
  Svelte context, and renders the breadcrumb + AppTabBar above
  every per-app page. The 5 subroute pages drop their own "← back"
  headers since the layout owns them now.
- apps/[slug]/+page.svelte's local-state activeTab becomes URL-
  driven via $page.url.searchParams.get('tab'). Defense-in-depth
  redirect for non-admin viewers landing on admin-only tabs uses
  goto({replaceState:true}) instead of mutating state.

Light-theme leftovers swept on 5 subroute pages:

- dead-letters: error banner, badge, pre/code blocks all swap to
  --color-danger-*, --bg-elevated, --text-primary
- files: button.danger, var(--muted,#666) → token-only
- queues + queues/[name]: bare hex fallbacks removed; .toolbar and
  .auto-refresh styled with tokens; data-testid for the queues
  empty state (already added by previous commit, reaffirmed here)
- users + users/invitations: badge-ok/badge-pending now use
  --color-success-bg/fg and --color-warning-bg/fg; chips use
  --bg-elevated + --text-strong; .create-form gets a token-styled
  surface; row-action buttons gain explicit dark-theme styling

E2E selector updates:

- members.spec.ts, integration.spec.ts, apps.spec.ts — the tab bar
  is now <a> elements (role=link) without a count suffix. Test
  selectors swap from getByRole('button', name: /^Scripts \(\d+\)$/)
  to getByRole('link', { name: 'Scripts' }), etc.
- New navigation/tabs.spec.ts still passes; existing 60+ tests
  unchanged except for the selector swap. Two pre-existing failures
  (routing.spec.ts:79, integration.spec.ts:89) untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 20:55:22 +02:00
MechaCat02
a1b7569d05 fix: post-review followups on slug-vs-UUID refactor
Addresses every finding from the four-agent review of commit aa493b9.

Dashboard — honor redirect_to on subtab loadApp() (closes the silent
historical-slug redirect UX gap):

  - queues/+page.svelte
  - queues/[name]/+page.svelte
  - files/+page.svelte
  - dead-letters/+page.svelte

  After a rename, the URL bar now reflects the canonical slug instead
  of silently rendering the renamed app's data under the stale URL.
  Mirrors the established pattern at apps/[slug]/+page.svelte:619-623.

manager-core:
  - queues_api.rs IntoResponse now uses the JSON envelope shape
    `{"error": "..."}` consistent with every sibling admin api file.
  - triggers_api::delete_trigger reordered: cap check fires BEFORE the
    trigger load, closing the 404-vs-403 existence side channel an
    unauthorized caller could otherwise probe.
  - InMemoryAppRepo mocks in topics_api + triggers_api now implement
    get_by_slug + get_by_slug_or_history (previously
    `unimplemented!()`), unblocking handler-level slug-input tests.
  - Added 4 slug-acceptance tests to topics_api and 2 to triggers_api
    (slug-resolves, unknown-slug-404, historical-slug-resolves,
    create-via-slug). Also added delete-without-cap-is-forbidden test
    pinning the new cap-first order.

e2e:
  - navigation/tabs.spec.ts split per-tab so a regression on one tab
    no longer masks regressions on the others.
  - Negative assertion widened: captures every /api/v1/admin/apps/*
    response and fails on any 4xx/5xx — not just the literal "Cannot
    parse" string. Catches a broader regression shape.
  - networkidle replaced with `expect(<main>).toBeVisible()` —
    networkidle is officially discouraged for SPAs and was at risk of
    timing out behind the queues auto-refresh.
  - Cleanup registration moved BEFORE the create-app API call so a
    flaky create still gets swept up.
  - Queue drilldown route /queues/[name] now covered.
  - Stable `data-testid="queues-empty-state"` replaces fragile
    UI-copy substring match for the positive assertion.
  - Header comment now spells out what this spec does and doesn't
    catch.

docs:
  - serverless_cloud_blueprint.md: slug-history described as
    "200 OK + redirect_to" JSON envelope rather than "301 redirect"
    — matches what apps_api actually implements (SPA can't honor a
    mid-tree HTTP redirect).

Unit-test gap (acknowledged): queues_api, files_api, secrets_api,
dead_letters_api have zero in-process tests. Adding them properly
needs a shared mock-repo helper crate — the standalone trait surface
(QueueRepo + TriggerRepo + ScriptRepository + AuthzRepo + repo-
specific) is ~30 methods per file. Documented inline in queues_api.rs
near the resolver. Integration coverage via crates/picloud/tests/ and
the new e2e spec cover the same paths end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-09 20:03:10 +02:00
MechaCat02
6d35eaad92 fix(dashboard): F-U-013 add Refresh + 5s auto-refresh toggle on queues overview
Queue depths change continuously; the page was a load-time snapshot
with no way to update without a hard refresh. Dead-letters has a
Refresh button; queues didn't.

Add:
- A Refresh button that re-fires loadQueues() (and shows "Refreshing…"
  while in-flight).
- An "Auto-refresh every 5s" checkbox. setInterval lifecycle is
  managed via onDestroy so navigating away cancels cleanly.

Queue-detail page is unchanged in this commit; same pattern can be
applied there in a follow-up.

AUDIT.md anchor: F-U-013 (overview list; detail page deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:08:28 +02:00
MechaCat02
6298c7d21c fix(dashboard): F-U-009 add download link + copy-id button per file row
Listing showed name/content-type/size/created/id; only mutation was
Delete. No download link, no copy-id button (the UUID was rendered
unclickable), no preview, no metadata refresh. Operators had to use
the admin API directly.

- Add api.files.downloadUrl(slug, collection, id) helper that builds
  the admin GET URL. The anchor uses HTML `download` so the browser
  saves with the original filename.
- Add a ⧉ copy-id button next to the UUID column using
  navigator.clipboard.writeText. Silently no-ops where the browser
  doesn't expose clipboard (plain-http LAN).
- Preview / metadata-refresh are deferred to a UI overhaul.

AUDIT.md anchor: F-U-009 (partial — download + copy; preview deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 21:07:36 +02:00
MechaCat02
37c21f0efd fix(dashboard): F-U-006 surface trigger.enabled with a "• disabled" pill
Trigger.enabled is a boolean on the DTO and the backend supports
disabled triggers, but the trigger list never showed the state.
Operators couldn't tell from the UI whether a trigger was paused.

Add a small "• disabled" pill next to the kind badge when
t.enabled === false, with a tooltip explaining the consequence. No
PATCH endpoint yet for enabled, so toggle UI is tracked separately
as a follow-up.

AUDIT.md anchor: F-U-006.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:59:20 +02:00
MechaCat02
86698afc24 fix(dashboard): F-U-008 + F-U-014 back-link to app slug, public_base_url for webhook
F-U-008: scripts/[id]'s "← Scripts" back-link used `base + '/'` which
the root page redirects to /apps. The breadcrumb already resolves
appSlug; switch the back-link to `{base}/apps/{appSlug}` (falling back
to `{base}/apps` when appSlug isn't loaded yet).

F-U-014: emailInboundUrl built the webhook URL from
window.location.origin — wrong when the admin browses via an internal
LAN address but the public webhook URL is on a different host.
VersionInfo.public_base_url already exists for exactly this case; the
apps/[slug] page now loads /version alongside the app fetch and
prefers public_base_url for the webhook URL display. Falls back to
window.location.origin if /version hasn't loaded.

AUDIT.md anchors: F-U-008, F-U-014.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:58:35 +02:00
MechaCat02
e9f1b835f8 fix(dashboard): F-S-014 warn on any permissiveness increase in topic edit modal
editFlipToExternal warned only on the internal → external transition.
Switching `token → public` or `session → public` while already external
is equally risky (opens topic to anonymous subscribers) but produced
no warning.

Replace with editPermissivenessChange that ranks the (external, auth_mode)
pair on a 0..3 scale and fires the warning whenever the resulting rank
strictly exceeds the current one:
  0 internal (any auth)
  1 external + session
  2 external + token
  3 external + public

Keep `editFlipToExternal` as an alias pointing at the new derived so
nothing downstream breaks.

AUDIT.md anchor: F-S-014.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:44:12 +02:00
MechaCat02
6d58178f68 fix(dashboard): F-U-003 surface known collection patterns on the Files page
The Files page could only list a collection if the operator already
knew its name and typed it in. No "browse known collections" affordance,
no backend endpoint listing collections.

Closest approximation without new backend: pull registered files-trigger
collection_globs from the existing triggers list and surface them as:
- a <datalist> on the collection input for autocomplete
- chip buttons under the form that one-click set the input

Empty state copy points the operator at the Triggers tab. Backend
endpoint to list known collections directly is still a v1.1.10+ task.

Styling uses the F-U-004 :root tokens so it inherits the dark theme.

AUDIT.md anchor: F-U-003 (frontend-only; backend endpoint deferred).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:34:58 +02:00
MechaCat02
99558e1987 fix(dashboard): F-U-001 add KV / Docs / Files / Dead-letter trigger create forms
Backend triggers_api.rs has long exposed POST /apps/{id}/triggers/{kv,
docs,files,dead_letter}; the dashboard Triggers tab shipped create
forms only for cron / pubsub / email / queue. Listing showed kv/docs/
files/dead_letter rows but operators couldn't create them from the UI.

This commit adds:
- 4 new CreateXxxTriggerInput types in api.ts.
- 4 new api.triggers.createXxx methods.
- 4 new submitCreateXxx functions in apps/[slug]/+page.svelte.
- 4 new <form class="create-form"> sections under the queue form.

KV / Docs / Files share the (script_id, collection_glob, ops[]) shape
with checkbox UI for the per-kind ops (insert/update/delete vs
create/update/delete). Dead-letter takes (script_id, source_filter,
trigger_id_filter, script_id_filter) — all but script_id optional, with
"leaving blank routes every dead-letter to this script" inline help.

`npm run check` clean (only pre-existing tests/e2e/* Playwright errors
remain, documented out-of-scope in the AUDIT.md methodology notes).

AUDIT.md anchor: F-U-001.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:33:33 +02:00
MechaCat02
c63c5cc275 fix(dashboard): F-U-002 queue drilldown consumer link points to scripts/{id}
The dashboard route tree has scripts at /scripts/[id], not under
apps/[slug]/scripts. The queue-drilldown page linked to a non-existent
app-scoped scripts route, so clicking the consumer-script name 404'd.

Reverts the link to {base}/scripts/{detail.consumer.script_id}, matching
how every other place in the dashboard navigates to a script page.

AUDIT.md anchor: F-U-002.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-07 20:03:18 +02:00
MechaCat02
529d34af83 feat(v1.1.9): dashboard Queues tab + queue:receive trigger form + 0.15.0
API client (src/lib/api.ts):
- TriggerKind gains 'queue'
- TriggerDetails gains { kind: 'queue', queue_name, visibility_timeout_secs, last_fired_at }
- CreateQueueTriggerInput, QueueSummary, QueueConsumer, QueueDetail
- api.triggers.createQueue(idOrSlug, input) -> POST /admin/.../triggers/queue
- api.queues.list(idOrSlug) -> GET /admin/.../queues
- api.queues.get(idOrSlug, name) -> GET /admin/.../queues/{name}

New routes:
- /apps/[slug]/queues — read-only list view (queue_name, total, pending,
  claimed, drilldown link); empty state explains how queues are created
  (first enqueue) and that consumers register via the Triggers tab
- /apps/[slug]/queues/[name] — drilldown showing depth + registered
  consumer (script name + visibility timeout + last_fired_at + trigger
  id); empty consumer state surfaces clearly

App detail page (src/routes/apps/[slug]/+page.svelte):
- New "Queues" link in the app-level nav between Files and Dead letters
- Triggers tab gains a "Queue:receive trigger (v1.1.9)" form alongside
  cron/pubsub/email — target script select, queue_name, visibility
  timeout (5–3600s, default 30), max_attempts (1–20, default 3)
- Trigger list renders queue triggers with queue_name + visibility
  timeout + last_fired_at

dashboard/package.json: 0.14.0 -> 0.15.0

npm run check — all queue/invoke-related code typechecks clean; the
existing Playwright test errors (149 unrelated) carry forward unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 22:41:59 +02:00
MechaCat02
ff4f443531 feat(v1.1.8): F3 realtime auth_mode = 'session' (migration 0033)
Migration 0033 widens topics.auth_mode CHECK to include 'session'
alongside 'public' and 'token'.

TopicAuthMode enum gains a Session variant (as_str + from_db
extended uniformly).

RealtimeAuthorityImpl now takes Arc<dyn UsersService> as a third
constructor arg. The Session branch of authorize_subscribe
delegates to UsersService::verify_session_for_realtime(app_id,
token):
  * Returns Some(user) → allow. The service bumps the sliding TTL
    on success.
  * Returns None → Unauthorized.
  * Defense-in-depth: even though verify_session_for_realtime
    already enforces cross-app isolation, the branch re-checks
    user.app_id == app_id.

Tests added (4 new cases): valid session token allows; missing
token is Unauthorized; wrong token is Unauthorized; cross-app
session token is Unauthorized. All 12 realtime_authority tests
pass.

Dashboard: TopicAuthMode TypeScript union widened to include
'session'; the topic create + edit forms gain a third radio option
labeled "session — requires a per-app user session minted by
users::login (v1.1.8)".

picloud binary: construction order reshuffled so users is built
before realtime_authority. app_secrets_repo is now .clone()'d into
the pubsub realtime wiring so the original Arc can be re-used by
realtime_authority.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 15:00:47 +02:00
MechaCat02
6449cb6f6a feat(v1.1.8): dashboard Users tab + Invitations sub-tab
apps/[slug]/users/+page.svelte: list, create form, edit modal,
revoke-sessions, reset-password (returns one-shot token in a copy
modal so the admin can paste it into a manual reset link), delete.

apps/[slug]/users/invitations/+page.svelte: pending list, create
form (with optional inline email template — off by default for
out-of-band delivery), revoke.

Tab strip in apps/[slug]/+page.svelte gets a Users entry above
Files, matching the external-route pattern files/ and dead-letters/
already use. Sub-tab navigation from Users -> Invitations and back.

api.ts gains AppUser / Invitation / ResetPasswordResponse /
CreateAppUserInput / PatchAppUserInput / InvitationTemplate /
CreateInvitationInput types and `users` + `appInvitations`
namespaces mirroring the appMembers shape.

svelte-check is green on every file under src/. The 150 errors
the runner reports are all pre-existing in tests/e2e/ (missing
@playwright/test install — unrelated to v1.1.8 changes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-06 14:54:20 +02:00
MechaCat02
1f78937dd2 feat(v1.1.7-email-inbound): webhook receiver + email:receive trigger
Inbound email: a provider POSTs a normalized JSON message to
POST /api/v1/email-inbound/{app_id}/{trigger_id}; the public receiver
verifies the optional HMAC signature, builds a TriggerEvent::Email, and
enqueues an outbox row the dispatcher delivers like any async trigger.
Handlers see ctx.event.email = #{from,to,cc,subject,text,html,
received_at,message_id}.

- migration 0024: widen triggers.kind + outbox.source_kind CHECKs to
  'email'; new email_trigger_details table.
- TriggerKind::Email, TriggerDetails::Email{has_inbound_secret},
  OutboxSourceKind::Email, TriggerEvent::Email; dispatcher routes the
  email row via the generic resolve_trigger path.
- Admin POST /apps/{id}/triggers/email (validate_trigger_target; module
  + cross-app rejection). inbound_secret is stored ENCRYPTED via the
  master key (deviation from the brief's plaintext default; decrypted
  per inbound request — see HANDBACK §7).
- Dashboard: email trigger form on the Triggers tab + webhook URL +
  expected-payload help.
- 8 DB-gated e2e tests (202/401/404/422/cross-app/handler-fire) +
  receiver unit tests (HMAC verify, secret round-trip, payload parse).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 22:24:35 +02:00
MechaCat02
2d11090d1a feat(v1.1.7-secrets): secrets SDK + table + admin API + dashboard
Encrypted per-app secrets, reachable from scripts as
secrets::{get,set,delete,list}(name) and managed from the dashboard
Secrets tab. Values are AES-256-GCM-sealed with the process master key
(picloud_shared::crypto) before they touch Postgres; the repo only ever
sees ciphertext + nonce. JSON round-trip preserves Rhai types.

- migration 0023_secrets.sql (PRIMARY KEY (app_id, name)).
- SecretsService trait (picloud-shared) + SecretsServiceImpl + repo
  (manager-core), wired into the Services bundle and Rhai engine.
- Capability::AppSecretsRead/Write (→ script:read / script:write); no
  new Scope variants (seven-scope commitment).
- Admin API GET/POST/DELETE /apps/{id}/secrets (list returns names +
  updated_at, never values).
- build_app now takes a MasterKey, sourced from PICLOUD_SECRET_KEY in
  main.rs; test callers pass a fixed test key.
- 64 KB value cap (PICLOUD_SECRET_MAX_VALUE_BYTES); no ServiceEvent
  emission (secret writes don't fire triggers, by design).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 21:37:17 +02:00
MechaCat02
fcbcc576a2 feat(v1.1.6): realtime channels + v1.1.5 follow-ups + version bumps
Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.

Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
  (-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
  plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
  401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
  (picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
  DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
  to in-process subscribers after the durable outbox commit (best-effort,
  panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
  + pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
  overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
  badge, flip confirmation).

v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).

Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:23:18 +02:00
MechaCat02
610fd4ffa2 feat(v1.1.3-modules): dashboard kind dropdown + scripts-list and detail badges
- `Script` type gains `kind: 'endpoint' | 'module'`. `CreateScriptInput`
  + `UpdateScriptInput` carry an optional `kind` field.
- App page's script-create form grows a kind dropdown next to Name +
  Description. Selecting "module" surfaces a hint that modules cannot
  bind to routes / triggers.
- Scripts list renders a small badge after the version: blue
  "endpoint" or purple "module".
- Script detail page renders the same badge next to the H1.

`npm run check` passes (0 errors, 0 warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 22:26:07 +02:00
MechaCat02
1795dfc98a feat(v1.1.1-dead-letters): dashboard badge + list view
Design notes §4 makes the dashboard surface load-bearing — with no
default DL handler, users wouldn't know dead letters exist
otherwise.

New route: `apps/[slug]/dead-letters/+page.svelte` — list view
columns per the design notes:
- `created_at`, `source`, `op`, `script_id`, `attempt_count`,
  `first/last_attempt_at`, `last_error` (truncated; clickable)
- per-row Replay + Mark resolved buttons
- expandable row detail panel showing full payload (JSON) +
  full last_error
- unresolved-only filter (default on); refresh button

Per-app detail page (`apps/[slug]/+page.svelte`) grows a "Dead
letters" link in the tabs nav, with a red unresolved-count pill
when > 0. Loaded in parallel with the existing app loaders so it
doesn't slow the page.

Apps list (`apps/+page.svelte`) shows the same red pill next to
each app's name when its unresolved count > 0. Counts fetched in
parallel after the apps list lands; failures here are non-fatal
(just no badge).

API client wiring: `api.deadLetters.{count,list,get,replay,resolve}`
mirrors the v1.1.1 admin endpoints. `DeadLetterRow` type added to
the dashboard's API shape declarations.

dashboard's svelte-check passes (369 files, 0 errors, 0 warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 22:21:20 +02:00
MechaCat02
d9c3d4d661 feat(dashboard): shadow apps + app-detail surfaces by role
Apps list: hide "New app" for members. App detail: hide New script for
viewers, Add domain + per-row Delete for non-admins, and the Members +
Settings tabs entirely for non-admins (with an effect that bounces a
stale activeTab back to Scripts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:31:56 +02:00
MechaCat02
a9fc838577 fix(dashboard): redirect after a member removes themselves
A member-with-app_admin who removes their own membership keeps a now-
broken Members tab open until reload — `myRole` is only computed once
in `loadApp`, and the next `/apps/{slug}` fetch would 403 anyway.

After the DELETE succeeds, if the removed user is the caller, navigate
back to /apps instead of refreshing the local member list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 22:00:13 +02:00
MechaCat02
816a13b920 feat(dashboard): Members tab on the app detail page
A new "Members" tab is rendered between Domains and Settings for
callers whose `my_role` on the app is `app_admin` (owners always;
explicit member-app_admins; admins do not see it — they're only
implicit editors and can't manage memberships).

The tab lets the caller:

- See every explicit member of the app with username, email, instance-
  role chip, app-role chip, and joined date. Inactive users render
  greyed-out so admins know the row exists.
- Pick a `member`-instance user from a dropdown and grant viewer /
  editor / app_admin access. The dropdown is populated from
  `/admin/admins` filtered to active members not already on the app.
- Promote / demote / remove existing members via the shared
  `ActionMenu` kebab. Removal goes through `ConfirmModal`.

Member-with-app_admin callers see a disabled add form with an
explanatory message — they have authority to manage memberships but
can't browse the user directory (gated on `InstanceManageUsers`),
which is a known phase-3.5 caveat to revisit in a follow-up.

Also extends `RoleChip` with an `appRole` prop and palette for app
roles, and adds an `appMembers` namespace to api.ts mirroring the
`domains` shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 21:37:36 +02:00
MechaCat02
a393f11344 feat(dashboard): auto-slug app names and infer route host kind from input
Two related polish passes on forms the operator hits most.

App create form: the slug field used to come before the name field and
demanded the operator hand-roll a valid slug. Now the name field comes
first and the slug is derived from it live, GitLab-style — Unicode
NFKD-decomposed, combining marks stripped (so `Café` → `cafe`), `ß`
mapped to `ss`, non-`[a-z0-9]` runs collapsed to `-`, trimmed and capped
at the backend's 63-char limit. The auto-sync releases as soon as the
operator edits the slug manually, and re-engages if they clear it. The
slug input itself runs every keystroke and paste through the same
normalizer, so dirty input never reaches the form state.

Route create form: the three-way host-kind `<select>` plus a sometimes-
disabled input was confusing — operators routinely picked the wrong
kind, typed a host the app didn't claim, and only saw the error after
hitting Create. Replace with a single text input that infers the kind
from what's there (`*` → any, `*.foo.com` → wildcard, `foo.com` →
strict), shows the detected kind as a colored chip beside the field, and
suggests the app's existing domain claims via a `<datalist>`. The same
matching logic the backend runs in `validate_route_host_against_app`
now lives in `route-utils.ts` so the form can surface a soft "not
covered by any claim" warning *before* submit. Path also pre-fills to
`/` so the most common case is one click away.

Lockfile drift from `npm install` (pre-existing 0.5.0 → 0.5.1 version
sync, npm metadata cleanup) is folded in here since it surfaced during
this work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:01:20 +02:00
MechaCat02
ad5492a4bd feat(manager-core,dashboard): cascading app delete with styled confirmation modal
Deleting an app used to require zero scripts and zero domain claims —
practical for empty apps, painful for anything else. Add an opt-in
cascade so the operator can wipe an app in one click while keeping the
safe default for the no-flag case.

Backend: `DELETE /api/v1/admin/apps/{id}?force=true` runs a single
transaction that removes every script in the app (routes and execution
logs cascade via `script_id` FK), then deletes the app row (domains and
slug-history cascade off it). Without `?force=true` the handler still
returns the same `409 HasScripts { script_count }` payload it always did.

Frontend: a new `ConfirmModal.svelte` replaces the bare `window.confirm`
on this page. It's reusable — danger/neutral variants, optional
GitHub-style "type the slug to confirm" gate, ESC/backdrop cancel,
busy state, and a generic body slot — so future destructive actions can
adopt the same pattern instead of growing more browser dialogs. The app
delete confirmation now spells out exactly what disappears (script
count, domain claim list, "all routes & logs") and only enables the red
button once the slug is retyped. The domain-claim delete is also
wired through the modal so this page no longer uses `window.confirm`
anywhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:01:05 +02:00
MechaCat02
4c41374db4 feat(manager-core,orchestrator-core): multi-app scoping (Phase 3b)
Apps become the isolation boundary for scripts, routes, domains, and
later data. Doing this now — while the surface is small — avoids
several migrations on populated tables once v1.1 data-plane services
ship.

Schema (migration 0005_apps.sql):
- New tables: apps, app_domains (with shape_key UNIQUE for collision
  detection), app_slug_history (for permanent slug-rename redirects).
- app_id added to scripts, routes, execution_logs (non-null, cascading
  rules per row).
- Script-name uniqueness becomes per-app; the route unique index is
  swapped for an app-scoped version.
- The "default" app is seeded unconditionally with a localhost claim;
  existing scripts/routes backfill into it. Fresh installs additionally
  get the Hello World seed via seed_hello_world_if_fresh after
  migrations run (idempotent — only fires when the default app has no
  scripts).

Orchestrator dispatch is two-phase: AppDomainTable resolves Host →
app_id (most-specific match wins, exact beats wildcard), then the
existing route matcher runs against that app's partitioned slice via
RouteTable. Unknown hosts return 404 at the app layer with a clear
message; /api/v1/execute/{id} still works as the implicit
__internal__ claim, decoupled from any public domain.

Manager API: full CRUD for /api/v1/admin/apps/* and
/api/v1/admin/apps/{id_or_slug}/domains/*, with slug:check + force
takeover semantics implementing the rename-history flow (two-step
check → confirm, never a single endpoint). Script create requires
app_id; list accepts ?app= filter. Route create validates host
against the parent app's claims; conflict detection stays strictly
intra-app.

Dashboard: /admin/apps and /admin/apps/{slug} (overview + scripts +
domains + settings tabs, with slug-history-aware redirects). Root
path redirects to the apps list. Script detail page gains an app
breadcrumb and threads app_id into the route preview.

Deferred per design: per-app admin roles. The require_admin middleware
remains the seam where role checks will slot in later.

Blueprint §11.5 and roadmap updated to reflect what shipped; docs/
versioning.md notes the schema 3 → 5 bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 21:03:05 +02:00