@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.
- package/calcom/package.json +10 -10
- package/discord/package.json +10 -10
- package/index.cjs.js +1732 -510
- package/index.esm.js +1717 -513
- package/mailgun/package.json +9 -9
- package/mcp/index.cjs.js +913 -167
- package/mcp/index.esm.js +906 -166
- package/mcp/package.json +13 -12
- package/mcp/src/lib/controller/mcp.controller.d.ts +28 -3
- package/mcp/src/lib/controller/mcp.wellknown.controller.d.ts +21 -1
- package/mcp/src/lib/mcp.config.d.ts +31 -8
- package/mcp/src/lib/mcp.module.d.ts +2 -1
- package/mcp/src/lib/service/index.d.ts +1 -0
- package/mcp/src/lib/service/mcp.server.factory.d.ts +8 -6
- package/mcp/src/lib/service/mcp.tool-generator.d.ts +1 -1
- package/mcp/src/lib/service/mcp.visibility.d.ts +1 -1
- package/mcp/src/lib/service/tools/mcp.tool.model-roles.d.ts +95 -0
- package/mcp/src/lib/transport/streamable-http.transport.d.ts +16 -11
- package/model/index.cjs.js +1 -1
- package/model/index.esm.js +1 -1
- package/model/package.json +9 -9
- package/oidc/index.cjs.js +257 -67
- package/oidc/index.esm.js +256 -69
- package/oidc/package.json +10 -10
- package/oidc/src/lib/profile.d.ts +21 -0
- package/oidc/src/lib/service/index.d.ts +1 -0
- package/oidc/src/lib/service/oidc.config.service.d.ts +14 -0
- package/oidc/src/lib/service/oidc.interaction-policy.d.ts +35 -0
- package/oidc/src/lib/service/oidc.service.d.ts +29 -0
- package/package.json +14 -13
- package/src/lib/env/env.service.d.ts +27 -3
- package/src/lib/nest/controller/api.scope.d.ts +63 -0
- package/src/lib/nest/controller/index.d.ts +2 -0
- package/src/lib/nest/controller/model/model.api.get.service.d.ts +146 -1
- package/src/lib/nest/controller/model/model.api.scope.d.ts +3 -2
- package/src/lib/nest/controller/session/index.d.ts +4 -0
- package/src/lib/nest/controller/session/session.api.config.d.ts +104 -0
- package/src/lib/nest/controller/session/session.api.controller.d.ts +26 -0
- package/src/lib/nest/controller/session/session.api.module.d.ts +49 -0
- package/src/lib/nest/controller/session/session.api.service.d.ts +71 -0
- package/test/index.cjs.js +8 -8
- package/test/index.esm.js +8 -8
- package/test/package.json +11 -11
- package/twilio/package.json +8 -8
- 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>;
|
|
@@ -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.
|
|
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.
|
|
73
|
-
"@dereekb/calcom": "13.
|
|
74
|
-
"@dereekb/date": "13.
|
|
75
|
-
"@dereekb/dbx-core": "13.
|
|
76
|
-
"@dereekb/discord": "13.
|
|
77
|
-
"@dereekb/firebase": "13.
|
|
78
|
-
"@dereekb/model": "13.
|
|
79
|
-
"@dereekb/nestjs": "13.
|
|
80
|
-
"@dereekb/rxjs": "13.
|
|
81
|
-
"@dereekb/util": "13.
|
|
82
|
-
"@dereekb/zoho": "13.
|
|
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/
|
|
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
|
|
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
|
|
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.
|
|
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,5 +1,6 @@
|
|
|
1
1
|
import { type Maybe } from '@dereekb/util';
|
|
2
|
-
import { type
|
|
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
|
|
44
|
-
*
|
|
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,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
|
+
}
|