@dereekb/firebase-server 13.1.0 → 13.2.1

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/index.cjs.js CHANGED
@@ -7,6 +7,7 @@ var date = require('@dereekb/date');
7
7
  var makeError = require('make-error');
8
8
  var rxjs = require('rxjs');
9
9
  var firestore = require('@google-cloud/firestore');
10
+ var crypto = require('crypto');
10
11
  var common = require('@nestjs/common');
11
12
  var nestjs = require('@dereekb/nestjs');
12
13
  var v2 = require('firebase-functions/v2');
@@ -271,7 +272,34 @@ class FirebaseServerAuthNewUserSendSetupDetailsSendOnceError extends makeError.B
271
272
  }
272
273
  }
273
274
 
274
- const DEFAULT_FIREBASE_PASSWORD_NUMBER_GENERATOR = util.randomNumberFactory({ min: 100000, max: 1000000, round: 'floor' }); // 6 digits
275
+ /**
276
+ * Generates a random 6-digit number for use as a temporary password or reset token.
277
+ *
278
+ * Used internally by {@link AbstractFirebaseServerAuthUserContext.beginResetPassword} and
279
+ * {@link AbstractFirebaseServerNewUserService.generateRandomSetupPassword} for one-time codes.
280
+ *
281
+ * @example
282
+ * ```typescript
283
+ * const pin = DEFAULT_FIREBASE_PASSWORD_NUMBER_GENERATOR(); // e.g. 482910
284
+ * ```
285
+ */
286
+ const DEFAULT_FIREBASE_PASSWORD_NUMBER_GENERATOR = util.randomNumberFactory({ min: 100000, max: 1000000, round: 'floor' });
287
+ /**
288
+ * Base implementation of {@link FirebaseServerAuthUserContext} that manages a single user's
289
+ * auth state (record, claims, roles, password) through the Firebase Admin Auth API.
290
+ *
291
+ * Caches the user record on first load and resets the cache automatically when claims are modified
292
+ * via {@link setClaims}. Subclass this to bind it to a specific {@link FirebaseServerAuthService} type.
293
+ *
294
+ * @example
295
+ * ```typescript
296
+ * export class MyAuthUserContext extends AbstractFirebaseServerAuthUserContext<MyAuthService> {}
297
+ *
298
+ * const ctx = new MyAuthUserContext(authService, 'some-uid');
299
+ * const roles = await ctx.loadRoles();
300
+ * await ctx.addRoles(AUTH_ADMIN_ROLE);
301
+ * ```
302
+ */
275
303
  class AbstractFirebaseServerAuthUserContext {
276
304
  _service;
277
305
  _uid;
@@ -295,6 +323,9 @@ class AbstractFirebaseServerAuthUserContext {
295
323
  loadDetails() {
296
324
  return this.loadRecord().then((record) => this.service.authDetailsForRecord(record));
297
325
  }
326
+ /**
327
+ * Generates a random numeric string for use as a temporary reset password.
328
+ */
298
329
  _generateResetPasswordKey() {
299
330
  return String(DEFAULT_FIREBASE_PASSWORD_NUMBER_GENERATOR());
300
331
  }
@@ -319,9 +350,6 @@ class AbstractFirebaseServerAuthUserContext {
319
350
  return undefined;
320
351
  }
321
352
  }
322
- /**
323
- * Sets the user's password.
324
- */
325
353
  async setPassword(password) {
326
354
  const record = await this.updateUser({ password });
327
355
  // clear password reset claims
@@ -354,15 +382,19 @@ class AbstractFirebaseServerAuthUserContext {
354
382
  return this.updateClaims(claims);
355
383
  }
356
384
  /**
357
- * Sets the claims using the input roles and roles set.
385
+ * Replaces all role-based claims with those derived from the given roles.
358
386
  *
359
- * All other claims are cleared.
387
+ * All existing claims are cleared first. Use `claimsToRetain` to preserve non-role claims
388
+ * (e.g., setup or application-specific claims) through the replacement.
360
389
  *
361
- * Use the claimsToRetain input to retain other claims that are outside of the roles.
390
+ * @param roles - The complete set of roles to assign.
391
+ * @param claimsToRetain - Additional claims to merge in alongside the role-derived claims.
362
392
  *
363
- * @param roles
364
- * @param claimsToRetain
365
- * @returns
393
+ * @example
394
+ * ```typescript
395
+ * // Set roles while preserving a custom claim
396
+ * await userCtx.setRoles([AUTH_ADMIN_ROLE], { customFlag: 1 });
397
+ * ```
366
398
  */
367
399
  async setRoles(roles, claimsToRetain) {
368
400
  const claims = {
@@ -371,8 +403,11 @@ class AbstractFirebaseServerAuthUserContext {
371
403
  };
372
404
  return this.setClaims(claims);
373
405
  }
406
+ /**
407
+ * Converts roles to their corresponding claim keys, filtering out null/undefined entries
408
+ * that represent unrelated claims in the service's {@link FirebaseServerAuthService.claimsForRoles} output.
409
+ */
374
410
  _claimsForRolesChange(roles) {
375
- // filter null/undefined since the claims will contain null values for claims that are not related.
376
411
  return util.filterNullAndUndefinedValues(this.service.claimsForRoles(util.asSet(roles)));
377
412
  }
378
413
  loadClaims() {
@@ -402,6 +437,15 @@ class AbstractFirebaseServerAuthUserContext {
402
437
  });
403
438
  }
404
439
  }
440
+ /**
441
+ * Base implementation of {@link FirebaseServerAuthContext} with cached getters for roles, admin status,
442
+ * and ToS status to avoid redundant computation within a single request.
443
+ *
444
+ * @example
445
+ * ```typescript
446
+ * export class MyAuthContext extends AbstractFirebaseServerAuthContext<MyAuthContext, MyUserContext, MyAuthService> {}
447
+ * ```
448
+ */
405
449
  class AbstractFirebaseServerAuthContext {
406
450
  _service;
407
451
  _context;
@@ -443,15 +487,52 @@ class AbstractFirebaseServerAuthContext {
443
487
  }
444
488
  }
445
489
  /**
446
- * 1 hour
490
+ * Default throttle duration (1 hour) between setup content sends to prevent spam.
491
+ *
492
+ * Used by {@link AbstractFirebaseServerNewUserService.sendSetupContent} to rate-limit delivery.
447
493
  */
448
494
  const DEFAULT_SETUP_COM_THROTTLE_TIME = date.hoursToMs(1);
495
+ /**
496
+ * Resolves a {@link UserContextOrUid} to a concrete user context instance.
497
+ *
498
+ * If a string UID is provided, creates a new user context via the auth service.
499
+ * If an existing context is provided, returns it as-is.
500
+ *
501
+ * @param authService - The auth service to create a context from if needed.
502
+ * @param userContextOrUid - A user context or UID string.
503
+ *
504
+ * @example
505
+ * ```typescript
506
+ * const ctx = userContextFromUid(authService, 'some-uid');
507
+ * const sameCtx = userContextFromUid(authService, ctx); // returns ctx unchanged
508
+ * ```
509
+ */
449
510
  function userContextFromUid(authService, userContextOrUid) {
450
511
  const userContext = typeof userContextOrUid === 'string' ? authService.userContext(userContextOrUid) : userContextOrUid;
451
512
  return userContext;
452
513
  }
514
+ /**
515
+ * Base implementation of {@link FirebaseServerNewUserService} that handles user creation,
516
+ * setup claims management, throttled setup content delivery, and setup completion.
517
+ *
518
+ * Subclasses must implement {@link sendSetupContentToUser} to define how setup content
519
+ * (e.g., invitation email, SMS) is delivered to the user.
520
+ *
521
+ * @example
522
+ * ```typescript
523
+ * export class MyNewUserService extends AbstractFirebaseServerNewUserService<MyUserContext> {
524
+ * protected async sendSetupContentToUser(details: FirebaseServerAuthNewUserSetupDetails<MyUserContext>): Promise<void> {
525
+ * await this.emailService.sendInvite(details.userContext.uid, details.claims.setupPassword);
526
+ * }
527
+ * }
528
+ * ```
529
+ */
453
530
  class AbstractFirebaseServerNewUserService {
454
531
  _authService;
532
+ /**
533
+ * Minimum time between setup content sends. Defaults to {@link DEFAULT_SETUP_COM_THROTTLE_TIME} (1 hour).
534
+ * Override in subclasses to customize the throttle window.
535
+ */
455
536
  setupThrottleTime = DEFAULT_SETUP_COM_THROTTLE_TIME;
456
537
  constructor(authService) {
457
538
  this._authService = authService;
@@ -563,19 +644,15 @@ class AbstractFirebaseServerNewUserService {
563
644
  }
564
645
  return details;
565
646
  }
647
+ /**
648
+ * Records the current timestamp as the last setup content communication date in the user's claims.
649
+ */
566
650
  async updateSetupContentSentTime(details) {
567
651
  const setupCommunicationAt = date.toISODateString(new Date());
568
652
  await details.userContext.updateClaims({
569
653
  setupCommunicationAt
570
654
  });
571
655
  }
572
- /**
573
- * Update a user's claims to clear any setup-related content.
574
- *
575
- * Returns true if a user was updated.
576
- *
577
- * @param uid
578
- */
579
656
  async markUserSetupAsComplete(uid) {
580
657
  const userContext = this.authService.userContext(uid);
581
658
  const userExists = await userContext.exists();
@@ -584,6 +661,13 @@ class AbstractFirebaseServerNewUserService {
584
661
  }
585
662
  return userExists;
586
663
  }
664
+ /**
665
+ * Creates a new Firebase Auth user from the initialization input.
666
+ *
667
+ * Generates a random setup password if none is provided. Override to customize user creation behavior.
668
+ *
669
+ * @throws Throws if the Firebase Admin SDK rejects the user creation.
670
+ */
587
671
  async createNewUser(input) {
588
672
  const { uid, displayName, email, phone: phoneNumber, setupPassword: inputPassword } = input;
589
673
  const password = inputPassword ?? this.generateRandomSetupPassword();
@@ -602,6 +686,9 @@ class AbstractFirebaseServerNewUserService {
602
686
  generateRandomSetupPassword() {
603
687
  return `${DEFAULT_FIREBASE_PASSWORD_NUMBER_GENERATOR()}`;
604
688
  }
689
+ /**
690
+ * Clears setup-related claims (setup password and last communication date) from the user.
691
+ */
605
692
  async updateClaimsToClearUser(userContext) {
606
693
  await userContext.updateClaims({
607
694
  [firebase.FIREBASE_SERVER_AUTH_CLAIMS_SETUP_PASSWORD_KEY]: null,
@@ -609,18 +696,65 @@ class AbstractFirebaseServerNewUserService {
609
696
  });
610
697
  }
611
698
  }
699
+ /**
700
+ * No-op implementation of {@link AbstractFirebaseServerNewUserService} that skips sending setup content.
701
+ *
702
+ * Used as the default {@link FirebaseServerNewUserService} when no custom delivery mechanism is configured.
703
+ */
612
704
  class NoSetupContentFirebaseServerNewUserService extends AbstractFirebaseServerNewUserService {
613
- async sendSetupContentToUser(user) {
705
+ async sendSetupContentToUser(_details) {
614
706
  // send nothing.
615
707
  }
616
708
  }
617
709
  /**
618
- * FirebaseServer auth service that provides accessors to auth-related components.
710
+ * Abstract contract for a Firebase Server authentication service.
711
+ *
712
+ * Provides the core API for creating auth contexts from callable requests, managing user contexts,
713
+ * checking admin/ToS status, converting between roles and claims, and creating new users.
714
+ *
715
+ * Implement this by extending {@link AbstractFirebaseServerAuthService}, which provides default
716
+ * implementations for most methods and only requires `readRoles`, `claimsForRoles`,
717
+ * `userContext`, and `_context` to be defined.
718
+ *
719
+ * @example
720
+ * ```typescript
721
+ * class MyAuthService extends AbstractFirebaseServerAuthService<MyUserContext, MyAuthContext> {
722
+ * readRoles(claims: AuthClaims): AuthRoleSet { ... }
723
+ * claimsForRoles(roles: AuthRoleSet): AuthClaimsUpdate { ... }
724
+ * userContext(uid: string): MyUserContext { ... }
725
+ * protected _context(ctx: CallableContextWithAuthData): MyAuthContext { ... }
726
+ * }
727
+ * ```
619
728
  */
620
729
  class FirebaseServerAuthService {
621
730
  }
622
731
  /**
623
- * Abstract FirebaseServerAuthService implementation.
732
+ * Base implementation of {@link FirebaseServerAuthService} providing standard admin/ToS checks,
733
+ * auth context creation with assertion, and a default no-op new user service.
734
+ *
735
+ * Subclasses must implement:
736
+ * - {@link _context} - to create the concrete auth context type.
737
+ * - {@link userContext} - to create the concrete user context type.
738
+ * - {@link readRoles} - to define the claims-to-roles mapping.
739
+ * - {@link claimsForRoles} - to define the roles-to-claims mapping.
740
+ *
741
+ * @example
742
+ * ```typescript
743
+ * export class MyAuthService extends AbstractFirebaseServerAuthService<MyUserContext, MyAuthContext> {
744
+ * protected _context(context: CallableContextWithAuthData): MyAuthContext {
745
+ * return new MyAuthContext(this, context);
746
+ * }
747
+ * userContext(uid: string): MyUserContext {
748
+ * return new MyUserContext(this, uid);
749
+ * }
750
+ * readRoles(claims: AuthClaims): AuthRoleSet {
751
+ * return MY_CLAIMS_SERVICE.toRoles(claims);
752
+ * }
753
+ * claimsForRoles(roles: AuthRoleSet): AuthClaimsUpdate {
754
+ * return MY_CLAIMS_SERVICE.toClaims(roles);
755
+ * }
756
+ * }
757
+ * ```
624
758
  */
625
759
  class AbstractFirebaseServerAuthService {
626
760
  _auth;
@@ -1048,6 +1182,140 @@ function googleCloudFirestoreDrivers() {
1048
1182
  */
1049
1183
  const googleCloudFirestoreContextFactory = firebase.firestoreContextFactory(googleCloudFirestoreDrivers());
1050
1184
 
1185
+ // MARK: Encrypted Field
1186
+ /**
1187
+ * AES-256-GCM encryption constants.
1188
+ */
1189
+ const ENCRYPTED_FIELD_ALGORITHM = 'aes-256-gcm';
1190
+ const ENCRYPTED_FIELD_IV_LENGTH = 12;
1191
+ const ENCRYPTED_FIELD_AUTH_TAG_LENGTH = 16;
1192
+ const ENCRYPTED_FIELD_KEY_LENGTH = 32;
1193
+ /**
1194
+ * Resolves the encryption key Buffer from a secret source.
1195
+ *
1196
+ * @param source - The secret source configuration.
1197
+ * @returns A 32-byte Buffer for AES-256 encryption.
1198
+ * @throws Error if the resolved key is not 64 hex characters.
1199
+ */
1200
+ function resolveEncryptionKey(source) {
1201
+ let hex;
1202
+ if (typeof source === 'string') {
1203
+ hex = source;
1204
+ }
1205
+ else if (typeof source === 'function') {
1206
+ hex = source();
1207
+ }
1208
+ else {
1209
+ const envValue = process.env[source.env];
1210
+ if (!envValue) {
1211
+ throw new Error(`firestoreEncryptedField: environment variable "${source.env}" is not set.`);
1212
+ }
1213
+ hex = envValue;
1214
+ }
1215
+ if (hex.length !== ENCRYPTED_FIELD_KEY_LENGTH * 2) {
1216
+ throw new Error(`firestoreEncryptedField: expected a ${ENCRYPTED_FIELD_KEY_LENGTH * 2}-character hex key, got ${hex.length} characters.`);
1217
+ }
1218
+ return Buffer.from(hex, 'hex');
1219
+ }
1220
+ /**
1221
+ * Encrypts a JSON-serializable value to a base64-encoded string.
1222
+ *
1223
+ * Format: base64(IV (12 bytes) + ciphertext + authTag (16 bytes))
1224
+ *
1225
+ * @param value - The value to encrypt.
1226
+ * @param key - The 32-byte encryption key.
1227
+ * @returns The encrypted value as a base64 string.
1228
+ */
1229
+ function encryptValue(value, key) {
1230
+ const iv = crypto.randomBytes(ENCRYPTED_FIELD_IV_LENGTH);
1231
+ const cipher = crypto.createCipheriv(ENCRYPTED_FIELD_ALGORITHM, key, iv);
1232
+ const plaintext = JSON.stringify(value);
1233
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
1234
+ const authTag = cipher.getAuthTag();
1235
+ const combined = Buffer.concat([iv, encrypted, authTag]);
1236
+ return combined.toString('base64');
1237
+ }
1238
+ /**
1239
+ * Decrypts a base64-encoded string back to the original value.
1240
+ *
1241
+ * @param encoded - The base64-encoded encrypted string (IV + ciphertext + authTag).
1242
+ * @param key - The 32-byte encryption key.
1243
+ * @returns The decrypted value.
1244
+ */
1245
+ function decryptValue(encoded, key) {
1246
+ const combined = Buffer.from(encoded, 'base64');
1247
+ const iv = combined.subarray(0, ENCRYPTED_FIELD_IV_LENGTH);
1248
+ const authTag = combined.subarray(combined.length - ENCRYPTED_FIELD_AUTH_TAG_LENGTH);
1249
+ const ciphertext = combined.subarray(ENCRYPTED_FIELD_IV_LENGTH, combined.length - ENCRYPTED_FIELD_AUTH_TAG_LENGTH);
1250
+ const decipher = crypto.createDecipheriv(ENCRYPTED_FIELD_ALGORITHM, key, iv);
1251
+ decipher.setAuthTag(authTag);
1252
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
1253
+ return JSON.parse(decrypted.toString('utf8'));
1254
+ }
1255
+ /**
1256
+ * Creates a Firestore field mapping that encrypts/decrypts a JSON-serializable value
1257
+ * using AES-256-GCM. The value is stored in Firestore as a base64-encoded string.
1258
+ *
1259
+ * The encryption key is resolved from the configured secret source on each read/write,
1260
+ * allowing for key rotation via environment variable changes.
1261
+ *
1262
+ * @example
1263
+ * ```typescript
1264
+ * const jwksField = firestoreEncryptedField<JWKSet>({
1265
+ * secret: { env: 'FIRESTORE_ENCRYPTION_KEY' },
1266
+ * default: () => ({ keys: [] })
1267
+ * });
1268
+ * ```
1269
+ *
1270
+ * @template T - The JSON-serializable value type.
1271
+ * @param config - Encryption field configuration.
1272
+ * @returns A field mapping configuration for encrypted values.
1273
+ */
1274
+ function firestoreEncryptedField(config) {
1275
+ const { secret, default: defaultValue } = config;
1276
+ return firebase.firestoreField({
1277
+ default: defaultValue,
1278
+ fromData: (data) => {
1279
+ const key = resolveEncryptionKey(secret);
1280
+ return decryptValue(data, key);
1281
+ },
1282
+ toData: (value) => {
1283
+ const key = resolveEncryptionKey(secret);
1284
+ return encryptValue(value, key);
1285
+ }
1286
+ });
1287
+ }
1288
+ /**
1289
+ * Creates a Firestore field mapping for an optional encrypted field.
1290
+ *
1291
+ * When the value is null/undefined, it is stored/read as null. When present, it is
1292
+ * encrypted/decrypted using AES-256-GCM.
1293
+ *
1294
+ * @example
1295
+ * ```typescript
1296
+ * const optionalSecretField = optionalFirestoreEncryptedField<OAuthClientSecret>({
1297
+ * secret: { env: 'FIRESTORE_ENCRYPTION_KEY' }
1298
+ * });
1299
+ * ```
1300
+ *
1301
+ * @template T - The JSON-serializable value type.
1302
+ * @param config - Encryption field configuration.
1303
+ * @returns A field mapping configuration for optional encrypted values.
1304
+ */
1305
+ function optionalFirestoreEncryptedField(config) {
1306
+ const { secret } = config;
1307
+ return firebase.optionalFirestoreField({
1308
+ transformFromData: (data) => {
1309
+ const key = resolveEncryptionKey(secret);
1310
+ return decryptValue(data, key);
1311
+ },
1312
+ transformToData: (value) => {
1313
+ const key = resolveEncryptionKey(secret);
1314
+ return encryptValue(value, key);
1315
+ }
1316
+ });
1317
+ }
1318
+
1051
1319
  function assertContextHasAuth(context) {
1052
1320
  if (!isContextWithAuthData(context)) {
1053
1321
  throw unauthenticatedContextHasNoUidError();
@@ -1892,6 +2160,163 @@ exports.ConfigureFirebaseWebhookMiddlewareModule = __decorate([
1892
2160
  common.Module({})
1893
2161
  ], exports.ConfigureFirebaseWebhookMiddlewareModule);
1894
2162
 
2163
+ // MARK: Type Guards
2164
+ /**
2165
+ * Whether the details are specifier-level (has specifiers map).
2166
+ */
2167
+ function isOnCallSpecifierApiDetails(details) {
2168
+ return details != null && 'specifiers' in details;
2169
+ }
2170
+ /**
2171
+ * Whether the details are CRUD-model-level (has modelTypes map).
2172
+ */
2173
+ function isOnCallCrudModelApiDetails(details) {
2174
+ return details != null && 'modelTypes' in details;
2175
+ }
2176
+ /**
2177
+ * Whether the details are handler-level (leaf node — no specifiers or modelTypes).
2178
+ */
2179
+ function isOnCallHandlerApiDetails(details) {
2180
+ return details != null && !('specifiers' in details) && !('modelTypes' in details);
2181
+ }
2182
+ /**
2183
+ * Attaches API details metadata to a handler function.
2184
+ *
2185
+ * The handler function is provided in the config object alongside its metadata.
2186
+ * The function is returned unchanged but with the _apiDetails property set.
2187
+ * Compatible with all handler types (create, read, update, delete, specifier).
2188
+ *
2189
+ * When `optionalAuth: true` is set, also marks the function as not requiring auth
2190
+ * (same effect as optionalAuthContext). This avoids the composition issue where
2191
+ * optionalAuthContext(withApiDetails(...)) would lose the _apiDetails.
2192
+ *
2193
+ * @example
2194
+ * ```typescript
2195
+ * // Handler with api details (auth required by default)
2196
+ * export const createGuestbook: DemoCreateModelFunction<CreateGuestbookParams> = withApiDetails({
2197
+ * inputType: createGuestbookParamsType,
2198
+ * fn: async (request) => {
2199
+ * const { nest, auth, data } = request;
2200
+ * const result = await nest.guestbookActions.createGuestbook(data);
2201
+ * return onCallCreateModelResultWithDocs(await result());
2202
+ * }
2203
+ * });
2204
+ *
2205
+ * // Handler with optional auth
2206
+ * export const profileCreate: DemoCreateModelFunction<{}> = withApiDetails({
2207
+ * optionalAuth: true,
2208
+ * fn: async (request) => { ... }
2209
+ * });
2210
+ * ```
2211
+ */
2212
+ function withApiDetails(config) {
2213
+ const { optionalAuth, fn, ...apiDetails } = config;
2214
+ fn._apiDetails = apiDetails;
2215
+ if (optionalAuth) {
2216
+ fn._requireAuth = false;
2217
+ }
2218
+ return fn;
2219
+ }
2220
+ // MARK: Aggregation Utilities
2221
+ /**
2222
+ * Reads _apiDetails from a function if present.
2223
+ */
2224
+ function readApiDetails(fn) {
2225
+ return fn?._apiDetails;
2226
+ }
2227
+ /**
2228
+ * Aggregates _apiDetails from a specifier handler config object.
2229
+ *
2230
+ * Returns OnCallSpecifierApiDetails if any handlers have _apiDetails, otherwise undefined.
2231
+ */
2232
+ function aggregateSpecifierApiDetails(config) {
2233
+ const specifiers = {};
2234
+ let hasAny = false;
2235
+ for (const [key, handler] of Object.entries(config)) {
2236
+ const details = readApiDetails(handler);
2237
+ if (details != null) {
2238
+ // At the specifier level, details should be handler-level (OnCallModelFunctionApiDetails)
2239
+ specifiers[key] = details;
2240
+ hasAny = true;
2241
+ }
2242
+ }
2243
+ return hasAny ? { specifiers } : undefined;
2244
+ }
2245
+ /**
2246
+ * Aggregates _apiDetails from a model type map (used by onCallCreateModel, etc.).
2247
+ *
2248
+ * Returns OnCallCrudModelApiDetails if any handlers have _apiDetails, otherwise undefined.
2249
+ */
2250
+ function aggregateCrudModelApiDetails(map) {
2251
+ const modelTypes = {};
2252
+ let hasAny = false;
2253
+ for (const [key, handler] of Object.entries(map)) {
2254
+ const details = readApiDetails(handler);
2255
+ if (details != null) {
2256
+ modelTypes[key] = details;
2257
+ hasAny = true;
2258
+ }
2259
+ }
2260
+ return hasAny ? { modelTypes } : undefined;
2261
+ }
2262
+ /**
2263
+ * Aggregates _apiDetails from the top-level call model map.
2264
+ *
2265
+ * Returns OnCallModelApiDetails if any CRUD handlers have _apiDetails, otherwise undefined.
2266
+ */
2267
+ function aggregateModelApiDetails(map) {
2268
+ const result = {};
2269
+ let hasAny = false;
2270
+ for (const [call, handler] of Object.entries(map)) {
2271
+ const details = readApiDetails(handler);
2272
+ if (details != null) {
2273
+ result[call] = details;
2274
+ hasAny = true;
2275
+ }
2276
+ }
2277
+ return hasAny ? result : undefined;
2278
+ }
2279
+ /**
2280
+ * Extracts and pivots API details from a call model function into a model-first view.
2281
+ *
2282
+ * The internal aggregation tree is organized as CRUD → modelType. This function
2283
+ * pivots it to modelType → CRUD, which is the natural shape for MCP tool generation
2284
+ * and schema introspection.
2285
+ *
2286
+ * @param callModelFn The function returned by onCallModel(), or any object with _apiDetails.
2287
+ * @returns Model-first API details, or undefined if no _apiDetails are present.
2288
+ *
2289
+ * @example
2290
+ * ```typescript
2291
+ * const details = getModelApiDetails(demoCallModel);
2292
+ * // details.models['guestbook'].calls.create => { inputType: createGuestbookParamsType }
2293
+ * // details.models['profile'].calls.update => { specifiers: { _: {...}, username: {...} } }
2294
+ * ```
2295
+ */
2296
+ function getModelApiDetails(callModelFn) {
2297
+ const topDetails = readApiDetails(callModelFn);
2298
+ if (topDetails == null) {
2299
+ return undefined;
2300
+ }
2301
+ const models = {};
2302
+ // Pivot: iterate CRUD types, then model types within each
2303
+ for (const [callType, crudDetails] of Object.entries(topDetails)) {
2304
+ if (crudDetails == null) {
2305
+ continue;
2306
+ }
2307
+ for (const [modelType, modelDetails] of Object.entries(crudDetails.modelTypes)) {
2308
+ if (modelDetails == null) {
2309
+ continue;
2310
+ }
2311
+ if (!models[modelType]) {
2312
+ models[modelType] = { calls: {} };
2313
+ }
2314
+ models[modelType].calls[callType] = modelDetails;
2315
+ }
2316
+ }
2317
+ return Object.keys(models).length > 0 ? { models } : undefined;
2318
+ }
2319
+
1895
2320
  const nestFirebaseDoesNotExistError = (firebaseContextGrantedModelRoles) => {
1896
2321
  return modelNotAvailableError({
1897
2322
  data: {
@@ -1924,6 +2349,11 @@ function onCallSpecifierHandler(config) {
1924
2349
  }
1925
2350
  };
1926
2351
  fn._requireAuth = false;
2352
+ // Aggregate _apiDetails from handler functions in the config
2353
+ const specifierApiDetails = aggregateSpecifierApiDetails(config);
2354
+ if (specifierApiDetails != null) {
2355
+ fn._apiDetails = specifierApiDetails;
2356
+ }
1927
2357
  return fn;
1928
2358
  }
1929
2359
  function unknownModelCrudFunctionSpecifierError(specifier) {
@@ -1945,7 +2375,7 @@ function unknownModelCrudFunctionSpecifierError(specifier) {
1945
2375
  */
1946
2376
  function onCallModel(map, config = {}) {
1947
2377
  const { preAssert = () => undefined } = config;
1948
- return (request) => {
2378
+ const fn = (request) => {
1949
2379
  const call = request.data?.call;
1950
2380
  if (call) {
1951
2381
  const callFn = map[call];
@@ -1962,6 +2392,12 @@ function onCallModel(map, config = {}) {
1962
2392
  throw onCallModelMissingCallTypeError();
1963
2393
  }
1964
2394
  };
2395
+ // Aggregate _apiDetails from CRUD handlers in the map
2396
+ const modelApiDetails = aggregateModelApiDetails(map);
2397
+ if (modelApiDetails != null) {
2398
+ fn._apiDetails = modelApiDetails;
2399
+ }
2400
+ return fn;
1965
2401
  }
1966
2402
  function onCallModelMissingCallTypeError() {
1967
2403
  return badRequestError(util.serverError({
@@ -1982,7 +2418,7 @@ function onCallModelUnknownCallTypeError(call) {
1982
2418
  }
1983
2419
  function _onCallWithCallTypeFunction(map, config) {
1984
2420
  const { callType, crudType, preAssert = () => undefined, throwOnUnknownModelType } = config;
1985
- return (request) => {
2421
+ const fn = (request) => {
1986
2422
  const modelType = request.data?.modelType;
1987
2423
  const crudFn = map[modelType];
1988
2424
  if (crudFn) {
@@ -1999,6 +2435,12 @@ function _onCallWithCallTypeFunction(map, config) {
1999
2435
  throw throwOnUnknownModelType(modelType);
2000
2436
  }
2001
2437
  };
2438
+ // Aggregate _apiDetails from model type handlers in the map
2439
+ const crudModelApiDetails = aggregateCrudModelApiDetails(map);
2440
+ if (crudModelApiDetails != null) {
2441
+ fn._apiDetails = crudModelApiDetails;
2442
+ }
2443
+ return fn;
2002
2444
  }
2003
2445
 
2004
2446
  function onCallCreateModel(map, config = {}) {
@@ -2727,6 +3169,9 @@ exports.UNAVAILABLE_OR_DEACTIVATED_FUNCTION_ERROR_CODE = UNAVAILABLE_OR_DEACTIVA
2727
3169
  exports.UNKNOWN_SCHEDULED_FUNCTION_DEVELOPMENT_FUNCTION_NAME_CODE = UNKNOWN_SCHEDULED_FUNCTION_DEVELOPMENT_FUNCTION_NAME_CODE;
2728
3170
  exports.UNKNOWN_SCHEDULED_FUNCTION_DEVELOPMENT_FUNCTION_TYPE_CODE = UNKNOWN_SCHEDULED_FUNCTION_DEVELOPMENT_FUNCTION_TYPE_CODE;
2729
3171
  exports._onCallWithCallTypeFunction = _onCallWithCallTypeFunction;
3172
+ exports.aggregateCrudModelApiDetails = aggregateCrudModelApiDetails;
3173
+ exports.aggregateModelApiDetails = aggregateModelApiDetails;
3174
+ exports.aggregateSpecifierApiDetails = aggregateSpecifierApiDetails;
2730
3175
  exports.alreadyExistsError = alreadyExistsError;
2731
3176
  exports.appFirestoreModuleMetadata = appFirestoreModuleMetadata;
2732
3177
  exports.assertContextHasAuth = assertContextHasAuth;
@@ -2766,9 +3211,11 @@ exports.firebaseServerStorageModuleMetadata = firebaseServerStorageModuleMetadat
2766
3211
  exports.firebaseServerValidationError = firebaseServerValidationError;
2767
3212
  exports.firebaseServerValidationServerError = firebaseServerValidationServerError;
2768
3213
  exports.firestoreClientQueryConstraintFunctionsDriver = firestoreClientQueryConstraintFunctionsDriver;
3214
+ exports.firestoreEncryptedField = firestoreEncryptedField;
2769
3215
  exports.firestoreServerIncrementUpdateToUpdateData = firestoreServerIncrementUpdateToUpdateData;
2770
3216
  exports.forbiddenError = forbiddenError;
2771
3217
  exports.getAuthUserOrUndefined = getAuthUserOrUndefined;
3218
+ exports.getModelApiDetails = getModelApiDetails;
2772
3219
  exports.googleCloudFileMetadataToStorageMetadata = googleCloudFileMetadataToStorageMetadata;
2773
3220
  exports.googleCloudFirebaseStorageContextFactory = googleCloudFirebaseStorageContextFactory;
2774
3221
  exports.googleCloudFirebaseStorageDrivers = googleCloudFirebaseStorageDrivers;
@@ -2797,6 +3244,9 @@ exports.isAdminOrTargetUserInRequestData = isAdminOrTargetUserInRequestData;
2797
3244
  exports.isContextWithAuthData = isContextWithAuthData;
2798
3245
  exports.isFirebaseError = isFirebaseError;
2799
3246
  exports.isFirebaseHttpsError = isFirebaseHttpsError;
3247
+ exports.isOnCallCrudModelApiDetails = isOnCallCrudModelApiDetails;
3248
+ exports.isOnCallHandlerApiDetails = isOnCallHandlerApiDetails;
3249
+ exports.isOnCallSpecifierApiDetails = isOnCallSpecifierApiDetails;
2800
3250
  exports.makeBlockingFunctionWithHandler = makeBlockingFunctionWithHandler;
2801
3251
  exports.makeOnScheduleHandlerWithNestApplicationRequest = makeOnScheduleHandlerWithNestApplicationRequest;
2802
3252
  exports.makeScheduledFunctionDevelopmentFunction = makeScheduledFunctionDevelopmentFunction;
@@ -2822,12 +3272,14 @@ exports.onCallUpdateModel = onCallUpdateModel;
2822
3272
  exports.onScheduleHandlerWithNestApplicationFactory = onScheduleHandlerWithNestApplicationFactory;
2823
3273
  exports.onScheduleHandlerWithNestContextFactory = onScheduleHandlerWithNestContextFactory;
2824
3274
  exports.optionalAuthContext = optionalAuthContext;
3275
+ exports.optionalFirestoreEncryptedField = optionalFirestoreEncryptedField;
2825
3276
  exports.permissionDeniedError = permissionDeniedError;
2826
3277
  exports.phoneNumberAlreadyExistsError = phoneNumberAlreadyExistsError;
2827
3278
  exports.preconditionConflictError = preconditionConflictError;
2828
3279
  exports.provideAppFirestoreCollections = provideAppFirestoreCollections;
2829
3280
  exports.provideFirebaseServerAuthService = provideFirebaseServerAuthService;
2830
3281
  exports.provideFirebaseServerStorageService = provideFirebaseServerStorageService;
3282
+ exports.readApiDetails = readApiDetails;
2831
3283
  exports.readModelUnknownModelTypeError = readModelUnknownModelTypeError;
2832
3284
  exports.setNestContextOnRequest = setNestContextOnRequest;
2833
3285
  exports.setNestContextOnScheduleRequest = setNestContextOnScheduleRequest;
@@ -2843,4 +3295,5 @@ exports.unknownScheduledFunctionDevelopmentFunctionType = unknownScheduledFuncti
2843
3295
  exports.updateModelUnknownModelTypeError = updateModelUnknownModelTypeError;
2844
3296
  exports.userContextFromUid = userContextFromUid;
2845
3297
  exports.verifyAppCheckInRequest = verifyAppCheckInRequest;
3298
+ exports.withApiDetails = withApiDetails;
2846
3299
  //# sourceMappingURL=index.cjs.js.map