@aooth/arbac-moost 0.1.7 → 0.1.9

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.
@@ -1,5 +1,5 @@
1
- import { t as AoothArbacUserCredentials } from "../user-ChbLPYhG.mjs";
2
- import { t as ArbacUserProvider } from "../user.provider-JpI5dD6Z.mjs";
1
+ import { t as AoothArbacUserCredentials } from "../user-Dzu_08g0.mjs";
2
+ import { r as AoothArbacClaims, t as ArbacUserProvider } from "../user.provider-CKPMp3G8.mjs";
3
3
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
4
4
 
5
5
  //#region src/atscript/auto-provider.d.ts
@@ -88,4 +88,96 @@ declare abstract class AtscriptArbacUserProvider<T extends object = object> exte
88
88
  private fetchRecord;
89
89
  }
90
90
  //#endregion
91
- export { AoothArbacUserCredentials, type ArbacUserTable, AtscriptArbacUserProvider };
91
+ //#region src/atscript/attenuation-extract.d.ts
92
+ /**
93
+ * Spec computed once per credential `TAtscriptAnnotatedType` — which typed root
94
+ * fields carry the restrict-only ARBAC attenuation:
95
+ *
96
+ * - `roleField` — the single `@arbac.attenuate.role`-annotated prop (holds the
97
+ * assumed-role SUBSET, `string[]`). More than one throws at spec time.
98
+ * - `attrFields` — every `@arbac.attenuate.attr "userAttr"` prop, paired with
99
+ * the target user-attribute key it narrows (the annotation argument).
100
+ */
101
+ interface ArbacAttenuationSpec {
102
+ roleField: string | undefined;
103
+ attrFields: Array<{
104
+ field: string;
105
+ userAttr: string;
106
+ }>;
107
+ }
108
+ /**
109
+ * Walk a credential model's `@arbac.attenuate.*` annotations into a cached
110
+ * {@link ArbacAttenuationSpec}. Mirrors the user-side `getArbacExtractSpec`:
111
+ * structural, computed once, fail-loud on an ambiguous (multiple) role field.
112
+ */
113
+ declare function getArbacAttenuationSpec(credType: TAtscriptAnnotatedType): ArbacAttenuationSpec;
114
+ /**
115
+ * Boot-time cross-model check: every `@arbac.attenuate.attr "userAttr"` target
116
+ * must name a real key in the user model's `@arbac.attribute` keyspace
117
+ * (`validUserAttrs`), else a typo silently no-ops the narrowing. Throws on the
118
+ * first miss. Call once at provider construction; safe and cheap (the spec is
119
+ * cached). Soundness does NOT depend on this — the scope conjunction clips
120
+ * regardless — but it turns a silent misconfig into a loud boot failure.
121
+ */
122
+ declare function validateAttenuationTargets(credType: TAtscriptAnnotatedType, validUserAttrs: Iterable<string>): void;
123
+ /**
124
+ * Build the restrict-only {@link AoothArbacClaims} for a credential `record`
125
+ * (its typed root fields — e.g. the auth context, which carries the credential
126
+ * payload flat) by reading the fields the credential model annotates. Designed
127
+ * to back a consumer's `ArbacUserProvider.getAttenuation()` override while
128
+ * arbac-moost stays auth-agnostic (the consumer supplies the record).
129
+ *
130
+ * Returns:
131
+ * - `undefined` when the model declares NO attenuation fields, OR a normal
132
+ * (non-attenuated) token carries none of them set → full user authority.
133
+ * - `{ roles, attrs }` otherwise. The narrowing is then clipped by the engine's
134
+ * scope conjunction, so it can never widen beyond the user.
135
+ *
136
+ * Fail-closed: when the assumed-role field IS set but holds a malformed value
137
+ * (no usable role strings), {@link parseRoles} yields `[]` — deny-all — never
138
+ * full authority. Absent (`undefined`/`null`) = the field was not set, so roles
139
+ * are omitted (attrs-only narrowing), which is the distinct "no role narrowing"
140
+ * case, not a malformed one.
141
+ */
142
+ declare function extractAttenuation(credType: TAtscriptAnnotatedType, record: object | null | undefined): AoothArbacClaims | undefined;
143
+ //#endregion
144
+ //#region src/atscript/handle-spec.d.ts
145
+ /**
146
+ * Resolved login/recovery HANDLE fields for a user credential model, discovered
147
+ * from the `@aooth.user.*` annotations (registered by `arbacPlugin`). Computed
148
+ * once per `TAtscriptAnnotatedType` and cached.
149
+ *
150
+ * - `emailField` — the field annotated `@aooth.user.email` **and** carrying a
151
+ * `@db.index.unique` index. `undefined` when no field is annotated, or when
152
+ * the annotated field lacks a unique index (the handle is then DISABLED — see
153
+ * `warnings`).
154
+ * - `phoneField` — same, for `@aooth.user.phone`.
155
+ * - `handleFields` — the enabled handle field names in resolution order (email,
156
+ * then phone; disabled handles omitted). Pass this straight to a `UserStore`
157
+ * (`UsersStoreAtscriptDb` / `UserStoreMemory`) as its `handleFields` — the
158
+ * store stays name-agnostic; this spec owns the email/phone semantics.
159
+ * - `warnings` — non-fatal misconfig notes (an annotated field with no unique
160
+ * index → that handle is disabled, not fatal). The wiring layer should log
161
+ * these at boot. Ambiguity (more than one field per role) is fatal and throws.
162
+ *
163
+ * A handle MUST be unique-when-present because `findByHandle` resolves a handle
164
+ * to AT MOST one row; a non-unique handle would resolve to an arbitrary one of
165
+ * several rows (an account-takeover footgun). That is why the unique index is
166
+ * the gate, and why a missing index disables rather than silently accepts.
167
+ */
168
+ interface AoothUserHandleSpec {
169
+ emailField: string | undefined;
170
+ phoneField: string | undefined;
171
+ handleFields: string[];
172
+ warnings: string[];
173
+ }
174
+ /**
175
+ * Walk a user model's `@aooth.user.*` annotations into a cached
176
+ * {@link AoothUserHandleSpec}. Mirrors {@link getArbacAttenuationSpec}:
177
+ * structural, computed once, fail-loud on ambiguity. A model with no
178
+ * `@aooth.user.*` field resolves to all-`undefined` handles (email/phone login
179
+ * is simply unavailable — graceful degradation, not an error).
180
+ */
181
+ declare function getAoothUserHandleSpec(userType: TAtscriptAnnotatedType): AoothUserHandleSpec;
182
+ //#endregion
183
+ export { AoothArbacUserCredentials, type AoothUserHandleSpec, type ArbacAttenuationSpec, type ArbacUserTable, AtscriptArbacUserProvider, extractAttenuation, getAoothUserHandleSpec, getArbacAttenuationSpec, validateAttenuationTargets };
@@ -1,14 +1,33 @@
1
- import { r as __decorate, t as ArbacUserProvider } from "../user.provider-CGWueNjx.mjs";
2
- import { t as AoothArbacUserCredentials } from "../user-ChbLPYhG.mjs";
1
+ import { r as __decorate, t as ArbacUserProvider } from "../user.provider-BiM5P8fl.mjs";
2
+ import { t as AoothArbacUserCredentials } from "../user-Dzu_08g0.mjs";
3
3
  import { defineWook, key } from "@wooksjs/event-core";
