@aglyn/tenant-feature-instance 1.0.0-beta.252 → 1.0.0-beta.254

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-feature-instance",
3
- "version": "1.0.0-beta.252",
3
+ "version": "1.0.0-beta.254",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.252",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.252",
30
- "@aglyn/shared-ui-jsx": "1.0.0-beta.252",
31
- "@aglyn/shared-util-email": "1.0.0-beta.252",
32
- "@aglyn/shared-util-http": "1.0.0-beta.252",
33
- "@aglyn/shared-util-timestamp": "1.0.0-beta.252",
28
+ "@aglyn/aglyn": "1.0.0-beta.254",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.254",
30
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.254",
31
+ "@aglyn/shared-util-email": "1.0.0-beta.254",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.254",
33
+ "@aglyn/shared-util-timestamp": "1.0.0-beta.254",
34
34
  "@swc/helpers": "0.5.23",
35
35
  "lodash-es": "^4.18.1"
36
36
  },
@@ -0,0 +1,55 @@
1
+ import { type Auth } from 'firebase/auth';
2
+ /**
3
+ * Let a cross-tab sign-in move this instance to the signed-in user's pool
4
+ * (AGL-3803).
5
+ *
6
+ * ## The bug
7
+ *
8
+ * A durable instance keeps ONE signed-in user per browser profile, under
9
+ * `firebase:authUser:<apiKey>:<appName>` — a key that does not name the
10
+ * Identity Platform pool. When another tab signs in, the SDK hands the record
11
+ * it wrote to every other tab through `_onStorageEvent`, which replaces the
12
+ * tab's user via `_updateCurrentUser`. That method asserts
13
+ * `auth.tenantId === user.tenantId`. A tab whose instance is in an SSO
14
+ * tenant (`adoptRestoredPool`, AGL-2486) and a peer tab that signs in to the
15
+ * project pool — or the reverse, or staff impersonation into another pool —
16
+ * fail it: `auth/tenant-id-mismatch` is thrown inside a listener nobody
17
+ * awaits, which is the unhandled rejection in the client-errors log.
18
+ *
19
+ * The tab is then wrong without saying so. It keeps the old user in memory
20
+ * and fires no auth-state change, while the profile's record, and the shared
21
+ * session cookie the peer minted, belong to the new user. The next token
22
+ * refresh writes the old user back over the record, so the peer tab gets the
23
+ * same error the other way round, and whichever tab reloads next restores
24
+ * whichever account wrote last.
25
+ *
26
+ * ## The fix
27
+ *
28
+ * On that one path, put the instance in the incoming user's pool first, so
29
+ * the SDK's own assertion holds and the switch completes exactly as it
30
+ * already does between two users of the same pool. The pool comes from the
31
+ * user, never from a caller, so this can only ever agree with the record
32
+ * every tab of this origin already treats as the truth.
33
+ *
34
+ * Why not a pool-specific persistence key (a Firebase app per pool)? Firestore,
35
+ * Storage and App Check hang off the one app, the pool is not known until
36
+ * the restore has run, and the shared session cookie is one per profile
37
+ * anyway. A profile has one signed-in account; this makes every tab agree on
38
+ * it instead of letting the pool decide which tabs may hear about it.
39
+ *
40
+ * ## What it does not change
41
+ *
42
+ * - The assertion still guards every in-tab sign-in, link and reauth: those
43
+ * pass `skipBeforeStateCallbacks = false` and go straight to the SDK.
44
+ * - Sign-out elsewhere (`user === null`) is untouched, and so is the pool
45
+ * of a signed-out tab.
46
+ * - A same-pool switch is untouched: the pools already agree.
47
+ *
48
+ * A tab that is mid sign-in when a peer signs in races it, exactly as the SDK
49
+ * already makes two same-pool sign-ins race over the one record: the last
50
+ * write wins in every tab.
51
+ *
52
+ * Idempotent: `getAuth(app)` hands back the same instance on every call, and
53
+ * the guard is installed on the first.
54
+ */
55
+ export declare function followPeerTabPool<T extends Auth>(auth: T): T;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ 'use client';
17
+ const INSTALLED = Symbol.for('aglyn.auth.followPeerTabPool');
18
+ /**
19
+ * Let a cross-tab sign-in move this instance to the signed-in user's pool
20
+ * (AGL-3803).
21
+ *
22
+ * ## The bug
23
+ *
24
+ * A durable instance keeps ONE signed-in user per browser profile, under
25
+ * `firebase:authUser:<apiKey>:<appName>` — a key that does not name the
26
+ * Identity Platform pool. When another tab signs in, the SDK hands the record
27
+ * it wrote to every other tab through `_onStorageEvent`, which replaces the
28
+ * tab's user via `_updateCurrentUser`. That method asserts
29
+ * `auth.tenantId === user.tenantId`. A tab whose instance is in an SSO
30
+ * tenant (`adoptRestoredPool`, AGL-2486) and a peer tab that signs in to the
31
+ * project pool — or the reverse, or staff impersonation into another pool —
32
+ * fail it: `auth/tenant-id-mismatch` is thrown inside a listener nobody
33
+ * awaits, which is the unhandled rejection in the client-errors log.
34
+ *
35
+ * The tab is then wrong without saying so. It keeps the old user in memory
36
+ * and fires no auth-state change, while the profile's record, and the shared
37
+ * session cookie the peer minted, belong to the new user. The next token
38
+ * refresh writes the old user back over the record, so the peer tab gets the
39
+ * same error the other way round, and whichever tab reloads next restores
40
+ * whichever account wrote last.
41
+ *
42
+ * ## The fix
43
+ *
44
+ * On that one path, put the instance in the incoming user's pool first, so
45
+ * the SDK's own assertion holds and the switch completes exactly as it
46
+ * already does between two users of the same pool. The pool comes from the
47
+ * user, never from a caller, so this can only ever agree with the record
48
+ * every tab of this origin already treats as the truth.
49
+ *
50
+ * Why not a pool-specific persistence key (a Firebase app per pool)? Firestore,
51
+ * Storage and App Check hang off the one app, the pool is not known until
52
+ * the restore has run, and the shared session cookie is one per profile
53
+ * anyway. A profile has one signed-in account; this makes every tab agree on
54
+ * it instead of letting the pool decide which tabs may hear about it.
55
+ *
56
+ * ## What it does not change
57
+ *
58
+ * - The assertion still guards every in-tab sign-in, link and reauth: those
59
+ * pass `skipBeforeStateCallbacks = false` and go straight to the SDK.
60
+ * - Sign-out elsewhere (`user === null`) is untouched, and so is the pool
61
+ * of a signed-out tab.
62
+ * - A same-pool switch is untouched: the pools already agree.
63
+ *
64
+ * A tab that is mid sign-in when a peer signs in races it, exactly as the SDK
65
+ * already makes two same-pool sign-ins race over the one record: the last
66
+ * write wins in every tab.
67
+ *
68
+ * Idempotent: `getAuth(app)` hands back the same instance on every call, and
69
+ * the guard is installed on the first.
70
+ */ export function followPeerTabPool(auth) {
71
+ const internals = auth;
72
+ // Not an SDK instance (a stub in a spec): nothing syncs, nothing to follow.
73
+ if (!internals || typeof internals._updateCurrentUser !== 'function') return auth;
74
+ if (internals[INSTALLED]) return auth;
75
+ const updateCurrentUser = internals._updateCurrentUser;
76
+ Object.defineProperty(internals, '_updateCurrentUser', {
77
+ configurable: true,
78
+ writable: true,
79
+ value: function followPeerTabUser(user, skipBeforeStateCallbacks) {
80
+ if (skipBeforeStateCallbacks === true && user) {
81
+ var _user_tenantId;
82
+ const pool = (_user_tenantId = user.tenantId) != null ? _user_tenantId : null;
83
+ if (this.tenantId !== pool) this.tenantId = pool;
84
+ }
85
+ return updateCurrentUser.call(this, user, skipBeforeStateCallbacks);
86
+ }
87
+ });
88
+ Object.defineProperty(internals, INSTALLED, {
89
+ value: true
90
+ });
91
+ return auth;
92
+ }
93
+
94
+ //# sourceMappingURL=auth-cross-tab-pool.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/auth-cross-tab-pool.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { type Auth } from 'firebase/auth'\n\n/**\n * The SDK internals this module touches, verified in `@firebase/auth` 1.13.6.\n *\n * - `PersistenceUserManager` binds `auth._onStorageEvent` ONCE, when the\n * instance initializes, as the persistence's cross-tab listener. A later\n * own-property `_onStorageEvent` would never be called, so the seam cannot\n * be there.\n * - `_onStorageEvent` reads the shared record and, for a user whose uid\n * differs from this tab's, calls `this._updateCurrentUser(user, true)`.\n * That call is looked up on `this` at call time, so an own property on the\n * instance is reached.\n * - `true` (`skipBeforeStateCallbacks`) with a NON-null user is passed by that\n * one call site and nowhere else. `signOut` passes `true` with `null`; every\n * sign-in, link, reauth and `updateCurrentUser` passes the default `false`.\n *\n * `auth-cross-tab-pool.spec.ts` drives the real SDK through a real `storage`\n * event, so an SDK upgrade that moves any of this reds that spec.\n */\ninterface CrossTabSyncInternals {\n tenantId: string | null\n _updateCurrentUser(\n user: { tenantId?: string | null } | null,\n skipBeforeStateCallbacks?: boolean,\n ): Promise<void>\n}\n\nconst INSTALLED = Symbol.for('aglyn.auth.followPeerTabPool')\n\n/**\n * Let a cross-tab sign-in move this instance to the signed-in user's pool\n * (AGL-3803).\n *\n * ## The bug\n *\n * A durable instance keeps ONE signed-in user per browser profile, under\n * `firebase:authUser:<apiKey>:<appName>` — a key that does not name the\n * Identity Platform pool. When another tab signs in, the SDK hands the record\n * it wrote to every other tab through `_onStorageEvent`, which replaces the\n * tab's user via `_updateCurrentUser`. That method asserts\n * `auth.tenantId === user.tenantId`. A tab whose instance is in an SSO\n * tenant (`adoptRestoredPool`, AGL-2486) and a peer tab that signs in to the\n * project pool — or the reverse, or staff impersonation into another pool —\n * fail it: `auth/tenant-id-mismatch` is thrown inside a listener nobody\n * awaits, which is the unhandled rejection in the client-errors log.\n *\n * The tab is then wrong without saying so. It keeps the old user in memory\n * and fires no auth-state change, while the profile's record, and the shared\n * session cookie the peer minted, belong to the new user. The next token\n * refresh writes the old user back over the record, so the peer tab gets the\n * same error the other way round, and whichever tab reloads next restores\n * whichever account wrote last.\n *\n * ## The fix\n *\n * On that one path, put the instance in the incoming user's pool first, so\n * the SDK's own assertion holds and the switch completes exactly as it\n * already does between two users of the same pool. The pool comes from the\n * user, never from a caller, so this can only ever agree with the record\n * every tab of this origin already treats as the truth.\n *\n * Why not a pool-specific persistence key (a Firebase app per pool)? Firestore,\n * Storage and App Check hang off the one app, the pool is not known until\n * the restore has run, and the shared session cookie is one per profile\n * anyway. A profile has one signed-in account; this makes every tab agree on\n * it instead of letting the pool decide which tabs may hear about it.\n *\n * ## What it does not change\n *\n * - The assertion still guards every in-tab sign-in, link and reauth: those\n * pass `skipBeforeStateCallbacks = false` and go straight to the SDK.\n * - Sign-out elsewhere (`user === null`) is untouched, and so is the pool\n * of a signed-out tab.\n * - A same-pool switch is untouched: the pools already agree.\n *\n * A tab that is mid sign-in when a peer signs in races it, exactly as the SDK\n * already makes two same-pool sign-ins race over the one record: the last\n * write wins in every tab.\n *\n * Idempotent: `getAuth(app)` hands back the same instance on every call, and\n * the guard is installed on the first.\n */\nexport function followPeerTabPool<T extends Auth>(auth: T): T {\n const internals = auth as unknown as CrossTabSyncInternals & {\n [INSTALLED]?: true\n }\n // Not an SDK instance (a stub in a spec): nothing syncs, nothing to follow.\n if (!internals || typeof internals._updateCurrentUser !== 'function') return auth\n if (internals[INSTALLED]) return auth\n const updateCurrentUser = internals._updateCurrentUser\n\n Object.defineProperty(internals, '_updateCurrentUser', {\n configurable: true,\n writable: true,\n value: function followPeerTabUser(\n this: CrossTabSyncInternals,\n user: { tenantId?: string | null } | null,\n skipBeforeStateCallbacks?: boolean,\n ): Promise<void> {\n if (skipBeforeStateCallbacks === true && user) {\n const pool = user.tenantId ?? null\n if (this.tenantId !== pool) this.tenantId = pool\n }\n return updateCurrentUser.call(this, user, skipBeforeStateCallbacks)\n },\n })\n Object.defineProperty(internals, INSTALLED, { value: true })\n return auth\n}\n"],"names":["INSTALLED","Symbol","for","followPeerTabPool","auth","internals","_updateCurrentUser","updateCurrentUser","Object","defineProperty","configurable","writable","value","followPeerTabUser","user","skipBeforeStateCallbacks","pool","tenantId","call"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AA8BA,MAAMA,YAAYC,OAAOC,GAAG,CAAC;AAE7B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoDC,GACD,OAAO,SAASC,kBAAkCC,IAAO;IACvD,MAAMC,YAAYD;IAGlB,4EAA4E;IAC5E,IAAI,CAACC,aAAa,OAAOA,UAAUC,kBAAkB,KAAK,YAAY,OAAOF;IAC7E,IAAIC,SAAS,CAACL,UAAU,EAAE,OAAOI;IACjC,MAAMG,oBAAoBF,UAAUC,kBAAkB;IAEtDE,OAAOC,cAAc,CAACJ,WAAW,sBAAsB;QACrDK,cAAc;QACdC,UAAU;QACVC,OAAO,SAASC,kBAEdC,IAAyC,EACzCC,wBAAkC;YAElC,IAAIA,6BAA6B,QAAQD,MAAM;oBAChCA;gBAAb,MAAME,QAAOF,iBAAAA,KAAKG,QAAQ,YAAbH,iBAAiB;gBAC9B,IAAI,IAAI,CAACG,QAAQ,KAAKD,MAAM,IAAI,CAACC,QAAQ,GAAGD;YAC9C;YACA,OAAOT,kBAAkBW,IAAI,CAAC,IAAI,EAAEJ,MAAMC;QAC5C;IACF;IACAP,OAAOC,cAAc,CAACJ,WAAWL,WAAW;QAAEY,OAAO;IAAK;IAC1D,OAAOR;AACT"}
@@ -15,6 +15,7 @@
15
15
  * limitations under the License.
