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>
Threads readOnly through to EditorState.readOnly + EditorView.editable so
script-detail can render a viewer-only editor without intercepting
keystrokes upstream.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure-function module that mirrors crates/manager-core/src/authz.rs and
lets dashboard pages decide which create / edit / delete affordances to
render. Widens the vitest include so the truth-table test runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two scenarios that span the dashboard UI and the data/control plane
end-to-end:
- App + domain claim + script + route all created via the dashboard,
then the script is invoked through the public URL with the
matching Host header. Verifies the dashboard actions actually
reach the orchestrator's route trie.
- API key minted via the dashboard, then used as a bearer token
against /api/v1/admin/* (the CLI surface). Confirms the scope is
enforced (script:read passes /scripts, 403s /admins) and that
revoking via the dashboard immediately invalidates the token.
Also: the B7 copy-token test selected the mint-form Name input via
getByLabel('Name'), which became ambiguous once the integration
test created an app and the Binding dropdown was no longer empty.
Switched both B7 mint flows to placeholder-based selectors.
Suite: 57/57 passing in ~18s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues found while running the full B1–B8 suite together:
- The B1 logout test was driving the shared admin storageState
token, invalidating it for every subsequent test. Switched it to
a fresh login so its session is disposable.
- Bumped navigationTimeout to 30s and capped local workers at 4 to
cope with the Vite dev server's first-compile cost under
parallel load. Local also gets one retry to absorb intermittent
warmup flakiness.
- Cleared a few lint warnings (unused appId / _adminPage vars) and
belt-and-braces gitignore for playwright artifacts written to
the repo root when the CLI is invoked from there by accident.
Suite now: 55/55 passing in ~21s.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five tests covering platform-wide guarantees: expired-token
redirect, HttpOnly session cookie, bootstrap password not leaked
into the DOM after login, missing-app slug fails gracefully, and
an XSS-sink probe across the main authed routes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Six tests covering /admin/profile: mint instance-wide key with the
reveal/ack flow, the app-binding mutual-exclusion guard (instance
scopes auto-disabled), revoke via the ConfirmModal, the
?denied=users banner, plus adversarial cases (empty-name button
disabled, copy-token writes the full token to the clipboard).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four tests covering the Members tab: invite + remove (action-menu +
phrase modal), role change, the non-app-admin viewer who never sees
the Members tab at all (cross-context via a second admin login),
and an adversarial that the role dropdown only exposes the
documented set of values.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven tests covering the Routing tab inside the script editor: add
+ list + remove (handling the window.confirm dialog), match-preview
round trip, path-kind mismatch warning, unclaimed-host warning,
duplicate-route 409, plus reserved-prefix rejection and a path-XSS
adversarial that checks no script tag escapes into the route list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven tests covering script creation via the Scripts tab, the source
editor (CodeMirror typing + save + reload), Format-button error
surfaces for both Rhai and the test-invoke JSON body, the test-invoke
happy path, settings input validation, and an infinite-loop adversarial
that asserts the sandbox timeout reports cleanly and the editor stays
interactive.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Seven tests covering app CRUD via the dashboard: create with
slug auto-derive, settings rename, delete with phrase-confirmation
modal, historical-slug takeover via the create form, plus adversarial
inputs (slug normalization, XSS in name/description, oversized name).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight tests covering the login form, layout-level redirects, logout,
and the obvious adversarial inputs (XSS in username, empty submit,
password field type, leaked tokens). All targeted at /admin/login and
the bounce-back behaviors implemented in +layout.svelte.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Milestone A of the frontend test plan. Sets up the test rig — config,
globalSetup that probes the backend and seeds an admin session into
storageState, lightweight fixtures, and a 3-test smoke spec — without
yet covering any user journeys (those land in Milestone B).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>
GET /api/v1/admin/apps/{id_or_slug} now returns an `AppRole`-typed
`my_role` alongside the existing app fields, computed server-side from
the Principal: `Owner → app_admin` and `Admin → editor` (both
implicit per blueprint §11.6), `Member → app_members.role` (looked up
via the existing `AuthzRepo::membership` already in `AppsState`).
The dashboard uses this single field to decide whether to render
admin-only surfaces (Members tab, etc.) instead of duplicating the
implicit-grant rules on the client side — keeps API and UI gate logic
identical with one round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the inline row-action buttons on the Users page with the new
shared ActionMenu kebab. Drops the redundant `is_active` toggle from the
edit form (Activate/Deactivate already lives in the kebab).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The backend's ApiKeyDto.prefix is just the 8-char public head
(e.g. "PKXPCPH3"); the actual token the user pastes into their
CLI is "pic_PKXPCPH3…". Display the full visible identifier so
operators can match a row against the token in their notes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/admin/users is a strict superset of the pre-3.5 /admin/admins
page (adds role chip, email column, search, role-aware affordance
hiding, and the password-reveal flow), so the old page would only
split traffic and confuse muscle memory.
Also drops the AdminUserRecord type alias kept in place to ease
the transition.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/admin/users is the owner+admin surface for managing the platform's
user list. Members get bounced to /profile?denied=users.
Invite generates a random 16-char password client-side, POSTs the
new user, and surfaces the cleartext exactly once in a yellow-
bordered reveal modal with a Copy button and an "I've shared it"
acknowledgement gate. Owner role is intentionally not in the create
form — promote via Edit after creation, matching the backend's
deliberate-step comment.
Edit handles username, email, role (with affordance hiding: admins
see admin/member only), is_active toggle, and a separate "Reset
password" button that re-uses the same reveal flow. Delete uses
ConfirmModal with confirmPhrase=username and explains the
last-owner/last-admin 422s up front.
Username + email validated client-side against the same patterns
the backend enforces so the form fails fast rather than always
on the round-trip.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
/admin/profile is the per-principal page available to every
authenticated user (owner, admin, member). Shows the caller's
identity (username, role chip, email, id) plus a full API-key
list/mint/revoke surface.
Minting reveals the raw token exactly once in a yellow-bordered
panel with a Copy button and an "I've saved it" acknowledgement
gate before the Done button enables, matching the spec's one-shot
secret-display pattern.
Live mirrors the backend bound-key guard: picking an app from the
binding dropdown drops any instance:* scopes from the selection and
greys out their checkboxes with a tooltip, so submit never hits a
422 on that case.
Also surfaces a one-shot info banner when /admin/users redirects a
member here with ?denied=users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The header nav now shows a Users link only for owners/admins, and
the username block becomes a profile-link chip rendering the role
pill next to the name. Both react to the currentUser store, so they
update on login without an extra fetch.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends api.ts with the Phase 3.5 wire types (InstanceRole, Scope,
MeDto, AdminDto, ApiKeyDto, MintApiKey*) and the matching apiKeys
namespace. AdminUser in auth.ts now carries instance_role and email,
so layout/store consumers see the role without a separate fetch.
Adds two tiny lib helpers used by the upcoming profile/users pages:
RoleChip.svelte for the colored owner/admin/member pill, and
password-gen.ts for crypto.getRandomValues-backed temporary
passwords used in user-invite + reset-password reveals.
AdminUserRecord stays as a deprecated alias until /admins is
retired in a follow-up commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 3.5 ships → product minor bump under pre-1.0 rules (any surface
bump triggers minor). Schema is now 6 (0006_users_authz.sql); API
remains 1 (additive endpoints + new credential type, no breaking
shape changes). docs/versioning.md updated.
main.rs gets warn_on_multi_owner_install() which fires once after
bootstrap when more than one active owner exists — points the
operator at PATCH /admin/admins/{id} for cleanup. Soft-fail on DB
error (does not block startup).
The api-test schema assertion was updated to expect 6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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>