@aglyn/tenant-data-admin 1.0.0-beta.235 → 1.0.0-beta.237
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 +7 -7
- package/src/lib/server/account-identity.d.ts +101 -0
- package/src/lib/server/account-identity.js +158 -0
- package/src/lib/server/account-identity.js.map +1 -0
- package/src/lib/server/client-error-report.js +13 -2
- package/src/lib/server/client-error-report.js.map +1 -1
- package/src/lib/server/console-api-rate-limit.d.ts +16 -0
- package/src/lib/server/console-api-rate-limit.js +36 -0
- package/src/lib/server/console-api-rate-limit.js.map +1 -1
- package/src/lib/server/erase.js +36 -0
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/organizations.js +5 -1
- package/src/lib/server/organizations.js.map +1 -1
- package/src/tenancy.d.ts +1 -0
- package/src/tenancy.js +3 -0
- package/src/tenancy.js.map +1 -1
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { CONSOLE_API_RATE_LIMIT, CONSOLE_API_RATE_WINDOW_MS, consoleApiRateLimitKey, isMachinePluginApiPath, lockdownIntentForMethod } from "@aglyn/aglyn/server";
|
|
17
17
|
import { consumeRateLimit } from "./rate-limit-store.js";
|
|
18
|
+
import { isCronAuthorized } from "./cron-auth.js";
|
|
18
19
|
import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
|
|
19
20
|
/**
|
|
20
21
|
* The rate-limit refusal a console plugin-API write returns, or null to
|
|
@@ -40,6 +41,24 @@ import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/r
|
|
|
40
41
|
if (typeof (headers == null ? void 0 : headers.get) !== 'function') return NO_CLIENT_ADDRESS_BUCKET;
|
|
41
42
|
return (_readClientIp = readClientIp(headers)) != null ? _readClientIp : NO_CLIENT_ADDRESS_BUCKET;
|
|
42
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Did this request present the console's VERIFIED cron secret?
|
|
46
|
+
*
|
|
47
|
+
* `isCronAuthorized` compares the value with `CRON_SECRET` in constant time,
|
|
48
|
+
* so this answers whether the caller holds the credential, not whether it
|
|
49
|
+
* attached a header with the right name.
|
|
50
|
+
*/ function presentsCronSecret(request) {
|
|
51
|
+
const headers = request == null ? void 0 : request.headers;
|
|
52
|
+
if (typeof (headers == null ? void 0 : headers.get) !== 'function') return false;
|
|
53
|
+
const read = (name)=>{
|
|
54
|
+
const value = headers.get == null ? void 0 : headers.get.call(headers, name);
|
|
55
|
+
return typeof value === 'string' ? value : undefined;
|
|
56
|
+
};
|
|
57
|
+
return isCronAuthorized({
|
|
58
|
+
authorization: read('authorization'),
|
|
59
|
+
'x-cron-secret': read('x-cron-secret')
|
|
60
|
+
});
|
|
61
|
+
}
|
|
43
62
|
/**
|
|
44
63
|
* Counts one console write and returns a 429 once the window is spent.
|
|
45
64
|
*
|
|
@@ -74,6 +93,22 @@ import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/r
|
|
|
74
93
|
* would also make the surface unlimited for the accounts most likely to be
|
|
75
94
|
* driving it from a script.
|
|
76
95
|
*
|
|
96
|
+
* ## The cron secret is exempt, verified
|
|
97
|
+
*
|
|
98
|
+
* A request that presents the console's `CRON_SECRET` is the platform's own
|
|
99
|
+
* scheduler, and it is not counted. It carries no Firebase token, so without
|
|
100
|
+
* this it fell into the client-address bucket, shared with every other
|
|
101
|
+
* tokenless caller behind the same Cloud Functions egress. On 2026-10-09 at
|
|
102
|
+
* 14:00Z `consoleAiInsightsDigest` was refused there with a 429 while the
|
|
103
|
+
* hourly crons fired beside it, and the day's digests went undelivered.
|
|
104
|
+
*
|
|
105
|
+
* The list of machine paths stays the first answer (a route that knows it is
|
|
106
|
+
* a machine's says so), and this is the backstop for one that forgot: a
|
|
107
|
+
* scheduler holding the secret could already run every cron route on the
|
|
108
|
+
* console, so counting it bounds nothing an attacker could reach. What is
|
|
109
|
+
* exempt is the VERIFIED secret, compared in constant time — never the mere
|
|
110
|
+
* presence of the header, which would let any caller switch the limiter off.
|
|
111
|
+
*
|
|
77
112
|
* ## What the caller is told
|
|
78
113
|
*
|
|
79
114
|
* A bare 429 with `Retry-After`, following `visitorWriteRateLimitRefusal` and
|
|
@@ -85,6 +120,7 @@ import { NO_CLIENT_ADDRESS_BUCKET, readClientIp } from "@aglyn/aglyn/app-utils/r
|
|
|
85
120
|
var _options_request;
|
|
86
121
|
if (lockdownIntentForMethod((_options_request = options.request) == null ? void 0 : _options_request.method) === 'read') return null;
|
|
87
122
|
if (isMachinePluginApiPath(options.path)) return null;
|
|
123
|
+
if (presentsCronSecret(options.request)) return null;
|
|
88
124
|
const nowMs = (_options_nowMs = options.nowMs) != null ? _options_nowMs : Date.now();
|
|
89
125
|
// Prefixed, so an address can never collide with a uid: Firebase uids are
|
|
90
126
|
// opaque and a bucket shared between an identified operator and everyone
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/console-api-rate-limit.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\nimport {\n CONSOLE_API_RATE_LIMIT,\n CONSOLE_API_RATE_WINDOW_MS,\n consoleApiRateLimitKey,\n isMachinePluginApiPath,\n lockdownIntentForMethod,\n} from '@aglyn/aglyn/server'\nimport { consumeRateLimit } from './rate-limit-store'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n/**\n * The rate-limit refusal a console plugin-API write returns, or null to\n * proceed:\n *\n * const limited = await consoleApiRateLimitRefusal({ path, uid, request })\n * if (limited) return limited\n *\n * The same split as `visitorWriteRateLimitRefusal` beside it — the policy\n * (ceiling, window, key, which paths are exempt) is pure and lives in\n * `@aglyn/aglyn/server`; this is only the durable half, which needs Firestore.\n * The counter is `consumeRateLimit`, unchanged and unwrapped, so the console\n * inherits its postures rather than acquiring a second set.\n */\n\n/**\n * The caller's address, through the shared reader, or the no-address bucket.\n *\n * `readClientIp` counts from the RIGHT of the forwarding chain and is the only\n * reader allowed to answer this question, so a caller cannot choose its own\n * bucket by prepending an address.\n */\nfunction clientIp(request: {\n headers?: { get?: (name: string) => unknown }\n}): string {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return NO_CLIENT_ADDRESS_BUCKET\n return (\n readClientIp(headers as { get(name: string): string | null }) ??\n NO_CLIENT_ADDRESS_BUCKET\n )\n}\n\nexport interface ConsoleApiRateLimitOptions {\n /** Dispatcher path, e.g. `email/list-members-add`. Decides machine vs not. */\n path: string\n /**\n * The verified Firebase uid, or null when the request carried no bearer\n * token or one that did not decode. The console dispatcher has already\n * decoded it for the lockdown gate, so this costs no second verification.\n */\n uid: string | null\n /** Read for its method and its client-address headers. */\n request: { method?: string; headers?: { get?: (name: string) => unknown } }\n limit?: number\n windowMs?: number\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n}\n\n/**\n * Counts one console write and returns a 429 once the window is spent.\n *\n * ## Reads are not limited\n *\n * The standing rule in `RATE_LIMITING.md` is \"`checkRateLimit` for volume,\n * `consumeRateLimit` for consequence\": a durable call costs one Firestore read\n * plus one write, which is the right price beside a handler that installs a\n * bundle or calls a paid model, and the wrong price in front of a listing GET.\n * Every route on this surface that spends something spends it on a POST — the\n * two email list previews, the member add, the suppression writes, `ai/assist`,\n * every marketplace install and publish — so writes-only covers the exposure\n * rather than merely most of it.\n *\n * ## Fail SOFT, inherited from `consumeRateLimit`\n *\n * A Firestore outage lets traffic through, flagged `degraded`, rather than\n * refusing it; a contended key is refused. That classification is the store's\n * and is not re-decided here. Failing closed would take the console's write\n * surface down during an unrelated blip — the operator's own recovery tools\n * among them — to bound spend that the outage has already stopped, since the\n * counter and the documents these handlers write are one Firestore. CSRF fails\n * closed because forging a request is the threat; here the threat is volume,\n * and volume during an outage is not the incident.\n *\n * ## No staff bypass\n *\n * The lockdown gates above this one grant staff a bypass, because their\n * question is \"should anyone be doing this right now\" and staff are how an\n * incident gets diagnosed. This one's question is \"how much is this costing\",\n * and a staff session spends exactly what a customer session spends. A bypass\n * would also make the surface unlimited for the accounts most likely to be\n * driving it from a script.\n *\n * ## What the caller is told\n *\n * A bare 429 with `Retry-After`, following `visitorWriteRateLimitRefusal` and\n * `forms/submit`. The fallback bucket below is reached by callers that proved\n * nothing, so the response says nothing a caller could spend the rest of the\n * window on.\n */\nexport async function consoleApiRateLimitRefusal(\n options: ConsoleApiRateLimitOptions,\n): Promise<Response | null> {\n if (lockdownIntentForMethod(options.request?.method) === 'read') return null\n if (isMachinePluginApiPath(options.path)) return null\n\n const nowMs = options.nowMs ?? Date.now()\n // Prefixed, so an address can never collide with a uid: Firebase uids are\n // opaque and a bucket shared between an identified operator and everyone\n // arriving from one address would refuse the wrong one of them.\n const subject = options.uid\n ? `uid:${options.uid}`\n : `ip:${clientIp(options.request)}`\n const rate = await consumeRateLimit(consoleApiRateLimitKey(subject), {\n limit: options.limit ?? CONSOLE_API_RATE_LIMIT,\n windowMs: options.windowMs ?? CONSOLE_API_RATE_WINDOW_MS,\n now: nowMs,\n firestore: options.firestore,\n })\n if (rate.allowed) return null\n\n return Response.json(\n { error: 'Too many requests' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(\n Math.max(1, Math.ceil((rate.resetMs - nowMs) / 1000)),\n ),\n },\n },\n )\n}\n\nexport default consoleApiRateLimitRefusal\n"],"names":["CONSOLE_API_RATE_LIMIT","CONSOLE_API_RATE_WINDOW_MS","consoleApiRateLimitKey","isMachinePluginApiPath","lockdownIntentForMethod","consumeRateLimit","NO_CLIENT_ADDRESS_BUCKET","readClientIp","clientIp","request","headers","get","consoleApiRateLimitRefusal","options","method","path","nowMs","Date","now","subject","uid","rate","limit","windowMs","firestore","allowed","Response","json","error","status","String","Math","max","ceil","resetMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,sBAAsB,EACtBC,0BAA0B,EAC1BC,sBAAsB,EACtBC,sBAAsB,EACtBC,uBAAuB,QAClB,sBAAqB;AAC5B,SAASC,gBAAgB,QAAQ,wBAAoB;AACrD,SACEC,wBAAwB,EACxBC,YAAY,QACP,oCAAmC;AAE1C;;;;;;;;;;;;CAYC,GAED;;;;;;CAMC,GACD,SAASC,SAASC,OAEjB;QAIGF;IAHF,MAAMG,UAAUD,2BAAAA,QAASC,OAAO;IAChC,IAAI,QAAOA,2BAAAA,QAASC,GAAG,MAAK,YAAY,OAAOL;IAC/C,QACEC,gBAAAA,aAAaG,oBAAbH,gBACAD;AAEJ;AAoBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwCC,GACD,OAAO,eAAeM,2BACpBC,OAAmC;QAKrBA,gBAQLA,gBACGA;QAZgBA;IAA5B,IAAIT,yBAAwBS,mBAAAA,QAAQJ,OAAO,qBAAfI,iBAAiBC,MAAM,MAAM,QAAQ,OAAO;IACxE,IAAIX,uBAAuBU,QAAQE,IAAI,GAAG,OAAO;IAEjD,MAAMC,SAAQH,iBAAAA,QAAQG,KAAK,YAAbH,iBAAiBI,KAAKC,GAAG;IACvC,0EAA0E;IAC1E,yEAAyE;IACzE,gEAAgE;IAChE,MAAMC,UAAUN,QAAQO,GAAG,GACvB,CAAC,IAAI,EAAEP,QAAQO,GAAG,EAAE,GACpB,CAAC,GAAG,EAAEZ,SAASK,QAAQJ,OAAO,GAAG;IACrC,MAAMY,OAAO,MAAMhB,iBAAiBH,uBAAuBiB,UAAU;QACnEG,KAAK,GAAET,iBAAAA,QAAQS,KAAK,YAAbT,iBAAiBb;QACxBuB,QAAQ,GAAEV,oBAAAA,QAAQU,QAAQ,YAAhBV,oBAAoBZ;QAC9BiB,KAAKF;QACLQ,WAAWX,QAAQW,SAAS;IAC9B;IACA,IAAIH,KAAKI,OAAO,EAAE,OAAO;IAEzB,OAAOC,SAASC,IAAI,CAClB;QAAEC,OAAO;IAAoB,GAC7B;QACEC,QAAQ;QACRnB,SAAS;YACP,eAAeoB,OACbC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,IAAI,CAAC,AAACZ,CAAAA,KAAKa,OAAO,GAAGlB,KAAI,IAAK;QAEnD;IACF;AAEJ;AAEA,eAAeJ,2BAA0B"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/console-api-rate-limit.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\nimport {\n CONSOLE_API_RATE_LIMIT,\n CONSOLE_API_RATE_WINDOW_MS,\n consoleApiRateLimitKey,\n isMachinePluginApiPath,\n lockdownIntentForMethod,\n} from '@aglyn/aglyn/server'\nimport { consumeRateLimit } from './rate-limit-store'\nimport { isCronAuthorized } from './cron-auth'\nimport {\n NO_CLIENT_ADDRESS_BUCKET,\n readClientIp,\n} from '@aglyn/aglyn/app-utils/request-ip'\n\n/**\n * The rate-limit refusal a console plugin-API write returns, or null to\n * proceed:\n *\n * const limited = await consoleApiRateLimitRefusal({ path, uid, request })\n * if (limited) return limited\n *\n * The same split as `visitorWriteRateLimitRefusal` beside it — the policy\n * (ceiling, window, key, which paths are exempt) is pure and lives in\n * `@aglyn/aglyn/server`; this is only the durable half, which needs Firestore.\n * The counter is `consumeRateLimit`, unchanged and unwrapped, so the console\n * inherits its postures rather than acquiring a second set.\n */\n\n/**\n * The caller's address, through the shared reader, or the no-address bucket.\n *\n * `readClientIp` counts from the RIGHT of the forwarding chain and is the only\n * reader allowed to answer this question, so a caller cannot choose its own\n * bucket by prepending an address.\n */\nfunction clientIp(request: {\n headers?: { get?: (name: string) => unknown }\n}): string {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return NO_CLIENT_ADDRESS_BUCKET\n return (\n readClientIp(headers as { get(name: string): string | null }) ??\n NO_CLIENT_ADDRESS_BUCKET\n )\n}\n\n/**\n * Did this request present the console's VERIFIED cron secret?\n *\n * `isCronAuthorized` compares the value with `CRON_SECRET` in constant time,\n * so this answers whether the caller holds the credential, not whether it\n * attached a header with the right name.\n */\nfunction presentsCronSecret(request: {\n headers?: { get?: (name: string) => unknown }\n}): boolean {\n const headers = request?.headers\n if (typeof headers?.get !== 'function') return false\n const read = (name: string) => {\n const value = headers.get?.(name)\n return typeof value === 'string' ? value : undefined\n }\n return isCronAuthorized({\n authorization: read('authorization'),\n 'x-cron-secret': read('x-cron-secret'),\n })\n}\n\nexport interface ConsoleApiRateLimitOptions {\n /** Dispatcher path, e.g. `email/list-members-add`. Decides machine vs not. */\n path: string\n /**\n * The verified Firebase uid, or null when the request carried no bearer\n * token or one that did not decode. The console dispatcher has already\n * decoded it for the lockdown gate, so this costs no second verification.\n */\n uid: string | null\n /** Read for its method and its client-address headers. */\n request: { method?: string; headers?: { get?: (name: string) => unknown } }\n limit?: number\n windowMs?: number\n nowMs?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: unknown\n}\n\n/**\n * Counts one console write and returns a 429 once the window is spent.\n *\n * ## Reads are not limited\n *\n * The standing rule in `RATE_LIMITING.md` is \"`checkRateLimit` for volume,\n * `consumeRateLimit` for consequence\": a durable call costs one Firestore read\n * plus one write, which is the right price beside a handler that installs a\n * bundle or calls a paid model, and the wrong price in front of a listing GET.\n * Every route on this surface that spends something spends it on a POST — the\n * two email list previews, the member add, the suppression writes, `ai/assist`,\n * every marketplace install and publish — so writes-only covers the exposure\n * rather than merely most of it.\n *\n * ## Fail SOFT, inherited from `consumeRateLimit`\n *\n * A Firestore outage lets traffic through, flagged `degraded`, rather than\n * refusing it; a contended key is refused. That classification is the store's\n * and is not re-decided here. Failing closed would take the console's write\n * surface down during an unrelated blip — the operator's own recovery tools\n * among them — to bound spend that the outage has already stopped, since the\n * counter and the documents these handlers write are one Firestore. CSRF fails\n * closed because forging a request is the threat; here the threat is volume,\n * and volume during an outage is not the incident.\n *\n * ## No staff bypass\n *\n * The lockdown gates above this one grant staff a bypass, because their\n * question is \"should anyone be doing this right now\" and staff are how an\n * incident gets diagnosed. This one's question is \"how much is this costing\",\n * and a staff session spends exactly what a customer session spends. A bypass\n * would also make the surface unlimited for the accounts most likely to be\n * driving it from a script.\n *\n * ## The cron secret is exempt, verified\n *\n * A request that presents the console's `CRON_SECRET` is the platform's own\n * scheduler, and it is not counted. It carries no Firebase token, so without\n * this it fell into the client-address bucket, shared with every other\n * tokenless caller behind the same Cloud Functions egress. On 2026-10-09 at\n * 14:00Z `consoleAiInsightsDigest` was refused there with a 429 while the\n * hourly crons fired beside it, and the day's digests went undelivered.\n *\n * The list of machine paths stays the first answer (a route that knows it is\n * a machine's says so), and this is the backstop for one that forgot: a\n * scheduler holding the secret could already run every cron route on the\n * console, so counting it bounds nothing an attacker could reach. What is\n * exempt is the VERIFIED secret, compared in constant time — never the mere\n * presence of the header, which would let any caller switch the limiter off.\n *\n * ## What the caller is told\n *\n * A bare 429 with `Retry-After`, following `visitorWriteRateLimitRefusal` and\n * `forms/submit`. The fallback bucket below is reached by callers that proved\n * nothing, so the response says nothing a caller could spend the rest of the\n * window on.\n */\nexport async function consoleApiRateLimitRefusal(\n options: ConsoleApiRateLimitOptions,\n): Promise<Response | null> {\n if (lockdownIntentForMethod(options.request?.method) === 'read') return null\n if (isMachinePluginApiPath(options.path)) return null\n if (presentsCronSecret(options.request)) return null\n\n const nowMs = options.nowMs ?? Date.now()\n // Prefixed, so an address can never collide with a uid: Firebase uids are\n // opaque and a bucket shared between an identified operator and everyone\n // arriving from one address would refuse the wrong one of them.\n const subject = options.uid\n ? `uid:${options.uid}`\n : `ip:${clientIp(options.request)}`\n const rate = await consumeRateLimit(consoleApiRateLimitKey(subject), {\n limit: options.limit ?? CONSOLE_API_RATE_LIMIT,\n windowMs: options.windowMs ?? CONSOLE_API_RATE_WINDOW_MS,\n now: nowMs,\n firestore: options.firestore,\n })\n if (rate.allowed) return null\n\n return Response.json(\n { error: 'Too many requests' },\n {\n status: 429,\n headers: {\n 'Retry-After': String(\n Math.max(1, Math.ceil((rate.resetMs - nowMs) / 1000)),\n ),\n },\n },\n )\n}\n\nexport default consoleApiRateLimitRefusal\n"],"names":["CONSOLE_API_RATE_LIMIT","CONSOLE_API_RATE_WINDOW_MS","consoleApiRateLimitKey","isMachinePluginApiPath","lockdownIntentForMethod","consumeRateLimit","isCronAuthorized","NO_CLIENT_ADDRESS_BUCKET","readClientIp","clientIp","request","headers","get","presentsCronSecret","read","name","value","undefined","authorization","consoleApiRateLimitRefusal","options","method","path","nowMs","Date","now","subject","uid","rate","limit","windowMs","firestore","allowed","Response","json","error","status","String","Math","max","ceil","resetMs"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,sBAAsB,EACtBC,0BAA0B,EAC1BC,sBAAsB,EACtBC,sBAAsB,EACtBC,uBAAuB,QAClB,sBAAqB;AAC5B,SAASC,gBAAgB,QAAQ,wBAAoB;AACrD,SAASC,gBAAgB,QAAQ,iBAAa;AAC9C,SACEC,wBAAwB,EACxBC,YAAY,QACP,oCAAmC;AAE1C;;;;;;;;;;;;CAYC,GAED;;;;;;CAMC,GACD,SAASC,SAASC,OAEjB;QAIGF;IAHF,MAAMG,UAAUD,2BAAAA,QAASC,OAAO;IAChC,IAAI,QAAOA,2BAAAA,QAASC,GAAG,MAAK,YAAY,OAAOL;IAC/C,QACEC,gBAAAA,aAAaG,oBAAbH,gBACAD;AAEJ;AAEA;;;;;;CAMC,GACD,SAASM,mBAAmBH,OAE3B;IACC,MAAMC,UAAUD,2BAAAA,QAASC,OAAO;IAChC,IAAI,QAAOA,2BAAAA,QAASC,GAAG,MAAK,YAAY,OAAO;IAC/C,MAAME,OAAO,CAACC;QACZ,MAAMC,QAAQL,QAAQC,GAAG,oBAAXD,QAAQC,GAAG,MAAXD,SAAcI;QAC5B,OAAO,OAAOC,UAAU,WAAWA,QAAQC;IAC7C;IACA,OAAOX,iBAAiB;QACtBY,eAAeJ,KAAK;QACpB,iBAAiBA,KAAK;IACxB;AACF;AAoBA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAwDC,GACD,OAAO,eAAeK,2BACpBC,OAAmC;QAMrBA,gBAQLA,gBACGA;QAbgBA;IAA5B,IAAIhB,yBAAwBgB,mBAAAA,QAAQV,OAAO,qBAAfU,iBAAiBC,MAAM,MAAM,QAAQ,OAAO;IACxE,IAAIlB,uBAAuBiB,QAAQE,IAAI,GAAG,OAAO;IACjD,IAAIT,mBAAmBO,QAAQV,OAAO,GAAG,OAAO;IAEhD,MAAMa,SAAQH,iBAAAA,QAAQG,KAAK,YAAbH,iBAAiBI,KAAKC,GAAG;IACvC,0EAA0E;IAC1E,yEAAyE;IACzE,gEAAgE;IAChE,MAAMC,UAAUN,QAAQO,GAAG,GACvB,CAAC,IAAI,EAAEP,QAAQO,GAAG,EAAE,GACpB,CAAC,GAAG,EAAElB,SAASW,QAAQV,OAAO,GAAG;IACrC,MAAMkB,OAAO,MAAMvB,iBAAiBH,uBAAuBwB,UAAU;QACnEG,KAAK,GAAET,iBAAAA,QAAQS,KAAK,YAAbT,iBAAiBpB;QACxB8B,QAAQ,GAAEV,oBAAAA,QAAQU,QAAQ,YAAhBV,oBAAoBnB;QAC9BwB,KAAKF;QACLQ,WAAWX,QAAQW,SAAS;IAC9B;IACA,IAAIH,KAAKI,OAAO,EAAE,OAAO;IAEzB,OAAOC,SAASC,IAAI,CAClB;QAAEC,OAAO;IAAoB,GAC7B;QACEC,QAAQ;QACRzB,SAAS;YACP,eAAe0B,OACbC,KAAKC,GAAG,CAAC,GAAGD,KAAKE,IAAI,CAAC,AAACZ,CAAAA,KAAKa,OAAO,GAAGlB,KAAI,IAAK;QAEnD;IACF;AAEJ;AAEA,eAAeJ,2BAA0B"}
|
package/src/lib/server/erase.js
CHANGED
|
@@ -108,6 +108,40 @@ import { addAdminAudit } from "./admin-audit-write.js";
|
|
|
108
108
|
return 0;
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Every in-app notification about the site, in every inbox (AGL-3719).
|
|
113
|
+
*
|
|
114
|
+
* `users/{uid}/notifications/{id}` names the site in `hostId` — "Your site is
|
|
115
|
+
* live", "Your site job needs you", the staff operator alerts — and links into
|
|
116
|
+
* it (`/{hostId}/ai-jobs/…`). Nothing else collects them: they carry no TTL,
|
|
117
|
+
* the `recursiveDelete(hosts/{hostId})` below cannot see a user's tree, and
|
|
118
|
+
* the inbox outlives the site, so every one became a link to a 404 that stood
|
|
119
|
+
* forever. A 2026-10-09 audit found 14 of them against deleted test sites.
|
|
120
|
+
*
|
|
121
|
+
* A collection-group query rather than a walk of the org's members, because
|
|
122
|
+
* the staff alerts land in inboxes that are not members of the org. That
|
|
123
|
+
* query needs the `notifications.hostId` COLLECTION_GROUP override in
|
|
124
|
+
* `cloud/firebase-firestore.indexes.json`.
|
|
125
|
+
*
|
|
126
|
+
* Best-effort, like every other trailing cleanup in `eraseHost`.
|
|
127
|
+
*/ async function eraseHostNotifications(hostId) {
|
|
128
|
+
if (!hostId) return 0;
|
|
129
|
+
try {
|
|
130
|
+
const firestore = firebaseAdmin.app().firestore();
|
|
131
|
+
const rows = await firestore.collectionGroup('notifications').where('hostId', '==', hostId).get();
|
|
132
|
+
for(let index = 0; index < rows.docs.length; index += 400){
|
|
133
|
+
const batch = firestore.batch();
|
|
134
|
+
for (const doc of rows.docs.slice(index, index + 400)){
|
|
135
|
+
batch.delete(doc.ref);
|
|
136
|
+
}
|
|
137
|
+
await batch.commit();
|
|
138
|
+
}
|
|
139
|
+
return rows.size;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error(`eraseHost: notification cleanup failed for ${hostId}`, error);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
111
145
|
/**
|
|
112
146
|
* Destroy the org documents that belong to the site (AGL-3273).
|
|
113
147
|
*
|
|
@@ -208,6 +242,8 @@ export async function eraseHost(hostId, options = {}) {
|
|
|
208
242
|
// SUPPLIER_DELIVERY_COLLECTION below for why this is here and not implied
|
|
209
243
|
// by the recursiveDelete at the end of this function.
|
|
210
244
|
await eraseHostSupplierDeliveries(hostId);
|
|
245
|
+
// In-app notifications about the site, in every inbox (AGL-3719).
|
|
246
|
+
await eraseHostNotifications(hostId);
|
|
211
247
|
// Routing: the middleware resolves a request to a host via hostIndex and
|
|
212
248
|
// the owning org's hosts map — drop both so the subdomain/cname 404s.
|
|
213
249
|
await firestore.collection('hostIndex').doc(hostId).delete().catch(()=>undefined);
|