@aglyn/tenant-data-admin 1.0.0-beta.149 → 1.0.0-beta.150

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-data-admin",
3
- "version": "1.0.0-beta.149",
3
+ "version": "1.0.0-beta.150",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,11 +25,11 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.149",
29
- "@aglyn/shared-util-email": "1.0.0-beta.149",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.149",
31
- "@aglyn/shared-util-http": "1.0.0-beta.149",
32
- "@aglyn/shared-util-tools": "1.0.0-beta.149",
28
+ "@aglyn/aglyn": "1.0.0-beta.150",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.150",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.150",
31
+ "@aglyn/shared-util-http": "1.0.0-beta.150",
32
+ "@aglyn/shared-util-tools": "1.0.0-beta.150",
33
33
  "@msgpack/msgpack": "^3.1.3",
34
34
  "@swc/helpers": "0.5.23",
35
35
  "sharp": "^0.35.3",
@@ -64,7 +64,7 @@ export declare function isEmailVerified(decoded: DecodedIdToken): boolean;
64
64
  /** Staff impersonation session (AGL-357/AGL-480): token minted with an
65
65
  * `impersonatedBy` claim. Exempt from the email-verification gate. */
66
66
  export declare function isImpersonationSession(decoded: DecodedIdToken): boolean;
67
- export declare function verifyConsoleIdToken(idToken: string, checkRevoked?: boolean): Promise<DecodedIdToken>;
67
+ export declare function verifyConsoleIdToken(idToken: string): Promise<DecodedIdToken>;
68
68
  /** 403 sent to callers whose email address is not yet verified (AGL-479). */
69
69
  export declare function emailUnverifiedResponse(): Response;
70
70
  export { firebaseAdmin };
