Compare commits
16 Commits
test/front
...
chore/ui-h
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42e273479 | ||
|
|
f32ed73561 | ||
|
|
64799b73ff | ||
|
|
beb3bcb97c | ||
|
|
79c8db2cb7 | ||
|
|
f4cd883d76 | ||
|
|
b459b99fe9 | ||
|
|
f694a6d504 | ||
|
|
70b66451d6 | ||
|
|
c4fa53052d | ||
|
|
2f6840fe3e | ||
|
|
75c815d02a | ||
|
|
d9c3d4d661 | ||
|
|
bef4d34c43 | ||
|
|
99a3ed1b6b | ||
|
|
4644ea4919 |
@@ -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?;
|
||||
|
||||
@@ -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?),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
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(),
|
||||
Decision::Allow,
|
||||
);
|
||||
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,
|
||||
);
|
||||
// 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, cap).await.unwrap(),
|
||||
Decision::Allow,
|
||||
"admin denied app-scoped capability {cap:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
60
dashboard/src/lib/capabilities.test.ts
Normal file
60
dashboard/src/lib/capabilities.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
43
dashboard/src/lib/capabilities.ts
Normal file
43
dashboard/src/lib/capabilities.ts
Normal 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';
|
||||
}
|
||||
54
dashboard/src/lib/password-gen.test.ts
Normal file
54
dashboard/src/lib/password-gen.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,18 +104,20 @@
|
||||
<section>
|
||||
<header class="page-header">
|
||||
<h1>Apps</h1>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showCreate = !showCreate;
|
||||
if (!showCreate) resetCreate();
|
||||
}}
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'New app'}
|
||||
</button>
|
||||
{#if canCreate}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
showCreate = !showCreate;
|
||||
if (!showCreate) resetCreate();
|
||||
}}
|
||||
>
|
||||
{showCreate ? 'Cancel' : 'New app'}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if showCreate}
|
||||
{#if showCreate && canCreate}
|
||||
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
|
||||
<div class="row">
|
||||
<label>
|
||||
|
||||
@@ -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
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeTab === 'settings'}
|
||||
onclick={() => (activeTab = 'settings')}>Settings</button
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class:active={activeTab === 'settings'}
|
||||
onclick={() => (activeTab = 'settings')}>Settings</button
|
||||
>
|
||||
</nav>
|
||||
|
||||
{#if activeTab === 'scripts'}
|
||||
<section>
|
||||
<div class="row">
|
||||
<h2>Scripts</h2>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showCreateScript = !showCreateScript)}
|
||||
>
|
||||
{showCreateScript ? 'Cancel' : 'New script'}
|
||||
</button>
|
||||
{#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,18 +491,20 @@
|
||||
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>
|
||||
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
||||
<input
|
||||
bind:value={createDomainPattern}
|
||||
required
|
||||
placeholder="app.example.com"
|
||||
/>
|
||||
<button type="submit" disabled={creatingDomain}>
|
||||
{creatingDomain ? 'Adding…' : 'Add domain'}
|
||||
</button>
|
||||
</form>
|
||||
{#if createDomainError}
|
||||
<div class="error">{createDomainError}</div>
|
||||
{#if canAdmin}
|
||||
<form class="create-form inline" onsubmit={submitCreateDomain}>
|
||||
<input
|
||||
bind:value={createDomainPattern}
|
||||
required
|
||||
placeholder="app.example.com"
|
||||
/>
|
||||
<button type="submit" disabled={creatingDomain}>
|
||||
{creatingDomain ? 'Adding…' : 'Add domain'}
|
||||
</button>
|
||||
</form>
|
||||
{#if createDomainError}
|
||||
<div class="error">{createDomainError}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if domains.length === 0}
|
||||
<p class="muted">No domain claims yet.</p>
|
||||
@@ -496,19 +516,21 @@
|
||||
<code>{d.pattern}</code>
|
||||
<span class="muted">— {d.shape}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary danger"
|
||||
onclick={() => askRemoveDomain(d)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{#if canAdmin}
|
||||
<button
|
||||
type="button"
|
||||
class="secondary danger"
|
||||
onclick={() => askRemoveDomain(d)}
|
||||
>
|
||||
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)}>
|
||||
|
||||
@@ -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}>
|
||||
{deleting ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
{#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>
|
||||
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</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,26 +467,35 @@
|
||||
<section class="card">
|
||||
<header class="editor-header">
|
||||
<h2>Source</h2>
|
||||
<button type="button" class="ghost small" onclick={formatRhaiSource}>
|
||||
Format
|
||||
</button>
|
||||
{#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}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveSource}
|
||||
disabled={savingSource || editableSource === script.source}
|
||||
>
|
||||
{savingSource ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{#if canWrite}
|
||||
<div class="actions">
|
||||
<button
|
||||
type="button"
|
||||
onclick={saveSource}
|
||||
disabled={savingSource || editableSource === script.source}
|
||||
>
|
||||
{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>
|
||||
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
|
||||
{showAddRoute ? 'Cancel' : '+ Add route'}
|
||||
</button>
|
||||
{#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>
|
||||
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
|
||||
remove
|
||||
</button>
|
||||
{#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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal file
46
dashboard/tests/e2e/fixtures/role-page.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ services:
|
||||
|
||||
caddy:
|
||||
image: caddy:2-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PICLOUD_HOST_PORT:-8000}:80"
|
||||
volumes:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user