16 Commits

Author SHA1 Message Date
MechaCat02
b42e273479 fix(test): admin_is_implicit_app_admin uses force=true on app delete
The test creates a script in the default app earlier in the body, so a
plain DELETE /apps/default hits the soft no-cascade guard and 409s
before the capability check runs. The intent is to validate that admin
holds AppAdmin everywhere, not to exercise the cascade contract — pass
?force=true so we reach the gate we're trying to test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 20:21:38 +02:00
MechaCat02
f32ed73561 fix(e2e): surface cleanup HTTP failures instead of swallowing them
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>
2026-05-28 20:10:45 +02:00
MechaCat02
64799b73ff chore(docker): caddy restart unless-stopped
Other services in the prod overlay already have it. Without it, a
`docker compose stop caddy` followed by `docker compose up -d` doesn't
bring caddy back up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:41:51 +02:00
MechaCat02
beb3bcb97c fix(e2e): use uniqueUsername helper in integration.spec
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>
2026-05-28 19:41:47 +02:00
MechaCat02
79c8db2cb7 fix(e2e): non-mutating reverse in CleanupRegistry
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>
2026-05-28 19:41:42 +02:00
MechaCat02
f4cd883d76 test(e2e): drive the new deactivate confirm modal
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>
2026-05-28 19:40:52 +02:00
MechaCat02
b459b99fe9 test(e2e): role-shadowing specs in apps + scripts suites
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>
2026-05-28 19:40:09 +02:00
MechaCat02
f694a6d504 test(e2e): orphan sweep at globalSetup
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>
2026-05-28 19:37:57 +02:00
MechaCat02
70b66451d6 fix(dashboard): rejection-sample password-gen to remove modulo bias
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>
2026-05-28 19:37:18 +02:00
MechaCat02
c4fa53052d feat(dashboard): confirm modal for user deactivate
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>
2026-05-28 19:36:17 +02:00
MechaCat02
2f6840fe3e feat(dashboard): confirm modal for script delete
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>
2026-05-28 19:35:14 +02:00
MechaCat02
75c815d02a feat(dashboard): shadow script-detail surfaces by role
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>
2026-05-28 19:33:48 +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
bef4d34c43 feat(dashboard): CodeEditor readOnly prop
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>
2026-05-28 19:29:55 +02:00
MechaCat02
99a3ed1b6b feat(dashboard): capabilities helper for role-aware UI shadowing
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>
2026-05-28 19:28:45 +02:00
MechaCat02
4644ea4919 feat(manager-core): admin is implicit app_admin; delete-script needs AppAdmin
Aligns the canonical capability rules with how the dashboard now shadows
its UI. Instance admins become implicit app_admin on every app (only
InstanceManageSettings stays owner-only), and the script-delete handler
moves from AppWriteScript to AppAdmin so editors can save but not delete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 19:27:32 +02:00
24 changed files with 931 additions and 211 deletions

View File