@@ -224,12 +224,33 @@ function revocationCheckedAuth(target) {
224
224
  if (prop === 'verifyIdToken') {
225
225
  return async (...args)=>{
226
226
  const verify = Reflect.get(auth, prop, auth);
227
- // Spread, never `(token, checkRevoked)`: passing an explicit
228
- // `undefined` where the caller passed nothing changes what the SDK
229
- // sees, and a wrapper that alters the call it forwards is not a
230
- // wrapper.
231
- const decoded = await verify.apply(auth, args);
232
- if (args[1] === true) return decoded;
227
+ /*
228
+ * ONE ARGUMENT, ALWAYS (AGL-3229).
229
+ *
230
+ * `checkRevoked` used to be forwarded, and a caller passing `true`
231
+ * then returned here with the SDK's own answer and skipped the
232
+ * check below. That is not the stricter call it reads as — it is
233
+ * the BROKEN one, and for exactly the reason `revocationPool`
234
+ * exists: firebase-admin's `checkRevoked` runs
235
+ * `this.getUser(sub)` on the handle that verified the token, which
236
+ * for an SSO account is the project pool the uid is not in. The
237
+ * lookup throws `auth/user-not-found`, `id-token-refusal.ts` reads
238
+ * that as a bad credential, and `POST /api/auth/session` answered
239
+ * `401 Unauthenticated` to every tenant user — so no SSO account
240
+ * could mint the shared cookie, the sign-out tombstone it replaces
241
+ * survived every sign-in, and the console re-asked for credentials
242
+ * on every load.
243
+ *
244
+ * So the flag is now STRIPPED rather than honoured: the check
245
+ * below is the check, it is tenant-aware, it is cached, and it
246
+ * raises the same `auth/id-token-revoked` / `auth/user-disabled`
247
+ * codes the SDK's does. A wrapper that alters the call it forwards
248
+ * needs a reason, and "the forwarded call cannot answer the
249
+ * question for half our accounts" is one. Passing `true` is
250
+ * harmless and redundant; it is no longer a way around this.
251
+ */ const decoded = await verify.apply(auth, [
252
+ args[0]
253
+ ]);
233
254
  // The pool the TOKEN belongs to, which is not always the pool that
234
255
  // verified it — see `revocationPool`. Never `receiver`: the lookup
235
256
  // must not re-enter this proxy.
@@ -317,13 +338,18 @@ export function isEmailVerified(decoded) {
317
338
  * `impersonatedBy` claim. Exempt from the email-verification gate. */ export function isImpersonationSession(decoded) {
318
339
  return typeof decoded['impersonatedBy'] === 'string';
319
340
  }
320
- export async function verifyConsoleIdToken(idToken, checkRevoked) {
341
+ export async function verifyConsoleIdToken(idToken) {
321
342
  // `firebaseAdmin.app().auth()`, not a bare `getAuth` (AGL-1881): the raw
322
343
  // SDK handle skips the revocation check, and this helper's whole promise is
323
344
  // that it verifies "exactly like `auth().verifyIdToken`" plus the email
324
345
  // gate. A door that reads as the STRICTER one while being the looser one is
325
346
  // the worst shape available.
326
- const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken, checkRevoked);
347
+ //
348
+ // It used to forward a `checkRevoked` parameter, and there is nothing left
349
+ // for that to mean (AGL-3229): the handle's own check is the check, so the
350
+ // flag could only ever have selected the SDK's project-pool one, which is
351
+ // the one an SSO account cannot pass. Nothing passed it.
352
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
327
353
  if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {
328
354
  throw new EmailNotVerifiedError();
329
355
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/firebase-admin.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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\nimport * as Aglyn from '@aglyn/aglyn/server'\n// Initializes the firebase-admin default app (cert credential, RTDB URL,\n// service account, AppCheck) on module load. `firestoreDatabaseId` is the\n// FIRESTORE_DATABASE_ID override (AGL-1490): unset → `(default)` as before;\n// set → every accessor targets the named database (disaster-recovery cutover,\n// see docs/DISASTER_RECOVERY.md).\nimport { firestoreDatabaseId } from '@aglyn/shared-util-fbserver'\nimport { decode, encode } from '@msgpack/msgpack'\nimport { type App, getApp } from 'firebase-admin/app'\nimport {\n type BaseAuth,\n type DecodedIdToken,\n getAuth,\n} from 'firebase-admin/auth'\nimport { assertIdTokenNotRevoked } from './token-revocation'\nimport { getDatabase } from 'firebase-admin/database'\nimport { getRemoteConfig } from 'firebase-admin/remote-config'\nimport {\n FieldPath,\n FieldValue,\n type FirestoreDataConverter,\n Timestamp,\n getFirestore,\n} from 'firebase-admin/firestore'\nimport { getStorage } from 'firebase-admin/storage'\n\nfunction compress(value: any) {\n return Buffer.from(encode(value))\n}\nfunction decompress(value: any) {\n return decode(value)\n}\n\nexport const hostConverter: FirestoreDataConverter<Aglyn.AglynHost> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n // data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynScreen\n },\n}\n\nexport const screenConverter: FirestoreDataConverter<Aglyn.AglynScreen> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynScreen\n },\n}\n\nexport const screenVersionConverter: FirestoreDataConverter<Aglyn.AglynScreenVersion> =\n {\n toFirestore(data) {\n if (data.elements) {\n data.nodes = data.elements\n delete data.elements\n }\n if (data['bundleId']) {\n data.pluginId = data['bundleId']\n delete data['bundleId']\n }\n if (data.nodes) data.nodes = compress(data.nodes) as any\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n if (data?.elements) {\n data.nodes = data.elements\n delete data.elements\n }\n if (data?.['bundleId']) {\n data.pluginId = data['bundleId']\n delete data['bundleId']\n }\n if (data?.nodes) {\n // Nodes saved while updateDoc bypassed the client converter are plain\n // maps rather than compressed bytes; decode only binary payloads.\n data.nodes = ArrayBuffer.isView(data.nodes)\n ? decompress(data.nodes)\n : data.nodes\n }\n data.$id = snapshot.id\n return data as Aglyn.AglynScreenVersion\n },\n }\n\nexport const layoutConverter: FirestoreDataConverter<Aglyn.AglynLayout> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynLayout\n },\n}\n\n// Layout versions persist nodes exactly like screen versions (compressed).\nexport const layoutVersionConverter =\n screenVersionConverter as unknown as FirestoreDataConverter<Aglyn.AglynLayoutVersion>\n\n/**\n * Compatibility facade replacing firebase-admin v14's removed namespace API\n * (`import * as admin from 'firebase-admin'`) so existing call sites\n * (`firebaseAdmin.app().firestore()`, `firebaseAdmin.firestore.FieldValue`,\n * `firebaseAdmin.database()`) keep working unchanged, backed internally by\n * the modular SDK.\n */\n/**\n * Auth wrapped so `verifyIdToken` also asks whether the account was revoked\n * (AGL-1881).\n *\n * WHY HERE, and not at the call sites: the audit found `checkRevoked` set on\n * 3 of 175 verifications, which is what a per-call-site opt-in converges to.\n * Every server door in this repo reaches auth through `firebaseAdmin.app()` —\n * all 117 console API routes, the marketplace/commerce/bookings plugin\n * servers, `authForPool`, `release-flags` — so this ONE function is where the\n * question can be asked once and be true everywhere, including on routes\n * written next year by someone who has never read AGL-1881.\n * `token-revocation.spec.ts` pins that the wrapping is real; the guard in\n * `token-revocation-coverage.spec.ts` pins that no future door bypasses it.\n *\n * A Proxy rather than a hand-written facade because `BaseAuth` has ~40\n * methods and a facade that listed 39 of them would silently drop the\n * fortieth. Only `verifyIdToken` and `tenantManager` are intercepted;\n * everything else is the SDK's own method, bound to the SDK's own instance.\n *\n * `tenantManager` is wrapped recursively because a GCIP tenant's\n * `TenantAwareAuth` is a DIFFERENT auth object with its own `verifyIdToken`,\n * and five console routes verify SSO tokens through it. Unwrapped, those five\n * would be exactly the holes this exists to close.\n *\n * An explicit `checkRevoked: true` is left alone: firebase-admin then runs the\n * same three checks itself, and doing both would buy one answer with two\n * round trips.\n */\n/**\n * The pool to ask about a verified token's account (AGL-2486).\n *\n * ## The bug this exists to fix\n *\n * `BaseAuth.verifyIdToken` does NOT reject a token minted in a GCIP tenant.\n * Only `TenantAwareAuth` overrides it to compare `firebase.tenant` against\n * its own id; the project-level handle checks a signature, an issuer and an\n * audience, all of which are project-wide. So an SSO user's ID token verifies\n * perfectly through `firebaseAdmin.app().auth()` — and every console API\n * route verifies exactly that way.\n *\n * The revocation check then ran `getUser(uid)` on that same project-level\n * handle. A tenant uid is not in the project pool: measured on production\n * 2026-08-22, a staff account in `aglyn-org-y5v14` verified through the\n * project handle, and a project-level `getUser` of the uid that token carries\n * threw `auth/user-not-found`. `assertIdTokenNotRevoked` reads \"not found\" as\n * \"account deleted\" — correctly, and fail-CLOSED by design — and threw\n * `auth/id-token-revoked`. Every SSO user was therefore refused at every door\n * that verifies a Bearer token, with whatever the route's catch-all produced;\n * on `/api/presence/token` that was a 500 and live co-editing simply never\n * started for them.\n *\n * ## Why the existing guard did not catch it\n *\n * `no-project-level-auth-lookup.spec.ts` reads the RECEIVER NAME of a\n * `.getUser(` call, and `assertIdTokenNotRevoked` names its parameter `pool`\n * precisely so that it passes. The name was honest about the contract and\n * said nothing about the ARGUMENT, which was the project-level handle at the\n * only call site there is. A guard that reads a name proves a name.\n *\n * ## What this does\n *\n * Sends the lookup to the pool named by the token's own `firebase.tenant`\n * claim, so the contract `assertIdTokenNotRevoked` documents (\"the lookup is\n * asked of the pool the token belongs to\") becomes structurally true instead\n * of being an assumption about the caller. One place, so a route written next\n * year inherits it — the same reasoning that put the revocation check here.\n *\n * Returns the handle unchanged for a project-pool token and for an auth\n * already scoped to that same tenant.\n *\n * Returns NULL when the token names a tenant this handle cannot reach — and\n * the caller then SKIPS the check rather than asking the wrong pool. That is\n * the whole lesson of this bug: \"not found in the pool I happened to ask\" is\n * not evidence that an account was deleted, it is evidence that the question\n * went to the wrong place, and `assertIdTokenNotRevoked` already models \"we\n * could not ask\" as fail-open. Falling back to the verifying handle would\n * re-create the exact outage in the one configuration where routing fails.\n * Unreachable in practice — the project-level `Auth` always has a\n * `tenantManager`, and a `TenantAwareAuth` holding a token for a DIFFERENT\n * tenant has already thrown `auth/mismatching-tenant-id` before this runs.\n *\n * The tenant handle is deliberately NOT wrapped in `revocationCheckedAuth`:\n * it is used only for `getUser`, and re-wrapping would put the revocation\n * check inside the revocation check.\n */\nfunction revocationPool(\n auth: object,\n decoded: DecodedIdToken,\n): Pick<BaseAuth, 'getUser'> | null {\n const self = auth as unknown as Pick<BaseAuth, 'getUser'>\n const tenantId = decoded?.firebase?.tenant\n // `typeof`, not a truthy test: `strictNullChecks` is off repo-wide, so a\n // bare `!tenantId` narrows nothing and would also swallow a real id.\n if (typeof tenantId !== 'string' || !tenantId) return self\n // Already the right pool — a route that verified through\n // `authForPool(tenantId)` has nothing to switch to, and `TenantAwareAuth`\n // has no `tenantManager` to ask anyway.\n if ((auth as { tenantId?: string }).tenantId === tenantId) return self\n const tenantManager = (auth as { tenantManager?: () => unknown })\n .tenantManager\n if (typeof tenantManager !== 'function') return null\n try {\n const manager = tenantManager.call(auth) as {\n authForTenant: (id: string) => Pick<BaseAuth, 'getUser'>\n }\n return manager.authForTenant(tenantId)\n } catch {\n return null\n }\n}\n\nfunction revocationCheckedAuth<T extends object>(target: T): T {\n return new Proxy(target, {\n get(auth, prop, receiver) {\n if (prop === 'verifyIdToken') {\n return async (...args: unknown[]) => {\n const verify = Reflect.get(auth, prop, auth) as (\n ...a: unknown[]\n ) => Promise<DecodedIdToken>\n // Spread, never `(token, checkRevoked)`: passing an explicit\n // `undefined` where the caller passed nothing changes what the SDK\n // sees, and a wrapper that alters the call it forwards is not a\n // wrapper.\n const decoded = await verify.apply(auth, args)\n if (args[1] === true) return decoded\n // The pool the TOKEN belongs to, which is not always the pool that\n // verified it — see `revocationPool`. Never `receiver`: the lookup\n // must not re-enter this proxy.\n const pool = revocationPool(auth, decoded)\n // `=== null`, not `!pool`: `strictNullChecks` is off repo-wide, so\n // a falsy test narrows nothing here.\n if (pool === null) {\n console.warn(\n '[auth] revocation check skipped: no handle for tenant',\n decoded?.firebase?.tenant,\n )\n return decoded\n }\n await assertIdTokenNotRevoked(pool, decoded)\n return decoded\n }\n }\n if (prop === 'tenantManager') {\n return (...args: unknown[]) => {\n const manager = (\n Reflect.get(auth, prop, auth) as (...a: unknown[]) => object\n ).apply(auth, args)\n return new Proxy(manager, {\n get(mgr, key, mgrReceiver) {\n if (key === 'authForTenant') {\n return (...tenantArgs: unknown[]) =>\n revocationCheckedAuth(\n (\n Reflect.get(mgr, key, mgr) as (...a: unknown[]) => object\n ).apply(mgr, tenantArgs),\n )\n }\n const value = Reflect.get(mgr, key, mgrReceiver)\n return typeof value === 'function' ? value.bind(mgr) : value\n },\n })\n }\n }\n const value = Reflect.get(auth, prop, receiver)\n // Bound to the SDK instance: firebase-admin's Auth keeps private state\n // on `this`, and an unbound method handed out through a Proxy loses it.\n return typeof value === 'function' ? value.bind(auth) : value\n },\n })\n}\n\nfunction wrapApp(app: App) {\n return {\n firestore: () => getFirestore(app, firestoreDatabaseId()),\n auth: () => revocationCheckedAuth(getAuth(app)),\n storage: () => getStorage(app),\n // Release-flag management (AGL-230): the staff admin flags API reads\n // and publishes the Remote Config template server-side.\n remoteConfig: () => getRemoteConfig(app),\n }\n}\n\nfunction firestoreNamespace() {\n return getFirestore(getApp(), firestoreDatabaseId())\n}\nfirestoreNamespace.FieldValue = FieldValue\nfirestoreNamespace.Timestamp = Timestamp\nfirestoreNamespace.FieldPath = FieldPath\n\nconst firebaseAdmin = {\n app: (name?: string) => wrapApp(name ? getApp(name) : getApp()),\n firestore: firestoreNamespace,\n database: () => getDatabase(getApp()),\n}\n\n/**\n * Email-verification gate (AGL-479). Email/password accounts must verify\n * their address before any console access; OAuth accounts arrive with\n * `email_verified: true`, so they pass untouched. `verifyConsoleIdToken`\n * verifies the ID token exactly like `auth().verifyIdToken` and additionally\n * throws `EmailNotVerifiedError` when the address is unverified — verified\n * callers see identical behavior. Route handlers pair the raw verify with\n * `emailUnverifiedResponse()` (a 403) so the denial stays distinct from an\n * invalid-token 401. Fails closed: a token with no `email_verified` claim\n * (e.g. some custom-token sign-ins) is treated as unverified.\n *\n * Exception (AGL-480): staff impersonation sessions carry an `impersonatedBy`\n * claim (minted by /api/admin/impersonate). Staff have already authenticated\n * and the act is audited, so the impersonated account's own verification\n * state must not gate the support session — otherwise staff can't reach a\n * brand-new, still-unverified owner, the exact account most likely to need\n * help. `isImpersonationSession` gates that exemption.\n */\nexport class EmailNotVerifiedError extends Error {\n readonly code = 'auth/email-not-verified'\n constructor() {\n super('Email address not verified')\n this.name = 'EmailNotVerifiedError'\n }\n}\n\nexport function isEmailVerified(decoded: DecodedIdToken): boolean {\n return decoded.email_verified === true\n}\n\n/** Staff impersonation session (AGL-357/AGL-480): token minted with an\n * `impersonatedBy` claim. Exempt from the email-verification gate. */\nexport function isImpersonationSession(decoded: DecodedIdToken): boolean {\n return typeof decoded['impersonatedBy'] === 'string'\n}\n\nexport async function verifyConsoleIdToken(\n idToken: string,\n checkRevoked?: boolean,\n): Promise<DecodedIdToken> {\n // `firebaseAdmin.app().auth()`, not a bare `getAuth` (AGL-1881): the raw\n // SDK handle skips the revocation check, and this helper's whole promise is\n // that it verifies \"exactly like `auth().verifyIdToken`\" plus the email\n // gate. A door that reads as the STRICTER one while being the looser one is\n // the worst shape available.\n const decoded = await firebaseAdmin\n .app()\n .auth()\n .verifyIdToken(idToken, checkRevoked)\n if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {\n throw new EmailNotVerifiedError()\n }\n return decoded\n}\n\n/** 403 sent to callers whose email address is not yet verified (AGL-479). */\nexport function emailUnverifiedResponse(): Response {\n return Response.json(\n { error: 'Verify your email to continue', reason: 'email-unverified' },\n { status: 403 },\n )\n}\n\nexport { firebaseAdmin }\nexport default firebaseAdmin\n"],"names":["firestoreDatabaseId","decode","encode","getApp","getAuth","assertIdTokenNotRevoked","getDatabase","getRemoteConfig","FieldPath","FieldValue","Timestamp","getFirestore","getStorage","compress","value","Buffer","from","decompress","hostConverter","toFirestore","data","$id","fromFirestore","snapshot","exists","undefined","id","screenConverter","updatedAt","now","screenVersionConverter","elements","nodes","pluginId","ArrayBuffer","isView","layoutConverter","layoutVersionConverter","revocationPool","auth","decoded","self","tenantId","firebase","tenant","tenantManager","manager","call","authForTenant","revocationCheckedAuth","target","Proxy","get","prop","receiver","args","verify","Reflect","apply","pool","console","warn","mgr","key","mgrReceiver","tenantArgs","bind","wrapApp","app","firestore","storage","remoteConfig","firestoreNamespace","firebaseAdmin","name","database","EmailNotVerifiedError","Error","code","isEmailVerified","email_verified","isImpersonationSession","verifyConsoleIdToken","idToken","checkRevoked","verifyIdToken","emailUnverifiedResponse","Response","json","error","reason","status"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/firebase-admin.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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\nimport * as Aglyn from '@aglyn/aglyn/server'\n// Initializes the firebase-admin default app (cert credential, RTDB URL,\n// service account, AppCheck) on module load. `firestoreDatabaseId` is the\n// FIRESTORE_DATABASE_ID override (AGL-1490): unset → `(default)` as before;\n// set → every accessor targets the named database (disaster-recovery cutover,\n// see docs/DISASTER_RECOVERY.md).\nimport { firestoreDatabaseId } from '@aglyn/shared-util-fbserver'\nimport { decode, encode } from '@msgpack/msgpack'\nimport { type App, getApp } from 'firebase-admin/app'\nimport {\n type BaseAuth,\n type DecodedIdToken,\n getAuth,\n} from 'firebase-admin/auth'\nimport { assertIdTokenNotRevoked } from './token-revocation'\nimport { getDatabase } from 'firebase-admin/database'\nimport { getRemoteConfig } from 'firebase-admin/remote-config'\nimport {\n FieldPath,\n FieldValue,\n type FirestoreDataConverter,\n Timestamp,\n getFirestore,\n} from 'firebase-admin/firestore'\nimport { getStorage } from 'firebase-admin/storage'\n\nfunction compress(value: any) {\n return Buffer.from(encode(value))\n}\nfunction decompress(value: any) {\n return decode(value)\n}\n\nexport const hostConverter: FirestoreDataConverter<Aglyn.AglynHost> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n // data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynScreen\n },\n}\n\nexport const screenConverter: FirestoreDataConverter<Aglyn.AglynScreen> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynScreen\n },\n}\n\nexport const screenVersionConverter: FirestoreDataConverter<Aglyn.AglynScreenVersion> =\n {\n toFirestore(data) {\n if (data.elements) {\n data.nodes = data.elements\n delete data.elements\n }\n if (data['bundleId']) {\n data.pluginId = data['bundleId']\n delete data['bundleId']\n }\n if (data.nodes) data.nodes = compress(data.nodes) as any\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n if (data?.elements) {\n data.nodes = data.elements\n delete data.elements\n }\n if (data?.['bundleId']) {\n data.pluginId = data['bundleId']\n delete data['bundleId']\n }\n if (data?.nodes) {\n // Nodes saved while updateDoc bypassed the client converter are plain\n // maps rather than compressed bytes; decode only binary payloads.\n data.nodes = ArrayBuffer.isView(data.nodes)\n ? decompress(data.nodes)\n : data.nodes\n }\n data.$id = snapshot.id\n return data as Aglyn.AglynScreenVersion\n },\n }\n\nexport const layoutConverter: FirestoreDataConverter<Aglyn.AglynLayout> = {\n toFirestore(data) {\n if (data.$id) delete data.$id\n data.updatedAt = Timestamp.now()\n return data\n },\n fromFirestore(snapshot) {\n if (!snapshot.exists) return undefined\n const data = snapshot.data()\n data.$id = snapshot.id\n return data as Aglyn.AglynLayout\n },\n}\n\n// Layout versions persist nodes exactly like screen versions (compressed).\nexport const layoutVersionConverter =\n screenVersionConverter as unknown as FirestoreDataConverter<Aglyn.AglynLayoutVersion>\n\n/**\n * Compatibility facade replacing firebase-admin v14's removed namespace API\n * (`import * as admin from 'firebase-admin'`) so existing call sites\n * (`firebaseAdmin.app().firestore()`, `firebaseAdmin.firestore.FieldValue`,\n * `firebaseAdmin.database()`) keep working unchanged, backed internally by\n * the modular SDK.\n */\n/**\n * Auth wrapped so `verifyIdToken` also asks whether the account was revoked\n * (AGL-1881).\n *\n * WHY HERE, and not at the call sites: the audit found `checkRevoked` set on\n * 3 of 175 verifications, which is what a per-call-site opt-in converges to.\n * Every server door in this repo reaches auth through `firebaseAdmin.app()` —\n * all 117 console API routes, the marketplace/commerce/bookings plugin\n * servers, `authForPool`, `release-flags` — so this ONE function is where the\n * question can be asked once and be true everywhere, including on routes\n * written next year by someone who has never read AGL-1881.\n * `token-revocation.spec.ts` pins that the wrapping is real; the guard in\n * `token-revocation-coverage.spec.ts` pins that no future door bypasses it.\n *\n * A Proxy rather than a hand-written facade because `BaseAuth` has ~40\n * methods and a facade that listed 39 of them would silently drop the\n * fortieth. Only `verifyIdToken` and `tenantManager` are intercepted;\n * everything else is the SDK's own method, bound to the SDK's own instance.\n *\n * `tenantManager` is wrapped recursively because a GCIP tenant's\n * `TenantAwareAuth` is a DIFFERENT auth object with its own `verifyIdToken`,\n * and five console routes verify SSO tokens through it. Unwrapped, those five\n * would be exactly the holes this exists to close.\n *\n * An explicit `checkRevoked: true` is left alone: firebase-admin then runs the\n * same three checks itself, and doing both would buy one answer with two\n * round trips.\n */\n/**\n * The pool to ask about a verified token's account (AGL-2486).\n *\n * ## The bug this exists to fix\n *\n * `BaseAuth.verifyIdToken` does NOT reject a token minted in a GCIP tenant.\n * Only `TenantAwareAuth` overrides it to compare `firebase.tenant` against\n * its own id; the project-level handle checks a signature, an issuer and an\n * audience, all of which are project-wide. So an SSO user's ID token verifies\n * perfectly through `firebaseAdmin.app().auth()` — and every console API\n * route verifies exactly that way.\n *\n * The revocation check then ran `getUser(uid)` on that same project-level\n * handle. A tenant uid is not in the project pool: measured on production\n * 2026-08-22, a staff account in `aglyn-org-y5v14` verified through the\n * project handle, and a project-level `getUser` of the uid that token carries\n * threw `auth/user-not-found`. `assertIdTokenNotRevoked` reads \"not found\" as\n * \"account deleted\" — correctly, and fail-CLOSED by design — and threw\n * `auth/id-token-revoked`. Every SSO user was therefore refused at every door\n * that verifies a Bearer token, with whatever the route's catch-all produced;\n * on `/api/presence/token` that was a 500 and live co-editing simply never\n * started for them.\n *\n * ## Why the existing guard did not catch it\n *\n * `no-project-level-auth-lookup.spec.ts` reads the RECEIVER NAME of a\n * `.getUser(` call, and `assertIdTokenNotRevoked` names its parameter `pool`\n * precisely so that it passes. The name was honest about the contract and\n * said nothing about the ARGUMENT, which was the project-level handle at the\n * only call site there is. A guard that reads a name proves a name.\n *\n * ## What this does\n *\n * Sends the lookup to the pool named by the token's own `firebase.tenant`\n * claim, so the contract `assertIdTokenNotRevoked` documents (\"the lookup is\n * asked of the pool the token belongs to\") becomes structurally true instead\n * of being an assumption about the caller. One place, so a route written next\n * year inherits it — the same reasoning that put the revocation check here.\n *\n * Returns the handle unchanged for a project-pool token and for an auth\n * already scoped to that same tenant.\n *\n * Returns NULL when the token names a tenant this handle cannot reach — and\n * the caller then SKIPS the check rather than asking the wrong pool. That is\n * the whole lesson of this bug: \"not found in the pool I happened to ask\" is\n * not evidence that an account was deleted, it is evidence that the question\n * went to the wrong place, and `assertIdTokenNotRevoked` already models \"we\n * could not ask\" as fail-open. Falling back to the verifying handle would\n * re-create the exact outage in the one configuration where routing fails.\n * Unreachable in practice — the project-level `Auth` always has a\n * `tenantManager`, and a `TenantAwareAuth` holding a token for a DIFFERENT\n * tenant has already thrown `auth/mismatching-tenant-id` before this runs.\n *\n * The tenant handle is deliberately NOT wrapped in `revocationCheckedAuth`:\n * it is used only for `getUser`, and re-wrapping would put the revocation\n * check inside the revocation check.\n */\nfunction revocationPool(\n auth: object,\n decoded: DecodedIdToken,\n): Pick<BaseAuth, 'getUser'> | null {\n const self = auth as unknown as Pick<BaseAuth, 'getUser'>\n const tenantId = decoded?.firebase?.tenant\n // `typeof`, not a truthy test: `strictNullChecks` is off repo-wide, so a\n // bare `!tenantId` narrows nothing and would also swallow a real id.\n if (typeof tenantId !== 'string' || !tenantId) return self\n // Already the right pool — a route that verified through\n // `authForPool(tenantId)` has nothing to switch to, and `TenantAwareAuth`\n // has no `tenantManager` to ask anyway.\n if ((auth as { tenantId?: string }).tenantId === tenantId) return self\n const tenantManager = (auth as { tenantManager?: () => unknown })\n .tenantManager\n if (typeof tenantManager !== 'function') return null\n try {\n const manager = tenantManager.call(auth) as {\n authForTenant: (id: string) => Pick<BaseAuth, 'getUser'>\n }\n return manager.authForTenant(tenantId)\n } catch {\n return null\n }\n}\n\nfunction revocationCheckedAuth<T extends object>(target: T): T {\n return new Proxy(target, {\n get(auth, prop, receiver) {\n if (prop === 'verifyIdToken') {\n return async (...args: unknown[]) => {\n const verify = Reflect.get(auth, prop, auth) as (\n ...a: unknown[]\n ) => Promise<DecodedIdToken>\n /*\n * ONE ARGUMENT, ALWAYS (AGL-3229).\n *\n * `checkRevoked` used to be forwarded, and a caller passing `true`\n * then returned here with the SDK's own answer and skipped the\n * check below. That is not the stricter call it reads as — it is\n * the BROKEN one, and for exactly the reason `revocationPool`\n * exists: firebase-admin's `checkRevoked` runs\n * `this.getUser(sub)` on the handle that verified the token, which\n * for an SSO account is the project pool the uid is not in. The\n * lookup throws `auth/user-not-found`, `id-token-refusal.ts` reads\n * that as a bad credential, and `POST /api/auth/session` answered\n * `401 Unauthenticated` to every tenant user — so no SSO account\n * could mint the shared cookie, the sign-out tombstone it replaces\n * survived every sign-in, and the console re-asked for credentials\n * on every load.\n *\n * So the flag is now STRIPPED rather than honoured: the check\n * below is the check, it is tenant-aware, it is cached, and it\n * raises the same `auth/id-token-revoked` / `auth/user-disabled`\n * codes the SDK's does. A wrapper that alters the call it forwards\n * needs a reason, and \"the forwarded call cannot answer the\n * question for half our accounts\" is one. Passing `true` is\n * harmless and redundant; it is no longer a way around this.\n */\n const decoded = await verify.apply(auth, [args[0]])\n // The pool the TOKEN belongs to, which is not always the pool that\n // verified it — see `revocationPool`. Never `receiver`: the lookup\n // must not re-enter this proxy.\n const pool = revocationPool(auth, decoded)\n // `=== null`, not `!pool`: `strictNullChecks` is off repo-wide, so\n // a falsy test narrows nothing here.\n if (pool === null) {\n console.warn(\n '[auth] revocation check skipped: no handle for tenant',\n decoded?.firebase?.tenant,\n )\n return decoded\n }\n await assertIdTokenNotRevoked(pool, decoded)\n return decoded\n }\n }\n if (prop === 'tenantManager') {\n return (...args: unknown[]) => {\n const manager = (\n Reflect.get(auth, prop, auth) as (...a: unknown[]) => object\n ).apply(auth, args)\n return new Proxy(manager, {\n get(mgr, key, mgrReceiver) {\n if (key === 'authForTenant') {\n return (...tenantArgs: unknown[]) =>\n revocationCheckedAuth(\n (\n Reflect.get(mgr, key, mgr) as (...a: unknown[]) => object\n ).apply(mgr, tenantArgs),\n )\n }\n const value = Reflect.get(mgr, key, mgrReceiver)\n return typeof value === 'function' ? value.bind(mgr) : value\n },\n })\n }\n }\n const value = Reflect.get(auth, prop, receiver)\n // Bound to the SDK instance: firebase-admin's Auth keeps private state\n // on `this`, and an unbound method handed out through a Proxy loses it.\n return typeof value === 'function' ? value.bind(auth) : value\n },\n })\n}\n\nfunction wrapApp(app: App) {\n return {\n firestore: () => getFirestore(app, firestoreDatabaseId()),\n auth: () => revocationCheckedAuth(getAuth(app)),\n storage: () => getStorage(app),\n // Release-flag management (AGL-230): the staff admin flags API reads\n // and publishes the Remote Config template server-side.\n remoteConfig: () => getRemoteConfig(app),\n }\n}\n\nfunction firestoreNamespace() {\n return getFirestore(getApp(), firestoreDatabaseId())\n}\nfirestoreNamespace.FieldValue = FieldValue\nfirestoreNamespace.Timestamp = Timestamp\nfirestoreNamespace.FieldPath = FieldPath\n\nconst firebaseAdmin = {\n app: (name?: string) => wrapApp(name ? getApp(name) : getApp()),\n firestore: firestoreNamespace,\n database: () => getDatabase(getApp()),\n}\n\n/**\n * Email-verification gate (AGL-479). Email/password accounts must verify\n * their address before any console access; OAuth accounts arrive with\n * `email_verified: true`, so they pass untouched. `verifyConsoleIdToken`\n * verifies the ID token exactly like `auth().verifyIdToken` and additionally\n * throws `EmailNotVerifiedError` when the address is unverified — verified\n * callers see identical behavior. Route handlers pair the raw verify with\n * `emailUnverifiedResponse()` (a 403) so the denial stays distinct from an\n * invalid-token 401. Fails closed: a token with no `email_verified` claim\n * (e.g. some custom-token sign-ins) is treated as unverified.\n *\n * Exception (AGL-480): staff impersonation sessions carry an `impersonatedBy`\n * claim (minted by /api/admin/impersonate). Staff have already authenticated\n * and the act is audited, so the impersonated account's own verification\n * state must not gate the support session — otherwise staff can't reach a\n * brand-new, still-unverified owner, the exact account most likely to need\n * help. `isImpersonationSession` gates that exemption.\n */\nexport class EmailNotVerifiedError extends Error {\n readonly code = 'auth/email-not-verified'\n constructor() {\n super('Email address not verified')\n this.name = 'EmailNotVerifiedError'\n }\n}\n\nexport function isEmailVerified(decoded: DecodedIdToken): boolean {\n return decoded.email_verified === true\n}\n\n/** Staff impersonation session (AGL-357/AGL-480): token minted with an\n * `impersonatedBy` claim. Exempt from the email-verification gate. */\nexport function isImpersonationSession(decoded: DecodedIdToken): boolean {\n return typeof decoded['impersonatedBy'] === 'string'\n}\n\nexport async function verifyConsoleIdToken(\n idToken: string,\n): Promise<DecodedIdToken> {\n // `firebaseAdmin.app().auth()`, not a bare `getAuth` (AGL-1881): the raw\n // SDK handle skips the revocation check, and this helper's whole promise is\n // that it verifies \"exactly like `auth().verifyIdToken`\" plus the email\n // gate. A door that reads as the STRICTER one while being the looser one is\n // the worst shape available.\n //\n // It used to forward a `checkRevoked` parameter, and there is nothing left\n // for that to mean (AGL-3229): the handle's own check is the check, so the\n // flag could only ever have selected the SDK's project-pool one, which is\n // the one an SSO account cannot pass. Nothing passed it.\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n if (!isEmailVerified(decoded) && !isImpersonationSession(decoded)) {\n throw new EmailNotVerifiedError()\n }\n return decoded\n}\n\n/** 403 sent to callers whose email address is not yet verified (AGL-479). */\nexport function emailUnverifiedResponse(): Response {\n return Response.json(\n { error: 'Verify your email to continue', reason: 'email-unverified' },\n { status: 403 },\n )\n}\n\nexport { firebaseAdmin }\nexport default firebaseAdmin\n"],"names":["firestoreDatabaseId","decode","encode","getApp","getAuth","assertIdTokenNotRevoked","getDatabase","getRemoteConfig","FieldPath","FieldValue","Timestamp","getFirestore","getStorage","compress","value","Buffer","from","decompress","hostConverter","toFirestore","data","$id","fromFirestore","snapshot","exists","undefined","id","screenConverter","updatedAt","now","screenVersionConverter","elements","nodes","pluginId","ArrayBuffer","isView","layoutConverter","layoutVersionConverter","revocationPool","auth","decoded","self","tenantId","firebase","tenant","tenantManager","manager","call","authForTenant","revocationCheckedAuth","target","Proxy","get","prop","receiver","args","verify","Reflect","apply","pool","console","warn","mgr","key","mgrReceiver","tenantArgs","bind","wrapApp","app","firestore","storage","remoteConfig","firestoreNamespace","firebaseAdmin","name","database","EmailNotVerifiedError","Error","code","isEmailVerified","email_verified","isImpersonationSession","verifyConsoleIdToken","idToken","verifyIdToken","emailUnverifiedResponse","Response","json","error","reason","status"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,yEAAyE;AACzE,0EAA0E;AAC1E,4EAA4E;AAC5E,8EAA8E;AAC9E,kCAAkC;AAClC,SAASA,mBAAmB,QAAQ,8BAA6B;AACjE,SAASC,MAAM,EAAEC,MAAM,QAAQ,mBAAkB;AACjD,SAAmBC,MAAM,QAAQ,qBAAoB;AACrD,SAGEC,OAAO,QACF,sBAAqB;AAC5B,SAASC,uBAAuB,QAAQ,wBAAoB;AAC5D,SAASC,WAAW,QAAQ,0BAAyB;AACrD,SAASC,eAAe,QAAQ,+BAA8B;AAC9D,SACEC,SAAS,EACTC,UAAU,EAEVC,SAAS,EACTC,YAAY,QACP,2BAA0B;AACjC,SAASC,UAAU,QAAQ,yBAAwB;AAEnD,SAASC,SAASC,KAAU;IAC1B,OAAOC,OAAOC,IAAI,CAACd,OAAOY;AAC5B;AACA,SAASG,WAAWH,KAAU;IAC5B,OAAOb,OAAOa;AAChB;AAEA,OAAO,MAAMI,gBAAyD;IACpEC,aAAYC,IAAI;QACd,IAAIA,KAAKC,GAAG,EAAE,OAAOD,KAAKC,GAAG;QAC7B,mCAAmC;QACnC,OAAOD;IACT;IACAE,eAAcC,QAAQ;QACpB,IAAI,CAACA,SAASC,MAAM,EAAE,OAAOC;QAC7B,MAAML,OAAOG,SAASH,IAAI;QAC1BA,KAAKC,GAAG,GAAGE,SAASG,EAAE;QACtB,OAAON;IACT;AACF,EAAC;AAED,OAAO,MAAMO,kBAA6D;IACxER,aAAYC,IAAI;QACd,IAAIA,KAAKC,GAAG,EAAE,OAAOD,KAAKC,GAAG;QAC7BD,KAAKQ,SAAS,GAAGlB,UAAUmB,GAAG;QAC9B,OAAOT;IACT;IACAE,eAAcC,QAAQ;QACpB,IAAI,CAACA,SAASC,MAAM,EAAE,OAAOC;QAC7B,MAAML,OAAOG,SAASH,IAAI;QAC1BA,KAAKC,GAAG,GAAGE,SAASG,EAAE;QACtB,OAAON;IACT;AACF,EAAC;AAED,OAAO,MAAMU,yBACX;IACEX,aAAYC,IAAI;QACd,IAAIA,KAAKW,QAAQ,EAAE;YACjBX,KAAKY,KAAK,GAAGZ,KAAKW,QAAQ;YAC1B,OAAOX,KAAKW,QAAQ;QACtB;QACA,IAAIX,IAAI,CAAC,WAAW,EAAE;YACpBA,KAAKa,QAAQ,GAAGb,IAAI,CAAC,WAAW;YAChC,OAAOA,IAAI,CAAC,WAAW;QACzB;QACA,IAAIA,KAAKY,KAAK,EAAEZ,KAAKY,KAAK,GAAGnB,SAASO,KAAKY,KAAK;QAChD,IAAIZ,KAAKC,GAAG,EAAE,OAAOD,KAAKC,GAAG;QAC7BD,KAAKQ,SAAS,GAAGlB,UAAUmB,GAAG;QAC9B,OAAOT;IACT;IACAE,eAAcC,QAAQ;QACpB,IAAI,CAACA,SAASC,MAAM,EAAE,OAAOC;QAC7B,MAAML,OAAOG,SAASH,IAAI;QAC1B,IAAIA,wBAAAA,KAAMW,QAAQ,EAAE;YAClBX,KAAKY,KAAK,GAAGZ,KAAKW,QAAQ;YAC1B,OAAOX,KAAKW,QAAQ;QACtB;QACA,IAAIX,wBAAAA,IAAM,CAAC,WAAW,EAAE;YACtBA,KAAKa,QAAQ,GAAGb,IAAI,CAAC,WAAW;YAChC,OAAOA,IAAI,CAAC,WAAW;QACzB;QACA,IAAIA,wBAAAA,KAAMY,KAAK,EAAE;YACf,sEAAsE;YACtE,kEAAkE;YAClEZ,KAAKY,KAAK,GAAGE,YAAYC,MAAM,CAACf,KAAKY,KAAK,IACtCf,WAAWG,KAAKY,KAAK,IACrBZ,KAAKY,KAAK;QAChB;QACAZ,KAAKC,GAAG,GAAGE,SAASG,EAAE;QACtB,OAAON;IACT;AACF,EAAC;AAEH,OAAO,MAAMgB,kBAA6D;IACxEjB,aAAYC,IAAI;QACd,IAAIA,KAAKC,GAAG,EAAE,OAAOD,KAAKC,GAAG;QAC7BD,KAAKQ,SAAS,GAAGlB,UAAUmB,GAAG;QAC9B,OAAOT;IACT;IACAE,eAAcC,QAAQ;QACpB,IAAI,CAACA,SAASC,MAAM,EAAE,OAAOC;QAC7B,MAAML,OAAOG,SAASH,IAAI;QAC1BA,KAAKC,GAAG,GAAGE,SAASG,EAAE;QACtB,OAAON;IACT;AACF,EAAC;AAED,2EAA2E;AAC3E,OAAO,MAAMiB,yBACXP,uBAAqF;AAEvF;;;;;;CAMC,GACD;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GACD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwDC,GACD,SAASQ,eACPC,IAAY,EACZC,OAAuB;QAGNA;IADjB,MAAMC,OAAOF;IACb,MAAMG,WAAWF,4BAAAA,oBAAAA,QAASG,QAAQ,qBAAjBH,kBAAmBI,MAAM;IAC1C,yEAAyE;IACzE,qEAAqE;IACrE,IAAI,OAAOF,aAAa,YAAY,CAACA,UAAU,OAAOD;IACtD,yDAAyD;IACzD,0EAA0E;IAC1E,wCAAwC;IACxC,IAAI,AAACF,KAA+BG,QAAQ,KAAKA,UAAU,OAAOD;IAClE,MAAMI,gBAAgB,AAACN,KACpBM,aAAa;IAChB,IAAI,OAAOA,kBAAkB,YAAY,OAAO;IAChD,IAAI;QACF,MAAMC,UAAUD,cAAcE,IAAI,CAACR;QAGnC,OAAOO,QAAQE,aAAa,CAACN;IAC/B,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA,SAASO,sBAAwCC,MAAS;IACxD,OAAO,IAAIC,MAAMD,QAAQ;QACvBE,KAAIb,IAAI,EAAEc,IAAI,EAAEC,QAAQ;YACtB,IAAID,SAAS,iBAAiB;gBAC5B,OAAO,OAAO,GAAGE;oBACf,MAAMC,SAASC,QAAQL,GAAG,CAACb,MAAMc,MAAMd;oBAGvC;;;;;;;;;;;;;;;;;;;;;;;;WAwBC,GACD,MAAMC,UAAU,MAAMgB,OAAOE,KAAK,CAACnB,MAAM;wBAACgB,IAAI,CAAC,EAAE;qBAAC;oBAClD,mEAAmE;oBACnE,mEAAmE;oBACnE,gCAAgC;oBAChC,MAAMI,OAAOrB,eAAeC,MAAMC;oBAClC,mEAAmE;oBACnE,qCAAqC;oBACrC,IAAImB,SAAS,MAAM;4BAGfnB;wBAFFoB,QAAQC,IAAI,CACV,yDACArB,4BAAAA,oBAAAA,QAASG,QAAQ,qBAAjBH,kBAAmBI,MAAM;wBAE3B,OAAOJ;oBACT;oBACA,MAAMnC,wBAAwBsD,MAAMnB;oBACpC,OAAOA;gBACT;YACF;YACA,IAAIa,SAAS,iBAAiB;gBAC5B,OAAO,CAAC,GAAGE;oBACT,MAAMT,UAAU,AACdW,QAAQL,GAAG,CAACb,MAAMc,MAAMd,MACxBmB,KAAK,CAACnB,MAAMgB;oBACd,OAAO,IAAIJ,MAAML,SAAS;wBACxBM,KAAIU,GAAG,EAAEC,GAAG,EAAEC,WAAW;4BACvB,IAAID,QAAQ,iBAAiB;gCAC3B,OAAO,CAAC,GAAGE,aACThB,sBACE,AACEQ,QAAQL,GAAG,CAACU,KAAKC,KAAKD,KACtBJ,KAAK,CAACI,KAAKG;4BAEnB;4BACA,MAAMnD,QAAQ2C,QAAQL,GAAG,CAACU,KAAKC,KAAKC;4BACpC,OAAO,OAAOlD,UAAU,aAAaA,MAAMoD,IAAI,CAACJ,OAAOhD;wBACzD;oBACF;gBACF;YACF;YACA,MAAMA,QAAQ2C,QAAQL,GAAG,CAACb,MAAMc,MAAMC;YACtC,uEAAuE;YACvE,wEAAwE;YACxE,OAAO,OAAOxC,UAAU,aAAaA,MAAMoD,IAAI,CAAC3B,QAAQzB;QAC1D;IACF;AACF;AAEA,SAASqD,QAAQC,GAAQ;IACvB,OAAO;QACLC,WAAW,IAAM1D,aAAayD,KAAKpE;QACnCuC,MAAM,IAAMU,sBAAsB7C,QAAQgE;QAC1CE,SAAS,IAAM1D,WAAWwD;QAC1B,qEAAqE;QACrE,wDAAwD;QACxDG,cAAc,IAAMhE,gBAAgB6D;IACtC;AACF;AAEA,SAASI;IACP,OAAO7D,aAAaR,UAAUH;AAChC;AACAwE,mBAAmB/D,UAAU,GAAGA;AAChC+D,mBAAmB9D,SAAS,GAAGA;AAC/B8D,mBAAmBhE,SAAS,GAAGA;AAE/B,MAAMiE,gBAAgB;IACpBL,KAAK,CAACM,OAAkBP,QAAQO,OAAOvE,OAAOuE,QAAQvE;IACtDkE,WAAWG;IACXG,UAAU,IAAMrE,YAAYH;AAC9B;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,MAAMyE,8BAA8BC;IAEzC,aAAc;QACZ,KAAK,CAAC,oCAFCC,OAAO;QAGd,IAAI,CAACJ,IAAI,GAAG;IACd;AACF;AAEA,OAAO,SAASK,gBAAgBvC,OAAuB;IACrD,OAAOA,QAAQwC,cAAc,KAAK;AACpC;AAEA;oEACoE,GACpE,OAAO,SAASC,uBAAuBzC,OAAuB;IAC5D,OAAO,OAAOA,OAAO,CAAC,iBAAiB,KAAK;AAC9C;AAEA,OAAO,eAAe0C,qBACpBC,OAAe;IAEf,yEAAyE;IACzE,4EAA4E;IAC5E,wEAAwE;IACxE,4EAA4E;IAC5E,6BAA6B;IAC7B,EAAE;IACF,2EAA2E;IAC3E,2EAA2E;IAC3E,0EAA0E;IAC1E,yDAAyD;IACzD,MAAM3C,UAAU,MAAMiC,cAAcL,GAAG,GAAG7B,IAAI,GAAG6C,aAAa,CAACD;IAC/D,IAAI,CAACJ,gBAAgBvC,YAAY,CAACyC,uBAAuBzC,UAAU;QACjE,MAAM,IAAIoC;IACZ;IACA,OAAOpC;AACT;AAEA,2EAA2E,GAC3E,OAAO,SAAS6C;IACd,OAAOC,SAASC,IAAI,CAClB;QAAEC,OAAO;QAAiCC,QAAQ;IAAmB,GACrE;QAAEC,QAAQ;IAAI;AAElB;AAEA,SAASjB,aAAa,GAAE;AACxB,eAAeA,cAAa"}