4
4
  import { Injectable } from "moost";
5
- //#region \0@oxc-project+runtime@0.129.0/helpers/decorateMetadata.js
5
+ //#region src/atscript/role-list.ts
6
+ /**
7
+ * Collect unique, non-empty strings from an iterable of unknown values,
8
+ * preserving first-seen order and dropping anything that is not a non-empty
9
+ * string. Shared by the user-side role extraction
10
+ * (`AtscriptArbacUserProvider.extractRoles`) and the credential-side
11
+ * attenuation role parse (`extractAttenuation`) so both apply identical
12
+ * dedup / drop-empty rules.
13
+ */
14
+ function uniqueStrings(values) {
15
+ const seen = /* @__PURE__ */ new Set();
16
+ const out = [];
17
+ for (const v of values) if (typeof v === "string" && v !== "" && !seen.has(v)) {
18
+ seen.add(v);
19
+ out.push(v);
20
+ }
21
+ return out;
22
+ }
23
+ //#endregion
24
+ //#region \0@oxc-project+runtime@0.133.0/helpers/esm/decorateMetadata.js
6
25
  function __decorateMetadata(k, v) {
7
26
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
8
27
  }
9
28
  //#endregion
10
29
  //#region src/atscript/auto-provider.ts
11
- const specCache = /* @__PURE__ */ new WeakMap();
30
+ const specCache$2 = /* @__PURE__ */ new WeakMap();
12
31
  /**
13
32
  * Resolve the identifier field on an object type using the three-step chain
14
33
  * documented on `ArbacExtractSpec.userIdField`. Used for the user type AND
@@ -47,7 +66,7 @@ function resolveIdentifierField(type) {
47
66
  return metaId;
48
67
  }
49
68
  function getArbacExtractSpec(type) {
50
- const cached = specCache.get(type);
69
+ const cached = specCache$2.get(type);
51
70
  if (cached !== void 0) return cached;
52
71
  const spec = {
53
72
  userIdField: void 0,
@@ -56,7 +75,7 @@ function getArbacExtractSpec(type) {
56
75
  };
57
76
  const def = type.type;
58
77
  if (def.kind !== "object") {
59
- specCache.set(type, spec);
78
+ specCache$2.set(type, spec);
60
79
  return spec;
61
80
  }
62
81
  const roleCandidates = [];
@@ -87,7 +106,7 @@ function getArbacExtractSpec(type) {
87
106
  };
88
107
  }
89
108
  spec.userIdField = resolveIdentifierField(type);
90
- specCache.set(type, spec);
109
+ specCache$2.set(type, spec);
91
110
  return spec;
92
111
  }
93
112
  /**
@@ -127,6 +146,8 @@ const useFetchCache = defineWook((ctx) => {
127
146
  return cache;
128
147
  });
129
148
  let AtscriptArbacUserProvider = class AtscriptArbacUserProvider extends ArbacUserProvider {
149
+ userType;
150
+ table;
130
151
  spec;
131
152
  projection;
132
153
  withClause;
@@ -167,22 +188,10 @@ let AtscriptArbacUserProvider = class AtscriptArbacUserProvider extends ArbacUse
167
188
  if (!roleField) return [];
168
189
  const raw = record[roleField.name];
169
190
  if (raw === void 0 || raw === null) return [];
170
- const seen = /* @__PURE__ */ new Set();
171
- const out = [];
172
- const push = (val) => {
173
- if (typeof val === "string" && val !== "" && !seen.has(val)) {
174
- seen.add(val);
175
- out.push(val);
176
- }
177
- };
178
- if (roleField.shape === "inline") if (Array.isArray(raw)) for (const item of raw) push(item);
179
- else push(raw);
180
- else {
181
- if (!Array.isArray(raw)) return out;
182
- const idField = roleField.roleTargetIdField;
183
- for (const item of raw) if (item && typeof item === "object") push(item[idField]);
184
- }
185
- return out;
191
+ if (roleField.shape === "inline") return uniqueStrings(Array.isArray(raw) ? raw : [raw]);
192
+ if (!Array.isArray(raw)) return [];
193
+ const idField = roleField.roleTargetIdField;
194
+ return uniqueStrings(raw.map((item) => item && typeof item === "object" ? item[idField] : void 0));
186
195
  }
