@dereekb/firebase-server 14.0.1 → 14.1.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 (34) hide show
  1. package/calcom/index.esm.js +3 -2
  2. package/calcom/package.json +10 -10
  3. package/calcom/src/lib/calcom.oauth.connection.service.d.ts +2 -2
  4. package/discord/index.esm.js +174 -19
  5. package/discord/package.json +10 -10
  6. package/discord/src/lib/discord.oauth.connection.config.d.ts +43 -2
  7. package/discord/src/lib/discord.oauth.connection.module.d.ts +18 -1
  8. package/discord/src/lib/discord.oauth.connection.service.d.ts +40 -3
  9. package/index.esm.js +9 -1
  10. package/mailgun/package.json +9 -9
  11. package/mcp/package.json +11 -11
  12. package/model/index.esm.js +5182 -2369
  13. package/model/package.json +9 -9
  14. package/model/src/lib/userexternalconnection/index.d.ts +2 -0
  15. package/model/src/lib/userexternalconnection/oauth/index.d.ts +1 -0
  16. package/model/src/lib/userexternalconnection/oauth/userexternalconnection.oauth.config.d.ts +51 -0
  17. package/model/src/lib/userexternalconnection/oauth/userexternalconnection.oauth.controller.d.ts +49 -0
  18. package/model/src/lib/userexternalconnection/oauth/userexternalconnection.oauth.refresh.d.ts +35 -0
  19. package/model/src/lib/userexternalconnection/oauth/userexternalconnection.oauth.service.d.ts +300 -7
  20. package/model/src/lib/userexternalconnection/oauth/userexternalconnection.oauth.state.d.ts +213 -12
  21. package/model/src/lib/userexternalconnection/oauth/userexternalconnection.oauth.throttle.d.ts +81 -0
  22. package/model/src/lib/userexternalconnection/userexternalconnection.action.server.d.ts +214 -1
  23. package/model/src/lib/userexternalconnection/userexternalconnection.error.d.ts +118 -5
  24. package/model/src/lib/userexternalconnection/userexternalconnection.module.d.ts +52 -2
  25. package/model/src/lib/userexternalconnection/userexternalconnection.policy.d.ts +117 -0
  26. package/model/src/lib/userexternalconnection/userexternalconnection.signin.d.ts +286 -0
  27. package/oidc/package.json +10 -10
  28. package/package.json +12 -12
  29. package/src/lib/auth/auth.service.d.ts +26 -0
  30. package/test/package.json +11 -11
  31. package/twilio/package.json +8 -8
  32. package/zoho/index.esm.js +3 -2
  33. package/zoho/package.json +10 -10
  34. package/zoho/src/lib/zoho.oauth.connection.service.d.ts +2 -2
@@ -29,13 +29,29 @@ export declare const TESTING_USER_EXTERNAL_CONNECTION_STATE_SECRET: AES256GCMEnc
29
29
  */
30
30
  export declare const DEFAULT_USER_EXTERNAL_CONNECTION_STATE_EXPIRATION: Milliseconds;
31
31
  /**
32
- * The payload carried inside an encrypted external-connection OAuth `state`.
32
+ * How long a minted sign-in ticket stays valid.
33
+ *
34
+ * Far shorter than a state: the ticket is redeemed by a page that has just been redirected to, so the
35
+ * whole window is one client-side navigation plus one POST.
33
36
  */
