@aglyn/tenant-data-admin 1.0.0-beta.231 → 1.0.0-beta.233
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-acquisition.d.ts +30 -0
- package/src/lib/server/account-acquisition.js +37 -2
- package/src/lib/server/account-acquisition.js.map +1 -1
- package/src/lib/server/account-lock-mail.d.ts +17 -0
- package/src/lib/server/account-lock-mail.js +28 -1
- package/src/lib/server/account-lock-mail.js.map +1 -1
- package/src/lib/server/api-keys.d.ts +14 -1
- package/src/lib/server/api-keys.js +28 -0
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/content-entry-drafts.d.ts +131 -0
- package/src/lib/server/content-entry-drafts.js +307 -0
- package/src/lib/server/content-entry-drafts.js.map +1 -0
- package/src/lib/server/email-delivery-log.js +18 -2
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/list-members.js +7 -2
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/operator-alerts-log.d.ts +31 -0
- package/src/lib/server/operator-alerts-log.js +87 -0
- package/src/lib/server/operator-alerts-log.js.map +1 -0
- package/src/lib/server/operator-alerts.js +3 -0
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/personal-data-export.js +123 -1
- package/src/lib/server/personal-data-export.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.233",
|
|
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.233",
|
|
29
|
+
"@aglyn/shared-util-email": "1.0.0-beta.233",
|
|
30
|
+
"@aglyn/shared-util-fbserver": "1.0.0-beta.233",
|
|
31
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.233",
|
|
32
|
+
"@aglyn/shared-util-http": "1.0.0-beta.233",
|
|
33
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.233",
|
|
34
34
|
"@msgpack/msgpack": "^3.1.3",
|
|
35
35
|
"@swc/helpers": "0.5.23",
|
|
36
36
|
"sharp": "^0.35.5",
|
|
@@ -82,3 +82,33 @@ export declare function requestGeography(headers: HeaderReader): {
|
|
|
82
82
|
};
|
|
83
83
|
/** Record the account's acquisition unless it already has one. */
|
|
84
84
|
export declare function recordAccountAcquisition(input: RecordAccountAcquisitionInput): Promise<RecordAccountAcquisitionResult>;
|
|
85
|
+
/** What a sign-up door knows about the account it is recording. */
|
|
86
|
+
export interface RecordSignUpAcquisitionInput {
|
|
87
|
+
uid: string;
|
|
88
|
+
/** The verified token's `sign_in_provider`. */
|
|
89
|
+
provider: string | null;
|
|
90
|
+
/** The verified token's address. */
|
|
91
|
+
email: string | null;
|
|
92
|
+
/** The first touch the page sent, else the request's cookie — untrusted. */
|
|
93
|
+
touch: unknown;
|
|
94
|
+
/** Which of the provider's doors the page says it was; see `signUpDoorFor`. */
|
|
95
|
+
doorHint?: unknown;
|
|
96
|
+
headers: HeaderReader;
|
|
97
|
+
nowMs?: number;
|
|
98
|
+
firestore?: FirebaseFirestore.Firestore;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Record a self-serve sign-up's acquisition, deciding from the verified
|
|
102
|
+
* provider and the auth record whether there is one to record.
|
|
103
|
+
*
|
|
104
|
+
* Shared by the two moments a sign-up passes through: the sign-up page's own
|
|
105
|
+
* call, right after the account exists, and workspace creation, which is the
|
|
106
|
+
* backstop for a page torn down before its call went out — the workspace
|
|
107
|
+
* copies its creator's record at birth, so that is the last moment one can
|
|
108
|
+
* still reach it. Both write through {@link recordAccountAcquisition}, so the
|
|
109
|
+
* first one wins and neither can restate the other; an account older than
|
|
110
|
+
* the window, or one a sign-up door did not create, writes nothing.
|
|
111
|
+
*
|
|
112
|
+
* `null` when the provider is not a sign-up door (SSO records its own).
|
|
113
|
+
*/
|
|
114
|
+
export declare function recordSignUpAcquisition(input: RecordSignUpAcquisitionInput): Promise<RecordAccountAcquisitionResult | null>;
|
|
@@ -13,8 +13,9 @@
|
|
|
13
13
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
|
-
*/ import { ACCOUNT_ACQUISITION_FIELD, buildAccountAcquisition, readAccountAcquisition } from "@aglyn/aglyn/app-utils/account-acquisition";
|
|
16
|
+
*/ import { ACCOUNT_ACQUISITION_FIELD, buildAccountAcquisition, readAccountAcquisition, signUpDoorFor } from "@aglyn/aglyn/app-utils/account-acquisition";
|
|
17
17
|
import { readRequestCity, readRequestGeo, readRequestRegionLabel } from "@aglyn/aglyn/app-utils/request-geo";
|
|
18
|
+
import { findUserByUidAcrossPools } from "./auth-pools.js";
|
|
18
19
|
import firebaseAdmin from "./firebase-admin.js";
|
|
19
20
|
/**
|
|
20
21
|
* Write where an account came from, ONCE, at account creation (AGL-3289).
|
|
@@ -71,7 +72,7 @@ import firebaseAdmin from "./firebase-admin.js";
|
|
|
71
72
|
status: 'not-new'
|
|
72
73
|
};
|
|
73
74
|
}
|
|
74
|
-
const invitedToOrgId = (_input_invitedToOrgId = input.invitedToOrgId) != null ? _input_invitedToOrgId : input.door === 'signup-password' || input.door === 'signup-google' ? await pendingInvitationOrgId(input.email, firestore) : null;
|
|
75
|
+
const invitedToOrgId = (_input_invitedToOrgId = input.invitedToOrgId) != null ? _input_invitedToOrgId : input.door === 'signup-password' || input.door === 'signup-google' || input.door === 'signup-google-redirect' ? await pendingInvitationOrgId(input.email, firestore) : null;
|
|
75
76
|
const record = buildAccountAcquisition({
|
|
76
77
|
touch: input.touch,
|
|
77
78
|
door: invitedToOrgId && input.door !== 'sso' ? 'invite' : input.door,
|
|
@@ -103,5 +104,39 @@ import firebaseAdmin from "./firebase-admin.js";
|
|
|
103
104
|
};
|
|
104
105
|
});
|
|
105
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Record a self-serve sign-up's acquisition, deciding from the verified
|
|
109
|
+
* provider and the auth record whether there is one to record.
|
|
110
|
+
*
|
|
111
|
+
* Shared by the two moments a sign-up passes through: the sign-up page's own
|
|
112
|
+
* call, right after the account exists, and workspace creation, which is the
|
|
113
|
+
* backstop for a page torn down before its call went out — the workspace
|
|
114
|
+
* copies its creator's record at birth, so that is the last moment one can
|
|
115
|
+
* still reach it. Both write through {@link recordAccountAcquisition}, so the
|
|
116
|
+
* first one wins and neither can restate the other; an account older than
|
|
117
|
+
* the window, or one a sign-up door did not create, writes nothing.
|
|
118
|
+
*
|
|
119
|
+
* `null` when the provider is not a sign-up door (SSO records its own).
|
|
120
|
+
*/ export async function recordSignUpAcquisition(input) {
|
|
121
|
+
var _ref;
|
|
122
|
+
const door = signUpDoorFor(input.provider, input.doorHint);
|
|
123
|
+
if (!door) return null;
|
|
124
|
+
// Across every pool, not the project's alone (AGL-1122): a lookup that
|
|
125
|
+
// cannot see an account answers "not new", which writes nothing.
|
|
126
|
+
const pooled = await findUserByUidAcrossPools(input.uid);
|
|
127
|
+
const createdAtMs = Date.parse((_ref = pooled == null ? void 0 : pooled.record.metadata.creationTime) != null ? _ref : '');
|
|
128
|
+
return recordAccountAcquisition({
|
|
129
|
+
uid: input.uid,
|
|
130
|
+
accountCreatedAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null,
|
|
131
|
+
touch: input.touch,
|
|
132
|
+
door,
|
|
133
|
+
provider: input.provider,
|
|
134
|
+
email: input.email,
|
|
135
|
+
headers: input.headers,
|
|
136
|
+
recordedBy: 'signup',
|
|
137
|
+
nowMs: input.nowMs,
|
|
138
|
+
firestore: input.firestore
|
|
139
|
+
});
|
|
140
|
+
}
|
|
106
141
|
|
|
107
142
|
//# sourceMappingURL=account-acquisition.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/account-acquisition.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n ACCOUNT_ACQUISITION_FIELD,\n buildAccountAcquisition,\n readAccountAcquisition,\n type AccountAcquisition,\n type AcquisitionDoor,\n} from '@aglyn/aglyn/app-utils/account-acquisition'\nimport {\n readRequestCity,\n readRequestGeo,\n readRequestRegionLabel,\n type HeaderReader,\n} from '@aglyn/aglyn/app-utils/request-geo'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * Write where an account came from, ONCE, at account creation (AGL-3289).\n *\n * The capture kept the first touch on the visitor's device; the door that\n * created the account hands it here, with what only the server knows beside\n * it: the door, the sign-in provider from the verified token, where the\n * request came from as the edge reported it, and whether the address had an\n * invitation waiting. The record goes on `users/{uid}` inside a transaction\n * that writes nothing when one is already there — the first write wins, and\n * a retry, a second tab or a later call can never restate it.\n *\n * The rules deny the field to every client, so this Admin SDK write is the\n * only way in. `createOrganization` copies it onto the workspace the account\n * creates.\n */\n\n/**\n * How long after an account is created its door may still record where it\n * came from. A door calls within seconds of creating the account; a mobile\n * redirect or a slow verification page within minutes. Anything later is not\n * account creation, and a record written then would describe a later visit.\n */\nexport const ACCOUNT_ACQUISITION_WINDOW_MS = 30 * 60 * 1000\n\n/** What a door knows when it records an account. */\nexport interface RecordAccountAcquisitionInput {\n uid: string\n /** When the auth record says the account was created; null refuses. */\n accountCreatedAtMs: number | null\n /** The first touch the device kept — untrusted. */\n touch: unknown\n door: AcquisitionDoor\n /** From the verified token's `sign_in_provider`. */\n provider: string | null\n /** The account's address, for the invitation check; null when it has none. */\n email: string | null\n /** The organization an invitation the door already found came from. */\n invitedToOrgId?: string | null\n /** The account-creating request's headers, for its geography. */\n headers: HeaderReader\n recordedBy: 'signup' | 'sso'\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}\n\nexport type RecordAccountAcquisitionResult =\n | { status: 'recorded'; record: AccountAcquisition }\n | { status: 'exists'; record: AccountAcquisition | null }\n /** The account was not created just now, so this is not its creation. */\n | { status: 'not-new' }\n\n/**\n * The organization whose invitation this address has waiting, or null. The\n * same query the console's invitation banner runs, one address wide.\n */\nexport async function pendingInvitationOrgId(\n email: string | null,\n firestore: FirebaseFirestore.Firestore = firebaseAdmin.app().firestore(),\n): Promise<string | null> {\n const address = String(email ?? '').trim().toLowerCase()\n if (!address) return null\n try {\n const snapshot = await firestore\n .collectionGroup('invites')\n .where('email', 'in', [address])\n .where('acceptedAt', '==', null)\n .limit(1)\n .get()\n return snapshot.docs[0]?.ref.parent.parent?.id ?? null\n } catch (error) {\n // An unanswerable question costs the door's label, never the record.\n console.error('[account-acquisition] invitation lookup failed', error)\n return null\n }\n}\n\n/** Where the request came from, as a person reads it — no address, no coordinates. */\nexport function requestGeography(headers: HeaderReader): {\n country: string | null\n region: string | null\n city: string | null\n} {\n return {\n country: readRequestGeo(headers).country,\n region: readRequestRegionLabel(headers),\n city: readRequestCity(headers),\n }\n}\n\n/** Record the account's acquisition unless it already has one. */\nexport async function recordAccountAcquisition(\n input: RecordAccountAcquisitionInput,\n): Promise<RecordAccountAcquisitionResult> {\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n const nowMs = input.nowMs ?? Date.now()\n if (\n input.accountCreatedAtMs === null ||\n !Number.isFinite(input.accountCreatedAtMs) ||\n nowMs - input.accountCreatedAtMs > ACCOUNT_ACQUISITION_WINDOW_MS\n ) {\n return { status: 'not-new' }\n }\n const invitedToOrgId =\n input.invitedToOrgId ??\n (input.door === 'signup-password' || input.door === 'signup-google'\n ? await pendingInvitationOrgId(input.email, firestore)\n : null)\n const record = buildAccountAcquisition({\n touch: input.touch,\n door: invitedToOrgId && input.door !== 'sso' ? 'invite' : input.door,\n provider: input.provider,\n geo: requestGeography(input.headers),\n invitedToOrgId,\n accountCreatedAtMs: input.accountCreatedAtMs,\n recordedBy: input.recordedBy,\n nowMs,\n })\n const userRef = firestore.collection('users').doc(input.uid)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(userRef)\n const existing = snapshot.exists ? snapshot.get(ACCOUNT_ACQUISITION_FIELD) : undefined\n if (existing !== undefined && existing !== null) {\n return { status: 'exists', record: readAccountAcquisition(existing) } as const\n }\n tx.set(userRef, { [ACCOUNT_ACQUISITION_FIELD]: record }, { merge: true })\n return { status: 'recorded', record } as const\n })\n}\n"],"names":["ACCOUNT_ACQUISITION_FIELD","buildAccountAcquisition","readAccountAcquisition","readRequestCity","readRequestGeo","readRequestRegionLabel","firebaseAdmin","ACCOUNT_ACQUISITION_WINDOW_MS","pendingInvitationOrgId","email","firestore","app","address","String","trim","toLowerCase","snapshot","collectionGroup","where","limit","get","docs","ref","parent","id","error","console","requestGeography","headers","country","region","city","recordAccountAcquisition","input","nowMs","Date","now","accountCreatedAtMs","Number","isFinite","status","invitedToOrgId","door","record","touch","provider","geo","recordedBy","userRef","collection","doc","uid","runTransaction","tx","existing","exists","undefined","set","merge"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/account-acquisition.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n ACCOUNT_ACQUISITION_FIELD,\n buildAccountAcquisition,\n readAccountAcquisition,\n signUpDoorFor,\n type AccountAcquisition,\n type AcquisitionDoor,\n} from '@aglyn/aglyn/app-utils/account-acquisition'\nimport {\n readRequestCity,\n readRequestGeo,\n readRequestRegionLabel,\n type HeaderReader,\n} from '@aglyn/aglyn/app-utils/request-geo'\nimport { findUserByUidAcrossPools } from './auth-pools'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * Write where an account came from, ONCE, at account creation (AGL-3289).\n *\n * The capture kept the first touch on the visitor's device; the door that\n * created the account hands it here, with what only the server knows beside\n * it: the door, the sign-in provider from the verified token, where the\n * request came from as the edge reported it, and whether the address had an\n * invitation waiting. The record goes on `users/{uid}` inside a transaction\n * that writes nothing when one is already there — the first write wins, and\n * a retry, a second tab or a later call can never restate it.\n *\n * The rules deny the field to every client, so this Admin SDK write is the\n * only way in. `createOrganization` copies it onto the workspace the account\n * creates.\n */\n\n/**\n * How long after an account is created its door may still record where it\n * came from. A door calls within seconds of creating the account; a mobile\n * redirect or a slow verification page within minutes. Anything later is not\n * account creation, and a record written then would describe a later visit.\n */\nexport const ACCOUNT_ACQUISITION_WINDOW_MS = 30 * 60 * 1000\n\n/** What a door knows when it records an account. */\nexport interface RecordAccountAcquisitionInput {\n uid: string\n /** When the auth record says the account was created; null refuses. */\n accountCreatedAtMs: number | null\n /** The first touch the device kept — untrusted. */\n touch: unknown\n door: AcquisitionDoor\n /** From the verified token's `sign_in_provider`. */\n provider: string | null\n /** The account's address, for the invitation check; null when it has none. */\n email: string | null\n /** The organization an invitation the door already found came from. */\n invitedToOrgId?: string | null\n /** The account-creating request's headers, for its geography. */\n headers: HeaderReader\n recordedBy: 'signup' | 'sso'\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}\n\nexport type RecordAccountAcquisitionResult =\n | { status: 'recorded'; record: AccountAcquisition }\n | { status: 'exists'; record: AccountAcquisition | null }\n /** The account was not created just now, so this is not its creation. */\n | { status: 'not-new' }\n\n/**\n * The organization whose invitation this address has waiting, or null. The\n * same query the console's invitation banner runs, one address wide.\n */\nexport async function pendingInvitationOrgId(\n email: string | null,\n firestore: FirebaseFirestore.Firestore = firebaseAdmin.app().firestore(),\n): Promise<string | null> {\n const address = String(email ?? '').trim().toLowerCase()\n if (!address) return null\n try {\n const snapshot = await firestore\n .collectionGroup('invites')\n .where('email', 'in', [address])\n .where('acceptedAt', '==', null)\n .limit(1)\n .get()\n return snapshot.docs[0]?.ref.parent.parent?.id ?? null\n } catch (error) {\n // An unanswerable question costs the door's label, never the record.\n console.error('[account-acquisition] invitation lookup failed', error)\n return null\n }\n}\n\n/** Where the request came from, as a person reads it — no address, no coordinates. */\nexport function requestGeography(headers: HeaderReader): {\n country: string | null\n region: string | null\n city: string | null\n} {\n return {\n country: readRequestGeo(headers).country,\n region: readRequestRegionLabel(headers),\n city: readRequestCity(headers),\n }\n}\n\n/** Record the account's acquisition unless it already has one. */\nexport async function recordAccountAcquisition(\n input: RecordAccountAcquisitionInput,\n): Promise<RecordAccountAcquisitionResult> {\n const firestore = input.firestore ?? firebaseAdmin.app().firestore()\n const nowMs = input.nowMs ?? Date.now()\n if (\n input.accountCreatedAtMs === null ||\n !Number.isFinite(input.accountCreatedAtMs) ||\n nowMs - input.accountCreatedAtMs > ACCOUNT_ACQUISITION_WINDOW_MS\n ) {\n return { status: 'not-new' }\n }\n const invitedToOrgId =\n input.invitedToOrgId ??\n (input.door === 'signup-password' ||\n input.door === 'signup-google' ||\n input.door === 'signup-google-redirect'\n ? await pendingInvitationOrgId(input.email, firestore)\n : null)\n const record = buildAccountAcquisition({\n touch: input.touch,\n door: invitedToOrgId && input.door !== 'sso' ? 'invite' : input.door,\n provider: input.provider,\n geo: requestGeography(input.headers),\n invitedToOrgId,\n accountCreatedAtMs: input.accountCreatedAtMs,\n recordedBy: input.recordedBy,\n nowMs,\n })\n const userRef = firestore.collection('users').doc(input.uid)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(userRef)\n const existing = snapshot.exists ? snapshot.get(ACCOUNT_ACQUISITION_FIELD) : undefined\n if (existing !== undefined && existing !== null) {\n return { status: 'exists', record: readAccountAcquisition(existing) } as const\n }\n tx.set(userRef, { [ACCOUNT_ACQUISITION_FIELD]: record }, { merge: true })\n return { status: 'recorded', record } as const\n })\n}\n\n/** What a sign-up door knows about the account it is recording. */\nexport interface RecordSignUpAcquisitionInput {\n uid: string\n /** The verified token's `sign_in_provider`. */\n provider: string | null\n /** The verified token's address. */\n email: string | null\n /** The first touch the page sent, else the request's cookie — untrusted. */\n touch: unknown\n /** Which of the provider's doors the page says it was; see `signUpDoorFor`. */\n doorHint?: unknown\n headers: HeaderReader\n nowMs?: number\n firestore?: FirebaseFirestore.Firestore\n}\n\n/**\n * Record a self-serve sign-up's acquisition, deciding from the verified\n * provider and the auth record whether there is one to record.\n *\n * Shared by the two moments a sign-up passes through: the sign-up page's own\n * call, right after the account exists, and workspace creation, which is the\n * backstop for a page torn down before its call went out — the workspace\n * copies its creator's record at birth, so that is the last moment one can\n * still reach it. Both write through {@link recordAccountAcquisition}, so the\n * first one wins and neither can restate the other; an account older than\n * the window, or one a sign-up door did not create, writes nothing.\n *\n * `null` when the provider is not a sign-up door (SSO records its own).\n */\nexport async function recordSignUpAcquisition(\n input: RecordSignUpAcquisitionInput,\n): Promise<RecordAccountAcquisitionResult | null> {\n const door = signUpDoorFor(input.provider, input.doorHint)\n if (!door) return null\n // Across every pool, not the project's alone (AGL-1122): a lookup that\n // cannot see an account answers \"not new\", which writes nothing.\n const pooled = await findUserByUidAcrossPools(input.uid)\n const createdAtMs = Date.parse(pooled?.record.metadata.creationTime ?? '')\n return recordAccountAcquisition({\n uid: input.uid,\n accountCreatedAtMs: Number.isFinite(createdAtMs) ? createdAtMs : null,\n touch: input.touch,\n door,\n provider: input.provider,\n email: input.email,\n headers: input.headers,\n recordedBy: 'signup',\n nowMs: input.nowMs,\n firestore: input.firestore,\n })\n}\n"],"names":["ACCOUNT_ACQUISITION_FIELD","buildAccountAcquisition","readAccountAcquisition","signUpDoorFor","readRequestCity","readRequestGeo","readRequestRegionLabel","findUserByUidAcrossPools","firebaseAdmin","ACCOUNT_ACQUISITION_WINDOW_MS","pendingInvitationOrgId","email","firestore","app","address","String","trim","toLowerCase","snapshot","collectionGroup","where","limit","get","docs","ref","parent","id","error","console","requestGeography","headers","country","region","city","recordAccountAcquisition","input","nowMs","Date","now","accountCreatedAtMs","Number","isFinite","status","invitedToOrgId","door","record","touch","provider","geo","recordedBy","userRef","collection","doc","uid","runTransaction","tx","existing","exists","undefined","set","merge","recordSignUpAcquisition","doorHint","pooled","createdAtMs","parse","metadata","creationTime"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,yBAAyB,EACzBC,uBAAuB,EACvBC,sBAAsB,EACtBC,aAAa,QAGR,6CAA4C;AACnD,SACEC,eAAe,EACfC,cAAc,EACdC,sBAAsB,QAEjB,qCAAoC;AAC3C,SAASC,wBAAwB,QAAQ,kBAAc;AACvD,OAAOC,mBAAmB,sBAAkB;AAE5C;;;;;;;;;;;;;;CAcC,GAED;;;;;CAKC,GACD,OAAO,MAAMC,gCAAgC,KAAK,KAAK,KAAI;AA6B3D;;;CAGC,GACD,OAAO,eAAeC,uBACpBC,KAAoB,EACpBC,YAAyCJ,cAAcK,GAAG,GAAGD,SAAS,EAAE;IAExE,MAAME,UAAUC,OAAOJ,gBAAAA,QAAS,IAAIK,IAAI,GAAGC,WAAW;IACtD,IAAI,CAACH,SAAS,OAAO;IACrB,IAAI;;YAOKI,mCAAAA;QANP,MAAMA,WAAW,MAAMN,UACpBO,eAAe,CAAC,WAChBC,KAAK,CAAC,SAAS,MAAM;YAACN;SAAQ,EAC9BM,KAAK,CAAC,cAAc,MAAM,MAC1BC,KAAK,CAAC,GACNC,GAAG;QACN,gBAAOJ,kBAAAA,SAASK,IAAI,CAAC,EAAE,sBAAhBL,oCAAAA,gBAAkBM,GAAG,CAACC,MAAM,CAACA,MAAM,qBAAnCP,kCAAqCQ,EAAE,mBAAI;IACpD,EAAE,OAAOC,OAAO;QACd,qEAAqE;QACrEC,QAAQD,KAAK,CAAC,kDAAkDA;QAChE,OAAO;IACT;AACF;AAEA,oFAAoF,GACpF,OAAO,SAASE,iBAAiBC,OAAqB;IAKpD,OAAO;QACLC,SAAS1B,eAAeyB,SAASC,OAAO;QACxCC,QAAQ1B,uBAAuBwB;QAC/BG,MAAM7B,gBAAgB0B;IACxB;AACF;AAEA,gEAAgE,GAChE,OAAO,eAAeI,yBACpBC,KAAoC;QAElBA,kBACJA,cASZA;IAVF,MAAMvB,aAAYuB,mBAAAA,MAAMvB,SAAS,YAAfuB,mBAAmB3B,cAAcK,GAAG,GAAGD,SAAS;IAClE,MAAMwB,SAAQD,eAAAA,MAAMC,KAAK,YAAXD,eAAeE,KAAKC,GAAG;IACrC,IACEH,MAAMI,kBAAkB,KAAK,QAC7B,CAACC,OAAOC,QAAQ,CAACN,MAAMI,kBAAkB,KACzCH,QAAQD,MAAMI,kBAAkB,GAAG9B,+BACnC;QACA,OAAO;YAAEiC,QAAQ;QAAU;IAC7B;IACA,MAAMC,kBACJR,wBAAAA,MAAMQ,cAAc,YAApBR,wBACCA,MAAMS,IAAI,KAAK,qBAChBT,MAAMS,IAAI,KAAK,mBACfT,MAAMS,IAAI,KAAK,2BACX,MAAMlC,uBAAuByB,MAAMxB,KAAK,EAAEC,aAC1C;IACN,MAAMiC,SAAS5C,wBAAwB;QACrC6C,OAAOX,MAAMW,KAAK;QAClBF,MAAMD,kBAAkBR,MAAMS,IAAI,KAAK,QAAQ,WAAWT,MAAMS,IAAI;QACpEG,UAAUZ,MAAMY,QAAQ;QACxBC,KAAKnB,iBAAiBM,MAAML,OAAO;QACnCa;QACAJ,oBAAoBJ,MAAMI,kBAAkB;QAC5CU,YAAYd,MAAMc,UAAU;QAC5Bb;IACF;IACA,MAAMc,UAAUtC,UAAUuC,UAAU,CAAC,SAASC,GAAG,CAACjB,MAAMkB,GAAG;IAC3D,OAAOzC,UAAU0C,cAAc,CAAC,OAAOC;QACrC,MAAMrC,WAAW,MAAMqC,GAAGjC,GAAG,CAAC4B;QAC9B,MAAMM,WAAWtC,SAASuC,MAAM,GAAGvC,SAASI,GAAG,CAACtB,6BAA6B0D;QAC7E,IAAIF,aAAaE,aAAaF,aAAa,MAAM;YAC/C,OAAO;gBAAEd,QAAQ;gBAAUG,QAAQ3C,uBAAuBsD;YAAU;QACtE;QACAD,GAAGI,GAAG,CAACT,SAAS;YAAE,CAAClD,0BAA0B,EAAE6C;QAAO,GAAG;YAAEe,OAAO;QAAK;QACvE,OAAO;YAAElB,QAAQ;YAAYG;QAAO;IACtC;AACF;AAkBA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAegB,wBACpB1B,KAAmC;;IAEnC,MAAMS,OAAOzC,cAAcgC,MAAMY,QAAQ,EAAEZ,MAAM2B,QAAQ;IACzD,IAAI,CAAClB,MAAM,OAAO;IAClB,uEAAuE;IACvE,iEAAiE;IACjE,MAAMmB,SAAS,MAAMxD,yBAAyB4B,MAAMkB,GAAG;IACvD,MAAMW,cAAc3B,KAAK4B,KAAK,SAACF,0BAAAA,OAAQlB,MAAM,CAACqB,QAAQ,CAACC,YAAY,mBAAI;IACvE,OAAOjC,yBAAyB;QAC9BmB,KAAKlB,MAAMkB,GAAG;QACdd,oBAAoBC,OAAOC,QAAQ,CAACuB,eAAeA,cAAc;QACjElB,OAAOX,MAAMW,KAAK;QAClBF;QACAG,UAAUZ,MAAMY,QAAQ;QACxBpC,OAAOwB,MAAMxB,KAAK;QAClBmB,SAASK,MAAML,OAAO;QACtBmB,YAAY;QACZb,OAAOD,MAAMC,KAAK;QAClBxB,WAAWuB,MAAMvB,SAAS;IAC5B;AACF"}
|
|
@@ -71,4 +71,21 @@ export declare function applyAccountLockToMail(input: AccountLockMailInput & {
|
|
|
71
71
|
export declare function liftAccountLockFromMail(input: AccountLockMailInput & {
|
|
72
72
|
releasedByUid?: string | null;
|
|
73
73
|
}): Promise<AccountLockMailReport>;
|
|
74
|
+
/**
|
|
75
|
+
* What a lock does to one address, as a house site's record page shows it
|
|
76
|
+
* (AGL-3686): `banned` while a live ban row holds the address, `locked`
|
|
77
|
+
* while the site holds the lock's own row, else `null`.
|
|
78
|
+
*
|
|
79
|
+
* Answered for the house workspace's sites only, and `null` everywhere
|
|
80
|
+
* else: no other workspace learns that an address belongs to a locked
|
|
81
|
+
* Aglyn account. The ban is read off the platform list rather than the
|
|
82
|
+
* site's row because a lock writes no row where one already stood — a
|
|
83
|
+
* banned address that had unsubscribed carries only its unsubscribe there.
|
|
84
|
+
* Fails open, logging: the chip says nothing rather than something wrong.
|
|
85
|
+
*/
|
|
86
|
+
export declare function accountLockStateFor(input: {
|
|
87
|
+
hostId: string;
|
|
88
|
+
email: string;
|
|
89
|
+
firestore?: any;
|
|
90
|
+
}): Promise<'banned' | 'locked' | null>;
|
|
74
91
|
export {};
|
|
@@ -15,7 +15,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { resolveAccountAddresses } from "./account-addresses.js";
|
|
18
|
-
import { releaseAccountBanSuppressions, releaseAccountLockSuppression, suppressEmail, suppressEmailForAccountLock } from "./email-suppression.js";
|
|
18
|
+
import { emailSuppressionKey, HOST_ACCOUNT_LOCK_SUPPRESSION_REASON, HOST_SUPPRESSIONS_SUBCOLLECTION, isAccountBanSuppression, releaseAccountBanSuppressions, releaseAccountLockSuppression, suppressEmail, suppressEmailForAccountLock } from "./email-suppression.js";
|
|
19
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
19
20
|
import { getOrgForHost } from "./organizations.js";
|
|
20
21
|
import { platformMarketingHostId } from "./platform-marketing-consent.js";
|
|
21
22
|
/** The house workspace's site ids, or none on an install without one. */ async function houseSiteIds() {
|
|
@@ -135,5 +136,31 @@ async function addressesOf(input) {
|
|
|
135
136
|
}
|
|
136
137
|
return report;
|
|
137
138
|
}
|
|
139
|
+
/**
|
|
140
|
+
* What a lock does to one address, as a house site's record page shows it
|
|
141
|
+
* (AGL-3686): `banned` while a live ban row holds the address, `locked`
|
|
142
|
+
* while the site holds the lock's own row, else `null`.
|
|
143
|
+
*
|
|
144
|
+
* Answered for the house workspace's sites only, and `null` everywhere
|
|
145
|
+
* else: no other workspace learns that an address belongs to a locked
|
|
146
|
+
* Aglyn account. The ban is read off the platform list rather than the
|
|
147
|
+
* site's row because a lock writes no row where one already stood — a
|
|
148
|
+
* banned address that had unsubscribed carries only its unsubscribe there.
|
|
149
|
+
* Fails open, logging: the chip says nothing rather than something wrong.
|
|
150
|
+
*/ export async function accountLockStateFor(input) {
|
|
151
|
+
const key = emailSuppressionKey(input.email);
|
|
152
|
+
if (!key || !input.hostId) return null;
|
|
153
|
+
try {
|
|
154
|
+
var _input_firestore;
|
|
155
|
+
if (!(await houseSiteIds()).includes(input.hostId)) return null;
|
|
156
|
+
if (await isAccountBanSuppression(input.email, input.firestore)) return 'banned';
|
|
157
|
+
const db = (_input_firestore = input.firestore) != null ? _input_firestore : firebaseAdmin.app().firestore();
|
|
158
|
+
const row = await db.collection('hosts').doc(input.hostId).collection(HOST_SUPPRESSIONS_SUBCOLLECTION).doc(key).get();
|
|
159
|
+
return (row == null ? void 0 : row.exists) && row.get('reason') === HOST_ACCOUNT_LOCK_SUPPRESSION_REASON ? 'locked' : null;
|
|
160
|
+
} catch (error) {
|
|
161
|
+
console.error('[account-lock-mail] lock state lookup failed', input.hostId, error);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
138
165
|
|
|
139
166
|
//# sourceMappingURL=account-lock-mail.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/account-lock-mail.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { resolveAccountAddresses } from './account-addresses'\nimport {\n releaseAccountBanSuppressions,\n releaseAccountLockSuppression,\n suppressEmail,\n suppressEmailForAccountLock,\n} from './email-suppression'\nimport { getOrgForHost } from './organizations'\nimport { platformMarketingHostId } from './platform-marketing-consent'\n\n/**\n * WHAT AN ACCOUNT LOCK DOES TO MAIL ADDRESSED BY EMAIL (AGL-3420).\n *\n * The uid-keyed fan-outs already skip a locked account\n * (`isAccountMailWithheld`, AGL-3418). What they cannot reach is mail\n * addressed by email alone — a campaign to a list, a sequence to a contact —\n * so the lock is written onto the suppression lists, keyed by every address\n * the account holds:\n *\n * - **Every lock:** each of the house workspace's sites, `account_lock`, so\n * Aglyn's own campaigns and sequences stop. Other workspaces are not ours\n * to speak for.\n * - **A ban** (`abuse`): the platform list as well, `account_ban`, which\n * the preflight on `sendEmail` refuses on every send by any sender. The\n * account is kept only so the address cannot sign up again; after its\n * notice it is sent nothing.\n *\n * Called after the lock is durable and after its notices have gone, and it\n * never undoes the lock: a failure is reported beside the lock, not instead\n * of it.\n */\n\nexport interface AccountLockMailReport {\n /** The addresses the account holds that were written or lifted. */\n addresses: number\n /** A source of addresses failed; the set may be short. */\n incomplete: boolean\n /** Platform ban rows filed (lock) or released (lift). */\n banRows: number\n /** House sites the per-site rows were written on or lifted from. */\n houseSites: number\n /** Per-site rows written (lock) or deleted (lift). */\n houseRows: number\n /** Writes that threw. */\n failed: number\n}\n\ninterface AccountLockMailInput {\n uid: string\n /** The Auth record, for its addresses. */\n record: {\n email?: string | null\n providerData?: readonly { email?: string | null }[]\n } | null\n firestore?: any\n}\n\n/** The house workspace's site ids, or none on an install without one. */\nasync function houseSiteIds(): Promise<string[]> {\n const houseHostId = platformMarketingHostId()\n if (!houseHostId) return []\n const house = await getOrgForHost(houseHostId)\n const directory = (house?.org as { hosts?: Record<string, unknown> } | undefined)?.hosts ?? {}\n const ids = Object.keys(directory).filter((id) => Boolean(directory[id]))\n return ids.includes(houseHostId) ? ids : [houseHostId, ...ids]\n}\n\nasync function addressesOf(input: AccountLockMailInput) {\n const set = await resolveAccountAddresses({\n uid: input.uid,\n record: input.record,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n return { emails: set.addresses.map((entry) => entry.address), incomplete: set.incomplete }\n}\n\n/** Write a lock onto the lists. `ban` files the platform rows as well. */\nexport async function applyAccountLockToMail(\n input: AccountLockMailInput & { ban: boolean },\n): Promise<AccountLockMailReport> {\n const { emails, incomplete } = await addressesOf(input)\n const sites = await houseSiteIds()\n const report: AccountLockMailReport = {\n addresses: emails.length,\n incomplete,\n banRows: 0,\n houseSites: sites.length,\n houseRows: 0,\n failed: 0,\n }\n for (const email of emails) {\n if (input.ban) {\n try {\n await suppressEmail({\n email,\n reason: 'account_ban',\n subjectUid: input.uid,\n context: 'account-ban',\n stampRecord: false,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n report.banRows += 1\n } catch (error) {\n console.error('[account-lock-mail] ban row failed', input.uid, error)\n report.failed += 1\n }\n }\n for (const hostId of sites) {\n try {\n const wrote = await suppressEmailForAccountLock({\n hostId,\n email,\n uid: input.uid,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n if (wrote) report.houseRows += 1\n } catch (error) {\n console.error('[account-lock-mail] house row failed', input.uid, hostId, error)\n report.failed += 1\n }\n }\n }\n return report\n}\n\n/**\n * Take a lift off the lists: the account's ban rows (restoring any\n * suppression a ban was filed over) and its house rows.\n */\nexport async function liftAccountLockFromMail(\n input: AccountLockMailInput & { releasedByUid?: string | null },\n): Promise<AccountLockMailReport> {\n const { emails, incomplete } = await addressesOf(input)\n const sites = await houseSiteIds()\n const report: AccountLockMailReport = {\n addresses: emails.length,\n incomplete,\n banRows: 0,\n houseSites: sites.length,\n houseRows: 0,\n failed: 0,\n }\n try {\n report.banRows = await releaseAccountBanSuppressions({\n uid: input.uid,\n releasedByUid: input.releasedByUid ?? null,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n } catch (error) {\n console.error('[account-lock-mail] ban release failed', input.uid, error)\n report.failed += 1\n }\n for (const email of emails) {\n for (const hostId of sites) {\n try {\n const deleted = await releaseAccountLockSuppression({\n hostId,\n email,\n uid: input.uid,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n if (deleted) report.houseRows += 1\n } catch (error) {\n console.error('[account-lock-mail] house release failed', input.uid, hostId, error)\n report.failed += 1\n }\n }\n }\n return report\n}\n"],"names":["resolveAccountAddresses","releaseAccountBanSuppressions","releaseAccountLockSuppression","suppressEmail","suppressEmailForAccountLock","getOrgForHost","platformMarketingHostId","houseSiteIds","house","houseHostId","directory","org","hosts","ids","Object","keys","filter","id","Boolean","includes","addressesOf","input","set","uid","record","firestore","emails","addresses","map","entry","address","incomplete","applyAccountLockToMail","sites","report","length","banRows","houseSites","houseRows","failed","email","ban","reason","subjectUid","context","stampRecord","error","console","hostId","wrote","liftAccountLockFromMail","releasedByUid","deleted"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/account-lock-mail.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { resolveAccountAddresses } from './account-addresses'\nimport {\n emailSuppressionKey,\n HOST_ACCOUNT_LOCK_SUPPRESSION_REASON,\n HOST_SUPPRESSIONS_SUBCOLLECTION,\n isAccountBanSuppression,\n releaseAccountBanSuppressions,\n releaseAccountLockSuppression,\n suppressEmail,\n suppressEmailForAccountLock,\n} from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport { getOrgForHost } from './organizations'\nimport { platformMarketingHostId } from './platform-marketing-consent'\n\n/**\n * WHAT AN ACCOUNT LOCK DOES TO MAIL ADDRESSED BY EMAIL (AGL-3420).\n *\n * The uid-keyed fan-outs already skip a locked account\n * (`isAccountMailWithheld`, AGL-3418). What they cannot reach is mail\n * addressed by email alone — a campaign to a list, a sequence to a contact —\n * so the lock is written onto the suppression lists, keyed by every address\n * the account holds:\n *\n * - **Every lock:** each of the house workspace's sites, `account_lock`, so\n * Aglyn's own campaigns and sequences stop. Other workspaces are not ours\n * to speak for.\n * - **A ban** (`abuse`): the platform list as well, `account_ban`, which\n * the preflight on `sendEmail` refuses on every send by any sender. The\n * account is kept only so the address cannot sign up again; after its\n * notice it is sent nothing.\n *\n * Called after the lock is durable and after its notices have gone, and it\n * never undoes the lock: a failure is reported beside the lock, not instead\n * of it.\n */\n\nexport interface AccountLockMailReport {\n /** The addresses the account holds that were written or lifted. */\n addresses: number\n /** A source of addresses failed; the set may be short. */\n incomplete: boolean\n /** Platform ban rows filed (lock) or released (lift). */\n banRows: number\n /** House sites the per-site rows were written on or lifted from. */\n houseSites: number\n /** Per-site rows written (lock) or deleted (lift). */\n houseRows: number\n /** Writes that threw. */\n failed: number\n}\n\ninterface AccountLockMailInput {\n uid: string\n /** The Auth record, for its addresses. */\n record: {\n email?: string | null\n providerData?: readonly { email?: string | null }[]\n } | null\n firestore?: any\n}\n\n/** The house workspace's site ids, or none on an install without one. */\nasync function houseSiteIds(): Promise<string[]> {\n const houseHostId = platformMarketingHostId()\n if (!houseHostId) return []\n const house = await getOrgForHost(houseHostId)\n const directory = (house?.org as { hosts?: Record<string, unknown> } | undefined)?.hosts ?? {}\n const ids = Object.keys(directory).filter((id) => Boolean(directory[id]))\n return ids.includes(houseHostId) ? ids : [houseHostId, ...ids]\n}\n\nasync function addressesOf(input: AccountLockMailInput) {\n const set = await resolveAccountAddresses({\n uid: input.uid,\n record: input.record,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n return { emails: set.addresses.map((entry) => entry.address), incomplete: set.incomplete }\n}\n\n/** Write a lock onto the lists. `ban` files the platform rows as well. */\nexport async function applyAccountLockToMail(\n input: AccountLockMailInput & { ban: boolean },\n): Promise<AccountLockMailReport> {\n const { emails, incomplete } = await addressesOf(input)\n const sites = await houseSiteIds()\n const report: AccountLockMailReport = {\n addresses: emails.length,\n incomplete,\n banRows: 0,\n houseSites: sites.length,\n houseRows: 0,\n failed: 0,\n }\n for (const email of emails) {\n if (input.ban) {\n try {\n await suppressEmail({\n email,\n reason: 'account_ban',\n subjectUid: input.uid,\n context: 'account-ban',\n stampRecord: false,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n report.banRows += 1\n } catch (error) {\n console.error('[account-lock-mail] ban row failed', input.uid, error)\n report.failed += 1\n }\n }\n for (const hostId of sites) {\n try {\n const wrote = await suppressEmailForAccountLock({\n hostId,\n email,\n uid: input.uid,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n if (wrote) report.houseRows += 1\n } catch (error) {\n console.error('[account-lock-mail] house row failed', input.uid, hostId, error)\n report.failed += 1\n }\n }\n }\n return report\n}\n\n/**\n * Take a lift off the lists: the account's ban rows (restoring any\n * suppression a ban was filed over) and its house rows.\n */\nexport async function liftAccountLockFromMail(\n input: AccountLockMailInput & { releasedByUid?: string | null },\n): Promise<AccountLockMailReport> {\n const { emails, incomplete } = await addressesOf(input)\n const sites = await houseSiteIds()\n const report: AccountLockMailReport = {\n addresses: emails.length,\n incomplete,\n banRows: 0,\n houseSites: sites.length,\n houseRows: 0,\n failed: 0,\n }\n try {\n report.banRows = await releaseAccountBanSuppressions({\n uid: input.uid,\n releasedByUid: input.releasedByUid ?? null,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n } catch (error) {\n console.error('[account-lock-mail] ban release failed', input.uid, error)\n report.failed += 1\n }\n for (const email of emails) {\n for (const hostId of sites) {\n try {\n const deleted = await releaseAccountLockSuppression({\n hostId,\n email,\n uid: input.uid,\n ...(input.firestore ? { firestore: input.firestore } : {}),\n })\n if (deleted) report.houseRows += 1\n } catch (error) {\n console.error('[account-lock-mail] house release failed', input.uid, hostId, error)\n report.failed += 1\n }\n }\n }\n return report\n}\n\n/**\n * What a lock does to one address, as a house site's record page shows it\n * (AGL-3686): `banned` while a live ban row holds the address, `locked`\n * while the site holds the lock's own row, else `null`.\n *\n * Answered for the house workspace's sites only, and `null` everywhere\n * else: no other workspace learns that an address belongs to a locked\n * Aglyn account. The ban is read off the platform list rather than the\n * site's row because a lock writes no row where one already stood — a\n * banned address that had unsubscribed carries only its unsubscribe there.\n * Fails open, logging: the chip says nothing rather than something wrong.\n */\nexport async function accountLockStateFor(input: {\n hostId: string\n email: string\n firestore?: any\n}): Promise<'banned' | 'locked' | null> {\n const key = emailSuppressionKey(input.email)\n if (!key || !input.hostId) return null\n try {\n if (!(await houseSiteIds()).includes(input.hostId)) return null\n if (await isAccountBanSuppression(input.email, input.firestore)) return 'banned'\n const db = input.firestore ?? firebaseAdmin.app().firestore()\n const row = await db\n .collection('hosts')\n .doc(input.hostId)\n .collection(HOST_SUPPRESSIONS_SUBCOLLECTION)\n .doc(key)\n .get()\n return row?.exists && row.get('reason') === HOST_ACCOUNT_LOCK_SUPPRESSION_REASON ? 'locked' : null\n } catch (error) {\n console.error('[account-lock-mail] lock state lookup failed', input.hostId, error)\n return null\n }\n}\n"],"names":["resolveAccountAddresses","emailSuppressionKey","HOST_ACCOUNT_LOCK_SUPPRESSION_REASON","HOST_SUPPRESSIONS_SUBCOLLECTION","isAccountBanSuppression","releaseAccountBanSuppressions","releaseAccountLockSuppression","suppressEmail","suppressEmailForAccountLock","firebaseAdmin","getOrgForHost","platformMarketingHostId","houseSiteIds","house","houseHostId","directory","org","hosts","ids","Object","keys","filter","id","Boolean","includes","addressesOf","input","set","uid","record","firestore","emails","addresses","map","entry","address","incomplete","applyAccountLockToMail","sites","report","length","banRows","houseSites","houseRows","failed","email","ban","reason","subjectUid","context","stampRecord","error","console","hostId","wrote","liftAccountLockFromMail","releasedByUid","deleted","accountLockStateFor","key","db","app","row","collection","doc","get","exists"],"mappings":"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"}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* and AGL-2276 re-added it in the same change that shipped the writes, which
|
|
22
22
|
* is the condition AGL-899 set).
|
|
23
23
|
*/
|
|
24
|
-
export declare const API_SCOPES: readonly ["datasets:read", "datasets:write", "contacts:read", "contacts:write", "sites:read", "sites:publish", "sites:write", "forms:read", "forms:write", "orders:read", "orders:write", "products:read", "media:read", "media:write", "crm:read", "crm:write"];
|
|
24
|
+
export declare const API_SCOPES: readonly ["datasets:read", "datasets:write", "contacts:read", "contacts:write", "sites:read", "sites:publish", "sites:write", "forms:read", "forms:write", "orders:read", "orders:write", "products:read", "bookings:read", "media:read", "media:write", "crm:read", "crm:write"];
|
|
25
25
|
export type ApiScope = (typeof API_SCOPES)[number];
|
|
26
26
|
/** Live-key token prefix. A `_test_` variant can follow the same shape later. */
|
|
27
27
|
export declare const API_KEY_TOKEN_PREFIX = "aglyn_sk_";
|
|
@@ -115,3 +115,16 @@ export declare function verifyApiKey(token: string): Promise<VerifiedApiKey | nu
|
|
|
115
115
|
export declare function listApiKeys(orgId: string): Promise<PublicApiKey[]>;
|
|
116
116
|
/** Revoke a key by its public id. Returns false if not found/already revoked. */
|
|
117
117
|
export declare function revokeApiKey(orgId: string, keyId: string): Promise<boolean>;
|
|
118
|
+
/** What a key may still do, read by its public id (AGL-3643). */
|
|
119
|
+
export interface ApiKeyGrant {
|
|
120
|
+
keyId: string;
|
|
121
|
+
name: string;
|
|
122
|
+
scopes: ApiScope[];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The key's grant as it stands now, by its public id: `null` when the key is
|
|
126
|
+
* unknown to the organization, revoked or expired. For something a key SET
|
|
127
|
+
* UP earlier that acts on its behalf later — a REST hook it subscribed —
|
|
128
|
+
* which must stop when the key does, and never outlive a revoke.
|
|
129
|
+
*/
|
|
130
|
+
export declare function readApiKeyGrant(orgId: string, keyId: string, now?: number): Promise<ApiKeyGrant | null>;
|
|
@@ -122,6 +122,14 @@ import { firebaseAdmin } from "./firebase-admin.js";
|
|
|
122
122
|
// takes, so the API can never write a status the console forbids.
|
|
123
123
|
'orders:write',
|
|
124
124
|
'products:read',
|
|
125
|
+
// AGL-3643. A site's bookings were the one record an integration could not
|
|
126
|
+
// read at all — not a booking app's sync, not a Zapier trigger's sample —
|
|
127
|
+
// though a booking is as much a customer's record as an order. Read-only
|
|
128
|
+
// on purpose, and its own scope for the reason `orders:read` is: a key
|
|
129
|
+
// handed to a scheduling or calendar tool needs the appointments and
|
|
130
|
+
// nothing else. Writes (moving or canceling one, which tells the guest and
|
|
131
|
+
// can refund them) want their own decision, in the change that ships them.
|
|
132
|
+
'bookings:read',
|
|
125
133
|
'media:read',
|
|
126
134
|
// AGL-2463. `media:read` was the only media scope, so an agency onboarding a
|
|
127
135
|
// client site could automate everything about that site except putting its
|
|
@@ -294,5 +302,25 @@ const tsToIso = (value)=>value ? value.toDate().toISOString() : null;
|
|
|
294
302
|
});
|
|
295
303
|
return true;
|
|
296
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* The key's grant as it stands now, by its public id: `null` when the key is
|
|
307
|
+
* unknown to the organization, revoked or expired. For something a key SET
|
|
308
|
+
* UP earlier that acts on its behalf later — a REST hook it subscribed —
|
|
309
|
+
* which must stop when the key does, and never outlive a revoke.
|
|
310
|
+
*/ export async function readApiKeyGrant(orgId, keyId, now = Date.now()) {
|
|
311
|
+
var _data_name, _data_scopes;
|
|
312
|
+
if (!orgId || !keyId) return null;
|
|
313
|
+
const snap = await collection().where('orgId', '==', orgId).where('keyId', '==', keyId).limit(1).get();
|
|
314
|
+
const doc = snap.docs[0];
|
|
315
|
+
if (!doc) return null;
|
|
316
|
+
const data = doc.data();
|
|
317
|
+
if (data.revokedAt) return null;
|
|
318
|
+
if (data.expiresAt && data.expiresAt.toMillis() <= now) return null;
|
|
319
|
+
return {
|
|
320
|
+
keyId: data.keyId,
|
|
321
|
+
name: String((_data_name = data.name) != null ? _data_name : ''),
|
|
322
|
+
scopes: normalizeScopes((_data_scopes = data.scopes) != null ? _data_scopes : [])
|
|
323
|
+
};
|
|
324
|
+
}
|
|
297
325
|
|
|
298
326
|
//# sourceMappingURL=api-keys.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-keys.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 * Customer API keys (AGL-616). Keys authenticate the public REST API\n * (`/api/v1/**`). Only a SHA-256 hash of the key is ever stored — the raw\n * token is shown to the user exactly once, at creation. The Firestore doc id\n * IS the hash, so verification is an O(1) `get()` and the hash never leaves\n * the server (listings return metadata only). The `apiKeys` collection is\n * deny-all in the security rules; all access is Admin-SDK only.\n *\n * The token/hash/format/scope helpers are pure and unit-tested; the storage\n * functions wrap the Admin SDK.\n */\nimport { createHash, randomBytes } from 'node:crypto'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * Scopes a key can be granted, as `resource:action`. Every entry must be\n * enforced by a real endpoint — a mintable scope that grants nothing reads\n * as a broken permission (AGL-899 removed `contacts:write` on those grounds,\n * and AGL-2276 re-added it in the same change that shipped the writes, which\n * is the condition AGL-899 set).\n */\nexport const API_SCOPES = [\n 'datasets:read',\n 'datasets:write',\n 'contacts:read',\n // AGL-2276. Contacts used to arrive only through the four site capture\n // points, so an integration that OWNS the customer list upstream — a CRM,\n // a mailing tool, a migration off another platform — could read the\n // audience and never add to it. The write is metered against the same\n // audience bands capture is (AGL-890): `checkContactQuota` on the create,\n // and the monthly rollup counts `orgs/{orgId}/contacts` whatever wrote\n // them, so an API-created contact bills exactly like a form-captured one.\n // The scope covers the create, the tag/notes edit the console's Contacts\n // page already makes, and the delete — never `email`, which is the dedupe\n // key, and never `sources`/`interactions`, which are provenance.\n 'contacts:write',\n 'sites:read',\n // AGL-2462. An integration could write a dataset record over /v1 and had no\n // way to make a live page show it: `getDatasets` caches for 60s behind\n // `tenant-data:{hostId}` and the catch-all page is `revalidate = 60`, and\n // time-based ISR is stale-while-revalidate — so the visitor AFTER the window\n // can still get the old copy. A cache expiring is not a publish.\n //\n // Its OWN scope rather than a `sites:write` covering two very different\n // powers. Publishing is not a site edit: it changes no stored field, and it\n // is the one /v1 call whose cost lands on a different service — up to 250\n // dropped pages, each re-rendering at roughly 40 Firestore reads. A key that\n // syncs a catalogue should not thereby be able to spend that, and an\n // integration that only needs to flush its own writes should not have to\n // hold a scope that could rename the site. Bounded by a per-HOST hourly\n // budget on top of the per-key request limit, because one call and 250\n // dropped pages are not the same unit of cost.\n 'sites:publish',\n // AGL-2465. Everything downstream of a site was automatable and creating\n // the site was not: `handleSites` 405'd every non-GET, so an agency\n // onboarding a client could push its datasets, contacts and media and still\n // had to open a browser to make the site those things go in.\n //\n // CREATE ONLY — no rename, no delete, and the omissions are deliberate\n // rather than unfinished. `hosts/rename` authorizes off the host projection\n // alone — the member's own entry in `memberRoles`, which must equal\n // `admin` — and an API key is an ORG credential with no uid, so that gate\n // has no translation at all; giving it one is a permission decision, not a\n // port. `hosts/delete` runs\n // `recursiveDelete` over the whole site plus a storage prefix wipe with no\n // hold and no soft-delete, and the type-the-name confirmation that guards it\n // is console UI only — a one-field irreversible delete is the last thing to\n // put behind a bearer token, and whether it should exist at all is a product\n // decision that has not been made.\n //\n // Replay-safe, which is what actually blocked this: site creation is the\n // most expensive object here to duplicate — it takes a `hostLimit` slot,\n // writes `hostIndex` and runs `syncOrgAuthProjections` across every member —\n // and `/v1` publishes idempotency as a contract. A POST that succeeded\n // server-side but lost its response now replays the original site rather\n // than minting a second one.\n 'sites:write',\n 'forms:read',\n // AGL-2127. A lead sync could READ a submission and never record that it\n // had — so it re-pushed the same lead on every poll, or kept its own\n // high-water mark against a list ordered by document id rather than by\n // time. `read` is the flag the console already toggles on the same\n // document, so the scope grants exactly the state the integration needs\n // (plus the delete that lets it purge after export), and nothing that\n // rewrites what a visitor actually typed.\n 'forms:write',\n // Commerce and media reads (AGL-1928). Each is separate from `sites:read`\n // for the same reason `forms:read` is: a key handed to a fulfilment or\n // accounting integration needs the order history and nothing else, and a\n // key that syncs a catalogue into a PIM should not thereby be able to read\n // every form submission a site has ever taken. Read-only on purpose —\n // writes here move money or stock and want their own decision, in the same\n // change that ships them (the AGL-899 rule for `contacts:write`).\n 'orders:read',\n // AGL-2461. The read-only note above says commerce writes wait for \"their\n // own decision, in the same change that ships them\" because they \"move\n // money or stock\". Recording a shipment does NEITHER — `fulfill-order.ts`'s\n // own header says so: \"a forward status flip plus a timeline entry, no\n // stock moved, no money moved\" — so it is precisely the write that rule\n // admits, and it is the day-one commerce automation: a 3PL, ShipStation, or\n // a warehouse's own system marking an order shipped with a tracking number.\n // A public API that can take an order and never ship it is a fulfilment\n // dead end.\n //\n // Fulfilment ONLY. `cancelled` and `refunded` are refused by name with a\n // 400: those move stock and money through their own transactions, and this\n // scope must never become the door around them. The transition rule itself\n // is not re-implemented here at all — the commerce plugin serves the\n // resource and writes through the same transaction its console route\n // takes, so the API can never write a status the console forbids.\n 'orders:write',\n 'products:read',\n 'media:read',\n // AGL-2463. `media:read` was the only media scope, so an agency onboarding a\n // client site could automate everything about that site except putting its\n // images in it — an integration could LIST the library and never add to it,\n // which stops the asset pipeline at the one step that matters.\n //\n // Create only: no replace, no delete, no edit of an existing file's metadata.\n // A key handed to a migration tool should be able to fill a library and not\n // to empty one, and the destructive half wants its own decision in the change\n // that ships it — the same rule that kept this scope out until now.\n //\n // Charged, not free: the create goes through `mediaStorageGate`, the same\n // helper the console's four ingress routes use, and consumes the existing\n // per-GB storage dimension rather than inventing a per-upload one.\n 'media:write',\n // AGL-2606. The CRM v2 collections (AGL-2595) — companies, pipelines,\n // deals, tasks and activities — sit beside `contacts` and were reachable\n // only from the console, so the integration that owns the sales process\n // upstream (a CRM sync, a dialer logging calls, a billing system closing\n // deals) could read the people and nothing they were doing with them.\n //\n // ONE pair for the five resources rather than five, because a key that\n // needs any of them needs the set: a deal names a company and a contact, a\n // task names a deal, an activity names all three, and a key that could\n // read deals but not the pipelines whose stages they cite would hand back\n // stage ids it cannot explain. The contact's own CRM profile — phone, job\n // title, company, owner, lifecycle stage — stays under `contacts:*`, since\n // it is a field of the contact and reads with the contact.\n //\n // Reads are org-wide, as every read on this surface is: a key is an\n // organization credential. Writes name the site they belong to and are\n // stamped in that site's scope (`crmCreateStamp`), so an API-created deal\n // lands exactly where a console-created one would, and never wider.\n 'crm:read',\n 'crm:write',\n] as const\n\nexport type ApiScope = (typeof API_SCOPES)[number]\n\n/** Live-key token prefix. A `_test_` variant can follow the same shape later. */\nexport const API_KEY_TOKEN_PREFIX = 'aglyn_sk_'\n\n/** Top-level collection; doc id = the token hash. */\nexport const API_KEYS_COLLECTION = 'apiKeys'\n\n/** How much of the raw token is retained (plaintext) for display/identification. */\nconst DISPLAY_BODY_CHARS = 6\n\n/** Stored document. `createdAt`/`lastUsedAt`/etc. are Firestore Timestamps. */\nexport interface ApiKeyDocument {\n /** Public, stable id shown in the UI and used for revoke (never the hash). */\n keyId: string\n orgId: string\n name: string\n /** e.g. `aglyn_sk_ab12cd…` — enough to recognize a key, not to use it. */\n keyPrefix: string\n scopes: ApiScope[]\n createdBy: string\n createdAt: FirebaseFirestore.Timestamp\n lastUsedAt: FirebaseFirestore.Timestamp | null\n revokedAt: FirebaseFirestore.Timestamp | null\n expiresAt: FirebaseFirestore.Timestamp | null\n}\n\n/** Metadata safe to return to a client (no hash, no raw token). */\nexport interface PublicApiKey {\n keyId: string\n name: string\n keyPrefix: string\n scopes: ApiScope[]\n createdAt: string | null\n lastUsedAt: string | null\n revokedAt: string | null\n expiresAt: string | null\n}\n\n/** The resolved principal after a successful key verification. */\nexport interface VerifiedApiKey {\n orgId: string\n keyId: string\n scopes: ApiScope[]\n /**\n * The name the organization gave the key — what a feed attributes the\n * key's writes to (AGL-2632). Every minted key carries one; empty only\n * for a document written before the field was required.\n */\n name: string\n}\n\n// ── Pure helpers (unit-tested; no I/O) ──────────────────────────────────────\n\n/** True if `value` is one of the known scopes. */\nexport function isApiScope(value: string): value is ApiScope {\n return (API_SCOPES as readonly string[]).includes(value)\n}\n\n/** Keep only valid, de-duplicated scopes, preserving `API_SCOPES` order. */\nexport function normalizeScopes(scopes: readonly string[]): ApiScope[] {\n const set = new Set(scopes.filter(isApiScope) as ApiScope[])\n return API_SCOPES.filter((scope) => set.has(scope))\n}\n\n/** SHA-256 hex of a raw token — the Firestore doc id and lookup key. */\nexport function hashApiKey(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/** Structural check before hashing/looking up — cheap reject of junk input. */\nexport function isValidApiKeyFormat(token: string): boolean {\n return (\n typeof token === 'string' &&\n token.startsWith(API_KEY_TOKEN_PREFIX) &&\n /^[A-Za-z0-9_-]{24,}$/.test(token.slice(API_KEY_TOKEN_PREFIX.length))\n )\n}\n\n/**\n * Generate a fresh token plus the derived hash and display prefix. The raw\n * `token` is the only time the secret exists in plaintext — persist the hash,\n * hand the token to the user once.\n */\nexport function generateApiKeyToken(): {\n token: string\n hash: string\n keyPrefix: string\n} {\n const body = randomBytes(24).toString('base64url')\n const token = `${API_KEY_TOKEN_PREFIX}${body}`\n return {\n token,\n hash: hashApiKey(token),\n keyPrefix: `${API_KEY_TOKEN_PREFIX}${body.slice(0, DISPLAY_BODY_CHARS)}…`,\n }\n}\n\n/** True when the granted scopes include the one an operation requires. */\nexport function hasScope(\n granted: readonly ApiScope[],\n required: ApiScope,\n): boolean {\n return granted.includes(required)\n}\n\n/** A random public key id (`key_…`), distinct from the secret and its hash. */\nexport function generateKeyId(): string {\n return `key_${randomBytes(9).toString('base64url')}`\n}\n\n// ── Storage (Admin SDK) ─────────────────────────────────────────────────────\n\nfunction collection() {\n return firebaseAdmin.app().firestore().collection(API_KEYS_COLLECTION)\n}\n\nconst tsToIso = (\n value: FirebaseFirestore.Timestamp | null | undefined,\n): string | null => (value ? value.toDate().toISOString() : null)\n\n/** Public projection of a stored key — never includes the hash/doc id. */\nexport function toPublicApiKey(doc: ApiKeyDocument): PublicApiKey {\n return {\n keyId: doc.keyId,\n name: doc.name,\n keyPrefix: doc.keyPrefix,\n scopes: doc.scopes ?? [],\n createdAt: tsToIso(doc.createdAt),\n lastUsedAt: tsToIso(doc.lastUsedAt),\n revokedAt: tsToIso(doc.revokedAt),\n expiresAt: tsToIso(doc.expiresAt),\n }\n}\n\nexport interface MintApiKeyInput {\n orgId: string\n name: string\n scopes: readonly string[]\n createdBy: string\n /** Optional absolute expiry, epoch ms. */\n expiresAtMs?: number | null\n}\n\n/**\n * Create a key for an org. Returns the raw `token` (show once) and the public\n * metadata. Invalid scopes are dropped; at least one valid scope is required.\n */\nexport async function mintApiKey(\n input: MintApiKeyInput,\n): Promise<{ token: string; key: PublicApiKey }> {\n const scopes = normalizeScopes(input.scopes)\n if (scopes.length === 0) {\n throw new Error('An API key needs at least one valid scope')\n }\n const { Timestamp, FieldValue } = firebaseAdmin.firestore\n const { token, hash, keyPrefix } = generateApiKeyToken()\n const keyId = generateKeyId()\n const expiresAt =\n input.expiresAtMs != null ? Timestamp.fromMillis(input.expiresAtMs) : null\n\n const doc = {\n keyId,\n orgId: input.orgId,\n name: input.name.slice(0, 120),\n keyPrefix,\n scopes,\n createdBy: input.createdBy,\n createdAt: FieldValue.serverTimestamp(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt,\n }\n // `create` (not set) so a hash collision can never overwrite another key.\n await collection().doc(hash).create(doc)\n\n return {\n token,\n key: {\n keyId,\n name: doc.name,\n keyPrefix,\n scopes,\n createdAt: new Date().toISOString(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt: expiresAt ? expiresAt.toDate().toISOString() : null,\n },\n }\n}\n\n/**\n * Resolve a raw token to its org/scopes, or `null` if it is malformed,\n * unknown, revoked, or expired. Refreshes `lastUsedAt` at most once a minute\n * (fire-and-forget) to avoid a write on every request.\n */\nexport async function verifyApiKey(\n token: string,\n): Promise<VerifiedApiKey | null> {\n if (!isValidApiKeyFormat(token)) return null\n const snap = await collection().doc(hashApiKey(token)).get()\n if (!snap.exists) return null\n const data = snap.data() as ApiKeyDocument\n\n if (data.revokedAt) return null\n const now = Date.now()\n if (data.expiresAt && data.expiresAt.toMillis() <= now) return null\n\n const lastUsedMs = data.lastUsedAt ? data.lastUsedAt.toMillis() : 0\n if (now - lastUsedMs > 60_000) {\n snap.ref\n .update({ lastUsedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp() })\n .catch(() => undefined)\n }\n\n return {\n orgId: data.orgId,\n keyId: data.keyId,\n scopes: data.scopes ?? [],\n name: String(data.name ?? ''),\n }\n}\n\n/** All keys for an org, newest first (metadata only). */\nexport async function listApiKeys(orgId: string): Promise<PublicApiKey[]> {\n const snap = await collection().where('orgId', '==', orgId).get()\n return snap.docs\n .map((doc) => toPublicApiKey(doc.data() as ApiKeyDocument))\n .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))\n}\n\n/** Revoke a key by its public id. Returns false if not found/already revoked. */\nexport async function revokeApiKey(\n orgId: string,\n keyId: string,\n): Promise<boolean> {\n const snap = await collection().where('orgId', '==', orgId).get()\n const match = snap.docs.find(\n (doc) => (doc.data() as ApiKeyDocument).keyId === keyId,\n )\n if (!match || (match.data() as ApiKeyDocument).revokedAt) return false\n await match.ref.update({\n revokedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return true\n}\n"],"names":["createHash","randomBytes","firebaseAdmin","API_SCOPES","API_KEY_TOKEN_PREFIX","API_KEYS_COLLECTION","DISPLAY_BODY_CHARS","isApiScope","value","includes","normalizeScopes","scopes","set","Set","filter","scope","has","hashApiKey","token","update","digest","isValidApiKeyFormat","startsWith","test","slice","length","generateApiKeyToken","body","toString","hash","keyPrefix","hasScope","granted","required","generateKeyId","collection","app","firestore","tsToIso","toDate","toISOString","toPublicApiKey","doc","keyId","name","createdAt","lastUsedAt","revokedAt","expiresAt","mintApiKey","input","Error","Timestamp","FieldValue","expiresAtMs","fromMillis","orgId","createdBy","serverTimestamp","create","key","Date","verifyApiKey","data","snap","get","exists","now","toMillis","lastUsedMs","ref","catch","undefined","String","listApiKeys","where","docs","map","sort","a","b","localeCompare","revokeApiKey","match","find"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-keys.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 * Customer API keys (AGL-616). Keys authenticate the public REST API\n * (`/api/v1/**`). Only a SHA-256 hash of the key is ever stored — the raw\n * token is shown to the user exactly once, at creation. The Firestore doc id\n * IS the hash, so verification is an O(1) `get()` and the hash never leaves\n * the server (listings return metadata only). The `apiKeys` collection is\n * deny-all in the security rules; all access is Admin-SDK only.\n *\n * The token/hash/format/scope helpers are pure and unit-tested; the storage\n * functions wrap the Admin SDK.\n */\nimport { createHash, randomBytes } from 'node:crypto'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * Scopes a key can be granted, as `resource:action`. Every entry must be\n * enforced by a real endpoint — a mintable scope that grants nothing reads\n * as a broken permission (AGL-899 removed `contacts:write` on those grounds,\n * and AGL-2276 re-added it in the same change that shipped the writes, which\n * is the condition AGL-899 set).\n */\nexport const API_SCOPES = [\n 'datasets:read',\n 'datasets:write',\n 'contacts:read',\n // AGL-2276. Contacts used to arrive only through the four site capture\n // points, so an integration that OWNS the customer list upstream — a CRM,\n // a mailing tool, a migration off another platform — could read the\n // audience and never add to it. The write is metered against the same\n // audience bands capture is (AGL-890): `checkContactQuota` on the create,\n // and the monthly rollup counts `orgs/{orgId}/contacts` whatever wrote\n // them, so an API-created contact bills exactly like a form-captured one.\n // The scope covers the create, the tag/notes edit the console's Contacts\n // page already makes, and the delete — never `email`, which is the dedupe\n // key, and never `sources`/`interactions`, which are provenance.\n 'contacts:write',\n 'sites:read',\n // AGL-2462. An integration could write a dataset record over /v1 and had no\n // way to make a live page show it: `getDatasets` caches for 60s behind\n // `tenant-data:{hostId}` and the catch-all page is `revalidate = 60`, and\n // time-based ISR is stale-while-revalidate — so the visitor AFTER the window\n // can still get the old copy. A cache expiring is not a publish.\n //\n // Its OWN scope rather than a `sites:write` covering two very different\n // powers. Publishing is not a site edit: it changes no stored field, and it\n // is the one /v1 call whose cost lands on a different service — up to 250\n // dropped pages, each re-rendering at roughly 40 Firestore reads. A key that\n // syncs a catalogue should not thereby be able to spend that, and an\n // integration that only needs to flush its own writes should not have to\n // hold a scope that could rename the site. Bounded by a per-HOST hourly\n // budget on top of the per-key request limit, because one call and 250\n // dropped pages are not the same unit of cost.\n 'sites:publish',\n // AGL-2465. Everything downstream of a site was automatable and creating\n // the site was not: `handleSites` 405'd every non-GET, so an agency\n // onboarding a client could push its datasets, contacts and media and still\n // had to open a browser to make the site those things go in.\n //\n // CREATE ONLY — no rename, no delete, and the omissions are deliberate\n // rather than unfinished. `hosts/rename` authorizes off the host projection\n // alone — the member's own entry in `memberRoles`, which must equal\n // `admin` — and an API key is an ORG credential with no uid, so that gate\n // has no translation at all; giving it one is a permission decision, not a\n // port. `hosts/delete` runs\n // `recursiveDelete` over the whole site plus a storage prefix wipe with no\n // hold and no soft-delete, and the type-the-name confirmation that guards it\n // is console UI only — a one-field irreversible delete is the last thing to\n // put behind a bearer token, and whether it should exist at all is a product\n // decision that has not been made.\n //\n // Replay-safe, which is what actually blocked this: site creation is the\n // most expensive object here to duplicate — it takes a `hostLimit` slot,\n // writes `hostIndex` and runs `syncOrgAuthProjections` across every member —\n // and `/v1` publishes idempotency as a contract. A POST that succeeded\n // server-side but lost its response now replays the original site rather\n // than minting a second one.\n 'sites:write',\n 'forms:read',\n // AGL-2127. A lead sync could READ a submission and never record that it\n // had — so it re-pushed the same lead on every poll, or kept its own\n // high-water mark against a list ordered by document id rather than by\n // time. `read` is the flag the console already toggles on the same\n // document, so the scope grants exactly the state the integration needs\n // (plus the delete that lets it purge after export), and nothing that\n // rewrites what a visitor actually typed.\n 'forms:write',\n // Commerce and media reads (AGL-1928). Each is separate from `sites:read`\n // for the same reason `forms:read` is: a key handed to a fulfilment or\n // accounting integration needs the order history and nothing else, and a\n // key that syncs a catalogue into a PIM should not thereby be able to read\n // every form submission a site has ever taken. Read-only on purpose —\n // writes here move money or stock and want their own decision, in the same\n // change that ships them (the AGL-899 rule for `contacts:write`).\n 'orders:read',\n // AGL-2461. The read-only note above says commerce writes wait for \"their\n // own decision, in the same change that ships them\" because they \"move\n // money or stock\". Recording a shipment does NEITHER — `fulfill-order.ts`'s\n // own header says so: \"a forward status flip plus a timeline entry, no\n // stock moved, no money moved\" — so it is precisely the write that rule\n // admits, and it is the day-one commerce automation: a 3PL, ShipStation, or\n // a warehouse's own system marking an order shipped with a tracking number.\n // A public API that can take an order and never ship it is a fulfilment\n // dead end.\n //\n // Fulfilment ONLY. `cancelled` and `refunded` are refused by name with a\n // 400: those move stock and money through their own transactions, and this\n // scope must never become the door around them. The transition rule itself\n // is not re-implemented here at all — the commerce plugin serves the\n // resource and writes through the same transaction its console route\n // takes, so the API can never write a status the console forbids.\n 'orders:write',\n 'products:read',\n // AGL-3643. A site's bookings were the one record an integration could not\n // read at all — not a booking app's sync, not a Zapier trigger's sample —\n // though a booking is as much a customer's record as an order. Read-only\n // on purpose, and its own scope for the reason `orders:read` is: a key\n // handed to a scheduling or calendar tool needs the appointments and\n // nothing else. Writes (moving or canceling one, which tells the guest and\n // can refund them) want their own decision, in the change that ships them.\n 'bookings:read',\n 'media:read',\n // AGL-2463. `media:read` was the only media scope, so an agency onboarding a\n // client site could automate everything about that site except putting its\n // images in it — an integration could LIST the library and never add to it,\n // which stops the asset pipeline at the one step that matters.\n //\n // Create only: no replace, no delete, no edit of an existing file's metadata.\n // A key handed to a migration tool should be able to fill a library and not\n // to empty one, and the destructive half wants its own decision in the change\n // that ships it — the same rule that kept this scope out until now.\n //\n // Charged, not free: the create goes through `mediaStorageGate`, the same\n // helper the console's four ingress routes use, and consumes the existing\n // per-GB storage dimension rather than inventing a per-upload one.\n 'media:write',\n // AGL-2606. The CRM v2 collections (AGL-2595) — companies, pipelines,\n // deals, tasks and activities — sit beside `contacts` and were reachable\n // only from the console, so the integration that owns the sales process\n // upstream (a CRM sync, a dialer logging calls, a billing system closing\n // deals) could read the people and nothing they were doing with them.\n //\n // ONE pair for the five resources rather than five, because a key that\n // needs any of them needs the set: a deal names a company and a contact, a\n // task names a deal, an activity names all three, and a key that could\n // read deals but not the pipelines whose stages they cite would hand back\n // stage ids it cannot explain. The contact's own CRM profile — phone, job\n // title, company, owner, lifecycle stage — stays under `contacts:*`, since\n // it is a field of the contact and reads with the contact.\n //\n // Reads are org-wide, as every read on this surface is: a key is an\n // organization credential. Writes name the site they belong to and are\n // stamped in that site's scope (`crmCreateStamp`), so an API-created deal\n // lands exactly where a console-created one would, and never wider.\n 'crm:read',\n 'crm:write',\n] as const\n\nexport type ApiScope = (typeof API_SCOPES)[number]\n\n/** Live-key token prefix. A `_test_` variant can follow the same shape later. */\nexport const API_KEY_TOKEN_PREFIX = 'aglyn_sk_'\n\n/** Top-level collection; doc id = the token hash. */\nexport const API_KEYS_COLLECTION = 'apiKeys'\n\n/** How much of the raw token is retained (plaintext) for display/identification. */\nconst DISPLAY_BODY_CHARS = 6\n\n/** Stored document. `createdAt`/`lastUsedAt`/etc. are Firestore Timestamps. */\nexport interface ApiKeyDocument {\n /** Public, stable id shown in the UI and used for revoke (never the hash). */\n keyId: string\n orgId: string\n name: string\n /** e.g. `aglyn_sk_ab12cd…` — enough to recognize a key, not to use it. */\n keyPrefix: string\n scopes: ApiScope[]\n createdBy: string\n createdAt: FirebaseFirestore.Timestamp\n lastUsedAt: FirebaseFirestore.Timestamp | null\n revokedAt: FirebaseFirestore.Timestamp | null\n expiresAt: FirebaseFirestore.Timestamp | null\n}\n\n/** Metadata safe to return to a client (no hash, no raw token). */\nexport interface PublicApiKey {\n keyId: string\n name: string\n keyPrefix: string\n scopes: ApiScope[]\n createdAt: string | null\n lastUsedAt: string | null\n revokedAt: string | null\n expiresAt: string | null\n}\n\n/** The resolved principal after a successful key verification. */\nexport interface VerifiedApiKey {\n orgId: string\n keyId: string\n scopes: ApiScope[]\n /**\n * The name the organization gave the key — what a feed attributes the\n * key's writes to (AGL-2632). Every minted key carries one; empty only\n * for a document written before the field was required.\n */\n name: string\n}\n\n// ── Pure helpers (unit-tested; no I/O) ──────────────────────────────────────\n\n/** True if `value` is one of the known scopes. */\nexport function isApiScope(value: string): value is ApiScope {\n return (API_SCOPES as readonly string[]).includes(value)\n}\n\n/** Keep only valid, de-duplicated scopes, preserving `API_SCOPES` order. */\nexport function normalizeScopes(scopes: readonly string[]): ApiScope[] {\n const set = new Set(scopes.filter(isApiScope) as ApiScope[])\n return API_SCOPES.filter((scope) => set.has(scope))\n}\n\n/** SHA-256 hex of a raw token — the Firestore doc id and lookup key. */\nexport function hashApiKey(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/** Structural check before hashing/looking up — cheap reject of junk input. */\nexport function isValidApiKeyFormat(token: string): boolean {\n return (\n typeof token === 'string' &&\n token.startsWith(API_KEY_TOKEN_PREFIX) &&\n /^[A-Za-z0-9_-]{24,}$/.test(token.slice(API_KEY_TOKEN_PREFIX.length))\n )\n}\n\n/**\n * Generate a fresh token plus the derived hash and display prefix. The raw\n * `token` is the only time the secret exists in plaintext — persist the hash,\n * hand the token to the user once.\n */\nexport function generateApiKeyToken(): {\n token: string\n hash: string\n keyPrefix: string\n} {\n const body = randomBytes(24).toString('base64url')\n const token = `${API_KEY_TOKEN_PREFIX}${body}`\n return {\n token,\n hash: hashApiKey(token),\n keyPrefix: `${API_KEY_TOKEN_PREFIX}${body.slice(0, DISPLAY_BODY_CHARS)}…`,\n }\n}\n\n/** True when the granted scopes include the one an operation requires. */\nexport function hasScope(\n granted: readonly ApiScope[],\n required: ApiScope,\n): boolean {\n return granted.includes(required)\n}\n\n/** A random public key id (`key_…`), distinct from the secret and its hash. */\nexport function generateKeyId(): string {\n return `key_${randomBytes(9).toString('base64url')}`\n}\n\n// ── Storage (Admin SDK) ─────────────────────────────────────────────────────\n\nfunction collection() {\n return firebaseAdmin.app().firestore().collection(API_KEYS_COLLECTION)\n}\n\nconst tsToIso = (\n value: FirebaseFirestore.Timestamp | null | undefined,\n): string | null => (value ? value.toDate().toISOString() : null)\n\n/** Public projection of a stored key — never includes the hash/doc id. */\nexport function toPublicApiKey(doc: ApiKeyDocument): PublicApiKey {\n return {\n keyId: doc.keyId,\n name: doc.name,\n keyPrefix: doc.keyPrefix,\n scopes: doc.scopes ?? [],\n createdAt: tsToIso(doc.createdAt),\n lastUsedAt: tsToIso(doc.lastUsedAt),\n revokedAt: tsToIso(doc.revokedAt),\n expiresAt: tsToIso(doc.expiresAt),\n }\n}\n\nexport interface MintApiKeyInput {\n orgId: string\n name: string\n scopes: readonly string[]\n createdBy: string\n /** Optional absolute expiry, epoch ms. */\n expiresAtMs?: number | null\n}\n\n/**\n * Create a key for an org. Returns the raw `token` (show once) and the public\n * metadata. Invalid scopes are dropped; at least one valid scope is required.\n */\nexport async function mintApiKey(\n input: MintApiKeyInput,\n): Promise<{ token: string; key: PublicApiKey }> {\n const scopes = normalizeScopes(input.scopes)\n if (scopes.length === 0) {\n throw new Error('An API key needs at least one valid scope')\n }\n const { Timestamp, FieldValue } = firebaseAdmin.firestore\n const { token, hash, keyPrefix } = generateApiKeyToken()\n const keyId = generateKeyId()\n const expiresAt =\n input.expiresAtMs != null ? Timestamp.fromMillis(input.expiresAtMs) : null\n\n const doc = {\n keyId,\n orgId: input.orgId,\n name: input.name.slice(0, 120),\n keyPrefix,\n scopes,\n createdBy: input.createdBy,\n createdAt: FieldValue.serverTimestamp(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt,\n }\n // `create` (not set) so a hash collision can never overwrite another key.\n await collection().doc(hash).create(doc)\n\n return {\n token,\n key: {\n keyId,\n name: doc.name,\n keyPrefix,\n scopes,\n createdAt: new Date().toISOString(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt: expiresAt ? expiresAt.toDate().toISOString() : null,\n },\n }\n}\n\n/**\n * Resolve a raw token to its org/scopes, or `null` if it is malformed,\n * unknown, revoked, or expired. Refreshes `lastUsedAt` at most once a minute\n * (fire-and-forget) to avoid a write on every request.\n */\nexport async function verifyApiKey(\n token: string,\n): Promise<VerifiedApiKey | null> {\n if (!isValidApiKeyFormat(token)) return null\n const snap = await collection().doc(hashApiKey(token)).get()\n if (!snap.exists) return null\n const data = snap.data() as ApiKeyDocument\n\n if (data.revokedAt) return null\n const now = Date.now()\n if (data.expiresAt && data.expiresAt.toMillis() <= now) return null\n\n const lastUsedMs = data.lastUsedAt ? data.lastUsedAt.toMillis() : 0\n if (now - lastUsedMs > 60_000) {\n snap.ref\n .update({ lastUsedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp() })\n .catch(() => undefined)\n }\n\n return {\n orgId: data.orgId,\n keyId: data.keyId,\n scopes: data.scopes ?? [],\n name: String(data.name ?? ''),\n }\n}\n\n/** All keys for an org, newest first (metadata only). */\nexport async function listApiKeys(orgId: string): Promise<PublicApiKey[]> {\n const snap = await collection().where('orgId', '==', orgId).get()\n return snap.docs\n .map((doc) => toPublicApiKey(doc.data() as ApiKeyDocument))\n .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))\n}\n\n/** Revoke a key by its public id. Returns false if not found/already revoked. */\nexport async function revokeApiKey(\n orgId: string,\n keyId: string,\n): Promise<boolean> {\n const snap = await collection().where('orgId', '==', orgId).get()\n const match = snap.docs.find(\n (doc) => (doc.data() as ApiKeyDocument).keyId === keyId,\n )\n if (!match || (match.data() as ApiKeyDocument).revokedAt) return false\n await match.ref.update({\n revokedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return true\n}\n\n/** What a key may still do, read by its public id (AGL-3643). */\nexport interface ApiKeyGrant {\n keyId: string\n name: string\n scopes: ApiScope[]\n}\n\n/**\n * The key's grant as it stands now, by its public id: `null` when the key is\n * unknown to the organization, revoked or expired. For something a key SET\n * UP earlier that acts on its behalf later — a REST hook it subscribed —\n * which must stop when the key does, and never outlive a revoke.\n */\nexport async function readApiKeyGrant(\n orgId: string,\n keyId: string,\n now = Date.now(),\n): Promise<ApiKeyGrant | null> {\n if (!orgId || !keyId) return null\n const snap = await collection()\n .where('orgId', '==', orgId)\n .where('keyId', '==', keyId)\n .limit(1)\n .get()\n const doc = snap.docs[0]\n if (!doc) return null\n const data = doc.data() as ApiKeyDocument\n if (data.revokedAt) return null\n if (data.expiresAt && data.expiresAt.toMillis() <= now) return null\n return { keyId: data.keyId, name: String(data.name ?? ''), scopes: normalizeScopes(data.scopes ?? []) }\n}\n"],"names":["createHash","randomBytes","firebaseAdmin","API_SCOPES","API_KEY_TOKEN_PREFIX","API_KEYS_COLLECTION","DISPLAY_BODY_CHARS","isApiScope","value","includes","normalizeScopes","scopes","set","Set","filter","scope","has","hashApiKey","token","update","digest","isValidApiKeyFormat","startsWith","test","slice","length","generateApiKeyToken","body","toString","hash","keyPrefix","hasScope","granted","required","generateKeyId","collection","app","firestore","tsToIso","toDate","toISOString","toPublicApiKey","doc","keyId","name","createdAt","lastUsedAt","revokedAt","expiresAt","mintApiKey","input","Error","Timestamp","FieldValue","expiresAtMs","fromMillis","orgId","createdBy","serverTimestamp","create","key","Date","verifyApiKey","data","snap","get","exists","now","toMillis","lastUsedMs","ref","catch","undefined","String","listApiKeys","where","docs","map","sort","a","b","localeCompare","revokeApiKey","match","find","readApiKeyGrant","limit"],"mappings":"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"}
|