16
16
  */ 'use client';
17
17
  import { getAuth, inMemoryPersistence, initializeAuth } from "firebase/auth";
18
+ import { followPeerTabPool } from "./auth-cross-tab-pool.js";
18
19
  /** `code` on {@link SealedAuthPersistenceError}, shaped like a Firebase error code. */ export const AUTH_PERSISTENCE_SEALED_CODE = 'aglyn-auth/persistence-sealed';
19
20
  /**
20
21
  * Thrown when something tries to re-persist an `ephemeral` auth instance.
@@ -107,11 +108,15 @@ import { getAuth, inMemoryPersistence, initializeAuth } from "firebase/auth";
107
108
  // No `popupRedirectResolver`: D6's second half. The auth helper iframe is
108
109
  // never loaded, so the custom domain needs no `frame-ancestors` entry and
109
110
  // can never need a Firebase authorized-domain entry for OAuth.
110
- return sealPersistence(initializeAuth(app, {
111
+ return followPeerTabPool(sealPersistence(initializeAuth(app, {
111
112
  persistence: inMemoryPersistence
112
- }));
113
+ })));
113
114
  }
114
- return getAuth(app);
115
+ // Every instance this factory hands out follows a peer tab's sign-in into
116
+ // that user's pool (AGL-3803). Only `durable` has peer tabs today; the
117
+ // ephemeral one gets it too, so the property belongs to the factory rather
118
+ // than to one class of origin.
119
+ return followPeerTabPool(getAuth(app));
115
120
  }
116
121
 
117
122
  //# sourceMappingURL=auth-persistence.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/auth-persistence.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { type FirebaseApp } from 'firebase/app'\nimport {\n type Auth,\n getAuth,\n inMemoryPersistence,\n initializeAuth,\n} from 'firebase/auth'\n\n/**\n * How much of a session an origin is allowed to keep (AGL-1379).\n *\n * - `durable` — the SDK default hierarchy (IndexedDB → localStorage →\n * sessionStorage). A **refresh token lands on disk in plaintext**, which is\n * correct on `*.aglyn.com`: we own the DNS forever, and a 14-day session is\n * the product.\n * - `ephemeral` — `initializeAuth(app, { persistence: inMemoryPersistence })`\n * with **no `popupRedirectResolver`**, per AGL-1099a D6. For a **custom\n * console domain**, whose DNS the customer can re-point at their own server\n * after a detach: a refresh token left in that origin's IndexedDB is a\n * durable account-takeover primitive that no cookie TTL and no server-side\n * revocation short of `revokeRefreshTokens` can reach.\n *\n * The class is a property of the **instance**, not of any call site, which is\n * why it is declared here and nowhere else — see `createAuthInstance`.\n */\nexport type AuthPersistenceClass = 'durable' | 'ephemeral'\n\n/** `code` on {@link SealedAuthPersistenceError}, shaped like a Firebase error code. */\nexport const AUTH_PERSISTENCE_SEALED_CODE = 'aglyn-auth/persistence-sealed'\n\n/**\n * Thrown when something tries to re-persist an `ephemeral` auth instance.\n *\n * Thrown **synchronously**, deliberately, even though `setPersistence` is\n * typed `Promise<void>`. That matches how the SDK already refuses the\n * federated family on a resolver-less instance (`signInWithPopup` and friends\n * throw `auth/argument-error` before any network request, measured in\n * `docs/design/agl-1099a-poc-findings.md` §3), and it means a fire-and-forget\n * `void setPersistence(...)` fails loudly instead of becoming an unhandled\n * rejection nobody reads.\n */\nexport class SealedAuthPersistenceError extends Error {\n readonly code = AUTH_PERSISTENCE_SEALED_CODE\n constructor() {\n super(\n 'setPersistence is sealed on this Auth instance: it was created with ' +\n \"persistence class 'ephemeral' (AGL-1099a D6), so persisting a \" +\n 'refresh token to this origin is not permitted. If this origin may ' +\n \"keep a session, create its Auth instance as 'durable' — do not \" +\n 'change persistence after the fact (AGL-1379).',\n )\n this.name = 'SealedAuthPersistenceError'\n }\n}\n\n/**\n * Is `setPersistence` sealed on this instance? A diagnostic, not a boundary —\n * the boundary is the property descriptor itself.\n */\nexport function isAuthPersistenceSealed(auth: Auth): boolean {\n const descriptor = Object.getOwnPropertyDescriptor(auth, 'setPersistence')\n return (\n !!descriptor && descriptor.writable === false && descriptor.configurable === false\n )\n}\n\n/**\n * Make `setPersistence` unrepresentable on this instance.\n *\n * The modular `setPersistence(auth, persistence)` is a one-line delegation —\n * `getModularInstance(auth).setPersistence(persistence)` (verified in\n * `@firebase/auth` 1.13.3) — so an own, non-writable, non-configurable\n * property shadowing the prototype method closes **every** route that goes\n * through the object: the free function, the method, a wrapper, an aliased\n * import, and any call site nobody has written yet. That is the point. The\n * six existing `setPersistence` call sites did not have to be touched, and\n * the seventh nobody has typed yet is covered too.\n *\n * Deliberate limit, stated rather than overclaimed: a caller who reaches\n * around the instance — pulling the method off `Object.getPrototypeOf(auth)`\n * and `call`-ing it — is not stopped. Overriding the prototype would seal\n * every `Auth` in the process, including the `durable` ones this file exists\n * to leave alone.\n */\nfunction sealPersistence(auth: Auth): Auth {\n // Idempotent: `initializeAuth` returns the existing instance when called\n // twice with deep-equal options, and `usePresence` re-enters its effect on\n // every room change. Re-defining a non-configurable property is a\n // TypeError, so the second pass must be a no-op rather than a crash.\n if (isAuthPersistenceSealed(auth)) return auth\n Object.defineProperty(auth, 'setPersistence', {\n value: (): Promise<void> => {\n throw new SealedAuthPersistenceError()\n },\n writable: false,\n configurable: false,\n enumerable: false,\n })\n return auth\n}\n\n/**\n * The only way an Aglyn `Auth` instance is born, and the persistence class is\n * a **required positional argument** (AGL-1379).\n *\n * ## Why this shape\n *\n * The thing that has to be guarded is a `setPersistence` call passing\n * `browserLocalPersistence`, which the PoC measured flipping a refresh token\n * from absent to present in IndexedDB on an in-memory instance — accepted at\n * runtime, no error, no warning.\n *\n * - **A lint rule cannot classify it.** Whether a given `setPersistence` call\n * is safe depends on which host the browser is on, and the same source file\n * serves both. `apps/console/utils/passkeys.ts:133` is *correct* on\n * `app.aglyn.com` and *fatal* on `console.acme-agency.com`. A rule banning\n * the call would be false half the time, and AGL-1357's lesson — derive the\n * classification from what the code declares — has nothing to derive from\n * at these call sites, because nothing there declares the instance's class.\n * - **A wrapper the six sites go through is bypassable** by the next person\n * who imports `setPersistence` from `firebase/auth` directly, which is what\n * all six already do.\n * - **A required argument works only where the class is knowable**, and that\n * is here, at creation — the AGL-1366 shape (`setNode`'s required `parent`)\n * applied at the one place it is honest. Every creation site must now say\n * which kind of origin it is on, and the guard rides the instance from\n * there.\n *\n * `durable` is the SDK default, reached by the exact `getAuth(app)` the\n * console has always called — this factory changes nothing about how\n * `*.aglyn.com` persists.\n */\nexport function createAuthInstance(\n app: FirebaseApp,\n persistenceClass: AuthPersistenceClass,\n): Auth {\n if (persistenceClass === 'ephemeral') {\n // No `popupRedirectResolver`: D6's second half. The auth helper iframe is\n // never loaded, so the custom domain needs no `frame-ancestors` entry and\n // can never need a Firebase authorized-domain entry for OAuth.\n return sealPersistence(initializeAuth(app, { persistence: inMemoryPersistence }))\n }\n return getAuth(app)\n}\n"],"names":["getAuth","inMemoryPersistence","initializeAuth","AUTH_PERSISTENCE_SEALED_CODE","SealedAuthPersistenceError","Error","code","name","isAuthPersistenceSealed","auth","descriptor","Object","getOwnPropertyDescriptor","writable","configurable","sealPersistence","defineProperty","value","enumerable","createAuthInstance","app","persistenceClass","persistence"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAGA,SAEEA,OAAO,EACPC,mBAAmB,EACnBC,cAAc,QACT,gBAAe;AAqBtB,qFAAqF,GACrF,OAAO,MAAMC,+BAA+B,gCAA+B;AAE3E;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,mCAAmCC;IAE9C,aAAc;QACZ,KAAK,CACH,yEACE,mEACA,uEACA,oEACA,uDAPGC,OAAOH;QASd,IAAI,CAACI,IAAI,GAAG;IACd;AACF;AAEA;;;CAGC,GACD,OAAO,SAASC,wBAAwBC,IAAU;IAChD,MAAMC,aAAaC,OAAOC,wBAAwB,CAACH,MAAM;IACzD,OACE,CAAC,CAACC,cAAcA,WAAWG,QAAQ,KAAK,SAASH,WAAWI,YAAY,KAAK;AAEjF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,SAASC,gBAAgBN,IAAU;IACjC,yEAAyE;IACzE,2EAA2E;IAC3E,kEAAkE;IAClE,qEAAqE;IACrE,IAAID,wBAAwBC,OAAO,OAAOA;IAC1CE,OAAOK,cAAc,CAACP,MAAM,kBAAkB;QAC5CQ,OAAO;YACL,MAAM,IAAIb;QACZ;QACAS,UAAU;QACVC,cAAc;QACdI,YAAY;IACd;IACA,OAAOT;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASU,mBACdC,GAAgB,EAChBC,gBAAsC;IAEtC,IAAIA,qBAAqB,aAAa;QACpC,0EAA0E;QAC1E,0EAA0E;QAC1E,+DAA+D;QAC/D,OAAON,gBAAgBb,eAAekB,KAAK;YAAEE,aAAarB;QAAoB;IAChF;IACA,OAAOD,QAAQoB;AACjB"}
1
+ {"version":3,"sources":["../../../../../../../../../libs/tenant/feature/instance/src/lib/hooks/firebase/auth-persistence.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n'use client'\n\nimport { type FirebaseApp } from 'firebase/app'\nimport {\n type Auth,\n getAuth,\n inMemoryPersistence,\n initializeAuth,\n} from 'firebase/auth'\n\nimport { followPeerTabPool } from './auth-cross-tab-pool'\n\n/**\n * How much of a session an origin is allowed to keep (AGL-1379).\n *\n * - `durable` — the SDK default hierarchy (IndexedDB → localStorage →\n * sessionStorage). A **refresh token lands on disk in plaintext**, which is\n * correct on `*.aglyn.com`: we own the DNS forever, and a 14-day session is\n * the product.\n * - `ephemeral` — `initializeAuth(app, { persistence: inMemoryPersistence })`\n * with **no `popupRedirectResolver`**, per AGL-1099a D6. For a **custom\n * console domain**, whose DNS the customer can re-point at their own server\n * after a detach: a refresh token left in that origin's IndexedDB is a\n * durable account-takeover primitive that no cookie TTL and no server-side\n * revocation short of `revokeRefreshTokens` can reach.\n *\n * The class is a property of the **instance**, not of any call site, which is\n * why it is declared here and nowhere else — see `createAuthInstance`.\n */\nexport type AuthPersistenceClass = 'durable' | 'ephemeral'\n\n/** `code` on {@link SealedAuthPersistenceError}, shaped like a Firebase error code. */\nexport const AUTH_PERSISTENCE_SEALED_CODE = 'aglyn-auth/persistence-sealed'\n\n/**\n * Thrown when something tries to re-persist an `ephemeral` auth instance.\n *\n * Thrown **synchronously**, deliberately, even though `setPersistence` is\n * typed `Promise<void>`. That matches how the SDK already refuses the\n * federated family on a resolver-less instance (`signInWithPopup` and friends\n * throw `auth/argument-error` before any network request, measured in\n * `docs/design/agl-1099a-poc-findings.md` §3), and it means a fire-and-forget\n * `void setPersistence(...)` fails loudly instead of becoming an unhandled\n * rejection nobody reads.\n */\nexport class SealedAuthPersistenceError extends Error {\n readonly code = AUTH_PERSISTENCE_SEALED_CODE\n constructor() {\n super(\n 'setPersistence is sealed on this Auth instance: it was created with ' +\n \"persistence class 'ephemeral' (AGL-1099a D6), so persisting a \" +\n 'refresh token to this origin is not permitted. If this origin may ' +\n \"keep a session, create its Auth instance as 'durable' — do not \" +\n 'change persistence after the fact (AGL-1379).',\n )\n this.name = 'SealedAuthPersistenceError'\n }\n}\n\n/**\n * Is `setPersistence` sealed on this instance? A diagnostic, not a boundary —\n * the boundary is the property descriptor itself.\n */\nexport function isAuthPersistenceSealed(auth: Auth): boolean {\n const descriptor = Object.getOwnPropertyDescriptor(auth, 'setPersistence')\n return (\n !!descriptor && descriptor.writable === false && descriptor.configurable === false\n )\n}\n\n/**\n * Make `setPersistence` unrepresentable on this instance.\n *\n * The modular `setPersistence(auth, persistence)` is a one-line delegation —\n * `getModularInstance(auth).setPersistence(persistence)` (verified in\n * `@firebase/auth` 1.13.3) — so an own, non-writable, non-configurable\n * property shadowing the prototype method closes **every** route that goes\n * through the object: the free function, the method, a wrapper, an aliased\n * import, and any call site nobody has written yet. That is the point. The\n * six existing `setPersistence` call sites did not have to be touched, and\n * the seventh nobody has typed yet is covered too.\n *\n * Deliberate limit, stated rather than overclaimed: a caller who reaches\n * around the instance — pulling the method off `Object.getPrototypeOf(auth)`\n * and `call`-ing it — is not stopped. Overriding the prototype would seal\n * every `Auth` in the process, including the `durable` ones this file exists\n * to leave alone.\n */\nfunction sealPersistence(auth: Auth): Auth {\n // Idempotent: `initializeAuth` returns the existing instance when called\n // twice with deep-equal options, and `usePresence` re-enters its effect on\n // every room change. Re-defining a non-configurable property is a\n // TypeError, so the second pass must be a no-op rather than a crash.\n if (isAuthPersistenceSealed(auth)) return auth\n Object.defineProperty(auth, 'setPersistence', {\n value: (): Promise<void> => {\n throw new SealedAuthPersistenceError()\n },\n writable: false,\n configurable: false,\n enumerable: false,\n })\n return auth\n}\n\n/**\n * The only way an Aglyn `Auth` instance is born, and the persistence class is\n * a **required positional argument** (AGL-1379).\n *\n * ## Why this shape\n *\n * The thing that has to be guarded is a `setPersistence` call passing\n * `browserLocalPersistence`, which the PoC measured flipping a refresh token\n * from absent to present in IndexedDB on an in-memory instance — accepted at\n * runtime, no error, no warning.\n *\n * - **A lint rule cannot classify it.** Whether a given `setPersistence` call\n * is safe depends on which host the browser is on, and the same source file\n * serves both. `apps/console/utils/passkeys.ts:133` is *correct* on\n * `app.aglyn.com` and *fatal* on `console.acme-agency.com`. A rule banning\n * the call would be false half the time, and AGL-1357's lesson — derive the\n * classification from what the code declares — has nothing to derive from\n * at these call sites, because nothing there declares the instance's class.\n * - **A wrapper the six sites go through is bypassable** by the next person\n * who imports `setPersistence` from `firebase/auth` directly, which is what\n * all six already do.\n * - **A required argument works only where the class is knowable**, and that\n * is here, at creation — the AGL-1366 shape (`setNode`'s required `parent`)\n * applied at the one place it is honest. Every creation site must now say\n * which kind of origin it is on, and the guard rides the instance from\n * there.\n *\n * `durable` is the SDK default, reached by the exact `getAuth(app)` the\n * console has always called — this factory changes nothing about how\n * `*.aglyn.com` persists.\n */\nexport function createAuthInstance(\n app: FirebaseApp,\n persistenceClass: AuthPersistenceClass,\n): Auth {\n if (persistenceClass === 'ephemeral') {\n // No `popupRedirectResolver`: D6's second half. The auth helper iframe is\n // never loaded, so the custom domain needs no `frame-ancestors` entry and\n // can never need a Firebase authorized-domain entry for OAuth.\n return followPeerTabPool(\n sealPersistence(initializeAuth(app, { persistence: inMemoryPersistence })),\n )\n }\n // Every instance this factory hands out follows a peer tab's sign-in into\n // that user's pool (AGL-3803). Only `durable` has peer tabs today; the\n // ephemeral one gets it too, so the property belongs to the factory rather\n // than to one class of origin.\n return followPeerTabPool(getAuth(app))\n}\n"],"names":["getAuth","inMemoryPersistence","initializeAuth","followPeerTabPool","AUTH_PERSISTENCE_SEALED_CODE","SealedAuthPersistenceError","Error","code","name","isAuthPersistenceSealed","auth","descriptor","Object","getOwnPropertyDescriptor","writable","configurable","sealPersistence","defineProperty","value","enumerable","createAuthInstance","app","persistenceClass","persistence"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAGA,SAEEA,OAAO,EACPC,mBAAmB,EACnBC,cAAc,QACT,gBAAe;AAEtB,SAASC,iBAAiB,QAAQ,2BAAuB;AAqBzD,qFAAqF,GACrF,OAAO,MAAMC,+BAA+B,gCAA+B;AAE3E;;;;;;;;;;CAUC,GACD,OAAO,MAAMC,mCAAmCC;IAE9C,aAAc;QACZ,KAAK,CACH,yEACE,mEACA,uEACA,oEACA,uDAPGC,OAAOH;QASd,IAAI,CAACI,IAAI,GAAG;IACd;AACF;AAEA;;;CAGC,GACD,OAAO,SAASC,wBAAwBC,IAAU;IAChD,MAAMC,aAAaC,OAAOC,wBAAwB,CAACH,MAAM;IACzD,OACE,CAAC,CAACC,cAAcA,WAAWG,QAAQ,KAAK,SAASH,WAAWI,YAAY,KAAK;AAEjF;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,SAASC,gBAAgBN,IAAU;IACjC,yEAAyE;IACzE,2EAA2E;IAC3E,kEAAkE;IAClE,qEAAqE;IACrE,IAAID,wBAAwBC,OAAO,OAAOA;IAC1CE,OAAOK,cAAc,CAACP,MAAM,kBAAkB;QAC5CQ,OAAO;YACL,MAAM,IAAIb;QACZ;QACAS,UAAU;QACVC,cAAc;QACdI,YAAY;IACd;IACA,OAAOT;AACT;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASU,mBACdC,GAAgB,EAChBC,gBAAsC;IAEtC,IAAIA,qBAAqB,aAAa;QACpC,0EAA0E;QAC1E,0EAA0E;QAC1E,+DAA+D;QAC/D,OAAOnB,kBACLa,gBAAgBd,eAAemB,KAAK;YAAEE,aAAatB;QAAoB;IAE3E;IACA,0EAA0E;IAC1E,uEAAuE;IACvE,2EAA2E;IAC3E,+BAA+B;IAC/B,OAAOE,kBAAkBH,QAAQqB;AACnC"}