dorfteich/packages/shared/src/permissions/schemas.ts
Claude Opus 4.8 f5f1310eb2
All checks were successful
CD / Build and push images (push) Successful in 3m14s
CI / Lint, typecheck, test (push) Successful in 2m31s
CI / Auth e2e pack (push) Successful in 3m21s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 11s
Add effective-permissions inspector (#57)
Pond Admins can answer "what can X actually see/do here?" — load-bearing for
trust in the grant system (permissions.md §UI obligations).

- shared: `explainPageCapability` — the resolver's decision chain (deciding
  level + the single deciding grant), sharing one code path with the boolean
  `resolvePageCapability` (now a thin wrapper), so the trace can never diverge
  from real access. Unit-tested against the permissions.md worked examples.
- api: `GET /ponds/:id/effective-permissions?subjectType=&subjectId=&pageId=`
  (Pond-Admin-gated, one pond only) resolves as the chosen subject (a user with
  their real Site-Admin flag, all signed-in users, or the public), optionally
  against a page, and returns the read + write outcome with the deciding rule
  enriched with subject/scope names.
- web: `EffectivePermissionsInspector` in Pond Settings — pick a subject and
  optionally a page → see the resolved read/edit verdict, the level that
  decided it, and the deciding rule spelled out as a de/en sentence (reusing
  the #55 sentence renderer). Hidden from non-admins.
- tests: explain-mode unit tests (worked examples + trace-matches-boolean);
  `inspector.e2e.db.test.ts` (deciding rule on a labelled page, pond-level base
  capability, public default-closed, Pond-Admin gating); a browser assertion in
  the access-rules pack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-10 00:00:27 +02:00

95 lines
3.5 KiB
TypeScript

import { z } from 'zod';
import type { ResolutionLevel } from './resolve';
import type { Grant, GrantSubjectType } from './types';
/**
* Wire schemas and views for the grant-management API (`/ponds/:id/grants`,
* issue #52). The structural rules beyond shape (pond_admin only at pond
* scope, subject/scope id presence, personal-pond single admin) live in
* {@link ./validate} and are applied by the service after parsing.
*/
export const GRANT_SUBJECT_TYPES = ['user', 'authenticated', 'public'] as const;
export const GRANT_ROLES = ['pond_admin', 'editor', 'reader'] as const;
export const GRANT_SCOPE_TYPES = ['pond', 'label', 'page'] as const;
export const GRANT_EFFECTS = ['allow', 'deny'] as const;
export const createGrantInputSchema = z.object({
subjectType: z.enum(GRANT_SUBJECT_TYPES),
/** Required for `user` subjects, absent otherwise (validate.ts checks). */
subjectId: z.string().min(1).nullish(),
role: z.enum(GRANT_ROLES),
scopeType: z.enum(GRANT_SCOPE_TYPES),
/** The label/page id for those scopes, absent at pond scope. */
scopeId: z.string().min(1).nullish(),
effect: z.enum(GRANT_EFFECTS),
});
export type CreateGrantInput = z.infer<typeof createGrantInputSchema>;
/** A stored grant as the API serves it. */
export interface GrantView extends Grant {
id: string;
pondId: string;
createdBy: string;
createdAt: string;
}
/**
* A grant enriched with the display names the access-rules UI renders as
* sentences (issue #55): the subject's display name and the scoped label/page
* name. Names are resolved server-side so the client needs no id lookups.
*/
export interface AccessRuleView extends GrantView {
/** The user subject's display name; `null` for `authenticated`/`public`. */
subjectName: string | null;
/** Label name or page title for label/page scope; `null` at pond scope. */
scopeName: string | null;
}
/** Query for the effective-permissions inspector (issue #57). */
export const inspectSubjectSchema = z.object({
subjectType: z.enum(GRANT_SUBJECT_TYPES),
/** Required for `user`, absent for `authenticated`/`public`. */
subjectId: z.string().min(1).optional(),
/** Optional page to resolve against; omit for the pond-level base capability. */
pageId: z.string().min(1).optional(),
});
export type InspectSubjectQuery = z.infer<typeof inspectSubjectSchema>;
/** A grant enriched with the names needed to render it as a sentence — the
* shape the inspector highlights (no id: the decider is shown, not managed). */
export interface DecidingRule extends Grant {
subjectName: string | null;
scopeName: string | null;
}
/** One resolved capability (read or write) with the rule that decided it. */
export interface DecisionView {
outcome: 'allow' | 'deny';
/** Which specificity level settled it (permissions.md §Resolution). */
decidedBy: ResolutionLevel;
/** The deciding grant, enriched with names; `null` for site-admin/default. */
decidingRule: DecidingRule | null;
}
/** The inspector's answer to "what can this subject do here?" (issue #57). */
export interface EffectivePermissionView {
subject: { type: GrantSubjectType; id: string | null; name: string | null };
page: { id: string; title: string } | null;
read: DecisionView;
write: DecisionView;
}
/** The parsed input as the resolver's grant shape (nullish → null). */
export function grantOfInput(input: CreateGrantInput): Grant {
return {
subjectType: input.subjectType,
subjectId: input.subjectId ?? null,
role: input.role,
scopeType: input.scopeType,
scopeId: input.scopeId ?? null,
effect: input.effect,
};
}