@dereekb/firebase-server 13.36.0 → 13.38.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.
Files changed (45) hide show
  1. package/calcom/package.json +10 -10
  2. package/discord/package.json +10 -10
  3. package/index.cjs.js +1732 -510
  4. package/index.esm.js +1717 -513
  5. package/mailgun/package.json +9 -9
  6. package/mcp/index.cjs.js +913 -167
  7. package/mcp/index.esm.js +906 -166
  8. package/mcp/package.json +13 -12
  9. package/mcp/src/lib/controller/mcp.controller.d.ts +28 -3
  10. package/mcp/src/lib/controller/mcp.wellknown.controller.d.ts +21 -1
  11. package/mcp/src/lib/mcp.config.d.ts +31 -8
  12. package/mcp/src/lib/mcp.module.d.ts +2 -1
  13. package/mcp/src/lib/service/index.d.ts +1 -0
  14. package/mcp/src/lib/service/mcp.server.factory.d.ts +8 -6
  15. package/mcp/src/lib/service/mcp.tool-generator.d.ts +1 -1
  16. package/mcp/src/lib/service/mcp.visibility.d.ts +1 -1
  17. package/mcp/src/lib/service/tools/mcp.tool.model-roles.d.ts +95 -0
  18. package/mcp/src/lib/transport/streamable-http.transport.d.ts +16 -11
  19. package/model/index.cjs.js +1 -1
  20. package/model/index.esm.js +1 -1
  21. package/model/package.json +9 -9
  22. package/oidc/index.cjs.js +257 -67
  23. package/oidc/index.esm.js +256 -69
  24. package/oidc/package.json +10 -10
  25. package/oidc/src/lib/profile.d.ts +21 -0
  26. package/oidc/src/lib/service/index.d.ts +1 -0
  27. package/oidc/src/lib/service/oidc.config.service.d.ts +14 -0
  28. package/oidc/src/lib/service/oidc.interaction-policy.d.ts +35 -0
  29. package/oidc/src/lib/service/oidc.service.d.ts +29 -0
  30. package/package.json +14 -13
  31. package/src/lib/env/env.service.d.ts +27 -3
  32. package/src/lib/nest/controller/api.scope.d.ts +63 -0
  33. package/src/lib/nest/controller/index.d.ts +2 -0
  34. package/src/lib/nest/controller/model/model.api.get.service.d.ts +146 -1
  35. package/src/lib/nest/controller/model/model.api.scope.d.ts +3 -2
  36. package/src/lib/nest/controller/session/index.d.ts +4 -0
  37. package/src/lib/nest/controller/session/session.api.config.d.ts +104 -0
  38. package/src/lib/nest/controller/session/session.api.controller.d.ts +26 -0
  39. package/src/lib/nest/controller/session/session.api.module.d.ts +49 -0
  40. package/src/lib/nest/controller/session/session.api.service.d.ts +71 -0
  41. package/test/index.cjs.js +8 -8
  42. package/test/index.esm.js +8 -8
  43. package/test/package.json +11 -11
  44. package/twilio/package.json +8 -8
  45. package/zoho/package.json +10 -10
@@ -34,3 +34,24 @@ export interface OidcClientProviderProfileScopes<S extends OidcScope = OidcScope
34
34
  * @returns The unlocked and required scope sets for the client.
35
35
  */
36
36
  export declare function oidcClientProviderProfileScopes<S extends OidcScope = OidcScope>(providerProfiles: readonly OidcProviderProfile<S>[] | undefined, clientProfileKeys: readonly OidcProviderProfileKey[] | undefined): OidcClientProviderProfileScopes<S>;
37
+ /**
38
+ * The subset of an {@link OidcProviderConfig} needed to resolve its admin-only scopes. Declared
39
+ * structurally so this module stays free of a dependency on the config type.
40
+ */
41
+ export interface OidcAdminOnlyScopesInput<S extends OidcScope = OidcScope> {
42
+ readonly adminOnlyScopes?: readonly string[];
43
+ readonly providerProfiles?: readonly OidcProviderProfile<S>[];
44
+ }
45
+ /**
46
+ * Resolves every scope restricted to admin users: the provider config's own `adminOnlyScopes`
47
+ * unioned with the scopes of each profile marked {@link OidcProviderProfile.adminOnly}.
48
+ *
49
+ * The single choke point for that union — the consent admin-only gate (which hard-rejects a
50
+ * non-admin who consented to one) and the consent URL builder (which withholds them from a
51
+ * non-admin's consent screen in the first place) both read through it, so the set a user is
52
+ * offered and the set they are judged against cannot drift apart.
53
+ *
54
+ * @param providerConfig - The provider config supplying `adminOnlyScopes` and the profile registry.
55
+ * @returns The union of config-level and profile-level admin-only scopes.
56
+ */
57
+ export declare function adminOnlyScopesForOidcProviderConfig<S extends OidcScope = OidcScope>(providerConfig: OidcAdminOnlyScopesInput<S>): Set<string>;
@@ -9,3 +9,4 @@ export * from './oidc.jwks.service';
9
9
  export * from './oidc.config.service';