@@ -270,10 +270,13 @@ async fn delete_script<R: ScriptRepository, L: ExecutionLogRepository>(
Path(id): Path<ScriptId>,
) -> Result<StatusCode, ApiError> {
let script = state.repo.get(id).await?.ok_or(ApiError::NotFound(id))?;
// Delete is gated tighter than Save: editors can edit scripts but
// only app_admin / instance admin / owner can remove them. See
// blueprint §11.6.
require(
state.authz.as_ref(),
&principal,
Capability::AppWriteScript(script.app_id),
Capability::AppAdmin(script.app_id),
)
.await?;
state.repo.delete(id).await?;

View File

@@ -143,8 +143,8 @@ pub struct AppLookupResponse {
pub redirect_to: Option<String>,
/// The caller's role on this app, used by the dashboard to decide
/// whether to render admin-only surfaces (Members tab, settings).
/// `Owner` maps to `app_admin`, `Admin` to `editor` (both implicit
/// per blueprint §11.6); `Member` carries its explicit
/// `Owner` and `Admin` both map to `app_admin` (implicit per
/// blueprint §11.6); `Member` carries its explicit
/// `app_members.role`.
pub my_role: Option<AppRole>,
}
@@ -226,16 +226,15 @@ async fn get_app(
/// Compute the caller's effective `AppRole` on a specific app. Mirrors
/// the implicit-grant logic in `authz::role_grants` but returns the
/// role itself (for UI gating) rather than a yes/no decision. `Owner`
/// is implicit `AppAdmin` everywhere; `Admin` is implicit `Editor`
/// everywhere; `Member` consults `app_members`.
/// and `Admin` are both implicit `AppAdmin` everywhere; `Member`
/// consults `app_members`.
async fn compute_my_role(
authz: &dyn AuthzRepo,
principal: &Principal,
app_id: AppId,
) -> Result<Option<AppRole>, AppsApiError> {
match principal.instance_role {
InstanceRole::Owner => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Admin => Ok(Some(AppRole::Editor)),
InstanceRole::Owner | InstanceRole::Admin => Ok(Some(AppRole::AppAdmin)),
InstanceRole::Member => Ok(authz.membership(principal.user_id, app_id).await?),
}
}

View File

@@ -199,21 +199,14 @@ async fn role_grants(
}
}
/// Admin is implicit `editor` on every app (per blueprint §11.6). They
/// can create apps and manage users, but NOT touch instance-wide
/// settings or take app-admin-only actions on apps they're not
/// explicitly app_admin of. Everything not in this set falls through
/// to deny (`InstanceManageSettings`, `AppManageDomains`, `AppAdmin`).
/// Admin is implicit `app_admin` on every app (per blueprint §11.6).
/// They can create apps, manage users, and take any app-scoped action
/// on any app without an explicit `app_members` row — single-human
/// installs would otherwise need to add themselves to every new app.
/// Only `InstanceManageSettings` (sandbox ceiling, etc.) stays
/// owner-only.
const fn admin_grants(cap: Capability) -> bool {
matches!(
cap,
Capability::InstanceCreateApp
| Capability::InstanceManageUsers
| Capability::AppRead(_)
| Capability::AppWriteScript(_)
| Capability::AppWriteRoute(_)
| Capability::AppLogRead(_)
)
!matches!(cap, Capability::InstanceManageSettings)
}
/// Member has zero instance authority. App authority requires an
@@ -357,10 +350,23 @@ mod tests {
}
#[tokio::test]
async fn admin_cannot_manage_instance_settings_or_app_admin_actions() {
async fn admin_cannot_manage_instance_settings() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn admin_is_implicit_app_admin_on_every_app() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Admin);
let app = AppId::new();
// Instance-scoped allowances.
assert_eq!(
can(&repo, &p, Capability::InstanceCreateApp).await.unwrap(),
Decision::Allow,
@@ -371,36 +377,22 @@ mod tests {
.unwrap(),
Decision::Allow,
);
// Editor-like + app-admin grants both succeed without any
// app_members row.
for cap in [
Capability::AppRead(app),
Capability::AppWriteScript(app),
Capability::AppWriteRoute(app),
Capability::AppLogRead(app),
Capability::AppManageDomains(app),
Capability::AppAdmin(app),
] {
assert_eq!(
can(&repo, &p, Capability::InstanceManageSettings)
.await
.unwrap(),
Decision::Deny,
);
// Editor-like grants succeed
assert_eq!(
can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap(),
can(&repo, &p, cap).await.unwrap(),
Decision::Allow,
"admin denied app-scoped capability {cap:?}"
);
assert_eq!(
can(&repo, &p, Capability::AppWriteRoute(app))
.await
.unwrap(),
Decision::Allow,
);
// App-admin grants do not
assert_eq!(
can(&repo, &p, Capability::AppManageDomains(app))
.await
.unwrap(),
Decision::Deny,
);
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
}
}
#[tokio::test]
@@ -474,6 +466,29 @@ mod tests {
);
}
/// Editors hold `AppWriteScript` (Save) but **not** `AppAdmin`
/// (Delete). The script-delete handler gates on the latter so the
/// API can't be tricked into letting an editor remove the script
/// they were only allowed to edit.
#[tokio::test]
async fn editor_can_write_scripts_but_not_delete_them() {
let repo = InMemoryAuthzRepo::default();
let p = principal(InstanceRole::Member);
let app = AppId::new();
repo.grant(p.user_id, app, AppRole::Editor).await;
assert!(can(&repo, &p, Capability::AppWriteScript(app))
.await
.unwrap()
.is_allow());
// Delete is gated on AppAdmin in the handler — editors must be
// denied here for that gate to bite.
assert_eq!(
can(&repo, &p, Capability::AppAdmin(app)).await.unwrap(),
Decision::Deny,
);
}
#[tokio::test]
async fn member_with_app_admin_role_can_do_app_admin_actions() {
let repo = InMemoryAuthzRepo::default();

View File

@@ -293,7 +293,7 @@ async fn owner_access_matrix(pool: PgPool) {
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
#[sqlx::test(migrations = "../manager-core/migrations")]
async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
async fn admin_is_implicit_app_admin_on_every_app(pool: PgPool) {
let s = boot(pool.clone()).await;
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
let token = login_token(&s.server, "alice", "alice-pw").await;
@@ -305,24 +305,34 @@ async fn admin_can_manage_users_but_not_app_admin_settings(pool: PgPool) {
.await
.assert_status_ok();
// Allowed: read default app (admin is implicit editor everywhere).
// Allowed: read default app admin is implicit app_admin
// everywhere (per blueprint §11.6).
s.server
.get("/api/v1/admin/apps/default")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status_ok();
// Allowed: write scripts (implicit editor).
// Allowed: write scripts.
let script = create_script_via_api(&s.server, &token, s.default_app, "admin-write").await;
assert!(script["id"].is_string());
// Denied: delete the default app (AppAdmin only).
let denied = s
.server
.delete("/api/v1/admin/apps/default")
// Allowed: list app members (AppAdmin gate). Pre-3.5.x this
// 403'd; now it's the same allow as the owner sees.
s.server
.get("/api/v1/admin/apps/default/members")
.add_header("authorization", format!("Bearer {token}"))
.await;
assert_eq!(denied.status_code(), axum::http::StatusCode::FORBIDDEN);
.await
.assert_status_ok();
// Allowed: delete the default app (AppAdmin). ?force=true because
// the script we created above pushes us past the soft no-cascade
// guard — this test is about the capability, not the cascade.
s.server
.delete("/api/v1/admin/apps/default?force=true")
.add_header("authorization", format!("Bearer {token}"))
.await
.assert_status(axum::http::StatusCode::NO_CONTENT);
}
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
@@ -735,7 +745,7 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
"owner reports app_admin"
);
// Admin → implicit editor everywhere.
// Admin → implicit app_admin everywhere (post-§11.6 update).
seed_user(&s.pool, "alice", "alice-pw", InstanceRole::Admin).await;
let admin_token = login_token(&s.server, "alice", "alice-pw").await;
let r = s
@@ -746,8 +756,8 @@ async fn my_role_field_matches_caller_role(pool: PgPool) {
r.assert_status_ok();
assert_eq!(
r.json::<Value>()["my_role"].as_str(),
Some("editor"),
"admin reports editor"
Some("app_admin"),
"admin reports app_admin"
);
// Member with explicit `viewer` membership → viewer.

View File

@@ -25,12 +25,18 @@
value = $bindable(''),
language = 'rhai' as Language,
placeholder = '',
minHeight = '12rem'
minHeight = '12rem',
readOnly = false
}: {
value?: string;
language?: Language;
placeholder?: string;
minHeight?: string;
/** When true the editor renders without a cursor and rejects
* keystrokes. Parent-driven `value` changes still apply via
* the dispatch path below — this only blocks user edits.
* Not reactive after mount; re-mount via `{#key}` if needed. */
readOnly?: boolean;
} = $props();
let host: HTMLDivElement | null = null;
@@ -48,6 +54,12 @@
keymap.of([indentWithTab]),
dashboardSyntaxHighlighting,
dashboardTheme,
// readOnly + editable together: readOnly blocks the
// underlying transactions, editable suppresses the caret
// + selection visuals so the user can see it's not
// editable.
EditorState.readOnly.of(readOnly),
EditorView.editable.of(!readOnly),
EditorView.updateListener.of((update) => {
if (update.docChanged && !pushingFromOutside) {
value = update.state.doc.toString();

View File

@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import type { AppRole, MeDto } from './api';
import { canAdminApp, canCreateApp, canManageUsers, canWriteApp } from './capabilities';
function me(role: MeDto['instance_role']): MeDto {
return { id: 'u', username: 'u', instance_role: role, email: null };
}
const ROLES: MeDto['instance_role'][] = ['owner', 'admin', 'member'];
const APP_ROLES: (AppRole | null)[] = ['app_admin', 'editor', 'viewer', null];
describe('capabilities', () => {
it('null caller is denied everything', () => {
expect(canCreateApp(null)).toBe(false);
expect(canManageUsers(null)).toBe(false);
expect(canWriteApp(null, 'app_admin')).toBe(false);
expect(canAdminApp(null, 'app_admin')).toBe(false);
});
it('canCreateApp + canManageUsers: owner/admin yes, member no', () => {
expect(canCreateApp(me('owner'))).toBe(true);
expect(canCreateApp(me('admin'))).toBe(true);
expect(canCreateApp(me('member'))).toBe(false);
expect(canManageUsers(me('owner'))).toBe(true);
expect(canManageUsers(me('admin'))).toBe(true);
expect(canManageUsers(me('member'))).toBe(false);
});
it('owner + admin can write and admin every app regardless of my_role', () => {
for (const role of ['owner', 'admin'] as const) {
for (const appRole of APP_ROLES) {
expect(canWriteApp(me(role), appRole)).toBe(true);
expect(canAdminApp(me(role), appRole)).toBe(true);
}
}
});
it('member: write requires app_admin or editor; admin requires app_admin', () => {
const m = me('member');
expect(canWriteApp(m, 'app_admin')).toBe(true);
expect(canWriteApp(m, 'editor')).toBe(true);
expect(canWriteApp(m, 'viewer')).toBe(false);
expect(canWriteApp(m, null)).toBe(false);
expect(canAdminApp(m, 'app_admin')).toBe(true);
expect(canAdminApp(m, 'editor')).toBe(false);
expect(canAdminApp(m, 'viewer')).toBe(false);
expect(canAdminApp(m, null)).toBe(false);
});
it('canAdminApp implies canWriteApp for every combination', () => {
for (const role of ROLES) {
for (const appRole of APP_ROLES) {
if (canAdminApp(me(role), appRole)) {
expect(canWriteApp(me(role), appRole)).toBe(true);
}
}
}
});
});

View File

@@ -0,0 +1,43 @@
// Permission predicates the dashboard uses to shadow create / edit /
// delete affordances. Mirrors the canonical role → capability rules in
// crates/manager-core/src/authz.rs:
//
// owner / admin instance role → implicit app_admin on every app
// app_admin → settings, domain claims, delete app, delete scripts
// editor → CRUD on scripts, routes, sandbox config (no script delete)
// viewer → read scripts + execution logs
// member with no membership → no access
//
// These helpers are read-only and have no Svelte runes — callers pass
// the current `MeDto` and (when relevant) the per-app `my_role` they
// already hold. Hiding here never authorizes anything; the backend's
// `require(Capability::…)` is always the ground truth.
import type { AppRole, MeDto } from './api';
/** Owner + admin only. Members never see "New app". */
export function canCreateApp(me: MeDto | null): boolean {
if (!me) return false;
return me.instance_role === 'owner' || me.instance_role === 'admin';
}
/** Owner + admin only — the "Users" admin page is also gated this way. */
export function canManageUsers(me: MeDto | null): boolean {
if (!me) return false;
return me.instance_role === 'owner' || me.instance_role === 'admin';
}
/** Can mutate scripts and routes (Save, +Add route, remove route). */
export function canWriteApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
if (!me) return false;
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
return appMyRole === 'app_admin' || appMyRole === 'editor';
}
/** Can take app-admin actions: app settings, domain claims, delete
* app, delete scripts, manage members. */
export function canAdminApp(me: MeDto | null, appMyRole: AppRole | null): boolean {
if (!me) return false;
if (me.instance_role === 'owner' || me.instance_role === 'admin') return true;
return appMyRole === 'app_admin';
}

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import { generatePassword } from './password-gen';
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
describe('generatePassword', () => {
it('rejects lengths under 8', () => {
expect(() => generatePassword(7)).toThrowError(/at least 8/);
});
it('respects the requested length', () => {
for (const len of [8, 16, 32, 64]) {
expect(generatePassword(len)).toHaveLength(len);
}
});
it('uses only characters from the documented charset', () => {
const set = new Set(CHARSET);
for (let i = 0; i < 1000; i++) {
for (const c of generatePassword(32)) {
expect(set.has(c)).toBe(true);
}
}
});
// Rejection-sampling sanity. With N = 71 the expected count per
// char over 100k samples is ~1408 (σ ≈ 37). A 6σ band catches
// any byte-level bias (biased modulo would push the first 38
// chars by ~16 ppm — too small for this band to flag on its
// own, but a regression to `% N` over Uint16/Uint32 with a
// non-power-of-two charset would still produce visible drift in
// pathological codepaths). Mostly this guards against
// fundamental mistakes (off-by-one in the loop, returning the
// same byte stream every time, etc.).
it('distribution stays within a wide tolerance band', () => {
const samples = 100_000;
const counts = new Map<string, number>();
for (let i = 0; i < samples; i++) {
const c = generatePassword(8)[0];
counts.set(c, (counts.get(c) ?? 0) + 1);
}
const expected = samples / CHARSET.length;
const sigma = Math.sqrt(expected);
const band = 6 * sigma;
for (const c of CHARSET) {
const observed = counts.get(c) ?? 0;
const drift = Math.abs(observed - expected);
expect(
drift,
`char "${c}": observed ${observed}, expected ~${Math.round(expected)} (drift ${drift.toFixed(0)} > ${band.toFixed(0)})`
).toBeLessThan(band);
}
});
});

View File

@@ -8,6 +8,11 @@
// entropy at 16 chars (~95 bits) to be uncopyable by hand mistakes,
// avoidant of characters that ship awkwardly through chat clients
// (no quotes, slashes, or backticks).
//
// Sampling: rejection sampling against a Uint8 stream. The naive
// `byte % CHARSET.length` would slightly overweight the first
// (256 mod N) chars; with N = 71 that's ~16 ppm of bias which is
// safe at 16 chars but easy to remove.
const CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&*+-?@';
@@ -15,11 +20,18 @@ export function generatePassword(length = 16): string {
if (length < 8) {
throw new Error('password length must be at least 8');
}
const buf = new Uint32Array(length);
crypto.getRandomValues(buf);
const n = CHARSET.length;
// Largest multiple of `n` that fits in a Uint8 — bytes ≥ MAX get
// rejected to remove modulo bias.
const max = 256 - (256 % n);
const buf = new Uint8Array(length);
let out = '';
for (let i = 0; i < length; i++) {
out += CHARSET[buf[i] % CHARSET.length];
while (out.length < length) {
crypto.getRandomValues(buf);
for (let i = 0; i < buf.length && out.length < length; i++) {
const byte = buf[i];
if (byte < max) out += CHARSET[byte % n];
}
}
return out;
}

View File

@@ -2,6 +2,11 @@
import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
import { canCreateApp } from '$lib/capabilities';
import { currentUser } from '$lib/auth';
const me = $derived($currentUser);
const canCreate = $derived(canCreateApp(me));
let apps = $state<App[] | null>(null);
let listError = $state<string | null>(null);
@@ -99,6 +104,7 @@
<section>
<header class="page-header">
<h1>Apps</h1>
{#if canCreate}
<button
type="button"
onclick={() => {
@@ -108,9 +114,10 @@
>
{showCreate ? 'Cancel' : 'New app'}
</button>
{/if}
</header>
{#if showCreate}
{#if showCreate && canCreate}
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
<div class="row">
<label>

View File

@@ -17,6 +17,7 @@
import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
const me = $derived($currentUser);
@@ -36,7 +37,12 @@
let domains = $state<AppDomain[]>([]);
let members = $state<AppMemberDto[]>([]);
const canAdminMembers = $derived(myRole === 'app_admin');
// Derive UI gates from the capabilities helper so the rules stay
// in lockstep with the backend's `can()`. canAdminApp also covers
// the Members + Settings + Domains-mutation tabs; canWriteApp
// covers New script.
const canWrite = $derived(canWriteApp(me, myRole));
const canAdmin = $derived(canAdminApp(me, myRole));
// Script create
let showCreateScript = $state(false);
@@ -102,7 +108,7 @@
editDescription = app.description ?? '';
editSlug = app.slug;
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
if (canAdminMembers) {
if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers());
}
await Promise.all(loaders);
@@ -362,6 +368,16 @@
$effect(() => {
void loadApp();
});
// Defense-in-depth: a viewer / editor following a stale link to
// the Settings or Members tab gets bounced back to Scripts. The
// backend still 403s the underlying calls, but no point showing an
// empty tab.
$effect(() => {
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) {
activeTab = 'scripts';
}
});
</script>
{#if loading && !app}
@@ -394,33 +410,35 @@
class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
>
{#if canAdminMembers}
{#if canAdmin}
<button
type="button"
class:active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
>
{/if}
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
{/if}
</nav>
{#if activeTab === 'scripts'}
<section>
<div class="row">
<h2>Scripts</h2>
{#if canWrite}
<button
type="button"
onclick={() => (showCreateScript = !showCreateScript)}
>
{showCreateScript ? 'Cancel' : 'New script'}
</button>
{/if}
</div>
{#if showCreateScript}
{#if showCreateScript && canWrite}
<form class="create-form" onsubmit={submitCreateScript}>
<div class="row">
<label>
@@ -473,6 +491,7 @@
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
</p>
{#if canAdmin}
<form class="create-form inline" onsubmit={submitCreateDomain}>
<input
bind:value={createDomainPattern}
@@ -486,6 +505,7 @@
{#if createDomainError}
<div class="error">{createDomainError}</div>
{/if}
{/if}
{#if domains.length === 0}
<p class="muted">No domain claims yet.</p>
{:else}
@@ -496,6 +516,7 @@
<code>{d.pattern}</code>
<span class="muted">{d.shape}</span>
</div>
{#if canAdmin}
<button
type="button"
class="secondary danger"
@@ -503,12 +524,13 @@
>
Delete
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'members' && canAdminMembers}
{:else if activeTab === 'members' && canAdmin}
<section>
<h2>Members</h2>
<p class="muted">
@@ -623,7 +645,7 @@
</div>
{/if}
</section>
{:else if activeTab === 'settings'}
{:else if activeTab === 'settings' && canAdmin}
<section>
<h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}>

View File

@@ -6,12 +6,15 @@
api,
ApiError,
type AppDomain,
type AppRole,
type ExecutionLog,
type Route,
type RouteInput,
type Script,
type VersionInfo
} from '$lib/api';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
import { logLevelColor, statusColor } from '$lib/styles';
import {
checkHostAgainstClaims,
@@ -21,6 +24,7 @@
pathKindMismatchWarning
} from '$lib/route-utils';
import CodeEditor from '$lib/CodeEditor.svelte';
import ConfirmModal from '$lib/ConfirmModal.svelte';
import { format as formatRhai } from '$lib/rhai';
/// Pretty-print a JSON string in place, leaving it untouched if the
@@ -47,6 +51,11 @@
let appSlug = $state<string | null>(null);
let appDomains = $state<AppDomain[]>([]);
let appMyRole = $state<AppRole | null>(null);
const me = $derived($currentUser);
const canWrite = $derived(canWriteApp(me, appMyRole));
const canAdmin = $derived(canAdminApp(me, appMyRole));
async function loadScript() {
scriptLoading = true;
@@ -58,15 +67,16 @@
editableDescription = script.description ?? '';
editableTimeout = script.timeout_seconds;
editableSandbox = { ...(script.sandbox ?? {}) };
// Resolve the owning app's slug for the breadcrumb and its
// domain claims for the route form's suggestions + live
// validation. Both are non-fatal — the page works without
// them.
// Resolve the owning app for the breadcrumb (slug),
// route-form host suggestions (domain claims), and UI
// shadowing (my_role on this app). All non-fatal — the
// page renders without them, just with reduced fidelity.
const appId = script.app_id;
void api.apps
.get(appId)
.then((a) => {
appSlug = a.slug;
appMyRole = a.my_role ?? null;
})
.catch(() => {});
void api.domains
@@ -366,16 +376,25 @@
}
// ---------------- deletion ----------------
let confirmingDelete = $state(false);
let deleting = $state(false);
async function remove() {
let deleteError = $state<string | null>(null);
function askDelete() {
deleteError = null;
confirmingDelete = true;
}
async function confirmDelete() {
if (!script) return;
if (!confirm(`Delete script "${script.name}"? This cannot be undone.`)) return;
deleting = true;
deleteError = null;
try {
await api.scripts.remove(id);
await goto(base + '/');
} catch (e) {
alert(e instanceof Error ? e.message : String(e));
deleteError = e instanceof Error ? e.message : String(e);
} finally {
deleting = false;
}
}
@@ -386,6 +405,15 @@
void loadRoutes();
void loadLogs();
});
// Defense-in-depth: anyone non-admin who lands on the Settings
// tab via a stale link gets bounced back to Edit. The tab button
// itself is also hidden.
$effect(() => {
if (!canAdmin && tab === 'settings') {
tab = 'edit';
}
});
</script>
<section>
@@ -410,9 +438,11 @@
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
</p>
</div>
<button type="button" class="danger" onclick={remove} disabled={deleting}>
{#if canAdmin}
<button type="button" class="danger" onclick={askDelete} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</button>
{/if}
</header>
<nav class="tabs">
@@ -423,7 +453,9 @@
<span class="badge-count">{routes.length}</span>
{/if}
</button>
{#if canAdmin}
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
{/if}
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
Executions
</button>
@@ -435,17 +467,25 @@
<section class="card">
<header class="editor-header">
<h2>Source</h2>
{#if canWrite}
<button type="button" class="ghost small" onclick={formatRhaiSource}>
Format
</button>
{/if}
</header>
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
<CodeEditor
bind:value={editableSource}
language="rhai"
minHeight="22rem"
readOnly={!canWrite}
/>
{#if rhaiFormatError}
<div class="error inline">{rhaiFormatError}</div>
{/if}
{#if saveSourceError}
<div class="error inline">{saveSourceError}</div>
{/if}
{#if canWrite}
<div class="actions">
<button
type="button"
@@ -455,6 +495,7 @@
{savingSource ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
<section class="card">
@@ -510,12 +551,14 @@
<section class="card wide">
<header class="card-header">
<h2>Routes</h2>
{#if canWrite}
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
{showAddRoute ? 'Cancel' : '+ Add route'}
</button>
{/if}
</header>
{#if showAddRoute}
{#if showAddRoute && canWrite}
<form class="route-form" onsubmit={submitRoute}>
<label class="full">
<span>Path</span>
@@ -626,9 +669,11 @@
: r.host}
</span>
<span class="path">{r.path}</span>
{#if canWrite}
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
remove
</button>
{/if}
</div>
{#if info}
<div class="route-url muted">{fullUrlForRoute(r)}</div>
@@ -670,7 +715,7 @@
</section>
<!-- ===================================================== SETTINGS ===== -->
{:else if tab === 'settings'}
{:else if tab === 'settings' && canAdmin}
<section class="card wide">
<h2>General</h2>
<label>
@@ -786,6 +831,35 @@
{/if}
</section>
{/if}
{#if confirmingDelete && script}
<ConfirmModal
title="Delete script “{script.name}”"
variant="danger"
confirmLabel="Delete script"
busyLabel="Deleting…"
confirmPhrase={script.name}
confirmPhrasePrompt="Type the script name to confirm:"
busy={deleting}
onConfirm={confirmDelete}
onCancel={() => (confirmingDelete = false)}
>
<p>
This will <strong>permanently delete</strong>
<strong>{script.name}</strong>, all its routes, and all its
execution logs. There is no undo.
</p>
{#if routes.length > 0}
<p class="muted">
{routes.length} route{routes.length === 1 ? '' : 's'} bound to
this script will be removed.
</p>
{/if}
{#if deleteError}
<p class="modal-error">{deleteError}</p>
{/if}
</ConfirmModal>
{/if}
{/if}
</section>

View File

@@ -79,6 +79,13 @@
let deleteTarget = $state<AdminDto | null>(null);
let deletePending = $state(false);
// Deactivate modal -------------------------------------------------------
// Reactivate is one-click (non-destructive); deactivate routes
// through the modal because it signs the user out and expires
// every API key they hold.
let deactivateTarget = $state<AdminDto | null>(null);
let deactivatePending = $state(false);
// Validation rules (mirror backend: 2-32, [a-z0-9._-]) -------------------
const USERNAME_RE = /^[a-z0-9._-]{2,32}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -219,19 +226,36 @@
}
}
async function toggleActive(row: AdminDto) {
async function reactivate(row: AdminDto) {
try {
const updated = await api.admins.update(row.id, { is_active: !row.is_active });
const updated = await api.admins.update(row.id, { is_active: true });
admins = admins.map((a) => (a.id === updated.id ? updated : a));
flash(
'info',
`${updated.username} ${updated.is_active ? 'reactivated' : 'deactivated'}.`
);
flash('info', `${updated.username} reactivated.`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
}
}
function askDeactivate(row: AdminDto) {
deactivateTarget = row;
}
async function confirmDeactivate() {
if (!deactivateTarget) return;
deactivatePending = true;
const target = deactivateTarget;
try {
const updated = await api.admins.update(target.id, { is_active: false });
admins = admins.map((a) => (a.id === updated.id ? updated : a));
deactivateTarget = null;
flash('info', `${updated.username} deactivated.`);
} catch (e) {
flash('error', e instanceof ApiError ? e.message : 'failed to update user');
} finally {
deactivatePending = false;
}
}
function openDelete(row: AdminDto) {
deleteTarget = row;
}
@@ -353,7 +377,8 @@
{ label: 'Edit', onClick: () => openEdit(row) },
{
label: row.is_active ? 'Deactivate' : 'Reactivate',
onClick: () => toggleActive(row)
onClick: () =>
row.is_active ? askDeactivate(row) : reactivate(row)
},
{
label: 'Delete',
@@ -571,6 +596,30 @@
</div>
{/if}
<!-- Deactivate confirmation -->
{#if deactivateTarget}
{@const dt = deactivateTarget}
<ConfirmModal
title="Deactivate {dt.username}?"
variant="danger"
confirmLabel="Deactivate"
busyLabel="Deactivating…"
busy={deactivatePending}
onConfirm={confirmDeactivate}
onCancel={() => (deactivateTarget = null)}
>
<p>
Deactivating signs <strong>{dt.username}</strong> out immediately and
expires <strong>every API key</strong> they hold. Their sessions and keys
won't come back if you reactivate — they'll need to log in again and
mint new keys.
</p>
<p class="muted">
Reactivation is one click — this isn't permanent.
</p>
</ConfirmModal>
{/if}
<!-- Delete confirmation -->
{#if deleteTarget}
{@const dt = deleteTarget}

View File

@@ -2,6 +2,36 @@ import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
const MEMBER_PW = 'e2e-member-pw';
async function seedAppAndMember(opts: {
slug: string;
username: string;
role: 'viewer' | 'editor' | 'app_admin';
}): Promise<{ appId: string; userId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug: opts.slug, name: opts.slug }
});
expect(appRes.ok()).toBe(true);
const appId = ((await appRes.json()) as { id: string }).id;
const userRes = await api.post('/api/v1/admin/admins', {
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
});
expect(userRes.ok()).toBe(true);
const userId = ((await userRes.json()) as { id: string }).id;
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
data: { user_id: userId, role: opts.role }
});
expect(memberRes.ok()).toBe(true);
return { appId, userId };
} finally {
await api.dispose();
}
}
// Phase B2 — Apps Lifecycle. Create, view, edit, delete, plus the
// historical-slug takeover flow and adversarial inputs.
@@ -224,3 +254,82 @@ test.describe('B2 apps adversarial', () => {
await expect(page.getByRole('button', { name: 'Settings' })).toBeVisible();
});
});
test.describe('B2 apps role shadowing', () => {
test('viewer member sees no "New app" on the apps list', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vlist');
const username = uniqueUsername('viewer');
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto('/admin/apps');
// Member can see the apps list (just the one they belong to)
// but the create-app affordance is hidden.
await expect(page.getByRole('link', { name: new RegExp(slug) })).toBeVisible();
await expect(page.getByRole('button', { name: /^New app$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('viewer sees no Add domain form and no Settings tab on app detail', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vdom');
const username = uniqueUsername('viewer');
const { userId } = await seedAppAndMember({ slug, username, role: 'viewer' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/apps/${slug}`);
await expect(
page.getByRole('button', { name: /^Scripts \(\d+\)$/ })
).toBeVisible();
// Settings tab is absent.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
// Domains tab still listable, but no Add-domain submit.
await page.getByRole('button', { name: /^Domains \(\d+\)$/ }).click();
await expect(page.getByRole('button', { name: /^Add domain$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('editor sees New script but no Settings tab', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('edit');
const username = uniqueUsername('editor');
const { userId } = await seedAppAndMember({ slug, username, role: 'editor' });
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/apps/${slug}`);
await expect(page.getByRole('button', { name: /^New script$/ })).toBeVisible();
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
await expect(
page.getByRole('button', { name: /^Members \(\d+\)$/ })
).toHaveCount(0);
} finally {
await page.context().close();
}
});
});

View File

@@ -3,28 +3,40 @@ import { adminApi } from './api';
// Resources to delete after a test, in LIFO order. Tests register
// their creations and the registry tears everything down in
// `cleanupRegistered` — typically called from `test.afterEach`.
// `run()` — typically called from `test.afterEach`.
//
// A non-2xx status (other than 404) is treated as a real failure and
// logged to stderr. The previous shape silently swallowed every
// error, so a backend that started returning 500 on cleanup would
// have leaked orphans invisibly across runs. 404 stays tolerated —
// the test may have already deleted the resource itself.
type Cleanup = (api: APIRequestContext) => Promise<void>;
interface CleanupItem {
label: string;
path: string;
}
export class CleanupRegistry {
private items: Cleanup[] = [];
private items: CleanupItem[] = [];
app(slugOrId: string): void {
this.items.push(async (api) => {
await api.delete(`/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`);
this.items.push({
label: `app=${slugOrId}`,
path: `/api/v1/admin/apps/${encodeURIComponent(slugOrId)}?force=true`
});
}
adminUser(userId: string): void {
this.items.push(async (api) => {
await api.delete(`/api/v1/admin/admins/${userId}`);
this.items.push({
label: `admin=${userId}`,
path: `/api/v1/admin/admins/${userId}`
});
}
apiKey(keyId: string): void {
this.items.push(async (api) => {
await api.delete(`/api/v1/admin/api-keys/${keyId}`);
this.items.push({
label: `key=${keyId}`,
path: `/api/v1/admin/api-keys/${keyId}`
});
}
@@ -32,13 +44,11 @@ export class CleanupRegistry {
if (this.items.length === 0) return;
const api = await adminApi();
try {
for (const item of this.items.reverse()) {
try {
await item(api);
} catch {
// Best-effort cleanup — a missing resource (already
// deleted by the test) shouldn't fail the suite.
}
// Copy-then-reverse so a defensive double-`run()` (or a
// caller that inspects the registry after a partial
// teardown) doesn't see the items in a re-reversed order.
for (const item of [...this.items].reverse()) {
await deleteAndReport(api, item);
}
} finally {
await api.dispose();
@@ -46,3 +56,22 @@ export class CleanupRegistry {
}
}
}
async function deleteAndReport(
api: APIRequestContext,
item: CleanupItem
): Promise<void> {
try {
const res = await api.delete(item.path);
// 2xx and 404 are both "this resource is no longer here" — fine.
if (!res.ok() && res.status() !== 404) {
console.warn(
`[cleanup] ${item.label} failed: HTTP ${res.status()} ${await res.text()}`
);
}
} catch (err) {
// Network-level failure (request never reached the server,
// timeout, etc.). Log so a leak doesn't accumulate silently.
console.warn(`[cleanup] ${item.label} failed: ${(err as Error).message}`);
}
}

View File

@@ -0,0 +1,46 @@
// Helpers for tests that drive the dashboard as a non-bootstrap admin
// (member with an app-membership row, custom InstanceRole, etc.).
//
// `loginAsUserToken` exchanges username/password for a bearer token
// via the admin API. `pageWithUserToken` opens a fresh browser
// context, seeds the dashboard's localStorage entry, and returns the
// page ready to navigate. Callers are responsible for closing the
// returned page's context.
import { expect, request, type Browser, type Page } from '@playwright/test';
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
export async function loginAsUserToken(
username: string,
password: string
): Promise<string> {
const probe = await request.newContext({ baseURL: API_BASE });
try {
const res = await probe.post('/api/v1/admin/auth/login', {
data: { username, password },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { token: string }).token;
} finally {
await probe.dispose();
}
}
export async function pageWithUserToken(
browser: Browser,
token: string
): Promise<Page> {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
// Seed localStorage on the right origin, then navigate normally.
await page.goto('/admin/login');
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
return page;
}

View File

@@ -18,6 +18,7 @@ export default async function globalSetup(): Promise<void> {
await assertBackendUp();
await fs.mkdir(AUTH_DIR, { recursive: true });
const token = await loginAsAdmin();
await sweepOrphans(token);
await persistAdminStorageState(token);
}
@@ -71,6 +72,57 @@ async function loginAsAdmin(): Promise<string> {
}
}
// Clean up apps + admin users left over from a previous crashed run.
// The convention is that every e2e-created resource has a slug
// starting with `e2e-` (apps) or a username starting with `e2e`
// (admins) — see fixtures/ids.ts. Best-effort: a sweep failure must
// not stop the suite from running.
async function sweepOrphans(token: string): Promise<void> {
const ctx = await request.newContext({
baseURL: API_BASE,
extraHTTPHeaders: { authorization: `Bearer ${token}` }
});
try {
try {
const res = await ctx.get('/api/v1/admin/apps');
if (res.ok()) {
const apps = (await res.json()) as Array<{ slug: string }>;
for (const app of apps) {
if (!app.slug.startsWith('e2e-')) continue;
try {
await ctx.delete(
`/api/v1/admin/apps/${encodeURIComponent(app.slug)}?force=true`
);
} catch {
// Individual delete failure is non-fatal — the per-test
// cleanup will catch it on the next run.
}
}
}
} catch {
// Listing failed; nothing to do but proceed.
}
try {
const res = await ctx.get('/api/v1/admin/admins');
if (res.ok()) {
const admins = (await res.json()) as Array<{ id: string; username: string }>;
for (const a of admins) {
if (!/^e2e/.test(a.username)) continue;
try {
await ctx.delete(`/api/v1/admin/admins/${a.id}`);
} catch {
// Same per-row tolerance as above.
}
}
}
} catch {
// Listing failed; same as above.
}
} finally {
await ctx.dispose();
}
}
// The dashboard reads its session from localStorage under the key
// `picloud.admin.token` (see src/lib/auth.ts). We can't write to
// localStorage without a browser context, so launch a throwaway one,

View File

@@ -87,9 +87,12 @@ test('end-to-end: app + domain + script + route via dashboard → invoke via pub
});
test('api key minted via dashboard works as a CLI bearer, then revoke disables it', async ({
page
page,
uniqueUsername
}) => {
const name = `e2e-cli-${Date.now()}`;
// Worker-aware unique helper instead of Date.now() — keeps two
// workers from minting the same name on the same millisecond.
const name = uniqueUsername('cli');
// 1. Mint the key from /profile and capture the revealed token.
await page.goto('/admin/profile');

View File

@@ -1,14 +1,13 @@
import { expect, type Browser, type Page } from '@playwright/test';
import { expect } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
// Phase B5 — App Members. Setup creates one or two extra admin
// users via the API; tests drive the Members tab through the
// dashboard like a real app admin would.
const API_BASE = process.env.E2E_API_BASE ?? 'http://127.0.0.1:18080';
const cleanup = new CleanupRegistry();
test.afterEach(async () => {
await cleanup.run();
@@ -38,36 +37,6 @@ async function createMemberUser(username: string): Promise<string> {
}
}
async function loginAsUserToken(username: string, password: string): Promise<string> {
const probe = await (await import('@playwright/test')).request.newContext({
baseURL: API_BASE
});
try {
const res = await probe.post('/api/v1/admin/auth/login', {
data: { username, password },
headers: { 'content-type': 'application/json' }
});
expect(res.ok()).toBe(true);
return ((await res.json()) as { token: string }).token;
} finally {
await probe.dispose();
}
}
async function pageWithUserToken(browser: Browser, token: string): Promise<Page> {
const ctx = await browser.newContext({ storageState: undefined });
const page = await ctx.newPage();
// Seed localStorage on the right origin, then navigate normally.
await page.goto('/admin/login');
await page.evaluate(
([key, value]) => {
localStorage.setItem(key, value);
},
['picloud.admin.token', token]
);
return page;
}
test.describe('B5 app members', () => {
test('invite a member-role user, then remove them', async ({ page, uniqueSlug, uniqueUsername }) => {
const slug = uniqueSlug('mem');

View File

@@ -2,6 +2,41 @@ import { expect, type Page } from '@playwright/test';
import { test } from '../fixtures/ids';
import { CleanupRegistry } from '../fixtures/cleanup';
import { adminApi } from '../fixtures/api';
import { loginAsUserToken, pageWithUserToken } from '../fixtures/role-page';
const MEMBER_PW = 'e2e-member-pw';
async function seedAppScriptAndMember(opts: {
slug: string;
username: string;
role: 'viewer' | 'editor';
}): Promise<{ scriptId: string; userId: string }> {
const api = await adminApi();
try {
const appRes = await api.post('/api/v1/admin/apps', {
data: { slug: opts.slug, name: opts.slug }
});
expect(appRes.ok()).toBe(true);
const appId = ((await appRes.json()) as { id: string }).id;
const scriptRes = await api.post('/api/v1/admin/scripts', {
data: { app_id: appId, name: `${opts.slug}-sc`, source: HELLO_RHAI }
});
expect(scriptRes.ok()).toBe(true);
const scriptId = ((await scriptRes.json()) as { id: string }).id;
const userRes = await api.post('/api/v1/admin/admins', {
data: { username: opts.username, password: MEMBER_PW, instance_role: 'member' }
});
expect(userRes.ok()).toBe(true);
const userId = ((await userRes.json()) as { id: string }).id;
const memberRes = await api.post(`/api/v1/admin/apps/${opts.slug}/members`, {
data: { user_id: userId, role: opts.role }
});
expect(memberRes.ok()).toBe(true);
return { scriptId, userId };
} finally {
await api.dispose();
}
}
// Phase B3 — Scripts CRUD + Editor. The script editor lives at
// /admin/scripts/{id}. Setup uses the API to create the app (and
@@ -175,6 +210,105 @@ test.describe('B3 settings', () => {
});
});
test.describe('B3 scripts role shadowing', () => {
test('viewer: no Delete header, no Save/Format on Edit, no Add route on Routing', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vscr');
const username = uniqueUsername('viewer');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'viewer'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
// Header Delete is hidden for non-admins.
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
// Save/Format on the Edit tab are hidden for viewers.
await expect(page.getByRole('button', { name: /^Save$/ })).toHaveCount(0);
await expect(
page.locator('.editor-header').getByRole('button', { name: 'Format' })
).toHaveCount(0);
// Test invoke is still visible (everyone with read access).
await expect(page.getByRole('button', { name: /^Send$/ })).toBeVisible();
// Routing tab loads, no +Add route.
await page.getByRole('button', { name: /Routing/ }).click();
await expect(page.getByRole('button', { name: /\+ Add route/ })).toHaveCount(0);
// Settings tab is absent for non-admins.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
test('viewer: CodeMirror is read-only', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('vro');
const username = uniqueUsername('viewer');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'viewer'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
const cm = page.locator('.cm-content').first();
await expect(cm).toBeVisible();
// CodeMirror sets contenteditable=false when EditorView.editable.of(false)
// is in effect; that's the canonical signal for read-only mode.
await expect(cm).toHaveAttribute('contenteditable', 'false');
} finally {
await page.context().close();
}
});
test('editor: Save visible, Delete header hidden', async ({
browser,
uniqueSlug,
uniqueUsername
}) => {
const slug = uniqueSlug('escr');
const username = uniqueUsername('editor');
const { scriptId, userId } = await seedAppScriptAndMember({
slug,
username,
role: 'editor'
});
cleanup.app(slug);
cleanup.adminUser(userId);
const token = await loginAsUserToken(username, MEMBER_PW);
const page = await pageWithUserToken(browser, token);
try {
await page.goto(`/admin/scripts/${scriptId}`);
// Editor sees Save (disabled until the buffer changes — that's fine).
await expect(page.getByRole('button', { name: /^Save$/ })).toBeVisible();
// Delete stays admin-only.
await expect(page.getByRole('button', { name: /^Delete$/ })).toHaveCount(0);
// Settings stays admin-only.
await expect(page.getByRole('button', { name: /^Settings$/ })).toHaveCount(0);
} finally {
await page.context().close();
}
});
});
test.describe('B3 adversarial', () => {
test('infinite loop script hits the sandbox timeout', async ({ page, uniqueSlug }) => {
const slug = uniqueSlug('loop');

View File

@@ -114,7 +114,7 @@ test.describe('B6 instance users', () => {
await expect(page.locator('.row', { hasText: decoy })).toHaveCount(0);
});
test('deactivate then reactivate toggles the inactive indicator', async ({
test('deactivate confirm modal: Cancel keeps active, Deactivate flips, reactivate is one click', async ({
page,
uniqueUsername
}) => {
@@ -127,10 +127,25 @@ test.describe('B6 instance users', () => {
const row = page.locator('.row:not(.head-row):not(.empty-row)', { hasText: username });
await expect(row).toBeVisible();
// Deactivate opens the confirm modal.
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(username);
// Cancel leaves the user active.
await dialog.getByRole('button', { name: /^Cancel$/ }).click();
await expect(dialog).toHaveCount(0);
await expect(row).not.toContainText(/inactive/i);
// Open again and confirm — user becomes inactive.
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Deactivate$/ }).click();
await page.getByRole('dialog').getByRole('button', { name: /^Deactivate$/ }).click();
await expect(row).toContainText(/inactive/i);
// Reactivate is still one-click (non-destructive — no modal).
await row.getByRole('button', { name: new RegExp(`User actions for ${username}`) }).click();
await page.getByRole('menuitem', { name: /^Reactivate$/ }).click();
await expect(row).not.toContainText(/inactive/i);

View File

@@ -9,7 +9,7 @@ import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['src/lib/rhai/**/*.test.ts'],
include: ['src/lib/**/*.test.ts'],
environment: 'node'
}
});

View File

@@ -61,6 +61,7 @@ services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "${PICLOUD_HOST_PORT:-8000}:80"
volumes:

View File

@@ -1049,7 +1049,7 @@ pub struct Principal {
| Role | Powers |
|---|---|
| `owner` | full instance control, manage other owners, implicit `app_admin` on every app. Multiple owners allowed. |
| `admin` | create apps, invite users, implicit `editor` on every app. Cannot manage instance-wide settings or other owners. |
| `admin` | create apps, invite users, implicit `app_admin` on every app. Cannot manage instance-wide settings (sandbox ceiling, etc.) or other owners. |
| `member` | invited into specific apps only. Cannot create apps, cannot invite. **Strict isolation enforced at SQL** — list endpoints `WHERE app_id IN (SELECT app_id FROM app_members WHERE user_id = $1)`; the API never returns apps a member isn't part of. |
The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'` on the new column. Multi-owner installs get a startup `tracing::warn!` listing the active owner usernames so the operator can demote extras via `PATCH /api/v1/admin/admins/{id}`.
@@ -1058,11 +1058,13 @@ The current Phase 3a `admin_users` rows all become `owner` via `DEFAULT 'owner'`
| Role | Grants |
|---|---|
| `app_admin` | settings, domain claims, delete app |
| `editor` | CRUD on scripts, routes, sandbox config |
| `app_admin` | settings, domain claims, delete app, **delete scripts** |
| `editor` | create + edit scripts, routes, sandbox config (no script delete) |
| `viewer` | read scripts + execution logs |
Implicit grants from instance role: every `owner` is `app_admin` on every app; every `admin` is `editor` on every app. Explicit `app_members` rows are the only path for `member` users.
Implicit grants from instance role: every `owner` and every `admin` is `app_admin` on every app — a single-human install would otherwise have to add itself to each new app's `app_members`. Explicit `app_members` rows are the only path for `member` users.
Script **save** uses `AppWriteScript` (editor+); script **delete** uses `AppAdmin` (app_admin+). Editors can iterate on a script's source freely but cannot remove it — destructive cleanup stays with the role that also owns the app.
### Auth Methods — Same Principal, Different Extractor