34
- export interface UserExternalConnectionStatePayload {
35
- /**
36
- * The user the handoff belongs to.
37
- */
38
- readonly uid: FirebaseAuthUserId;
37
+ export declare const DEFAULT_USER_EXTERNAL_CONNECTION_TICKET_EXPIRATION: Milliseconds;
38
+ /**
39
+ * Which direction a handoff runs in.
40
+ *
41
+ * - `connect` — an ALREADY authenticated user is attaching a third-party account. The state carries
42
+ * their uid, minted by an authenticated call before the redirect.
43
+ * - `signin` — an anonymous visitor is authenticating THROUGH the third party. There is no uid yet;
44
+ * the state carries a client-supplied PKCE challenge instead, which the ticket exchange answers.
45
+ * - `link` — an ALREADY authenticated user is making the third party a LOGIN METHOD for their account.
46
+ * Carries their uid like a connect, but runs the sign-in scopes and writes only the login link. A
47
+ * third mode rather than a flag on `connect` because the two request different scopes and produce
48
+ * different writes, and because a state minted for one must not be usable for the other.
49
+ */
50
+ export type UserExternalConnectionStateMode = 'connect' | 'signin' | 'link';
51
+ /**
52
+ * Fields shared by both {@link UserExternalConnectionStatePayload} branches.
53
+ */
54
+ export interface UserExternalConnectionStatePayloadBase {
39
55
  /**
40
56
  * The provider the handoff was started for.
41
57
  *
@@ -47,17 +63,166 @@ export interface UserExternalConnectionStatePayload {
47
63
  * Epoch milliseconds after which the state is rejected.
48
64
  */
49
65
  readonly exp: number;
66
+ /**
67
+ * The PKCE code verifier the authorize request sent the challenge for — the PROVIDER-facing half
68
+ * of the flow, unrelated to `challenge` below.
69
+ *
70
+ * Held here rather than in a server-side store because the state is already an
71
+ * encrypted-and-authenticated envelope only this server can read, which is exactly what a verifier
72
+ * needs. Optional so a state minted before PKCE was added still exchanges.
73
+ */
74
+ readonly cv?: Maybe<string>;
75
+ }
76
+ /**
77
+ * A `connect` state: an authenticated user attaching a provider.
78
+ */
79
+ export interface UserExternalConnectionConnectStatePayload extends UserExternalConnectionStatePayloadBase {
80
+ /**
81
+ * Absent on states minted before sign-in mode existed, which is why an ABSENT mode means `connect`
82
+ * — a state already in flight when this shipped must still verify.
83
+ */
84
+ readonly mode?: Maybe<'connect'>;
85
+ /**
86
+ * The user the handoff belongs to.
87
+ */
88
+ readonly uid: FirebaseAuthUserId;
89
+ }
90
+ /**
91
+ * A `signin` state: an anonymous visitor authenticating through a provider.
92
+ */
93
+ export interface UserExternalConnectionSignInStatePayload extends UserExternalConnectionStatePayloadBase {
94
+ readonly mode: 'signin';
95
+ /**
96
+ * The CLIENT's PKCE challenge (base64url SHA-256 of a verifier held in the browser).
97
+ *
98
+ * Binds the eventual ticket to the browser that started the flow: a stolen ticket is useless
99
+ * without the verifier, which never leaves the originating tab.
100
+ */
101
+ readonly challenge: string;
102
+ /**
103
+ * Optional app path to return to, already validated against the app's allowlist before minting.
104
+ */
105
+ readonly returnPath?: Maybe<string>;
50
106
  }
51
107
  /**
52
- * Who a verified state belongs to.
108
+ * A `link` state: an authenticated user making a provider a login method for their account.
109
+ *
110
+ * Shaped like a connect — it carries the uid, and is minted by the same authenticated
111
+ * `read:authorizeState` call — and differs only in the mode, which is what selects the sign-in scopes
112
+ * on the way out and the link write on the way back.
53
113
  */
54
- export interface UserExternalConnectionStateActor {
114
+ export interface UserExternalConnectionLinkStatePayload extends UserExternalConnectionStatePayloadBase {
115
+ readonly mode: 'link';
116
+ /**
117
+ * The user the link belongs to.
118
+ */
55
119
  readonly uid: FirebaseAuthUserId;
56
120
  }
57
- export interface MintUserExternalConnectionStateInput {
121
+ /**
122
+ * The payload carried inside an encrypted external-connection OAuth `state`.
123
+ */
124
+ export type UserExternalConnectionStatePayload = UserExternalConnectionConnectStatePayload | UserExternalConnectionSignInStatePayload | UserExternalConnectionLinkStatePayload;
125
+ /**
126
+ * The payload carried inside an encrypted sign-in ticket.
127
+ *
128
+ * Shares the state's secret and envelope, and is distinguished from it by {@link USER_EXTERNAL_CONNECTION_TICKET_PAYLOAD_TYPE}:
129
+ * without that tag a captured state could be submitted where a ticket is expected.
130
+ */
131
+ export interface UserExternalConnectionSignInTicketPayload {
132
+ readonly t: typeof USER_EXTERNAL_CONNECTION_TICKET_PAYLOAD_TYPE;
133
+ /**
134
+ * The Firebase custom token to hand back once the verifier proves possession.
135
+ */
136
+ readonly customToken: string;
137
+ /**
138
+ * The same client challenge the state carried.
139
+ */
140
+ readonly challenge: string;
141
+ /**
142
+ * The user the token was minted for. Returned only for logging/telemetry at the redemption site.
143
+ */
144
+ readonly uid: FirebaseAuthUserId;
145
+ /**
146
+ * Epoch milliseconds after which the ticket is rejected.
147
+ */
148
+ readonly exp: number;
149
+ }
150
+ /**
151
+ * Type tag distinguishing a ticket payload from a state payload under the shared secret.
152
+ */
153
+ export declare const USER_EXTERNAL_CONNECTION_TICKET_PAYLOAD_TYPE = "uec-signin-ticket";
154
+ /**
155
+ * A verified `connect` state — the user it belongs to.
156
+ */
157
+ export interface UserExternalConnectionConnectStateActor {
158
+ readonly mode: 'connect';
159
+ readonly uid: FirebaseAuthUserId;
160
+ readonly codeVerifier?: Maybe<string>;
161
+ }
162
+ /**
163
+ * A verified `signin` state. Carries no uid: resolving one is the callback's job.
164
+ */
165
+ export interface UserExternalConnectionSignInStateActor {
166
+ readonly mode: 'signin';
167
+ readonly challenge: string;
168
+ readonly codeVerifier?: Maybe<string>;
169
+ readonly returnPath?: Maybe<string>;
170
+ }
171
+ /**
172
+ * A verified `link` state — the user the login link will be written for.
173
+ */
174
+ export interface UserExternalConnectionLinkStateActor {
175
+ readonly mode: 'link';
176
+ readonly uid: FirebaseAuthUserId;
177
+ readonly codeVerifier?: Maybe<string>;
178
+ }
179
+ /**
180
+ * Who (or what) a verified state belongs to.
181
+ */
182
+ export type UserExternalConnectionStateActor = UserExternalConnectionConnectStateActor | UserExternalConnectionSignInStateActor | UserExternalConnectionLinkStateActor;
183
+ /**
184
+ * Returns whether a verified actor came from a `signin` state.
185
+ *
186
+ * @param actor - The verified actor to narrow.
187
+ * @returns True when the actor is a sign-in actor.
188
+ */
189
+ export declare function isUserExternalConnectionSignInStateActor(actor: Maybe<UserExternalConnectionStateActor>): actor is UserExternalConnectionSignInStateActor;
190
+ /**
191
+ * Returns whether a verified actor came from a `link` state.
192
+ *
193
+ * @param actor - The verified actor to narrow.
194
+ * @returns True when the actor is a link actor.
195
+ */
196
+ export declare function isUserExternalConnectionLinkStateActor(actor: Maybe<UserExternalConnectionStateActor>): actor is UserExternalConnectionLinkStateActor;
197
+ export interface MintUserExternalConnectionConnectStateInput {
198
+ readonly mode?: Maybe<'connect'>;
58
199
  readonly uid: FirebaseAuthUserId;
59
200
  readonly providerType: UserExternalConnectionProviderType;
201
+ /**
202
+ * The PKCE code verifier whose challenge the authorize request sends to the provider.
203
+ */
204
+ readonly codeVerifier?: Maybe<string>;
60
205
  }
206
+ export interface MintUserExternalConnectionSignInStateInput {
207
+ readonly mode: 'signin';
208
+ readonly providerType: UserExternalConnectionProviderType;
209
+ /**
210
+ * The client's PKCE challenge, already validated as present.
211
+ */
212
+ readonly challenge: string;
213
+ readonly returnPath?: Maybe<string>;
214
+ readonly codeVerifier?: Maybe<string>;
215
+ }
216
+ export interface MintUserExternalConnectionLinkStateInput {
217
+ readonly mode: 'link';
218
+ readonly uid: FirebaseAuthUserId;
219
+ readonly providerType: UserExternalConnectionProviderType;
220
+ /**
221
+ * The PKCE code verifier whose challenge the authorize request sends to the provider.
222
+ */
223
+ readonly codeVerifier?: Maybe<string>;
224
+ }
225
+ export type MintUserExternalConnectionStateInput = MintUserExternalConnectionConnectStateInput | MintUserExternalConnectionSignInStateInput | MintUserExternalConnectionLinkStateInput;
61
226
  export interface VerifyUserExternalConnectionStateInput {
62
227
  readonly state: Maybe<string>;
63
228
  /**
@@ -65,12 +230,35 @@ export interface VerifyUserExternalConnectionStateInput {
65
230
  */
66
231
  readonly providerType: UserExternalConnectionProviderType;
67
232
  }
233
+ export interface MintUserExternalConnectionTicketInput {
234
+ readonly customToken: string;
235
+ readonly challenge: string;
236
+ readonly uid: FirebaseAuthUserId;
237
+ }
238
+ export interface VerifyUserExternalConnectionTicketInput {
239
+ readonly ticket: Maybe<string>;
240
+ /**
241
+ * The PKCE code verifier the browser retained. Hashed and compared to the ticket's challenge.
242
+ */
243
+ readonly verifier: Maybe<string>;
244
+ }
245
+ /**
246
+ * A redeemed sign-in ticket.
247
+ */
248
+ export interface UserExternalConnectionSignInTicket {
249
+ readonly customToken: string;
250
+ readonly uid: FirebaseAuthUserId;
251
+ }
68
252
  export interface UserExternalConnectionStateCoderConfig {
69
253
  readonly secret: AES256GCMEncryptionSecret;
70
254
  /**
71
255
  * How long a minted state stays valid. Defaults to {@link DEFAULT_USER_EXTERNAL_CONNECTION_STATE_EXPIRATION}.
72
256
  */
73
257
  readonly expiresIn?: Maybe<Milliseconds>;
258
+ /**
259
+ * How long a minted sign-in ticket stays valid. Defaults to {@link DEFAULT_USER_EXTERNAL_CONNECTION_TICKET_EXPIRATION}.
260
+ */
261
+ readonly ticketExpiresIn?: Maybe<Milliseconds>;
74
262
  }
75
263
  /**
76
264
  * Mints and verifies the OAuth `state` for external-connection handoffs.
@@ -80,14 +268,27 @@ export interface UserExternalConnectionStateCoderConfig {
80
268
  */
81
269
  export declare abstract class UserExternalConnectionStateCoder {
82
270
  /**
83
- * Mints a short-lived state for a user's connect handoff with a provider.
271
+ * Mints a short-lived state for a connect or sign-in handoff with a provider.
84
272
  */
85
273
  abstract readonly mintState: (input: MintUserExternalConnectionStateInput) => string;
86
274
  /**
87
- * Resolves the user a state belongs to, or null when it is absent, tampered with, expired, or was
88
- * minted for a different provider.
275
+ * Resolves who a state belongs to, or null when it is absent, tampered with, expired, or was minted
276
+ * for a different provider.
89
277
  */
90
278
  abstract readonly verifyState: (input: VerifyUserExternalConnectionStateInput) => Maybe<UserExternalConnectionStateActor>;
279
+ /**
280
+ * Mints a short-lived ticket carrying a custom token, redeemable only by the browser holding the
281
+ * verifier for its challenge.
282
+ *
283
+ * The ticket exists so the custom token never rides in a URL, where it would land in browser
284
+ * history, the Referer header, and any proxy's access log.
285
+ */
286
+ abstract readonly mintTicket: (input: MintUserExternalConnectionTicketInput) => string;
287
+ /**
288
+ * Redeems a ticket against the verifier the browser retained, or resolves null when the ticket is
289
+ * absent, tampered with, expired, or the verifier does not answer its challenge.
290
+ */
291
+ abstract readonly verifyTicket: (input: VerifyUserExternalConnectionTicketInput) => Promise<Maybe<UserExternalConnectionSignInTicket>>;
91
292
  }
92
293
  /**
93
294
  * Creates the coder that mints and verifies the OAuth `state` for external-connection handoffs.
@@ -0,0 +1,81 @@
1
+ import { type Maybe, type Milliseconds } from '@dereekb/util';
2
+ /**
3
+ * Identifies the caller a throttle decision is made about.
4
+ *
5
+ * A client IP is the only thing an unauthenticated sign-in request carries that is even loosely tied
6
+ * to a caller. It is not a strong identity — NAT shares it, and a determined attacker rotates it —
7
+ * so this bounds trivial abuse rather than defeating a distributed one.
8
+ */
9
+ export interface UserExternalConnectionSignInThrottleKeyInput {
10
+ /**
11
+ * The provider the request targets. Part of the key so one provider's traffic cannot exhaust
12
+ * another's budget.
13
+ */
14
+ readonly providerType: string;
15
+ /**
16
+ * The client's IP, when the request carried a resolvable one.
17
+ */
18
+ readonly clientIp?: Maybe<string>;
19
+ }
20
+ /**
21
+ * Rate limits the unauthenticated sign-in endpoints.
22
+ *
23
+ * These are the only routes in this module reachable without a credential, and `/signin` fronts
24
+ * account creation — so an unlimited one is an open provisioning endpoint. An abstract class so it is
25
+ * its own injection token; optional to provide, but the OAuth service installs
26
+ * {@link memoryUserExternalConnectionSignInThrottle} when an app registers none, so a sign-in
27
+ * provider is never entirely unthrottled.
28
+ */
29
+ export declare abstract class UserExternalConnectionSignInThrottle {
30
+ /**
31
+ * Records an attempt and returns whether it should be REJECTED.
32
+ */
33
+ abstract readonly throttleSignInAttempt: (input: UserExternalConnectionSignInThrottleKeyInput) => Promise<boolean>;
34
+ }
35
+ /**
36
+ * Configuration for {@link memoryUserExternalConnectionSignInThrottle}.
37
+ */
38
+ export interface MemoryUserExternalConnectionSignInThrottleConfig {
39
+ /**
40
+ * Minimum time between two accepted attempts from one key. Defaults to 0 (no minimum gap).
41
+ *
42
+ * OFF by default deliberately: the burst limit is the real guard, and a minimum gap punishes the
43
+ * shared-IP case hardest — every caller behind one NAT, and every caller at all in a deployment
44
+ * that cannot resolve client IPs, shares a single bucket. Set it for a deployment where per-IP
45
+ * really does mean per-user.
46
+ */
47
+ readonly throttleTime?: Maybe<Milliseconds>;
48
+ /**
49
+ * How many attempts one key may make within {@link burstWindow} before being throttled.
50
+ * Defaults to 10.
51
+ */
52
+ readonly burstLimit?: Maybe<number>;
53
+ /**
54
+ * The window the burst limit applies over. Defaults to one minute.
55
+ */
56
+ readonly burstWindow?: Maybe<Milliseconds>;
57
+ }
58
+ /**
59
+ * Creates an IN-PROCESS {@link UserExternalConnectionSignInThrottle}.
60
+ *
61
+ * ## What this is and is not
62
+ *
63
+ * State lives in this process's memory, so in a multi-instance deployment each instance enforces the
64
+ * budget separately and a cold start clears it. That makes it a guard against a single client
65
+ * hammering one instance, NOT a distributed rate limiter. An app that needs a real one implements
66
+ * {@link UserExternalConnectionSignInThrottle} against a shared store and provides that instead —
67
+ * which is why the throttle is an injectable abstraction rather than a hard-coded check.
68
+ *
69
+ * @param config - Optional overrides for the rate and the burst window.
70
+ * @returns The in-memory throttle.
71
+ *
72
+ * @__NO_SIDE_EFFECTS__
73
+ */
74
+ export declare function memoryUserExternalConnectionSignInThrottle(config?: Maybe<MemoryUserExternalConnectionSignInThrottleConfig>): UserExternalConnectionSignInThrottle;
75
+ /**
76
+ * Size the in-memory throttle map is swept at.
77
+ *
78
+ * High enough that a normal caller set never triggers a sweep, low enough that the map cannot grow
79
+ * without bound under an IP-rotating flood.
80
+ */
81
+ export declare const MEMORY_THROTTLE_SWEEP_SIZE = 10000;
@@ -1,12 +1,28 @@
1
1
  import { type Maybe } from '@dereekb/util';
2
- import { type FirebaseAuthUserId, type FirestoreContextReference, type UserExternalConnectionDocument, type UserExternalConnectionEntryStatus, type UserExternalConnectionErrorCode, type UserExternalConnectionFirestoreCollections, type UserExternalConnectionProviderType } from '@dereekb/firebase';
2
+ import { type FirebaseAuthUserId, type FirestoreContextReference, type Transaction, type UserExternalConnectionDocument, type UserExternalConnectionEntryStatus, type UserExternalConnectionErrorCode, type UserExternalConnectionExternalAccountId, type UserExternalConnectionFirestoreCollections, type UserExternalConnectionLoginIdentity, type UserExternalConnectionProviderType } from '@dereekb/firebase';
3
+ import { type FirebaseServerAuthService } from '@dereekb/firebase-server';
3
4
  import { type UserExternalConnectionCredentials, type UserExternalConnectionServerFirestoreCollections } from './userexternalconnection.private';
5
+ import { type UserExternalConnectionProviderPolicyRegistry } from './userexternalconnection.policy';
4
6
  /**
5
7
  * Context required by {@link userExternalConnectionServerActions}.
6
8
  *
7
9
  * Carries BOTH halves of the pair. Nothing else in the workspace should hold the private collection.
8
10
  */
9
11
  export interface UserExternalConnectionServerActionsContext extends FirestoreContextReference, UserExternalConnectionFirestoreCollections, UserExternalConnectionServerFirestoreCollections {
12
+ /**
13
+ * The app's per-provider policies. Optional: a missing registry reads as "all defaults", which is
14
+ * exactly the behavior this module had before policies existed.
15
+ */
16
+ readonly userExternalConnectionProviderPolicyRegistry?: Maybe<UserExternalConnectionProviderPolicyRegistry>;
17
+ /**
18
+ * The app's auth service, used ONLY by the unlink lockout guard to read whether the user still has a
19
+ * Firebase-native login method.
20
+ *
21
+ * Optional because an app that never enables sign-in writes no login links and so never reaches the
22
+ * guard. When it is absent and a link IS being removed, the guard reads the account as having no
23
+ * native provider — the conservative answer, since the alternative is locking someone out.
24
+ */
25
+ readonly userExternalConnectionAuthService?: Maybe<FirebaseServerAuthService>;
10
26
  }
11
27
  /**
12
28
  * Reference to a {@link UserExternalConnectionServerActions} instance.
@@ -59,6 +75,30 @@ export interface UserExternalConnectionDisconnectParams {
59
75
  readonly retainEntry?: Maybe<boolean>;
60
76
  readonly now?: Maybe<Date>;
61
77
  }
78
+ /**
79
+ * Parameters for linking a provider as a LOGIN METHOD for a user.
80
+ *
81
+ * NOTE what is absent, as on {@link UserExternalConnectionConnectParams}: there is no parameter for any
82
+ * field of the stored link. Every one is derived from `identity`, which the server read from a token it
83
+ * obtained itself.
84
+ */
85
+ export interface UserExternalConnectionLinkLoginParams {
86
+ readonly uid: FirebaseAuthUserId;
87
+ readonly providerType: UserExternalConnectionProviderType;
88
+ /**
89
+ * The identity the identity-scoped OAuth round trip resolved.
90
+ */
91
+ readonly identity: UserExternalConnectionLoginIdentity;
92
+ readonly now?: Maybe<Date>;
93
+ }
94
+ /**
95
+ * Parameters for removing a provider as a login method for a user.
96
+ */
97
+ export interface UserExternalConnectionUnlinkLoginParams {
98
+ readonly uid: FirebaseAuthUserId;
99
+ readonly providerType: UserExternalConnectionProviderType;
100
+ readonly now?: Maybe<Date>;
101
+ }
62
102
  /**
63
103
  * Parameters for creating a user's connection document.
64
104
  */
@@ -92,7 +132,34 @@ export declare abstract class UserExternalConnectionServerActions {
92
132
  abstract refreshUserExternalConnectionCredentials(params: UserExternalConnectionRefreshCredentialsParams): Promise<UserExternalConnectionDocument>;
93
133
  abstract markUserExternalConnectionError(params: UserExternalConnectionMarkErrorParams): Promise<UserExternalConnectionDocument>;
94
134
  abstract disconnectUserExternalConnection(params: UserExternalConnectionDisconnectParams): Promise<UserExternalConnectionDocument>;
135
+ /**
136
+ * Records that a provider is a LOGIN METHOD for the user.
137
+ *
138
+ * Writes `li` and recomputes `c`/`ec`; `e` and the private credentials document are left untouched.
139
+ * That separation is the point: the identity-scoped grant a link is established with is not the data
140
+ * grant, so storing its credentials as the data connection would replace a broad grant with a narrow
141
+ * one.
142
+ */
143
+ abstract linkUserExternalConnectionLogin(params: UserExternalConnectionLinkLoginParams): Promise<UserExternalConnectionDocument>;
144
+ /**
145
+ * Removes a provider as a login method for the user.
146
+ *
147
+ * Strictly larger than a disconnect: it removes the login link, the entry, AND the credentials in one
148
+ * transaction. Unlinking is the user saying "this is no longer my account", which cannot leave a
149
+ * live data connection to it behind.
150
+ */
151
+ abstract unlinkUserExternalConnectionLogin(params: UserExternalConnectionUnlinkLoginParams): Promise<UserExternalConnectionDocument>;
95
152
  abstract deleteAllUserExternalConnectionsForUser(params: UserExternalConnectionDeleteAllParams): Promise<void>;
153
+ /**
154
+ * Recomputes every document's derived `ec` array. A one-off job — see
155
+ * {@link backfillUserExternalConnectionExternalAccountKeysFactory}.
156
+ */
157
+ abstract backfillUserExternalConnectionExternalAccountKeys(params?: Maybe<BackfillUserExternalConnectionParams>): Promise<BackfillUserExternalConnectionResult>;
158
+ /**
159
+ * Creates a login link for every connected account that predates `li`. A one-off job — see
160
+ * {@link backfillUserExternalConnectionLoginsFactory}.
161
+ */
162
+ abstract backfillUserExternalConnectionLogins(params?: Maybe<BackfillUserExternalConnectionParams>): Promise<BackfillUserExternalConnectionResult>;
96
163
  }
97
164
  /**
98
165
  * The single write a per-user token cache needs: persisting credentials the provider just issued.
@@ -163,6 +230,95 @@ export interface WriteUserExternalConnectionPairParams {
163
230
  * @returns A function that applies one provider's outcome to both documents atomically.
164
231
  */
165
232
  export declare function writeUserExternalConnectionPairInTransactionFactory(context: UserExternalConnectionServerActionsContext): (params: WriteUserExternalConnectionPairParams) => Promise<UserExternalConnectionDocument>;
233
+ /**
234
+ * Input for the collision check performed inside the paired write's read phase.
235
+ */
236
+ export interface ResolveUserExternalAccountCollisionInput {
237
+ readonly transaction: Transaction;
238
+ /**
239
+ * The user doing the connecting. A document already held by THIS user is not a collision.
240
+ */
241
+ readonly uid: FirebaseAuthUserId;
242
+ readonly providerType: UserExternalConnectionProviderType;
243
+ readonly externalAccountId: UserExternalConnectionExternalAccountId;
244
+ readonly policy: 'block' | 'transfer' | 'allow';
245
+ }
246
+ /**
247
+ * A prior holder of an external account, and the write that removes their claim to it.
248
+ *
249
+ * Returned rather than performed so the caller keeps every read ahead of every write, which a
250
+ * Firestore transaction requires.
251
+ *
252
+ * `release`, not `disconnect`: it removes the prior holder's LOGIN LINK as well as their entry and
253
+ * credentials. `ec` is the union of both maps, so dropping only the entry would leave their key in
254
+ * `ec` and break the very uniqueness invariant the transfer exists to maintain — two users would
255
+ * answer "who is this account?".
256
+ */
257
+ export interface DisplacedUserExternalConnectionHolder {
258
+ readonly uid: FirebaseAuthUserId;
259
+ readonly release: (now: Date) => Promise<void>;
260
+ }
261
+ /**
262
+ * Creates the uniqueness check the paired write runs when a provider's policy declares its
263
+ * connections unique.
264
+ *
265
+ * ## Known limitation, stated deliberately
266
+ *
267
+ * A Firestore transaction adds the documents a query RETURNED to its read set, but it does not lock
268
+ * the ABSENCE of a match. Two simultaneous first-time connects to the same external account can
269
+ * therefore both see no holder and both commit. The window is one transaction wide and every other
270
+ * case (a second connect while a holder exists) is deterministic. Closing it entirely needs a
271
+ * doc-id-keyed claim record — `<providerType>_<externalAccountId>` → uid, created in the same
272
+ * transaction, where the id collision is what serializes the writers. That is additive whenever it
273
+ * is needed.
274
+ *
275
+ * @param context - The context carrying the public collection.
276
+ * @returns A function resolving the collision inside a transaction.
277
+ */
278
+ export declare function resolveUserExternalAccountCollisionInTransactionFactory(context: UserExternalConnectionServerActionsContext): (input: ResolveUserExternalAccountCollisionInput) => Promise<Maybe<DisplacedUserExternalConnectionHolder>>;
279
+ /**
280
+ * Creates the function that records a provider as a LOGIN METHOD for a user.
281
+ *
282
+ * Writes ONLY the public document's `li` map, then recomputes `c`/`ec` from both maps. The entry map
283
+ * and the credentials document are untouched, because the identity-scoped grant this link was
284
+ * established with is not the data grant — persisting it as one would replace a broad grant with a
285
+ * narrow one.
286
+ *
287
+ * Runs the same uniqueness check the paired write runs, for the same reason: after this write the
288
+ * user's `ec` claims the account, and `unique` means at most one user may.
289
+ *
290
+ * @param context - The context carrying both halves of the pair.
291
+ * @returns A function that applies one provider's login link.
292
+ */
293
+ export declare function linkUserExternalConnectionLoginFactory(context: UserExternalConnectionServerActionsContext): (params: UserExternalConnectionLinkLoginParams) => Promise<UserExternalConnectionDocument>;
294
+ /**
295
+ * Creates the function that removes a provider as a login method for a user.
296
+ *
297
+ * One transaction removing `li[providerType]`, `e[providerType]` and `cr[providerType]`. Strictly more
298
+ * than a disconnect: an unlink is the user saying the third-party account is not theirs, which cannot
299
+ * leave a live data connection to it behind.
300
+ *
301
+ * ## The lockout guard
302
+ *
303
+ * Refuses when removing the link would leave the account with NO remaining login link and NO
304
+ * Firebase-native provider on the auth record — the state a custom-token-only user is in, and one
305
+ * nobody can get back out of. Deliberately conservative: an app that provides no auth service to the
306
+ * actions context reads as "no native provider", because guessing wrong in the other direction
307
+ * produces an account no support path can recover.
308
+ *
309
+ * In practice this should rarely fire. The sign-in service provisions a password credential alongside
310
+ * the email on every user it creates (`provisionPasswordCredential`, on by default), so those accounts
311
+ * carry a `password` provider and can always recover through "forgot password". The guard is the
312
+ * BACKSTOP for the cases that do not: a user created with no email, an app that turned the option off,
313
+ * accounts created before it existed, and a user who has since removed their password credential.
314
+ *
315
+ * The guard is skipped entirely when the provider has no link to remove: nothing is being taken away
316
+ * as a login method, and the call degenerates to a disconnect.
317
+ *
318
+ * @param context - The context carrying both halves of the pair.
319
+ * @returns A function that unlinks one provider.
320
+ */
321
+ export declare function unlinkUserExternalConnectionLoginFactory(context: UserExternalConnectionServerActionsContext): (params: UserExternalConnectionUnlinkLoginParams) => Promise<UserExternalConnectionDocument>;
166
322
  /**
167
323
  * Creates a function that deletes a user's entire connection pair in one transaction.
168
324
  *
@@ -170,3 +326,60 @@ export declare function writeUserExternalConnectionPairInTransactionFactory(cont
170
326
  * @returns A function that removes both documents for a uid.
171
327
  */
172
328
  export declare function deleteAllUserExternalConnectionsForUserFactory(context: UserExternalConnectionServerActionsContext): (params: UserExternalConnectionDeleteAllParams) => Promise<void>;
329
+ /**
330
+ * Parameters shared by the one-off backfills.
331
+ */
332
+ export interface BackfillUserExternalConnectionParams {
333
+ /**
334
+ * How many documents to load per checkpoint. Defaults to 100.
335
+ */
336
+ readonly limitPerCheckpoint?: Maybe<number>;
337
+ /**
338
+ * When true, report what would change without writing anything. Defaults to false.
339
+ */
340
+ readonly dryRun?: Maybe<boolean>;
341
+ }
342
+ /**
343
+ * The outcome of a backfill.
344
+ */
345
+ export interface BackfillUserExternalConnectionResult {
346
+ readonly visited: number;
347
+ readonly updated: number;
348
+ }
349
+ /**
350
+ * Creates the one-off job that recomputes every document's derived `ec` array.
351
+ *
352
+ * Documents written BEFORE `ec` existed have none, and `ec` is what the uniqueness policy and the
353
+ * sign-in lookup both query. It is now the union of `e` and `li`, so this also repairs a document whose
354
+ * `ec` predates login links. Until this has run over a collection, a provider marked `unique` sees a
355
+ * pre-existing connection as no connection at all — it would let a second user claim an account the
356
+ * first already holds, and a returning user would be treated as a stranger.
357
+ *
358
+ * Idempotent, and safe to re-run: `ec` is derived purely from `e`, so a document already carrying the
359
+ * correct value is skipped rather than rewritten. Expose it through the app's developer-functions map
360
+ * (`firebaseServerDevFunctions`) rather than any user-reachable route.
361
+ *
362
+ * @param context - The context carrying the public collection.
363
+ * @returns A function performing the backfill.
364
+ */
365
+ export declare function backfillUserExternalConnectionExternalAccountKeysFactory(context: UserExternalConnectionServerActionsContext): (params?: Maybe<BackfillUserExternalConnectionParams>) => Promise<BackfillUserExternalConnectionResult>;
366
+ /**
367
+ * Creates the one-off job that gives every pre-existing connection its LOGIN LINK.
368
+ *
369
+ * Connections established before `li` existed carry their identity only in the entry's `ea`. They keep
370
+ * signing in — `ec` is the union, so the entry still resolves the account — but the settings page shows
371
+ * the provider as NOT linked until the user runs the link flow once, and disconnecting the data
372
+ * connection would then take the binding with it.
373
+ *
374
+ * Only entries whose provider policy declares `signIn: true` are backfilled. A connect-only provider is
375
+ * not a login method, and inventing a link for one would put a sign-in binding on an account that never
376
+ * agreed to it. An entry with no `ea` is skipped: there is no identity to record. A provider that
377
+ * already has a link is left alone, so `lat` is never rewritten.
378
+ *
379
+ * Idempotent and safe to re-run. Expose it through the app's developer-functions map
380
+ * (`firebaseServerDevFunctions`) rather than any user-reachable route.
381
+ *
382
+ * @param context - The context carrying the public collection and the provider policies.
383
+ * @returns A function performing the backfill.
384
+ */
385
+ export declare function backfillUserExternalConnectionLoginsFactory(context: UserExternalConnectionServerActionsContext): (params?: Maybe<BackfillUserExternalConnectionParams>) => Promise<BackfillUserExternalConnectionResult>;