10
10
  export * from './oidc.client.service';
11
11
  export * from './oidc.interaction.service';
12
+ export * from './oidc.interaction-policy';
@@ -81,6 +81,20 @@ export declare class OidcProviderConfigService {
81
81
  * Scopes derived from the claims configuration keys.
82
82
  */
83
83
  readonly scopesSupported: string[];
84
+ /**
85
+ * {@link scopesSupported} minus the scopes only an admin-assigned {@link OidcProviderProfile}
86
+ * unlocks — what an arbitrary client (a dynamically registered one included) may actually put in
87
+ * an authorization request.
88
+ *
89
+ * The issuer's own discovery document keeps advertising the full {@link scopesSupported}: the
90
+ * provider does support those scopes, for the clients holding the profile. This narrower list is
91
+ * for metadata a client treats as a request template — notably an MCP protected-resource
92
+ * document's `scopes_supported`, which dynamic-registration clients (the Claude Code CLI) copy
93
+ * verbatim onto `/authorize`. An assignment-only scope advertised there ends the flow in
94
+ * `access_denied`: the consent unlock gate judges the REQUEST, so unlike an admin-only scope
95
+ * there is no deselect-at-consent way through.
96
+ */
97
+ readonly clientRequestableScopesSupported: string[];
84
98
  /**
85
99
  * Flat list of all unique claim names from the claims configuration.
86
100
  */
@@ -0,0 +1,35 @@
1
+ import { type interactionPolicy } from 'oidc-provider';
2
+ /**
3
+ * Reason string of the check {@link buildOidcInteractionPolicy} adds to the `login` prompt.
4
+ */
5
+ export declare const STALE_SESSION_ACCOUNT_CHECK_REASON = "stale_session_account";
6
+ /**
7
+ * The `interactionPolicy` namespace as imported at runtime.
8
+ *
9
+ * Passed in rather than imported here because `oidc-provider` is ESM-only and the service reaches
10
+ * it through a dynamic `import()`; taking it as an argument keeps this module synchronously
11
+ * testable.
12
+ */
13
+ export type OidcInteractionPolicyNamespace = typeof interactionPolicy;
14
+ /**
15
+ * Builds the interaction policy: oidc-provider's default prompts, plus a `login` check that
16
+ * catches a session whose account no longer resolves.
17
+ *
18
+ * The default policy crashes on that case. `login`'s `no_session` check passes on
19
+ * `oidc.session.accountId` alone — the value in the `_session` cookie — while `loadAccount` puts
20
+ * the result of `findAccount` on `oidc.account`, and `loadGrant` establishes `oidc.grant` ONLY when
21
+ * that account resolved. So a cookie naming a deleted account skips the login prompt, reaches the
22
+ * `consent` prompt's `op_scopes_missing` check, and dereferences `oidc.grant.getOIDCScopeEncountered()`
23
+ * on `undefined` — a `TypeError` that oidc-provider renders as an opaque `server_error`, on every
24
+ * authorization request, until the user clears the cookie by hand.
25
+ *
26
+ * Deleted accounts are ordinary: an emulator database reset in development, an account deletion in
27
+ * production. Re-authenticating is the correct response, so this requests the `login` prompt and the
28
+ * user recovers by logging in. The check is added to `login` rather than guarding `consent` so the
29
+ * prompt loop short-circuits before any consent check runs (it breaks on the first prompt that
30
+ * fires).
31
+ *
32
+ * @param policyNamespace - The `interactionPolicy` namespace from the runtime `oidc-provider` import.
33
+ * @returns The default policy with the stale-session check installed on the `login` prompt.
34
+ */
35
+ export declare function buildOidcInteractionPolicy(policyNamespace: OidcInteractionPolicyNamespace): interactionPolicy.DefaultPolicy;
@@ -34,6 +34,7 @@ export declare class OidcService {
34
34
  private readonly accountService;
35
35
  private readonly collections;
36
36
  private readonly encryptionService;
37
+ private readonly _logger;
37
38
  private readonly _getProvider;
38
39
  constructor(config: OidcModuleConfig, providerConfigService: OidcProviderConfigService, jwksService: JwksService, accountService: OidcAccountService, collections: OidcServerFirestoreCollections, encryptionService: OidcEncryptionService);
39
40
  /**
@@ -108,4 +109,32 @@ export declare class OidcService {
108
109
  */
109
110
  buildProviderConfiguration(cookieKeys: string[]): Configuration;
110
111
  private _buildProvider;
112
+ /**
113
+ * Whether the user an interaction resolved to is an admin.
114
+ *
115
+ * Reads the same `isAdminUser` delegate the consent admin-only gate uses. Fails closed — an
116
+ * interaction with no resolved account (the login prompt, before authentication) or a delegate
117
+ * that does not implement the hook is treated as non-admin, so admin-only scopes are withheld
118
+ * unless admin status is positively established.
119
+ *
120
+ * @param interaction - The interaction whose session account should be checked.
121
+ * @returns True when the interaction's account resolves to an admin user.
122
+ */
123
+ private _isAdminInteractionUser;
124
+ /**
125
+ * Wires oidc-provider's error events to the Nest logger.
126
+ *
127
+ * oidc-provider catches everything thrown inside its koa stack and renders the generic
128
+ * `{"error":"server_error","error_description":"oops! something went wrong"}` body. The real
129
+ * cause is only ever handed to the `server_error` event — with no listener it is dropped, and a
130
+ * failing authorization request is undiagnosable from either the response or the function logs.
131
+ *
132
+ * `grant.error` / `introspection.error` / `revocation.error` cover the token-endpoint equivalents.
133
+ * These are all genuine server-side faults; expected protocol rejections (`invalid_client`,
134
+ * `access_denied`, …) travel as normal responses and are NOT emitted here, so this stays quiet in
135
+ * healthy operation.
136
+ *
137
+ * @param provider - The constructed oidc-provider instance to attach listeners to.
138
+ */
139
+ private _attachProviderErrorLogging;
111
140
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dereekb/firebase-server",
3
- "version": "13.36.0",
3
+ "version": "13.38.0",
4
4
  "sideEffects": false,
5
5
  "exports": {
6
6
  "./test": {
@@ -69,20 +69,21 @@
69
69
  "types": "./src/index.d.ts",
70
70
  "peerDependencies": {
71
71
  "@cantoo/pdf-lib": "^2.6.5",
72
- "@dereekb/analytics": "13.36.0",
73
- "@dereekb/calcom": "13.36.0",
74
- "@dereekb/date": "13.36.0",
75
- "@dereekb/dbx-core": "13.36.0",
76
- "@dereekb/discord": "13.36.0",
77
- "@dereekb/firebase": "13.36.0",
78
- "@dereekb/model": "13.36.0",
79
- "@dereekb/nestjs": "13.36.0",
80
- "@dereekb/rxjs": "13.36.0",
81
- "@dereekb/util": "13.36.0",
82
- "@dereekb/zoho": "13.36.0",
72
+ "@dereekb/analytics": "13.38.0",
73
+ "@dereekb/calcom": "13.38.0",
74
+ "@dereekb/date": "13.38.0",
75
+ "@dereekb/dbx-core": "13.38.0",
76
+ "@dereekb/discord": "13.38.0",
77
+ "@dereekb/firebase": "13.38.0",
78
+ "@dereekb/model": "13.38.0",
79
+ "@dereekb/nestjs": "13.38.0",
80
+ "@dereekb/rxjs": "13.38.0",
81
+ "@dereekb/util": "13.38.0",
82
+ "@dereekb/zoho": "13.38.0",
83
83
  "@google-cloud/firestore": "^7.11.6",
84
84
  "@google-cloud/storage": "^7.19.0",
85
- "@modelcontextprotocol/sdk": "1.29.0",
85
+ "@modelcontextprotocol/node": "2.0.0",
86
+ "@modelcontextprotocol/server": "2.0.0",
86
87
  "@nestjs/common": "^11.1.19",
87
88
  "@nestjs/config": "^4.0.4",
88
89
  "@nestjs/core": "^11.1.19",
@@ -14,15 +14,39 @@ export interface FirebaseServerEnvServiceRef<S extends FirebaseServerEnvService
14
14
  */
15
15
  export declare abstract class FirebaseServerEnvService {
16
16
  /**
17
- * Whether the server is running in a test/CI environment.
17
+ * Whether the server is running under a test runner / in CI.
18
+ *
19
+ * NOT the complement of {@link isProduction}. There are three distinct situations, and a local
20
+ * emulator run (`nx run <app>:serve`) is the third one — neither testing nor deployed:
21
+ *
22
+ * | situation | isTestingEnv | isProduction |
23
+ * | -------------------------- | ------------ | ------------ |
24
+ * | test / CI run | true | false |
25
+ * | local emulator `serve` | false | false |
26
+ * | deployed (staging or prod) | false | true |
27
+ *
28
+ * Use this only to branch on "a test runner is driving this" — seeded fixtures, relaxed timeouts,
29
+ * assertions. To decide whether real Firebase infrastructure is reachable, use {@link isProduction}:
30
+ * `!isTestingEnv` wrongly includes local emulator runs, which is a common source of calls to live
31
+ * backends (App Check, IAM signing, service-account discovery) that fail with no emulator behind them.
18
32
  */
19
33
  abstract readonly isTestingEnv: boolean;
20
34
  /**
21
- * Whether the server is running in production mode. (This may be true in both prod or a staging running as production).
35
+ * Whether the server is DEPLOYED — true for every deployed environment, including staging.
36
+ *
37
+ * This is the gate for "real Firebase infrastructure is reachable": services with no emulator
38
+ * (App Check token minting, IAM signing, GCE metadata service-account discovery) may only be
39
+ * called when this is true. It is false for both a test run and a local emulator `serve`, which is
40
+ * what makes it — and not `!isTestingEnv` — the correct check for those calls.
41
+ *
42
+ * Use {@link isStaging} to distinguish staging from true production.
22
43
  */
23
44
  abstract readonly isProduction: boolean;
24
45
  /**
25
- * Whether the server is running in a staging environment. isProduction is also typically true when this is true.
46
+ * Whether the server is running in a staging environment.
47
+ *
48
+ * Narrows within a deployed environment: {@link isProduction} is also true when this is true, since
49
+ * staging runs as production against real infrastructure.
26
50
  */
27
51
  abstract readonly isStaging: boolean;
28
52
  /**
@@ -0,0 +1,63 @@
1
+ import { type Maybe } from '@dereekb/util';
2
+ import { type OidcScope, type OidcScopeTerm } from '@dereekb/firebase';
3
+ import { type FirebaseServerAuthData } from './auth.context.server';
4
+ /**
5
+ * Error code thrown by {@link assertEndpointOidcScope} when an OIDC caller is missing the scope a
6
+ * non-model endpoint requires.
7
+ *
8
+ * Distinct from the callModel-layer code so a client can tell "you cannot call this endpoint" apart
9
+ * from "you cannot make this model call".
10
+ */
11
+ export declare const MISSING_ENDPOINT_OIDC_SCOPE_ERROR_CODE = "MISSING_ENDPOINT_OIDC_SCOPE_ERROR";
12
+ /**
13
+ * Reads the set of OIDC scopes carried by an authenticated request, or `undefined` for a non-OIDC
14
+ * (regular Firebase ID-token) caller.
15
+ *
16
+ * The OIDC bearer-token middleware attaches the validated access-token claims at
17
+ * `auth.oidcValidatedToken` (with the space-delimited `scope` string); a non-OIDC caller has neither
18
+ * that field nor a `scope` on `auth.token`. Reading is defensive — the auth shape is only typed as
19
+ * {@link FirebaseServerAuthData} here, since the OIDC-specific `oidcValidatedToken` lives in the
20
+ * `@dereekb/firebase-server/oidc` sub-package this core layer cannot import — and the actual parse is
21
+ * delegated to the shared {@link oidcScopesFromScopeClaim} so there is no drift with
22
+ * `getOidcScopesFromRequest`.
23
+ *
24
+ * @param auth - The request auth data, or undefined for unauthenticated requests.
25
+ * @returns The granted scope set, or `undefined` when the request carries no OIDC `scope` claim.
26
+ */
27
+ export declare function oidcScopesFromRequestAuth(auth: Maybe<FirebaseServerAuthData>): Maybe<Set<OidcScope>>;
28
+ /**
29
+ * Inputs to {@link assertEndpointOidcScope}.
30
+ */
31
+ export interface AssertEndpointOidcScopeInput {
32
+ /**
33
+ * The scope term the endpoint requires — a single scope, or an OR-group satisfied by holding any
34
+ * one of its scopes. `undefined`/`null` imposes no requirement.
35
+ */
36
+ readonly requiredScope: Maybe<OidcScopeTerm>;
37
+ /**
38
+ * The scopes the caller was granted, or `undefined` for a non-OIDC caller (bypasses enforcement).
39
+ */
40
+ readonly grantedScopes: Maybe<ReadonlySet<OidcScope>>;
41
+ /**
42
+ * Human-readable endpoint identifier used in the thrown error message (e.g. `/api/session/firestore`).
43
+ */
44
+ readonly endpoint: string;
45
+ }
46
+ /**
47
+ * Enforces a single OIDC scope requirement for a plain (non-callModel) endpoint, throwing a `403`
48
+ * when an OIDC caller does not hold it.
49
+ *
50
+ * The non-model counterpart to `assertModelApiOidcScope`, which is unusable outside the model API
51
+ * because it requires a `{ call, modelType }` pair. Evaluation reuses the same shipped
52
+ * {@link oidcScopeTermSatisfied} primitive so a term means the same thing on both surfaces.
53
+ *
54
+ * Bypasses (no-op) when `grantedScopes` is `undefined` — i.e. a non-OIDC caller carrying a plain
55
+ * Firebase ID token, which has no `scope` claim to enforce against. **This is why scope-gating alone
56
+ * is never a sufficient gate**: an endpoint that must reject non-admins needs its own admin check,
57
+ * with the scope acting as defence in depth.
58
+ *
59
+ * @param input - The required term, the caller's granted scopes, and the endpoint name for the message.
60
+ * @throws A `403` forbidden error (code {@link MISSING_ENDPOINT_OIDC_SCOPE_ERROR_CODE}) when an OIDC
61
+ * caller does not satisfy the requirement.
62
+ */
63
+ export declare function assertEndpointOidcScope(input: AssertEndpointOidcScopeInput): void;
@@ -1,2 +1,4 @@
1
1
  export * from './auth.context.server';
2
+ export * from './api.scope';
2
3
  export * from './model';
4
+ export * from './session';
@@ -1,5 +1,6 @@
1
1
  import { type Maybe } from '@dereekb/util';
2
- import { type FirestoreModelIdentity, type FirestoreModelKey, type FirestoreModelType } from '@dereekb/firebase';
2
+ import { type GrantedRoleMap } from '@dereekb/model';
3
+ import { type FirebaseAuthUserId, type FirestoreModelIdentity, type FirestoreModelKey, type FirestoreModelType } from '@dereekb/firebase';
3
4
  import { type INestApplicationContext } from '@nestjs/common';
4
5
  import { ModelApiDispatchConfig } from './model.api.dispatch';
5
6
  import { type FirebaseServerAuthData } from '../auth.context.server';
@@ -7,6 +8,13 @@ import { type FirebaseServerAuthData } from '../auth.context.server';
7
8
  * Maximum number of keys allowed in a multi-read request.
8
9
  */
9
10
  export declare const MAX_MODEL_ACCESS_MULTI_READ_KEYS = 50;
11
+ /**
12
+ * Error code returned when a read targets a model the app declared SERVER-ONLY.
13
+ *
14
+ * Deliberately distinct from a permission error: nothing the caller can be granted will make this
15
+ * read succeed, so the message needs to say WHY rather than implying a missing role.
16
+ */
17
+ export declare const MODEL_IS_SERVER_ONLY_ERROR_CODE = "MODEL_IS_SERVER_ONLY";
10
18
  /**
11
19
  * Result of a single document access read.
12
20
  */
@@ -29,6 +37,90 @@ export interface ModelAccessReadError {
29
37
  readonly message: string;
30
38
  readonly code?: string;
31
39
  }
40
+ /**
41
+ * The resolved permission state for a single model key, as computed by that model's
42
+ * `roleMapForModel()` delegate for a specific user.
43
+ */
44
+ export interface ModelAccessRoleMapResult {
45
+ readonly key: FirestoreModelKey;
46
+ /**
47
+ * Whether the underlying document exists.
48
+ *
49
+ * Roles are only computed for documents that exist — `FirebaseModelPermissionServiceInstance`
50
+ * gates on `isUsableOutputForRoles(output) => output.exists`, so a missing document always
51
+ * resolves to an empty role set. Surfacing existence separately is what lets a caller tell
52
+ * "the document is not there" apart from "the document is there and you may not touch it";
53
+ * both otherwise present as `roles: []`.
54
+ */
55
+ readonly exists: boolean;
56
+ /**
57
+ * True when the model granted the full-access marker ({@link FULL_ACCESS_ROLE_KEY}) rather than
58
+ * an enumerated role set — the shape admin short-circuits like `fullAccessRoleMap()` produce.
59
+ * When true, {@link roles} is empty and every role is implicitly granted.
60
+ */
61
+ readonly fullAccess: boolean;
62
+ /**
63
+ * The granted role names, sorted. Empty when {@link fullAccess} is true or nothing was granted.
64
+ */
65
+ readonly roles: string[];
66
+ }
67
+ /**
68
+ * Result of a multi-key role-map resolution.
69
+ */
70
+ export interface ModelAccessMultiRoleMapResult {
71
+ /**
72
+ * The uid the roles were resolved for — the caller's own uid unless the request targeted
73
+ * another user. `undefined` for an unauthenticated resolution.
74
+ */
75
+ readonly uid?: string;
76
+ /**
77
+ * True when {@link uid} is someone other than the calling user.
78
+ */
79
+ readonly targeted: boolean;
80
+ readonly results: ModelAccessRoleMapResult[];
81
+ readonly errors: ModelAccessReadError[];
82
+ }
83
+ /**
84
+ * Input for {@link ModelApiGetService.readRoleMaps}.
85
+ */
86
+ export interface ModelAccessRoleMapParams {
87
+ readonly modelType: FirestoreModelType;
88
+ readonly keys: FirestoreModelKey[];
89
+ /**
90
+ * The calling request's auth data.
91
+ */
92
+ readonly auth: Maybe<FirebaseServerAuthData>;
93
+ /**
94
+ * Resolve roles as this user instead of the caller. Callers are responsible for authorizing
95
+ * this before passing it — the service performs no permission check of its own on the target.
96
+ */
97
+ readonly targetUid?: Maybe<FirebaseAuthUserId>;
98
+ }
99
+ /**
100
+ * Input for {@link modelAccessRoleMapResultFromGrantedRoles}.
101
+ */
102
+ export interface ModelAccessRoleMapResultParams {
103
+ readonly key: FirestoreModelKey;
104
+ /**
105
+ * The resolved `ContextGrantedModelRoles` for the key. Typed structurally (rather than importing
106
+ * the generic) so the mapper stays testable with a plain object.
107
+ */
108
+ readonly granted: {
109
+ readonly data?: Maybe<{
110
+ readonly exists?: boolean;
111
+ }>;
112
+ readonly roleMap: GrantedRoleMap<string>;
113
+ };
114
+ }
115
+ /**
116
+ * Maps a resolved `ContextGrantedModelRoles` into the flat {@link ModelAccessRoleMapResult} wire
117
+ * shape — collapsing the full-access marker into a boolean, dropping the structural marker keys, and
118
+ * sorting the enumerated role keys so output is stable across calls.
119
+ *
120
+ * @param input - The key and its resolved granted-roles result.
121
+ * @returns The flattened per-key permission state.
122
+ */
123
+ export declare function modelAccessRoleMapResultFromGrantedRoles(input: ModelAccessRoleMapResultParams): ModelAccessRoleMapResult;
32
124
  /**
33
125
  * Shape of a single failed-key entry from `useMultipleModels({ throwOnFirstError: false })`.
34
126
  * Exposed so the mapper below stays testable without a live nest context.
@@ -77,6 +169,29 @@ export declare class ModelApiGetService {
77
169
  * @param auth - The request's auth data (OIDC scopes are read from it).
78
170
  */
79
171
  private _assertReadScope;
172
+ /**
173
+ * Refuses a read of a model the app declared SERVER-ONLY, BEFORE `useModel` runs.
174
+ *
175
+ * The model API authorizes through `roleMapForModel` under the Admin SDK, which bypasses
176
+ * `firestore.rules` entirely. For a model the rules file deliberately leaves unmatched (or denies
177
+ * outright), those two systems disagree and the model API is the one handing out the document.
178
+ * Gating here — ahead of the role map, and on the ONE service both `/model/<type>/get` and the
179
+ * `model-get` MCP tool share — is what makes the two read paths agree.
180
+ *
181
+ * @param modelType - The Firestore model type being read.
182
+ * @param auth - The request's auth data; used to build the context the service lookup needs.
183
+ * @throws {HttpsError} `MODEL_IS_SERVER_ONLY` when the model opted in via `serverOnly`.
184
+ */
185
+ private _assertNotServerOnly;
186
+ /**
187
+ * Reads the `serverOnly` flag off the registered model service, tolerating a type whose service
188
+ * cannot be constructed (which `readDocument` then reports as an unknown model).
189
+ *
190
+ * @param modelType - The Firestore model type to inspect.
191
+ * @param auth - The request's auth data.
192
+ * @returns `true` when the model declared itself server-only.
193
+ */
194
+ private _isServerOnlyModel;
80
195
  /**
81
196
  * Returns the registered {@link FirestoreModelIdentity} for the given `modelType` string, or
82
197
  * `undefined` when no model of that type is registered.
@@ -114,6 +229,36 @@ export declare class ModelApiGetService {
114
229
  * @returns Results and errors for each requested key.
115
230
  */
116
231
  readDocuments(modelType: FirestoreModelType, keys: FirestoreModelKey[], auth: Maybe<FirebaseServerAuthData>): Promise<ModelAccessMultiReadResult>;
232
+ /**
233
+ * Resolves the granted role map for one or more keys of the same model type — i.e. "what is this
234
+ * user actually allowed to do with this document?".
235
+ *
236
+ * This is the same computation the permission-checked read path runs (`roleMapForModel()` on the
237
+ * model's registered service factory), but the resolved roles are returned instead of being
238
+ * consumed to allow/deny an operation. Because it runs the model's real delegate, derived and
239
+ * cascading roles are included exactly as the API would grant them.
240
+ *
241
+ * Per-key failures are captured in `errors` rather than thrown, matching {@link readDocuments}.
242
+ * A key that resolves to a missing document is NOT an error — it comes back with
243
+ * `exists: false, roles: []`, which is what distinguishes "not there" from "no access".
244
+ *
245
+ * @param params - Model type, keys, calling auth, and an optional target uid.
246
+ * @returns The per-key permission state plus the uid the roles were resolved for.
247
+ */
248
+ readRoleMaps(params: ModelAccessRoleMapParams): Promise<ModelAccessMultiRoleMapResult>;
249
+ /**
250
+ * Builds a synthetic {@link AuthData} for an arbitrary uid so role resolution can run *as* that
251
+ * user rather than as the caller.
252
+ *
253
+ * The target's custom claims are read from their Firebase Auth record and spread into the
254
+ * synthetic token, mirroring how Firebase merges custom claims into a real decoded ID token —
255
+ * so claim-reading permission delegates behave identically to a live request from that user.
256
+ *
257
+ * @param uid - The uid to resolve as.
258
+ * @returns An auth ref carrying the target user's claims.
259
+ * @throws {Error} If no Firebase Auth user exists for the uid.
260
+ */
261
+ private _makeAuthRefForUid;
117
262
  /**
118
263
  * Builds an {@link AuthDataRef} compatible with `useModel()` from the HTTP request auth.
119
264
  *
@@ -40,8 +40,9 @@ export interface ModelApiOidcScopeConfig {
40
40
  * `auth.oidcValidatedToken` (with the space-delimited `scope` string); a non-OIDC caller has neither
41
41
  * that field nor a `scope` on `auth.token`. Reading is defensive (the auth shape is only typed as
42
42
  * {@link FirebaseServerAuthData} here — the OIDC-specific `oidcValidatedToken` lives in the
43
- * `@dereekb/firebase-server/oidc` sub-package this core layer cannot import), delegating the actual
44
- * parse to the shared {@link oidcScopesFromScopeClaim} so there is no drift with `getOidcScopesFromRequest`.
43
+ * `@dereekb/firebase-server/oidc` sub-package this core layer cannot import), delegating entirely to the
44
+ * shared {@link oidcScopesFromRequestAuth} so there is no drift with `getOidcScopesFromRequest` or with
45
+ * the non-model endpoints that read scopes the same way.
45
46
  *
46
47
  * @param auth - The request auth data, or undefined for unauthenticated requests.
47
48
  * @returns The granted scope set, or `undefined` when the request carries no OIDC `scope` claim.
@@ -0,0 +1,4 @@
1
+ export * from './session.api.config';
2
+ export * from './session.api.service';
3
+ export * from './session.api.controller';
4
+ export * from './session.api.module';
@@ -0,0 +1,104 @@
1
+ import { type Maybe, type Milliseconds, type PromiseOrValue } from '@dereekb/util';
2
+ import { type OidcScopeTerm } from '@dereekb/firebase';
3
+ import { type FirebaseServerAuthData } from '../auth.context.server';
4
+ /**
5
+ * Route prefix the session API controller is mounted at. Under the `/api` global route prefix the
6
+ * routes become `/api/session/*`.
7
+ */
8
+ export declare const SESSION_API_ROUTE_PREFIX = "session";
9
+ /**
10
+ * Path (relative to the API base URL) of the direct-Firestore session endpoint.
11
+ *
12
+ * The `@dereekb/dbx-cli` client posts to `<apiBaseUrl>${FIRESTORE_SESSION_API_PATH}`.
13
+ */
14
+ export declare const FIRESTORE_SESSION_API_PATH = "/session/firestore";
15
+ /**
16
+ * Path prefix apps must add to their OIDC `protectedPaths` so the bearer-token middleware
17
+ * authenticates the session endpoint (today typically `['/api/model', '/mcp']`).
18
+ *
19
+ * Without this the endpoint is reachable unauthenticated — `req.auth` would be `undefined` and the
20
+ * request rejected as unauthenticated, but the gate belongs at the middleware, not the handler.
21
+ */
22
+ export declare const FIREBASE_SERVER_SESSION_API_PROTECTED_PATH = "/api/session";
23
+ /**
24
+ * Default lifetime requested for the minted App Check token. Matches the Admin SDK's own default.
25
+ */
26
+ export declare const DEFAULT_FIRESTORE_SESSION_APP_CHECK_TTL_MILLIS: Milliseconds;
27
+ /**
28
+ * Minimum lifetime the Admin SDK accepts for an App Check token (30 minutes).
29
+ */
30
+ export declare const MIN_FIRESTORE_SESSION_APP_CHECK_TTL_MILLIS: Milliseconds;
31
+ /**
32
+ * Maximum lifetime the Admin SDK accepts for an App Check token (7 days).
33
+ *
34
+ * Deliberately NOT the default: this endpoint mints a valid web-app App Check attestation for
35
+ * whoever calls it, so the shortest workable lifetime is the right one.
36
+ */
37
+ export declare const MAX_FIRESTORE_SESSION_APP_CHECK_TTL_MILLIS: Milliseconds;
38
+ /**
39
+ * Window a Firebase Auth custom token may be exchanged for an ID token within (1 hour, fixed by
40
+ * Firebase). The exchanged ID token then lives its own hour from sign-in.
41
+ */
42
+ export declare const FIREBASE_CUSTOM_TOKEN_EXCHANGE_WINDOW_MILLIS: Milliseconds;
43
+ /**
44
+ * Signature for the predicate that authorizes a caller to open a direct-Firestore session.
45
+ *
46
+ * Receives the calling request's auth data (`undefined` for an unauthenticated request) and returns
47
+ * true when that caller may be handed a custom token + App Check attestation. Typically an admin
48
+ * check, e.g. `(auth) => authRoleClaimsService.toRoles(auth?.token ?? {}).has('admin')`.
49
+ *
50
+ * This is the LOAD-BEARING gate. `assertIsAdminInRequest` cannot be used here — it needs a
51
+ * `NestContextCallableRequestWithOptionalAuth` with `.nest` attached, and a plain Nest controller
52
+ * only has an Express request carrying `req.auth` — so the check is delegated to the app, keeping
53
+ * `@dereekb/firebase-server` app-agnostic about what "admin" means.
54
+ *
55
+ * When no predicate is provided the endpoint fails closed for EVERY caller.
56
+ */
57
+ export type FirestoreSessionAdminPredicate = (auth: Maybe<FirebaseServerAuthData>) => PromiseOrValue<boolean>;
58
+ /**
59
+ * NestJS injection token for the {@link FirestoreSessionAdminPredicate} provider.
60
+ */
61
+ export declare const FIRESTORE_SESSION_ADMIN_PREDICATE = "FIRESTORE_SESSION_ADMIN_PREDICATE";
62
+ /**
63
+ * Optional configuration for the session API module, supplied by the app via its dependency module.
64
+ *
65
+ * Everything here is optional — with no config provided the endpoint still mints custom tokens, just
66
+ * without an App Check attestation (correct for a project that does not enforce App Check, and for
67
+ * emulator-backed local development).
68
+ */
69
+ export declare abstract class SessionApiModuleConfig {
70
+ /**
71
+ * The Firebase `appId` of the registered **web** app to mint App Check tokens for. This is the same
72
+ * `appId` the browser client initializes with — the attestation must match an app the project's
73
+ * App Check enforcement recognizes.
74
+ *
75
+ * When unset, no App Check token is minted and the response's `appCheckToken` is omitted.
76
+ */
77
+ readonly appCheckAppId?: string;
78
+ /**
79
+ * Lifetime to request for the minted App Check token. Clamped to
80
+ * [{@link MIN_FIRESTORE_SESSION_APP_CHECK_TTL_MILLIS}, {@link MAX_FIRESTORE_SESSION_APP_CHECK_TTL_MILLIS}].
81
+ *
82
+ * Defaults to {@link DEFAULT_FIRESTORE_SESSION_APP_CHECK_TTL_MILLIS}.
83
+ */
84
+ readonly appCheckTokenTtlMillis?: Milliseconds;
85
+ /**
86
+ * OIDC scope term an OIDC caller must hold to open a session. Defaults to
87
+ * {@link FIRESTORE_SESSION_OIDC_SCOPE}. Pass `null` to disable scope enforcement entirely (the admin
88
+ * predicate remains the real gate either way).
89
+ */
90
+ readonly requiredScope?: Maybe<OidcScopeTerm>;
91
+ }
92
+ /**
93
+ * Resolves the effective App Check TTL from the module config, clamped to the Admin SDK's accepted range.
94
+ *
95
+ * @param ttlMillis - The configured TTL, or `undefined` to use the default.
96
+ * @returns The TTL to request, in milliseconds.
97
+ * @__NO_SIDE_EFFECTS__
98
+ */
99
+ export declare function firestoreSessionAppCheckTtlMillis(ttlMillis: Maybe<Milliseconds>): Milliseconds;
100
+ /**
101
+ * The default {@link SessionApiModuleConfig.requiredScope}, re-exported for apps that want to widen it
102
+ * into an OR-group rather than replace it.
103
+ */
104
+ export declare const DEFAULT_FIRESTORE_SESSION_REQUIRED_OIDC_SCOPE: OidcScopeTerm;
@@ -0,0 +1,26 @@
1
+ import { type Request } from 'express';
2
+ import { FirestoreSessionApiService, type FirestoreSessionResult } from './session.api.service';
3
+ /**
4
+ * REST controller that hands an authenticated admin caller the credentials needed to talk to
5
+ * Firestore directly, rather than only through the model HTTP API.
6
+ *
7
+ * Mounted at `session` — under the `/api` global prefix the route becomes `GET /api/session/firestore`.
8
+ *
9
+ * Auth comes from the global OIDC bearer middleware, so `'/api/session'` MUST be listed in the OIDC
10
+ * module's `protectedPaths` (see `FIREBASE_SERVER_SESSION_API_PROTECTED_PATH`). Follows `McpController`:
11
+ * there are no NestJS guards in this codebase — protection is path-prefix middleware plus the
12
+ * per-endpoint checks in {@link FirestoreSessionApiService}.
13
+ */
14
+ export declare class SessionApiController {
15
+ private readonly sessionService;
16
+ constructor(sessionService: FirestoreSessionApiService);
17
+ /**
18
+ * Mints a short-lived Firebase Auth custom token (+ App Check attestation, when configured) for the
19
+ * calling user.
20
+ *
21
+ * @param req - The Express request carrying auth credentials on `req.auth`.
22
+ * @returns The {@link FirestoreSessionResult} credential bundle.
23
+ */
24
+ getFirestoreSession(req: Request): Promise<FirestoreSessionResult>;
25
+ private _toHttpException;
26
+ }