@maravilla-labs/platform 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maravilla-labs/platform",
3
- "version": "0.5.1",
3
+ "version": "0.7.0",
4
4
  "description": "Universal platform client for Maravilla runtime",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,9 +28,11 @@
28
28
  "build": "tsup",
29
29
  "dev": "tsup --watch",
30
30
  "typecheck": "tsc --noEmit",
31
+ "typecheck:test-d": "tsc --noEmit -p tsconfig.test.json",
31
32
  "test": "vitest run"
32
33
  },
33
34
  "dependencies": {
35
+ "@maravilla-labs/types": "^0.6.0",
34
36
  "@noble/hashes": "^1.5.0",
35
37
  "livekit-client": "^2.18.1"
36
38
  },
package/src/config.ts CHANGED
@@ -41,6 +41,173 @@ export type SecretRef = string | { env: string };
41
41
  import type { TransformsConfig } from './transforms.js';
42
42
  export type { TransformsConfig, TransformsPatternSpec } from './transforms.js';
43
43
 
44
+ // ════════════════════════════════════════════════════════════════════
45
+ // Typed policy builder (FR-7)
46
+ // ════════════════════════════════════════════════════════════════════
47
+ //
48
+ // A tiny, composable builder for raisin-rel policy expressions. It exists
49
+ // to (a) make the common patterns terse and discoverable and (b) refuse
50
+ // the two footguns that silently fail closed in the runtime:
51
+ // - bare `is_admin` / `auth.isAdmin` / `auth.admin` — use `isAdmin()` /
52
+ // `auth.is_admin`;
53
+ // - `VIA` followed by anything other than a single-quoted bareword —
54
+ // use `relatesVia('NAME')`.
55
+ // The builder emits valid syntax; `Policy.raw(...)` lints hand-written
56
+ // expressions for the same footguns and throws on them.
57
+
58
+ /**
59
+ * Sentinel wrapping an unexpanded `fragment('name')` reference. Padded
60
+ * with spaces so it composes safely inside `&&`/`||` chains, and chosen
61
+ * so it cannot appear in valid raisin-rel source. It is always replaced
62
+ * by {@link defineConfig} before reaching the runtime.
63
+ */
64
+ const FRAGMENT_OPEN = ' fragment(';
65
+ const FRAGMENT_CLOSE = ') ';
66
+ /** Matches the sentinel and captures the fragment NAME. */
67
+ const FRAGMENT_RE = / fragment\(([^)]+)\) /g;
68
+
69
+ /**
70
+ * Throws when `expr` contains a known legacy/footgun policy shape that the
71
+ * runtime evaluator would fail closed on. Called by {@link Policy.raw}.
72
+ *
73
+ * @internal
74
+ */
75
+ export function assertNoLegacyShapes(expr: string): void {
76
+ // `auth.isAdmin` / `auth.admin` — camelCase / wrong field. Check before
77
+ // the bare-`is_admin` rule so the message is the most specific one.
78
+ if (/auth\.isAdmin\b/.test(expr)) {
79
+ throw new Error("Policy: `auth.isAdmin` is invalid — use the `isAdmin()` builder or write `auth.is_admin`.");
80
+ }
81
+ if (/auth\.admin\b/.test(expr)) {
82
+ throw new Error("Policy: `auth.admin` is invalid — use the `isAdmin()` builder or write `auth.is_admin`.");
83
+ }
84
+ // Bare `is_admin` (the valid form is the qualified `auth.is_admin`).
85
+ // Negative lookbehind: any `is_admin` not preceded by a word char or
86
+ // dot is "bare". `auth.is_admin` is preceded by `.` and thus allowed.
87
+ if (/(?<![\w.])is_admin\b/.test(expr)) {
88
+ throw new Error(
89
+ "Policy: bare `is_admin` is not a valid root — use the `isAdmin()` builder or write `auth.is_admin`.",
90
+ );
91
+ }
92
+ // `VIA` must be followed by a single-quoted relation name: `VIA 'NAME'`.
93
+ // Reject `VIA "NAME"`, `VIA STEWARDS`, `VIA auth.x`, trailing `VIA`, etc.
94
+ const viaRe = /\bVIA\b\s*([^\s)]+)?/g;
95
+ let m: RegExpExecArray | null;
96
+ while ((m = viaRe.exec(expr)) !== null) {
97
+ const operand = m[1];
98
+ if (!operand || !/^'[^']+'$/.test(operand)) {
99
+ throw new Error(
100
+ "Policy: `VIA` must be followed by a single-quoted relation name (e.g. VIA 'STEWARDS'). " +
101
+ "Use the `relatesVia('NAME')` builder instead of writing the clause by hand.",
102
+ );
103
+ }
104
+ }
105
+ }
106
+
107
+ /**
108
+ * A composable, typed policy expression. Build with the helper functions
109
+ * ({@link ownsIt}, {@link isStaff}, {@link isAdmin}, {@link relatesVia},
110
+ * {@link publicWhen}, {@link fragment}) and combine with `.and()` / `.or()`.
111
+ * `toString()` yields the raisin-rel source.
112
+ */
113
+ export class Policy {
114
+ private constructor(private readonly expr: string) {}
115
+
116
+ /**
117
+ * Wrap a raw raisin-rel expression. Lints for legacy footguns and
118
+ * throws on them (see {@link assertNoLegacyShapes}).
119
+ */
120
+ static raw(expr: string): Policy {
121
+ assertNoLegacyShapes(expr);
122
+ return new Policy(expr);
123
+ }
124
+
125
+ /** @internal Build without linting — for the builder helpers only. */
126
+ private static unchecked(expr: string): Policy {
127
+ return new Policy(expr);
128
+ }
129
+
130
+ toString(): string {
131
+ return this.expr;
132
+ }
133
+
134
+ /** Logical OR with another policy (each side parenthesized). */
135
+ or(other: Policy): Policy {
136
+ return Policy.unchecked(`${this.expr} || ${other.expr}`);
137
+ }
138
+
139
+ /** Logical AND with another policy (each side parenthesized). */
140
+ and(other: Policy): Policy {
141
+ return Policy.unchecked(`${this.expr} && ${other.expr}`);
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Caller owns the resource: `auth.user_id == node.<field>` (default
147
+ * `owner`).
148
+ */
149
+ export function ownsIt(field: string = 'owner'): Policy {
150
+ return Policy.raw(`auth.user_id == node.${field}`);
151
+ }
152
+
153
+ /**
154
+ * Caller is a member of a staff-like group:
155
+ * `auth.roles.contains('<group>')` (default `staff`).
156
+ */
157
+ export function isStaff(group: string = 'staff'): Policy {
158
+ return Policy.raw(`auth.roles.contains('${group}')`);
159
+ }
160
+
161
+ /**
162
+ * Caller is a platform admin: `auth.is_admin`. Valid now that the runtime
163
+ * populates `is_admin` from membership in the `admin` group.
164
+ */
165
+ export function isAdmin(): Policy {
166
+ return Policy.raw('auth.is_admin');
167
+ }
168
+
169
+ /**
170
+ * A typed `RELATES … VIA '<name>'` clause (FR-1). Emits
171
+ * `<object> RELATES <subject> VIA '<relationName>'[ DEPTH a..b]`.
172
+ *
173
+ * @param relationName - the relation type name (cross-validated against
174
+ * the declared `relations[]` at {@link defineConfig} time).
175
+ * @param opts.subject - the related party (default `auth.user_id`).
176
+ * @param opts.object - the anchor node (default `node.owner`).
177
+ * @param opts.depth - inclusive `[min, max]` traversal depth.
178
+ */
179
+ export function relatesVia(
180
+ relationName: string,
181
+ opts?: { subject?: string; object?: string; depth?: [number, number] },
182
+ ): Policy {
183
+ const subject = opts?.subject ?? 'auth.user_id';
184
+ const object = opts?.object ?? 'node.owner';
185
+ let expr = `${object} RELATES ${subject} VIA '${relationName}'`;
186
+ if (opts?.depth) {
187
+ expr += ` DEPTH ${opts.depth[0]}..${opts.depth[1]}`;
188
+ }
189
+ return Policy.raw(expr);
190
+ }
191
+
192
+ /**
193
+ * Resource is publicly readable: `node.<field> == true` (default
194
+ * `public`).
195
+ */
196
+ export function publicWhen(field: string = 'public'): Policy {
197
+ return Policy.raw(`node.${field} == true`);
198
+ }
199
+
200
+ /**
201
+ * Reference a named fragment declared in `auth.fragments`. Resolved and
202
+ * inlined (parenthesized) by {@link defineConfig}; a dangling reference
203
+ * (no matching fragment) throws there.
204
+ */
205
+ export function fragment(name: string): Policy {
206
+ // Carries a sentinel that defineConfig() recognizes and expands. It is
207
+ // never emitted to the runtime un-expanded.
208
+ return Policy.raw(`${FRAGMENT_OPEN}${name}${FRAGMENT_CLOSE}`);
209
+ }
210
+
44
211
  // ── Resources + policies ──
45
212
 
46
213
  /**
@@ -83,14 +250,20 @@ export interface ResourceDefinition {
83
250
  * Optional raisin-rel policy expression. Evaluated on every KV/DB/
84
251
  * realtime/media op that targets this resource. Leave empty to skip
85
252
  * Layer 2 for this resource — tenant + owner isolation still applies.
253
+ *
254
+ * Accepts either a raw string or a typed {@link Policy} built with
255
+ * `ownsIt()`/`isStaff()`/`isAdmin()`/`relatesVia()`/`publicWhen()`/
256
+ * `fragment()` (and `.and()`/`.or()`). {@link defineConfig} serializes
257
+ * it via `.toString()` and expands any `fragment()` references.
86
258
  */
87
- policy?: string;
259
+ policy?: string | Policy;
88
260
  /**
89
261
  * C+D read-filter (option ii). JSON object the runtime ANDs into the
90
262
  * caller's filter on `db.find` / `db.findOne`. Supports `$auth.<path>`
91
263
  * placeholder strings (e.g. `"$auth.user_id"`) substituted from the
92
264
  * caller's identity at request time. Allowed paths: `user_id`, `email`,
93
- * `is_admin`, `roles`.
265
+ * `is_admin`, `status`, `email_verified`, `groups`, `roles`, `circles`,
266
+ * `profile.<field>`, `scopes.<field>`.
94
267
  *
95
268
  * Independent of `policy` — `policy` gates writes and resolved-doc reads;
96
269
  * `read_filter` scopes which rows the caller can ever see.
@@ -98,6 +271,19 @@ export interface ResourceDefinition {
98
271
  * Example: `'{"$or":[{"owner":"$auth.user_id"},{"public":true}]}'`
99
272
  */
100
273
  read_filter?: string;
274
+ /**
275
+ * Field-level redaction. Maps a field dot-path to a raisin-rel expression;
276
+ * a field is replaced with null on read when its expression is truthy for
277
+ * the caller (fail-closed). Example: `{ birth_date: "!auth.is_admin" }`.
278
+ */
279
+ redact?: Record<string, string>;
280
+ /** When true, every policy decision on this resource is recorded. */
281
+ audit?: boolean;
282
+ /**
283
+ * Fallback decision when this resource has no `policy`: `'allow'` (the
284
+ * default) or `'deny'`. Overrides the tenant-wide `security.default_policy`.
285
+ */
286
+ default_policy?: 'allow' | 'deny';
101
287
  }
102
288
 
103
289
  // ── Groups ──
@@ -112,6 +298,8 @@ export interface GroupPermissionDefinition {
112
298
  export interface GroupDefinition {
113
299
  /** Unique group name per tenant. */
114
300
  name: string;
301
+ /** Tenant-unique slug for stable addressing in JWT/API/policies. */
302
+ slug?: string;
115
303
  /** Optional description for the admin UI. */
116
304
  description?: string;
117
305
  /** Resource permissions granted to the group. Replaces the group's current permissions when declared. */
@@ -121,8 +309,15 @@ export interface GroupDefinition {
121
309
  // ── Relations ──
122
310
 
123
311
  export interface RelationTypeDefinition {
124
- /** Uppercase identifier used in policies (`... VIA 'STEWARDS'`). */
312
+ /**
313
+ * Uppercase identifier referenced from policies. Don't hand-write the
314
+ * `... VIA 'STEWARDS'` clause — use the typed {@link relatesVia} builder
315
+ * (`relatesVia('STEWARDS')`), which emits the correct single-quoted form
316
+ * and is cross-validated against this list at {@link defineConfig} time.
317
+ */
125
318
  relation_name: string;
319
+ /** Tenant-unique slug for stable addressing. */
320
+ slug?: string;
126
321
  /** Human-readable title. */
127
322
  title: string;
128
323
  description?: string;
@@ -234,6 +429,23 @@ export interface AuthConfigBlock {
234
429
  oauth?: OAuthProvidersConfig;
235
430
  security?: SecurityConfig;
236
431
  branding?: BrandingConfig;
432
+ /**
433
+ * Named, reusable policy fragments. Reference a fragment from any
434
+ * resource policy with `fragment('name')`; {@link defineConfig} expands
435
+ * the reference inline (wrapped in parens) at build time, so the
436
+ * runtime never sees the indirection. Values may be a {@link Policy} or
437
+ * a raw string expression.
438
+ *
439
+ * @example
440
+ * ```ts
441
+ * defineConfig({ auth: {
442
+ * fragments: { staffOrAdmin: isStaff().or(isAdmin()) },
443
+ * resources: [{ name: 'todos', title: 'Todos', actions: ['read'],
444
+ * policy: ownsIt().or(fragment('staffOrAdmin')) }],
445
+ * }});
446
+ * ```
447
+ */
448
+ fragments?: Record<string, Policy | string>;
237
449
  }
238
450
 
239
451
  export interface MaravillaConfig {
@@ -306,21 +518,132 @@ export interface DatabaseConfigBlock {
306
518
  }
307
519
 
308
520
  /**
309
- * Identity function that returns the config unchanged — exists purely so the
310
- * TypeScript compiler can infer `MaravillaConfig` and give you IntelliSense
311
- * on every field.
521
+ * Recursively expand `fragment(name)` sentinels in `expr` against the
522
+ * declared `fragments` map. Each expansion is wrapped in parens so it
523
+ * binds correctly inside `&&`/`||`. Throws on an unknown fragment or a
524
+ * fragment cycle.
525
+ *
526
+ * @internal
527
+ */
528
+ function expandFragments(
529
+ expr: string,
530
+ fragments: Record<string, string>,
531
+ seen: ReadonlySet<string> = new Set(),
532
+ ): string {
533
+ return expr.replace(FRAGMENT_RE, (_match, rawName: string) => {
534
+ const name = rawName.trim();
535
+ if (!(name in fragments)) {
536
+ throw new Error(
537
+ `Policy: fragment('${name}') is not declared in auth.fragments. ` +
538
+ `Declared fragments: ${Object.keys(fragments).join(', ') || '(none)'}.`,
539
+ );
540
+ }
541
+ if (seen.has(name)) {
542
+ throw new Error(
543
+ `Policy: fragment('${name}') is part of a reference cycle (${[...seen, name].join(' → ')}).`,
544
+ );
545
+ }
546
+ const expanded = expandFragments(fragments[name], fragments, new Set([...seen, name]));
547
+ return `(${expanded.trim()})`;
548
+ });
549
+ }
550
+
551
+ /** Extract every relation name referenced via `VIA 'NAME'`. @internal */
552
+ function relationNamesIn(expr: string): string[] {
553
+ const out: string[] = [];
554
+ const re = /\bVIA\s+'([^']+)'/g;
555
+ let m: RegExpExecArray | null;
556
+ while ((m = re.exec(expr)) !== null) out.push(m[1]);
557
+ return out;
558
+ }
559
+
560
+ /** Extract every group referenced via `auth.roles.contains('GROUP')`. @internal */
561
+ function groupNamesIn(expr: string): string[] {
562
+ const out: string[] = [];
563
+ const re = /auth\.roles\.contains\(\s*'([^']+)'\s*\)/g;
564
+ let m: RegExpExecArray | null;
565
+ while ((m = re.exec(expr)) !== null) out.push(m[1]);
566
+ return out;
567
+ }
568
+
569
+ /**
570
+ * Validate + normalize a Maravilla config.
571
+ *
572
+ * Beyond giving you full IntelliSense, this:
573
+ * - serializes every `Policy`-typed resource policy to its string form;
574
+ * - inlines `fragment('name')` references against `auth.fragments`
575
+ * (throws on an unknown fragment or a cycle);
576
+ * - cross-validates that relation names used in `relatesVia()` exist in
577
+ * the declared `auth.relations[]`, and (when `auth.groups[]` is
578
+ * declared) that groups referenced via `isStaff()` /
579
+ * `auth.roles.contains(...)` exist there — throwing on unknown.
580
+ *
581
+ * The returned config has all policies as plain strings, ready for the
582
+ * reconciler. Sections you didn't declare are left untouched.
312
583
  *
313
584
  * @example
314
585
  * ```typescript
315
- * import { defineConfig } from '@maravilla-labs/platform/config';
586
+ * import { defineConfig, ownsIt, isStaff } from '@maravilla-labs/platform/config';
316
587
  *
317
588
  * export default defineConfig({
318
589
  * auth: {
319
- * resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write'] }],
590
+ * resources: [{ name: 'todos', title: 'Todos', actions: ['read', 'write'],
591
+ * policy: ownsIt().or(isStaff()) }],
320
592
  * },
321
593
  * });
322
594
  * ```
323
595
  */
324
596
  export function defineConfig(config: MaravillaConfig): MaravillaConfig {
325
- return config;
597
+ const auth = config.auth;
598
+ if (!auth) return config;
599
+
600
+ // Normalize the declared fragments to strings up front (a fragment may
601
+ // itself be a Policy or a raw string).
602
+ const fragmentStrings: Record<string, string> = {};
603
+ if (auth.fragments) {
604
+ for (const [name, frag] of Object.entries(auth.fragments)) {
605
+ fragmentStrings[name] = typeof frag === 'string' ? frag : frag.toString();
606
+ }
607
+ }
608
+
609
+ const declaredRelations = new Set((auth.relations ?? []).map((r) => r.relation_name));
610
+ const declaredGroups = new Set((auth.groups ?? []).map((g) => g.name));
611
+ const validateGroups = (auth.groups ?? []).length > 0;
612
+
613
+ const resources = auth.resources?.map((res) => {
614
+ if (res.policy == null) return res;
615
+ const raw = typeof res.policy === 'string' ? res.policy : res.policy.toString();
616
+ const expanded = expandFragments(raw, fragmentStrings).trim();
617
+
618
+ for (const rel of relationNamesIn(expanded)) {
619
+ if (!declaredRelations.has(rel)) {
620
+ throw new Error(
621
+ `Policy for resource '${res.name}' references relation '${rel}' via relatesVia(), ` +
622
+ `but it is not declared in auth.relations[]. ` +
623
+ `Declared relations: ${[...declaredRelations].join(', ') || '(none)'}.`,
624
+ );
625
+ }
626
+ }
627
+ if (validateGroups) {
628
+ for (const group of groupNamesIn(expanded)) {
629
+ if (!declaredGroups.has(group)) {
630
+ throw new Error(
631
+ `Policy for resource '${res.name}' references group '${group}', ` +
632
+ `but it is not declared in auth.groups[]. ` +
633
+ `Declared groups: ${[...declaredGroups].join(', ')}.`,
634
+ );
635
+ }
636
+ }
637
+ }
638
+
639
+ return { ...res, policy: expanded };
640
+ });
641
+
642
+ return {
643
+ ...config,
644
+ auth: {
645
+ ...auth,
646
+ ...(resources ? { resources } : {}),
647
+ },
648
+ };
326
649
  }
@@ -1,4 +1,4 @@
1
- import type { KvNamespace, KvListResult, Database, DbFindOptions, Storage, RealtimeService, PresenceService, AuthService, AuthCaller, AuthUser, AuthSession, AuthField, RegisterOptions, LoginOptions, UserListFilter, UserListResponse, UpdateUserOptions, PolicyService, VectorIndexSpec, VectorIndexDescriptor, VectorQueryWithFilter, VectorSearchHit, IndexSpec, IndexDescriptor, Workflows, WorkflowHandle, WorkflowRun, WorkflowStepRecord } from './types.js';
1
+ import type { KvNamespace, KvListResult, Database, DbDocument, DbFindOptions, Storage, RealtimeService, PresenceService, AuthService, AuthCaller, AuthUser, AuthSession, AuthField, RegisterOptions, LoginOptions, CreateManagedUserOptions, UserListFilter, UserListResponse, UpdateUserOptions, Relation, AddRelationOptions, ListRelationsOptions, PolicyExplain, CanCheck, PolicyService, VectorIndexSpec, VectorIndexDescriptor, VectorQueryWithFilter, VectorSearchHit, IndexSpec, IndexDescriptor, Workflows, WorkflowHandle, WorkflowRun, WorkflowStepRecord } from './types.js';
2
2
  import type { TransformsService, TranscodeOpts, ThumbnailOpts, ResizeOpts, OcrOpts, DocToPdfOpts, DocThumbnailOpts, DocConvertOpts, DocToMarkdownOpts, DocToHtmlOpts, DocReplaceImagesOpts, DocInsertQrCodeOpts, DocTemplateMergeOpts, JobHandle, JobStatusResponse, MediaInfo } from './transforms.js';
3
3
  import { RemoteMediaService } from './media.js';
4
4
  import { getRequestAuthHeader } from './request-scope.js';
@@ -107,21 +107,21 @@ class RemoteDatabase implements Database {
107
107
  return response;
108
108
  }
109
109
 
110
- async find(collection: string, filter: any = {}, options: DbFindOptions = {}): Promise<any[]> {
110
+ async find<T = Record<string, unknown>>(collection: string, filter: any = {}, options: DbFindOptions = {}): Promise<DbDocument<T>[]> {
111
111
  const response = await this.fetch(`${this.baseUrl}/api/db/${collection}`, {
112
112
  method: 'POST',
113
113
  body: JSON.stringify({ filter, options }),
114
114
  });
115
- return response.json() as Promise<any[]>;
115
+ return response.json() as Promise<DbDocument<T>[]>;
116
116
  }
117
117
 
118
- async findOne(collection: string, filter: any): Promise<any | null> {
118
+ async findOne<T = Record<string, unknown>>(collection: string, filter: any): Promise<DbDocument<T> | null> {
119
119
  const response = await this.fetch(`${this.baseUrl}/api/db/${collection}/findOne`, {
120
120
  method: 'POST',
121
121
  body: JSON.stringify(filter),
122
122
  });
123
123
  if (response.status === 404) return null;
124
- return response.json();
124
+ return response.json() as Promise<DbDocument<T>>;
125
125
  }
126
126
 
127
127
  async insertOne(collection: string, document: any): Promise<string> {
@@ -558,6 +558,10 @@ class RemoteAuthService implements AuthService {
558
558
  await this.post('/delete-user', { user_id: userId });
559
559
  }
560
560
 
561
+ async createManagedUser(options: CreateManagedUserOptions): Promise<AuthUser> {
562
+ return this.post('/create-managed-user', options);
563
+ }
564
+
561
565
  async sendVerification(userId: string): Promise<{ token: string }> {
562
566
  return this.post('/send-verification', { user_id: userId });
563
567
  }
@@ -693,6 +697,32 @@ class RemoteAuthService implements AuthService {
693
697
  await this.post('/relation-types/delete', { id });
694
698
  }
695
699
 
700
+ // ── Relations (typed edges, FR-1) ──
701
+
702
+ async addRelation(options: AddRelationOptions): Promise<Relation> {
703
+ return this.post('/relations/add', {
704
+ from_user_id: options.from_user_id,
705
+ to_user_id: options.to_user_id,
706
+ relation_type: options.relation_type,
707
+ metadata: options.metadata ?? null,
708
+ });
709
+ }
710
+
711
+ async removeRelation(fromUserId: string, toUserId: string, relationTypeId: string): Promise<void> {
712
+ await this.post('/relations/remove', {
713
+ from_user_id: fromUserId,
714
+ to_user_id: toUserId,
715
+ relation_type_id: relationTypeId,
716
+ });
717
+ }
718
+
719
+ async listRelations(options: ListRelationsOptions): Promise<Relation[]> {
720
+ return this.post('/relations/list', {
721
+ user_id: options.user_id,
722
+ direction: options.direction ?? 'both',
723
+ });
724
+ }
725
+
696
726
  // ── Profile ──
697
727
 
698
728
  async getProfile(userId: string): Promise<Record<string, any>> {
@@ -856,6 +886,52 @@ class RemoteAuthService implements AuthService {
856
886
  });
857
887
  return Boolean((r as any)?.allowed);
858
888
  }
889
+
890
+ async explain(
891
+ action: string,
892
+ resourceId: string,
893
+ node?: Record<string, unknown> | null,
894
+ ): Promise<PolicyExplain> {
895
+ const { getCurrentRequestStore } = await import('./request-scope.js');
896
+ const store = getCurrentRequestStore();
897
+ const token = store?.token;
898
+ if (!token) {
899
+ // No bound user → fail-closed with a reason, mirroring can().
900
+ return { allowed: false, reason: 'no bound user' };
901
+ }
902
+ const r = await this.post('/can-explain', {
903
+ token,
904
+ action,
905
+ resource_id: resourceId,
906
+ node: node ?? null,
907
+ }) as any;
908
+ return {
909
+ allowed: Boolean(r?.allowed),
910
+ reason: r?.reason,
911
+ failedClause: r?.failedClause,
912
+ };
913
+ }
914
+
915
+ async canMany(checks: CanCheck[]): Promise<{ allowed: boolean }[]> {
916
+ const { getCurrentRequestStore } = await import('./request-scope.js');
917
+ const store = getCurrentRequestStore();
918
+ const token = store?.token;
919
+ if (!token) {
920
+ // No bound user → fail-closed for every check, preserving order.
921
+ return checks.map(() => ({ allowed: false }));
922
+ }
923
+ const r = await this.post('/can-many', {
924
+ token,
925
+ checks: checks.map((c) => ({
926
+ action: c.action,
927
+ resource_id: c.resourceId,
928
+ node: c.node ?? null,
929
+ })),
930
+ }) as any;
931
+ const results: any[] = Array.isArray(r) ? r : (r?.results ?? []);
932
+ // Preserve request order; default any missing entry to fail-closed.
933
+ return checks.map((_, i) => ({ allowed: Boolean(results[i]?.allowed) }));
934
+ }
859
935
  }
860
936
 
861
937
  // Cache the request-scope module on the class so getCurrentUser can read