A re-review of eaf5ace surfaced three follow-ups, all in the fixes themselves:
- pull vs an old server: a returned empty `definition` can only mean a server
predating the definition-in-list field (a real zero-step workflow is rejected
at apply). Emitting a stepless `[[workflows]]` block would just fail the next
apply, so `wire_workflow_to_manifest` now warns and skips it instead.
- base_ms/backoff defaults: the pull mapper keyed its default-omission off a
hardcoded `500` literal. `default_base_ms` is now `pub` + re-exported, and the
mapper omits whatever equals `default_base_ms()` / `WorkflowBackoff::default()`
— no drift if a default ever changes.
- dashboard: a run that succeeds with an `on_error = continue` step failure now
carries a partial-failure note in `run.error` (from the compute_advance
change); the run-detail view rendered it as a red failure, contradicting the
green "succeeded" badge. It now renders as a `.notice` (warning) for a
succeeded run, `.error` only for a failed one.
Verified: fmt + clippy -D warnings clean; 450 lib tests; 5 workflow CLI
journeys (incl. the pull→plan round-trip); dashboard npm run check clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A per-app "Workflows" tab: list definitions, browse run history, and inspect a
run's per-step progress with a static layered DAG.
- API: the run-detail endpoint now returns each step's `depends_on` (loaded
from the definition via a new `get_workflow_by_id` reader) so the dashboard
can draw the graph edges — the run's step rows don't carry them.
- dashboard `$lib/api`: a `workflows` namespace (list / runs / start / run) +
the matching types.
- `apps/[slug]/workflows/+page.svelte`: workflow table → drill into runs →
drill into a run. The run view renders a step table plus an SVG DAG (nodes =
steps colored by status, laid out by longest-path level; edges = depends_on,
arrowed) and a "Start run" button. Nested/parked steps show their child run.
- AppTabBar + the per-app layout gain the Workflows tab (admin-only).
Tests: `npm run check` clean (0 errors); two Playwright smoke tests
(navigation tabs — workflows loads cleanly + renders its empty state) pass
against the full stack.
With M6 the v1.2 Workflows track (M1 schema/validation → M2 durable
orchestrator → M3 when/templating → M4 nested → M5 SDK/API/CLI → M6 dashboard)
is COMPLETE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remediate the dashboard-coverage and editor findings from the 2026-07-11 audit.
Close the group-surface gap: a GroupTabBar plus read-only surfaces for the v1.2
group data plane the dashboard previously exposed only via the CLI — vars,
secrets (names + timestamps only, never values), shared collections (KV/docs/
files browsers), scripts, triggers, routes, suppressions, extension points,
dead-letters, and ownership. All server data renders through Svelte's escaped
interpolation (no `{@html}`); each tab hits its existing
`/api/v1/admin/groups/{id}/...` read endpoint with independent loading/empty
state.
B7 — the CodeEditor `readOnly` prop is now reactive via a CodeMirror
Compartment, fixing the race where an authorized user opening a script on a warm
SPA nav got a permanently read-only editor (role resolved after mount). The
reconfigure preserves editor content.
A viewer lacking a read cap now sees a calm "you don't have permission" note on
every tab (structured LoadError + classifyError) instead of a red error panel,
generalizing the one-off Secrets 403 handling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
- 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>
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>
Closes 4 audit findings:
- Route TS interface now carries dispatch_mode (sync|async). Backend's
shared::route::Route has had this since 0012_routes_dispatch_mode but
the TS client silently always created sync routes and the list
display dropped the field. Add a Dispatch select to the new-route
form and an "ASYNC" badge in the route list.
- api.files.downloadUrl pointed to a never-registered backend endpoint.
The dashboard's live Download button was hitting 405. Add the GET
handler (AppFilesRead + FilesRepo::head + FilesRepo::get, Content-
Disposition: inline) at the same path that delete already used.
- F-T-003: adminRequest's 401 handler called goto(login) without a
recursion cap. If the login endpoint itself returned 401, the wrapper
looped until browser nav limits. Track consecutive 401s within a 10s
window and hard-reload to /login?reason=auth-loop after the third,
showing the user an explanatory banner. Any 2xx resets the counter.
- Login flow now honors a ?returnTo= query parameter. adminRequest and
the root layout both append the current location when redirecting to
/login; the login page validates the value is a same-origin admin
path (no open-redirect) and goto's there after successful sign-in.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
The Queues tab at /admin/apps/default/queues was returning
"Cannot parse `app_id` with value `default`: UUID parsing failed".
27 admin endpoints across 6 files used strict `Path<AppId>` instead
of the canonical `Path<String>` + `resolve_app()` pattern from
app_repo.rs:34. Working endpoints (apps, app_members, users_admin)
all use the lenient pattern; this commit brings the remaining 6
files into line:
- queues_api.rs — 2 handlers
- files_api.rs — 2 handlers
- secrets_api.rs — 3 handlers
- topics_api.rs — 4 handlers
- dead_letters_api.rs — 5 handlers
- triggers_api.rs — 10 handlers
The handler bodies (authz, repo calls) are unchanged; only the path
extractor and the per-file `ensure_app_exists` helper (now renamed
`resolve_app`) move. Lib tests updated to pass `.to_string()` at
the call site (Path now takes String, not AppId).
email_inbound_api.rs deliberately stays strict-UUID — it's a public
webhook receiver consumed by external providers, not by the
slug-based dashboard.
Adds Playwright spec `dashboard/tests/e2e/navigation/tabs.spec.ts`
covering every per-app tab (queues, files, dead-letters, users,
invitations, plus the main page hosting triggers/secrets/topics)
with a negative assertion against the "Cannot parse" error text
plus a focused regression test for the original queues report.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ConfirmModal focused the first input on mount and listened for Escape,
but didn't trap Tab. Keyboard users could Tab out of the modal into
the underlying page — a strong accessibility violation and a confusing
keyboard-UX moment.
Add a handleTrapTab keydown handler on the dialog div that:
- Enumerates focusables (button:not([disabled]), input:not([disabled]),
select, textarea, a[href], [tabindex]:not(-1)).
- On Tab from the last focusable, wraps to the first.
- On Shift+Tab from the first, wraps to the last.
The dialog ref is bound via bind:this; the handler is no-op if the
dialog isn't mounted yet.
AUDIT.md anchor: F-U-016.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
The invite-user modal offered only admin/member radios. Owner could
only be granted by editing an existing user — asymmetric with
editRoleOptions which lets owners assign owner directly. The original
intention was preventing the first-owner footgun but the asymmetry
was confusing.
Show the Owner radio only when me?.instance_role === 'owner'. The
help text ("Owners can't be created here — promote via Edit") still
shows for non-owner admins so the friction is preserved where it
matters.
AUDIT.md anchor: F-U-018.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
scripts/[id]'s removeRoute used window.confirm('Delete this route?')
and surfaced errors via window.alert(). The rest of the dashboard had
adopted ConfirmModal for this exact case — the browser modal was
unstyled, couldn't show route detail, and the alert dead-end made
errors hard to recover from.
Rebuild around ConfirmModal:
- requestRemoveRoute(route) opens the modal carrying the full Route.
- The modal body shows `method path` plus `on host` if host-bound.
- A muted line explains the consequence ("Existing inbound requests
will 404 on this path immediately").
- Errors surface inline via removeRouteError instead of `alert()`.
AUDIT.md anchor: F-U-007.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
Five dashboard pages (dead-letters, files, users, invitations, queues)
reference CSS custom properties like var(--text-muted, #666),
var(--bg-secondary, #f5f5f5), var(--border, #e0e0e0),
var(--color-link) that the dashboard never defined — so users saw the
light-theme fallbacks: #666 text on #f5f5f5 backgrounds, borders that
disappeared, a white-on-red error banner. The dashboard otherwise
renders with the slate-900 dark palette.
Define a :root token set in routes/+layout.svelte using the slate
shades already used inline elsewhere. Per-page styles stop hitting
their fallbacks; the dark theme renders consistently everywhere.
No per-page CSS touched — the tokens cover every fallback already in
use (audited via grep across the five pages cited in the AUDIT.md).
Adds a few extra tokens (danger/success/warning) so future pages have a
single source of truth instead of inline hex.
`npm run check` shows no new errors (the pre-existing 149 errors in
tests/e2e/* from missing @playwright/test were documented out-of-scope
in the AUDIT.md methodology notes).
AUDIT.md anchor: F-U-004.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Workspace package version 1.1.7 -> 1.1.8.
shared::version::SDK_VERSION 1.8 -> 1.9 with the additive surface
note: users::* (CRUD, login/verify/logout, email verification,
password reset, invitations, string-tagged roles) added to the
Services bundle; topic auth_mode 'session' added server-side.
Dashboard package version 0.13.0 -> 0.14.0.
@picloud/client does NOT bump in v1.1.8 — the auth.login/logout
helpers it already ships call dev-defined endpoints; nothing to
change in the client.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
- Workspace `1.1.2` → `1.1.3` (`Cargo.toml`).
- Dashboard `0.8.0` → `0.9.0` (`package.json`).
- CHANGELOG: full v1.1.3 entry covering ScriptKind, ModuleSource,
PicloudModuleResolver, the two caches, dep-graph table, route +
trigger module rejection, the latent cross-app trigger gap that
this release closes, migrations 0015/0016, and downgrade caveats.
- Blueprint: mark the "Can scripts `import` Rhai modules?" question
as resolved; one-line pointer to the v1.1.3 semantics.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- `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>
Workspace package version 1.1.1 -> 1.1.2; dashboard 0.7.0 -> 0.8.0
(workspace alignment, no docs-specific UI yet); SDK_VERSION
1.2 -> 1.3 for the docs:: surface + ctx.event.docs additions.
CHANGELOG entry documents the docs store, the query DSL subset, the
docs:* trigger kind, the prev_data change-data-capture surface, and
the new AppDocsRead/AppDocsWrite capabilities. Includes a downgrade
caveat (v1.1.2 -> v1.1.1 with queued docs outbox rows would fail
TriggerEvent deserialization) and known-limitations notes for the
text-lex comparison gotcha and the concurrent-update prev_data race.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Workspace package version: 1.1.0 → 1.1.1 (patch under the
post-1.0 expansion-phase carve-out in docs/versioning.md)
- Rhai SDK version: 1.1 → 1.2 — minor bump, additive only.
New surfaces: kv::*, dead_letters::*, ctx.event.
- Dashboard package version: 0.6.0 → 0.7.0 for the dead-letters UI.
- HTTP API version stays at 1 (additive: trigger CRUD, dead-letter
admin endpoints, dispatch_mode field on routes).
- Schema version: 6 → 12 (migrations 0007–0012).
CHANGELOG.md created at the repo root following the convention from
prior bumps (release commits + design-notes references).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
CleanupRegistry's catch-all was masking every kind of teardown error,
not just the intended "resource already gone" 404. A backend returning
500 on delete would leak orphans run after run without ever surfacing.
Now treat 2xx and 404 as success, log any other status (and any
thrown network error) to stderr with the resource label, and keep
running the remaining items. The suite stays best-effort but no
longer hides accumulating leaks.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Date.now() can collide across workers running on the same millisecond
boundary. The worker-aware helper that the rest of the suite uses
side-steps that without changing the test's intent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Array.reverse mutates in place — a defensive double-run() would have
re-reversed the items. Iterate over a copy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cancels once to assert the modal can be dismissed without side
effects, then confirms to flip the user to inactive, then reactivates
to assert that direction remains one-click.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lifts loginAsUserToken + pageWithUserToken out of members.spec.ts into
fixtures/role-page.ts (third file that needs them). Adds shadowing
coverage: viewer member sees no New-app / Add-domain / Settings / Save
/ +Add-route, editor sees Save but no Delete header, and CodeMirror
renders contenteditable=false for viewers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wipes e2e-* apps and e2e* admin users before the suite starts so a
prior crashed run doesn't accumulate state across runs (45 rows
observed on 2026-05-28). Per-row try/catch keeps it best-effort; a
sweep failure never blocks the suite.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches to Uint8 rejection sampling against the largest multiple of
the charset length that fits in a byte. Eliminates the ~16 ppm
overweight the previous `% N` over Uint32 would otherwise leave on the
first 38 chars. Adds a vitest distribution check.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Deactivation signs the user out and expires every API key they hold —
warrants a styled confirm. Reactivation stays one-click since it's
non-destructive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces window.confirm + alert() with the in-dashboard ConfirmModal
(danger variant, name-retype). Body summarises what gets removed
(routes + execution logs) and embeds the API error inline rather than
firing a native alert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures my_role off the existing parent-app fetch (no extra HTTP call)
and uses canWriteApp / canAdminApp to hide: header Delete, Edit Save +
Format, Routing +Add route + per-row remove, and the Settings tab.
CodeEditor renders read-only for viewers. An effect bounces a stale
Settings tab back to Edit for non-admins.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>