@aglyn/tenant-data-admin 1.0.0-beta.261 → 1.0.0-beta.263
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/figure-readers.d.ts +44 -0
- package/src/lib/server/figure-readers.js +45 -0
- package/src/lib/server/figure-readers.js.map +1 -0
- package/src/lib/server/keep-alive.d.ts +18 -0
- package/src/lib/server/keep-alive.js +59 -0
- package/src/lib/server/keep-alive.js.map +1 -0
- package/src/lib/server/mobile-push-switch.js +17 -4
- package/src/lib/server/mobile-push-switch.js.map +1 -1
- package/src/lib/server/native-push.js +6 -3
- package/src/lib/server/native-push.js.map +1 -1
- package/src/lib/server/notifications.d.ts +16 -5
- package/src/lib/server/notifications.js +30 -9
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -0
- package/src/lib/server/operator-alerts.js +6 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/operator-health.d.ts +69 -0
- package/src/lib/server/operator-health.js +87 -0
- package/src/lib/server/operator-health.js.map +1 -1
- package/src/lib/server/push-apns.js +3 -1
- package/src/lib/server/push-apns.js.map +1 -1
- package/src/lib/server/push-delivery.d.ts +9 -0
- package/src/lib/server/push-delivery.js +10 -0
- package/src/lib/server/push-delivery.js.map +1 -1
- package/src/lib/server/render-system-email.d.ts +7 -1
- package/src/lib/server/render-system-email.js +1 -1
- package/src/lib/server/render-system-email.js.map +1 -1
- package/src/lib/server/sso-domain-policy.d.ts +43 -7
- package/src/lib/server/sso-domain-policy.js +61 -9
- package/src/lib/server/sso-domain-policy.js.map +1 -1
- package/src/lib/server/weekly-summary.d.ts +160 -0
- package/src/lib/server/weekly-summary.js +568 -0
- package/src/lib/server/weekly-summary.js.map +1 -0
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* The block below runs in two places: here, at the session mint, and in the
|
|
19
|
+
* `cloud/functions` account-creation blocking function, which refuses the
|
|
20
|
+
* account before it exists. `cloud/functions` is a plain npm package that can
|
|
21
|
+
* import only firebase-admin and firebase-functions, so it holds a verbatim
|
|
22
|
+
* copy (`cloud/functions/src/sso-domain-policy.ts`), and
|
|
23
|
+
* `apps/console/specs/sso-domain-creation-wiring.spec.ts` fails if the two
|
|
24
|
+
* differ by a single character. Change it here, then copy the region across.
|
|
25
|
+
*/
|
|
17
26
|
export declare const SSO_REQUIRED_DOMAINS_ENV = "AGLYN_SSO_REQUIRED_DOMAINS";
|
|
18
27
|
export type SsoRequiredDomains = Readonly<Record<string, string>>;
|
|
19
28
|
/**
|
|
@@ -28,6 +37,23 @@ export declare function parseSsoRequiredDomains(raw: string | null | undefined):
|
|
|
28
37
|
* The operator's SSO-required domains. Empty unless configured — see above.
|
|
29
38
|
*/
|
|
30
39
|
export declare function ssoRequiredDomains(env?: Record<string, string | undefined>): SsoRequiredDomains;
|
|
40
|
+
/**
|
|
41
|
+
* Addresses on a governed domain that may still sign in OUTSIDE its tenant.
|
|
42
|
+
*
|
|
43
|
+
* For accounts that cannot use the IdP at all because someone outside the
|
|
44
|
+
* company has to sign in with them: the demo login an app store reviewer is
|
|
45
|
+
* given, for example. Exact addresses only, never a pattern, so every
|
|
46
|
+
* exception is named and can be read back from configuration:
|
|
47
|
+
*
|
|
48
|
+
* ```
|
|
49
|
+
* AGLYN_SSO_EXEMPT_EMAILS="reviewer@example.com"
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* Comma or whitespace separated, compared lower-cased. Empty by default.
|
|
53
|
+
*/
|
|
54
|
+
export declare const SSO_EXEMPT_EMAILS_ENV = "AGLYN_SSO_EXEMPT_EMAILS";
|
|
55
|
+
export declare function parseSsoExemptEmails(raw: string | null | undefined): ReadonlySet<string>;
|
|
56
|
+
export declare function ssoExemptEmails(env?: Record<string, string | undefined>): ReadonlySet<string>;
|
|
31
57
|
export type SsoDomainVerdict =
|
|
32
58
|
/** Domain is not governed. Any pool is fine — this is most of the world. */
|
|
33
59
|
'allow-ungoverned'
|
|
@@ -39,6 +65,11 @@ export type SsoDomainVerdict =
|
|
|
39
65
|
* nobody asked for — but surfaced so a staff-access review can see it.
|
|
40
66
|
*/
|
|
41
67
|
| 'allow-foreign-tenant'
|
|
68
|
+
/**
|
|
69
|
+
* Governed domain, no tenant, but the address is named in
|
|
70
|
+
* {@link SSO_EXEMPT_EMAILS_ENV}. Allowed and reviewable.
|
|
71
|
+
*/
|
|
72
|
+
| 'allow-exempt'
|
|
42
73
|
/**
|
|
43
74
|
* Governed domain with NO tenant at all: a project-pool password or consumer
|
|
44
75
|
* Google sign-in. The only refusal this policy ever produces.
|
|
@@ -82,7 +113,7 @@ export declare function domainOf(email: string | null | undefined): string | nul
|
|
|
82
113
|
* so a spec can drive every combination directly, and so the audit view and
|
|
83
114
|
* the enforcement path can never disagree about what the policy says.
|
|
84
115
|
*/
|
|
85
|
-
export declare function evaluateSsoDomainPolicy(identity: SsoDomainIdentity, domains?: SsoRequiredDomains): SsoDomainDecision;
|
|
116
|
+
export declare function evaluateSsoDomainPolicy(identity: SsoDomainIdentity, domains?: SsoRequiredDomains, exempt?: ReadonlySet<string>): SsoDomainDecision;
|
|
86
117
|
/**
|
|
87
118
|
* THE ENFORCEMENT SWITCH — ships OFF.
|
|
88
119
|
*
|
|
@@ -102,18 +133,23 @@ export declare function evaluateSsoDomainPolicy(identity: SsoDomainIdentity, dom
|
|
|
102
133
|
* **Precondition for flipping it — written down so it is not re-litigated:**
|
|
103
134
|
* 1. SSO staff access verified working through a REAL sign-in (not a token
|
|
104
135
|
* inspection): a staff account reaches `/admin/*` via the SAML tenant.
|
|
105
|
-
* 2. Every identity on a governed domain has been migrated into its tenant
|
|
106
|
-
* retired
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* e2e smoke sign-in, which is the one that bites without warning: it is an
|
|
110
|
-
* AUTOMATION account, so nobody is watching a sign-in page when it fails.
|
|
136
|
+
* 2. Every identity on a governed domain has been migrated into its tenant,
|
|
137
|
+
* retired, or named in {@link SSO_EXEMPT_EMAILS_ENV}. An automation or
|
|
138
|
+
* reviewer account is the one that bites without warning: nobody is
|
|
139
|
+
* watching a sign-in page when it fails.
|
|
111
140
|
*
|
|
112
141
|
* A second staff identity is NOT a precondition: the permanent break-glass
|
|
113
142
|
* account is on an ungoverned domain, so this rule can never refuse it.
|
|
114
143
|
*/
|
|
115
144
|
export declare const SSO_DOMAIN_ENFORCEMENT_ENV = "AGLYN_SSO_DOMAIN_ENFORCEMENT";
|
|
116
145
|
export declare function ssoDomainEnforcementEnabled(env?: Record<string, string | undefined>): boolean;
|
|
146
|
+
/**
|
|
147
|
+
* Whether an account about to be CREATED must be refused. Same switch, same
|
|
148
|
+
* domains, same exemptions as the session mint, so an address the mint would
|
|
149
|
+
* turn away is never created in the first place, and an address it admits
|
|
150
|
+
* is never refused here.
|
|
151
|
+
*/
|
|
152
|
+
export declare function ssoDomainCreationRefused(identity: SsoDomainIdentity, env?: Record<string, string | undefined>): boolean;
|
|
117
153
|
/**
|
|
118
154
|
* What the refused person is told. Names the fix, not the mechanism.
|
|
119
155
|
*
|
|
@@ -83,6 +83,15 @@
|
|
|
83
83
|
* naming WHOSE SSO: the point is IdP lifecycle control, which only the tenant
|
|
84
84
|
* wired to that domain's SAML provider delivers.
|
|
85
85
|
*/ import { PLATFORM_BRAND_NAME } from "@aglyn/aglyn/app-utils/platform-brand";
|
|
86
|
+
/**
|
|
87
|
+
* The block below runs in two places: here, at the session mint, and in the
|
|
88
|
+
* `cloud/functions` account-creation blocking function, which refuses the
|
|
89
|
+
* account before it exists. `cloud/functions` is a plain npm package that can
|
|
90
|
+
* import only firebase-admin and firebase-functions, so it holds a verbatim
|
|
91
|
+
* copy (`cloud/functions/src/sso-domain-policy.ts`), and
|
|
92
|
+
* `apps/console/specs/sso-domain-creation-wiring.spec.ts` fails if the two
|
|
93
|
+
* differ by a single character. Change it here, then copy the region across.
|
|
94
|
+
*/ // #region sso-domain-policy
|
|
86
95
|
export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
|
|
87
96
|
/**
|
|
88
97
|
* Parse the configured domain→tenant map. Total: anything unparseable is
|
|
@@ -108,6 +117,31 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
|
|
|
108
117
|
*/ export function ssoRequiredDomains(env = process.env) {
|
|
109
118
|
return parseSsoRequiredDomains(env[SSO_REQUIRED_DOMAINS_ENV]);
|
|
110
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Addresses on a governed domain that may still sign in OUTSIDE its tenant.
|
|
122
|
+
*
|
|
123
|
+
* For accounts that cannot use the IdP at all because someone outside the
|
|
124
|
+
* company has to sign in with them: the demo login an app store reviewer is
|
|
125
|
+
* given, for example. Exact addresses only, never a pattern, so every
|
|
126
|
+
* exception is named and can be read back from configuration:
|
|
127
|
+
*
|
|
128
|
+
* ```
|
|
129
|
+
* AGLYN_SSO_EXEMPT_EMAILS="reviewer@example.com"
|
|
130
|
+
* ```
|
|
131
|
+
*
|
|
132
|
+
* Comma or whitespace separated, compared lower-cased. Empty by default.
|
|
133
|
+
*/ export const SSO_EXEMPT_EMAILS_ENV = 'AGLYN_SSO_EXEMPT_EMAILS';
|
|
134
|
+
export function parseSsoExemptEmails(raw) {
|
|
135
|
+
const emails = new Set();
|
|
136
|
+
for (const entry of String(raw != null ? raw : '').split(/[,\s]+/)){
|
|
137
|
+
const email = entry.trim().toLowerCase();
|
|
138
|
+
if (email.includes('@')) emails.add(email);
|
|
139
|
+
}
|
|
140
|
+
return emails;
|
|
141
|
+
}
|
|
142
|
+
export function ssoExemptEmails(env = process.env) {
|
|
143
|
+
return parseSsoExemptEmails(env[SSO_EXEMPT_EMAILS_ENV]);
|
|
144
|
+
}
|
|
111
145
|
/** The domain part of an address, lower-cased. Null when there isn't one. */ export function domainOf(email) {
|
|
112
146
|
const address = String(email != null ? email : '').trim().toLowerCase();
|
|
113
147
|
const at = address.lastIndexOf('@');
|
|
@@ -118,8 +152,8 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
|
|
|
118
152
|
* Decide the policy for one identity. Pure and total — no Firebase, no env —
|
|
119
153
|
* so a spec can drive every combination directly, and so the audit view and
|
|
120
154
|
* the enforcement path can never disagree about what the policy says.
|
|
121
|
-
*/ export function evaluateSsoDomainPolicy(identity, domains = ssoRequiredDomains()) {
|
|
122
|
-
var _identity_tenantId;
|
|
155
|
+
*/ export function evaluateSsoDomainPolicy(identity, domains = ssoRequiredDomains(), exempt = ssoExemptEmails()) {
|
|
156
|
+
var _identity_tenantId, _identity_email;
|
|
123
157
|
const domain = domainOf(identity.email);
|
|
124
158
|
const tenantId = (_identity_tenantId = identity.tenantId) != null ? _identity_tenantId : null;
|
|
125
159
|
// Exact-match lookup, never a suffix test: `notaglyn.com` ends with
|
|
@@ -161,6 +195,16 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
|
|
|
161
195
|
reviewable: true
|
|
162
196
|
};
|
|
163
197
|
}
|
|
198
|
+
if (exempt.has(String((_identity_email = identity.email) != null ? _identity_email : '').trim().toLowerCase())) {
|
|
199
|
+
return {
|
|
200
|
+
verdict: 'allow-exempt',
|
|
201
|
+
refused: false,
|
|
202
|
+
domain,
|
|
203
|
+
requiredTenantId,
|
|
204
|
+
tenantId: null,
|
|
205
|
+
reviewable: true
|
|
206
|
+
};
|
|
207
|
+
}
|
|
164
208
|
return {
|
|
165
209
|
verdict: 'refuse-sso-required',
|
|
166
210
|
refused: true,
|
|
@@ -189,12 +233,10 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
|
|
|
189
233
|
* **Precondition for flipping it — written down so it is not re-litigated:**
|
|
190
234
|
* 1. SSO staff access verified working through a REAL sign-in (not a token
|
|
191
235
|
* inspection): a staff account reaches `/admin/*` via the SAML tenant.
|
|
192
|
-
* 2. Every identity on a governed domain has been migrated into its tenant
|
|
193
|
-
* retired
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* e2e smoke sign-in, which is the one that bites without warning: it is an
|
|
197
|
-
* AUTOMATION account, so nobody is watching a sign-in page when it fails.
|
|
236
|
+
* 2. Every identity on a governed domain has been migrated into its tenant,
|
|
237
|
+
* retired, or named in {@link SSO_EXEMPT_EMAILS_ENV}. An automation or
|
|
238
|
+
* reviewer account is the one that bites without warning: nobody is
|
|
239
|
+
* watching a sign-in page when it fails.
|
|
198
240
|
*
|
|
199
241
|
* A second staff identity is NOT a precondition: the permanent break-glass
|
|
200
242
|
* account is on an ungoverned domain, so this rule can never refuse it.
|
|
@@ -202,6 +244,16 @@ export const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS';
|
|
|
202
244
|
export function ssoDomainEnforcementEnabled(env = process.env) {
|
|
203
245
|
return env[SSO_DOMAIN_ENFORCEMENT_ENV] === 'on';
|
|
204
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Whether an account about to be CREATED must be refused. Same switch, same
|
|
249
|
+
* domains, same exemptions as the session mint, so an address the mint would
|
|
250
|
+
* turn away is never created in the first place, and an address it admits
|
|
251
|
+
* is never refused here.
|
|
252
|
+
*/ export function ssoDomainCreationRefused(identity, env = process.env) {
|
|
253
|
+
if (!ssoDomainEnforcementEnabled(env)) return false;
|
|
254
|
+
return evaluateSsoDomainPolicy(identity, ssoRequiredDomains(env), ssoExemptEmails(env)).refused;
|
|
255
|
+
}
|
|
256
|
+
// #endregion sso-domain-policy
|
|
205
257
|
/**
|
|
206
258
|
* What the refused person is told. Names the fix, not the mechanism.
|
|
207
259
|
*
|
|
@@ -220,7 +272,7 @@ export function ssoDomainEnforcementEnabled(env = process.env) {
|
|
|
220
272
|
* authentication path is inert until the account owner flips it — the call site is live,
|
|
221
273
|
* the consequence is not.
|
|
222
274
|
*/ export function ssoDomainRefusal(identity, env = process.env) {
|
|
223
|
-
const decision = evaluateSsoDomainPolicy(identity, ssoRequiredDomains(env));
|
|
275
|
+
const decision = evaluateSsoDomainPolicy(identity, ssoRequiredDomains(env), ssoExemptEmails(env));
|
|
224
276
|
if (!decision.refused) return null;
|
|
225
277
|
if (!ssoDomainEnforcementEnabled(env)) return null;
|
|
226
278
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sso-domain-policy.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 * Workspace-domain SSO policy (AGL-1993).\n *\n * **The rule is about a DOMAIN, not about staff.** An `@aglyn.com` address is a\n * Google Workspace identity, and Workspace is what enforces MFA, disables an\n * account at offboarding, and lets an admin revoke a session. An `@aglyn.com`\n * address that signs in through a project-pool password or a consumer Google\n * account has none of that: it looks like a company identity and is governed\n * like a personal one. That gap is the whole reason this module exists.\n *\n * ## Staff is a SEPARATE axis and this module never reads it\n *\n * Staff may be granted to anybody — SSO or not, `@aglyn.com` or not, project\n * pool or any GCIP tenant including a *customer's*. Nothing here consults the\n * `staff` claim, and `sso-domain-policy.spec.ts` pins that by evaluating every\n * staff permutation and asserting the verdict does not move. The two axes are\n * independent; conflating them would turn a narrow domain control into an\n * accidental SSO mandate for staff, which is explicitly not the policy.\n *\n * These all remain valid, and each has a test:\n *\n * | identity | staff | verdict |\n * | ------------------------------------------ | ----- | ------------------------- |\n * | `@aglyn.com` in the designated tenant | yes | allowed |\n * | personal address, project pool (break-glass)| yes | allowed — permanent design|\n * | personal address, project pool | no | allowed |\n * | SSO identity in a CUSTOMER org's tenant | yes | allowed |\n * | `@aglyn.com` in the PROJECT pool | any | **refused** when enforced |\n *\n * Only the last row is what the switch exists for.\n *\n * ## How this differs from `sso-enforcement.ts`\n *\n * {@link enforceSsoSignInMethods} (AGL-1128/1129) strips non-IdP providers from\n * accounts that are ALREADY INSIDE a tenant. It cannot see an identity that\n * never joined the tenant at all — a project-pool `@aglyn.com` account is\n * invisible to every tenant sweep, by construction. Measured on aglyn-main on\n * 2026-08-18: two such accounts existed, one of them holding a live\n * `google.com` provider. This module covers that outside-the-tenant half.\n *\n * ## Shape note: deliberately NOT a discriminated union\n *\n * `strictNullChecks` is off repo-wide, which defeats boolean-discriminant\n * narrowing — a spec can read the wrong arm of a union and pass silently. So\n * the decision is ONE flat interface with a string `verdict`, and specs assert\n * the literal. There is no arm to read by mistake.\n */\n\n/**\n * ## Self-hosting: this policy is CONFIGURATION, never a compiled-in constant\n *\n * Aglyn ships in two shapes — the Aglyn-operated SaaS, and self-hosted installs\n * on the operator's own Firebase project. `aglyn.com` is *our* Workspace\n * domain; a self-hoster has their own domain, their own IdP, or none at all.\n * Baking our domain in would make the rule dead weight for them at best and a\n * lockout at worst, so the governed set is read from configuration and is\n * **empty by default**.\n *\n * Empty means the rule does nothing. That is simultaneously the correct\n * behaviour for a fresh self-host install and the correct OFF state for us —\n * one default serving both, rather than a special case for either.\n *\n * Format — `domain=tenantId`, comma or whitespace separated:\n *\n * ```\n * AGLYN_SSO_REQUIRED_DOMAINS=\"aglyn.com=aglyn-org-y5v14\"\n * ```\n *\n * A map rather than a bare list because \"must use SSO\" is meaningless without\n * naming WHOSE SSO: the point is IdP lifecycle control, which only the tenant\n * wired to that domain's SAML provider delivers.\n */\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\n\nexport const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS'\n\nexport type SsoRequiredDomains = Readonly<Record<string, string>>\n\n/**\n * Parse the configured domain→tenant map. Total: anything unparseable is\n * dropped rather than throwing, because this sits on the authentication path\n * and a malformed env var must not take sign-in down. A dropped entry fails\n * OPEN (the domain is simply ungoverned), which is the safe direction for a\n * rule whose only power is to refuse.\n */\nexport function parseSsoRequiredDomains(\n raw: string | null | undefined,\n): SsoRequiredDomains {\n const map: Record<string, string> = {}\n for (const pair of String(raw ?? '').split(/[,\\s]+/)) {\n if (!pair) continue\n const eq = pair.indexOf('=')\n if (eq <= 0) continue\n const domain = pair.slice(0, eq).trim().toLowerCase()\n const tenantId = pair.slice(eq + 1).trim()\n if (!domain || !tenantId) continue\n map[domain] = tenantId\n }\n return map\n}\n\n/**\n * The operator's SSO-required domains. Empty unless configured — see above.\n */\nexport function ssoRequiredDomains(\n env: Record<string, string | undefined> = process.env,\n): SsoRequiredDomains {\n return parseSsoRequiredDomains(env[SSO_REQUIRED_DOMAINS_ENV])\n}\n\nexport type SsoDomainVerdict =\n /** Domain is not governed. Any pool is fine — this is most of the world. */\n | 'allow-ungoverned'\n /** Governed domain, signed in through the tenant we designated for it. */\n | 'allow-designated-tenant'\n /**\n * Governed domain, signed in through SOME tenant, but not the designated\n * one. Allowed — they did use SSO, and refusing would invent a restriction\n * nobody asked for — but surfaced so a staff-access review can see it.\n */\n | 'allow-foreign-tenant'\n /**\n * Governed domain with NO tenant at all: a project-pool password or consumer\n * Google sign-in. The only refusal this policy ever produces.\n */\n | 'refuse-sso-required'\n\nexport interface SsoDomainDecision {\n verdict: SsoDomainVerdict\n /**\n * Whether enforcement WOULD refuse this identity. Independent of whether\n * enforcement is currently switched on — {@link ssoDomainEnforcementEnabled}\n * decides that — so the same evaluation can drive both an audit in\n * report-only mode and the refusal once the switch is flipped.\n */\n refused: boolean\n /** Lower-cased domain, or null when the identity has no email address. */\n domain: string | null\n /** Tenant this domain is required to use, or null when ungoverned. */\n requiredTenantId: string | null\n /** Tenant the identity actually signed in through; null = project pool. */\n tenantId: string | null\n /** Worth a human look even though it is allowed (foreign-tenant case). */\n reviewable: boolean\n}\n\nexport interface SsoDomainIdentity {\n /** The identity's email address. Absent/empty is treated as ungoverned. */\n email?: string | null\n /**\n * GCIP tenant the identity signed in through — `decoded.firebase?.tenant`,\n * or a `PooledUserRecord.tenantId`. Null/undefined means the project pool.\n *\n * Never inferred and never defaulted to a hard-coded tenant: a uid is only\n * unique WITHIN a pool, and assuming one is how a lookup silently lands on\n * the wrong record.\n */\n tenantId?: string | null\n}\n\n/** The domain part of an address, lower-cased. Null when there isn't one. */\nexport function domainOf(email: string | null | undefined): string | null {\n const address = String(email ?? '').trim().toLowerCase()\n const at = address.lastIndexOf('@')\n if (at < 0 || at === address.length - 1) return null\n return address.slice(at + 1)\n}\n\n/**\n * Decide the policy for one identity. Pure and total — no Firebase, no env —\n * so a spec can drive every combination directly, and so the audit view and\n * the enforcement path can never disagree about what the policy says.\n */\nexport function evaluateSsoDomainPolicy(\n identity: SsoDomainIdentity,\n domains: SsoRequiredDomains = ssoRequiredDomains(),\n): SsoDomainDecision {\n const domain = domainOf(identity.email)\n const tenantId = identity.tenantId ?? null\n // Exact-match lookup, never a suffix test: `notaglyn.com` ends with\n // `aglyn.com`, and a suffix test would refuse an unrelated company's staff.\n const requiredTenantId = domain\n ? (Object.prototype.hasOwnProperty.call(domains, domain)\n ? domains[domain]\n : null)\n : null\n\n if (!requiredTenantId) {\n // Ungoverned: a personal address, a customer's address, anything not on\n // the list. Includes the permanent break-glass account by construction\n // rather than by special case — see the staff-access doc.\n return {\n verdict: 'allow-ungoverned',\n refused: false,\n domain,\n requiredTenantId: null,\n tenantId,\n reviewable: false,\n }\n }\n\n if (tenantId === requiredTenantId) {\n return {\n verdict: 'allow-designated-tenant',\n refused: false,\n domain,\n requiredTenantId,\n tenantId,\n reviewable: false,\n }\n }\n\n if (tenantId) {\n // A governed address inside somebody else's tenant. They authenticated\n // through an IdP, so the hole this policy closes is not open here — but\n // the IdP is not one Aglyn controls, so it is flagged for review.\n return {\n verdict: 'allow-foreign-tenant',\n refused: false,\n domain,\n requiredTenantId,\n tenantId,\n reviewable: true,\n }\n }\n\n return {\n verdict: 'refuse-sso-required',\n refused: true,\n domain,\n requiredTenantId,\n tenantId: null,\n reviewable: true,\n }\n}\n\n/**\n * THE ENFORCEMENT SWITCH — ships OFF.\n *\n * Off means `evaluateSsoDomainPolicy` still computes `refused`, and callers\n * still record it, but nobody is turned away. That is deliberate: the audit\n * trail accumulates under the real rule before the rule bites, so flipping the\n * switch holds no surprises.\n *\n * Opt-in by exact string. Anything else — unset, empty, `false`, `0`, a typo —\n * is OFF. A security control that switches itself on through a misspelling is\n * a control that can lock people out through a misspelling.\n *\n * Note both halves must be set for anything to happen: this switch AND a\n * non-empty {@link SSO_REQUIRED_DOMAINS_ENV}. A self-host install that sets\n * only this switch still governs nothing.\n *\n * **Precondition for flipping it — written down so it is not re-litigated:**\n * 1. SSO staff access verified working through a REAL sign-in (not a token\n * inspection): a staff account reaches `/admin/*` via the SAML tenant.\n * 2. Every identity on a governed domain has been migrated into its tenant or\n * retired. Measured on aglyn-main 2026-08-18: TWO would have been refused,\n * neither of them staff — one on a consumer `google.com` provider, and the\n * e2e smoke account on `password`. Flipping before migrating breaks the\n * e2e smoke sign-in, which is the one that bites without warning: it is an\n * AUTOMATION account, so nobody is watching a sign-in page when it fails.\n *\n * A second staff identity is NOT a precondition: the permanent break-glass\n * account is on an ungoverned domain, so this rule can never refuse it.\n */\nexport const SSO_DOMAIN_ENFORCEMENT_ENV = 'AGLYN_SSO_DOMAIN_ENFORCEMENT'\n\nexport function ssoDomainEnforcementEnabled(\n env: Record<string, string | undefined> = process.env,\n): boolean {\n return env[SSO_DOMAIN_ENFORCEMENT_ENV] === 'on'\n}\n\n/**\n * What the refused person is told. Names the fix, not the mechanism.\n *\n * `PLATFORM_BRAND_NAME`, not a literal (AGL-2214). Everything else in this\n * module is careful that the RULE is configuration rather than a compiled-in\n * assumption about Aglyn — and then the one string a refused person actually\n * reads named us anyway. A self-hoster who governs their own domain would\n * have turned their own staff away in our name.\n *\n * Byte-identical for a deployment that has not renamed itself.\n */\nexport const SSO_DOMAIN_REFUSAL_MESSAGE =\n `Sign in with your ${PLATFORM_BRAND_NAME} Workspace account ` +\n '(Single sign-on). Accounts on this domain cannot use a password or ' +\n 'personal Google sign-in.'\n\n/**\n * The refusal a caller should return, or null to carry on.\n *\n * Returns null whenever the switch is off, so wiring this into an\n * authentication path is inert until the account owner flips it — the call site is live,\n * the consequence is not.\n */\nexport function ssoDomainRefusal(\n identity: SsoDomainIdentity,\n env: Record<string, string | undefined> = process.env,\n): { decision: SsoDomainDecision; response: Response } | null {\n const decision = evaluateSsoDomainPolicy(identity, ssoRequiredDomains(env))\n if (!decision.refused) return null\n if (!ssoDomainEnforcementEnabled(env)) return null\n return {\n decision,\n response: Response.json(\n {\n error: SSO_DOMAIN_REFUSAL_MESSAGE,\n reason: 'sso-domain-required',\n domain: decision.domain,\n },\n { status: 403 },\n ),\n }\n}\n\nexport default evaluateSsoDomainPolicy\n"],"names":["PLATFORM_BRAND_NAME","SSO_REQUIRED_DOMAINS_ENV","parseSsoRequiredDomains","raw","map","pair","String","split","eq","indexOf","domain","slice","trim","toLowerCase","tenantId","ssoRequiredDomains","env","process","domainOf","email","address","at","lastIndexOf","length","evaluateSsoDomainPolicy","identity","domains","requiredTenantId","Object","prototype","hasOwnProperty","call","verdict","refused","reviewable","SSO_DOMAIN_ENFORCEMENT_ENV","ssoDomainEnforcementEnabled","SSO_DOMAIN_REFUSAL_MESSAGE","ssoDomainRefusal","decision","response","Response","json","error","reason","status"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sso-domain-policy.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 * Workspace-domain SSO policy (AGL-1993).\n *\n * **The rule is about a DOMAIN, not about staff.** An `@aglyn.com` address is a\n * Google Workspace identity, and Workspace is what enforces MFA, disables an\n * account at offboarding, and lets an admin revoke a session. An `@aglyn.com`\n * address that signs in through a project-pool password or a consumer Google\n * account has none of that: it looks like a company identity and is governed\n * like a personal one. That gap is the whole reason this module exists.\n *\n * ## Staff is a SEPARATE axis and this module never reads it\n *\n * Staff may be granted to anybody — SSO or not, `@aglyn.com` or not, project\n * pool or any GCIP tenant including a *customer's*. Nothing here consults the\n * `staff` claim, and `sso-domain-policy.spec.ts` pins that by evaluating every\n * staff permutation and asserting the verdict does not move. The two axes are\n * independent; conflating them would turn a narrow domain control into an\n * accidental SSO mandate for staff, which is explicitly not the policy.\n *\n * These all remain valid, and each has a test:\n *\n * | identity | staff | verdict |\n * | ------------------------------------------ | ----- | ------------------------- |\n * | `@aglyn.com` in the designated tenant | yes | allowed |\n * | personal address, project pool (break-glass)| yes | allowed — permanent design|\n * | personal address, project pool | no | allowed |\n * | SSO identity in a CUSTOMER org's tenant | yes | allowed |\n * | `@aglyn.com` in the PROJECT pool | any | **refused** when enforced |\n *\n * Only the last row is what the switch exists for.\n *\n * ## How this differs from `sso-enforcement.ts`\n *\n * {@link enforceSsoSignInMethods} (AGL-1128/1129) strips non-IdP providers from\n * accounts that are ALREADY INSIDE a tenant. It cannot see an identity that\n * never joined the tenant at all — a project-pool `@aglyn.com` account is\n * invisible to every tenant sweep, by construction. Measured on aglyn-main on\n * 2026-08-18: two such accounts existed, one of them holding a live\n * `google.com` provider. This module covers that outside-the-tenant half.\n *\n * ## Shape note: deliberately NOT a discriminated union\n *\n * `strictNullChecks` is off repo-wide, which defeats boolean-discriminant\n * narrowing — a spec can read the wrong arm of a union and pass silently. So\n * the decision is ONE flat interface with a string `verdict`, and specs assert\n * the literal. There is no arm to read by mistake.\n */\n\n/**\n * ## Self-hosting: this policy is CONFIGURATION, never a compiled-in constant\n *\n * Aglyn ships in two shapes — the Aglyn-operated SaaS, and self-hosted installs\n * on the operator's own Firebase project. `aglyn.com` is *our* Workspace\n * domain; a self-hoster has their own domain, their own IdP, or none at all.\n * Baking our domain in would make the rule dead weight for them at best and a\n * lockout at worst, so the governed set is read from configuration and is\n * **empty by default**.\n *\n * Empty means the rule does nothing. That is simultaneously the correct\n * behaviour for a fresh self-host install and the correct OFF state for us —\n * one default serving both, rather than a special case for either.\n *\n * Format — `domain=tenantId`, comma or whitespace separated:\n *\n * ```\n * AGLYN_SSO_REQUIRED_DOMAINS=\"aglyn.com=aglyn-org-y5v14\"\n * ```\n *\n * A map rather than a bare list because \"must use SSO\" is meaningless without\n * naming WHOSE SSO: the point is IdP lifecycle control, which only the tenant\n * wired to that domain's SAML provider delivers.\n */\nimport { PLATFORM_BRAND_NAME } from '@aglyn/aglyn/app-utils/platform-brand'\n\n/**\n * The block below runs in two places: here, at the session mint, and in the\n * `cloud/functions` account-creation blocking function, which refuses the\n * account before it exists. `cloud/functions` is a plain npm package that can\n * import only firebase-admin and firebase-functions, so it holds a verbatim\n * copy (`cloud/functions/src/sso-domain-policy.ts`), and\n * `apps/console/specs/sso-domain-creation-wiring.spec.ts` fails if the two\n * differ by a single character. Change it here, then copy the region across.\n */\n// #region sso-domain-policy\nexport const SSO_REQUIRED_DOMAINS_ENV = 'AGLYN_SSO_REQUIRED_DOMAINS'\n\nexport type SsoRequiredDomains = Readonly<Record<string, string>>\n\n/**\n * Parse the configured domain→tenant map. Total: anything unparseable is\n * dropped rather than throwing, because this sits on the authentication path\n * and a malformed env var must not take sign-in down. A dropped entry fails\n * OPEN (the domain is simply ungoverned), which is the safe direction for a\n * rule whose only power is to refuse.\n */\nexport function parseSsoRequiredDomains(\n raw: string | null | undefined,\n): SsoRequiredDomains {\n const map: Record<string, string> = {}\n for (const pair of String(raw ?? '').split(/[,\\s]+/)) {\n if (!pair) continue\n const eq = pair.indexOf('=')\n if (eq <= 0) continue\n const domain = pair.slice(0, eq).trim().toLowerCase()\n const tenantId = pair.slice(eq + 1).trim()\n if (!domain || !tenantId) continue\n map[domain] = tenantId\n }\n return map\n}\n\n/**\n * The operator's SSO-required domains. Empty unless configured — see above.\n */\nexport function ssoRequiredDomains(\n env: Record<string, string | undefined> = process.env,\n): SsoRequiredDomains {\n return parseSsoRequiredDomains(env[SSO_REQUIRED_DOMAINS_ENV])\n}\n\n/**\n * Addresses on a governed domain that may still sign in OUTSIDE its tenant.\n *\n * For accounts that cannot use the IdP at all because someone outside the\n * company has to sign in with them: the demo login an app store reviewer is\n * given, for example. Exact addresses only, never a pattern, so every\n * exception is named and can be read back from configuration:\n *\n * ```\n * AGLYN_SSO_EXEMPT_EMAILS=\"reviewer@example.com\"\n * ```\n *\n * Comma or whitespace separated, compared lower-cased. Empty by default.\n */\nexport const SSO_EXEMPT_EMAILS_ENV = 'AGLYN_SSO_EXEMPT_EMAILS'\n\nexport function parseSsoExemptEmails(\n raw: string | null | undefined,\n): ReadonlySet<string> {\n const emails = new Set<string>()\n for (const entry of String(raw ?? '').split(/[,\\s]+/)) {\n const email = entry.trim().toLowerCase()\n if (email.includes('@')) emails.add(email)\n }\n return emails\n}\n\nexport function ssoExemptEmails(\n env: Record<string, string | undefined> = process.env,\n): ReadonlySet<string> {\n return parseSsoExemptEmails(env[SSO_EXEMPT_EMAILS_ENV])\n}\n\nexport type SsoDomainVerdict =\n /** Domain is not governed. Any pool is fine — this is most of the world. */\n | 'allow-ungoverned'\n /** Governed domain, signed in through the tenant we designated for it. */\n | 'allow-designated-tenant'\n /**\n * Governed domain, signed in through SOME tenant, but not the designated\n * one. Allowed — they did use SSO, and refusing would invent a restriction\n * nobody asked for — but surfaced so a staff-access review can see it.\n */\n | 'allow-foreign-tenant'\n /**\n * Governed domain, no tenant, but the address is named in\n * {@link SSO_EXEMPT_EMAILS_ENV}. Allowed and reviewable.\n */\n | 'allow-exempt'\n /**\n * Governed domain with NO tenant at all: a project-pool password or consumer\n * Google sign-in. The only refusal this policy ever produces.\n */\n | 'refuse-sso-required'\n\nexport interface SsoDomainDecision {\n verdict: SsoDomainVerdict\n /**\n * Whether enforcement WOULD refuse this identity. Independent of whether\n * enforcement is currently switched on — {@link ssoDomainEnforcementEnabled}\n * decides that — so the same evaluation can drive both an audit in\n * report-only mode and the refusal once the switch is flipped.\n */\n refused: boolean\n /** Lower-cased domain, or null when the identity has no email address. */\n domain: string | null\n /** Tenant this domain is required to use, or null when ungoverned. */\n requiredTenantId: string | null\n /** Tenant the identity actually signed in through; null = project pool. */\n tenantId: string | null\n /** Worth a human look even though it is allowed (foreign-tenant case). */\n reviewable: boolean\n}\n\nexport interface SsoDomainIdentity {\n /** The identity's email address. Absent/empty is treated as ungoverned. */\n email?: string | null\n /**\n * GCIP tenant the identity signed in through — `decoded.firebase?.tenant`,\n * or a `PooledUserRecord.tenantId`. Null/undefined means the project pool.\n *\n * Never inferred and never defaulted to a hard-coded tenant: a uid is only\n * unique WITHIN a pool, and assuming one is how a lookup silently lands on\n * the wrong record.\n */\n tenantId?: string | null\n}\n\n/** The domain part of an address, lower-cased. Null when there isn't one. */\nexport function domainOf(email: string | null | undefined): string | null {\n const address = String(email ?? '').trim().toLowerCase()\n const at = address.lastIndexOf('@')\n if (at < 0 || at === address.length - 1) return null\n return address.slice(at + 1)\n}\n\n/**\n * Decide the policy for one identity. Pure and total — no Firebase, no env —\n * so a spec can drive every combination directly, and so the audit view and\n * the enforcement path can never disagree about what the policy says.\n */\nexport function evaluateSsoDomainPolicy(\n identity: SsoDomainIdentity,\n domains: SsoRequiredDomains = ssoRequiredDomains(),\n exempt: ReadonlySet<string> = ssoExemptEmails(),\n): SsoDomainDecision {\n const domain = domainOf(identity.email)\n const tenantId = identity.tenantId ?? null\n // Exact-match lookup, never a suffix test: `notaglyn.com` ends with\n // `aglyn.com`, and a suffix test would refuse an unrelated company's staff.\n const requiredTenantId = domain\n ? (Object.prototype.hasOwnProperty.call(domains, domain)\n ? domains[domain]\n : null)\n : null\n\n if (!requiredTenantId) {\n // Ungoverned: a personal address, a customer's address, anything not on\n // the list. Includes the permanent break-glass account by construction\n // rather than by special case — see the staff-access doc.\n return {\n verdict: 'allow-ungoverned',\n refused: false,\n domain,\n requiredTenantId: null,\n tenantId,\n reviewable: false,\n }\n }\n\n if (tenantId === requiredTenantId) {\n return {\n verdict: 'allow-designated-tenant',\n refused: false,\n domain,\n requiredTenantId,\n tenantId,\n reviewable: false,\n }\n }\n\n if (tenantId) {\n // A governed address inside somebody else's tenant. They authenticated\n // through an IdP, so the hole this policy closes is not open here — but\n // the IdP is not one Aglyn controls, so it is flagged for review.\n return {\n verdict: 'allow-foreign-tenant',\n refused: false,\n domain,\n requiredTenantId,\n tenantId,\n reviewable: true,\n }\n }\n\n if (exempt.has(String(identity.email ?? '').trim().toLowerCase())) {\n return {\n verdict: 'allow-exempt',\n refused: false,\n domain,\n requiredTenantId,\n tenantId: null,\n reviewable: true,\n }\n }\n\n return {\n verdict: 'refuse-sso-required',\n refused: true,\n domain,\n requiredTenantId,\n tenantId: null,\n reviewable: true,\n }\n}\n\n/**\n * THE ENFORCEMENT SWITCH — ships OFF.\n *\n * Off means `evaluateSsoDomainPolicy` still computes `refused`, and callers\n * still record it, but nobody is turned away. That is deliberate: the audit\n * trail accumulates under the real rule before the rule bites, so flipping the\n * switch holds no surprises.\n *\n * Opt-in by exact string. Anything else — unset, empty, `false`, `0`, a typo —\n * is OFF. A security control that switches itself on through a misspelling is\n * a control that can lock people out through a misspelling.\n *\n * Note both halves must be set for anything to happen: this switch AND a\n * non-empty {@link SSO_REQUIRED_DOMAINS_ENV}. A self-host install that sets\n * only this switch still governs nothing.\n *\n * **Precondition for flipping it — written down so it is not re-litigated:**\n * 1. SSO staff access verified working through a REAL sign-in (not a token\n * inspection): a staff account reaches `/admin/*` via the SAML tenant.\n * 2. Every identity on a governed domain has been migrated into its tenant,\n * retired, or named in {@link SSO_EXEMPT_EMAILS_ENV}. An automation or\n * reviewer account is the one that bites without warning: nobody is\n * watching a sign-in page when it fails.\n *\n * A second staff identity is NOT a precondition: the permanent break-glass\n * account is on an ungoverned domain, so this rule can never refuse it.\n */\nexport const SSO_DOMAIN_ENFORCEMENT_ENV = 'AGLYN_SSO_DOMAIN_ENFORCEMENT'\n\nexport function ssoDomainEnforcementEnabled(\n env: Record<string, string | undefined> = process.env,\n): boolean {\n return env[SSO_DOMAIN_ENFORCEMENT_ENV] === 'on'\n}\n\n/**\n * Whether an account about to be CREATED must be refused. Same switch, same\n * domains, same exemptions as the session mint, so an address the mint would\n * turn away is never created in the first place, and an address it admits\n * is never refused here.\n */\nexport function ssoDomainCreationRefused(\n identity: SsoDomainIdentity,\n env: Record<string, string | undefined> = process.env,\n): boolean {\n if (!ssoDomainEnforcementEnabled(env)) return false\n return evaluateSsoDomainPolicy(\n identity,\n ssoRequiredDomains(env),\n ssoExemptEmails(env),\n ).refused\n}\n// #endregion sso-domain-policy\n\n/**\n * What the refused person is told. Names the fix, not the mechanism.\n *\n * `PLATFORM_BRAND_NAME`, not a literal (AGL-2214). Everything else in this\n * module is careful that the RULE is configuration rather than a compiled-in\n * assumption about Aglyn — and then the one string a refused person actually\n * reads named us anyway. A self-hoster who governs their own domain would\n * have turned their own staff away in our name.\n *\n * Byte-identical for a deployment that has not renamed itself.\n */\nexport const SSO_DOMAIN_REFUSAL_MESSAGE =\n `Sign in with your ${PLATFORM_BRAND_NAME} Workspace account ` +\n '(Single sign-on). Accounts on this domain cannot use a password or ' +\n 'personal Google sign-in.'\n\n/**\n * The refusal a caller should return, or null to carry on.\n *\n * Returns null whenever the switch is off, so wiring this into an\n * authentication path is inert until the account owner flips it — the call site is live,\n * the consequence is not.\n */\nexport function ssoDomainRefusal(\n identity: SsoDomainIdentity,\n env: Record<string, string | undefined> = process.env,\n): { decision: SsoDomainDecision; response: Response } | null {\n const decision = evaluateSsoDomainPolicy(\n identity,\n ssoRequiredDomains(env),\n ssoExemptEmails(env),\n )\n if (!decision.refused) return null\n if (!ssoDomainEnforcementEnabled(env)) return null\n return {\n decision,\n response: Response.json(\n {\n error: SSO_DOMAIN_REFUSAL_MESSAGE,\n reason: 'sso-domain-required',\n domain: decision.domain,\n },\n { status: 403 },\n ),\n }\n}\n\nexport default evaluateSsoDomainPolicy\n"],"names":["PLATFORM_BRAND_NAME","SSO_REQUIRED_DOMAINS_ENV","parseSsoRequiredDomains","raw","map","pair","String","split","eq","indexOf","domain","slice","trim","toLowerCase","tenantId","ssoRequiredDomains","env","process","SSO_EXEMPT_EMAILS_ENV","parseSsoExemptEmails","emails","Set","entry","email","includes","add","ssoExemptEmails","domainOf","address","at","lastIndexOf","length","evaluateSsoDomainPolicy","identity","domains","exempt","requiredTenantId","Object","prototype","hasOwnProperty","call","verdict","refused","reviewable","has","SSO_DOMAIN_ENFORCEMENT_ENV","ssoDomainEnforcementEnabled","ssoDomainCreationRefused","SSO_DOMAIN_REFUSAL_MESSAGE","ssoDomainRefusal","decision","response","Response","json","error","reason","status"],"mappings":"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"}
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
import type { AglynOrgMember } from '@aglyn/aglyn/foundation/definitions/organization.types';
|
|
18
|
+
import { type ReleaseFlagKey } from '@aglyn/aglyn/app-utils/release-flags';
|
|
19
|
+
import { type WeeklySummaryColors, type WeeklySummaryEmail } from '@aglyn/aglyn/app-utils/weekly-summary';
|
|
20
|
+
import { type ResolvedPluginFigureReader } from '@aglyn/aglyn/plugin-manager/plugin-figures';
|
|
21
|
+
import { type FigureReaderContext } from './figure-readers';
|
|
22
|
+
/**
|
|
23
|
+
* THE WEEKLY SUMMARY'S SENDS (AGL-3867): `POST /api/admin/weekly-summary`,
|
|
24
|
+
* Mondays at 13:00 UTC (08:00 Central) from `scheduled-crons.yml`.
|
|
25
|
+
*
|
|
26
|
+
* ## What is sent, and to whom
|
|
27
|
+
*
|
|
28
|
+
* One email for each of a workspace's sites that had something to report,
|
|
29
|
+
* and a workspace overview that adds them up: totals across the sites, a line
|
|
30
|
+
* for each, and the workspace's own figures, such as its deals.
|
|
31
|
+
*
|
|
32
|
+
* - The workspace's owners and admins get the overview and every site's
|
|
33
|
+
* email. In a workspace where only one site had anything to report, the
|
|
34
|
+
* overview would repeat it, so that site's email carries the workspace's
|
|
35
|
+
* own figures instead and no overview is sent.
|
|
36
|
+
* - A member who is an admin of a site, without being one of the
|
|
37
|
+
* workspace's, gets that site's email: they run it.
|
|
38
|
+
*
|
|
39
|
+
* The **Weekly summary** switch on each person's account
|
|
40
|
+
* (`users/{uid}.digestPrefs.weeklySummary`, on until turned off) governs all
|
|
41
|
+
* of it. A locked or disabled account, a suppressed address and a suspended
|
|
42
|
+
* member are not mailed.
|
|
43
|
+
*
|
|
44
|
+
* ## What it reads
|
|
45
|
+
*
|
|
46
|
+
* The workspace's busiest `WEEKLY_SUMMARY_MAX_SITES` sites by last week's
|
|
47
|
+
* page views, each through every site reader that declares a
|
|
48
|
+
* `weeklySummary`, and the workspace through every workspace reader that
|
|
49
|
+
* does. Each reader is offered only where its plan feature is included and
|
|
50
|
+
* its plugin runs (`eligibleFigureReaders`), read over the seven whole days
|
|
51
|
+
* before the run, and summarized by its owner. A reader that fails is left
|
|
52
|
+
* out; the rest are still read. Every figure is read once per workspace, and
|
|
53
|
+
* every recipient is sent the same figures.
|
|
54
|
+
*
|
|
55
|
+
* ## Once a week, and resumable
|
|
56
|
+
*
|
|
57
|
+
* `orgs/{orgId}/weeklySummaries/{week}.sent.{uid}.{email}` records each
|
|
58
|
+
* email sent — `overview`, or a site's id — stamped after each send, so a run
|
|
59
|
+
* that dies mid-workspace sends only what never went, and a second run in the
|
|
60
|
+
* same week sends nothing. Workspaces are swept in id order behind a cursor,
|
|
61
|
+
* the `done: false` loop the workflow already runs for the CRM digest.
|
|
62
|
+
*
|
|
63
|
+
* The email is a `bulk` send under the platform send-rate governor: a refusal
|
|
64
|
+
* stops the run and answers `done: true`, and a manual re-run in the same week
|
|
65
|
+
* picks up the rest. Metered as PLATFORM mail, not the workspace's: it is the
|
|
66
|
+
* platform reporting to the people who run it.
|
|
67
|
+
*/
|
|
68
|
+
type Firestore = FirebaseFirestore.Firestore;
|
|
69
|
+
/** The beat `/api/health/crons` reads, once this job joins its inventory. */
|
|
70
|
+
export declare const WEEKLY_SUMMARY_JOB_ID = "weekly-summary";
|
|
71
|
+
/** `orgs/{orgId}/weeklySummaries/{week}` — server-written, matched by no rule. */
|
|
72
|
+
export declare const WEEKLY_SUMMARY_MARKERS = "weeklySummaries";
|
|
73
|
+
/** The marker key of the overview; a site's email is keyed by the site's id. */
|
|
74
|
+
export declare const WEEKLY_SUMMARY_OVERVIEW = "overview";
|
|
75
|
+
/** The system emails: the overview, and one site's. */
|
|
76
|
+
export declare const WEEKLY_SUMMARY_TEMPLATE = "weekly-summary";
|
|
77
|
+
export declare const WEEKLY_SUMMARY_SITE_TEMPLATE = "weekly-summary-site";
|
|
78
|
+
/** Workspaces per invocation; a chunk that times out never advances its cursor. */
|
|
79
|
+
export declare const WEEKLY_SUMMARY_ORG_CHUNK = 20;
|
|
80
|
+
/** Sites a workspace's sweep ranks, by document id. */
|
|
81
|
+
export declare const WEEKLY_SUMMARY_HOST_READ_LIMIT = 50;
|
|
82
|
+
/** Sites a week's summary reports on: the busiest by last week's page views. */
|
|
83
|
+
export declare const WEEKLY_SUMMARY_MAX_SITES = 5;
|
|
84
|
+
/** Members per workspace whose address is chased through the auth pools when their member document has none. */
|
|
85
|
+
export declare const WEEKLY_SUMMARY_MAX_UID_LOOKUPS = 5;
|
|
86
|
+
/** How long one call works before it stops and leaves the rest to the next. */
|
|
87
|
+
export declare const WEEKLY_SUMMARY_RUN_BUDGET_MS = 45000;
|
|
88
|
+
export interface WeeklySummarySendResult {
|
|
89
|
+
sent: boolean;
|
|
90
|
+
/** The platform's send rate refused it for now. */
|
|
91
|
+
rateLimited: boolean;
|
|
92
|
+
reason?: string;
|
|
93
|
+
}
|
|
94
|
+
export interface WeeklySummaryMessage {
|
|
95
|
+
to: string;
|
|
96
|
+
template: typeof WEEKLY_SUMMARY_TEMPLATE | typeof WEEKLY_SUMMARY_SITE_TEMPLATE;
|
|
97
|
+
email: WeeklySummaryEmail;
|
|
98
|
+
org: Record<string, unknown>;
|
|
99
|
+
merge: Record<string, string>;
|
|
100
|
+
}
|
|
101
|
+
/** Everything the sweep reaches beyond Firestore, so a spec can hold each one. */
|
|
102
|
+
export interface WeeklySummaryDeps {
|
|
103
|
+
firestore: Firestore;
|
|
104
|
+
runAt: Date;
|
|
105
|
+
dryRun: boolean;
|
|
106
|
+
/** The console's absolute origin, or `''` when none is configured. */
|
|
107
|
+
origin: string;
|
|
108
|
+
/** Whether a release flag is on for an org. */
|
|
109
|
+
flagOn: (flag: ReleaseFlagKey, orgId: string, org: Record<string, unknown>) => boolean;
|
|
110
|
+
orgMembers: (orgId: string) => Promise<AglynOrgMember[]>;
|
|
111
|
+
/** The uids whose accounts may be mailed. */
|
|
112
|
+
reachable: (uids: string[]) => Promise<string[]>;
|
|
113
|
+
/** The readers that declare a `weeklySummary` and pass the gates here. */
|
|
114
|
+
readers: (context: Omit<FigureReaderContext, 'accept'>) => Promise<ResolvedPluginFigureReader[]>;
|
|
115
|
+
/** The org's email colors, from its theme. */
|
|
116
|
+
colors: (org: Record<string, unknown>) => WeeklySummaryColors;
|
|
117
|
+
/** The member's deliverable address, or `null` when there is none or it is suppressed. */
|
|
118
|
+
address: (member: AglynOrgMember, lookups: {
|
|
119
|
+
left: number;
|
|
120
|
+
}) => Promise<string | null>;
|
|
121
|
+
send: (message: WeeklySummaryMessage) => Promise<WeeklySummarySendResult>;
|
|
122
|
+
budgetMs?: number;
|
|
123
|
+
}
|
|
124
|
+
export interface WeeklySummaryOrgReport {
|
|
125
|
+
skipped?: string;
|
|
126
|
+
sites: number;
|
|
127
|
+
recipients: number;
|
|
128
|
+
sent: number;
|
|
129
|
+
failed?: number;
|
|
130
|
+
/** Why the workspace stopped short: the call's time ran out, or the send rate refused. */
|
|
131
|
+
deferred?: 'time' | 'rate';
|
|
132
|
+
}
|
|
133
|
+
export interface WeeklySummaryReport {
|
|
134
|
+
week: string;
|
|
135
|
+
dryRun: boolean;
|
|
136
|
+
swept: number;
|
|
137
|
+
sent: number;
|
|
138
|
+
orgs: Record<string, WeeklySummaryOrgReport>;
|
|
139
|
+
deferred: false | 'time' | 'rate';
|
|
140
|
+
nextCursor: string | null;
|
|
141
|
+
done: boolean;
|
|
142
|
+
}
|
|
143
|
+
/** One chunk of the sweep: workspaces in id order after `cursor`. */
|
|
144
|
+
export declare function runWeeklySummarySweep(deps: WeeklySummaryDeps, options?: {
|
|
145
|
+
cursor?: string | null;
|
|
146
|
+
limit?: number;
|
|
147
|
+
}): Promise<WeeklySummaryReport>;
|
|
148
|
+
/** The sweep's reach into the real platform: Firestore, the flags, the roster, the mail. */
|
|
149
|
+
/** The sweep's reach into the real platform: Firestore, the flags, the roster, the mail. */
|
|
150
|
+
export declare function weeklySummaryDeps(options: {
|
|
151
|
+
runAt: Date;
|
|
152
|
+
dryRun: boolean;
|
|
153
|
+
}): Promise<WeeklySummaryDeps>;
|
|
154
|
+
/**
|
|
155
|
+
* `POST /api/admin/weekly-summary` sends; a GET, or `{"dryRun": true}`,
|
|
156
|
+
* reports who would be sent what and sends nothing. The body may carry the
|
|
157
|
+
* `cursor` a previous chunk answered and a smaller `limit`.
|
|
158
|
+
*/
|
|
159
|
+
export declare function weeklySummaryRoute(request: Request): Promise<Response>;
|
|
160
|
+
export {};
|