@sendoracloud/sdk-react-native 1.39.1 → 1.41.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/CHANGELOG.md +747 -0
- package/README.md +81 -21
- package/dist/index.cjs +2287 -1010
- package/dist/index.d.cts +1007 -529
- package/dist/index.d.ts +1007 -529
- package/dist/index.js +2280 -1009
- package/package.json +3 -6
package/dist/index.d.ts
CHANGED
|
@@ -127,6 +127,31 @@ interface SendoraConfig {
|
|
|
127
127
|
* either drop that and use this, or expect `<yours><ours>key`.
|
|
128
128
|
*/
|
|
129
129
|
storagePrefix?: string;
|
|
130
|
+
/**
|
|
131
|
+
* ADR-033 project guard (opt-in). The UUID of the Sendora project this build
|
|
132
|
+
* is bound to — set it from the same constant you use for `projectId`. When
|
|
133
|
+
* set, a stored, refreshed or restored session whose JWT `project` claim
|
|
134
|
+
* names a DIFFERENT project (or the org-wide pool) is `withheld` instead of
|
|
135
|
+
* being served: `auth.status()` reports `"withheld"`, `getWithheld()` says
|
|
136
|
+
* why, every auth verb rejects `AuthError{kind:'config', code:'SESSION_WITHHELD'}`,
|
|
137
|
+
* and NOTHING is written to storage — relaunching with the matching key
|
|
138
|
+
* (or calling `auth.discardWithheld()`) is the exit. A freshly minted or
|
|
139
|
+
* signed-in session that disagrees is rejected (`PROJECT_MISMATCH`) and its
|
|
140
|
+
* token revoked, so a wrong-key build fails loudly on first launch instead
|
|
141
|
+
* of installing a foreign session. Validated at init: must be a UUID and,
|
|
142
|
+
* when `projectId` is also set, equal to it. Default off (no `withheld`).
|
|
143
|
+
*/
|
|
144
|
+
expectedProjectId?: string;
|
|
145
|
+
/**
|
|
146
|
+
* ADR-033 held guests. When the device is signed out and holds a guest that
|
|
147
|
+
* a previous sign-in displaced (`auth.getHeldGuests()`), `ensureSession()` /
|
|
148
|
+
* `signInAnonymously()` restore the NEWEST eligible one instead of minting a
|
|
149
|
+
* fresh guest — visibly, as `signed_in{cause:'restore'}` +
|
|
150
|
+
* `guest_released{reason:'restored'}`. Default `true`. `false` mints while
|
|
151
|
+
* the device holds fewer than 5 guests (at the cap a plain mint still
|
|
152
|
+
* restores). Guests left behind by `deleteAccount()` are never auto-restored.
|
|
153
|
+
*/
|
|
154
|
+
autoRestoreHeldGuest?: boolean;
|
|
130
155
|
}
|
|
131
156
|
/**
|
|
132
157
|
* Async key/value adapter for encrypted-at-rest auth storage. Compatible with a
|
|
@@ -241,62 +266,94 @@ declare class Storage {
|
|
|
241
266
|
set(key: string, value: string): void;
|
|
242
267
|
remove(key: string): void;
|
|
243
268
|
/**
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
269
|
+
* Clear this SDK's keys from memory, AsyncStorage and the secure adapter.
|
|
270
|
+
*
|
|
271
|
+
* **With `opts.keys` (ADR-033, the `reset()` path):** removes EXACTLY the
|
|
272
|
+
* listed bare keys of the active namespace — minus `opts.except` — from both
|
|
273
|
+
* stores. An exact list is what lets `Sendora.reset()` keep the held-guest
|
|
274
|
+
* slots by default (a guest's refresh token is its only credential, so
|
|
275
|
+
* sweeping them on logout would be deletion by assumption, s58.316), and it
|
|
276
|
+
* never touches a sibling `sendora_<env>_` keyspace that happens to share
|
|
277
|
+
* the prefix. Values kept in `except` stay in the cache too.
|
|
278
|
+
*
|
|
279
|
+
* **Without `opts` (legacy):** the pre-1.41 sweep — every prefixed
|
|
280
|
+
* AsyncStorage key (via `getAllKeys()`, falling back to the secure-key list)
|
|
281
|
+
* plus every secure key from the adapter.
|
|
249
282
|
*/
|
|
250
|
-
clearAll(
|
|
283
|
+
clearAll(opts?: {
|
|
284
|
+
keys: readonly string[];
|
|
285
|
+
except?: readonly string[];
|
|
286
|
+
}): Promise<void>;
|
|
251
287
|
}
|
|
252
288
|
|
|
253
289
|
/**
|
|
254
|
-
* Auth Service surface for the React Native SDK.
|
|
290
|
+
* Auth Service surface for the React Native SDK — ADR-033 wave 1 (1.41.0),
|
|
291
|
+
* "One Slot, Two Verbs".
|
|
255
292
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* /auth-service/signup as a fresh account.
|
|
264
|
-
* signIn(email, password) — POST /auth-service/login.
|
|
293
|
+
* THE MECHANISM (ADR-033 §3). The SDK has exactly ONE identity record and
|
|
294
|
+
* exactly ONE function that writes it: `commit()`. Every public method is sugar
|
|
295
|
+
* for one event of the shared transition table
|
|
296
|
+
* (`packages/shared/src/auth-machine/transitions.ts`, replayed by
|
|
297
|
+
* `test/auth-machine-conformance.test.ts`), and nothing outside `commit()`
|
|
298
|
+
* assigns `this.user` / the tokens, writes the auth storage keys, arms or stops
|
|
299
|
+
* the refresh cron, or decides which `onAuthStateChanged` event fires (INV-1).
|
|
265
300
|
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
301
|
+
* - Proposal, then CAS (INV-2). A network call changes nothing until it
|
|
302
|
+
* succeeds, and its result is applied only if the session it was derived from
|
|
303
|
+
* is still current: identity-changing commits are keyed on the server session
|
|
304
|
+
* family id `sid` (stable across token rotation), token commits on the
|
|
305
|
+
* refresh token that was sent. A failed, abandoned or superseded attempt
|
|
306
|
+
* leaves the caller exactly as found — there is no "wipe before the network
|
|
307
|
+
* call" API left to misuse (the s58.271 class is unreachable).
|
|
308
|
+
* - Sign-in never carries the guest (INV-3). No request body built here ever
|
|
309
|
+
* contains `prevAnonRefreshToken`, `linkAnonymous` or `onCredentialInUse`.
|
|
310
|
+
* A sign-in from a guest HOLDS the guest on the device (its live tokens go
|
|
311
|
+
* into one of K=5 fixed secure slots `auth_held_0..4`) and switches; the
|
|
312
|
+
* server row survives.
|
|
313
|
+
* - Link is the only upgrade (INV-4). `signUp()`, `signInWith*({link:true})`,
|
|
314
|
+
* `loginSocial({link:true})`, `link*()` and `link()` all go to `/me/link/*`
|
|
315
|
+
* on the current Bearer session (same `sub`). `link:true` over an identified
|
|
316
|
+
* session attaches or returns a typed error — it never switches (INV-14).
|
|
317
|
+
* - Every session-installing event executes only from `signed_out` (INV-5);
|
|
318
|
+
* in `unknown` it queues until the gap-fill refresh answers; in
|
|
319
|
+
* `anonymous`/`identified` MINT returns the current user and the others link.
|
|
320
|
+
* - The project guard (INV-15) is a commit guard: with `init({expectedProjectId})`
|
|
321
|
+
* a stored/refreshed/restored session whose JWT `project` claim disagrees is
|
|
322
|
+
* `withheld` — a fifth status, zero storage writes, every verb rejecting
|
|
323
|
+
* `AuthError{kind:'config', code:'SESSION_WITHHELD'}`; a fresh mint that
|
|
324
|
+
* disagrees is rejected (`PROJECT_MISMATCH`) and its refresh revoked.
|
|
277
325
|
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
* a
|
|
326
|
+
* STORAGE. The four frozen keys (`auth_access_token`, `auth_access_expires`,
|
|
327
|
+
* `auth_refresh_token`, `auth_user`) keep their 1.x format and are written on
|
|
328
|
+
* EVERY install commit (dual-write) so a downgrade to 1.40.0 reads a valid
|
|
329
|
+
* session. `auth_session` is the single-record form written beside them
|
|
330
|
+
* (read-first on hydrate, write-forward from the legacy keys). The held slots
|
|
331
|
+
* are five LITERAL key names — never built dynamically — so they are in
|
|
332
|
+
* `SECURE_KEYS` and route through the app's Keychain/Keystore adapter. Tokens
|
|
333
|
+
* persist in AsyncStorage BY DEFAULT (OWASP MASVS-L1); the opt-in
|
|
334
|
+
* `secureStorage` adapter (1.18.0) moves the secure keys into the Keychain.
|
|
281
335
|
*
|
|
282
|
-
*
|
|
283
|
-
*
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
* OPTIONAL `secureStorage` adapter (`Sendora.init({ secureStorage })`,
|
|
287
|
-
* wired in index.ts) routes the SECURE_KEYS (refresh/access/user) through
|
|
288
|
-
* an app-supplied Keychain / Keystore adapter, with transparent
|
|
289
|
-
* plaintext→secure migration; omit it and behaviour is unchanged. The
|
|
290
|
-
* `auth_refresh_token` storage key stays frozen for back-compat either way.
|
|
291
|
-
*
|
|
292
|
-
* Response payloads are validated non-empty before persisting so a
|
|
293
|
-
* malformed/MITM'd response can't install an `id = ""` user.
|
|
294
|
-
*
|
|
295
|
-
* Access token expiry is tracked locally — getAccessToken() returns
|
|
296
|
-
* null past expiry and triggers a single-flight refresh on the next
|
|
297
|
-
* call.
|
|
336
|
+
* TOKEN LIVENESS is unchanged machinery (s58.281): two deadlines, the one-probe
|
|
337
|
+
* `clockSkewConfirmed` guard, single-flight refresh, 30 s-capped backoff. The
|
|
338
|
+
* token layer never decides identity except on the single server verdict
|
|
339
|
+
* `INVALID_REFRESH_TOKEN` (INV-6).
|
|
298
340
|
*/
|
|
299
341
|
|
|
342
|
+
/** K — the held-guest capacity (ADR-033 §3.2, §8-Q19). */
|
|
343
|
+
declare const HELD_GUEST_SLOTS = 5;
|
|
344
|
+
/**
|
|
345
|
+
* The five client states (ADR-033 §3.2). `unknown` ≠ `signed_out` and
|
|
346
|
+
* `withheld` ≠ `signed_out` — `status()` is the ONLY call that tells them
|
|
347
|
+
* apart, because `getUserSync()` is `null` and `isReady()` is `true` in all three.
|
|
348
|
+
*
|
|
349
|
+
* ⚠ NORMATIVE: consumers MUST default-branch on this value. `withheld` is new in
|
|
350
|
+
* 1.41.0; any LATER value ships opt-in-gated or in a MAJOR.
|
|
351
|
+
*/
|
|
352
|
+
type AuthStatus = "unknown" | "signed_out" | "anonymous" | "identified" | "withheld";
|
|
353
|
+
/** `error.details.endReason` on an `INVALID_REFRESH_TOKEN` rejection (ADR-033 B11). */
|
|
354
|
+
type RefreshEndReason = "expired" | "revoked" | "evicted" | "deleted" | "reuse" | "unknown";
|
|
355
|
+
/** The `code` of the refusal when a destructive/identity held-guest verb omits `id` with 2+ guests held. */
|
|
356
|
+
declare const AMBIGUOUS_HELD_GUEST = "AMBIGUOUS_HELD_GUEST";
|
|
300
357
|
interface AuthUser {
|
|
301
358
|
id: string;
|
|
302
359
|
email: string | null;
|
|
@@ -315,6 +372,12 @@ interface AuthUser {
|
|
|
315
372
|
*/
|
|
316
373
|
signupMethod?: string | null;
|
|
317
374
|
lastLoginMethod?: string | null;
|
|
375
|
+
/**
|
|
376
|
+
* The account's project pool (ADR-033 B17) — the same value as the JWT
|
|
377
|
+
* `project` claim. A string = the project; an EXPLICIT `null` = the org-wide
|
|
378
|
+
* pool; absent = a backend older than B17 (never read absent as null).
|
|
379
|
+
*/
|
|
380
|
+
project?: string | null;
|
|
318
381
|
}
|
|
319
382
|
interface AuthTokens {
|
|
320
383
|
accessToken: string;
|
|
@@ -340,6 +403,122 @@ interface GameCenterPayload {
|
|
|
340
403
|
}
|
|
341
404
|
/** @see AuthApiResponse.anonRetirement */
|
|
342
405
|
type AnonRetirementOutcome = "retired" | "preserved" | "none";
|
|
406
|
+
type SocialCredentialProvider = "google" | "github" | "apple" | "microsoft" | "linkedin" | "facebook" | "twitter" | "discord";
|
|
407
|
+
/**
|
|
408
|
+
* One closed union instead of nine method signatures. Each member carries
|
|
409
|
+
* exactly the wire fields of its sign-in / link route MINUS the guest hint
|
|
410
|
+
* (INV-3: there is no field to put a guest credential in).
|
|
411
|
+
*/
|
|
412
|
+
type Credential = {
|
|
413
|
+
kind: "password";
|
|
414
|
+
email: string;
|
|
415
|
+
password: string;
|
|
416
|
+
} | {
|
|
417
|
+
kind: SocialCredentialProvider;
|
|
418
|
+
code?: string;
|
|
419
|
+
idToken?: string;
|
|
420
|
+
redirectUri?: string;
|
|
421
|
+
codeVerifier?: string;
|
|
422
|
+
appleName?: {
|
|
423
|
+
firstName?: string;
|
|
424
|
+
lastName?: string;
|
|
425
|
+
};
|
|
426
|
+
} | ({
|
|
427
|
+
kind: "gameCenter";
|
|
428
|
+
} & GameCenterPayload) | {
|
|
429
|
+
kind: "playGames";
|
|
430
|
+
serverAuthCode: string;
|
|
431
|
+
} | {
|
|
432
|
+
kind: "magicLink";
|
|
433
|
+
token: string;
|
|
434
|
+
} | {
|
|
435
|
+
kind: "emailOtp";
|
|
436
|
+
email: string;
|
|
437
|
+
code: string;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* React Native ships no WebAuthn authenticator; the kind is accepted for
|
|
441
|
+
* parity with the shared union. `assertion` is forwarded as the
|
|
442
|
+
* `passkeys/authenticate/finish` body (`{ response, userId }`); an object
|
|
443
|
+
* without a `response` key is wrapped as the response itself.
|
|
444
|
+
*/
|
|
445
|
+
| {
|
|
446
|
+
kind: "passkey";
|
|
447
|
+
assertion: Record<string, unknown>;
|
|
448
|
+
} | {
|
|
449
|
+
kind: "mfa";
|
|
450
|
+
challengeToken: string;
|
|
451
|
+
code: string;
|
|
452
|
+
};
|
|
453
|
+
type CredentialKind = Credential["kind"];
|
|
454
|
+
/**
|
|
455
|
+
* `previous` is what the SWITCH replaced. `held: true` ⇔ the previous slot was
|
|
456
|
+
* anonymous and is now in a held slot. `previous.isAnonymous === false` ⇒ an
|
|
457
|
+
* identified → identified switch: skip `mergeAnon` and reset local save state.
|
|
458
|
+
* `reactivated` surfaces a cancelled pending self-service deletion (s58.269).
|
|
459
|
+
*/
|
|
460
|
+
type SignInResult = {
|
|
461
|
+
status: "done";
|
|
462
|
+
user: AuthUser;
|
|
463
|
+
previous: {
|
|
464
|
+
userId: string;
|
|
465
|
+
isAnonymous: boolean;
|
|
466
|
+
held: boolean;
|
|
467
|
+
} | null;
|
|
468
|
+
reactivated: boolean;
|
|
469
|
+
} | {
|
|
470
|
+
status: "mfa_required";
|
|
471
|
+
challengeToken: string;
|
|
472
|
+
/** Completes the challenge — a sign-in continuation on the same credential. */
|
|
473
|
+
verify(code: string): Promise<SignInResult>;
|
|
474
|
+
};
|
|
475
|
+
type LinkCollision = "identity" | "email";
|
|
476
|
+
/**
|
|
477
|
+
* Three outcomes, nothing mutated on the two refusals (INV-12, B2).
|
|
478
|
+
*
|
|
479
|
+
* - `done{alreadyLinked:true}` — a quiet 200: NO event, NO revoke, NO step-up (B13).
|
|
480
|
+
* - `done{upgraded}` — true when the caller was anonymous (same `sub`, rotated pair).
|
|
481
|
+
* - `credential_in_use` — owned by another account (409). `switchAccount()` is
|
|
482
|
+
* the ONLY path from a refused link to a sign-in and is BOUND to the refused
|
|
483
|
+
* credential (INV-14): same payload if under 5 min old, else re-fetched
|
|
484
|
+
* natively (Game Center / Play Games) and compared, aborting with
|
|
485
|
+
* `CREDENTIAL_CHANGED` on mismatch. From anonymous the guest is HELD; from
|
|
486
|
+
* identified `previous:{isAnonymous:false, held:false}`.
|
|
487
|
+
* - `recent_auth_required` — the credential is fine, the session is stale (403).
|
|
488
|
+
* Deliberately NO `reauthenticate(credential)` closure: the credential being
|
|
489
|
+
* linked must never serve as the re-auth.
|
|
490
|
+
*/
|
|
491
|
+
type LinkResult = {
|
|
492
|
+
status: "done";
|
|
493
|
+
user: AuthUser;
|
|
494
|
+
upgraded: boolean;
|
|
495
|
+
alreadyLinked: boolean;
|
|
496
|
+
} | {
|
|
497
|
+
status: "credential_in_use";
|
|
498
|
+
provider: string;
|
|
499
|
+
collision: LinkCollision;
|
|
500
|
+
switchAccount(): Promise<SignInResult>;
|
|
501
|
+
keepCurrent(): void;
|
|
502
|
+
} | {
|
|
503
|
+
status: "recent_auth_required";
|
|
504
|
+
};
|
|
505
|
+
/** What `getHeldGuests()` returns — the slot MINUS its tokens (never exposes tokens). */
|
|
506
|
+
interface HeldGuestSummary {
|
|
507
|
+
userId: string;
|
|
508
|
+
heldAt: number;
|
|
509
|
+
autoRestore: boolean;
|
|
510
|
+
project: string | null;
|
|
511
|
+
}
|
|
512
|
+
/** `guest_released.reason` — every exit from a slot names one. */
|
|
513
|
+
type GuestReleasedReason = "claimed" | "restored" | "discarded" | "dead" | "reset";
|
|
514
|
+
/** What `getWithheld()` returns while `status() === "withheld"`. Never exposes tokens. */
|
|
515
|
+
interface WithheldInfo {
|
|
516
|
+
reason: "project_mismatch";
|
|
517
|
+
expectedProjectId: string;
|
|
518
|
+
actualProjectId: string | null;
|
|
519
|
+
userId: string;
|
|
520
|
+
isAnonymous: boolean;
|
|
521
|
+
}
|
|
343
522
|
declare class EmailAlreadyTakenError extends Error {
|
|
344
523
|
constructor(message?: string);
|
|
345
524
|
}
|
|
@@ -349,6 +528,7 @@ declare class EmailAlreadyTakenError extends Error {
|
|
|
349
528
|
* current one — use `linkEmailPassword()` / `linkGoogle()` / … (ADR-030) to
|
|
350
529
|
* add the credential to THIS account, or sign out first. Distinct from
|
|
351
530
|
* `EmailAlreadyTakenError`: the email isn't the problem, the live session is.
|
|
531
|
+
* Decided LOCALLY since 1.41.0 (no round-trip).
|
|
352
532
|
*/
|
|
353
533
|
declare class AlreadyIdentifiedError extends Error {
|
|
354
534
|
constructor(message?: string);
|
|
@@ -356,7 +536,8 @@ declare class AlreadyIdentifiedError extends Error {
|
|
|
356
536
|
/**
|
|
357
537
|
* A credential you tried to LINK (ADR-030) is already attached to a DIFFERENT
|
|
358
538
|
* account. Sendora never auto-merges two real accounts — sign in to that
|
|
359
|
-
* account instead
|
|
539
|
+
* account instead (`switchAccount()` on the typed `link()` result, or
|
|
540
|
+
* `onCredentialInUse: "adopt"`), or link a different credential.
|
|
360
541
|
*/
|
|
361
542
|
declare class CredentialInUseError extends Error {
|
|
362
543
|
/** Always `"CREDENTIAL_IN_USE"`. */
|
|
@@ -380,11 +561,11 @@ declare class CredentialInUseError extends Error {
|
|
|
380
561
|
* already linked to another account; `"email"` = the provider's verified
|
|
381
562
|
* email already belongs to one. Absent on older backends.
|
|
382
563
|
*/
|
|
383
|
-
readonly collision?:
|
|
564
|
+
readonly collision?: LinkCollision;
|
|
384
565
|
readonly status?: number;
|
|
385
566
|
constructor(message?: string, opts?: {
|
|
386
567
|
provider?: string;
|
|
387
|
-
collision?:
|
|
568
|
+
collision?: LinkCollision;
|
|
388
569
|
status?: number;
|
|
389
570
|
});
|
|
390
571
|
}
|
|
@@ -392,42 +573,36 @@ declare class CredentialInUseError extends Error {
|
|
|
392
573
|
* What a sign-in should do when the credential you present already belongs to
|
|
393
574
|
* an account.
|
|
394
575
|
*
|
|
395
|
-
* **You usually want the default — omit this.** Since 1.
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
399
|
-
*
|
|
400
|
-
* is the reinstall-recovery path: a Game Center / Play Games identity
|
|
401
|
-
* survives a reinstall server-side, so the "collision" is usually the SAME
|
|
402
|
-
* person's earlier account, and they get it back. Never refused.
|
|
403
|
-
* - **Live guest session** → adopting would retire it (**its row is deleted**
|
|
404
|
-
* and `onDeviceTakeover` fires). Refused with `CredentialInUseError` so you
|
|
405
|
-
* can ask the player instead of the SDK guessing on their behalf.
|
|
406
|
-
*
|
|
407
|
-
* Override in either direction:
|
|
576
|
+
* **You usually want the default — omit this.** Since 1.41.0 a collision is
|
|
577
|
+
* only ever observed on a LINK (`link:true` from a guest or an identified
|
|
578
|
+
* session), where the server refuses with 409 `CREDENTIAL_IN_USE` and NOTHING
|
|
579
|
+
* is mutated: the guest account on this device is never deleted by a sign-in
|
|
580
|
+
* any more (ADR-033 INV-3). The question is only what to do next:
|
|
408
581
|
*
|
|
409
|
-
* - `"
|
|
410
|
-
*
|
|
411
|
-
* -
|
|
582
|
+
* - **omitted / `"reject"`** — throw `CredentialInUseError` so you can ask the
|
|
583
|
+
* player. The installed collision dialog keeps working unchanged.
|
|
584
|
+
* - **`"adopt"`** — fall through to a plain sign-in into the owning account
|
|
585
|
+
* (explicit consent). From a guest the guest is HELD on the device (see
|
|
586
|
+
* `getHeldGuests()`), not retired; from an identified session nothing is held.
|
|
412
587
|
*
|
|
413
|
-
* ⚠ A
|
|
414
|
-
*
|
|
415
|
-
*
|
|
588
|
+
* ⚠ A plain `signInWith*()` without `link:true` never sees a collision: with no
|
|
589
|
+
* guest hint on the wire there is nothing for the server to refuse — it signs
|
|
590
|
+
* into the owning account and holds the previous guest.
|
|
416
591
|
*/
|
|
417
592
|
type CredentialCollisionPolicy = "adopt" | "reject";
|
|
418
593
|
/** Options for the Game Center / Play Games sign-in methods. */
|
|
419
594
|
type GameSignInOptions = {
|
|
420
595
|
/**
|
|
421
|
-
* ADR-
|
|
422
|
-
*
|
|
423
|
-
*
|
|
424
|
-
* `
|
|
596
|
+
* Attach-or-error (ADR-033 INV-14). In `anonymous` the guest is promoted in
|
|
597
|
+
* place (same user id); in `identified` the identity is attached to the
|
|
598
|
+
* current account — an identity already on it is a quiet no-op, one owned
|
|
599
|
+
* elsewhere throws `CredentialInUseError`, a stale session throws
|
|
600
|
+
* `RECENT_AUTH_REQUIRED`. In `signed_out` it is a plain sign-in.
|
|
425
601
|
*/
|
|
426
602
|
link?: boolean;
|
|
427
603
|
/**
|
|
428
|
-
* What to do when the
|
|
429
|
-
*
|
|
430
|
-
* deleted. See {@link CredentialCollisionPolicy}.
|
|
604
|
+
* What to do when `link:true` finds the identity on ANOTHER account. Omit it
|
|
605
|
+
* to be asked (`CredentialInUseError`); see {@link CredentialCollisionPolicy}.
|
|
431
606
|
*/
|
|
432
607
|
onCredentialInUse?: CredentialCollisionPolicy;
|
|
433
608
|
};
|
|
@@ -459,10 +634,9 @@ type UnlinkResult = {
|
|
|
459
634
|
* Closed set of auth failure categories (1.28.0). Branch on `kind` instead of
|
|
460
635
|
* string-matching the ~20 raw `code`s the backend and transport can produce.
|
|
461
636
|
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
* the stored refresh token exactly as they were.
|
|
637
|
+
* ⚠ CLOSED — mirrored by every SDK and compared by
|
|
638
|
+
* `packages/shared/src/auth-error-kind-parity.test.ts`. ADR-033's two project
|
|
639
|
+
* guard refusals REUSE `config` (`code: SESSION_WITHHELD | PROJECT_MISMATCH`).
|
|
466
640
|
*/
|
|
467
641
|
type AuthErrorKind = "network" | "server" | "rate_limited" | "invalid_credential" | "account_locked" | "credential_in_use" | "last_credential" | "recent_auth_required" | "already_identified" | "cancelled" | "config" | "unknown";
|
|
468
642
|
declare class AuthError extends Error {
|
|
@@ -484,12 +658,45 @@ declare class AuthError extends Error {
|
|
|
484
658
|
readonly retryAfterSeconds?: number;
|
|
485
659
|
/** HTTP status when the failure came from a response (absent when offline). */
|
|
486
660
|
readonly status?: number;
|
|
661
|
+
/**
|
|
662
|
+
* ADR-033 B11: on an `INVALID_REFRESH_TOKEN` rejection, the server's own
|
|
663
|
+
* verdict (`expired | revoked | deleted | reuse | unknown`; `evicted`
|
|
664
|
+
* reserved). Absent on every other code and on an older backend.
|
|
665
|
+
*/
|
|
666
|
+
readonly endReason?: RefreshEndReason;
|
|
487
667
|
constructor(code: string, message: string, opts?: {
|
|
488
668
|
status?: number;
|
|
489
669
|
retryAfterSeconds?: number;
|
|
490
670
|
kind?: AuthErrorKind;
|
|
671
|
+
retryable?: boolean;
|
|
672
|
+
endReason?: RefreshEndReason;
|
|
491
673
|
});
|
|
492
674
|
}
|
|
675
|
+
/**
|
|
676
|
+
* The device holds K=5 guests and the requested mint would exceed the bound
|
|
677
|
+
* T = |held| + (current anonymous ? 1 : 0) ≤ K (ADR-033 §3.3): thrown by
|
|
678
|
+
* `signInAnonymously({forceNew:true})` at the cap, and by a plain mint /
|
|
679
|
+
* `ensureSession()` in `signed_out` when every slot is ineligible for
|
|
680
|
+
* auto-restore (all stamped `autoRestore:false` by `deleteAccount()`, or
|
|
681
|
+
* project-withheld — §8-Q22). Nothing was mutated; the app must
|
|
682
|
+
* `restoreHeldGuest(id)` or `discardHeldGuest(id)` first.
|
|
683
|
+
*/
|
|
684
|
+
declare class HeldGuestLimitError extends AuthError {
|
|
685
|
+
constructor(message?: string);
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* `restoreHeldGuest(id)` was refused CLIENT-SIDE: the slot's `project` claim
|
|
689
|
+
* recorded at HOLD (or on its refresh response) mismatches
|
|
690
|
+
* `expectedProjectId ?? config.projectId`. The slot is KEPT and excluded from
|
|
691
|
+
* auto-restore; no network call was made. Only `claimHeldGuest()` is
|
|
692
|
+
* server-guarded — the server does not enforce this on refresh.
|
|
693
|
+
*/
|
|
694
|
+
declare class HeldGuestProjectMismatch extends AuthError {
|
|
695
|
+
readonly userId: string;
|
|
696
|
+
readonly expectedProjectId: string;
|
|
697
|
+
readonly actualProjectId: string | null;
|
|
698
|
+
constructor(userId: string, expectedProjectId: string, actualProjectId: string | null);
|
|
699
|
+
}
|
|
493
700
|
interface AuthHooks {
|
|
494
701
|
/** Storage shared with the parent SDK so re-hydration is consistent. */
|
|
495
702
|
storage: Storage;
|
|
@@ -497,23 +704,39 @@ interface AuthHooks {
|
|
|
497
704
|
* identity (analytics userId, push token registration, etc). */
|
|
498
705
|
onIdentityChange: (userId: string | null) => void;
|
|
499
706
|
/**
|
|
500
|
-
* Called from
|
|
501
|
-
* `reason` lets the parent decide whether to rotate the anonymous analytics
|
|
502
|
-
* it rotates ONLY at a person-boundary ("user" = explicit signOut,
|
|
503
|
-
* "account_deleted"), never on
|
|
504
|
-
*
|
|
505
|
-
*
|
|
506
|
-
* semantics
|
|
707
|
+
* Called from the clear funnel whenever the local session is cleared. The
|
|
708
|
+
* `reason` lets the parent decide whether to rotate the anonymous analytics
|
|
709
|
+
* id: it rotates ONLY at a person-boundary ("user" = explicit signOut,
|
|
710
|
+
* "account_deleted"), never on an involuntary session death
|
|
711
|
+
* ("session_expired" / the B11 reasons) — those are the same person, and the
|
|
712
|
+
* anon id must survive so pre-auth analytics link to them (Firebase-FID
|
|
713
|
+
* semantics). A successful sign-in or upgrade never calls this at all since
|
|
714
|
+
* 1.41.0: the guest is HELD, not wiped, and the anon id is untouched (s58.350).
|
|
507
715
|
*/
|
|
508
716
|
onAnonymousWipe: (reason: WipeReason) => Promise<void>;
|
|
717
|
+
/**
|
|
718
|
+
* ADR-033 [2c] analytics quarantine hook: the stored session was refused by
|
|
719
|
+
* the project guard. The parent nulls its in-memory `userId` ONLY — the
|
|
720
|
+
* persisted `user_id` is untouched (zero storage writes in `withheld`).
|
|
721
|
+
*/
|
|
722
|
+
onIdentityWithheld?: (info: WithheldInfo) => void;
|
|
509
723
|
apiUrl: string;
|
|
510
724
|
publicKey: string;
|
|
511
725
|
debug: boolean;
|
|
726
|
+
/**
|
|
727
|
+
* Opt-in project guard (ADR-033 [2c]). Validated by `index.ts` (UUID; equal
|
|
728
|
+
* to `projectId` when both set). Produces the `withheld` status.
|
|
729
|
+
*/
|
|
730
|
+
expectedProjectId?: string;
|
|
731
|
+
/** `config.projectId` — the fallback the held-slot project check compares against. */
|
|
732
|
+
projectId?: string;
|
|
733
|
+
/** Default true (§8-Q1): `ensureSession()` in `signed_out` restores the newest eligible held guest before minting. */
|
|
734
|
+
autoRestoreHeldGuest?: boolean;
|
|
512
735
|
}
|
|
513
736
|
/**
|
|
514
|
-
* Detail handed to onDeviceTakeover subscribers.
|
|
515
|
-
*
|
|
516
|
-
*
|
|
737
|
+
* Detail handed to onDeviceTakeover subscribers. LEGACY (ADR-033): fires only
|
|
738
|
+
* when the SERVER reports a retirement, which this SDK never requests — a
|
|
739
|
+
* wave-1 sign-in carries no guest hint, so no guest is ever retired for it.
|
|
517
740
|
*/
|
|
518
741
|
interface DeviceTakeoverEvent {
|
|
519
742
|
/** The anonymous user_id Sendora just hard-deleted. */
|
|
@@ -535,26 +758,40 @@ interface DeletionCancelledEvent {
|
|
|
535
758
|
at: Date;
|
|
536
759
|
}
|
|
537
760
|
/**
|
|
538
|
-
* Why the local session ended
|
|
539
|
-
* the background emitted NOTHING — an app could not tell a deliberate sign-out
|
|
540
|
-
* apart from an expired refresh token, and only discovered the difference when
|
|
541
|
-
* `getAccessToken()` started returning null.
|
|
761
|
+
* Why the local session ended.
|
|
542
762
|
*
|
|
543
|
-
* - `user` — the app called `signOut()
|
|
544
|
-
* - `session_expired` — the server rejected the stored refresh token
|
|
545
|
-
*
|
|
546
|
-
* rate limits do NOT produce this.
|
|
763
|
+
* - `user` — the app called `signOut()` (or `discardWithheld()`).
|
|
764
|
+
* - `session_expired` — the server rejected the stored refresh token with no
|
|
765
|
+
* finer reason (or `endReason: expired | reuse | unknown`).
|
|
547
766
|
* - `account_deleted` — `deleteAccount()` succeeded.
|
|
767
|
+
* - `revoked` / `evicted` / `deleted` — ADR-033 B11 (additive): the server's
|
|
768
|
+
* own `endReason` on the dead-token verdict. `evicted` is
|
|
769
|
+
* reserved and not emitted by the backend yet.
|
|
770
|
+
*
|
|
771
|
+
* Transient network failures and rate limits never produce any of these.
|
|
772
|
+
* Consumers default-branch (§3.5 NORMATIVE).
|
|
548
773
|
*/
|
|
549
|
-
type AuthSignedOutReason = "user" | "session_expired" | "account_deleted";
|
|
774
|
+
type AuthSignedOutReason = "user" | "session_expired" | "account_deleted" | "revoked" | "evicted" | "deleted";
|
|
550
775
|
/**
|
|
551
|
-
*
|
|
552
|
-
*
|
|
553
|
-
*
|
|
776
|
+
* Why a `signed_in` fired (ADR-033 §3.5). `restore` ⇒ re-register push (the
|
|
777
|
+
* held guest's push tokens were erased if it had been deleted elsewhere);
|
|
778
|
+
* `link` = the legacy same-`sub` promotion the legacy-signature methods
|
|
779
|
+
* (`signInWith*({link:true})`, `loginSocial({link:true})`, `signUp`) keep
|
|
780
|
+
* emitting (§8-Q18). Absent on a gap-fill recovery from `unknown` (1.29.0).
|
|
781
|
+
*/
|
|
782
|
+
type AuthSignedInCause = "mint" | "sign_in" | "restore" | "link";
|
|
783
|
+
/** What changed the user object WITHOUT changing the `sub`. */
|
|
784
|
+
type AuthUserUpdatedCause = "link" | "unlink" | "email_verified" | "refresh" | "merge";
|
|
785
|
+
/**
|
|
786
|
+
* A single auth-state transition. Subscribe with `onAuthStateChanged` — one
|
|
787
|
+
* stream covering everything. The first four members are the pre-1.41.0
|
|
788
|
+
* contract verbatim (`signed_in` gaining an OPTIONAL `cause`); the rest are
|
|
789
|
+
* additive (ADR-033). ⚠ Default-branch on `event`.
|
|
554
790
|
*/
|
|
555
791
|
type AuthStateChange = {
|
|
556
792
|
event: "signed_in";
|
|
557
793
|
user: AuthUser;
|
|
794
|
+
cause?: AuthSignedInCause;
|
|
558
795
|
} | {
|
|
559
796
|
event: "signed_out";
|
|
560
797
|
user: null;
|
|
@@ -566,12 +803,47 @@ type AuthStateChange = {
|
|
|
566
803
|
} | {
|
|
567
804
|
event: "deletion_cancelled";
|
|
568
805
|
user: AuthUser | null;
|
|
806
|
+
}
|
|
807
|
+
/** The settled boot state, exactly once per subscriber — INCLUDING `signed_out`, `unknown` and `withheld`. */
|
|
808
|
+
| {
|
|
809
|
+
event: "initial";
|
|
810
|
+
status: AuthStatus;
|
|
811
|
+
user: AuthUser | null;
|
|
812
|
+
withheld?: WithheldInfo;
|
|
813
|
+
}
|
|
814
|
+
/** Same `sub`, different user object (a link attach, an unlink, a claim, a refresh that changed the user). */
|
|
815
|
+
| {
|
|
816
|
+
event: "user_updated";
|
|
817
|
+
user: AuthUser;
|
|
818
|
+
cause: AuthUserUpdatedCause;
|
|
819
|
+
}
|
|
820
|
+
/** A guest was moved into a held slot (`count` = guests held after this one). */
|
|
821
|
+
| {
|
|
822
|
+
event: "guest_held";
|
|
823
|
+
userId: string;
|
|
824
|
+
count: number;
|
|
825
|
+
user: AuthUser;
|
|
826
|
+
}
|
|
827
|
+
/** A held slot was emptied. ⚠ `dead` = "unavailable when its token was presented", NOT an erasure signal. */
|
|
828
|
+
| {
|
|
829
|
+
event: "guest_released";
|
|
830
|
+
userId: string;
|
|
831
|
+
reason: GuestReleasedReason;
|
|
832
|
+
endReason?: RefreshEndReason;
|
|
833
|
+
}
|
|
834
|
+
/** An in-process transition INTO `withheld` (a refresh answered with a mismatching claim). Never replayed. */
|
|
835
|
+
| {
|
|
836
|
+
event: "session_withheld";
|
|
837
|
+
reason: "project_mismatch";
|
|
838
|
+
expectedProjectId: string;
|
|
839
|
+
actualProjectId: string | null;
|
|
840
|
+
userId: string;
|
|
569
841
|
};
|
|
570
842
|
type AuthStateListener = (change: AuthStateChange) => void;
|
|
571
843
|
/**
|
|
572
|
-
*
|
|
573
|
-
*
|
|
574
|
-
*
|
|
844
|
+
* The reasons the clear funnel can be entered with. `replaced` is kept on the
|
|
845
|
+
* type for compatibility with hooks written against 1.38–1.40 but is no longer
|
|
846
|
+
* produced: a successful sign-in HOLDS the previous guest instead of wiping it.
|
|
575
847
|
*/
|
|
576
848
|
type WipeReason = AuthSignedOutReason | "replaced";
|
|
577
849
|
declare class Auth {
|
|
@@ -579,6 +851,16 @@ declare class Auth {
|
|
|
579
851
|
private user;
|
|
580
852
|
private accessToken;
|
|
581
853
|
private accessExpiresAt;
|
|
854
|
+
/** The in-memory mirror of the K held slots (index = slot number). Written only by commit(). */
|
|
855
|
+
private held;
|
|
856
|
+
/** Non-null ⇔ `status() === "withheld"` (ADR-033 [2c]). Never persisted. */
|
|
857
|
+
private withheld;
|
|
858
|
+
/** The refresh token the withheld record carried — the CAS key of `discardWithheld()`. */
|
|
859
|
+
private withheldRefresh;
|
|
860
|
+
/** B15: held guests whose `POST /guest/held` report has not yet returned 2xx. Persisted in `auth_session`. */
|
|
861
|
+
private heldReportsPending;
|
|
862
|
+
/** The in-flight identity-changing proposal; the proactive cron pauses while it is set. */
|
|
863
|
+
private attempt;
|
|
582
864
|
/**
|
|
583
865
|
* Set once we have PROOF that this device's clock runs fast, which
|
|
584
866
|
* releases the `exp` guard in `canServeCachedToken`.
|
|
@@ -603,15 +885,10 @@ declare class Auth {
|
|
|
603
885
|
private inflight;
|
|
604
886
|
private refreshInflight;
|
|
605
887
|
/**
|
|
606
|
-
* Wave 62 — refresh failure back-pressure.
|
|
607
|
-
*
|
|
608
|
-
*
|
|
609
|
-
*
|
|
610
|
-
* back off so the next caller doesn't immediately fire another
|
|
611
|
-
* fetch that will also time out. After `REFRESH_FAILURE_COOLDOWN_MS`
|
|
612
|
-
* the counter resets + retries are allowed. AppState foreground
|
|
613
|
-
* also clears it so a user returning to the app gets an immediate
|
|
614
|
-
* fresh attempt.
|
|
888
|
+
* Wave 62 — refresh failure back-pressure. Consecutive network-timeout
|
|
889
|
+
* failures don't wipe identity (refresh may still be valid; iOS NSURLSession
|
|
890
|
+
* was just stalled) but do back off so the next caller doesn't immediately
|
|
891
|
+
* fire another fetch that will also time out. AppState foreground clears it.
|
|
615
892
|
*/
|
|
616
893
|
private refreshFailureCount;
|
|
617
894
|
private refreshCooldownUntil;
|
|
@@ -621,176 +898,132 @@ declare class Auth {
|
|
|
621
898
|
private lastDeletionCancelled;
|
|
622
899
|
private lastAnonRetirement;
|
|
623
900
|
private lastTakeover;
|
|
624
|
-
/**
|
|
625
|
-
* Anon refresh token captured at `signIn()` time, stashed keyed to the
|
|
626
|
-
* issued `mfaChallengeToken` so the later `challengeMfa()` can forward it
|
|
627
|
-
* for device-takeover (s58.111). Before this, the MFA branch wiped the anon
|
|
628
|
-
* identity before the challenge resolved → the takeover hint was lost → one
|
|
629
|
-
* device ended with two user_ids + duplicate pushes (audit s58.203 fix).
|
|
630
|
-
*/
|
|
631
|
-
private pendingAnonTakeover;
|
|
632
901
|
/**
|
|
633
902
|
* Resolves when the parent SDK has finished its async init() —
|
|
634
903
|
* AsyncStorage hydrated + auth.hydrate() restored the cached
|
|
635
904
|
* session. All public auth methods await this internally so a
|
|
636
905
|
* customer who forgets `await sendora.init()` before showing the
|
|
637
|
-
* signup UI doesn't hit the cold-start race
|
|
638
|
-
* path's `storage.get(REFRESH_KEY)` returns null even though a
|
|
639
|
-
* valid anonymous session is sitting on disk.
|
|
640
|
-
*
|
|
641
|
-
* Industry-standard "SDK gates on internal readiness" pattern
|
|
642
|
-
* (Firebase Auth, Auth0, Clerk all do equivalent internal
|
|
643
|
-
* gating).
|
|
906
|
+
* signup UI doesn't hit the cold-start race.
|
|
644
907
|
*/
|
|
645
908
|
private readyPromise;
|
|
646
909
|
private resolveReady;
|
|
647
|
-
/**
|
|
648
|
-
* True once `hydrate()` has run. Distinguishes "no session restored YET"
|
|
649
|
-
* from "genuinely signed out" for `onAuthStateChanged`'s replay-on-subscribe.
|
|
650
|
-
*/
|
|
910
|
+
/** True once `hydrate()` has run. Gates `onAuthStateChanged`'s replay-on-subscribe. */
|
|
651
911
|
private hydrated;
|
|
652
912
|
constructor(hooks: AuthHooks);
|
|
653
913
|
/**
|
|
654
|
-
*
|
|
655
|
-
* init() after Storage.hydrate() has run.
|
|
656
|
-
*
|
|
657
|
-
*
|
|
658
|
-
*
|
|
659
|
-
*
|
|
660
|
-
*
|
|
661
|
-
*
|
|
662
|
-
*
|
|
663
|
-
*
|
|
664
|
-
*
|
|
665
|
-
*
|
|
666
|
-
*
|
|
667
|
-
* (
|
|
668
|
-
*
|
|
669
|
-
*
|
|
914
|
+
* Restore the session from storage. Called by the parent SDK during
|
|
915
|
+
* init() after Storage.hydrate() has run. Disk-only, never waits on the
|
|
916
|
+
* network; resolves the internal ready gate so queued auth calls unblock.
|
|
917
|
+
*
|
|
918
|
+
* Reads `auth_session` FIRST, then the four frozen keys. When the frozen keys
|
|
919
|
+
* carry a refresh token that differs from the record's, the FROZEN keys win:
|
|
920
|
+
* every SDK version writes them, so after a 1.41 → 1.40 → 1.41 round trip
|
|
921
|
+
* they are the fresher copy and the stale `auth_session` must not resurrect a
|
|
922
|
+
* rotated-away token. The record is then written forward from them.
|
|
923
|
+
*
|
|
924
|
+
* Coherence (INV-9): user ∧ refresh → judged by the project guard
|
|
925
|
+
* (match → installed; mismatch → `withheld`, ZERO writes; unknown → stay
|
|
926
|
+
* `unknown`, one gap-fill refresh re-judges it); refresh ∧ ¬user → `unknown`
|
|
927
|
+
* (LIMBO_A — never `signed_out`, never a mint; the refresh token is the only
|
|
928
|
+
* thing that can recover the account, 1.28.0); user ∧ ¬refresh → the stale
|
|
929
|
+
* blob is cleared → `signed_out`. Held slots: a slot whose `userId` equals the
|
|
930
|
+
* current user is dropped (current wins); a slot that fails to parse is KEPT
|
|
931
|
+
* and ignored, never cleared (P11).
|
|
670
932
|
*/
|
|
671
933
|
hydrate(): void;
|
|
934
|
+
/** Read `auth_session` + the four frozen keys and reconcile them (see `hydrate()`). */
|
|
935
|
+
private readDiskSession;
|
|
936
|
+
/** Load the K slots into the in-memory mirror; dedupe against the current user (current wins); keep unparseable slots untouched. */
|
|
937
|
+
private hydrateHeldSlots;
|
|
938
|
+
/** Parse one slot off disk. `null` for an empty OR unparseable slot (the latter is left on disk, P11). */
|
|
939
|
+
private readSlot;
|
|
940
|
+
/**
|
|
941
|
+
* The closed boot/identity state. Synchronous, never waits on the network.
|
|
942
|
+
*
|
|
943
|
+
* - `unknown` — not hydrated yet, OR a refresh token is on disk but the
|
|
944
|
+
* user is not readable / not yet judged (the gap-fill
|
|
945
|
+
* refresh settles it). Session-installing verbs QUEUE here.
|
|
946
|
+
* - `signed_out` — no refresh token. The only state a mint executes in.
|
|
947
|
+
* - `anonymous` / `identified` — a live session of that kind.
|
|
948
|
+
* - `withheld` — a stored session refused by `init({expectedProjectId})`.
|
|
949
|
+
*
|
|
950
|
+
* ⚠ This is the ONLY way to tell `withheld` and `unknown` from `signed_out`:
|
|
951
|
+
* `getUserSync()` is `null` and `isReady()` is `true` in all three. Consumers
|
|
952
|
+
* MUST default-branch — a later value ships opt-in-gated or in a MAJOR.
|
|
953
|
+
*/
|
|
954
|
+
status(): AuthStatus;
|
|
955
|
+
/** Resolves after hydrate with the settled status — possibly `unknown` or `withheld`. Never waits on the network. */
|
|
956
|
+
authStateReady(): Promise<AuthStatus>;
|
|
672
957
|
getCurrentUser(): AuthUser | null;
|
|
958
|
+
/** Alias of `getCurrentUser()` gated on hydration (parity with `Sendora.getUserSync()`). */
|
|
959
|
+
getUserSync(): AuthUser | null;
|
|
960
|
+
/** True once `hydrate()` has run. `true` in `withheld` and `unknown` too — read `status()`. */
|
|
961
|
+
isReady(): boolean;
|
|
673
962
|
/**
|
|
674
|
-
* Subscribe to device-takeover events (s58.111+).
|
|
675
|
-
* the
|
|
676
|
-
*
|
|
677
|
-
*
|
|
678
|
-
*
|
|
679
|
-
*
|
|
680
|
-
* Fires on EVERY identified sign-in path — `signIn`, `loginSocial`,
|
|
681
|
-
* `signInWithGameCenter`, `signInWithPlayGames`, `challengeMfa`,
|
|
682
|
-
* `verifyMagicLink`, `verifyEmailOtp`, passkey authentication, and SSO
|
|
683
|
-
* completion (`consumeSsoFragment`) — because it is fired centrally from
|
|
684
|
-
* `persist()` whenever the response carries `retiredAnonUserId`.
|
|
685
|
-
*
|
|
686
|
-
* ⚠ The two gaming paths are the ones that matter most here, and were
|
|
687
|
-
* missing from this list before 1.31.0. When the presented player identity
|
|
688
|
-
* already belongs to another account, the sign-in ADOPTS that account and
|
|
689
|
-
* the anonymous account on this device is retired — its row is DELETED
|
|
690
|
-
* server-side. This listener is the only client-side signal that happened;
|
|
691
|
-
* the call itself resolves successfully. If you would rather it not happen
|
|
692
|
-
* at all, pass `onCredentialInUse: "reject"`.
|
|
693
|
-
*
|
|
694
|
-
* Returns an unsubscribe function.
|
|
963
|
+
* Subscribe to device-takeover events (s58.111+). LEGACY after ADR-033: a
|
|
964
|
+
* wave-1 SDK never sends the guest hint, so the server never retires a guest
|
|
965
|
+
* for its sign-ins and this fires only if the SERVER reports a retirement.
|
|
966
|
+
* Kept callable; replace your handler with `onAuthStateChanged` +
|
|
967
|
+
* `guest_held` / `getHeldGuests()`. Returns an unsubscribe function.
|
|
695
968
|
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
969
|
+
* Historically the account-DELETING paths were the gaming sign-ins —
|
|
970
|
+
* `signInWithGameCenter` / `signInWithPlayGames` (and `loginSocial`) adopting
|
|
971
|
+
* an existing account retired this device's guest row while the call itself
|
|
972
|
+
* resolved successfully. On 1.41.0 those calls HOLD the guest instead; a
|
|
973
|
+
* takeover can only be reported for a sign-in that reached the legacy path.
|
|
698
974
|
*/
|
|
699
975
|
onDeviceTakeover(listener: (evt: DeviceTakeoverEvent) => void): () => void;
|
|
700
976
|
/**
|
|
701
|
-
* Subscribe to every auth-state transition
|
|
702
|
-
*
|
|
703
|
-
*
|
|
977
|
+
* Subscribe to every auth-state transition — the single stream that answers
|
|
978
|
+
* "what happened to my session?". Firebase `onAuthStateChanged` / Supabase
|
|
979
|
+
* `onAuthStateChange` parity.
|
|
704
980
|
*
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
*
|
|
708
|
-
*
|
|
709
|
-
*
|
|
710
|
-
*
|
|
711
|
-
*
|
|
712
|
-
* hydrated (a `signed_in` when a session was restored from disk — including
|
|
713
|
-
* offline, since hydrate is disk-only), so a subscriber never misses the
|
|
714
|
-
* state it started in. Nothing is emitted for a signed-out cold start.
|
|
981
|
+
* Replays the SETTLED boot state exactly once on subscribe, once `init()` has
|
|
982
|
+
* hydrated, as `initial{status,user,withheld?}` — including `signed_out`,
|
|
983
|
+
* `unknown` and `withheld`, so a subscriber can never confuse "no session"
|
|
984
|
+
* with "not hydrated yet". With a live session (`anonymous` / `identified`)
|
|
985
|
+
* `initial` is FOLLOWED by the legacy `signed_in{user}` replay that
|
|
986
|
+
* 1.28.0–1.40.0 sent (kept — ADR-023); new code should read `initial` and
|
|
987
|
+
* ignore a `signed_in` that carries no `cause` right after it.
|
|
715
988
|
*
|
|
716
989
|
* A failed sign-in emits NOTHING: the session is unchanged, so there is no
|
|
717
990
|
* transition to report — the rejected promise is the whole story.
|
|
718
991
|
*
|
|
719
|
-
*
|
|
720
|
-
*
|
|
992
|
+
* ⚠ This is a TRANSITION stream, not a health or connectivity signal. A
|
|
993
|
+
* transient failure of the background token refresh (offline, timeout, 429,
|
|
994
|
+
* 5xx) emits nothing: the stored refresh token is kept and the refresh retried
|
|
995
|
+
* with backoff. Only a server-REJECTED refresh token produces `signed_out`
|
|
996
|
+
* (reason `session_expired`, or the server's own `revoked` / `deleted`). For
|
|
997
|
+
* liveness read `getAccessToken()` (null = no usable token right now).
|
|
998
|
+
*
|
|
999
|
+
* Replacing a guest with an account emits `guest_held` then ONE `signed_in`
|
|
1000
|
+
* — no `signed_out`. Listeners are best-effort; a throwing listener is
|
|
1001
|
+
* swallowed (dev-logged). Returns an unsubscribe function.
|
|
721
1002
|
*/
|
|
722
1003
|
onAuthStateChanged(listener: AuthStateListener): () => void;
|
|
723
1004
|
/** Fan a state change out to subscribers. Never throws. */
|
|
724
1005
|
private emitAuthState;
|
|
725
|
-
/**
|
|
726
|
-
* Inspect the most recent device-takeover event observed by the
|
|
727
|
-
* SDK. Cleared on signOut. Returns null when no takeover has
|
|
728
|
-
* happened during this session — useful for late subscribers or
|
|
729
|
-
* post-sign-in side-effects (e.g. app-launch hydrate).
|
|
730
|
-
*/
|
|
1006
|
+
/** The most recent device-takeover event observed, or null. Cleared on signOut. */
|
|
731
1007
|
getLastDeviceTakeover(): DeviceTakeoverEvent | null;
|
|
732
|
-
/** Internal — fan out to subscribers + cache the latest. */
|
|
733
1008
|
private fireDeviceTakeover;
|
|
734
1009
|
/**
|
|
735
1010
|
* Subscribe to deletion-cancelled events (s58.269): fires when a sign-in
|
|
736
1011
|
* cancelled a pending self-service account deletion within its grace window
|
|
737
|
-
* (the account is restored, same user_id).
|
|
738
|
-
*
|
|
739
|
-
* loginSocial / signInWithGameCenter / signInWithPlayGames. Returns an
|
|
740
|
-
* unsubscribe function; listener errors are swallowed (best-effort).
|
|
1012
|
+
* (the account is restored, same user_id). Also surfaced as
|
|
1013
|
+
* `SignInResult.reactivated`. Returns an unsubscribe function.
|
|
741
1014
|
*/
|
|
742
1015
|
onDeletionCancelled(listener: (evt: DeletionCancelledEvent) => void): () => void;
|
|
743
1016
|
/** The most recent deletion-cancelled event this session, or null. Cleared on signOut. */
|
|
744
1017
|
getLastDeletionCancelled(): DeletionCancelledEvent | null;
|
|
745
1018
|
/**
|
|
746
|
-
* What the last sign-in did with this device's guest account
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
*
|
|
750
|
-
*
|
|
751
|
-
* your other account" is a real offer rather than a guess. `"retired"` means
|
|
752
|
-
* migrate from `onDeviceTakeover`'s id; `"none"` means there was nothing to
|
|
753
|
-
* reconcile. Cleared on signOut.
|
|
1019
|
+
* What the last sign-in did with this device's guest account (s58.278).
|
|
1020
|
+
* LEGACY: a wave-1 SDK never presents a guest token, so a current backend
|
|
1021
|
+
* answers `"none"` for every sign-in. Read `getHeldGuests()` instead — the
|
|
1022
|
+
* guest is on this device, held. Recorded only when the server states the
|
|
1023
|
+
* field. Cleared on signOut.
|
|
754
1024
|
*/
|
|
755
1025
|
getLastAnonRetirement(): AnonRetirementOutcome | null;
|
|
756
|
-
/** Internal — fan out deletion-cancelled to subscribers + cache the latest. */
|
|
757
1026
|
private fireDeletionCancelled;
|
|
758
|
-
/**
|
|
759
|
-
* Returns a VALID (non-expired) access token, or null.
|
|
760
|
-
*
|
|
761
|
-
* Hard contract (matches sdk-ios / sdk-android, and the industry
|
|
762
|
-
* token-client pattern — Supabase `_callRefreshToken`, Auth0
|
|
763
|
-
* `getTokenSilently`, Firebase `getIdToken`): this method NEVER
|
|
764
|
-
* returns a token past its `exp`. Callers attach the result as a
|
|
765
|
-
* Bearer to arbitrary resource servers — including third-party
|
|
766
|
-
* backends (PostgREST/Supabase) that have NO knowledge of Sendora's
|
|
767
|
-
* own refresh grace window — so a token must be either fresh or
|
|
768
|
-
* absent. A token expired by hours is useless and yields a 401.
|
|
769
|
-
*
|
|
770
|
-
* Resolution order:
|
|
771
|
-
* 1. Cached token with comfortable life left (>= REFRESH_SAFETY_MS
|
|
772
|
-
* before BOTH the tracked deadline and the token's own `exp`) →
|
|
773
|
-
* return it. Fast path, no I/O. See `canServeCachedToken` for why
|
|
774
|
-
* both are required and when the second is released.
|
|
775
|
-
* Pass `{ forceRefresh: true }` to skip this step when the caller
|
|
776
|
-
* knows the tracked deadline is wrong.
|
|
777
|
-
* 2. No cached token AND no refresh token in storage → genuinely
|
|
778
|
-
* signed out → null.
|
|
779
|
-
* 3. Expired/near-expiry but a refresh chain exists:
|
|
780
|
-
* a. A refresh is already inflight → AWAIT it and return the
|
|
781
|
-
* fresh token. Concurrent callers coalesce onto the single
|
|
782
|
-
* in-flight `/token/refresh` (single-flight) — nobody is
|
|
783
|
-
* handed a stale token. The inflight fetch is
|
|
784
|
-
* AbortController-bounded (http.ts caps `/auth-service/*` at
|
|
785
|
-
* 10s), so awaiting can NEVER hang the JS bridge. That
|
|
786
|
-
* bounded-fetch guarantee — NOT the old stale-token return —
|
|
787
|
-
* is what actually fixed the Wave-62 cold-launch hang.
|
|
788
|
-
* b. A recent refresh failed and we're in backoff cooldown →
|
|
789
|
-
* null. Honest "no usable token right now" rather than a
|
|
790
|
-
* provably-expired one; the AppState foreground hook clears
|
|
791
|
-
* the cooldown for an immediate retry on resume.
|
|
792
|
-
* c. Otherwise → fire the refresh and await it.
|
|
793
|
-
*/
|
|
794
1027
|
/**
|
|
795
1028
|
* The cached-token guard, and the single place the two deadlines are
|
|
796
1029
|
* combined. Both must agree before a cached token is served:
|
|
@@ -801,16 +1034,8 @@ declare class Auth {
|
|
|
801
1034
|
* full offset on a constantly-wrong clock.
|
|
802
1035
|
*
|
|
803
1036
|
* Guard 2 is released once `clockSkewConfirmed` proves the second failure
|
|
804
|
-
* mode is the one we are actually in.
|
|
805
|
-
*
|
|
806
|
-
* The gap this closes: `accessExpiresAt` is written in the device's clock
|
|
807
|
-
* frame at mint and PERSISTED. Step the clock backwards between mint and
|
|
808
|
-
* read — an iPhone acquiring automatic time after a long power-off, a
|
|
809
|
-
* restore, or a manual set — and the deadline is stale by the size of the
|
|
810
|
-
* correction, surviving relaunches. The SDK then serves a provably dead
|
|
811
|
-
* token, every request 401s, and because the fast path returns before
|
|
812
|
-
* reaching `persist()` or `wipeLocalIdentity()` nothing emits
|
|
813
|
-
* `signed_out`. Reported by Word Hurdle with a token 1929s past its `exp`.
|
|
1037
|
+
* mode is the one we are actually in. ⚠ NEVER derive one deadline from the
|
|
1038
|
+
* other (`accessExpiresAt = exp * 1000` throws away skew-invariance).
|
|
814
1039
|
*/
|
|
815
1040
|
private canServeToken;
|
|
816
1041
|
private canServeCachedToken;
|
|
@@ -826,67 +1051,102 @@ declare class Auth {
|
|
|
826
1051
|
* the correction, so a mint-time probe records "clock is fast", and the flag
|
|
827
1052
|
* then waves through the very token the guard exists to refuse. Probing here
|
|
828
1053
|
* — only on a refresh we performed BECAUSE `exp` contradicted a tracked
|
|
829
|
-
* deadline that still claimed life — separates the two cases,
|
|
830
|
-
* the clock has either been corrected (fresh token reads live → stale
|
|
831
|
-
* deadline, guard holds) or is genuinely fast (fresh token still reads dead
|
|
832
|
-
* → release the guard, one probe, no loop).
|
|
1054
|
+
* deadline that still claimed life — separates the two cases: one probe, no loop.
|
|
833
1055
|
*/
|
|
834
1056
|
private refreshAndReconcile;
|
|
835
|
-
/**
|
|
836
|
-
* Record whether a token the server issued MOMENTS AGO already reads as
|
|
837
|
-
* expired. It cannot genuinely be — so a `false` here is proof the device
|
|
838
|
-
* clock runs fast, and releases the `exp` guard for this process.
|
|
839
|
-
*
|
|
840
|
-
* Call this at every site that installs a server-issued token, and only
|
|
841
|
-
* with a token that just arrived. Handing it a cached token would let a
|
|
842
|
-
* stale deadline confirm itself.
|
|
843
|
-
*/
|
|
1057
|
+
/** A token the server issued MOMENTS AGO already reads as expired ⇒ the clock runs fast; release the `exp` guard for this process. */
|
|
844
1058
|
private learnClockSkewFrom;
|
|
845
1059
|
/** True when a cached token is unusable ONLY because its `exp` contradicts a live tracked deadline. */
|
|
846
1060
|
private deadlinesDisagree;
|
|
1061
|
+
/**
|
|
1062
|
+
* Returns a VALID (non-expired) access token, or null.
|
|
1063
|
+
*
|
|
1064
|
+
* Hard contract (matches sdk-ios / sdk-android, and the industry
|
|
1065
|
+
* token-client pattern — Supabase `_callRefreshToken`, Auth0
|
|
1066
|
+
* `getTokenSilently`, Firebase `getIdToken`): this method NEVER returns a
|
|
1067
|
+
* token past its `exp`. Callers attach the result as a Bearer to arbitrary
|
|
1068
|
+
* resource servers that know nothing of Sendora's refresh grace, so a token
|
|
1069
|
+
* must be either fresh or absent.
|
|
1070
|
+
*
|
|
1071
|
+
* Resolution order:
|
|
1072
|
+
* 0. `withheld` → null, no refresh (ADR-033 [2c]: the refused session is
|
|
1073
|
+
* never served and never refreshed by this process).
|
|
1074
|
+
* 1. Cached token with comfortable life left (>= 30 s before BOTH the
|
|
1075
|
+
* tracked deadline and the token's own `exp`) → return it. Fast path.
|
|
1076
|
+
* `{ forceRefresh: true }` skips it (the caller has out-of-band evidence
|
|
1077
|
+
* the tracked deadline is wrong); it does NOT bypass the single-flight or
|
|
1078
|
+
* the backoff cooldown.
|
|
1079
|
+
* 2. No refresh token in storage → genuinely signed out → null.
|
|
1080
|
+
* 3. Expired/near-expiry but a refresh chain exists: a refresh already
|
|
1081
|
+
* inflight → await it (single-flight, AbortController-bounded, can never
|
|
1082
|
+
* hang the bridge); in backoff cooldown → null (honest "no token now");
|
|
1083
|
+
* else fire the refresh and await it.
|
|
1084
|
+
*/
|
|
847
1085
|
getAccessToken(options?: {
|
|
848
1086
|
forceRefresh?: boolean;
|
|
849
1087
|
}): Promise<string | null>;
|
|
850
|
-
/** Sync read — returns whatever's cached even if expired. Prefer the async variant. */
|
|
1088
|
+
/** Sync read — returns whatever's cached even if expired (`null` in `withheld`). Prefer the async variant. */
|
|
851
1089
|
getAccessTokenSync(): string | null;
|
|
852
1090
|
/**
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
*
|
|
864
|
-
*
|
|
865
|
-
*
|
|
1091
|
+
* The currently-stored refresh token, or null when signed out (and in
|
|
1092
|
+
* `withheld`). Kept for integrators that journal the guest token for the
|
|
1093
|
+
* deprecated `merge()`; since 1.41.0 the guest is HELD on the device instead —
|
|
1094
|
+
* read `getHeldGuests()`.
|
|
1095
|
+
*/
|
|
1096
|
+
getRefreshToken(): string | null;
|
|
1097
|
+
/**
|
|
1098
|
+
* Return the current session, minting an anonymous one only when there is
|
|
1099
|
+
* none.
|
|
1100
|
+
*
|
|
1101
|
+
* - `anonymous` / `identified` → returned as-is, no network (the 1.29 / 1.40.0
|
|
1102
|
+
* guards as one row). Read `user.isAnonymous` to know which kind you got.
|
|
1103
|
+
* - `signed_out` → with `autoRestoreHeldGuest` on (default) and an eligible
|
|
1104
|
+
* held guest on the device, the NEWEST one is restored instead of minting —
|
|
1105
|
+
* visibly: `signed_in{cause:'restore'}` + `guest_released{reason:'restored'}`.
|
|
1106
|
+
* A dead slot is released (`guest_released{dead}`) and the next tried; none
|
|
1107
|
+
* left → a fresh mint in the same call. Otherwise `POST /anonymous` →
|
|
1108
|
+
* `signed_in{cause:'mint'}`.
|
|
1109
|
+
* - `unknown` → queued until the gap-fill refresh settles the status.
|
|
1110
|
+
* - `withheld` → rejects `AuthError{kind:'config', code:'SESSION_WITHHELD'}`.
|
|
1111
|
+
*
|
|
1112
|
+
* `forceNew: true` abandons the current session and mints a separate guest:
|
|
1113
|
+
* from a guest the current guest is HELD (never deleted); from an identified
|
|
1114
|
+
* session it is replaced silently (its refresh token revoked). At the held
|
|
1115
|
+
* capacity (K=5) it throws `HeldGuestLimitError` and mutates nothing.
|
|
866
1116
|
*/
|
|
867
1117
|
signInAnonymously(opts?: {
|
|
868
1118
|
name?: string;
|
|
869
1119
|
metadata?: Record<string, unknown>;
|
|
870
1120
|
forceNew?: boolean;
|
|
871
1121
|
}): Promise<AuthUser>;
|
|
872
|
-
/**
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
*
|
|
878
|
-
*
|
|
879
|
-
*
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
*
|
|
886
|
-
*
|
|
887
|
-
*
|
|
888
|
-
*
|
|
889
|
-
*
|
|
1122
|
+
/** Alias of `signInAnonymously()` — "make sure I have a session". */
|
|
1123
|
+
ensureSession(): Promise<AuthUser>;
|
|
1124
|
+
/** Row 6 (and row 11's second half): `POST /anonymous` → ProjectGuard on the RESPONSE → install. */
|
|
1125
|
+
private mintFresh;
|
|
1126
|
+
/**
|
|
1127
|
+
* Row 7 — one auto-restore attempt for `signInAnonymously()`. Returns the
|
|
1128
|
+
* restored user, or `null` when the slot was dead and released (the caller
|
|
1129
|
+
* tries the next slot, then mints). A transient failure propagates.
|
|
1130
|
+
*/
|
|
1131
|
+
private restoreSlotForMint;
|
|
1132
|
+
/** The per-attempt B10 key: reuse the persisted one (a crash-retry) or mint a new one and persist it until MINT_OK. */
|
|
1133
|
+
private currentMintKey;
|
|
1134
|
+
/**
|
|
1135
|
+
* Create an email/password account — routed by `status()`:
|
|
1136
|
+
*
|
|
1137
|
+
* - `anonymous` → `POST /me/link/email` on the current Bearer session: the
|
|
1138
|
+
* guest is promoted IN PLACE (same `user.id`), the rotated
|
|
1139
|
+
* pair is installed, `signed_in{cause:'link'}` fires. A
|
|
1140
|
+
* taken email throws `EmailAlreadyTakenError` and changes
|
|
1141
|
+
* nothing.
|
|
1142
|
+
* - `signed_out` → `POST /signup`, a fresh account.
|
|
1143
|
+
* - `identified` → `AlreadyIdentifiedError`, decided LOCALLY (no round-trip,
|
|
1144
|
+
* nothing minted — use `linkEmailPassword()` instead).
|
|
1145
|
+
* - `unknown` → waits for the status to settle, then routes as above.
|
|
1146
|
+
* - `withheld` → rejects `SESSION_WITHHELD`.
|
|
1147
|
+
*
|
|
1148
|
+
* Never calls `/upgrade` (ADR-033 INV-4): a generic 401 there used to wipe
|
|
1149
|
+
* the guest, and the "dead token → wipe → fresh signup" branch is gone.
|
|
890
1150
|
*/
|
|
891
1151
|
signUp(email: string, password: string, opts?: {
|
|
892
1152
|
name?: string;
|
|
@@ -896,6 +1156,10 @@ declare class Auth {
|
|
|
896
1156
|
* Sign in with email + password. When the user has confirmed MFA,
|
|
897
1157
|
* returns `{mfaRequired, mfaChallengeToken}` instead of an
|
|
898
1158
|
* `AuthUser`; caller must follow up with `challengeMfa()`.
|
|
1159
|
+
*
|
|
1160
|
+
* A sign-in from a guest HOLDS the guest on this device (`guest_held`, then
|
|
1161
|
+
* ONE `signed_in{cause:'sign_in'}`); the guest's server row survives. The
|
|
1162
|
+
* request never carries the guest's refresh token (ADR-033 INV-3).
|
|
899
1163
|
*/
|
|
900
1164
|
signIn(email: string, password: string): Promise<AuthUser | {
|
|
901
1165
|
mfaRequired: true;
|
|
@@ -906,11 +1170,18 @@ declare class Auth {
|
|
|
906
1170
|
* Verify an IdP-issued credential (authorization code OR id_token)
|
|
907
1171
|
* and mint a Sendora session. Customer's app handles the IdP
|
|
908
1172
|
* dance — typically via expo-auth-session / react-native-app-auth
|
|
909
|
-
* — then hands the result here.
|
|
910
|
-
*
|
|
1173
|
+
* — then hands the result here. Mirrors the web SDK's surface 1:1.
|
|
1174
|
+
*
|
|
1175
|
+
* `link: true` (ADR-033 INV-14) — attach-or-error, never a silent switch:
|
|
1176
|
+
* from a guest the account is promoted IN PLACE (same `user.id`,
|
|
1177
|
+
* `signed_in{cause:'link'}`); over an identified session the identity is
|
|
1178
|
+
* attached (`user_updated{cause:'link'}`), an identity already on the account
|
|
1179
|
+
* resolves quietly, one owned elsewhere throws `CredentialInUseError`
|
|
1180
|
+
* (or switches when `onCredentialInUse: "adopt"`), a stale session throws
|
|
1181
|
+
* `RECENT_AUTH_REQUIRED`. In `signed_out` it is a plain sign-in.
|
|
911
1182
|
*/
|
|
912
1183
|
loginSocial(input: {
|
|
913
|
-
provider:
|
|
1184
|
+
provider: SocialCredentialProvider;
|
|
914
1185
|
code?: string;
|
|
915
1186
|
idToken?: string;
|
|
916
1187
|
redirectUri?: string;
|
|
@@ -919,21 +1190,7 @@ declare class Auth {
|
|
|
919
1190
|
firstName?: string;
|
|
920
1191
|
lastName?: string;
|
|
921
1192
|
};
|
|
922
|
-
/**
|
|
923
|
-
* ADR-025 link-in-place opt-in. When this device is anonymous and you set
|
|
924
|
-
* `link: true`, an anon→social upgrade KEEPS the same user id (sub) — the
|
|
925
|
-
* anon account is promoted in place (like Firebase linkWithCredential)
|
|
926
|
-
* instead of a device-takeover that mints a new id. No effect when the
|
|
927
|
-
* device isn't anonymous, or when the social identity already belongs to
|
|
928
|
-
* another account (collision → falls back to takeover/merge).
|
|
929
|
-
*/
|
|
930
1193
|
link?: boolean;
|
|
931
|
-
/**
|
|
932
|
-
* What to do if this social identity (or its verified email) already
|
|
933
|
-
* belongs to an account. **Omit it** — the default adopts when nothing
|
|
934
|
-
* would be destroyed and refuses when a live guest session would be
|
|
935
|
-
* deleted. See {@link CredentialCollisionPolicy}.
|
|
936
|
-
*/
|
|
937
1194
|
onCredentialInUse?: CredentialCollisionPolicy;
|
|
938
1195
|
}): Promise<AuthUser>;
|
|
939
1196
|
signInWithGoogle(code: string, redirectUri: string, link?: boolean): Promise<AuthUser>;
|
|
@@ -946,7 +1203,7 @@ declare class Auth {
|
|
|
946
1203
|
firstName?: string;
|
|
947
1204
|
lastName?: string;
|
|
948
1205
|
};
|
|
949
|
-
/** ADR-
|
|
1206
|
+
/** Attach-or-error (ADR-033 INV-14). See `loginSocial`. */
|
|
950
1207
|
link?: boolean;
|
|
951
1208
|
}): Promise<AuthUser>;
|
|
952
1209
|
signInWithMicrosoft(code: string, redirectUri: string): Promise<AuthUser>;
|
|
@@ -958,15 +1215,18 @@ declare class Auth {
|
|
|
958
1215
|
*
|
|
959
1216
|
* **No-arg (1.23.0, recommended):** `signInWithGameCenter()` drives Game
|
|
960
1217
|
* Center natively via the `@sendoracloud/sdk-react-native-gaming` companion
|
|
961
|
-
*
|
|
962
|
-
* `
|
|
963
|
-
* config plugin `{ gameCenter: true }` + a Dev Client — NOT Expo Go.
|
|
1218
|
+
* and posts the result. Needs that companion + its config plugin
|
|
1219
|
+
* `{ gameCenter: true }` + a Dev Client — NOT Expo Go.
|
|
964
1220
|
*
|
|
965
1221
|
* **Explicit:** `signInWithGameCenter(payload)` posts a payload you obtained
|
|
966
1222
|
* from your OWN GameKit module (works anywhere, incl. bare RN).
|
|
967
1223
|
*
|
|
968
|
-
* `link: true`
|
|
969
|
-
* (
|
|
1224
|
+
* `link: true` = attach-or-error (ADR-033 INV-14): promotes a guest in place
|
|
1225
|
+
* (same `user.id`); over an identified session attaches, resolves quietly when
|
|
1226
|
+
* the identity is already on the account, throws `CredentialInUseError` when
|
|
1227
|
+
* it is owned elsewhere (pass `onCredentialInUse: "adopt"` to switch — the
|
|
1228
|
+
* guest is HELD on the device, never deleted). A plain call switches accounts
|
|
1229
|
+
* and holds the previous guest.
|
|
970
1230
|
*/
|
|
971
1231
|
signInWithGameCenter(opts?: GameSignInOptions): Promise<AuthUser>;
|
|
972
1232
|
signInWithGameCenter(payload: GameCenterPayload, opts?: GameSignInOptions): Promise<AuthUser>;
|
|
@@ -974,26 +1234,79 @@ declare class Auth {
|
|
|
974
1234
|
* Google Play Games sign-in (email-less, Android-only).
|
|
975
1235
|
*
|
|
976
1236
|
* **No-arg (1.23.0, recommended):** `signInWithPlayGames()` drives Play Games
|
|
977
|
-
* natively via the
|
|
978
|
-
*
|
|
979
|
-
* config-plugin manifest meta-data) and posts the `serverAuthCode`. Needs that
|
|
980
|
-
* companion + its plugin `{ playGamesWebClientId, playGamesProjectId }` + a Dev
|
|
981
|
-
* Client — NOT Expo Go.
|
|
1237
|
+
* natively via the companion and posts the `serverAuthCode`. Needs the
|
|
1238
|
+
* companion + its plugin + a Dev Client — NOT Expo Go.
|
|
982
1239
|
*
|
|
983
1240
|
* **Explicit:** `signInWithPlayGames({ serverAuthCode })` posts a code you
|
|
984
1241
|
* obtained from your OWN Play Games module.
|
|
985
1242
|
*
|
|
986
|
-
* `link: true` =
|
|
1243
|
+
* `link: true` = attach-or-error (ADR-033 INV-14); see `signInWithGameCenter`.
|
|
987
1244
|
*/
|
|
988
1245
|
signInWithPlayGames(opts?: GameSignInOptions): Promise<AuthUser>;
|
|
989
1246
|
signInWithPlayGames(payload: {
|
|
990
1247
|
serverAuthCode: string;
|
|
991
1248
|
}, opts?: GameSignInOptions): Promise<AuthUser>;
|
|
1249
|
+
/**
|
|
1250
|
+
* Shared gaming-identity sign-in. Resolves the credential FIRST (for the
|
|
1251
|
+
* no-arg native path this triggers the Game Center / Play Games sheet inside
|
|
1252
|
+
* the serialized op — a cancelled sheet leaves the session untouched), then
|
|
1253
|
+
* routes by `link` and `status()` exactly like `loginSocial`.
|
|
1254
|
+
*/
|
|
1255
|
+
private gameIdentitySignIn;
|
|
1256
|
+
/**
|
|
1257
|
+
* The legacy-signature LINK (`signInWith*({link:true})`, `loginSocial({link:true})`)
|
|
1258
|
+
* in `anonymous` / `identified` (ADR-033 rows 15–16, §8-Q18):
|
|
1259
|
+
* - a promotion / attach resolves with the user (`signed_in{cause:'link'}` on
|
|
1260
|
+
* the same-`sub` promotion — preserved from 1.30–1.40; `user_updated` on an
|
|
1261
|
+
* identified attach; NOTHING on `alreadyLinked`);
|
|
1262
|
+
* - 409 → `CredentialInUseError`, or with `onCredentialInUse: "adopt"` the
|
|
1263
|
+
* explicit switch to the owning account (the guest is HELD);
|
|
1264
|
+
* - 403 → `AuthError` `RECENT_AUTH_REQUIRED` (kind `recent_auth_required`).
|
|
1265
|
+
*/
|
|
1266
|
+
private legacyLink;
|
|
1267
|
+
/**
|
|
1268
|
+
* Exchange the MFA challenge token from `signIn` for a real session by
|
|
1269
|
+
* submitting a TOTP or recovery code. A wrong code throws and preserves the
|
|
1270
|
+
* current session for a retry; success holds the guest (if any) and installs
|
|
1271
|
+
* the account exactly like a direct sign-in.
|
|
1272
|
+
*/
|
|
1273
|
+
challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
|
|
1274
|
+
/**
|
|
1275
|
+
* Plain sign-in / SWITCH with a typed credential. Never links: from a guest
|
|
1276
|
+
* the guest is HELD (`previous.held === true`); from an identified account
|
|
1277
|
+
* the previous session is replaced and its refresh token revoked
|
|
1278
|
+
* (`previous.isAnonymous === false` ⇒ skip `mergeAnon`, reset local save
|
|
1279
|
+
* state). The request never carries a guest credential (INV-3). MFA returns
|
|
1280
|
+
* `{status:'mfa_required', verify(code)}`.
|
|
1281
|
+
*/
|
|
1282
|
+
signInWith(credential: Credential): Promise<SignInResult>;
|
|
1283
|
+
/**
|
|
1284
|
+
* Attach a credential to the CURRENT session — the ONLY upgrade (INV-4).
|
|
1285
|
+
* From a guest: promoted in place (same `user.id`, `upgraded: true`,
|
|
1286
|
+
* `user_updated{cause:'link'}`). From an identified account: attached
|
|
1287
|
+
* (`user_updated`), or `alreadyLinked: true` with no event. Owned elsewhere →
|
|
1288
|
+
* `{status:'credential_in_use', switchAccount(), keepCurrent()}` with nothing
|
|
1289
|
+
* mutated; stale session → `{status:'recent_auth_required'}`. Throws
|
|
1290
|
+
* `NOT_SIGNED_IN` in `signed_out` (nothing to attach to) and
|
|
1291
|
+
* `UNSUPPORTED_LINK_CREDENTIAL` for a kind that cannot be attached
|
|
1292
|
+
* (`magicLink`, `emailOtp`, `passkey`, `mfa`).
|
|
1293
|
+
*/
|
|
1294
|
+
link(credential: Credential): Promise<LinkResult>;
|
|
1295
|
+
/**
|
|
1296
|
+
* The ONLY path from a refused link to a sign-in, BOUND to the refused
|
|
1297
|
+
* credential (INV-14): the same payload is reused while under 5 minutes old;
|
|
1298
|
+
* older than that, a Game Center / Play Games credential is re-fetched
|
|
1299
|
+
* natively and the Game Center `teamPlayerId` compared — a different player
|
|
1300
|
+
* aborts with `CREDENTIAL_CHANGED` and nothing is mutated. (A Play Games
|
|
1301
|
+
* server auth code carries no player id, so a re-fetched code is used as-is.)
|
|
1302
|
+
*/
|
|
1303
|
+
private switchAccount;
|
|
1304
|
+
private toSignInResult;
|
|
992
1305
|
/** Link email + password to the current account (sub preserved). */
|
|
993
1306
|
linkEmailPassword(email: string, password: string): Promise<AuthUser>;
|
|
994
1307
|
/** Link an OAuth social identity (verified idToken or code) to the current account. */
|
|
995
1308
|
linkSocial(input: {
|
|
996
|
-
provider:
|
|
1309
|
+
provider: SocialCredentialProvider;
|
|
997
1310
|
code?: string;
|
|
998
1311
|
idToken?: string;
|
|
999
1312
|
redirectUri?: string;
|
|
@@ -1033,76 +1346,39 @@ declare class Auth {
|
|
|
1033
1346
|
serverAuthCode: string;
|
|
1034
1347
|
}): Promise<AuthUser>;
|
|
1035
1348
|
/**
|
|
1036
|
-
* Shared link executor
|
|
1037
|
-
*
|
|
1038
|
-
*
|
|
1039
|
-
*
|
|
1040
|
-
* deadlock — same non-serialized posture as deleteAccount().
|
|
1349
|
+
* Shared `link*()` executor — the LINK row with the `link*()` emission
|
|
1350
|
+
* (`user_updated{cause:'link'}`, never `signed_in`). A 409 throws
|
|
1351
|
+
* `CredentialInUseError`; a 403 throws `RECENT_AUTH_REQUIRED`; no session →
|
|
1352
|
+
* `NOT_SIGNED_IN` with no network call.
|
|
1041
1353
|
*/
|
|
1042
1354
|
private linkCredential;
|
|
1043
|
-
/**
|
|
1044
|
-
* Refresh the cached user after a link — the sub is unchanged, so only the
|
|
1045
|
-
* user object + its storage copy are updated (no token/identity rotation).
|
|
1046
|
-
*/
|
|
1047
|
-
private updateLocalUser;
|
|
1048
1355
|
/**
|
|
1049
1356
|
* List the auth methods/identities linked to the current account (ADR-030
|
|
1050
|
-
* read side, s58.270)
|
|
1051
|
-
*
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
* `lastLoginMethod`). Resolves a FRESH access token first (mirrors
|
|
1056
|
-
* `deleteAccount`). Firebase `user.providerData` / Supabase `user.identities`.
|
|
1357
|
+
* read side, s58.270): every social/gaming identity plus a `hasPassword`
|
|
1358
|
+
* flag — the cross-device / reinstall-durable source of truth for a
|
|
1359
|
+
* "Connected: Game Center · Google" UI. Bearer-gated network read that
|
|
1360
|
+
* resolves a FRESH access token first. Firebase `user.providerData` /
|
|
1361
|
+
* Supabase `user.identities` parity.
|
|
1057
1362
|
*/
|
|
1058
1363
|
listLinkedIdentities(): Promise<LinkedIdentitiesResponse>;
|
|
1059
1364
|
/**
|
|
1060
|
-
* Remove one linked provider from the signed-in account
|
|
1061
|
-
*
|
|
1062
|
-
* The write half of `listLinkedIdentities()` — what a "Disconnect" button
|
|
1063
|
-
* next to each entry on a Connected Accounts screen calls.
|
|
1365
|
+
* Remove one linked provider from the signed-in account — the write half of
|
|
1366
|
+
* `listLinkedIdentities()`.
|
|
1064
1367
|
*
|
|
1065
1368
|
* ⚠ The server REFUSES to remove the last way into the account, with
|
|
1066
|
-
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
* lockout, not an inconvenience. A password counts as a credential.
|
|
1070
|
-
*
|
|
1071
|
-
* Prefer to prevent the tap rather than explain the refusal: read
|
|
1072
|
-
* `listLinkedIdentities()` and disable Disconnect when
|
|
1073
|
-
* `identities.length + (hasPassword ? 1 : 0) <= 1`. The error is the
|
|
1074
|
-
* backstop, not the UX.
|
|
1369
|
+
* `AuthError` `code: "LAST_CREDENTIAL"` / `kind: "last_credential"` — a
|
|
1370
|
+
* permanent lockout, not an inconvenience. Prefer preventing the tap: disable
|
|
1371
|
+
* Disconnect when `identities.length + (hasPassword ? 1 : 0) <= 1`.
|
|
1075
1372
|
*
|
|
1076
1373
|
* Throws `NOT_SIGNED_IN` when there is no session, and `NOT_FOUND` when the
|
|
1077
|
-
* provider is not linked
|
|
1078
|
-
* provider is NOT treated as success — a silent no-op would let a buggy
|
|
1079
|
-
* screen report "disconnected" for a credential that is still attached.
|
|
1374
|
+
* provider is not linked (an absent provider is NOT reported as removed).
|
|
1080
1375
|
*/
|
|
1081
1376
|
unlink(provider: string): Promise<UnlinkResult>;
|
|
1082
|
-
/**
|
|
1083
|
-
* Shared gaming-identity sign-in. Mirrors `loginSocial`'s anon-takeover +
|
|
1084
|
-
* ADR-025 link-in-place handling: reads the anon refresh BEFORE wiping local
|
|
1085
|
-
* identity, forwards it as `prevAnonRefreshToken`, and opts into
|
|
1086
|
-
* `linkAnonymous` when `link` is set. `persist()` fires `onDeviceTakeover`
|
|
1087
|
-
* centrally from `retiredAnonUserId`.
|
|
1088
|
-
*/
|
|
1089
|
-
private gameIdentitySignIn;
|
|
1090
|
-
/**
|
|
1091
|
-
* Read the stored anon refresh token if (and only if) the local
|
|
1092
|
-
* subject is currently anonymous. Used by every identified-session
|
|
1093
|
-
* mint path so the backend can run device-takeover (s58.111 +
|
|
1094
|
-
* s58.112). Returns `undefined` when not anonymous — never forward
|
|
1095
|
-
* an identified user's own refresh token.
|
|
1096
|
-
*/
|
|
1097
|
-
private readPrevAnonRefreshToken;
|
|
1098
|
-
/**
|
|
1099
|
-
* Exchange the MFA challenge token from `signIn` for a real session
|
|
1100
|
-
* by submitting a TOTP or recovery code from the user's authenticator.
|
|
1101
|
-
*/
|
|
1102
|
-
challengeMfa(mfaChallengeToken: string, code: string): Promise<AuthUser>;
|
|
1103
1377
|
sendMagicLink(email: string): Promise<void>;
|
|
1378
|
+
/** A magic-link token is routinely expired or already consumed (mail scanners pre-fetch, users tap twice); a rejection never costs the session. */
|
|
1104
1379
|
verifyMagicLink(token: string): Promise<AuthUser>;
|
|
1105
1380
|
sendEmailOtp(email: string): Promise<void>;
|
|
1381
|
+
/** Mistyping a 6-digit code is the most common outcome of this flow; the session survives for the retry. */
|
|
1106
1382
|
verifyEmailOtp(email: string, code: string): Promise<AuthUser>;
|
|
1107
1383
|
/** Trigger password-reset email. Resolves on 204 even when address is unknown (anti-enumeration). */
|
|
1108
1384
|
requestPasswordReset(email: string): Promise<void>;
|
|
@@ -1112,6 +1388,81 @@ declare class Auth {
|
|
|
1112
1388
|
verifyEmail(token: string): Promise<void>;
|
|
1113
1389
|
/** Re-send the email-verification email for the currently-signed-in user. */
|
|
1114
1390
|
sendVerificationEmail(): Promise<void>;
|
|
1391
|
+
/**
|
|
1392
|
+
* The guests held on this device, newest first (`heldAt` desc). Never exposes
|
|
1393
|
+
* tokens. Best-effort: a guest may have been deleted server-side since it was
|
|
1394
|
+
* held — that is only discovered when its token is presented (restore /
|
|
1395
|
+
* claim / discard), as `guest_released{reason:'dead'}`.
|
|
1396
|
+
*
|
|
1397
|
+
* `autoRestore === false` ⇔ the slot was stamped by `deleteAccount()`: only an
|
|
1398
|
+
* explicit `restoreHeldGuest(id)` revives it. `project` is the guest's JWT
|
|
1399
|
+
* `project` claim at HOLD (`null` when it was unreadable).
|
|
1400
|
+
*/
|
|
1401
|
+
getHeldGuests(): HeldGuestSummary[];
|
|
1402
|
+
/** The newest held guest, or null. */
|
|
1403
|
+
getHeldGuest(): {
|
|
1404
|
+
userId: string;
|
|
1405
|
+
heldAt: number;
|
|
1406
|
+
} | null;
|
|
1407
|
+
/**
|
|
1408
|
+
* Fold a held guest INTO the current identified account (`POST /merge`):
|
|
1409
|
+
* reassigns its events, profile, entities and push tokens, DELETES the guest
|
|
1410
|
+
* row and fires `auth.user_merged`. Irreversible, Sendora-side only — it does
|
|
1411
|
+
* not touch app data keyed on the guest's `sub`. ⚠ OPTIONAL: an app whose
|
|
1412
|
+
* backend does not act on `auth.user_merged` MUST NOT call it.
|
|
1413
|
+
*
|
|
1414
|
+
* Identified only. `id` may be omitted only when exactly one guest is held
|
|
1415
|
+
* (else `AMBIGUOUS_HELD_GUEST`). The held token is refreshed FIRST (proves
|
|
1416
|
+
* liveness, repairs a spent parent inside the 60 s grace) and the rotation is
|
|
1417
|
+
* persisted into the slot BEFORE the merge; a dead guest releases the slot
|
|
1418
|
+
* (`guest_released{dead}`) and rejects `HELD_GUEST_GONE`. Emits
|
|
1419
|
+
* `user_updated{cause:'merge'}` + `guest_released{reason:'claimed'}`.
|
|
1420
|
+
*/
|
|
1421
|
+
claimHeldGuest(id?: string): Promise<{
|
|
1422
|
+
user: AuthUser;
|
|
1423
|
+
stats: MergeStats;
|
|
1424
|
+
}>;
|
|
1425
|
+
/**
|
|
1426
|
+
* Sign back in AS a held guest without merging (the RESTORE row). From
|
|
1427
|
+
* `signed_out` or an identified account (whose refresh token is revoked
|
|
1428
|
+
* fire-and-forget); from a guest the CURRENT guest is held first (its slot is
|
|
1429
|
+
* written before the chosen one is released, so there is never a window with
|
|
1430
|
+
* zero handles to either). `id` may be omitted only when exactly one guest is
|
|
1431
|
+
* held. Client-side project check FIRST: a slot whose recorded `project`
|
|
1432
|
+
* mismatches `expectedProjectId ?? config.projectId` throws
|
|
1433
|
+
* `HeldGuestProjectMismatch` with no network and the slot kept. A dead guest
|
|
1434
|
+
* releases the slot and rejects `HELD_GUEST_GONE`. Emits
|
|
1435
|
+
* `signed_in{cause:'restore'}` (re-register push) + `guest_released{restored}`.
|
|
1436
|
+
*/
|
|
1437
|
+
restoreHeldGuest(id?: string): Promise<AuthUser>;
|
|
1438
|
+
/**
|
|
1439
|
+
* DELETE a held guest on the server (`POST /guest/discard` — a real erasure:
|
|
1440
|
+
* data → credentials → row → `auth.user_deleted{reason:'guest_discarded'}`)
|
|
1441
|
+
* and release its slot. The app is the requester on behalf of its player
|
|
1442
|
+
* (s58.316); the SDK never calls this on its own. `id` may be omitted only
|
|
1443
|
+
* when exactly one guest is held. A 2xx or a 404 (already gone) releases the
|
|
1444
|
+
* slot; any other failure keeps it. Emits `guest_released{reason:'discarded'}`.
|
|
1445
|
+
*/
|
|
1446
|
+
discardHeldGuest(id?: string): Promise<void>;
|
|
1447
|
+
/** Shared body of `discardHeldGuest()` and `deleteAccount({alsoDiscardHeldGuests})`. */
|
|
1448
|
+
private discardSlot;
|
|
1449
|
+
/**
|
|
1450
|
+
* While `status() === "withheld"`: why the stored session was refused. Never
|
|
1451
|
+
* exposes tokens. `userId` lets a `sub`-keyed backend record oldSub → newSub
|
|
1452
|
+
* before `discardWithheld()`. `null` in every other state.
|
|
1453
|
+
*/
|
|
1454
|
+
getWithheld(): WithheldInfo | null;
|
|
1455
|
+
/**
|
|
1456
|
+
* Resolve `withheld` — the local counterpart of `signOut()` in that state
|
|
1457
|
+
* (and what `signOut()` does there). Precondition `status() === "withheld"`
|
|
1458
|
+
* (else resolves `false`, nothing mutated). CAS on the withheld refresh token;
|
|
1459
|
+
* clears the session keys only; fire-and-forget `/token/revoke` like
|
|
1460
|
+
* `signOut()`; LOCAL ONLY — never `/guest/discard`, never a server delete;
|
|
1461
|
+
* KEEPS held slots, the event queue and the persisted `user_id`. Emits
|
|
1462
|
+
* `signed_out{reason:'user'}`. The other exit is relaunching with the matching
|
|
1463
|
+
* `expectedProjectId`.
|
|
1464
|
+
*/
|
|
1465
|
+
discardWithheld(): Promise<boolean>;
|
|
1115
1466
|
enrollMfa(): Promise<{
|
|
1116
1467
|
secret: string;
|
|
1117
1468
|
otpauthUrl: string;
|
|
@@ -1119,14 +1470,10 @@ declare class Auth {
|
|
|
1119
1470
|
}>;
|
|
1120
1471
|
confirmMfa(code: string): Promise<boolean>;
|
|
1121
1472
|
/**
|
|
1122
|
-
* Disable MFA.
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1126
|
-
*
|
|
1127
|
-
* ⚠ Discarded its response until s58.335, and `request()` never throws on a
|
|
1128
|
-
* non-2xx (it warns and returns the Response), so the refusal had nowhere to
|
|
1129
|
-
* surface — the app reported MFA as off while TOTP stayed enrolled.
|
|
1473
|
+
* Disable MFA. ⚠ STEP-UP gated (s58.319): a stale `auth_time` is refused with
|
|
1474
|
+
* 403 `RECENT_AUTH_REQUIRED` / kind `recent_auth_required`. The response is
|
|
1475
|
+
* inspected (s58.335): `request()` never throws on a non-2xx, so a discarded
|
|
1476
|
+
* return would report the refusal as success.
|
|
1130
1477
|
*/
|
|
1131
1478
|
disableMfa(): Promise<void>;
|
|
1132
1479
|
listMySessions(): Promise<Array<{
|
|
@@ -1136,174 +1483,236 @@ declare class Auth {
|
|
|
1136
1483
|
lastUsedAt: string | null;
|
|
1137
1484
|
createdAt: string;
|
|
1138
1485
|
}>>;
|
|
1139
|
-
/**
|
|
1140
|
-
* Revoke one of the caller's own sessions ("sign out that device").
|
|
1141
|
-
* ⚠ Discarded its response until s58.335 — a "sign out this device" button
|
|
1142
|
-
* that reports success on a failure is the one place a user acts on the claim
|
|
1143
|
-
* and then stops worrying.
|
|
1144
|
-
*/
|
|
1486
|
+
/** Revoke one of the caller's own sessions ("sign out that device"). Inspects the response (s58.335). */
|
|
1145
1487
|
revokeSession(sessionId: string): Promise<void>;
|
|
1146
|
-
/** Revoke every session for the caller.
|
|
1488
|
+
/** Revoke every session for the caller. Inspects the response (s58.335). */
|
|
1147
1489
|
revokeAllSessions(): Promise<void>;
|
|
1148
1490
|
/**
|
|
1149
1491
|
* Delete the signed-in user's account (Apple App Store Guideline 5.1.1(v)).
|
|
1150
|
-
* Honors the project's configured grace period:
|
|
1151
|
-
*
|
|
1152
|
-
*
|
|
1153
|
-
*
|
|
1154
|
-
*
|
|
1155
|
-
*
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
*
|
|
1160
|
-
*
|
|
1161
|
-
*
|
|
1162
|
-
|
|
1163
|
-
|
|
1492
|
+
* Honors the project's configured grace period: `"purged"` (hard-deleted
|
|
1493
|
+
* now) or `"pending"` (deactivated + sessions revoked now, hard-deleted at
|
|
1494
|
+
* `scheduledPurgeAt`; cancellable by signing back in). Resolves a FRESH access
|
|
1495
|
+
* token first — a stale token's 401 would silently strand the user with an
|
|
1496
|
+
* undeleted account.
|
|
1497
|
+
*
|
|
1498
|
+
* Held guests (ADR-033): deleting an account never touches a held guest, on
|
|
1499
|
+
* the device or the server. By default they SURVIVE with auto-restore
|
|
1500
|
+
* suppressed (`autoRestore: false` — only an explicit `restoreHeldGuest(id)`
|
|
1501
|
+
* revives one; `ensureSession()` mints fresh). `alsoDiscardHeldGuests: true`
|
|
1502
|
+
* is the player's explicit ask to erase them too: each is discarded after the
|
|
1503
|
+
* 2xx, a slot that fails to discard stays held and is listed in
|
|
1504
|
+
* `discardFailed`. `heldGuests` = guests still held afterwards. The queued
|
|
1505
|
+
* analytics events are dropped (erasure completeness, 1.39.1).
|
|
1506
|
+
*/
|
|
1507
|
+
deleteAccount(opts?: {
|
|
1508
|
+
alsoDiscardHeldGuests?: boolean;
|
|
1509
|
+
}): Promise<{
|
|
1164
1510
|
status: "purged" | "pending";
|
|
1165
1511
|
scheduledPurgeAt: string | null;
|
|
1166
1512
|
graceDays: number;
|
|
1513
|
+
heldGuests: number;
|
|
1514
|
+
discardFailed: string[];
|
|
1167
1515
|
}>;
|
|
1168
1516
|
/**
|
|
1169
|
-
* Start OIDC sign-in. Returns the IdP's authorization URL — caller
|
|
1170
|
-
*
|
|
1171
|
-
*
|
|
1172
|
-
*
|
|
1173
|
-
* `returnTo`
|
|
1174
|
-
*
|
|
1175
|
-
* On the customer's app side, `returnTo` MUST be a custom URL scheme
|
|
1176
|
-
* registered in Info.plist (iOS) / AndroidManifest.xml intent-filter
|
|
1177
|
-
* (Android), e.g. `myapp://oidc-callback`. When the deep link fires,
|
|
1178
|
-
* call `consumeSsoFragment(url)` on the captured URL.
|
|
1517
|
+
* Start OIDC sign-in. Returns the IdP's authorization URL — caller opens it
|
|
1518
|
+
* via `Linking.openURL()` (or `WebBrowser.openAuthSessionAsync`). The IdP
|
|
1519
|
+
* redirects through Sendora's callback to the `returnTo` URL with
|
|
1520
|
+
* `#sendora_oidc_token=...` in the fragment; hand that URL to
|
|
1521
|
+
* `consumeSsoFragment(url)`. `returnTo` MUST be a custom URL scheme
|
|
1522
|
+
* registered in Info.plist / AndroidManifest. No guest hint travels (INV-3).
|
|
1179
1523
|
*/
|
|
1180
1524
|
startSso(returnTo: string): Promise<{
|
|
1181
1525
|
authorizationUrl: string;
|
|
1182
1526
|
}>;
|
|
1183
|
-
/**
|
|
1184
|
-
* Kick off a SAML 2.0 SSO flow. Mirrors `startSso` for SAML —
|
|
1185
|
-
* backend POST `/auth-service/sso/saml/start` returns the IdP
|
|
1186
|
-
* AuthnRequest URL. Caller opens it with
|
|
1187
|
-
* ASWebAuthenticationSession (iOS) / Custom Tabs (Android) /
|
|
1188
|
-
* Linking, captures the callback URL via the standard
|
|
1189
|
-
* `Linking.addEventListener` flow, then passes the captured URL to
|
|
1190
|
-
* `consumeSsoFragment(url)`. consumeSsoFragment handles both OIDC
|
|
1191
|
-
* and SAML fragments transparently.
|
|
1192
|
-
*/
|
|
1527
|
+
/** Kick off a SAML 2.0 SSO flow. Mirrors `startSso`; `consumeSsoFragment` handles both fragments. */
|
|
1193
1528
|
startSaml(returnTo: string): Promise<{
|
|
1194
1529
|
authorizationUrl: string;
|
|
1195
1530
|
}>;
|
|
1196
1531
|
/**
|
|
1197
|
-
* Consume an OIDC callback URL captured via
|
|
1198
|
-
*
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1532
|
+
* Consume an OIDC/SAML callback URL captured via `Linking.addEventListener`.
|
|
1533
|
+
* Reads the fragment for `sendora_oidc_token` / `sendora_saml_token`,
|
|
1534
|
+
* exchanges it for a session via `/token/refresh`, and installs it as a
|
|
1535
|
+
* sign-in (holding a guest, if any). The callback is parsed and exchanged
|
|
1536
|
+
* FIRST — an IdP "Cancel" or a garbage fragment leaves the session untouched.
|
|
1201
1537
|
*/
|
|
1202
1538
|
consumeSsoFragment(callbackUrl: string): Promise<AuthUser>;
|
|
1539
|
+
/** Populate a user object from access-token claims when a response carries none (pre-B17 `/token/refresh`). */
|
|
1540
|
+
private userFromClaims;
|
|
1541
|
+
/**
|
|
1542
|
+
* Build the Authorization header for end-user-authenticated routes. Awaits
|
|
1543
|
+
* `getAccessToken()` (parity with sdk-web since 1.41.0), so a cold start with
|
|
1544
|
+
* no cached access token or an expired token transparently refreshes first.
|
|
1545
|
+
* Throws `NOT_SIGNED_IN` when there is genuinely no usable session.
|
|
1546
|
+
*/
|
|
1203
1547
|
private bearerHeaders;
|
|
1204
1548
|
private unwrap;
|
|
1205
1549
|
/**
|
|
1206
|
-
* Sign out:
|
|
1207
|
-
* device even if the revoke network call hangs (airplane mode, 5xx)
|
|
1208
|
-
*
|
|
1550
|
+
* Sign out: clear the local session FIRST so the user is logged out on
|
|
1551
|
+
* device even if the revoke network call hangs (airplane mode, 5xx); revoke
|
|
1552
|
+
* is fire-and-forget after the clear. Rotates the analytics anon id (a
|
|
1553
|
+
* person boundary). KEEPS held guests — a guest's refresh token is its only
|
|
1554
|
+
* credential, so wiping the slots on logout would be deletion by assumption
|
|
1555
|
+
* (use `discardHeldGuest()` / `reset({includeHeldGuests:true})` deliberately).
|
|
1556
|
+
* In `withheld` this is `discardWithheld()`.
|
|
1209
1557
|
*/
|
|
1210
1558
|
signOut(): Promise<void>;
|
|
1211
|
-
private serialize;
|
|
1212
1559
|
/**
|
|
1213
|
-
*
|
|
1214
|
-
*
|
|
1215
|
-
* round-trips. Returns null on any failure so callers can decide
|
|
1216
|
-
* whether to surface a sign-in prompt or proceed anonymously.
|
|
1560
|
+
* @deprecated Transfer every event + trait from an anonymous user to the
|
|
1561
|
+
* currently-signed-in (target) user, then delete the anonymous row.
|
|
1217
1562
|
*
|
|
1218
|
-
*
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1223
|
-
*
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1563
|
+
* Since 1.41.0 the guest you signed in over is HELD on this device, so a
|
|
1564
|
+
* token that matches a held slot (its current refresh token OR the token it
|
|
1565
|
+
* was held with) routes to `claimHeldGuest(thatGuest)` — a DESTRUCTIVE claim.
|
|
1566
|
+
* Any other token takes the raw `/merge` path as before. Prefer
|
|
1567
|
+
* `claimHeldGuest(id)`; never call either from a backend that does not act
|
|
1568
|
+
* on `auth.user_merged`.
|
|
1569
|
+
*/
|
|
1570
|
+
merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<MergeStats>;
|
|
1571
|
+
/** `POST /merge` — bypasses `callAuth`'s `isAuthApiResponse` gate (`/merge` returns `{ ...stats, tokens, csrfToken }`, no `user`). */
|
|
1572
|
+
private postMerge;
|
|
1573
|
+
private http;
|
|
1574
|
+
/** ADR-033 [2c]: in `withheld` every verb in REJECTED_IN_WITHHELD rejects with no network and no write. */
|
|
1575
|
+
private rejectIfWithheld;
|
|
1576
|
+
/** True in `anonymous` / `identified` — a live session a LINK can attach to. */
|
|
1577
|
+
private hasSession;
|
|
1578
|
+
private serialize;
|
|
1227
1579
|
/**
|
|
1228
|
-
*
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
1231
|
-
*
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1580
|
+
* INV-5 — a session-installing verb in `unknown` QUEUES until the status
|
|
1581
|
+
* settles: the single gap-fill refresh either installs the identity or
|
|
1582
|
+
* reports the token dead (`signed_out`). Offline it keeps waiting with the
|
|
1583
|
+
* refresh backoff (never minting, never wiping). Ends in `withheld` → rejects.
|
|
1584
|
+
*/
|
|
1585
|
+
private awaitSettled;
|
|
1586
|
+
/** An identity-changing attempt awaits any in-flight refresh at start AND at commit (ADR-033 §3.3 scheduling rule 1). */
|
|
1587
|
+
private awaitRefreshQuiet;
|
|
1588
|
+
private snapshotIdentity;
|
|
1589
|
+
/** The identity CAS (INV-2): `sid` when both sides know it, else the `sub`; a signed-out side never matches a live one. */
|
|
1590
|
+
private identityMatches;
|
|
1591
|
+
/** The current session's JWT `project` claim (string | null), or undefined when unreadable. */
|
|
1592
|
+
private claimProject;
|
|
1593
|
+
/**
|
|
1594
|
+
* The ProjectGuard (INV-15) as `commit()` applies it: the shared
|
|
1595
|
+
* `projectVerdict()` over the access JWT, and — when the claim is unreadable
|
|
1596
|
+
* — the B17 `user.project` field as the second source. Absent on both = `unknown`.
|
|
1597
|
+
*/
|
|
1598
|
+
private verdictFor;
|
|
1599
|
+
private heldCount;
|
|
1600
|
+
private heldSorted;
|
|
1601
|
+
/** Slot indices eligible for auto-restore, newest first (ADR-033 §3.3 "eligible"). */
|
|
1602
|
+
private eligibleHeldSlots;
|
|
1603
|
+
private freeSlotIndex;
|
|
1604
|
+
/** The `id` rule shared by claim / restore / discard: omit it ONLY when exactly one guest is held. */
|
|
1605
|
+
private resolveSlot;
|
|
1606
|
+
private doSignInDone;
|
|
1607
|
+
/**
|
|
1608
|
+
* The SIGN_IN / SWITCH row (ADR-033 §3.3 row 18). The body carries NO guest
|
|
1609
|
+
* hint; a success HOLDS a previous guest (post-refresh tokens) and installs
|
|
1610
|
+
* the new session under the identity CAS; any failure leaves the caller
|
|
1611
|
+
* exactly as found.
|
|
1612
|
+
*/
|
|
1613
|
+
private doSignIn;
|
|
1614
|
+
/** Commit a sign-in response as the SIGN_IN row; a CAS drop revokes the dropped refresh so server session B is not left live (row 19). */
|
|
1615
|
+
private installSignIn;
|
|
1616
|
+
/**
|
|
1617
|
+
* The LINK / LINK-UPGRADE rows (ADR-033 §3.3 rows 15–16) on the current
|
|
1618
|
+
* Bearer session. 200 with a rotated pair (`upgraded`) → ProjectGuard →
|
|
1619
|
+
* install (same `sub`); 200 attached → user updated in place; 200
|
|
1620
|
+
* `alreadyLinked` → nothing; 409 / 403 → typed outcomes, nothing mutated.
|
|
1621
|
+
*/
|
|
1622
|
+
private doLink;
|
|
1623
|
+
/**
|
|
1624
|
+
* Present a held guest's refresh token to `/token/refresh` — the ONLY way a
|
|
1625
|
+
* held token is ever refreshed (the proactive cron never touches a slot).
|
|
1626
|
+
* The rotation is persisted INTO THE SLOT under a per-slot CAS before anything
|
|
1627
|
+
* else happens; a dead verdict releases the slot only for `endReason ∈
|
|
1628
|
+
* deleted|revoked|expired` (`reuse`/absent keep it: `kept`).
|
|
1629
|
+
*/
|
|
1630
|
+
private refreshHeld;
|
|
1631
|
+
/** The RESTORE row's install half: ProjectGuard on the refresh response, install BEFORE releasing the slot. */
|
|
1632
|
+
private installRestored;
|
|
1633
|
+
/**
|
|
1634
|
+
* Hot-path token refresh. Single-flight via `refreshInflight` so a UI batch
|
|
1635
|
+
* firing 10 simultaneous reads never produces 10 `/refresh` round-trips.
|
|
1636
|
+
* Returns null on any failure so callers can decide whether to surface a
|
|
1637
|
+
* sign-in prompt or proceed anonymously.
|
|
1638
|
+
*
|
|
1639
|
+
* The result is applied ONLY if the token that was sent is still the one on
|
|
1640
|
+
* disk (token CAS, INV-2): a refresh landing after `signOut()` must not
|
|
1641
|
+
* resurrect the session, and an OLD token's rejection must not wipe a newer
|
|
1642
|
+
* one. A rotation is not a transition; the user object is adopted when the
|
|
1643
|
+
* SDK has none (gap-fill from `unknown`, 1.29.0) and re-judged by the project
|
|
1644
|
+
* guard before install (a mismatch → `withheld`, INV-15).
|
|
1236
1645
|
*/
|
|
1646
|
+
private refreshAccessToken;
|
|
1647
|
+
/** Wave 62 — AppState resume hook: clear stuck backoff so a returning user gets an immediate attempt. */
|
|
1237
1648
|
private resetRefreshState;
|
|
1238
1649
|
/**
|
|
1239
|
-
* Proactive refresh (s58.72). Fires when access-token age crosses
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
*
|
|
1243
|
-
*
|
|
1650
|
+
* Proactive refresh (s58.72). Fires when access-token age crosses ~80% of
|
|
1651
|
+
* TTL, repairs a stale deadline the combined guard can see, and performs the
|
|
1652
|
+
* single gap-fill refresh for an `unknown` record. Armed ONLY by `commit()`
|
|
1653
|
+
* (install / hydrate), stopped by the clear and withhold paths. ⚠ Never
|
|
1654
|
+
* starts a refresh while an identity attempt is pending (ADR-033 §3.3
|
|
1655
|
+
* scheduling rule 2) and never refreshes a held guest.
|
|
1244
1656
|
*/
|
|
1245
1657
|
private proactiveRefreshTimer;
|
|
1246
1658
|
private proactiveRefreshAppStateUnsub;
|
|
1247
1659
|
private startProactiveRefreshCron;
|
|
1248
1660
|
private stopProactiveRefreshCron;
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
*/
|
|
1256
|
-
getRefreshToken(): string | null;
|
|
1257
|
-
/**
|
|
1258
|
-
* Transfer every event + trait from an anonymous user to the
|
|
1259
|
-
* currently-signed-in (target) user, then delete the anonymous row.
|
|
1260
|
-
* `targetRefreshToken` defaults to the SDK's currently-stored refresh
|
|
1261
|
-
* token — call this AFTER a successful `signIn()` so the storage
|
|
1262
|
-
* already holds the target's refresh.
|
|
1263
|
-
*
|
|
1264
|
-
* Throws AuthError when:
|
|
1265
|
-
* - `anonymousRefreshToken` is invalid / expired / revoked,
|
|
1266
|
-
* - the bound source row is not anonymous (CONFLICT — use upgrade),
|
|
1267
|
-
* - the target is anonymous (CONFLICT — sign in first),
|
|
1268
|
-
* - both tokens resolve to the same user (CONFLICT — no-op).
|
|
1269
|
-
*
|
|
1270
|
-
* Mirrors Auth0 `accounts.link` + Firebase manual transfer semantics.
|
|
1271
|
-
*/
|
|
1272
|
-
merge(anonymousRefreshToken: string, targetRefreshToken?: string): Promise<{
|
|
1273
|
-
mergedUserId: string;
|
|
1274
|
-
deletedAnonymousId: string;
|
|
1275
|
-
eventsReassigned: number;
|
|
1276
|
-
profilesReassigned: number;
|
|
1277
|
-
profilesDropped: number;
|
|
1278
|
-
entitiesReassigned: number;
|
|
1279
|
-
entitiesDropped: number;
|
|
1280
|
-
}>;
|
|
1661
|
+
private revokeFireAndForget;
|
|
1662
|
+
/** B15: report a held guest, fire-and-forget; the pending flag is cleared only on a 2xx and retried on hydrate. */
|
|
1663
|
+
private reportHeldFireAndForget;
|
|
1664
|
+
private retryPendingHeldReports;
|
|
1665
|
+
/** `Sendora.reset({includeHeldGuests:true})`: release every slot locally (NO server delete), via commit. */
|
|
1666
|
+
releaseAllHeldGuests(): void;
|
|
1281
1667
|
private callAuth;
|
|
1282
|
-
private
|
|
1283
|
-
/**
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
* on a SUCCESSFUL sign-in) emits nothing — subscribers see one `signed_in`,
|
|
1289
|
-
* not a spurious logout/login pair.
|
|
1290
|
-
*
|
|
1291
|
-
* ⚠ Never call this before a fallible network request. A failed sign-in must
|
|
1292
|
-
* leave the caller exactly as it found them; for an anonymous session the
|
|
1293
|
-
* refresh token dropped here is the ONLY durable handle on the account, so a
|
|
1294
|
-
* pre-call wipe that is not followed by a successful `persist()` orphans the
|
|
1295
|
-
* account permanently (offline: deterministically). Capture the anon refresh
|
|
1296
|
-
* into a local, make the call, and wipe only after the response validates.
|
|
1297
|
-
*/
|
|
1668
|
+
private commit;
|
|
1669
|
+
/** `commit()` for the clear path, awaiting the parent's `onAnonymousWipe` hook before resolving (the emission has already fired). */
|
|
1670
|
+
private commitAsync;
|
|
1671
|
+
private pendingWipeHook;
|
|
1672
|
+
private commitInstall;
|
|
1673
|
+
private commitClear;
|
|
1298
1674
|
/**
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
* token over a session that has since been signed out or replaced, and
|
|
1303
|
-
* wiping a fresh session because an OLD token was rejected.
|
|
1675
|
+
* The parent-SDK half of a clear: rotate the analytics anon id at a person
|
|
1676
|
+
* boundary (`"user"` / `"account_deleted"`), drop the queue on deletion, null
|
|
1677
|
+
* the analytics user id. Kept as its own method so its reasons stay pinned.
|
|
1304
1678
|
*/
|
|
1305
|
-
private tokenStillCurrent;
|
|
1306
1679
|
private wipeLocalIdentity;
|
|
1680
|
+
private commitUser;
|
|
1681
|
+
private commitRelease;
|
|
1682
|
+
/** Row 21 — a refresh of the CURRENT token answered with a mismatching claim: persist the rotation (token CAS) and withhold. */
|
|
1683
|
+
private commitWithhold;
|
|
1684
|
+
/** Enter `withheld`: in-memory only (ZERO storage writes), no cron, analytics quarantined; emits `session_withheld` only for an in-process transition. */
|
|
1685
|
+
private enterWithheld;
|
|
1686
|
+
/** Row 14 — `discardWithheld()` / `signOut()` in `withheld`: LOCAL ONLY, keeps held slots + queue + `user_id`. */
|
|
1687
|
+
private commitDiscardWithheld;
|
|
1688
|
+
/** A rotated pair for the current session when no user is known to install (pre-B17 gap-fill). Token CAS. */
|
|
1689
|
+
private commitTokens;
|
|
1690
|
+
/** Row 22 — CAS PER SLOT: re-read the slot at write time; if it no longer holds `sent`, DROP. */
|
|
1691
|
+
private commitSlotTokens;
|
|
1692
|
+
/** RESTORE row: a `null` slot project judged on the refresh response records the claim it saw. */
|
|
1693
|
+
private commitSlotProject;
|
|
1694
|
+
private commitHeldReport;
|
|
1695
|
+
/** Dual-write: the four frozen keys (downgrade-readable) + the `auth_session` record. */
|
|
1696
|
+
private writeSessionRecord;
|
|
1697
|
+
/** Hydrate's write-forward: ONLY the `auth_session` record (the frozen keys already hold the session untouched). */
|
|
1698
|
+
private writeSessionRecordOnly;
|
|
1699
|
+
/** The rotated pair only (the user is unchanged) — frozen keys + the record's token fields. */
|
|
1700
|
+
private writeTokens;
|
|
1701
|
+
/** Clear EXACTLY the session keys (the four frozen + the record). Held slots, the queue and `user_id` are never touched here. */
|
|
1702
|
+
private clearSessionKeys;
|
|
1703
|
+
/** Keep the B15 pending-report flags on disk even with no session (a stub record); remove the record when nothing is pending. */
|
|
1704
|
+
private persistPendingReports;
|
|
1705
|
+
private writeSlot;
|
|
1706
|
+
}
|
|
1707
|
+
/** What `/merge` (and `claimHeldGuest()`) moved. */
|
|
1708
|
+
interface MergeStats {
|
|
1709
|
+
mergedUserId: string;
|
|
1710
|
+
deletedAnonymousId: string;
|
|
1711
|
+
eventsReassigned: number;
|
|
1712
|
+
profilesReassigned: number;
|
|
1713
|
+
profilesDropped: number;
|
|
1714
|
+
entitiesReassigned: number;
|
|
1715
|
+
entitiesDropped: number;
|
|
1307
1716
|
}
|
|
1308
1717
|
|
|
1309
1718
|
/**
|
|
@@ -1590,6 +1999,56 @@ declare class Links {
|
|
|
1590
1999
|
attachLinkingApi(opts?: AttachLinkingApiOptions): Promise<() => void>;
|
|
1591
2000
|
}
|
|
1592
2001
|
|
|
2002
|
+
/**
|
|
2003
|
+
* ADR-033 INV-15 — `projectVerdict()`, the ProjectGuard every InstallTx is
|
|
2004
|
+
* judged by BEFORE any assignment (§3.3 `commit()` header).
|
|
2005
|
+
*
|
|
2006
|
+
* ⚠ VERBATIM PORT of `packages/shared/src/auth-machine/project-verdict.ts`
|
|
2007
|
+
* (ADR-033 B8). The published SDK cannot import the internal `@sendora/shared`
|
|
2008
|
+
* package at runtime, so the function lives here as a copy; the conformance
|
|
2009
|
+
* suite (`test/auth-machine-conformance.test.ts`) drives THIS copy over every
|
|
2010
|
+
* `projectVerdictCases` entry of the shared golden fixture, which is what keeps
|
|
2011
|
+
* the two from drifting.
|
|
2012
|
+
*
|
|
2013
|
+
* WHY A PURE FUNCTION. `withheld` (§3.2) is "a pure function of (disk record,
|
|
2014
|
+
* config), recomputed every hydrate, never persisted" — so a re-init with the
|
|
2015
|
+
* right key or a rollback needs no migration. That property only holds if the
|
|
2016
|
+
* verdict reads nothing but its two arguments: no `Date` (expiry is
|
|
2017
|
+
* irrelevant — a stale token still names its project truthfully), no network,
|
|
2018
|
+
* no storage, no `atob` (Hermes/JSC/browsers/Node disagree on its presence and
|
|
2019
|
+
* its handling of non-ASCII, and a verdict that depends on the host's `atob`
|
|
2020
|
+
* is a verdict that differs per platform).
|
|
2021
|
+
*
|
|
2022
|
+
* The three answers, and why absent ≠ match (§3.3, §8-Q13, TODO.md:52 superseded):
|
|
2023
|
+
*
|
|
2024
|
+
* expected unset → "match" (guard off)
|
|
2025
|
+
* token missing / undecodable / claim KEY absent → "unknown" (NOT match: a
|
|
2026
|
+
* pre-s58.276 token says nothing about its project; the SDK stays
|
|
2027
|
+
* `unknown` and lets the refresh answer — the server emits the claim
|
|
2028
|
+
* ALWAYS since s58.276, so one refresh settles it)
|
|
2029
|
+
* claim === null → "mismatch" (the server
|
|
2030
|
+
* emits an EXPLICIT null for the org-wide pool; under a project-bound
|
|
2031
|
+
* expectedProjectId an org-wide session is the wrong tenant)
|
|
2032
|
+
* claim is a string → equal ? "match" : "mismatch"
|
|
2033
|
+
*
|
|
2034
|
+
* ⚠ The guard is ADVISORY (a client consistency check against a wrong-key
|
|
2035
|
+
* build), not an authorization boundary, and it never produces a dead-token
|
|
2036
|
+
* verdict (INV-6/INV-15).
|
|
2037
|
+
*/
|
|
2038
|
+
type ProjectVerdict = "match" | "mismatch" | "unknown";
|
|
2039
|
+
declare function projectVerdict(expectedProjectId: string | undefined, accessJwt: string | null | undefined): ProjectVerdict;
|
|
2040
|
+
/**
|
|
2041
|
+
* Decode a compact JWS payload (`header.payload.signature`) WITHOUT verifying
|
|
2042
|
+
* the signature. The verdict only needs to READ a claim from a token the SDK
|
|
2043
|
+
* already holds (the server verifies on every request); verification here
|
|
2044
|
+
* would need the JWKS, i.e. the network, which the verdict must not touch.
|
|
2045
|
+
*
|
|
2046
|
+
* Returns `null` for anything that is not a three-segment token whose second
|
|
2047
|
+
* segment base64url-decodes to a UTF-8 JSON object. Arrays, scalars and
|
|
2048
|
+
* malformed input are all "undecodable".
|
|
2049
|
+
*/
|
|
2050
|
+
declare function decodeJwtPayload(token: string | null | undefined): Record<string, unknown> | null;
|
|
2051
|
+
|
|
1593
2052
|
/**
|
|
1594
2053
|
* @sendoracloud/sdk-react-native — React Native + Expo SDK.
|
|
1595
2054
|
*
|
|
@@ -1625,6 +2084,8 @@ declare class SendoraSDK {
|
|
|
1625
2084
|
private sessionId;
|
|
1626
2085
|
private autoTrackCleanup;
|
|
1627
2086
|
private eventQueue;
|
|
2087
|
+
/** ADR-033 [2c]: subscribers to `onIdentityWithheld`. */
|
|
2088
|
+
private withheldListeners;
|
|
1628
2089
|
private engEnabled;
|
|
1629
2090
|
private engScreen;
|
|
1630
2091
|
private engAccumMs;
|
|
@@ -2116,17 +2577,34 @@ declare class SendoraSDK {
|
|
|
2116
2577
|
private _chatbotSessionId;
|
|
2117
2578
|
private chatbotSessionId;
|
|
2118
2579
|
/**
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2121
|
-
* after
|
|
2122
|
-
*
|
|
2123
|
-
*
|
|
2124
|
-
* removes the auth keys too, so doing it directly left `Auth` holding an
|
|
2125
|
-
* in-memory user and reporting itself signed in while its refresh token was
|
|
2126
|
-
* already gone — with the server-side session never revoked and no
|
|
2127
|
-
* `onAuthStateChanged` emission telling the app the session had ended.
|
|
2580
|
+
* ADR-033 [2c]: subscribe to the project guard refusing a stored session
|
|
2581
|
+
* (`auth.status() === "withheld"`). Fires once per withhold with
|
|
2582
|
+
* `getWithheld()`'s payload, after the SDK has nulled its in-memory
|
|
2583
|
+
* analytics user id. Record `info.userId` (oldSub) in your backend before
|
|
2584
|
+
* calling `auth.discardWithheld()`. Returns an unsubscribe function.
|
|
2128
2585
|
*/
|
|
2129
|
-
|
|
2586
|
+
onIdentityWithheld(cb: (info: WithheldInfo) => void): () => void;
|
|
2587
|
+
/**
|
|
2588
|
+
* Clear identity + rotate the anonymous id. Call on logout. Awaits
|
|
2589
|
+
* the storage writes so a caller who kills the app immediately after
|
|
2590
|
+
* doesn't leave stale identity on disk.
|
|
2591
|
+
*
|
|
2592
|
+
* Signs out through `auth` (so the server session is revoked and
|
|
2593
|
+
* `onAuthStateChanged` fires `signed_out`), drops the queued events, then
|
|
2594
|
+
* clears EXACTLY this SDK's keys in the active namespace.
|
|
2595
|
+
*
|
|
2596
|
+
* ⚠ KEEPS the held guests by default (ADR-033). A guest displaced by a
|
|
2597
|
+
* sign-in is held on the device (`auth.getHeldGuests()`), and its refresh
|
|
2598
|
+
* token is its only credential — wiping the slots on logout would strand a
|
|
2599
|
+
* guest whose progress the player may still want. Pass
|
|
2600
|
+
* `{ includeHeldGuests: true }` to release every slot too (locally only —
|
|
2601
|
+
* `guest_released{reason:'reset'}` per guest, NO server delete; use
|
|
2602
|
+
* `auth.discardHeldGuest(id)` for that). In `withheld` this resolves the
|
|
2603
|
+
* withheld session like `auth.discardWithheld()` and keeps the slots too.
|
|
2604
|
+
*/
|
|
2605
|
+
reset(opts?: {
|
|
2606
|
+
includeHeldGuests?: boolean;
|
|
2607
|
+
}): Promise<void>;
|
|
2130
2608
|
/** Tear down auto-track listeners. Call in test harnesses. */
|
|
2131
2609
|
destroy(): void;
|
|
2132
2610
|
/**
|
|
@@ -2146,4 +2624,4 @@ declare class SendoraSDK {
|
|
|
2146
2624
|
}
|
|
2147
2625
|
declare const SendoraCloud: SendoraSDK;
|
|
2148
2626
|
|
|
2149
|
-
export { AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedOutReason, type AuthStateChange, type AuthTokens, type AuthUser, type CredentialCollisionPolicy, CredentialInUseError, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameSignInOptions, type IdentifyTraits, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type PushTokenReceipt, type PushTokenRegistration, SendoraCloud, type SendoraConfig, type TrackProperties, type UnlinkResult, computeDeviceFingerprint, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer };
|
|
2627
|
+
export { AMBIGUOUS_HELD_GUEST, AlreadyIdentifiedError, type AnonRetirementOutcome, type AttachLinkingApiOptions, Auth, AuthError, type AuthErrorKind, type AuthSignedInCause, type AuthSignedOutReason, type AuthStateChange, type AuthStatus, type AuthTokens, type AuthUser, type AuthUserUpdatedCause, type Credential, type CredentialCollisionPolicy, CredentialInUseError, type CredentialKind, type DeletionCancelledEvent, type DeviceTakeoverEvent, EmailAlreadyTakenError, type GameCenterPayload, type GameSignInOptions, type GuestReleasedReason, HELD_GUEST_SLOTS, HeldGuestLimitError, HeldGuestProjectMismatch, type HeldGuestSummary, type IdentifyTraits, type LinkCollision, type LinkCreateInput, type LinkCreateResult, type LinkData, LinkError, type LinkErrorCode, type LinkMatchInput, type LinkOpenedEvent, type LinkOpenedHandler, type LinkResult, type LinkStats, type LinkedIdentitiesResponse, type LinkedIdentity, Links, type MergeStats, type ProjectVerdict, type PushTokenReceipt, type PushTokenRegistration, type RefreshEndReason, SendoraCloud, type SendoraConfig, type SignInResult, type SocialCredentialProvider, type TrackProperties, type UnlinkResult, type WithheldInfo, computeDeviceFingerprint, decodeJwtPayload, SendoraCloud as default, extractShortcodeFromUrl, getPlayInstallReferrer, projectVerdict };
|