187
196
  /**
188
197
  * Override seam: `@arbac.attribute` fields keyed by prop name. Undefined
@@ -226,4 +235,162 @@ let AtscriptArbacUserProvider = class AtscriptArbacUserProvider extends ArbacUse
226
235
  };
227
236
  AtscriptArbacUserProvider = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [Object, Object])], AtscriptArbacUserProvider);
228
237
  //#endregion
229
- export { AoothArbacUserCredentials, AtscriptArbacUserProvider };
238
+ //#region src/atscript/attenuation-extract.ts
239
+ const specCache$1 = /* @__PURE__ */ new WeakMap();
240
+ /**
241
+ * Walk a credential model's `@arbac.attenuate.*` annotations into a cached
242
+ * {@link ArbacAttenuationSpec}. Mirrors the user-side `getArbacExtractSpec`:
243
+ * structural, computed once, fail-loud on an ambiguous (multiple) role field.
244
+ */
245
+ function getArbacAttenuationSpec(credType) {
246
+ const cached = specCache$1.get(credType);
247
+ if (cached !== void 0) return cached;
248
+ const spec = {
249
+ roleField: void 0,
250
+ attrFields: []
251
+ };
252
+ const def = credType.type;
253
+ if (def.kind !== "object") {
254
+ specCache$1.set(credType, spec);
255
+ return spec;
256
+ }
257
+ const roleCandidates = [];
258
+ for (const [fieldName, fieldType] of def.props) {
259
+ const md = fieldType.metadata;
260
+ if (md.get("arbac.attenuate.role")) roleCandidates.push(fieldName);
261
+ const userAttr = md.get("arbac.attenuate.attr");
262
+ if (typeof userAttr === "string" && userAttr !== "") spec.attrFields.push({
263
+ field: fieldName,
264
+ userAttr
265
+ });
266
+ }
267
+ if (roleCandidates.length > 1) throw new Error(`getArbacAttenuationSpec: multiple @arbac.attenuate.role fields declared (${roleCandidates.join(", ")}). Exactly one assumed-role source is supported — drop @arbac.attenuate.role from all but the canonical field.`);
268
+ if (roleCandidates.length === 1) spec.roleField = roleCandidates[0];
269
+ specCache$1.set(credType, spec);
270
+ return spec;
271
+ }
272
+ /**
273
+ * Boot-time cross-model check: every `@arbac.attenuate.attr "userAttr"` target
274
+ * must name a real key in the user model's `@arbac.attribute` keyspace
275
+ * (`validUserAttrs`), else a typo silently no-ops the narrowing. Throws on the
276
+ * first miss. Call once at provider construction; safe and cheap (the spec is
277
+ * cached). Soundness does NOT depend on this — the scope conjunction clips
278
+ * regardless — but it turns a silent misconfig into a loud boot failure.
279
+ */
280
+ function validateAttenuationTargets(credType, validUserAttrs) {
281
+ const spec = getArbacAttenuationSpec(credType);
282
+ const valid = new Set(validUserAttrs);
283
+ for (const { field, userAttr } of spec.attrFields) if (!valid.has(userAttr)) throw new Error(`validateAttenuationTargets: @arbac.attenuate.attr "${userAttr}" on field "${field}" targets a user attribute that does not exist in the user model's @arbac.attribute keyspace (${[...valid].join(", ") || "<none>"}). Fix the annotation argument.`);
284
+ }
285
+ /** Lenient role parse: accept `string | string[]`, drop empty/non-string, dedupe. */
286
+ function parseRoles(raw) {
287
+ return uniqueStrings(Array.isArray(raw) ? raw : [raw]);
288
+ }
289
+ /**
290
+ * Build the restrict-only {@link AoothArbacClaims} for a credential `record`
291
+ * (its typed root fields — e.g. the auth context, which carries the credential
292
+ * payload flat) by reading the fields the credential model annotates. Designed
293
+ * to back a consumer's `ArbacUserProvider.getAttenuation()` override while
294
+ * arbac-moost stays auth-agnostic (the consumer supplies the record).
295
+ *
296
+ * Returns:
297
+ * - `undefined` when the model declares NO attenuation fields, OR a normal
298
+ * (non-attenuated) token carries none of them set → full user authority.
299
+ * - `{ roles, attrs }` otherwise. The narrowing is then clipped by the engine's
300
+ * scope conjunction, so it can never widen beyond the user.
301
+ *
302
+ * Fail-closed: when the assumed-role field IS set but holds a malformed value
303
+ * (no usable role strings), {@link parseRoles} yields `[]` — deny-all — never
304
+ * full authority. Absent (`undefined`/`null`) = the field was not set, so roles
305
+ * are omitted (attrs-only narrowing), which is the distinct "no role narrowing"
306
+ * case, not a malformed one.
307
+ */
308
+ function extractAttenuation(credType, record) {
309
+ const spec = getArbacAttenuationSpec(credType);
310
+ if (spec.roleField === void 0 && spec.attrFields.length === 0) return void 0;
311
+ if (!record) return void 0;
312
+ const rec = record;
313
+ let roles;
314
+ if (spec.roleField !== void 0) {
315
+ const raw = rec[spec.roleField];
316
+ if (raw !== void 0 && raw !== null) roles = parseRoles(raw);
317
+ }
318
+ let attrs;
319
+ for (const { field, userAttr } of spec.attrFields) {
320
+ const value = rec[field];
321
+ if (value !== void 0 && value !== null) {
322
+ attrs ??= {};
323
+ attrs[userAttr] = value;
324
+ }
325
+ }
326
+ if (roles === void 0 && attrs === void 0) return void 0;
327
+ const out = {};
328
+ if (roles !== void 0) out.roles = roles;
329
+ if (attrs !== void 0) out.attrs = attrs;
330
+ return out;
331
+ }
332
+ //#endregion
333
+ //#region src/atscript/handle-spec.ts
334
+ const specCache = /* @__PURE__ */ new WeakMap();
335
+ /** A `@db.index.unique` annotation is a non-empty `(string | true)[]`. */
336
+ function hasUniqueIndex(md) {
337
+ const unique = md.get("db.index.unique");
338
+ return Array.isArray(unique) && unique.length > 0;
339
+ }
340
+ /**
341
+ * Resolve a single handle field for one role: fail loud on ambiguity (more than
342
+ * one annotated field — there is no canonical choice), and warn-and-disable when
343
+ * the sole annotated field lacks a unique index.
344
+ */
345
+ function resolveHandleField(role, annotation, candidates, warnings) {
346
+ if (candidates.length === 0) return void 0;
347
+ if (candidates.length > 1) throw new Error(`getAoothUserHandleSpec: multiple ${annotation} fields declared (${candidates.map((c) => c.field).join(", ")}). Exactly one ${role} login handle is supported — drop ${annotation} from all but the canonical field.`);
348
+ const { field, md } = candidates[0];
349
+ if (!hasUniqueIndex(md)) {
350
+ warnings.push(`${annotation} on field "${field}" has no @db.index.unique — a login handle must resolve to at most one row, so the ${role} handle is DISABLED (login/recovery by ${role} unavailable). Add @db.index.unique to the field to enable it.`);
351
+ return;
352
+ }
353
+ return field;
354
+ }
355
+ /**
356
+ * Walk a user model's `@aooth.user.*` annotations into a cached
357
+ * {@link AoothUserHandleSpec}. Mirrors {@link getArbacAttenuationSpec}:
358
+ * structural, computed once, fail-loud on ambiguity. A model with no
359
+ * `@aooth.user.*` field resolves to all-`undefined` handles (email/phone login
360
+ * is simply unavailable — graceful degradation, not an error).
361
+ */
362
+ function getAoothUserHandleSpec(userType) {
363
+ const cached = specCache.get(userType);
364
+ if (cached !== void 0) return cached;
365
+ const spec = {
366
+ emailField: void 0,
367
+ phoneField: void 0,
368
+ handleFields: [],
369
+ warnings: []
370
+ };
371
+ const def = userType.type;
372
+ if (def.kind !== "object") {
373
+ specCache.set(userType, spec);
374
+ return spec;
375
+ }
376
+ const emailCandidates = [];
377
+ const phoneCandidates = [];
378
+ for (const [fieldName, fieldType] of def.props) {
379
+ const md = fieldType.metadata;
380
+ if (md.get("aooth.user.email")) emailCandidates.push({
381
+ field: fieldName,
382
+ md
383
+ });
384
+ if (md.get("aooth.user.phone")) phoneCandidates.push({
385
+ field: fieldName,
386
+ md
387
+ });
388
+ }
389
+ spec.emailField = resolveHandleField("email", "@aooth.user.email", emailCandidates, spec.warnings);
390
+ spec.phoneField = resolveHandleField("phone", "@aooth.user.phone", phoneCandidates, spec.warnings);
391
+ spec.handleFields = [spec.emailField, spec.phoneField].filter((f) => f !== void 0);
392
+ specCache.set(userType, spec);
393
+ return spec;
394
+ }
395
+ //#endregion
396
+ export { AoothArbacUserCredentials, AtscriptArbacUserProvider, extractAttenuation, getAoothUserHandleSpec, getArbacAttenuationSpec, validateAttenuationTargets };
package/dist/index.d.mts CHANGED
@@ -1,12 +1,11 @@
1
- import { n as ArbacUserProviderToken, t as ArbacUserProvider } from "./user.provider-JpI5dD6Z.mjs";
1
+ import { a as ArbacDbScope, c as enforceControlsPolicy, d as NavRelationKey, f as NavTarget, i as conjoinArbacDbScopes, l as extractUsedControlValues, m as ProjectionOf, n as ArbacUserProviderToken, o as AsArbacDbController, p as OwnFieldKey, r as AoothArbacClaims, s as applyAllowedFieldsAndSet, t as ArbacUserProvider, u as ControlsOf } from "./user.provider-CKPMp3G8.mjs";
2
2
  import { Arbac, Arbac as Arbac$1, TArbacCompiledRule, TArbacEvalResult, TArbacRole, TArbacRoleForResource, TArbacRule, arbacPatternToRegex } from "@aooth/arbac-core";
