import type { SublayState } from "../sublayReducers"; import type { PushDeviceIdentifier } from "../../interfaces/PushTokenAdapter"; export interface AccountSummary { id: string; name: string | null; /** * Optional because entries persisted before this field existed simply do not * carry it. Absent means *unknown*, not "the user has no username" — the two * are different and only the second is `null`. */ username?: string | null; email: string | null; avatar: string | null; } export interface AccountEntry { refreshToken: string; tokenExpiresAt: number; user: AccountSummary; /** * Whether this account wants push notifications on THIS device. * * Client-owned and durable: it records an explicit user choice, so it must * survive the entry being rebuilt — which happens on every launch and every * transition, because the server rotates the refresh token on each exchange * and `useAccountSync` Phase B writes a fresh entry whenever it changes. * That is why `upsertAccount` merges rather than replaces. * * **Three states, not two.** `true` and `false` are explicit user choices; * ABSENT means "never expressed a preference", which every entry written * before this field existed carries. * * Which way absent reads depends on the question being asked, and the two * questions have separate predicates on purpose: * * - *"What does this account's push state look like?"* — * `isAccountPushEnabled`. Absent reads as ENABLED. It is the value a * per-account toggle renders as `checked`, and reading an upgrading * install's accounts as off would flip every one of those switches. * - *"May we create a push binding for this account?"* — * `accountOptedIntoPush`. Absent reads as NO, because a binding routes * message content to a device and nobody asked for it. This is the one * that decides whether anything is bound, and it is deliberately NOT * exported from the package. * * Never test the field directly; the absent case is what gets it wrong. */ pushEnabled?: boolean; /** * Set when a transition INTO this account failed because the stored refresh * token was rejected — expired, revoked, reuse-detected, killed by a password * change, a remote sign-out-all or an admin revocation. * * The reactive half of the pair with `tokenExpiresAt`, and neither is * sufficient alone. `tokenExpiresAt` is proactive but only knows what the * JWT's own `exp` claim says; every death in the list above kills the token * family while `exp` is still comfortably in the future, and the only way to * learn about those is to have tried. This field records that we tried. * * **Absent means "no opinion", not "healthy".** `upsertAccount` merges, and * `useAccountSync` Phase B rebuilds a fresh entry literal on every launch and * every transition — an entry that says nothing about re-auth. If absent * overwrote, the marker would be erased on a cadence. It is cleared * deliberately instead, by `setActiveAccount`, which is dispatched on exactly * one occasion: a successful activation of that account. * * Read it through `accountNeedsReauth`, never as a bare truthiness test on a * possibly-absent field. */ needsReauth?: boolean; /** * Set when this device's push identifier changed while the account was not * the active one, so its server-side binding now points at a token this * device no longer holds. * * **Why a marker and not a re-bind.** Binding a non-active account means * exchanging its stored refresh token for a session, and that exchange is * one-time-use: the server revokes the presented token as it answers. An * interruption between the two — the app swiped away, an OS suspension, a * dropped connection — leaves the stored copy dead and the successor * unsaved, and that account is then locked out for good. Running that trade * for up to five background accounts at launch is a lot of exposure to buy * notification routing with, so the rotation records what needs repairing * and the repair happens on the account's next activation, where a live * session already exists and nothing has to be exchanged. * * **Not `needsReauth`, and the two must not be conflated.** `needsReauth` * means this account's credential is dead and the user has to sign in again. * This means the credential is fine and only the notification routing is * stale — the account works, it is just quiet until it is next opened. * * Absent means "nothing to repair". Read it through `accountNeedsPushRebind`. */ needsPushRebind?: boolean; } /** * `true` unless the account was explicitly silenced on this device. * * **Reported state, not a binding decision.** This is what a per-account push * switch renders as its `checked` value, and it answers the absent case with * `true` so an account that predates the preference does not display as off. * Deciding whether to actually CREATE a binding is a different and stricter * question — see `accountOptedIntoPush`. * * Read the flag through this, never as `entry.pushEnabled` — absent and `true` * report the same thing and a bare truthiness test gets the absent case wrong. */ export declare function isAccountPushEnabled(entry: AccountEntry): boolean; /** * `true` only when this account has EXPLICITLY asked for push on this device. * * Internal, and deliberately not exported from the package: it is the rule for * *acting*, and publishing it alongside `isAccountPushEnabled` would invite * call sites to pick whichever one they read first. * * The difference is the absent case, and it is the whole point. An absent * preference is not consent. Treating it as consent is what let a plain * sign-in bind a brand-new account to a device whose identifier happened to * survive the previous user's sign-out — nobody granted anything, the app * never called `register()`, and the binding survived a restart. It is also * what would mark an upgrading install's accounts as needing a re-bind that * the activation path, which applies this same rule, would then never clear. * * **Both sides use this one.** Marking and binding have to agree, or a mark is * either raised for something that will never be repaired or dropped for * something that needed repairing. */ export declare function accountOptedIntoPush(entry: AccountEntry): boolean; /** * `true` when this device's push identifier moved on while the account was in * the background, so its binding needs re-creating on next activation. * * Distinct from `accountNeedsReauth`: that one says the credential is dead and * the user must sign in again; this one says the credential is fine and only * the notifications are paused. An app that surfaces both should say different * things about them. */ export declare function accountNeedsPushRebind(entry: AccountEntry): boolean; /** * `true` only when a transition into this account has actually been refused. * * Absent means nothing has gone wrong *that we know of* — it is not a promise * that the credential is live. Pair it with `tokenExpiresAt` when rendering a * switcher: expiry catches the deaths that are predictable, this catches the * ones that are not. */ export declare function accountNeedsReauth(entry: AccountEntry): boolean; export interface AccountMap { activeAccountId: string | null; accounts: Record; /** * This device's push identifier — a device-level sibling of `accounts`, not a * per-account field. One copy serves every stored account. * * Persisted rather than cached in memory because the docs tell apps to call * `register()` on a deliberate user action and explicitly *not* on mount, so * an in-memory copy would be cold on nearly every launch while the server-side * binding lives on. With a cold copy, account removal cannot unbind its push * and the per-account toggle silently no-ops. * * It survives `clearAllAccounts` and sign-out-all — a device's push token does * not stop being that device's token because nobody is signed in — and is * cleared only by `deleteAccountMap`, which is a full wipe. */ deviceIdentifier?: PushDeviceIdentifier | null; /** * `true` means this device has already run the ONE-TIME unconditional read of * its push identifier, so the read must never run again. * * **What it is for.** `usePushRegistration`'s mount read is gated on the OS * notification permission, which is a good steady-state rule and a wrong * rule exactly once: an install that registered on a release that persisted * no identifier, and has since revoked permission in system settings, holds a * live server-side binding that the gate makes unreachable — sign-out, * account removal and the per-account toggle are all gated on having an * identifier, so none of them can unbind it, and nothing else ever will * (revoking permission does not invalidate an APNs/FCM token, and the * server prunes only on uninstall/dead-token signals). So the read runs ONCE * ignoring permission, and this flag is what makes "once" survive a relaunch. * * Written by two places, both meaning "there is nothing left to discover": * * - `usePushRegistration`, after the mount read has actually completed. * - `useAccountSync` Phase A, when storage holds NO map at all. A device * with no account map has never stored an account, so it cannot hold a * binding created by an older release — and marking it here is what stops * the one-shot firing on a fresh install whose app only mounts the push * hook after sign-in. * * Device state like `deviceIdentifier`, not account state: it survives * `clearAllAccounts` and is dropped only by `deleteAccountMap`. * * Absent (maps written before this field existed) reads as `false`, which is * the whole point — those are precisely the maps the one-shot exists for. */ pushIdentifierProbed?: boolean; /** * `true` means "the last thing that happened was a deliberate sign-out". * * Persisted, because relaunch is exactly the case it exists to survive. It is * what distinguishes *"deliberately signed out"* from *"nothing selected * yet"* — two states that both carry `activeAccountId: null`, and which * `useAccountSync` Phase A must tell apart: it falls back to the first stored * account when nothing was ever selected, and must NOT do that after a * deliberate sign-out (that would silently drop the user into an identity * they did not choose, on every launch). * * Absent (maps written before this field existed) reads as `false` — the * pre-existing "pick the first account" behavior, unchanged. */ signedOut?: boolean; } export interface AccountsState { accounts: Record; activeAccountId: string | null; /** * See `AccountMap.deviceIdentifier`. It needs a Redux home and not just a * storage slot: `useAccountSync` Phase C builds the persisted map out of this * state, so without a field here every persist would silently drop it — and * the sign-out callers have to read it synchronously while building their * request, where `AccountStorage` is not reachable. */ deviceIdentifier: PushDeviceIdentifier | null; /** See `AccountMap.pushIdentifierProbed`. */ pushIdentifierProbed: boolean; /** See `AccountMap.signedOut`. */ signedOut: boolean; /** * Set when an account could not be admitted because the map is already at * `MAX_ACCOUNTS`. Secondary to the rejection the call site raises — this is * for UI that prefers reading a flag, and it is the ONLY channel on the two * OAuth paths, whose entry point is synchronous and cannot reject its caller. * * Cleared on any successful admission and on any removal, so an app does not * keep rendering the cap error after the user frees a slot and retries. */ accountLimitReached: boolean; isReady: boolean; accountManagerRegistered: boolean; } export declare const MAX_ACCOUNTS = 5; /** * Whether admitting `userId` into `accounts` would exceed `MAX_ACCOUNTS`. * * **An id already in the map is never an admission** — it is the same person * signing in again, and re-authenticating an account you are already storing * has to work at the cap or a user with five accounts can never sign back into * any of them. That is the whole reason the authoritative cap check is keyed on * the resolved `user.id` and runs *after* authentication: the identity has to * be known before "is this new?" can be answered. Matching a typed email * against the stored summaries beforehand looks equivalent and is not — a * summary can hold a stale email, a null one (accounts admitted through * `verifyExternalUser`), or the same address in different case. * * Pass no `userId` for the pre-flight on sign-UP, where the answer is * unconditional: a sign-up creates a person who by definition is not in the map. */ export declare function wouldExceedAccountLimit(accounts: Record, userId?: string | null): boolean; export declare const setAccountMap: import("@reduxjs/toolkit").ActionCreatorWithPayload, upsertAccount: import("@reduxjs/toolkit").ActionCreatorWithPayload<{ userId: string; entry: AccountEntry; }, "accounts/upsertAccount">, setDeviceIdentifier: import("@reduxjs/toolkit").ActionCreatorWithPayload, markPushIdentifierProbed: import("@reduxjs/toolkit").ActionCreatorWithoutPayload<"accounts/markPushIdentifierProbed">, setAccountPushEnabled: import("@reduxjs/toolkit").ActionCreatorWithPayload<{ userId: string; enabled: boolean; }, "accounts/setAccountPushEnabled">, setAccountCredential: import("@reduxjs/toolkit").ActionCreatorWithPayload<{ userId: string; refreshToken: string; tokenExpiresAt: number; }, "accounts/setAccountCredential">, setAccountNeedsReauth: import("@reduxjs/toolkit").ActionCreatorWithPayload<{ userId: string; needsReauth: boolean; }, "accounts/setAccountNeedsReauth">, setAccountNeedsPushRebind: import("@reduxjs/toolkit").ActionCreatorWithPayload<{ userId: string; needsRebind: boolean; }, "accounts/setAccountNeedsPushRebind">, removeAccount: import("@reduxjs/toolkit").ActionCreatorWithPayload, setActiveAccount: import("@reduxjs/toolkit").ActionCreatorWithPayload, setSignedOut: import("@reduxjs/toolkit").ActionCreatorWithPayload, setAccountLimitReached: import("@reduxjs/toolkit").ActionCreatorWithPayload, clearAllAccounts: import("@reduxjs/toolkit").ActionCreatorWithoutPayload<"accounts/clearAllAccounts">, setAccountsReady: import("@reduxjs/toolkit").ActionCreatorWithPayload, registerAccountManager: import("@reduxjs/toolkit").ActionCreatorWithoutPayload<"accounts/registerAccountManager">; /** * Builds the persistable `AccountMap` out of the slice's current state. * * The single definition of what a persisted map contains, shared by * `useAccountSync` Phase C and by the non-React callers that must AWAIT a write * before continuing (the minted-token helper, push reconciliation, the * per-account push toggle). Those callers hold `getState`, not a React * subscription, and a second hand-rolled literal is how a field silently stops * being persisted. * * Returns a fresh object on every call, so it is a snapshot builder for * imperative callers — do NOT pass it to `useSelector`, which would re-render on * every store action. */ export declare function buildAccountMap(state: AccountsState): AccountMap; /** `buildAccountMap` against a full root state — for `getState()` callers. */ export declare function selectAccountMapSnapshot(state: { sublay: SublayState; }): AccountMap; export declare const selectAccounts: (state: { sublay: SublayState; }) => Record; export declare const selectActiveAccountId: (state: { sublay: SublayState; }) => string | null; export declare const selectSignedOut: (state: { sublay: SublayState; }) => boolean; export declare const selectDeviceIdentifier: (state: { sublay: SublayState; }) => PushDeviceIdentifier | null; export declare const selectAccountLimitReached: (state: { sublay: SublayState; }) => boolean; export declare const selectAccountsReady: (state: { sublay: SublayState; }) => boolean; export declare const selectAccountManagerRegistered: (state: { sublay: SublayState; }) => boolean; declare const _default: import("@reduxjs/toolkit").Reducer; export default _default;