@aglyn/tenant-data-admin 1.0.0-beta.236 → 1.0.0-beta.238
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-data-admin",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.238",
|
|
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.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.238",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.238",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.238",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.238",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.238",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.238",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.5",
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The server half of the account-identity resolver (AGL-3721): reading the
|
|
19
|
+
* profile documents the pure resolver needs, and filling a blank SSO Auth
|
|
20
|
+
* record from what the IdP sent at sign-in.
|
|
21
|
+
*
|
|
22
|
+
* See `@aglyn/shared-util-tools/account-identity` for the precedence and why
|
|
23
|
+
* it exists.
|
|
24
|
+
*/
|
|
25
|
+
import { type AccountIdentityIdpSource, type AccountIdentityProfileSource, type ResolvedAccountIdentity } from '@aglyn/shared-util-tools/account-identity';
|
|
26
|
+
import type { UserRecord } from 'firebase-admin/auth';
|
|
27
|
+
/** The `users/{uid}` fields the resolver reads, plus the removal marker. */
|
|
28
|
+
export interface AccountProfileIdentity extends AccountIdentityProfileSource {
|
|
29
|
+
photoUrlErasedAt?: unknown;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The identity fields of each account's `users/{uid}` document, keyed by
|
|
33
|
+
* uid. A uid with no document is absent from the map. Read with `getAll`, so
|
|
34
|
+
* N accounts cost N document reads in ⌈N/100⌉ round trips — callers pass
|
|
35
|
+
* only the accounts whose Auth record left something blank.
|
|
36
|
+
*/
|
|
37
|
+
export declare function readAccountProfiles(uids: readonly string[], options?: {
|
|
38
|
+
firestore?: any;
|
|
39
|
+
}): Promise<Map<string, AccountProfileIdentity>>;
|
|
40
|
+
/** The Auth-record fields the resolver reads. */
|
|
41
|
+
type IdentityRecord = Pick<UserRecord, 'displayName' | 'photoURL' | 'providerData'>;
|
|
42
|
+
/** Resolve one Auth record (plus its profile, when read) to a name and photo. */
|
|
43
|
+
export declare function resolveUserRecordIdentity(record: IdentityRecord & {
|
|
44
|
+
email?: string | null;
|
|
45
|
+
}, profile?: AccountIdentityProfileSource | null, idp?: AccountIdentityIdpSource | null): ResolvedAccountIdentity;
|
|
46
|
+
/**
|
|
47
|
+
* Whether the Auth record alone leaves the name or the photo blank — the
|
|
48
|
+
* only rows worth a profile read. Providers count: Google's avatar on
|
|
49
|
+
* `providerData` is an answer.
|
|
50
|
+
*/
|
|
51
|
+
export declare function userRecordIdentityIncomplete(record: IdentityRecord): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a batch of rows that carry an Auth record, reading `users/{uid}`
|
|
54
|
+
* only for the rows the record leaves incomplete.
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolveUserRecordIdentities<Row extends {
|
|
57
|
+
record: IdentityRecord & {
|
|
58
|
+
uid: string;
|
|
59
|
+
email?: string | null;
|
|
60
|
+
};
|
|
61
|
+
}>(rows: readonly Row[], options?: {
|
|
62
|
+
firestore?: any;
|
|
63
|
+
}): Promise<Map<string, ResolvedAccountIdentity>>;
|
|
64
|
+
export interface SyncAuthIdentityInput {
|
|
65
|
+
uid: string;
|
|
66
|
+
/** GCIP tenant id, or null for the project pool. */
|
|
67
|
+
tenantId: string | null;
|
|
68
|
+
/** The Auth record when the caller already holds it; read otherwise. */
|
|
69
|
+
record?: Pick<UserRecord, 'displayName' | 'photoURL'> | null;
|
|
70
|
+
/** What the IdP sent: `resolveIdpDisplayName` / `resolveIdpPhotoUrl`. */
|
|
71
|
+
idp: {
|
|
72
|
+
displayName?: string | null;
|
|
73
|
+
photoUrl?: string | null;
|
|
74
|
+
};
|
|
75
|
+
/** Injectable for tests. */
|
|
76
|
+
firestore?: any;
|
|
77
|
+
auth?: {
|
|
78
|
+
getUser(uid: string): Promise<Pick<UserRecord, 'displayName' | 'photoURL'>>;
|
|
79
|
+
updateUser(uid: string, fields: Record<string, unknown>): Promise<unknown>;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Fill an Auth record's blank `displayName` / `photoURL` from the IdP's copy
|
|
84
|
+
* at sign-in (AGL-3721).
|
|
85
|
+
*
|
|
86
|
+
* The Auth record is what every Admin-SDK surface reads — the staff Users
|
|
87
|
+
* list, the staff user page, the members route mirroring identity onto a
|
|
88
|
+
* roster — and a SAML sign-in leaves both fields empty, because GCIP keeps the
|
|
89
|
+
* assertion's attributes on the token rather than on the record. The profile
|
|
90
|
+
* seed already stores them in `users/{uid}`; this puts them where the rest of
|
|
91
|
+
* the platform looks too.
|
|
92
|
+
*
|
|
93
|
+
* NEVER OVERWRITES. Only a blank field is written, so a name or photo the
|
|
94
|
+
* person or staff set always wins over the directory's. The photo also honours
|
|
95
|
+
* the profile's `photoUrlErasedAt`: someone who removed their avatar has asked
|
|
96
|
+
* not to have the directory's put back, and the Auth record is no exception.
|
|
97
|
+
*/
|
|
98
|
+
export declare function syncAuthIdentityFromIdp(input: SyncAuthIdentityInput): Promise<{
|
|
99
|
+
fields: string[];
|
|
100
|
+
}>;
|
|
101
|
+
export {};
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
*/ /**
|
|
17
|
+
* The server half of the account-identity resolver (AGL-3721): reading the
|
|
18
|
+
* profile documents the pure resolver needs, and filling a blank SSO Auth
|
|
19
|
+
* record from what the IdP sent at sign-in.
|
|
20
|
+
*
|
|
21
|
+
* See `@aglyn/shared-util-tools/account-identity` for the precedence and why
|
|
22
|
+
* it exists.
|
|
23
|
+
*/ import { authIdentityFillFromIdp, resolveAccountIdentity } from "@aglyn/shared-util-tools/account-identity";
|
|
24
|
+
import { authForPool } from "./auth-pools.js";
|
|
25
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
26
|
+
const PROFILE_FIELDS = [
|
|
27
|
+
'firstName',
|
|
28
|
+
'lastName',
|
|
29
|
+
'displayName',
|
|
30
|
+
'photoUrl',
|
|
31
|
+
'photoUrlErasedAt'
|
|
32
|
+
];
|
|
33
|
+
/** `getAll` takes any number of refs; chunked so one call stays modest. */ const GET_ALL_CHUNK = 100;
|
|
34
|
+
function pickProfile(data) {
|
|
35
|
+
const out = {};
|
|
36
|
+
for (const field of PROFILE_FIELDS){
|
|
37
|
+
if (data && data[field] !== undefined) out[field] = data[field];
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The identity fields of each account's `users/{uid}` document, keyed by
|
|
43
|
+
* uid. A uid with no document is absent from the map. Read with `getAll`, so
|
|
44
|
+
* N accounts cost N document reads in ⌈N/100⌉ round trips — callers pass
|
|
45
|
+
* only the accounts whose Auth record left something blank.
|
|
46
|
+
*/ export async function readAccountProfiles(uids, options = {}) {
|
|
47
|
+
var _options_firestore;
|
|
48
|
+
const db = (_options_firestore = options.firestore) != null ? _options_firestore : firebaseAdmin.app().firestore();
|
|
49
|
+
const unique = [
|
|
50
|
+
...new Set(uids.filter(Boolean))
|
|
51
|
+
];
|
|
52
|
+
const out = new Map();
|
|
53
|
+
for(let i = 0; i < unique.length; i += GET_ALL_CHUNK){
|
|
54
|
+
const refs = unique.slice(i, i + GET_ALL_CHUNK).map((uid)=>db.collection('users').doc(uid));
|
|
55
|
+
if (!refs.length) continue;
|
|
56
|
+
const snapshots = await db.getAll(...refs);
|
|
57
|
+
for (const snapshot of snapshots){
|
|
58
|
+
if (snapshot.exists) out.set(snapshot.id, pickProfile(snapshot.data()));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
/** Resolve one Auth record (plus its profile, when read) to a name and photo. */ export function resolveUserRecordIdentity(record, profile, idp) {
|
|
64
|
+
var _record_email;
|
|
65
|
+
return resolveAccountIdentity({
|
|
66
|
+
auth: {
|
|
67
|
+
displayName: record.displayName,
|
|
68
|
+
photoURL: record.photoURL,
|
|
69
|
+
providerData: record.providerData
|
|
70
|
+
},
|
|
71
|
+
profile: profile != null ? profile : null,
|
|
72
|
+
idp: idp != null ? idp : null,
|
|
73
|
+
email: (_record_email = record.email) != null ? _record_email : null
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Whether the Auth record alone leaves the name or the photo blank — the
|
|
78
|
+
* only rows worth a profile read. Providers count: Google's avatar on
|
|
79
|
+
* `providerData` is an answer.
|
|
80
|
+
*/ export function userRecordIdentityIncomplete(record) {
|
|
81
|
+
const resolved = resolveUserRecordIdentity(record);
|
|
82
|
+
return !resolved.displayName || !resolved.photoUrl;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve a batch of rows that carry an Auth record, reading `users/{uid}`
|
|
86
|
+
* only for the rows the record leaves incomplete.
|
|
87
|
+
*/ export async function resolveUserRecordIdentities(rows, options = {}) {
|
|
88
|
+
const incomplete = rows.filter((row)=>userRecordIdentityIncomplete(row.record)).map((row)=>row.record.uid);
|
|
89
|
+
let profiles = new Map();
|
|
90
|
+
if (incomplete.length) {
|
|
91
|
+
try {
|
|
92
|
+
profiles = await readAccountProfiles(incomplete, options);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// A cosmetic enrichment must not fail the listing: the rows fall back
|
|
95
|
+
// to what the Auth record says, which is what they showed before.
|
|
96
|
+
console.error('[account-identity] profile read failed', error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const out = new Map();
|
|
100
|
+
for (const row of rows){
|
|
101
|
+
var _profiles_get;
|
|
102
|
+
out.set(row.record.uid, resolveUserRecordIdentity(row.record, (_profiles_get = profiles.get(row.record.uid)) != null ? _profiles_get : null));
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Fill an Auth record's blank `displayName` / `photoURL` from the IdP's copy
|
|
108
|
+
* at sign-in (AGL-3721).
|
|
109
|
+
*
|
|
110
|
+
* The Auth record is what every Admin-SDK surface reads — the staff Users
|
|
111
|
+
* list, the staff user page, the members route mirroring identity onto a
|
|
112
|
+
* roster — and a SAML sign-in leaves both fields empty, because GCIP keeps the
|
|
113
|
+
* assertion's attributes on the token rather than on the record. The profile
|
|
114
|
+
* seed already stores them in `users/{uid}`; this puts them where the rest of
|
|
115
|
+
* the platform looks too.
|
|
116
|
+
*
|
|
117
|
+
* NEVER OVERWRITES. Only a blank field is written, so a name or photo the
|
|
118
|
+
* person or staff set always wins over the directory's. The photo also honours
|
|
119
|
+
* the profile's `photoUrlErasedAt`: someone who removed their avatar has asked
|
|
120
|
+
* not to have the directory's put back, and the Auth record is no exception.
|
|
121
|
+
*/ export async function syncAuthIdentityFromIdp(input) {
|
|
122
|
+
var _input_idp_displayName, _input_idp_photoUrl, _input_auth, _input_record;
|
|
123
|
+
const wantsName = Boolean(String((_input_idp_displayName = input.idp.displayName) != null ? _input_idp_displayName : '').trim());
|
|
124
|
+
const wantsPhoto = Boolean(String((_input_idp_photoUrl = input.idp.photoUrl) != null ? _input_idp_photoUrl : '').trim());
|
|
125
|
+
if (!wantsName && !wantsPhoto) return {
|
|
126
|
+
fields: []
|
|
127
|
+
};
|
|
128
|
+
const pool = (_input_auth = input.auth) != null ? _input_auth : authForPool(input.tenantId);
|
|
129
|
+
const record = (_input_record = input.record) != null ? _input_record : await pool.getUser(input.uid);
|
|
130
|
+
// Nothing blank that the IdP could fill — the common case after the first
|
|
131
|
+
// sign-in, and it costs no profile read.
|
|
132
|
+
let fill = authIdentityFillFromIdp({
|
|
133
|
+
auth: record,
|
|
134
|
+
idp: input.idp
|
|
135
|
+
});
|
|
136
|
+
if (!Object.keys(fill).length) return {
|
|
137
|
+
fields: []
|
|
138
|
+
};
|
|
139
|
+
if (fill.photoURL) {
|
|
140
|
+
var _input_firestore;
|
|
141
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : firebaseAdmin.app().firestore();
|
|
142
|
+
const profile = await db.collection('users').doc(input.uid).get();
|
|
143
|
+
fill = authIdentityFillFromIdp({
|
|
144
|
+
auth: record,
|
|
145
|
+
idp: input.idp,
|
|
146
|
+
photoErased: Boolean(profile.exists && profile.get('photoUrlErasedAt'))
|
|
147
|
+
});
|
|
148
|
+
if (!Object.keys(fill).length) return {
|
|
149
|
+
fields: []
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
await pool.updateUser(input.uid, fill);
|
|
153
|
+
return {
|
|
154
|
+
fields: Object.keys(fill)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
//# sourceMappingURL=account-identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/account-identity.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\n/**\n * The server half of the account-identity resolver (AGL-3721): reading the\n * profile documents the pure resolver needs, and filling a blank SSO Auth\n * record from what the IdP sent at sign-in.\n *\n * See `@aglyn/shared-util-tools/account-identity` for the precedence and why\n * it exists.\n */\n\nimport {\n authIdentityFillFromIdp,\n resolveAccountIdentity,\n type AccountIdentityIdpSource,\n type AccountIdentityProfileSource,\n type ResolvedAccountIdentity,\n} from '@aglyn/shared-util-tools/account-identity'\nimport type { UserRecord } from 'firebase-admin/auth'\nimport { authForPool } from './auth-pools'\nimport firebaseAdmin from './firebase-admin'\n\n/** The `users/{uid}` fields the resolver reads, plus the removal marker. */\nexport interface AccountProfileIdentity extends AccountIdentityProfileSource {\n photoUrlErasedAt?: unknown\n}\n\nconst PROFILE_FIELDS = [\n 'firstName',\n 'lastName',\n 'displayName',\n 'photoUrl',\n 'photoUrlErasedAt',\n] as const\n\n/** `getAll` takes any number of refs; chunked so one call stays modest. */\nconst GET_ALL_CHUNK = 100\n\nfunction pickProfile(data: Record<string, unknown> | undefined): AccountProfileIdentity {\n const out: Record<string, unknown> = {}\n for (const field of PROFILE_FIELDS) {\n if (data && data[field] !== undefined) out[field] = data[field]\n }\n return out as AccountProfileIdentity\n}\n\n/**\n * The identity fields of each account's `users/{uid}` document, keyed by\n * uid. A uid with no document is absent from the map. Read with `getAll`, so\n * N accounts cost N document reads in ⌈N/100⌉ round trips — callers pass\n * only the accounts whose Auth record left something blank.\n */\nexport async function readAccountProfiles(\n uids: readonly string[],\n options: { firestore?: any } = {},\n): Promise<Map<string, AccountProfileIdentity>> {\n const db = options.firestore ?? firebaseAdmin.app().firestore()\n const unique = [...new Set(uids.filter(Boolean))]\n const out = new Map<string, AccountProfileIdentity>()\n for (let i = 0; i < unique.length; i += GET_ALL_CHUNK) {\n const refs = unique\n .slice(i, i + GET_ALL_CHUNK)\n .map((uid) => db.collection('users').doc(uid))\n if (!refs.length) continue\n const snapshots = await db.getAll(...refs)\n for (const snapshot of snapshots) {\n if (snapshot.exists) out.set(snapshot.id, pickProfile(snapshot.data()))\n }\n }\n return out\n}\n\n/** The Auth-record fields the resolver reads. */\ntype IdentityRecord = Pick<UserRecord, 'displayName' | 'photoURL' | 'providerData'>\n\n/** Resolve one Auth record (plus its profile, when read) to a name and photo. */\nexport function resolveUserRecordIdentity(\n record: IdentityRecord & { email?: string | null },\n profile?: AccountIdentityProfileSource | null,\n idp?: AccountIdentityIdpSource | null,\n): ResolvedAccountIdentity {\n return resolveAccountIdentity({\n auth: {\n displayName: record.displayName,\n photoURL: record.photoURL,\n providerData: record.providerData,\n },\n profile: profile ?? null,\n idp: idp ?? null,\n email: record.email ?? null,\n })\n}\n\n/**\n * Whether the Auth record alone leaves the name or the photo blank — the\n * only rows worth a profile read. Providers count: Google's avatar on\n * `providerData` is an answer.\n */\nexport function userRecordIdentityIncomplete(record: IdentityRecord): boolean {\n const resolved = resolveUserRecordIdentity(record)\n return !resolved.displayName || !resolved.photoUrl\n}\n\n/**\n * Resolve a batch of rows that carry an Auth record, reading `users/{uid}`\n * only for the rows the record leaves incomplete.\n */\nexport async function resolveUserRecordIdentities<\n Row extends { record: IdentityRecord & { uid: string; email?: string | null } },\n>(\n rows: readonly Row[],\n options: { firestore?: any } = {},\n): Promise<Map<string, ResolvedAccountIdentity>> {\n const incomplete = rows\n .filter((row) => userRecordIdentityIncomplete(row.record))\n .map((row) => row.record.uid)\n let profiles = new Map<string, AccountProfileIdentity>()\n if (incomplete.length) {\n try {\n profiles = await readAccountProfiles(incomplete, options)\n } catch (error) {\n // A cosmetic enrichment must not fail the listing: the rows fall back\n // to what the Auth record says, which is what they showed before.\n console.error('[account-identity] profile read failed', error)\n }\n }\n const out = new Map<string, ResolvedAccountIdentity>()\n for (const row of rows) {\n out.set(\n row.record.uid,\n resolveUserRecordIdentity(row.record, profiles.get(row.record.uid) ?? null),\n )\n }\n return out\n}\n\nexport interface SyncAuthIdentityInput {\n uid: string\n /** GCIP tenant id, or null for the project pool. */\n tenantId: string | null\n /** The Auth record when the caller already holds it; read otherwise. */\n record?: Pick<UserRecord, 'displayName' | 'photoURL'> | null\n /** What the IdP sent: `resolveIdpDisplayName` / `resolveIdpPhotoUrl`. */\n idp: { displayName?: string | null; photoUrl?: string | null }\n /** Injectable for tests. */\n firestore?: any\n auth?: { getUser(uid: string): Promise<Pick<UserRecord, 'displayName' | 'photoURL'>>; updateUser(uid: string, fields: Record<string, unknown>): Promise<unknown> }\n}\n\n/**\n * Fill an Auth record's blank `displayName` / `photoURL` from the IdP's copy\n * at sign-in (AGL-3721).\n *\n * The Auth record is what every Admin-SDK surface reads — the staff Users\n * list, the staff user page, the members route mirroring identity onto a\n * roster — and a SAML sign-in leaves both fields empty, because GCIP keeps the\n * assertion's attributes on the token rather than on the record. The profile\n * seed already stores them in `users/{uid}`; this puts them where the rest of\n * the platform looks too.\n *\n * NEVER OVERWRITES. Only a blank field is written, so a name or photo the\n * person or staff set always wins over the directory's. The photo also honours\n * the profile's `photoUrlErasedAt`: someone who removed their avatar has asked\n * not to have the directory's put back, and the Auth record is no exception.\n */\nexport async function syncAuthIdentityFromIdp(\n input: SyncAuthIdentityInput,\n): Promise<{ fields: string[] }> {\n const wantsName = Boolean(String(input.idp.displayName ?? '').trim())\n const wantsPhoto = Boolean(String(input.idp.photoUrl ?? '').trim())\n if (!wantsName && !wantsPhoto) return { fields: [] }\n const pool = input.auth ?? authForPool(input.tenantId)\n const record = input.record ?? (await pool.getUser(input.uid))\n // Nothing blank that the IdP could fill — the common case after the first\n // sign-in, and it costs no profile read.\n let fill = authIdentityFillFromIdp({ auth: record, idp: input.idp })\n if (!Object.keys(fill).length) return { fields: [] }\n if (fill.photoURL) {\n const db = input.firestore ?? firebaseAdmin.app().firestore()\n const profile = await db.collection('users').doc(input.uid).get()\n fill = authIdentityFillFromIdp({\n auth: record,\n idp: input.idp,\n photoErased: Boolean(profile.exists && profile.get('photoUrlErasedAt')),\n })\n if (!Object.keys(fill).length) return { fields: [] }\n }\n await pool.updateUser(input.uid, fill)\n return { fields: Object.keys(fill) }\n}\n"],"names":["authIdentityFillFromIdp","resolveAccountIdentity","authForPool","firebaseAdmin","PROFILE_FIELDS","GET_ALL_CHUNK","pickProfile","data","out","field","undefined","readAccountProfiles","uids","options","db","firestore","app","unique","Set","filter","Boolean","Map","i","length","refs","slice","map","uid","collection","doc","snapshots","getAll","snapshot","exists","set","id","resolveUserRecordIdentity","record","profile","idp","auth","displayName","photoURL","providerData","email","userRecordIdentityIncomplete","resolved","photoUrl","resolveUserRecordIdentities","rows","incomplete","row","profiles","error","console","get","syncAuthIdentityFromIdp","input","wantsName","String","trim","wantsPhoto","fields","pool","tenantId","getUser","fill","Object","keys","photoErased","updateUser"],"mappings":"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"}
|
|
@@ -114,7 +114,18 @@ function clampString(value, max) {
|
|
|
114
114
|
* WITHOUT a stack must carry `context.reportLocation` instead or ingestion
|
|
115
115
|
* drops it. Both shapes are built here so every accepted beacon event is
|
|
116
116
|
* representable.
|
|
117
|
-
*/
|
|
117
|
+
*/ /**
|
|
118
|
+
* The stack, headed by the message when the stack does not already carry it.
|
|
119
|
+
*
|
|
120
|
+
* V8 opens a stack with `Name: message`; JavaScriptCore and SpiderMonkey do
|
|
121
|
+
* not, so a Safari or Firefox report used to arrive as bare frames with no
|
|
122
|
+
* message at all — the 2026-10-09 01:23Z `host-activity-write` page could not
|
|
123
|
+
* say what had failed.
|
|
124
|
+
*/ function stackWithMessage(stack, message) {
|
|
125
|
+
if (!message || stack.includes(message)) return stack;
|
|
126
|
+
return `Error: ${message}\n${stack}`;
|
|
127
|
+
}
|
|
128
|
+
function toReportedEvent(event, service, version) {
|
|
118
129
|
var _event_line;
|
|
119
130
|
var _event_stack;
|
|
120
131
|
const hasStack = Boolean((_event_stack = event.stack) == null ? void 0 : _event_stack.includes('\n'));
|
|
@@ -124,7 +135,7 @@ function clampString(value, max) {
|
|
|
124
135
|
service,
|
|
125
136
|
version
|
|
126
137
|
},
|
|
127
|
-
message: hasStack ? event.stack : `${event.kind}: ${event.message}`,
|
|
138
|
+
message: hasStack ? stackWithMessage(event.stack, event.message) : `${event.kind}: ${event.message}`,
|
|
128
139
|
// Outside `serviceContext` so Error Reporting ignores it while a log-match
|
|
129
140
|
// policy can still filter on it — the same placement, for the same reason,
|
|
130
141
|
// that the server half gives `route`/`method`/`digest`.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/client-error-report.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\n/**\n * Server half of the first-party error beacon (AGL-1538): validates a\n * browser-posted batch of uncaught errors and forwards each to Google Cloud\n * Error Reporting, where grouping, retention, and the alerting policy live.\n *\n * Why Error Reporting rather than a vendor: Google is already a subprocessor\n * and the GCP monitoring/alerting stack already pages (AGL-1502) — a Sentry\n * would add a subprocessor and a DPA line item, which is a deliberately\n * deferred decision. Why a route in between rather than client-direct: the\n * API needs OAuth, and the route is also where the payload is disarmed —\n * clamped, capped, and stripped before anything leaves our origin.\n *\n * The caller is an unauthenticated browser, so every parsed field is clamped\n * here AGAIN regardless of what the client-side scrubber promised — the\n * beacon script is a promise, this is the boundary.\n *\n * Fail-soft throughout: a reporting failure logs one line and returns.\n * The one thing this module must never do is turn an observed error into a\n * served one.\n */\n\nimport { getApp } from 'firebase-admin/app'\n// Side-effect import: initializes the firebase-admin default app (cert\n// credential) exactly the way firebase-admin.ts does, so `getApp()` below\n// always finds it and its credential can mint the OAuth token.\nimport '@aglyn/shared-util-fbserver'\n// The readable half of the server-error signal (AGL-1921) — see the block\n// comment in `reportServerError`.\nimport {\n beaconHealth,\n deploymentCommitRef,\n deploymentEnvironmentLabel,\n isDeployedRuntime,\n isTransientBeaconCode,\n type BeaconCheck,\n} from '@aglyn/aglyn/server'\n// The scheme list the beacon and the CSP collector share (AGL-2786). A\n// subpath, not the `/server` barrel: specs that stub that barrel with a\n// hand-built factory would silently turn this into `undefined`.\nimport { isForeignScriptStack } from '@aglyn/aglyn/app-utils/foreign-script'\n\nimport {\n readBeaconHeartbeat,\n recordBeaconHeartbeat,\n recordServerError,\n} from './rate-limit-store'\n\nconst MAX_EVENTS_PER_REQUEST = 10\nconst MAX_MESSAGE = 1_024\nconst MAX_STACK = 8_192\nconst MAX_URL = 512\nconst REPORT_TIMEOUT_MS = 4_000\n\nexport interface ClientErrorEvent {\n kind: string\n message: string\n stack?: string\n source?: string\n line?: number\n url?: string\n}\n\nfunction clampString(value: unknown, max: number): string {\n return typeof value === 'string' ? value.slice(0, max) : ''\n}\n\n/** Origin + pathname only, server-enforced — never trust the client scrub. */\nfunction scrubUrl(value: unknown): string {\n const raw = clampString(value, MAX_URL)\n if (!raw) return ''\n try {\n const url = new URL(raw)\n return `${url.origin}${url.pathname}`\n } catch {\n return ''\n }\n}\n\n/**\n * Was this stack thrown with no code of ours on it, and was all of it seen?\n *\n * Every frame under a scheme no deployment of ours serves (AGL-2786) — an\n * in-app webview's bridge, an extension's content script. The beacon drops\n * these before sending; this is the half that also covers a bundle already\n * cached in a visitor's tab, which keeps posting whatever its copy of the rule\n * missed for as long as the tab lives.\n *\n * Only a stack that arrived WHOLE is judged. One at the clamp may be the head\n * of a longer stack, and the tail it lost could hold the frame that makes the\n * error ours.\n *\n * The beacon's other tell — every frame is the document — is deliberately not\n * applied here. This side has no page to compare against, and our own inline\n * code's handled errors carry the document's URL too.\n */\nfunction isForeignOnlyStack(stack: string): boolean {\n return stack.length > 0 && stack.length < MAX_STACK && isForeignScriptStack(stack)\n}\n\n/**\n * The events in a posted payload, clamped and capped. Anything malformed is\n * simply dropped — this endpoint never argues with a browser — and so is an\n * error none of our code threw (see {@link isForeignOnlyStack}).\n */\nexport function parseClientErrorEvents(payload: unknown): ClientErrorEvent[] {\n const events = (payload as { events?: unknown })?.events\n if (!Array.isArray(events)) return []\n const parsed: ClientErrorEvent[] = []\n for (const entry of events.slice(0, MAX_EVENTS_PER_REQUEST)) {\n if (!entry || typeof entry !== 'object') continue\n const event = entry as Record<string, unknown>\n const message = clampString(event.message, MAX_MESSAGE)\n if (!message) continue\n const stack = clampString(event.stack, MAX_STACK)\n if (isForeignOnlyStack(stack)) continue\n parsed.push({\n kind: clampString(event.kind, 32) || 'error',\n message,\n stack: stack || undefined,\n source: scrubUrl(event.source) || undefined,\n line: typeof event.line === 'number' ? Math.trunc(event.line) : undefined,\n url: scrubUrl(event.url) || undefined,\n })\n }\n return parsed\n}\n\n/**\n * Error Reporting groups by parsing the `message` as a stack trace; an event\n * WITHOUT a stack must carry `context.reportLocation` instead or ingestion\n * drops it. Both shapes are built here so every accepted beacon event is\n * representable.\n */\nfunction toReportedEvent(\n event: ClientErrorEvent,\n service: string,\n version: string | undefined,\n): Record<string, unknown> {\n const hasStack = Boolean(event.stack?.includes('\\n'))\n return {\n '@type':\n 'type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent',\n serviceContext: { service, version },\n message: hasStack ? event.stack : `${event.kind}: ${event.message}`,\n // Outside `serviceContext` so Error Reporting ignores it while a log-match\n // policy can still filter on it — the same placement, for the same reason,\n // that the server half gives `route`/`method`/`digest`.\n //\n // Until AGL-2523 the kind reached the payload ONLY on the stackless path,\n // folded into `message` and `reportLocation.functionName`. Every stacked\n // error — which is nearly all of them — arrived unclassifiable, so the\n // `Client error beacon` policy had no way to express anything narrower\n // than \"any entry at all\". That is why it pages for one visitor whose\n // webview rewrote the DOM.\n kind: clampString(event.kind, 32) || 'error',\n context: {\n httpRequest: event.url ? { url: event.url } : undefined,\n ...(hasStack\n ? {}\n : {\n reportLocation: {\n filePath: event.source || 'unknown',\n lineNumber: event.line ?? 0,\n functionName: event.kind,\n },\n }),\n },\n }\n}\n\n/** The log the beacon writes; the alert policy and any triage query key on it. */\nexport const CLIENT_ERROR_LOG_ID = 'client-errors'\n\n/**\n * The admin credential and project the beacon writes under.\n *\n * Extracted (AGL-1923) so the heartbeat below mints its token through the\n * SAME path the reporter does. That sharing is the whole value of the\n * heartbeat: the failures it exists to catch — an expired service-account\n * key, a revoked `logging.logEntries.create`, an exhausted Logging quota —\n * are environmental, and a heartbeat that acquired its credential some other\n * way could report healthy while every real report was being dropped.\n *\n * Returns null when either half is missing; the caller decides what that\n * means (the reporter drops the batch, the heartbeat reports degraded).\n *\n * EXPORTED for the Vercel log-drain receiver (AGL-1921, third arm), which\n * writes to `vercel-runtime` through this same credential for the same\n * reason the heartbeat does: the failures that would silence the drain are\n * the environmental ones AGL-1923's heartbeat already probes, and it probes\n * them through exactly this path. A receiver that minted its own token would\n * need its own dead-man's switch.\n */\nexport async function beaconLoggingTarget(): Promise<{\n token: string\n projectId: string\n} | null> {\n return (await resolveBeaconLoggingTarget()).target\n}\n\n/** The credential, plus WHY there isn't one when there isn't. */\nexport interface BeaconTargetResolution {\n target: { token: string; projectId: string } | null\n /** Set only when `target` is null. */\n code?: 'no-credential' | 'credential-unavailable'\n}\n\n/**\n * The same resolution, saying which of two very different things went wrong\n * (AGL-2713).\n *\n * `no-credential` used to cover both, and the collapse is what made\n * `/api/health/error-beacon` cry wolf: one of the two is permanent and one is\n * a cold lambda's first outbound connection.\n *\n * - **`no-credential`** — there is no admin app, or it has no project, or it\n * has no credential object. `initializeApp` runs at module load and is\n * guarded on `FIREBASE_PRIVATE_KEY` + `FIREBASE_CLIENT_EMAIL` +\n * `NEXT_PUBLIC_FIREBASE_PROJECT_ID`, so this is a STATIC fact about the\n * deployment's environment: it cannot heal on the next request, and every\n * browser error is being dropped for as long as it holds. Red immediately —\n * which is exactly what the remedy text on the staff board already says\n * (`no-credential` is the deployment `FIREBASE_*` env).\n * - **`credential-unavailable`** — the app and the certificate are both there\n * and `getAccessToken()` did not produce a token on THIS attempt. That is a\n * JWT-bearer exchange against `oauth2.googleapis.com`, and on a cold\n * instance it is the first outbound TLS handshake the sandbox has ever made.\n * Nothing was decided, so nothing about the beacon has been established.\n */\nexport async function resolveBeaconLoggingTarget(): Promise<BeaconTargetResolution> {\n let app\n try {\n app = getApp()\n } catch {\n return { target: null, code: 'no-credential' }\n }\n const projectId = (app.options.projectId ??\n process.env['NEXT_PUBLIC_FIREBASE_PROJECT_ID']) as string | undefined\n const credential = app.options.credential\n if (!projectId || !credential) return { target: null, code: 'no-credential' }\n let token: string | undefined\n try {\n token = (await credential.getAccessToken())?.access_token\n } catch {\n token = undefined\n }\n if (!token) return { target: null, code: 'credential-unavailable' }\n return { target: { token, projectId } }\n}\n\n/**\n * Forwards events under the admin service account — via Cloud LOGGING, not\n * the Error Reporting `events:report` API, and the transport is the point:\n * a log entry whose payload carries the `ReportedErrorEvent` `@type` is\n * ingested by Error Reporting automatically (grouping, the Error Reporting\n * console, version tracking), while ALSO being a real log entry that a\n * Cloud Monitoring log-match alert policy can page on — `events:report`\n * produces no log entry and nothing standard to alert from. One batched\n * write per request instead of one call per event is the bonus.\n *\n * Returns how many events were written; failures are logged (one line) and\n * swallowed.\n */\nexport async function reportClientErrors(\n events: readonly ClientErrorEvent[],\n options: { service: string },\n): Promise<number> {\n if (!events.length) return 0\n\n /*==========================================\n * A LAPTOP IS NOT A DEPLOYMENT (AGL-1925, option 1).\n *\n * Measured 2026-08-18 over a trailing week of Error Reporting `groupStats`:\n * 13 of 17 events — 76% — were not production, and the single highest-ranked\n * \"production error group\" in the project was a dev-only artifact with\n * `http://localhost:4200` stack frames. Ranking is the whole point of Error\n * Reporting, so a majority-noise stream does not merely cost storage: it\n * aims triage at the wrong thing, and the `Client error beacon` policy\n * notifies its single human recipient for every uncaught error on any\n * developer's machine.\n *\n * The credential is why localhost reaches production at all: `firebase-admin`\n * is configured from the root `.env`, which names the platform project.\n *\n * Unlike the server half this stream carries NO `environment` stamp — the\n * only marker was `serviceContext.version` being absent off Vercel, which\n * did that job by accident and stopped doing it once `deploymentCommitRef`\n * started resolving a commit for self-hosted builds. So refusing the write\n * is not one of two options here; it is the only one that works at all.\n *\n * `isDeployedRuntime`, matching `reportServerError`: an operator's own\n * container reports its visitors' errors, and a developer's console already\n * shows them the error it would have sent.\n *==========================================*/\n if (!isDeployedRuntime()) return 0\n\n const target = await beaconLoggingTarget()\n const token = target?.token\n const projectId = target?.projectId\n if (!token || !projectId) {\n console.warn(\n JSON.stringify({ tag: 'AGL-1538:error-beacon', drop: events.length, reason: 'no-credential' }),\n )\n return 0\n }\n /*\n * Which BUILD this report came from, resolved the way `/api/health` resolves\n * it — `BUILD_ID`, then `COMMIT_REF`, then Vercel's own.\n *\n * This read `VERCEL_GIT_COMMIT_SHA` directly, which is set on Aglyn's cloud\n * and nowhere else, so every error a self-hosted deployment reported arrived\n * with no version at all while the health endpoint beside it named the\n * commit correctly. An error report you cannot tie to a build is an error\n * report you cannot act on.\n *\n * `undefined`, not `null`: this becomes Error Reporting's\n * `serviceContext.version`, where an absent key is omitted and an empty one\n * is a version.\n */\n const version = deploymentCommitRef() ?? undefined\n try {\n const response = await fetch('https://logging.googleapis.com/v2/entries:write', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n logName: `projects/${projectId}/logs/${CLIENT_ERROR_LOG_ID}`,\n resource: { type: 'global' },\n entries: events.map((event) => ({\n severity: 'ERROR',\n jsonPayload: toReportedEvent(event, options.service, version),\n })),\n }),\n signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),\n })\n if (!response.ok) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1538:error-beacon',\n status: response.status,\n drop: events.length,\n }),\n )\n return 0\n }\n return events.length\n } catch (error) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1538:error-beacon',\n transport: String(error).slice(0, 200),\n }),\n )\n return 0\n }\n}\n\n/**\n * The log the SERVER half writes to (AGL-1921).\n *\n * DELIBERATELY NOT `client-errors`. The `Client error beacon` policy is a\n * log-match on that log id, and a server 5xx is a different incident with a\n * different first move — a browser error is one visitor's broken render, a\n * 500 rate is every visitor's. Merging the streams would make the existing\n * policy fire for both and force triage to start by asking which it was.\n * A sibling log id also lets the counter this whole issue is about key on\n * server errors ALONE.\n */\nexport const SERVER_ERROR_LOG_ID = 'server-errors'\n\n/**\n * One server-side error, as `onRequestError` sees it.\n *\n * `route` is the route PATTERN (`/[host]/[scheme]/[[...slug]]`), never the resolved\n * path: the resolved path carries org slugs, document ids and whatever a\n * visitor typed into a URL, and this payload leaves our origin for a Google\n * log. The pattern is what you group and alert on anyway.\n */\nexport interface ServerErrorEvent {\n message: string\n stack?: string\n route?: string\n method?: string\n routeType?: string\n digest?: string\n}\n\n/**\n * Per-instance write budget (AGL-1921).\n *\n * The failure this exists to observe is a SPIKE, and a spike is exactly when\n * an unbounded reporter turns one incident into two — a 500 on a hot path\n * can run at request rate, and every entry is a billable Logging write on an\n * account whose whole monitoring budget is $20/month. The budget is per\n * instance and per minute, so the shape of the spike still reaches Logging\n * (the counter only needs to cross a threshold, not be exact) while the cost\n * stays bounded no matter how hard the incident pushes.\n *\n * Suppression is REPORTED, never silent: when the window rolls over, one\n * summary line records how many were dropped. A monitoring path that hides\n * its own lossiness is the thing this repo keeps finding.\n */\nconst SERVER_ERROR_BUDGET_PER_WINDOW = 60\nconst SERVER_ERROR_WINDOW_MS = 60_000\nlet serverErrorWindowStartedAt = 0\nlet serverErrorsWritten = 0\nlet serverErrorsSuppressed = 0\n\n/** What happened to one `reportServerError` call, for tests and callers. */\nexport type ServerErrorOutcome = 'written' | 'suppressed' | 'dropped'\n\n/**\n * Forwards ONE server-side error to Cloud Logging under the admin credential.\n *\n * This is AGL-1921's fallback arm, and it is a fallback on purpose: it cannot\n * see an error that kills the process before the handler runs, nor a\n * platform-level 5xx that never reaches our code at all. A Vercel log drain\n * would see both. What this does buy is the case that matters most and is\n * most likely — our own route handlers and renders throwing — reported\n * through a transport that already works, on an account we already pay for.\n * `docs/UPTIME_AND_SLA.md` carries the blind spots in writing, because\n * partial visibility whose gaps are documented beats none.\n *\n * It shares `beaconLoggingTarget` with the client reporter deliberately: that\n * makes AGL-1923's heartbeat a dead-man's switch for THIS path too. The\n * failures that would silence server-error reporting — expired key, revoked\n * `logging.logEntries.create`, exhausted quota — are the same environmental\n * ones the heartbeat already probes, and it probes them through this exact\n * credential.\n *\n * Fail-soft and never throws: this runs inside `onRequestError`, so throwing\n * here would turn an observed error into a second one during an incident.\n */\nexport async function reportServerError(\n event: ServerErrorEvent,\n options: { service: string },\n): Promise<ServerErrorOutcome> {\n const message = clampString(event.message, MAX_MESSAGE)\n if (!message) return 'dropped'\n\n /*==========================================\n * A LAPTOP IS NOT A DEPLOYMENT, AND THIS IS THE ONLY GATE THAT CAN SAY SO.\n *\n * ABOVE the marker below, unlike every other gate here, and the difference\n * is what is being refused. Those gates are about a write that FAILED and\n * must still be counted. This one is about a process that should not be\n * reporting at all: the count is as wrong as the log entry, because\n * `/api/health/server-errors` reads that marker out of the platform project\n * and would answer for a developer's terminal.\n *\n * The credential makes this reachable. `firebase-admin` is configured from\n * the root `.env`, which names the platform project, so `nx dev` writes to\n * production Cloud Logging under the same key production uses. Measured\n * 2026-08-31: three parse errors from an interrupted rebase — carrying\n * `file:///Users/.../aglyn/apps/...` stacks — landed in `server-errors` and\n * opened the `Server errors: uncaught 5xx` policy, which was still open two\n * days later while all thirteen uptime checks read 100%.\n *\n * `environment` was stamped for this (see the field below), but a stamp only\n * helps a reader who filters on it, and the one reader this log can have is\n * a hand-made alert policy that did not. Refusing the write is the half that\n * does not depend on somebody else's configuration.\n *\n * `isDeployedRuntime` and not `isProductionDeployment`, deliberately: a\n * preview's 5xx is a real 5xx served by a real deployment, and the stamp\n * already separates it for anyone who wants to.\n *==========================================*/\n if (!isDeployedRuntime()) return 'dropped'\n\n /*==========================================\n * COUNT IT WHERE SOMETHING CAN READ THE COUNT (AGL-1921, second pass).\n *\n * FIRST, above every gate below, and that ordering is the whole point.\n *\n * The Logging entry this function goes on to write is the right record for\n * triage and the wrong one for an alarm we can ship: measured 2026-08-24,\n * the production credential is refused `entries:list` on that log with\n * `403 Permission denied for all log views`, so nothing in this repo can\n * read back what it wrote. The only reader the log can ever have is a GCP\n * alert policy somebody creates by hand.\n *\n * The marker is readable today, and putting it above the budget gate, the\n * credential check and the fetch means the count survives every way the\n * Logging arm can fail. That is not tidiness — a beacon whose transport is\n * dead reports ZERO errors, which is the measured-zero shape this repo keeps\n * finding, and it would report it during exactly the incident that killed\n * the transport.\n *\n * Never awaited: this runs inside `onRequestError`, and the marker coalesces\n * in process anyway (at most one write per five seconds per instance).\n *==========================================*/\n recordServerError(options.service)\n\n const now = Date.now()\n if (now - serverErrorWindowStartedAt >= SERVER_ERROR_WINDOW_MS) {\n if (serverErrorsSuppressed > 0) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1921:server-error-beacon',\n suppressed: serverErrorsSuppressed,\n written: serverErrorsWritten,\n windowMs: SERVER_ERROR_WINDOW_MS,\n }),\n )\n }\n serverErrorWindowStartedAt = now\n serverErrorsWritten = 0\n serverErrorsSuppressed = 0\n }\n if (serverErrorsWritten >= SERVER_ERROR_BUDGET_PER_WINDOW) {\n serverErrorsSuppressed += 1\n return 'suppressed'\n }\n\n const target = await beaconLoggingTarget()\n if (!target) {\n console.warn(\n JSON.stringify({ tag: 'AGL-1921:server-error-beacon', drop: 1, reason: 'no-credential' }),\n )\n return 'dropped'\n }\n\n const stack = clampString(event.stack, MAX_STACK)\n const hasStack = stack.includes('\\n')\n const version = deploymentCommitRef() ?? undefined\n try {\n const response = await fetch('https://logging.googleapis.com/v2/entries:write', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${target.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n logName: `projects/${target.projectId}/logs/${SERVER_ERROR_LOG_ID}`,\n resource: { type: 'global' },\n entries: [\n {\n severity: 'ERROR',\n jsonPayload: {\n '@type':\n 'type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent',\n serviceContext: { service: options.service, version },\n // Same rule as the client half: Error Reporting groups by\n // parsing `message` as a stack trace, and an event without one\n // must carry `context.reportLocation` or ingestion drops it.\n message: hasStack ? stack : message,\n context: {\n ...(hasStack\n ? {}\n : {\n reportLocation: {\n filePath: clampString(event.route, MAX_URL) || 'unknown',\n lineNumber: 0,\n functionName: clampString(event.routeType, 32) || 'server',\n },\n }),\n },\n // Outside `serviceContext` so Error Reporting ignores them and\n // a log-match policy can still filter on them.\n route: clampString(event.route, MAX_URL) || undefined,\n method: clampString(event.method, 16) || undefined,\n digest: clampString(event.digest, 64) || undefined,\n // WHICH DEPLOYMENT THREW, and the log-match policy has no other\n // way to ask. The credential comes from the environment, so a\n // developer serving the console locally against the platform\n // project writes here under the same key as production and\n // lands in the same log — a throw on a laptop is then\n // indistinguishable from a 500 served to a customer. The\n // heartbeat beside this already stamps it for that reason.\n environment: deploymentEnvironmentLabel(),\n },\n },\n ],\n }),\n signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),\n })\n if (!response.ok) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1921:server-error-beacon',\n status: response.status,\n drop: 1,\n }),\n )\n return 'dropped'\n }\n serverErrorsWritten += 1\n return 'written'\n } catch (error) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1921:server-error-beacon',\n transport: String(error).slice(0, 200),\n }),\n )\n return 'dropped'\n }\n}\n\n/**\n * The log the beacon's heartbeat is written to (AGL-1923).\n *\n * DELIBERATELY NOT `client-errors`. The `Client error beacon` alert policy is\n * a log-match on `logName=\"…/client-errors\" AND severity>=ERROR`, so a\n * heartbeat written there would page the on-call owner on every probe — building the\n * alert-fatigue mechanism the heartbeat exists to protect against. A separate\n * log id at INFO keeps the two streams from ever being confused, and keeps\n * the payload out of Error Reporting (which ingests on the\n * `ReportedErrorEvent` `@type`, absent here on purpose).\n */\nexport const BEACON_HEARTBEAT_LOG_ID = 'client-error-beacon-heartbeat'\n\n/** What a heartbeat attempt proved, for the health verdict to reduce. */\nexport interface BeaconHeartbeatResult {\n /** Did the entry actually reach Cloud Logging? */\n ok: boolean\n /** A stable code on failure — never a raw error message. */\n code?: string\n}\n\n/**\n * Write one heartbeat entry through the beacon's own transport (AGL-1923).\n *\n * ## Why this exists\n *\n * `reportClientErrors` is fail-soft by design and must stay that way — it may\n * never turn an observed error into a served one. But every one of its\n * failure paths ends in a `console.warn` to the Vercel runtime log, which\n * retains about an hour and drains nowhere (AGL-1799). So the beacon can fail\n * completely and the only readings anyone has are \"Error Reporting shows zero\n * errors\" and \"the log-match policy is silent\" — which are exactly the\n * readings a clean launch produces. **A dead beacon is indistinguishable from\n * zero errors.**\n *\n * A log-match policy can only ever report presence. The fix is a signal whose\n * ABSENCE is detectable, and the cheapest one that needs no new vendor is a\n * write that a health endpoint can grade synchronously: this returns whether\n * the write landed, `/api/health/error-beacon` turns that into the 200/503\n * contract the sibling health checks already speak, and the existing uptime\n * check + alert + email path becomes the listener. The uptime probe is what\n * winds the dead-man's switch, so there is no cron to forget.\n *\n * ## What it can and cannot prove\n *\n * It proves the credential mints, the IAM grant still covers\n * `logging.logEntries.create`, the Logging API answers, and the quota is not\n * exhausted — the whole environmental failure class AGL-1923 names. It does\n * NOT prove a browser can reach `/api/errors`, and it does not prove the\n * payload shape Error Reporting ingests on, because it deliberately does not\n * write that shape.\n *\n * ## Clearing\n *\n * The condition it raises is an EVENT, not a state: the next successful write\n * clears it, within one probe TTL. Nothing here can latch (AGL-1843).\n *\n * Never throws. A heartbeat that could 500 a health endpoint would be a\n * monitoring probe that causes the outage it reports.\n */\nexport async function writeBeaconHeartbeat(options: {\n service: string\n}): Promise<BeaconHeartbeatResult> {\n const { target, code } = await resolveBeaconLoggingTarget()\n if (!target) return { ok: false, code: code ?? 'no-credential' }\n try {\n const response = await fetch('https://logging.googleapis.com/v2/entries:write', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${target.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n logName: `projects/${target.projectId}/logs/${BEACON_HEARTBEAT_LOG_ID}`,\n resource: { type: 'global' },\n entries: [\n {\n severity: 'INFO',\n jsonPayload: {\n // No `@type`: this must not be ingested by Error Reporting.\n tag: 'AGL-1923:beacon-heartbeat',\n service: options.service,\n version: deploymentCommitRef(),\n environment: deploymentEnvironmentLabel(),\n },\n },\n ],\n }),\n signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),\n })\n // The STATUS only. The health body is public and a Google error message\n // can carry project ids and resource paths — the same rule the backups\n // probe follows.\n return response.ok ? { ok: true } : { ok: false, code: `http-${response.status}` }\n } catch (error) {\n // Codes, not messages: `TimeoutError` for the 4s abort, whatever the\n // fetch layer names for a transport failure.\n return {\n ok: false,\n code: `transport-${String((error as { name?: string })?.name ?? 'unknown')}`,\n }\n }\n}\n\n/**\n * How long the probe waits before its second attempt (AGL-2713).\n *\n * Long enough for a JWT-bearer exchange that lost its first connection to get\n * another one, short enough that it is invisible next to a probe already\n * budgeted at 4s for the Logging write. It is a delay, not a backoff schedule:\n * a second attempt either establishes something or it does not, and a third\n * would only spend a public endpoint's time proving the same point.\n */\nexport const BEACON_HEARTBEAT_RETRY_DELAY_MS = 250\n\n/**\n * How long a missed heartbeat is forgiven after the last one that landed.\n *\n * One monitor interval. The uptime workflow reads this door every 15 minutes,\n * so a grace of exactly that means the FIRST probe after a miss can be\n * forgiven and the second cannot — \"tolerate a single miss, red on a\n * sustained one\", stated in the only unit the reader actually samples in.\n *\n * The bound this puts on a real outage: the marker is refreshed by every\n * successful probe, and this endpoint is polled every 5 minutes, so the last\n * landing is at most one probe TTL old when a sustained failure starts. Worst\n * case the door reds 20 minutes later than it does today, and the 15-minute\n * workflow sees it on its next run.\n */\nexport const BEACON_HEARTBEAT_GRACE_MS = 15 * 60_000\n\n/** Injectable clock, sleep, writer and store, so a spec can drive every branch. */\nexport interface BeaconHeartbeatProbeOptions {\n service: string\n now?: () => number\n sleep?: (ms: number) => Promise<void>\n firestore?: any\n graceMs?: number\n retryDelayMs?: number\n /**\n * The heartbeat writer. Defaults to the real one, and exists as a seam\n * because the belt below catches a contract `writeBeaconHeartbeat` is\n * documented never to break — a branch that is otherwise unreachable, and\n * therefore one nobody has ever watched work.\n */\n write?: (options: { service: string }) => Promise<BeaconHeartbeatResult>\n}\n\n/**\n * One heartbeat probe, graded — the whole of what both doors do (AGL-2713).\n *\n * ## Why this is one function and not two copies\n *\n * `apps/console` and `apps/tenant` each own a copy of\n * `/api/health/error-beacon`, and they must: the deployments carry different\n * admin credentials, so a console heartbeat proves nothing about the tenant\n * one. What they do NOT need is two copies of the policy. Before this the two\n * route files were byte-identical apart from a service name, which is the\n * arrangement that guarantees a fix reaches one door and not the other. The\n * per-deployment part (which credential, which service name, which memo) stays\n * in the routes; the grading lives here, once.\n *\n * ## The sequence, and why each step is where it is\n *\n * 1. Write. If it lands, record the durable marker and report green — the\n * marker being the evidence a LATER miss will be forgiven with.\n * 2. If it did not land and the code is a refusal — `http-401`/`http-403`\n * (a lost `logging.logEntries.create` grant), `http-429` (quota),\n * `no-credential` (the deployment env) — report red NOW, with no retry and\n * no grace. Those are permanent until a person acts, and re-asking a\n * credential that has been revoked only delays the page.\n * 3. Otherwise nothing was decided, so ask once more after a short delay. On a\n * cold lambda this is the step that actually fixes the measured incident:\n * the memo is per-instance, so a fresh instance has no history to be\n * forgiving WITH, and the only tolerance available inside the request is\n * another attempt.\n * 4. If the second attempt misses too, forgive only against the durable\n * marker — see {@link beaconHealth}, which will not forgive without proof.\n *\n * `attempts` and `ms` ride in the body on every outcome, including green ones,\n * because a retry that is quietly always needed is a credential on its way out\n * and a door that hid it would be back where it started.\n */\nexport async function beaconHeartbeatProbe(\n options: BeaconHeartbeatProbeOptions,\n): Promise<BeaconCheck> {\n const clock = options.now ?? Date.now\n const pause =\n options.sleep ??\n ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))\n const graceMs = options.graceMs ?? BEACON_HEARTBEAT_GRACE_MS\n const startedAt = clock()\n const service = options.service\n\n const write = options.write ?? writeBeaconHeartbeat\n\n const attempt = async (): Promise<BeaconHeartbeatResult | null> => {\n try {\n return await write({ service })\n } catch {\n // `writeBeaconHeartbeat` is documented never to throw; this is the belt\n // that keeps a monitoring probe from ever being the outage it reports.\n // A null result is degraded by contract — \"we could not determine\n // whether the beacon works\" IS the condition this endpoint exists to\n // catch, and unlike a missed write it is a broken contract rather than\n // a quiet network, so it is never one of the tolerated shapes.\n return null\n }\n }\n\n const graded = (\n write: BeaconHeartbeatResult | null,\n attempts: number,\n landedAtMs: number | null,\n ): BeaconCheck =>\n beaconHealth(write, BEACON_HEARTBEAT_LOG_ID, service, clock() - startedAt, {\n attempts,\n landedAtMs,\n graceMs,\n now: clock(),\n })\n\n const first = await attempt()\n if (first?.ok) {\n const landedAtMs = clock()\n recordBeaconHeartbeat(service, {\n now: landedAtMs,\n firestore: options.firestore,\n })\n return graded(first, 1, landedAtMs)\n }\n if (first === null || !isTransientBeaconCode(first.code)) {\n return graded(first, 1, null)\n }\n\n await pause(options.retryDelayMs ?? BEACON_HEARTBEAT_RETRY_DELAY_MS)\n\n const second = await attempt()\n if (second?.ok) {\n const landedAtMs = clock()\n recordBeaconHeartbeat(service, {\n now: landedAtMs,\n firestore: options.firestore,\n })\n return graded(second, 2, landedAtMs)\n }\n if (second === null || !isTransientBeaconCode(second.code)) {\n return graded(second, 2, null)\n }\n // Both attempts came back with nothing decided. The only thing that can\n // forgive that is proof another instance landed a heartbeat recently, and\n // a store that cannot answer is not proof.\n return graded(second, 2, await readBeaconHeartbeat(service, {\n firestore: options.firestore,\n }))\n}\n"],"names":["getApp","beaconHealth","deploymentCommitRef","deploymentEnvironmentLabel","isDeployedRuntime","isTransientBeaconCode","isForeignScriptStack","readBeaconHeartbeat","recordBeaconHeartbeat","recordServerError","MAX_EVENTS_PER_REQUEST","MAX_MESSAGE","MAX_STACK","MAX_URL","REPORT_TIMEOUT_MS","clampString","value","max","slice","scrubUrl","raw","url","URL","origin","pathname","isForeignOnlyStack","stack","length","parseClientErrorEvents","payload","events","Array","isArray","parsed","entry","event","message","push","kind","undefined","source","line","Math","trunc","toReportedEvent","service","version","hasStack","Boolean","includes","serviceContext","context","httpRequest","reportLocation","filePath","lineNumber","functionName","CLIENT_ERROR_LOG_ID","beaconLoggingTarget","resolveBeaconLoggingTarget","target","app","code","projectId","options","process","env","credential","token","getAccessToken","access_token","reportClientErrors","console","warn","JSON","stringify","tag","drop","reason","response","fetch","method","headers","Authorization","body","logName","resource","type","entries","map","severity","jsonPayload","signal","AbortSignal","timeout","ok","status","error","transport","String","SERVER_ERROR_LOG_ID","SERVER_ERROR_BUDGET_PER_WINDOW","SERVER_ERROR_WINDOW_MS","serverErrorWindowStartedAt","serverErrorsWritten","serverErrorsSuppressed","reportServerError","now","Date","suppressed","written","windowMs","route","routeType","digest","environment","BEACON_HEARTBEAT_LOG_ID","writeBeaconHeartbeat","name","BEACON_HEARTBEAT_RETRY_DELAY_MS","BEACON_HEARTBEAT_GRACE_MS","beaconHeartbeatProbe","clock","pause","sleep","ms","Promise","resolve","setTimeout","graceMs","startedAt","write","attempt","graded","attempts","landedAtMs","first","firestore","retryDelayMs","second"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/client-error-report.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\n/**\n * Server half of the first-party error beacon (AGL-1538): validates a\n * browser-posted batch of uncaught errors and forwards each to Google Cloud\n * Error Reporting, where grouping, retention, and the alerting policy live.\n *\n * Why Error Reporting rather than a vendor: Google is already a subprocessor\n * and the GCP monitoring/alerting stack already pages (AGL-1502) — a Sentry\n * would add a subprocessor and a DPA line item, which is a deliberately\n * deferred decision. Why a route in between rather than client-direct: the\n * API needs OAuth, and the route is also where the payload is disarmed —\n * clamped, capped, and stripped before anything leaves our origin.\n *\n * The caller is an unauthenticated browser, so every parsed field is clamped\n * here AGAIN regardless of what the client-side scrubber promised — the\n * beacon script is a promise, this is the boundary.\n *\n * Fail-soft throughout: a reporting failure logs one line and returns.\n * The one thing this module must never do is turn an observed error into a\n * served one.\n */\n\nimport { getApp } from 'firebase-admin/app'\n// Side-effect import: initializes the firebase-admin default app (cert\n// credential) exactly the way firebase-admin.ts does, so `getApp()` below\n// always finds it and its credential can mint the OAuth token.\nimport '@aglyn/shared-util-fbserver'\n// The readable half of the server-error signal (AGL-1921) — see the block\n// comment in `reportServerError`.\nimport {\n beaconHealth,\n deploymentCommitRef,\n deploymentEnvironmentLabel,\n isDeployedRuntime,\n isTransientBeaconCode,\n type BeaconCheck,\n} from '@aglyn/aglyn/server'\n// The scheme list the beacon and the CSP collector share (AGL-2786). A\n// subpath, not the `/server` barrel: specs that stub that barrel with a\n// hand-built factory would silently turn this into `undefined`.\nimport { isForeignScriptStack } from '@aglyn/aglyn/app-utils/foreign-script'\n\nimport {\n readBeaconHeartbeat,\n recordBeaconHeartbeat,\n recordServerError,\n} from './rate-limit-store'\n\nconst MAX_EVENTS_PER_REQUEST = 10\nconst MAX_MESSAGE = 1_024\nconst MAX_STACK = 8_192\nconst MAX_URL = 512\nconst REPORT_TIMEOUT_MS = 4_000\n\nexport interface ClientErrorEvent {\n kind: string\n message: string\n stack?: string\n source?: string\n line?: number\n url?: string\n}\n\nfunction clampString(value: unknown, max: number): string {\n return typeof value === 'string' ? value.slice(0, max) : ''\n}\n\n/** Origin + pathname only, server-enforced — never trust the client scrub. */\nfunction scrubUrl(value: unknown): string {\n const raw = clampString(value, MAX_URL)\n if (!raw) return ''\n try {\n const url = new URL(raw)\n return `${url.origin}${url.pathname}`\n } catch {\n return ''\n }\n}\n\n/**\n * Was this stack thrown with no code of ours on it, and was all of it seen?\n *\n * Every frame under a scheme no deployment of ours serves (AGL-2786) — an\n * in-app webview's bridge, an extension's content script. The beacon drops\n * these before sending; this is the half that also covers a bundle already\n * cached in a visitor's tab, which keeps posting whatever its copy of the rule\n * missed for as long as the tab lives.\n *\n * Only a stack that arrived WHOLE is judged. One at the clamp may be the head\n * of a longer stack, and the tail it lost could hold the frame that makes the\n * error ours.\n *\n * The beacon's other tell — every frame is the document — is deliberately not\n * applied here. This side has no page to compare against, and our own inline\n * code's handled errors carry the document's URL too.\n */\nfunction isForeignOnlyStack(stack: string): boolean {\n return stack.length > 0 && stack.length < MAX_STACK && isForeignScriptStack(stack)\n}\n\n/**\n * The events in a posted payload, clamped and capped. Anything malformed is\n * simply dropped — this endpoint never argues with a browser — and so is an\n * error none of our code threw (see {@link isForeignOnlyStack}).\n */\nexport function parseClientErrorEvents(payload: unknown): ClientErrorEvent[] {\n const events = (payload as { events?: unknown })?.events\n if (!Array.isArray(events)) return []\n const parsed: ClientErrorEvent[] = []\n for (const entry of events.slice(0, MAX_EVENTS_PER_REQUEST)) {\n if (!entry || typeof entry !== 'object') continue\n const event = entry as Record<string, unknown>\n const message = clampString(event.message, MAX_MESSAGE)\n if (!message) continue\n const stack = clampString(event.stack, MAX_STACK)\n if (isForeignOnlyStack(stack)) continue\n parsed.push({\n kind: clampString(event.kind, 32) || 'error',\n message,\n stack: stack || undefined,\n source: scrubUrl(event.source) || undefined,\n line: typeof event.line === 'number' ? Math.trunc(event.line) : undefined,\n url: scrubUrl(event.url) || undefined,\n })\n }\n return parsed\n}\n\n/**\n * Error Reporting groups by parsing the `message` as a stack trace; an event\n * WITHOUT a stack must carry `context.reportLocation` instead or ingestion\n * drops it. Both shapes are built here so every accepted beacon event is\n * representable.\n */\n/**\n * The stack, headed by the message when the stack does not already carry it.\n *\n * V8 opens a stack with `Name: message`; JavaScriptCore and SpiderMonkey do\n * not, so a Safari or Firefox report used to arrive as bare frames with no\n * message at all — the 2026-10-09 01:23Z `host-activity-write` page could not\n * say what had failed.\n */\nfunction stackWithMessage(stack: string, message: string | undefined): string {\n if (!message || stack.includes(message)) return stack\n return `Error: ${message}\\n${stack}`\n}\n\nfunction toReportedEvent(\n event: ClientErrorEvent,\n service: string,\n version: string | undefined,\n): Record<string, unknown> {\n const hasStack = Boolean(event.stack?.includes('\\n'))\n return {\n '@type':\n 'type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent',\n serviceContext: { service, version },\n message: hasStack\n ? stackWithMessage(event.stack as string, event.message)\n : `${event.kind}: ${event.message}`,\n // Outside `serviceContext` so Error Reporting ignores it while a log-match\n // policy can still filter on it — the same placement, for the same reason,\n // that the server half gives `route`/`method`/`digest`.\n //\n // Until AGL-2523 the kind reached the payload ONLY on the stackless path,\n // folded into `message` and `reportLocation.functionName`. Every stacked\n // error — which is nearly all of them — arrived unclassifiable, so the\n // `Client error beacon` policy had no way to express anything narrower\n // than \"any entry at all\". That is why it pages for one visitor whose\n // webview rewrote the DOM.\n kind: clampString(event.kind, 32) || 'error',\n context: {\n httpRequest: event.url ? { url: event.url } : undefined,\n ...(hasStack\n ? {}\n : {\n reportLocation: {\n filePath: event.source || 'unknown',\n lineNumber: event.line ?? 0,\n functionName: event.kind,\n },\n }),\n },\n }\n}\n\n/** The log the beacon writes; the alert policy and any triage query key on it. */\nexport const CLIENT_ERROR_LOG_ID = 'client-errors'\n\n/**\n * The admin credential and project the beacon writes under.\n *\n * Extracted (AGL-1923) so the heartbeat below mints its token through the\n * SAME path the reporter does. That sharing is the whole value of the\n * heartbeat: the failures it exists to catch — an expired service-account\n * key, a revoked `logging.logEntries.create`, an exhausted Logging quota —\n * are environmental, and a heartbeat that acquired its credential some other\n * way could report healthy while every real report was being dropped.\n *\n * Returns null when either half is missing; the caller decides what that\n * means (the reporter drops the batch, the heartbeat reports degraded).\n *\n * EXPORTED for the Vercel log-drain receiver (AGL-1921, third arm), which\n * writes to `vercel-runtime` through this same credential for the same\n * reason the heartbeat does: the failures that would silence the drain are\n * the environmental ones AGL-1923's heartbeat already probes, and it probes\n * them through exactly this path. A receiver that minted its own token would\n * need its own dead-man's switch.\n */\nexport async function beaconLoggingTarget(): Promise<{\n token: string\n projectId: string\n} | null> {\n return (await resolveBeaconLoggingTarget()).target\n}\n\n/** The credential, plus WHY there isn't one when there isn't. */\nexport interface BeaconTargetResolution {\n target: { token: string; projectId: string } | null\n /** Set only when `target` is null. */\n code?: 'no-credential' | 'credential-unavailable'\n}\n\n/**\n * The same resolution, saying which of two very different things went wrong\n * (AGL-2713).\n *\n * `no-credential` used to cover both, and the collapse is what made\n * `/api/health/error-beacon` cry wolf: one of the two is permanent and one is\n * a cold lambda's first outbound connection.\n *\n * - **`no-credential`** — there is no admin app, or it has no project, or it\n * has no credential object. `initializeApp` runs at module load and is\n * guarded on `FIREBASE_PRIVATE_KEY` + `FIREBASE_CLIENT_EMAIL` +\n * `NEXT_PUBLIC_FIREBASE_PROJECT_ID`, so this is a STATIC fact about the\n * deployment's environment: it cannot heal on the next request, and every\n * browser error is being dropped for as long as it holds. Red immediately —\n * which is exactly what the remedy text on the staff board already says\n * (`no-credential` is the deployment `FIREBASE_*` env).\n * - **`credential-unavailable`** — the app and the certificate are both there\n * and `getAccessToken()` did not produce a token on THIS attempt. That is a\n * JWT-bearer exchange against `oauth2.googleapis.com`, and on a cold\n * instance it is the first outbound TLS handshake the sandbox has ever made.\n * Nothing was decided, so nothing about the beacon has been established.\n */\nexport async function resolveBeaconLoggingTarget(): Promise<BeaconTargetResolution> {\n let app\n try {\n app = getApp()\n } catch {\n return { target: null, code: 'no-credential' }\n }\n const projectId = (app.options.projectId ??\n process.env['NEXT_PUBLIC_FIREBASE_PROJECT_ID']) as string | undefined\n const credential = app.options.credential\n if (!projectId || !credential) return { target: null, code: 'no-credential' }\n let token: string | undefined\n try {\n token = (await credential.getAccessToken())?.access_token\n } catch {\n token = undefined\n }\n if (!token) return { target: null, code: 'credential-unavailable' }\n return { target: { token, projectId } }\n}\n\n/**\n * Forwards events under the admin service account — via Cloud LOGGING, not\n * the Error Reporting `events:report` API, and the transport is the point:\n * a log entry whose payload carries the `ReportedErrorEvent` `@type` is\n * ingested by Error Reporting automatically (grouping, the Error Reporting\n * console, version tracking), while ALSO being a real log entry that a\n * Cloud Monitoring log-match alert policy can page on — `events:report`\n * produces no log entry and nothing standard to alert from. One batched\n * write per request instead of one call per event is the bonus.\n *\n * Returns how many events were written; failures are logged (one line) and\n * swallowed.\n */\nexport async function reportClientErrors(\n events: readonly ClientErrorEvent[],\n options: { service: string },\n): Promise<number> {\n if (!events.length) return 0\n\n /*==========================================\n * A LAPTOP IS NOT A DEPLOYMENT (AGL-1925, option 1).\n *\n * Measured 2026-08-18 over a trailing week of Error Reporting `groupStats`:\n * 13 of 17 events — 76% — were not production, and the single highest-ranked\n * \"production error group\" in the project was a dev-only artifact with\n * `http://localhost:4200` stack frames. Ranking is the whole point of Error\n * Reporting, so a majority-noise stream does not merely cost storage: it\n * aims triage at the wrong thing, and the `Client error beacon` policy\n * notifies its single human recipient for every uncaught error on any\n * developer's machine.\n *\n * The credential is why localhost reaches production at all: `firebase-admin`\n * is configured from the root `.env`, which names the platform project.\n *\n * Unlike the server half this stream carries NO `environment` stamp — the\n * only marker was `serviceContext.version` being absent off Vercel, which\n * did that job by accident and stopped doing it once `deploymentCommitRef`\n * started resolving a commit for self-hosted builds. So refusing the write\n * is not one of two options here; it is the only one that works at all.\n *\n * `isDeployedRuntime`, matching `reportServerError`: an operator's own\n * container reports its visitors' errors, and a developer's console already\n * shows them the error it would have sent.\n *==========================================*/\n if (!isDeployedRuntime()) return 0\n\n const target = await beaconLoggingTarget()\n const token = target?.token\n const projectId = target?.projectId\n if (!token || !projectId) {\n console.warn(\n JSON.stringify({ tag: 'AGL-1538:error-beacon', drop: events.length, reason: 'no-credential' }),\n )\n return 0\n }\n /*\n * Which BUILD this report came from, resolved the way `/api/health` resolves\n * it — `BUILD_ID`, then `COMMIT_REF`, then Vercel's own.\n *\n * This read `VERCEL_GIT_COMMIT_SHA` directly, which is set on Aglyn's cloud\n * and nowhere else, so every error a self-hosted deployment reported arrived\n * with no version at all while the health endpoint beside it named the\n * commit correctly. An error report you cannot tie to a build is an error\n * report you cannot act on.\n *\n * `undefined`, not `null`: this becomes Error Reporting's\n * `serviceContext.version`, where an absent key is omitted and an empty one\n * is a version.\n */\n const version = deploymentCommitRef() ?? undefined\n try {\n const response = await fetch('https://logging.googleapis.com/v2/entries:write', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n logName: `projects/${projectId}/logs/${CLIENT_ERROR_LOG_ID}`,\n resource: { type: 'global' },\n entries: events.map((event) => ({\n severity: 'ERROR',\n jsonPayload: toReportedEvent(event, options.service, version),\n })),\n }),\n signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),\n })\n if (!response.ok) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1538:error-beacon',\n status: response.status,\n drop: events.length,\n }),\n )\n return 0\n }\n return events.length\n } catch (error) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1538:error-beacon',\n transport: String(error).slice(0, 200),\n }),\n )\n return 0\n }\n}\n\n/**\n * The log the SERVER half writes to (AGL-1921).\n *\n * DELIBERATELY NOT `client-errors`. The `Client error beacon` policy is a\n * log-match on that log id, and a server 5xx is a different incident with a\n * different first move — a browser error is one visitor's broken render, a\n * 500 rate is every visitor's. Merging the streams would make the existing\n * policy fire for both and force triage to start by asking which it was.\n * A sibling log id also lets the counter this whole issue is about key on\n * server errors ALONE.\n */\nexport const SERVER_ERROR_LOG_ID = 'server-errors'\n\n/**\n * One server-side error, as `onRequestError` sees it.\n *\n * `route` is the route PATTERN (`/[host]/[scheme]/[[...slug]]`), never the resolved\n * path: the resolved path carries org slugs, document ids and whatever a\n * visitor typed into a URL, and this payload leaves our origin for a Google\n * log. The pattern is what you group and alert on anyway.\n */\nexport interface ServerErrorEvent {\n message: string\n stack?: string\n route?: string\n method?: string\n routeType?: string\n digest?: string\n}\n\n/**\n * Per-instance write budget (AGL-1921).\n *\n * The failure this exists to observe is a SPIKE, and a spike is exactly when\n * an unbounded reporter turns one incident into two — a 500 on a hot path\n * can run at request rate, and every entry is a billable Logging write on an\n * account whose whole monitoring budget is $20/month. The budget is per\n * instance and per minute, so the shape of the spike still reaches Logging\n * (the counter only needs to cross a threshold, not be exact) while the cost\n * stays bounded no matter how hard the incident pushes.\n *\n * Suppression is REPORTED, never silent: when the window rolls over, one\n * summary line records how many were dropped. A monitoring path that hides\n * its own lossiness is the thing this repo keeps finding.\n */\nconst SERVER_ERROR_BUDGET_PER_WINDOW = 60\nconst SERVER_ERROR_WINDOW_MS = 60_000\nlet serverErrorWindowStartedAt = 0\nlet serverErrorsWritten = 0\nlet serverErrorsSuppressed = 0\n\n/** What happened to one `reportServerError` call, for tests and callers. */\nexport type ServerErrorOutcome = 'written' | 'suppressed' | 'dropped'\n\n/**\n * Forwards ONE server-side error to Cloud Logging under the admin credential.\n *\n * This is AGL-1921's fallback arm, and it is a fallback on purpose: it cannot\n * see an error that kills the process before the handler runs, nor a\n * platform-level 5xx that never reaches our code at all. A Vercel log drain\n * would see both. What this does buy is the case that matters most and is\n * most likely — our own route handlers and renders throwing — reported\n * through a transport that already works, on an account we already pay for.\n * `docs/UPTIME_AND_SLA.md` carries the blind spots in writing, because\n * partial visibility whose gaps are documented beats none.\n *\n * It shares `beaconLoggingTarget` with the client reporter deliberately: that\n * makes AGL-1923's heartbeat a dead-man's switch for THIS path too. The\n * failures that would silence server-error reporting — expired key, revoked\n * `logging.logEntries.create`, exhausted quota — are the same environmental\n * ones the heartbeat already probes, and it probes them through this exact\n * credential.\n *\n * Fail-soft and never throws: this runs inside `onRequestError`, so throwing\n * here would turn an observed error into a second one during an incident.\n */\nexport async function reportServerError(\n event: ServerErrorEvent,\n options: { service: string },\n): Promise<ServerErrorOutcome> {\n const message = clampString(event.message, MAX_MESSAGE)\n if (!message) return 'dropped'\n\n /*==========================================\n * A LAPTOP IS NOT A DEPLOYMENT, AND THIS IS THE ONLY GATE THAT CAN SAY SO.\n *\n * ABOVE the marker below, unlike every other gate here, and the difference\n * is what is being refused. Those gates are about a write that FAILED and\n * must still be counted. This one is about a process that should not be\n * reporting at all: the count is as wrong as the log entry, because\n * `/api/health/server-errors` reads that marker out of the platform project\n * and would answer for a developer's terminal.\n *\n * The credential makes this reachable. `firebase-admin` is configured from\n * the root `.env`, which names the platform project, so `nx dev` writes to\n * production Cloud Logging under the same key production uses. Measured\n * 2026-08-31: three parse errors from an interrupted rebase — carrying\n * `file:///Users/.../aglyn/apps/...` stacks — landed in `server-errors` and\n * opened the `Server errors: uncaught 5xx` policy, which was still open two\n * days later while all thirteen uptime checks read 100%.\n *\n * `environment` was stamped for this (see the field below), but a stamp only\n * helps a reader who filters on it, and the one reader this log can have is\n * a hand-made alert policy that did not. Refusing the write is the half that\n * does not depend on somebody else's configuration.\n *\n * `isDeployedRuntime` and not `isProductionDeployment`, deliberately: a\n * preview's 5xx is a real 5xx served by a real deployment, and the stamp\n * already separates it for anyone who wants to.\n *==========================================*/\n if (!isDeployedRuntime()) return 'dropped'\n\n /*==========================================\n * COUNT IT WHERE SOMETHING CAN READ THE COUNT (AGL-1921, second pass).\n *\n * FIRST, above every gate below, and that ordering is the whole point.\n *\n * The Logging entry this function goes on to write is the right record for\n * triage and the wrong one for an alarm we can ship: measured 2026-08-24,\n * the production credential is refused `entries:list` on that log with\n * `403 Permission denied for all log views`, so nothing in this repo can\n * read back what it wrote. The only reader the log can ever have is a GCP\n * alert policy somebody creates by hand.\n *\n * The marker is readable today, and putting it above the budget gate, the\n * credential check and the fetch means the count survives every way the\n * Logging arm can fail. That is not tidiness — a beacon whose transport is\n * dead reports ZERO errors, which is the measured-zero shape this repo keeps\n * finding, and it would report it during exactly the incident that killed\n * the transport.\n *\n * Never awaited: this runs inside `onRequestError`, and the marker coalesces\n * in process anyway (at most one write per five seconds per instance).\n *==========================================*/\n recordServerError(options.service)\n\n const now = Date.now()\n if (now - serverErrorWindowStartedAt >= SERVER_ERROR_WINDOW_MS) {\n if (serverErrorsSuppressed > 0) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1921:server-error-beacon',\n suppressed: serverErrorsSuppressed,\n written: serverErrorsWritten,\n windowMs: SERVER_ERROR_WINDOW_MS,\n }),\n )\n }\n serverErrorWindowStartedAt = now\n serverErrorsWritten = 0\n serverErrorsSuppressed = 0\n }\n if (serverErrorsWritten >= SERVER_ERROR_BUDGET_PER_WINDOW) {\n serverErrorsSuppressed += 1\n return 'suppressed'\n }\n\n const target = await beaconLoggingTarget()\n if (!target) {\n console.warn(\n JSON.stringify({ tag: 'AGL-1921:server-error-beacon', drop: 1, reason: 'no-credential' }),\n )\n return 'dropped'\n }\n\n const stack = clampString(event.stack, MAX_STACK)\n const hasStack = stack.includes('\\n')\n const version = deploymentCommitRef() ?? undefined\n try {\n const response = await fetch('https://logging.googleapis.com/v2/entries:write', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${target.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n logName: `projects/${target.projectId}/logs/${SERVER_ERROR_LOG_ID}`,\n resource: { type: 'global' },\n entries: [\n {\n severity: 'ERROR',\n jsonPayload: {\n '@type':\n 'type.googleapis.com/google.devtools.clouderrorreporting.v1beta1.ReportedErrorEvent',\n serviceContext: { service: options.service, version },\n // Same rule as the client half: Error Reporting groups by\n // parsing `message` as a stack trace, and an event without one\n // must carry `context.reportLocation` or ingestion drops it.\n message: hasStack ? stack : message,\n context: {\n ...(hasStack\n ? {}\n : {\n reportLocation: {\n filePath: clampString(event.route, MAX_URL) || 'unknown',\n lineNumber: 0,\n functionName: clampString(event.routeType, 32) || 'server',\n },\n }),\n },\n // Outside `serviceContext` so Error Reporting ignores them and\n // a log-match policy can still filter on them.\n route: clampString(event.route, MAX_URL) || undefined,\n method: clampString(event.method, 16) || undefined,\n digest: clampString(event.digest, 64) || undefined,\n // WHICH DEPLOYMENT THREW, and the log-match policy has no other\n // way to ask. The credential comes from the environment, so a\n // developer serving the console locally against the platform\n // project writes here under the same key as production and\n // lands in the same log — a throw on a laptop is then\n // indistinguishable from a 500 served to a customer. The\n // heartbeat beside this already stamps it for that reason.\n environment: deploymentEnvironmentLabel(),\n },\n },\n ],\n }),\n signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),\n })\n if (!response.ok) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1921:server-error-beacon',\n status: response.status,\n drop: 1,\n }),\n )\n return 'dropped'\n }\n serverErrorsWritten += 1\n return 'written'\n } catch (error) {\n console.warn(\n JSON.stringify({\n tag: 'AGL-1921:server-error-beacon',\n transport: String(error).slice(0, 200),\n }),\n )\n return 'dropped'\n }\n}\n\n/**\n * The log the beacon's heartbeat is written to (AGL-1923).\n *\n * DELIBERATELY NOT `client-errors`. The `Client error beacon` alert policy is\n * a log-match on `logName=\"…/client-errors\" AND severity>=ERROR`, so a\n * heartbeat written there would page the on-call owner on every probe — building the\n * alert-fatigue mechanism the heartbeat exists to protect against. A separate\n * log id at INFO keeps the two streams from ever being confused, and keeps\n * the payload out of Error Reporting (which ingests on the\n * `ReportedErrorEvent` `@type`, absent here on purpose).\n */\nexport const BEACON_HEARTBEAT_LOG_ID = 'client-error-beacon-heartbeat'\n\n/** What a heartbeat attempt proved, for the health verdict to reduce. */\nexport interface BeaconHeartbeatResult {\n /** Did the entry actually reach Cloud Logging? */\n ok: boolean\n /** A stable code on failure — never a raw error message. */\n code?: string\n}\n\n/**\n * Write one heartbeat entry through the beacon's own transport (AGL-1923).\n *\n * ## Why this exists\n *\n * `reportClientErrors` is fail-soft by design and must stay that way — it may\n * never turn an observed error into a served one. But every one of its\n * failure paths ends in a `console.warn` to the Vercel runtime log, which\n * retains about an hour and drains nowhere (AGL-1799). So the beacon can fail\n * completely and the only readings anyone has are \"Error Reporting shows zero\n * errors\" and \"the log-match policy is silent\" — which are exactly the\n * readings a clean launch produces. **A dead beacon is indistinguishable from\n * zero errors.**\n *\n * A log-match policy can only ever report presence. The fix is a signal whose\n * ABSENCE is detectable, and the cheapest one that needs no new vendor is a\n * write that a health endpoint can grade synchronously: this returns whether\n * the write landed, `/api/health/error-beacon` turns that into the 200/503\n * contract the sibling health checks already speak, and the existing uptime\n * check + alert + email path becomes the listener. The uptime probe is what\n * winds the dead-man's switch, so there is no cron to forget.\n *\n * ## What it can and cannot prove\n *\n * It proves the credential mints, the IAM grant still covers\n * `logging.logEntries.create`, the Logging API answers, and the quota is not\n * exhausted — the whole environmental failure class AGL-1923 names. It does\n * NOT prove a browser can reach `/api/errors`, and it does not prove the\n * payload shape Error Reporting ingests on, because it deliberately does not\n * write that shape.\n *\n * ## Clearing\n *\n * The condition it raises is an EVENT, not a state: the next successful write\n * clears it, within one probe TTL. Nothing here can latch (AGL-1843).\n *\n * Never throws. A heartbeat that could 500 a health endpoint would be a\n * monitoring probe that causes the outage it reports.\n */\nexport async function writeBeaconHeartbeat(options: {\n service: string\n}): Promise<BeaconHeartbeatResult> {\n const { target, code } = await resolveBeaconLoggingTarget()\n if (!target) return { ok: false, code: code ?? 'no-credential' }\n try {\n const response = await fetch('https://logging.googleapis.com/v2/entries:write', {\n method: 'POST',\n headers: {\n Authorization: `Bearer ${target.token}`,\n 'Content-Type': 'application/json',\n },\n body: JSON.stringify({\n logName: `projects/${target.projectId}/logs/${BEACON_HEARTBEAT_LOG_ID}`,\n resource: { type: 'global' },\n entries: [\n {\n severity: 'INFO',\n jsonPayload: {\n // No `@type`: this must not be ingested by Error Reporting.\n tag: 'AGL-1923:beacon-heartbeat',\n service: options.service,\n version: deploymentCommitRef(),\n environment: deploymentEnvironmentLabel(),\n },\n },\n ],\n }),\n signal: AbortSignal.timeout(REPORT_TIMEOUT_MS),\n })\n // The STATUS only. The health body is public and a Google error message\n // can carry project ids and resource paths — the same rule the backups\n // probe follows.\n return response.ok ? { ok: true } : { ok: false, code: `http-${response.status}` }\n } catch (error) {\n // Codes, not messages: `TimeoutError` for the 4s abort, whatever the\n // fetch layer names for a transport failure.\n return {\n ok: false,\n code: `transport-${String((error as { name?: string })?.name ?? 'unknown')}`,\n }\n }\n}\n\n/**\n * How long the probe waits before its second attempt (AGL-2713).\n *\n * Long enough for a JWT-bearer exchange that lost its first connection to get\n * another one, short enough that it is invisible next to a probe already\n * budgeted at 4s for the Logging write. It is a delay, not a backoff schedule:\n * a second attempt either establishes something or it does not, and a third\n * would only spend a public endpoint's time proving the same point.\n */\nexport const BEACON_HEARTBEAT_RETRY_DELAY_MS = 250\n\n/**\n * How long a missed heartbeat is forgiven after the last one that landed.\n *\n * One monitor interval. The uptime workflow reads this door every 15 minutes,\n * so a grace of exactly that means the FIRST probe after a miss can be\n * forgiven and the second cannot — \"tolerate a single miss, red on a\n * sustained one\", stated in the only unit the reader actually samples in.\n *\n * The bound this puts on a real outage: the marker is refreshed by every\n * successful probe, and this endpoint is polled every 5 minutes, so the last\n * landing is at most one probe TTL old when a sustained failure starts. Worst\n * case the door reds 20 minutes later than it does today, and the 15-minute\n * workflow sees it on its next run.\n */\nexport const BEACON_HEARTBEAT_GRACE_MS = 15 * 60_000\n\n/** Injectable clock, sleep, writer and store, so a spec can drive every branch. */\nexport interface BeaconHeartbeatProbeOptions {\n service: string\n now?: () => number\n sleep?: (ms: number) => Promise<void>\n firestore?: any\n graceMs?: number\n retryDelayMs?: number\n /**\n * The heartbeat writer. Defaults to the real one, and exists as a seam\n * because the belt below catches a contract `writeBeaconHeartbeat` is\n * documented never to break — a branch that is otherwise unreachable, and\n * therefore one nobody has ever watched work.\n */\n write?: (options: { service: string }) => Promise<BeaconHeartbeatResult>\n}\n\n/**\n * One heartbeat probe, graded — the whole of what both doors do (AGL-2713).\n *\n * ## Why this is one function and not two copies\n *\n * `apps/console` and `apps/tenant` each own a copy of\n * `/api/health/error-beacon`, and they must: the deployments carry different\n * admin credentials, so a console heartbeat proves nothing about the tenant\n * one. What they do NOT need is two copies of the policy. Before this the two\n * route files were byte-identical apart from a service name, which is the\n * arrangement that guarantees a fix reaches one door and not the other. The\n * per-deployment part (which credential, which service name, which memo) stays\n * in the routes; the grading lives here, once.\n *\n * ## The sequence, and why each step is where it is\n *\n * 1. Write. If it lands, record the durable marker and report green — the\n * marker being the evidence a LATER miss will be forgiven with.\n * 2. If it did not land and the code is a refusal — `http-401`/`http-403`\n * (a lost `logging.logEntries.create` grant), `http-429` (quota),\n * `no-credential` (the deployment env) — report red NOW, with no retry and\n * no grace. Those are permanent until a person acts, and re-asking a\n * credential that has been revoked only delays the page.\n * 3. Otherwise nothing was decided, so ask once more after a short delay. On a\n * cold lambda this is the step that actually fixes the measured incident:\n * the memo is per-instance, so a fresh instance has no history to be\n * forgiving WITH, and the only tolerance available inside the request is\n * another attempt.\n * 4. If the second attempt misses too, forgive only against the durable\n * marker — see {@link beaconHealth}, which will not forgive without proof.\n *\n * `attempts` and `ms` ride in the body on every outcome, including green ones,\n * because a retry that is quietly always needed is a credential on its way out\n * and a door that hid it would be back where it started.\n */\nexport async function beaconHeartbeatProbe(\n options: BeaconHeartbeatProbeOptions,\n): Promise<BeaconCheck> {\n const clock = options.now ?? Date.now\n const pause =\n options.sleep ??\n ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)))\n const graceMs = options.graceMs ?? BEACON_HEARTBEAT_GRACE_MS\n const startedAt = clock()\n const service = options.service\n\n const write = options.write ?? writeBeaconHeartbeat\n\n const attempt = async (): Promise<BeaconHeartbeatResult | null> => {\n try {\n return await write({ service })\n } catch {\n // `writeBeaconHeartbeat` is documented never to throw; this is the belt\n // that keeps a monitoring probe from ever being the outage it reports.\n // A null result is degraded by contract — \"we could not determine\n // whether the beacon works\" IS the condition this endpoint exists to\n // catch, and unlike a missed write it is a broken contract rather than\n // a quiet network, so it is never one of the tolerated shapes.\n return null\n }\n }\n\n const graded = (\n write: BeaconHeartbeatResult | null,\n attempts: number,\n landedAtMs: number | null,\n ): BeaconCheck =>\n beaconHealth(write, BEACON_HEARTBEAT_LOG_ID, service, clock() - startedAt, {\n attempts,\n landedAtMs,\n graceMs,\n now: clock(),\n })\n\n const first = await attempt()\n if (first?.ok) {\n const landedAtMs = clock()\n recordBeaconHeartbeat(service, {\n now: landedAtMs,\n firestore: options.firestore,\n })\n return graded(first, 1, landedAtMs)\n }\n if (first === null || !isTransientBeaconCode(first.code)) {\n return graded(first, 1, null)\n }\n\n await pause(options.retryDelayMs ?? BEACON_HEARTBEAT_RETRY_DELAY_MS)\n\n const second = await attempt()\n if (second?.ok) {\n const landedAtMs = clock()\n recordBeaconHeartbeat(service, {\n now: landedAtMs,\n firestore: options.firestore,\n })\n return graded(second, 2, landedAtMs)\n }\n if (second === null || !isTransientBeaconCode(second.code)) {\n return graded(second, 2, null)\n }\n // Both attempts came back with nothing decided. The only thing that can\n // forgive that is proof another instance landed a heartbeat recently, and\n // a store that cannot answer is not proof.\n return graded(second, 2, await readBeaconHeartbeat(service, {\n firestore: options.firestore,\n }))\n}\n"],"names":["getApp","beaconHealth","deploymentCommitRef","deploymentEnvironmentLabel","isDeployedRuntime","isTransientBeaconCode","isForeignScriptStack","readBeaconHeartbeat","recordBeaconHeartbeat","recordServerError","MAX_EVENTS_PER_REQUEST","MAX_MESSAGE","MAX_STACK","MAX_URL","REPORT_TIMEOUT_MS","clampString","value","max","slice","scrubUrl","raw","url","URL","origin","pathname","isForeignOnlyStack","stack","length","parseClientErrorEvents","payload","events","Array","isArray","parsed","entry","event","message","push","kind","undefined","source","line","Math","trunc","stackWithMessage","includes","toReportedEvent","service","version","hasStack","Boolean","serviceContext","context","httpRequest","reportLocation","filePath","lineNumber","functionName","CLIENT_ERROR_LOG_ID","beaconLoggingTarget","resolveBeaconLoggingTarget","target","app","code","projectId","options","process","env","credential","token","getAccessToken","access_token","reportClientErrors","console","warn","JSON","stringify","tag","drop","reason","response","fetch","method","headers","Authorization","body","logName","resource","type","entries","map","severity","jsonPayload","signal","AbortSignal","timeout","ok","status","error","transport","String","SERVER_ERROR_LOG_ID","SERVER_ERROR_BUDGET_PER_WINDOW","SERVER_ERROR_WINDOW_MS","serverErrorWindowStartedAt","serverErrorsWritten","serverErrorsSuppressed","reportServerError","now","Date","suppressed","written","windowMs","route","routeType","digest","environment","BEACON_HEARTBEAT_LOG_ID","writeBeaconHeartbeat","name","BEACON_HEARTBEAT_RETRY_DELAY_MS","BEACON_HEARTBEAT_GRACE_MS","beaconHeartbeatProbe","clock","pause","sleep","ms","Promise","resolve","setTimeout","graceMs","startedAt","write","attempt","graded","attempts","landedAtMs","first","firestore","retryDelayMs","second"],"mappings":"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"}
|
|
@@ -70,6 +70,22 @@ export interface ConsoleApiRateLimitOptions {
|
|
|
70
70
|
* would also make the surface unlimited for the accounts most likely to be
|
|
71
71
|
* driving it from a script.
|
|
72
72
|
*
|
|
73
|
+
* ## The cron secret is exempt, verified
|
|
74
|
+
*
|
|
75
|
+
* A request that presents the console's `CRON_SECRET` is the platform's own
|
|
76
|
+
* scheduler, and it is not counted. It carries no Firebase token, so without
|
|
77
|
+
* this it fell into the client-address bucket, shared with every other
|
|
78
|
+
* tokenless caller behind the same Cloud Functions egress. On 2026-10-09 at
|
|
79
|
+
* 14:00Z `consoleAiInsightsDigest` was refused there with a 429 while the
|
|
80
|
+
* hourly crons fired beside it, and the day's digests went undelivered.
|
|
81
|
+
*
|
|
82
|
+
* The list of machine paths stays the first answer (a route that knows it is
|
|
83
|
+
* a machine's says so), and this is the backstop for one that forgot: a
|
|
84
|
+
* scheduler holding the secret could already run every cron route on the
|
|
85
|
+
* console, so counting it bounds nothing an attacker could reach. What is
|
|
86
|
+
* exempt is the VERIFIED secret, compared in constant time — never the mere
|
|
87
|
+
* presence of the header, which would let any caller switch the limiter off.
|
|
88
|
+
*
|
|
73
89
|
* ## What the caller is told
|
|
74
90
|
*
|
|
75
91
|
* A bare 429 with `Retry-After`, following `visitorWriteRateLimitRefusal` and
|