3
3
  import { TAuthGuardDef } from "@moostjs/event-http";
4
4
  import { EventContext } from "@wooksjs/event-core";
5
5
  import { Mate, TMateParamMeta, TMoostMetadata } from "moost";
6
- import { ControlGate, ControlGate as ControlGate$1, TProjection, TScopeFilter } from "@aooth/arbac";
7
- import { AsDbController, AsDbReadableController } from "@atscript/moost-db";
8
- import { NavPropsOf, OwnPropsOf, TAtscriptAnnotatedType } from "@atscript/typescript/utils";
9
- import { TMetaResponse } from "@atscript/db";
6
+ import { ControlGate, TProjection } from "@aooth/arbac";
7
+ import { AsDbReadableController } from "@atscript/moost-db";
8
+ import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
10
9
 
11
10
  //#region src/arbac.composables.d.ts
12
11
  interface ArbacBindings {
@@ -112,217 +111,6 @@ type ArbacMate = Mate<TMoostMetadata & {
112
111
  */
113
112
  declare function getArbacMate(): ArbacMate;
114
113
  //#endregion
115
- //#region src/db/scope-types.d.ts
116
- /**
117
- * Unwrap a `NavPropsOf<T>[K]` value to its target model type:
118
- * `Comment[]` → `Comment`, `Task` → `Task`. Used to recurse `with.<K>` into
119
- * the joined model's own type so per-relation scopes get typed against the
120
- * RIGHT entity (not the array wrapper).
121
- */
122
- type NavTarget<U> = U extends readonly (infer V)[] ? V : U;
123
- /**
124
- * True only when `T` is `unknown` (or `any`). `unknown extends T` holds for
125
- * both. Callers use this to pick the legacy untyped shape when no model is
126
- * annotated.
127
- */
128
- type IsUnknown<T> = unknown extends T ? true : false;
129
- /**
130
- * Permissive own-field key set. With a typed `T`, autocompletes against
131
- * `keyof OwnPropsOf<T>` while still accepting dotted-path projections like
132
- * `mfa.value` via the `(string & {})` escape hatch. With `T = unknown`,
133
- * `OwnPropsOf<unknown>` collapses to `unknown` so `keyof` is `never` →
134
- * falls back to plain `string`.
135
- */
136
- type OwnFieldKey<T> = keyof OwnPropsOf<T> extends never ? string : (keyof OwnPropsOf<T> & string) | (string & {});
137
- /**
138
- * Same idiom for nav relation names. `NavPropsOf<unknown>` is
139
- * `Record<string, never>`, so its `keyof` is `string` — the typed branch
140
- * still resolves to plain `string` (the `(string & {})` escape collapses
141
- * with `string`).
142
- */
143
- type NavRelationKey<T> = keyof NavPropsOf<T> extends never ? string : (keyof NavPropsOf<T> & string) | (string & {});
144
- /**
145
- * Typed projection keyed by own-field paths (with dotted-path escape).
146
- * Falls back to the legacy `TProjection = Record<string, 0 | 1>` for
147
- * untyped scopes so existing callers that pass the result straight to
148
- * `unionProjections` / `restrictProjection` keep compiling.
149
- */
150
- type ProjectionOf<T> = IsUnknown<T> extends true ? TProjection : Partial<Record<OwnFieldKey<T>, 0 | 1>>;
151
- /**
152
- * Typed control gates: `$with` against nav names, `$select` against own
153
- * fields, other `$`-prefixed controls passthrough. Falls back to the
154
- * legacy untyped `Record<string, ControlGate>` for `T = unknown` so
155
- * internal helpers (`unionControlsPolicy`) and existing untyped call
156
- * sites keep compiling.
157
- */
158
- type ControlsOf<T> = IsUnknown<T> extends true ? Record<string, ControlGate$1> : {
159
- $with?: Array<NavRelationKey<T>> | boolean;
160
- $select?: Array<OwnFieldKey<T>> | boolean;
161
- $groupBy?: ControlGate$1;
162
- $having?: ControlGate$1;
163
- [key: `$${string}`]: ControlGate$1 | Array<string> | undefined;
164
- };
165
- //#endregion
166
- //#region src/db/as-arbac-db-controller.d.ts
167
- /**
168
- * Contract returned by an ARBAC role's scope predicate on a DB-backed resource.
169
- *
170
- * Apps can extend this interface with their own fields via TypeScript
171
- * declaration merging — both at role-definition time (returned from
172
- * `role.allow(...)`) and at consumption time (custom controller overrides
173
- * reading `scopes[i].myCustomField` get full type safety):
174
- *
175
- * @example
176
- * ```ts
177
- * declare module '@aooth/arbac-moost' {
178
- * interface ArbacDbScope {
179
- * tenantId?: string
180
- * }
181
- * }
182
- * ```
183
- */
184
- interface ArbacDbScope<T = unknown> {
185
- filter?: TScopeFilter;
186
- projection?: ProjectionOf<T>;
187
- set?: Partial<Record<OwnFieldKey<T>, unknown>>;
188
- allowedFields?: Array<OwnFieldKey<T>>;
189
- /**
190
- * Per-control gates for Uniquery URL controls (`$with`, `$groupBy`, `$having`, …).
191
- * Evaluated by {@link AsArbacDbController.validateControls} before query execution;
192
- * a violation throws `HttpError(403)`.
193
- *
194
- * Per-key semantics ({@link ControlGate}):
195
- * - absent / `true` — allowed.
196
- * - `false` — denied entirely.
197
- * - `readonly string[]` — whitelist (e.g. `{ $with: ['comments'] }` allows
198
- * `?$with=comments`, rejects `?$with=tasks`). Supported only for `$with`
199
- * (relation names) and `$groupBy` (column names) in v1.
200
- *
201
- * Across roles, gates union additively (silence wins) via {@link unionControlsPolicy}.
202
- *
203
- * @example `{ controls: { $with: false } }` — disable $with for this role.
204
- * @example `{ controls: { $with: ['comments', 'owner'] } }` — restrict relations.
205
- */
206
- controls?: ControlsOf<T>;
207
- /**
208
- * Per-relation sub-scopes applied when the request expands a relation via
209
- * `?$with=<name>`. Recursive — each sub-scope has the same shape and can
210
- * declare its own `with` for nested expansions (e.g. tasks → comments → task).
211
- *
212
- * **Authority model**: the PARENT scope owns the policy for joined rows.
213
- * arbac-moost does NOT re-evaluate ARBAC against the joined resource's own
214
- * scopes — that would be a confusing indirection and a perf hit. Whatever
215
- * the parent declares here is what surfaces from the expansion.
216
- *
217
- * **Union across roles**: when multiple roles allow the same parent table,
218
- * their `with[name]` sub-scopes are unioned at every nested level using the
219
- * existing `unionProjections` / `mergeScopeFilters` / `unionControlsPolicy`
220
- * primitives (additive: broader access wins, same rules as the parent).
221
- *
222
- * **Silence wins**: if no role declares `with.<name>`, expansion is
223
- * unrestricted (matches `controls.$with` whitelist semantics; the gate
224
- * still applies if declared).
225
- */
226
- with?: WithOf<T>;
227
- }
228
- /**
229
- * Per-relation sub-scope map. For `T = unknown` falls back to the legacy
230
- * untyped `Record<string, ArbacDbScope>`. With a typed `T`, each known
231
- * relation key gets its scope typed against the joined model (via
232
- * `NavTarget` to unwrap arrays), while arbitrary `(string & {})` keys
233
- * keep the untyped escape hatch. Lives here (not in `scope-types.ts`)
234
- * because it must reference `ArbacDbScope` recursively.
235
- */
236
- type WithOf<T> = unknown extends T ? Record<string, ArbacDbScope> : { [K in NavRelationKey<T>]?: K extends keyof NavPropsOf<T> ? ArbacDbScope<NavTarget<NavPropsOf<T>[K]>> : ArbacDbScope };
237
- declare class AsArbacDbController<T extends TAtscriptAnnotatedType = TAtscriptAnnotatedType> extends AsDbController<T> {
238
- protected transformFilter(filter: Record<string, unknown> | undefined): Promise<Record<string, unknown>>;
239
- protected transformProjection(projection?: TProjection): TProjection | undefined | Promise<TProjection | undefined>;
240
- /**
241
- * Translate `@uniqu/url` parser failures into HTTP 400 instead of letting them
242
- * bubble as 500. The base `parseQueryString` calls `parseUrl()` directly with
243
- * no try/catch, so a malformed `?status=...` (e.g. unquoted single quote at a
244
- * non-string position) surfaces as a server error — misleading, since the
245
- * server is fine and the client sent bad input.
246
- *
247
- * Narrow targeting: only `SyntaxError` raised from inside the parser is
248
- * remapped. Other errors (e.g. a programmer-introduced TypeError in a future
249
- * override) still bubble as 500. SQL-injection-shaped payloads remain safely
250
- * handled either way — see SEC-17 for the parameterisation invariant; this
251
- * override is an orthogonal robustness pin around the parse step.
252
- */
253
- protected parseQueryString(url: string): ReturnType<AsDbController<T>["parseQueryString"]>;
254
- /**
255
- * Same parser-error → 400 remap as {@link parseQueryString}, applied to the
256
- * `/one` and `/one?…` code paths which use `parseControlsOnlyFromUrl`.
257
- */
258
- protected parseControlsOnlyFromUrl(url: string): ReturnType<AsDbController<T>["parseControlsOnlyFromUrl"]>;
259
- /**
260
- * Enforce per-role `ArbacDbScope.controls` gates against the parsed Uniquery
261
- * controls of a request. Runs after the base validator (which checks the
262
- * controls DTO shape) and BEFORE the query/aggregation pipeline executes.
263
- *
264
- * `transformFilter` runs first on every read endpoint and caches the
265
- * evaluated scopes via `arbac.setScopes(...)`, so we read those scopes
266
- * directly here without re-evaluating ARBAC.
267
- *
268
- * On a violation we throw `HttpError(403)`. moost-db's `query` / `pages` /
269
- * `getOne` handlers do NOT wrap `validateParsed` in try/catch, so the
270
- * thrown error bubbles to moost which translates it to a 403 response.
271
- */
272
- protected validateControls(controls: Record<string, unknown>, type: "query" | "pages" | "getOne"): string | undefined;
273
- protected applyMetaOverlay(meta: TMetaResponse): Promise<TMetaResponse>;
274
- protected onWrite(action: "insert" | "insertMany" | "replace" | "replaceMany" | "update" | "updateMany", data: unknown): Promise<unknown>;
275
- protected onRemove(id: unknown): Promise<unknown>;
276
- private assertInScope;
277
- private identifierFields;
278
- }
279
- /**
280
- * Test-friendly internal helper — exported for unit tests and helper
281
- * composition; regular consumers should not call this directly.
282
- *
283
- * Enforce a per-control policy against a parsed Uniquery `controls` map.
284
- *
285
- * Throws `HttpError(403)` on the first violation. Pure (no DI) so it is
286
- * trivially unit-testable; `validateControls` wires it up to the controller's
287
- * cached scopes.
288
- *
289
- * Semantics per gate (see {@link ControlGate}):
290
- * - `true` (or absent — dropped by `unionControlsPolicy`): allow.
291
- * - `false`: deny if the control is used at all.
292
- * - `readonly string[]`: allow only the listed values; reject any other.
293
- *
294
- * "Used" means the control key is present AND non-empty (an empty array is
295
- * treated as not used, matching how the parser leaves missing controls).
296
- */
297
- declare function enforceControlsPolicy(policy: Record<string, ControlGate$1>, controls: Record<string, unknown>): void;
298
- /**
299
- * Test-friendly internal helper — exported for unit tests and helper
300
- * composition; regular consumers should not call this directly.
301
- *
302
- * Extract the set of "named values" from a Uniquery control payload, for
303
- * use against a whitelist gate.
304
- *
305
- * Currently supported (matches `WHITELISTABLE_CONTROLS` in `unionControlsPolicy`):
306
- * - `$with` — array of `{ name, … }` objects (per `TypedWithRelation`,
307
- * see `@uniqu/core` parser at `parseWithSegment`); we extract `name`.
308
- * Bare strings are tolerated for forward compatibility.
309
- * - `$groupBy` — array of column names (strings). Returned as-is.
310
- *
311
- * For unknown controls we return an empty array; the caller then enforces
312
- * `false`-only semantics (controlled by `unionControlsPolicy`'s whitelist
313
- * gate, which throws if a non-whitelistable control receives a string[]).
314
- */
315
- declare function extractUsedControlValues(key: string, value: unknown): string[];
316
- /**
317
- * Test-friendly internal helper — exported for unit tests and helper
318
- * composition; regular consumers should not call this directly.
319
- *
320
- * Apply the union of `allowedFields` whitelists (with `preserveFields`
321
- * always preserved) and overlay each scope's `set` overrides. Returns a
322
- * shallow copy; original `data` is not mutated.
323
- */
324
- declare function applyAllowedFieldsAndSet(data: unknown, scopes: ArbacDbScope[], preserveFields?: readonly string[]): unknown;
325
- //#endregion
326
114
  //#region src/db/as-arbac-db-readable-controller.d.ts
327
115
  /**
328
116
  * Read-only mirror of {@link AsArbacDbController} for view-style controllers
@@ -350,4 +138,4 @@ declare class AsArbacDbReadableController<T extends TAtscriptAnnotatedType = TAt
350
138
  */
351
139
  declare class MoostArbac<TUserAttrs extends object, TScope extends object> extends Arbac$1<TUserAttrs, TScope> {}
352
140
  //#endregion
353
- export { Arbac, ArbacAction, ArbacAuthorize, ArbacDbScope, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, type ControlGate, type ControlsOf, MoostArbac, type NavRelationKey, type NavTarget, type OwnFieldKey, type ProjectionOf, type TArbacCompiledRule, type TArbacEvalResult, TArbacMeta, type TArbacRole, type TArbacRoleForResource, type TArbacRule, applyAllowedFieldsAndSet, arbacAuthorizeInterceptor, arbacPatternToRegex, enforceControlsPolicy, extractUsedControlValues, getArbacMate, useArbac };
141
+ export { type AoothArbacClaims, Arbac, ArbacAction, ArbacAuthorize, ArbacDbScope, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, type ControlGate, type ControlsOf, MoostArbac, type NavRelationKey, type NavTarget, type OwnFieldKey, type ProjectionOf, type TArbacCompiledRule, type TArbacEvalResult, TArbacMeta, type TArbacRole, type TArbacRoleForResource, type TArbacRule, applyAllowedFieldsAndSet, arbacAuthorizeInterceptor, arbacPatternToRegex, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, useArbac };