feat(dashboard): group data-plane read surfaces + CodeEditor readOnly fix

Remediate the dashboard-coverage and editor findings from the 2026-07-11 audit.

Close the group-surface gap: a GroupTabBar plus read-only surfaces for the v1.2
group data plane the dashboard previously exposed only via the CLI — vars,
secrets (names + timestamps only, never values), shared collections (KV/docs/
files browsers), scripts, triggers, routes, suppressions, extension points,
dead-letters, and ownership. All server data renders through Svelte's escaped
interpolation (no `{@html}`); each tab hits its existing
`/api/v1/admin/groups/{id}/...` read endpoint with independent loading/empty
state.

B7 — the CodeEditor `readOnly` prop is now reactive via a CodeMirror
Compartment, fixing the race where an authorized user opening a script on a warm
SPA nav got a permanently read-only editor (role resolved after mount). The
reconfigure preserves editor content.

A viewer lacking a read cap now sees a calm "you don't have permission" note on
every tab (structured LoadError + classifyError) instead of a red error panel,
generalizing the one-off Secrets 403 handling.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 23:48:09 +02:00
parent a3c4267f6c
commit c03c0c83c5
4 changed files with 1321 additions and 44 deletions

View File

@@ -491,6 +491,105 @@ export interface SecretListItem {
updated_at: string;
}
// ---------------------------------------------------------------------------
// v1.2 group read-only inspection surfaces. Every shape below mirrors a Rust
// handler in crates/manager-core/src (vars_api, secrets_api, apply_api,
// group_blobs_api, group_scripts_api, projects_api). All READ-ONLY — group
// shared-collection writes + template authoring happen via the `pic` CLI.
// ---------------------------------------------------------------------------
/// One group var row (vars_api::VarItem). `env` is `*` (env-agnostic) or a
/// concrete env name; a tombstone suppresses an inherited key.
export interface GroupVarItem {
key: string;
env: string;
value: unknown;
is_tombstone: boolean;
updated_at: string;
}
/// One group secret row (secrets_api::SecretItem). NAMES + env + updated_at
/// only — values never leave the server.
export interface GroupSecretItem {
name: string;
env: string;
updated_at: string;
}
/// §11.6 shared-collection kinds. kv/docs/files have a data browser; topic and
/// queue are storeless / consumed differently (no per-group data endpoint).
export type GroupCollectionKind = 'kv' | 'docs' | 'files' | 'topic' | 'queue';
/// One declared shared collection (apply_service::CollectionInfo).
export interface GroupCollection {
name: string;
kind: GroupCollectionKind;
}
/// One group trigger-template row (apply_service::TriggerTemplateInfo).
export interface GroupTriggerTemplate {
kind: string;
target: string;
script: string;
enabled: boolean;
sealed: boolean;
shared: boolean;
}
/// One group route-template row (apply_service::RouteTemplateInfo).
export interface GroupRouteTemplate {
method: string;
host: string;
path_kind: string;
path: string;
script: string;
dispatch: string;
enabled: boolean;
sealed: boolean;
}
/// One group/app suppression row (apply_service::SuppressionInfo).
export interface SuppressionInfo {
target_kind: string;
reference: string;
}
/// One extension-point row (apply_service::ExtensionPointInfo).
export interface ExtensionPointInfo {
name: string;
declared_here: boolean;
provider: string | null;
}
/// One shared-collection doc entry (group_blobs_api::DocEntry).
export interface GroupDocEntry {
id: string;
data: unknown;
}
/// One group shared-queue dead-letter (group_blobs_api::DeadLetterDto). Note:
/// this shape is distinct from the per-app `DeadLetterRow` — it carries a
/// `collection` and no trigger/script fields.
export interface GroupDeadLetter {
id: string;
collection: string;
source: string;
op: string;
attempt_count: number;
last_error: string;
created_at: string;
resolved_at: string | null;
payload: unknown;
}
/// One project row (projects_api::ProjectListItem).
export interface ProjectListItem {
slug: string;
name: string;
owned_groups: number;
created_at: string;
}
export interface ExecutionResult {
status: number;
headers: Record<string, string>;
@@ -864,9 +963,122 @@ export const api = {
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'DELETE' }
)
},
// --- v1.2 read-only inspection surfaces (all GET) --------------
// The group's OWN vars (not the inherited/resolved view).
vars: (idOrSlug: string) =>
adminRequest<{ vars: GroupVarItem[] }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/vars`
),
// Secret NAMES + env + updated_at only — never values. Requires
// GroupSecretsWrite; a 403 is surfaced as a permission note, not a crash.
secrets: (idOrSlug: string) =>
adminRequest<{ secrets: GroupSecretItem[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/secrets`
),
// Declared shared collections (kv/docs/files/topic/queue).
collections: (idOrSlug: string) =>
adminRequest<GroupCollection[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/collections`
),
scripts: (idOrSlug: string) =>
adminRequest<Script[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/scripts`
),
triggers: (idOrSlug: string) =>
adminRequest<GroupTriggerTemplate[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/triggers`
),
routes: (idOrSlug: string) =>
adminRequest<GroupRouteTemplate[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/routes`
),
suppressions: (idOrSlug: string) =>
adminRequest<SuppressionInfo[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/suppressions`
),
extensionPoints: (idOrSlug: string) =>
adminRequest<ExtensionPointInfo[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/extension-points`
),
// Shared-KV browser: list keys, then get a single value.
groupKv: {
list: (
idOrSlug: string,
collection: string,
opts: { cursor?: string; limit?: number } = {}
) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ keys: string[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv?${params.toString()}`
);
},
get: (idOrSlug: string, collection: string, key: string) =>
adminRequest<{ value: unknown }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`
)
},
// Shared-docs browser: list documents (id + data), then get one.
groupDocs: {
list: (
idOrSlug: string,
collection: string,
opts: { cursor?: string; limit?: number } = {}
) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ docs: GroupDocEntry[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs?${params.toString()}`
);
},
get: (idOrSlug: string, collection: string, docId: string) =>
adminRequest<{ id: string; data: unknown }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs/${encodeURIComponent(collection)}/${docId}`
)
},
// Shared-files browser: list metadata + download URL (GET streams bytes).
groupFiles: {
list: (
idOrSlug: string,
collection: string,
opts: { cursor?: string; limit?: number } = {}
) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ files: FileMeta[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files?${params.toString()}`
);
},
downloadUrl: (idOrSlug: string, collection: string, fileId: string): string =>
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`
},
// §11.6 D3 shared-queue dead-letters (newest-first).
deadLetters: (
idOrSlug: string,
opts: { unresolved?: boolean; limit?: number } = {}
) => {
const params = new URLSearchParams();
if (opts.unresolved) params.set('unresolved', 'true');
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
const qs = params.toString();
return adminRequest<GroupDeadLetter[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/dead-letters${qs ? `?${qs}` : ''}`
);
}
},
projects: {
list: () => adminRequest<ProjectListItem[]>('/api/v1/admin/projects')
},
domains: {
listForApp: (idOrSlug: string) =>
adminRequest<AppDomain[]>(