@aglyn/aglyn 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 +11 -11
- package/src/lib/app-utils/account-acquisition.d.ts +15 -2
- package/src/lib/app-utils/account-acquisition.js +20 -1
- package/src/lib/app-utils/account-acquisition.js.map +1 -1
- package/src/lib/app-utils/admin-audit-index.d.ts +3 -1
- package/src/lib/app-utils/admin-audit-index.js +9 -1
- package/src/lib/app-utils/admin-audit-index.js.map +1 -1
- package/src/lib/app-utils/analytics-summary.d.ts +95 -0
- package/src/lib/app-utils/analytics-summary.js +113 -0
- package/src/lib/app-utils/analytics-summary.js.map +1 -0
- package/src/lib/app-utils/artifact-list-keys.js +12 -0
- package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
- package/src/lib/app-utils/artifact-list-queries.d.ts +51 -0
- package/src/lib/app-utils/artifact-list-queries.js +321 -0
- package/src/lib/app-utils/artifact-list-queries.js.map +1 -0
- package/src/lib/app-utils/business-profile.d.ts +171 -0
- package/src/lib/app-utils/business-profile.js +277 -0
- package/src/lib/app-utils/business-profile.js.map +1 -0
- package/src/lib/app-utils/child-contract.d.ts +4 -1
- package/src/lib/app-utils/child-contract.js +5 -2
- package/src/lib/app-utils/child-contract.js.map +1 -1
- package/src/lib/app-utils/console-routes.d.ts +5 -0
- package/src/lib/app-utils/console-routes.js +1 -0
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/content-schema-type.d.ts +9 -0
- package/src/lib/app-utils/content-schema-type.js +4 -0
- package/src/lib/app-utils/content-schema-type.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +30 -2
- package/src/lib/app-utils/crm.js +77 -15
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +58 -10
- package/src/lib/app-utils/docs-help.generated.js +118 -4
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +320 -19
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/entry-list-declaration.d.ts +33 -0
- package/src/lib/app-utils/entry-list-declaration.js +172 -0
- package/src/lib/app-utils/entry-list-declaration.js.map +1 -0
- package/src/lib/app-utils/first-version-seed.d.ts +19 -0
- package/src/lib/app-utils/first-version-seed.js +53 -0
- package/src/lib/app-utils/first-version-seed.js.map +1 -0
- package/src/lib/app-utils/local-business.d.ts +8 -0
- package/src/lib/app-utils/local-business.js +4 -0
- package/src/lib/app-utils/local-business.js.map +1 -1
- package/src/lib/app-utils/lockdown.js +1 -1
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/message-search.js +5 -2
- package/src/lib/app-utils/message-search.js.map +1 -1
- package/src/lib/app-utils/name-search.js +15 -3
- package/src/lib/app-utils/name-search.js.map +1 -1
- package/src/lib/app-utils/page-markdown.js +3 -0
- package/src/lib/app-utils/page-markdown.js.map +1 -1
- package/src/lib/app-utils/screen-analytics-aggregate.d.ts +70 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js +82 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js.map +1 -0
- package/src/lib/app-utils/screen-kind.d.ts +25 -0
- package/src/lib/app-utils/screen-kind.js +25 -0
- package/src/lib/app-utils/screen-kind.js.map +1 -0
- package/src/lib/app-utils/screen-route.d.ts +1 -5
- package/src/lib/app-utils/screen-route.js +2 -4
- package/src/lib/app-utils/screen-route.js.map +1 -1
- package/src/lib/app-utils/site-journey-steps.d.ts +38 -0
- package/src/lib/app-utils/site-journey-steps.js +54 -0
- package/src/lib/app-utils/site-journey-steps.js.map +1 -0
- package/src/lib/app-utils/site-journey.d.ts +2 -22
- package/src/lib/app-utils/site-journey.js +2 -32
- package/src/lib/app-utils/site-journey.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +19 -0
- package/src/lib/plugin-manager/feature-plugins.js +8 -0
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +124 -2
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-channel-orders.d.ts +262 -0
- package/src/lib/plugin-manager/plugin-channel-orders.js +32 -0
- package/src/lib/plugin-manager/plugin-channel-orders.js.map +1 -0
- package/src/lib/plugin-manager/plugin-checkout-credits.d.ts +288 -0
- package/src/lib/plugin-manager/plugin-checkout-credits.js +196 -0
- package/src/lib/plugin-manager/plugin-checkout-credits.js.map +1 -0
- package/src/lib/plugin-manager/plugin-product-catalog.d.ts +5 -0
- package/src/lib/plugin-manager/plugin-product-catalog.js.map +1 -1
- package/src/lib/plugin-manager/plugin-product-writer.d.ts +166 -0
- package/src/lib/plugin-manager/plugin-product-writer.js +31 -0
- package/src/lib/plugin-manager/plugin-product-writer.js.map +1 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.d.ts +10 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.js.map +1 -1
- package/src/lib/plugin-manager/plugin-theme-presets.d.ts +40 -0
- package/src/lib/plugin-manager/plugin-theme-presets.js +44 -0
- package/src/lib/plugin-manager/plugin-theme-presets.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/aglyn",
|
|
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": {
|
|
@@ -37,16 +37,16 @@
|
|
|
37
37
|
"./package.json": "./package.json"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@aglyn/shared-data-enums": "1.0.0-beta.
|
|
41
|
-
"@aglyn/shared-data-mdi": "1.0.0-beta.
|
|
42
|
-
"@aglyn/shared-data-types": "1.0.0-beta.
|
|
43
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
44
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
45
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
46
|
-
"@aglyn/shared-util-logger": "1.0.0-beta.
|
|
47
|
-
"@aglyn/shared-util-timestamp": "1.0.0-beta.
|
|
48
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
49
|
-
"@aglyn/shared-util-vendor": "1.0.0-beta.
|
|
40
|
+
"@aglyn/shared-data-enums": "1.0.0-beta.233",
|
|
41
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.233",
|
|
42
|
+
"@aglyn/shared-data-types": "1.0.0-beta.233",
|
|
43
|
+
"@aglyn/shared-util-email": "1.0.0-beta.233",
|
|
44
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.233",
|
|
45
|
+
"@aglyn/shared-util-http": "1.0.0-beta.233",
|
|
46
|
+
"@aglyn/shared-util-logger": "1.0.0-beta.233",
|
|
47
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.233",
|
|
48
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.233",
|
|
49
|
+
"@aglyn/shared-util-vendor": "1.0.0-beta.233",
|
|
50
50
|
"@data-driven-forms/react-form-renderer": "^4.2.0",
|
|
51
51
|
"@msgpack/msgpack": "^3.1.3",
|
|
52
52
|
"@types/unist": "^3.0.3",
|
|
@@ -47,10 +47,23 @@ import { type AcquisitionChannel, type FirstTouchClickId, type FirstTouchUtm } f
|
|
|
47
47
|
export declare const ACCOUNT_ACQUISITION_FIELD = "acquisition";
|
|
48
48
|
/**
|
|
49
49
|
* How an account was created: the signup form with a password, the signup
|
|
50
|
-
* form through Google
|
|
50
|
+
* form through Google — in a popup, or by the full-page redirect phones use —
|
|
51
|
+
* accepting an invitation, or single sign-on.
|
|
51
52
|
*/
|
|
52
|
-
export declare const ACQUISITION_DOORS: readonly ["signup-password", "signup-google", "invite", "sso"];
|
|
53
|
+
export declare const ACQUISITION_DOORS: readonly ["signup-password", "signup-google", "signup-google-redirect", "invite", "sso"];
|
|
53
54
|
export type AcquisitionDoor = (typeof ACQUISITION_DOORS)[number] | 'unknown';
|
|
55
|
+
/** The sign-up form's doors: the ones a person reaches by signing up themselves. */
|
|
56
|
+
export type SignUpAcquisitionDoor = Extract<AcquisitionDoor, 'signup-password' | 'signup-google' | 'signup-google-redirect'>;
|
|
57
|
+
/**
|
|
58
|
+
* The sign-up door an account took, from the provider its VERIFIED token
|
|
59
|
+
* names, or null when that provider is not one the sign-up form offers.
|
|
60
|
+
*
|
|
61
|
+
* The provider decides; the page's `hint` only says which of a provider's
|
|
62
|
+
* doors it was, so a Google account can say "redirect" and nothing else — it
|
|
63
|
+
* can never claim the password door, and a password account can never claim
|
|
64
|
+
* a Google one.
|
|
65
|
+
*/
|
|
66
|
+
export declare function signUpDoorFor(provider: string | null | undefined, hint?: unknown): SignUpAcquisitionDoor | null;
|
|
54
67
|
/** What wrote a record: a door at creation, an organization copying its creator's, or the backfill. */
|
|
55
68
|
export type AcquisitionRecordedBy = 'signup' | 'sso' | 'org-creation' | 'backfill';
|
|
56
69
|
/** Where the account-creating request came from, as the edge reported it. */
|
|
@@ -45,13 +45,30 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
45
45
|
*/ /** The field on `users/{uid}` and `orgs/{orgId}`. */ export const ACCOUNT_ACQUISITION_FIELD = 'acquisition';
|
|
46
46
|
/**
|
|
47
47
|
* How an account was created: the signup form with a password, the signup
|
|
48
|
-
* form through Google
|
|
48
|
+
* form through Google — in a popup, or by the full-page redirect phones use —
|
|
49
|
+
* accepting an invitation, or single sign-on.
|
|
49
50
|
*/ export const ACQUISITION_DOORS = [
|
|
50
51
|
'signup-password',
|
|
51
52
|
'signup-google',
|
|
53
|
+
'signup-google-redirect',
|
|
52
54
|
'invite',
|
|
53
55
|
'sso'
|
|
54
56
|
];
|
|
57
|
+
/**
|
|
58
|
+
* The sign-up door an account took, from the provider its VERIFIED token
|
|
59
|
+
* names, or null when that provider is not one the sign-up form offers.
|
|
60
|
+
*
|
|
61
|
+
* The provider decides; the page's `hint` only says which of a provider's
|
|
62
|
+
* doors it was, so a Google account can say "redirect" and nothing else — it
|
|
63
|
+
* can never claim the password door, and a password account can never claim
|
|
64
|
+
* a Google one.
|
|
65
|
+
*/ export function signUpDoorFor(provider, hint) {
|
|
66
|
+
if (provider === 'password') return 'signup-password';
|
|
67
|
+
if (provider === 'google.com') {
|
|
68
|
+
return hint === 'signup-google-redirect' ? 'signup-google-redirect' : 'signup-google';
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
55
72
|
const MAX_LABEL = 100;
|
|
56
73
|
const PROVIDER_SHAPE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
57
74
|
const RECORDED_BY = [
|
|
@@ -278,6 +295,8 @@ function doorPhrase(record) {
|
|
|
278
295
|
return 'signed up with password';
|
|
279
296
|
case 'signup-google':
|
|
280
297
|
return 'signed up with Google';
|
|
298
|
+
case 'signup-google-redirect':
|
|
299
|
+
return 'signed up with Google, by redirect';
|
|
281
300
|
case 'invite':
|
|
282
301
|
return provider ? `signed up from an invitation, with ${provider}` : 'signed up from an invitation';
|
|
283
302
|
case 'sso':
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/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 classifyChannel,\n displayHost,\n sanitizeFirstTouch,\n sourceAndMedium,\n type AcquisitionChannel,\n type FirstTouchClickId,\n type FirstTouchUtm,\n} from '@aglyn/shared-util-first-touch'\n\n/**\n * Where an account came from, written ONCE on the account when it is created\n * (AGL-3289) — `users/{uid}.acquisition`, and on the organization created in\n * the same flow as `orgs/{orgId}.acquisition`.\n *\n * ## Why this exists\n *\n * Answering \"where did this person come from?\" for one new workspace took a\n * sales session twenty-five minutes across five tools: the staff user page\n * had no source, Firestore held nothing beyond names, and the answer — a\n * review site's referral — was finally found in an analytics property and in\n * the marketing host's daily referrer counts. The platform never stored the\n * fact on the account it was about. This record is that fact, read from one\n * card on the staff user and organization pages.\n *\n * ## What writes it, and what never may\n *\n * The capture library keeps the first touch on the visitor's device; the\n * platform turns it into this record at account creation, server-side, and\n * the rules refuse the field to every client — owner and staff alike. A\n * record the account holder could restate would be a record nobody could\n * trust. It is never overwritten: a later touch, if one is ever kept, goes\n * BESIDE it as its own field.\n *\n * Every field here is built from the untrusted touch by re-scrubbing it, the\n * same way the capture scrubbed it on the device, and nothing the record says\n * grants anything: it is a label for a staff reader.\n */\n\n/** The field on `users/{uid}` and `orgs/{orgId}`. */\nexport const ACCOUNT_ACQUISITION_FIELD = 'acquisition'\n\n/**\n * How an account was created: the signup form with a password, the signup\n * form through Google, accepting an invitation, or single sign-on.\n */\nexport const ACQUISITION_DOORS = [\n 'signup-password',\n 'signup-google',\n 'invite',\n 'sso',\n] as const\nexport type AcquisitionDoor = (typeof ACQUISITION_DOORS)[number] | 'unknown'\n\n/** What wrote a record: a door at creation, an organization copying its creator's, or the backfill. */\nexport type AcquisitionRecordedBy = 'signup' | 'sso' | 'org-creation' | 'backfill'\n\n/** Where the account-creating request came from, as the edge reported it. */\nexport interface AcquisitionGeo {\n country: string | null\n region: string | null\n city: string | null\n}\n\n/** The stored record. */\nexport interface AccountAcquisition {\n v: 1\n /**\n * Where the visit came from, spelled the way reports spell it — a\n * `utm_source`, a referring host, `(direct)` — or `unknown` when nothing was\n * captured.\n */\n source: string\n /** `utm_medium`, or what the channel implies; null when unknown. */\n medium: string | null\n /** `utm_campaign`, when the landing URL named one. */\n campaign: string | null\n /** The channel, from the platform's own rule table. */\n channel: AcquisitionChannel\n /** When the first visit landed; null when it was never captured. */\n capturedAt: number | null\n /** The first page the visitor landed on. */\n landing: { host: string; path: string } | null\n /** The external host that sent them. */\n referrerHost: string | null\n /** One of the platform's own hosts they came from before anything captured them. */\n viaHost: string | null\n /** Every `utm_*` the landing URL carried. */\n utm: FirstTouchUtm | null\n /** Which ad click ids the landing URL carried — presence only. */\n clickIds: FirstTouchClickId[]\n /**\n * The Google Ads campaign the click came from (`gad_campaignid`), which\n * auto-tagging appends whether or not the ad carries a `utm_campaign`.\n */\n adCampaignId: string | null\n /** How the account was created. */\n door: AcquisitionDoor\n /** The sign-in provider it was created with: `password`, `google.com`, `saml.…`. */\n provider: string | null\n /** The organization whose invitation the person signed up to accept. */\n invitedToOrgId: string | null\n /** Where the account-creating request came from. */\n geo: AcquisitionGeo | null\n /** When the auth record says the account was created. */\n accountCreatedAt: number | null\n /** When this record was written. */\n recordedAt: number\n recordedBy: AcquisitionRecordedBy\n /** On an organization's copy: the account it was copied from. */\n copiedFromUid?: string\n}\n\nconst MAX_LABEL = 100\nconst PROVIDER_SHAPE = /^[a-z0-9][a-z0-9._-]{0,63}$/i\nconst RECORDED_BY: readonly AcquisitionRecordedBy[] = [\n 'signup',\n 'sso',\n 'org-creation',\n 'backfill',\n]\n\nfunction label(value: unknown, max = MAX_LABEL): string | null {\n if (typeof value !== 'string') return null\n const clean = value.replace(/[\\u0000-\\u001f\\u007f]/g, '').trim() // eslint-disable-line no-control-regex\n return clean ? clean.slice(0, max) : null\n}\n\nfunction doorOf(value: unknown): AcquisitionDoor {\n return (ACQUISITION_DOORS as readonly string[]).includes(value as string)\n ? (value as AcquisitionDoor)\n : 'unknown'\n}\n\nfunction providerOf(value: unknown): string | null {\n return typeof value === 'string' && PROVIDER_SHAPE.test(value.trim())\n ? value.trim().toLowerCase()\n : null\n}\n\nfunction geoOf(value: unknown): AcquisitionGeo | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const geo: AcquisitionGeo = {\n country: label(raw['country'], 8)?.toUpperCase() ?? null,\n region: label(raw['region'], 64),\n city: label(raw['city'], 64),\n }\n return geo.country || geo.region || geo.city ? geo : null\n}\n\nfunction millisOf(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null\n}\n\nfunction uidOf(value: unknown): string | null {\n return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : null\n}\n\n/** What a door knows when it records an account. */\nexport interface AccountAcquisitionInput {\n /** The first touch the capture kept — untrusted, from the device. */\n touch: unknown\n door: unknown\n provider?: unknown\n geo?: unknown\n invitedToOrgId?: unknown\n /** When the auth record says the account was created. */\n accountCreatedAtMs?: number | null\n recordedBy: AcquisitionRecordedBy\n nowMs: number\n}\n\n/**\n * The record for an account being created now.\n *\n * A missing or unusable touch still produces a record — `source: 'unknown'`\n * with whatever the door knows — because \"we asked and nothing came back\" is\n * a fact about the account too, and a card that renders blank would read as\n * \"never asked\".\n */\nexport function buildAccountAcquisition(input: AccountAcquisitionInput): AccountAcquisition {\n const touch = sanitizeFirstTouch(input.touch, input.nowMs)\n const base = {\n door: doorOf(input.door),\n provider: providerOf(input.provider),\n invitedToOrgId: uidOf(input.invitedToOrgId),\n geo: geoOf(input.geo),\n accountCreatedAt: millisOf(input.accountCreatedAtMs),\n recordedAt: input.nowMs,\n recordedBy: input.recordedBy,\n }\n if (!touch) return unknownAccountAcquisition(base)\n const { source, medium } = sourceAndMedium(touch)\n return {\n v: 1,\n source,\n medium,\n campaign: touch.utm?.campaign ?? null,\n channel: classifyChannel(touch),\n capturedAt: touch.at,\n landing: { host: touch.host, path: touch.path },\n referrerHost: touch.ref,\n viaHost: touch.via ?? null,\n utm: touch.utm ?? null,\n clickIds: touch.click ?? [],\n adCampaignId: touch.adCampaign ?? null,\n ...base,\n }\n}\n\n/** A record for an account nothing was captured for. */\nexport function unknownAccountAcquisition(input: {\n recordedAt: number\n recordedBy: AcquisitionRecordedBy\n door?: AcquisitionDoor\n provider?: string | null\n invitedToOrgId?: string | null\n geo?: AcquisitionGeo | null\n accountCreatedAt?: number | null\n}): AccountAcquisition {\n return {\n v: 1,\n source: 'unknown',\n medium: null,\n campaign: null,\n channel: 'unknown',\n capturedAt: null,\n landing: null,\n referrerHost: null,\n viaHost: null,\n utm: null,\n clickIds: [],\n adCampaignId: null,\n door: input.door ?? 'unknown',\n provider: input.provider ?? null,\n invitedToOrgId: input.invitedToOrgId ?? null,\n geo: input.geo ?? null,\n accountCreatedAt: input.accountCreatedAt ?? null,\n recordedAt: input.recordedAt,\n recordedBy: input.recordedBy,\n }\n}\n\n/**\n * The organization's copy of its creator's record, or an `unknown` one naming\n * the creator when the account has none — so an organization's card is never\n * blank either, and always says whose record it is.\n */\nexport function organizationAcquisition(\n creatorRecord: unknown,\n creatorUid: string,\n nowMs: number,\n): AccountAcquisition {\n const record = readAccountAcquisition(creatorRecord)\n const copied = record\n ? { ...record }\n : unknownAccountAcquisition({ recordedAt: nowMs, recordedBy: 'org-creation' })\n delete copied.copiedFromUid\n return { ...copied, copiedFromUid: creatorUid }\n}\n\n/**\n * A stored record as a reader should trust it: re-validated field by field,\n * so a document written by an older version, a backfill or a hand edit still\n * renders, and renders nothing it should not. Null when the value is not a\n * record at all.\n */\nexport function readAccountAcquisition(value: unknown): AccountAcquisition | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const source = label(raw['source'])\n if (!source) return null\n const channel = raw['channel']\n const landing = raw['landing'] as Record<string, unknown> | null\n const utmRaw = raw['utm'] as Record<string, unknown> | null\n const utm: FirstTouchUtm = {}\n for (const key of ['source', 'medium', 'campaign', 'content', 'term'] as const) {\n const cleaned = label(utmRaw?.[key])\n if (cleaned) utm[key] = cleaned\n }\n const clickIds = Array.isArray(raw['clickIds'])\n ? (['gclid', 'fbclid', 'msclkid'] as const).filter((id) =>\n (raw['clickIds'] as unknown[]).includes(id),\n )\n : []\n const recordedBy = RECORDED_BY.includes(raw['recordedBy'] as AcquisitionRecordedBy)\n ? (raw['recordedBy'] as AcquisitionRecordedBy)\n : 'backfill'\n const record: AccountAcquisition = {\n v: 1,\n source,\n medium: label(raw['medium']),\n campaign: label(raw['campaign']),\n channel:\n typeof channel === 'string' &&\n [\n 'organic-search',\n 'paid-search',\n 'social',\n 'referral',\n 'email',\n 'direct',\n 'unknown',\n ].includes(channel)\n ? (channel as AcquisitionChannel)\n : 'unknown',\n capturedAt:\n typeof raw['capturedAt'] === 'number' && raw['capturedAt'] > 0\n ? (raw['capturedAt'] as number)\n : null,\n landing:\n landing && label(landing['host'], 253)\n ? { host: label(landing['host'], 253) as string, path: label(landing['path'], 200) ?? '/' }\n : null,\n referrerHost: label(raw['referrerHost'], 253),\n viaHost: label(raw['viaHost'], 253),\n utm: Object.keys(utm).length ? utm : null,\n clickIds,\n adCampaignId:\n typeof raw['adCampaignId'] === 'string' && /^\\d{1,20}$/.test(raw['adCampaignId'])\n ? raw['adCampaignId']\n : null,\n door: doorOf(raw['door']),\n provider: providerOf(raw['provider']),\n invitedToOrgId: uidOf(raw['invitedToOrgId']),\n geo: geoOf(raw['geo']),\n accountCreatedAt: millisOf(raw['accountCreatedAt']),\n recordedAt:\n typeof raw['recordedAt'] === 'number' ? (raw['recordedAt'] as number) : 0,\n recordedBy,\n }\n const copiedFrom = uidOf(raw['copiedFromUid'])\n if (copiedFrom) record.copiedFromUid = copiedFrom\n return record\n}\n\n/** The Google Ads page for a campaign id, opened in whichever account is signed in. */\nexport function googleAdsCampaignUrl(campaignId: string): string {\n return `https://ads.google.com/aw/campaigns?campaignId=${encodeURIComponent(campaignId)}`\n}\n\n/** A sign-in provider id as a person reads it. */\nexport function providerLabel(provider: string | null | undefined): string | null {\n if (!provider) return null\n if (provider === 'password') return 'password'\n if (provider === 'google.com') return 'Google'\n if (provider === 'apple.com') return 'Apple'\n if (provider === 'microsoft.com') return 'Microsoft'\n if (provider === 'github.com') return 'GitHub'\n if (provider.startsWith('saml.') || provider.startsWith('oidc.')) return 'single sign-on'\n return provider\n}\n\nfunction channelPhrase(record: AccountAcquisition): string {\n const from = displayHost(record.referrerHost) || record.source\n const named = record.campaign ?? (record.adCampaignId ? `campaign ${record.adCampaignId}` : null)\n const campaign = named ? `, ${named}` : ''\n switch (record.channel) {\n case 'referral':\n return `Referral from ${from}${campaign}`\n case 'organic-search':\n return `Organic search from ${from}`\n case 'paid-search':\n return `Paid search (${record.source}${campaign})`\n case 'social':\n return `Social from ${from}${campaign}`\n case 'email':\n return `Email (${record.source}${campaign})`\n case 'direct':\n return record.viaHost\n ? `Direct, first seen arriving from ${record.viaHost}`\n : 'Direct'\n default:\n return record.recordedBy === 'backfill'\n ? 'Source unknown — the account predates capture'\n : 'Source unknown — nothing was captured'\n }\n}\n\nfunction doorPhrase(record: AccountAcquisition): string {\n const provider = providerLabel(record.provider)\n switch (record.door) {\n case 'signup-password':\n return 'signed up with password'\n case 'signup-google':\n return 'signed up with Google'\n case 'invite':\n return provider\n ? `signed up from an invitation, with ${provider}`\n : 'signed up from an invitation'\n case 'sso':\n return 'signed in with single sign-on'\n default:\n return provider ? `created with ${provider}` : 'created'\n }\n}\n\n/**\n * The record as one sentence a staff member reads at a glance:\n * \"Referral from g2.com → /pricing → signed up with password\".\n *\n * The landing is a bare path when it was on `primaryHost` — the platform's own\n * home — and host-and-path anywhere else, so a docs landing says so.\n */\nexport function describeAccountAcquisition(\n record: AccountAcquisition | null | undefined,\n options: { primaryHost?: string | null } = {},\n): string {\n if (!record) return 'No acquisition record'\n const parts = [channelPhrase(record)]\n if (record.landing) {\n const onPrimary =\n options.primaryHost &&\n displayHost(record.landing.host) === displayHost(options.primaryHost)\n parts.push(\n onPrimary ? record.landing.path : `${displayHost(record.landing.host)}${record.landing.path}`,\n )\n }\n parts.push(doorPhrase(record))\n return parts.join(' → ')\n}\n"],"names":["classifyChannel","displayHost","sanitizeFirstTouch","sourceAndMedium","ACCOUNT_ACQUISITION_FIELD","ACQUISITION_DOORS","MAX_LABEL","PROVIDER_SHAPE","RECORDED_BY","label","value","max","clean","replace","trim","slice","doorOf","includes","providerOf","test","toLowerCase","geoOf","raw","geo","country","toUpperCase","region","city","millisOf","Number","isFinite","uidOf","buildAccountAcquisition","input","touch","nowMs","base","door","provider","invitedToOrgId","accountCreatedAt","accountCreatedAtMs","recordedAt","recordedBy","unknownAccountAcquisition","source","medium","v","campaign","utm","channel","capturedAt","at","landing","host","path","referrerHost","ref","viaHost","via","clickIds","click","adCampaignId","adCampaign","organizationAcquisition","creatorRecord","creatorUid","record","readAccountAcquisition","copied","copiedFromUid","utmRaw","key","cleaned","Array","isArray","filter","id","Object","keys","length","copiedFrom","googleAdsCampaignUrl","campaignId","encodeURIComponent","providerLabel","startsWith","channelPhrase","from","named","doorPhrase","describeAccountAcquisition","options","parts","onPrimary","primaryHost","push","join"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/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 classifyChannel,\n displayHost,\n sanitizeFirstTouch,\n sourceAndMedium,\n type AcquisitionChannel,\n type FirstTouchClickId,\n type FirstTouchUtm,\n} from '@aglyn/shared-util-first-touch'\n\n/**\n * Where an account came from, written ONCE on the account when it is created\n * (AGL-3289) — `users/{uid}.acquisition`, and on the organization created in\n * the same flow as `orgs/{orgId}.acquisition`.\n *\n * ## Why this exists\n *\n * Answering \"where did this person come from?\" for one new workspace took a\n * sales session twenty-five minutes across five tools: the staff user page\n * had no source, Firestore held nothing beyond names, and the answer — a\n * review site's referral — was finally found in an analytics property and in\n * the marketing host's daily referrer counts. The platform never stored the\n * fact on the account it was about. This record is that fact, read from one\n * card on the staff user and organization pages.\n *\n * ## What writes it, and what never may\n *\n * The capture library keeps the first touch on the visitor's device; the\n * platform turns it into this record at account creation, server-side, and\n * the rules refuse the field to every client — owner and staff alike. A\n * record the account holder could restate would be a record nobody could\n * trust. It is never overwritten: a later touch, if one is ever kept, goes\n * BESIDE it as its own field.\n *\n * Every field here is built from the untrusted touch by re-scrubbing it, the\n * same way the capture scrubbed it on the device, and nothing the record says\n * grants anything: it is a label for a staff reader.\n */\n\n/** The field on `users/{uid}` and `orgs/{orgId}`. */\nexport const ACCOUNT_ACQUISITION_FIELD = 'acquisition'\n\n/**\n * How an account was created: the signup form with a password, the signup\n * form through Google — in a popup, or by the full-page redirect phones use —\n * accepting an invitation, or single sign-on.\n */\nexport const ACQUISITION_DOORS = [\n 'signup-password',\n 'signup-google',\n 'signup-google-redirect',\n 'invite',\n 'sso',\n] as const\nexport type AcquisitionDoor = (typeof ACQUISITION_DOORS)[number] | 'unknown'\n\n/** The sign-up form's doors: the ones a person reaches by signing up themselves. */\nexport type SignUpAcquisitionDoor = Extract<\n AcquisitionDoor,\n 'signup-password' | 'signup-google' | 'signup-google-redirect'\n>\n\n/**\n * The sign-up door an account took, from the provider its VERIFIED token\n * names, or null when that provider is not one the sign-up form offers.\n *\n * The provider decides; the page's `hint` only says which of a provider's\n * doors it was, so a Google account can say \"redirect\" and nothing else — it\n * can never claim the password door, and a password account can never claim\n * a Google one.\n */\nexport function signUpDoorFor(\n provider: string | null | undefined,\n hint?: unknown,\n): SignUpAcquisitionDoor | null {\n if (provider === 'password') return 'signup-password'\n if (provider === 'google.com') {\n return hint === 'signup-google-redirect' ? 'signup-google-redirect' : 'signup-google'\n }\n return null\n}\n\n/** What wrote a record: a door at creation, an organization copying its creator's, or the backfill. */\nexport type AcquisitionRecordedBy = 'signup' | 'sso' | 'org-creation' | 'backfill'\n\n/** Where the account-creating request came from, as the edge reported it. */\nexport interface AcquisitionGeo {\n country: string | null\n region: string | null\n city: string | null\n}\n\n/** The stored record. */\nexport interface AccountAcquisition {\n v: 1\n /**\n * Where the visit came from, spelled the way reports spell it — a\n * `utm_source`, a referring host, `(direct)` — or `unknown` when nothing was\n * captured.\n */\n source: string\n /** `utm_medium`, or what the channel implies; null when unknown. */\n medium: string | null\n /** `utm_campaign`, when the landing URL named one. */\n campaign: string | null\n /** The channel, from the platform's own rule table. */\n channel: AcquisitionChannel\n /** When the first visit landed; null when it was never captured. */\n capturedAt: number | null\n /** The first page the visitor landed on. */\n landing: { host: string; path: string } | null\n /** The external host that sent them. */\n referrerHost: string | null\n /** One of the platform's own hosts they came from before anything captured them. */\n viaHost: string | null\n /** Every `utm_*` the landing URL carried. */\n utm: FirstTouchUtm | null\n /** Which ad click ids the landing URL carried — presence only. */\n clickIds: FirstTouchClickId[]\n /**\n * The Google Ads campaign the click came from (`gad_campaignid`), which\n * auto-tagging appends whether or not the ad carries a `utm_campaign`.\n */\n adCampaignId: string | null\n /** How the account was created. */\n door: AcquisitionDoor\n /** The sign-in provider it was created with: `password`, `google.com`, `saml.…`. */\n provider: string | null\n /** The organization whose invitation the person signed up to accept. */\n invitedToOrgId: string | null\n /** Where the account-creating request came from. */\n geo: AcquisitionGeo | null\n /** When the auth record says the account was created. */\n accountCreatedAt: number | null\n /** When this record was written. */\n recordedAt: number\n recordedBy: AcquisitionRecordedBy\n /** On an organization's copy: the account it was copied from. */\n copiedFromUid?: string\n}\n\nconst MAX_LABEL = 100\nconst PROVIDER_SHAPE = /^[a-z0-9][a-z0-9._-]{0,63}$/i\nconst RECORDED_BY: readonly AcquisitionRecordedBy[] = [\n 'signup',\n 'sso',\n 'org-creation',\n 'backfill',\n]\n\nfunction label(value: unknown, max = MAX_LABEL): string | null {\n if (typeof value !== 'string') return null\n const clean = value.replace(/[\\u0000-\\u001f\\u007f]/g, '').trim() // eslint-disable-line no-control-regex\n return clean ? clean.slice(0, max) : null\n}\n\nfunction doorOf(value: unknown): AcquisitionDoor {\n return (ACQUISITION_DOORS as readonly string[]).includes(value as string)\n ? (value as AcquisitionDoor)\n : 'unknown'\n}\n\nfunction providerOf(value: unknown): string | null {\n return typeof value === 'string' && PROVIDER_SHAPE.test(value.trim())\n ? value.trim().toLowerCase()\n : null\n}\n\nfunction geoOf(value: unknown): AcquisitionGeo | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const geo: AcquisitionGeo = {\n country: label(raw['country'], 8)?.toUpperCase() ?? null,\n region: label(raw['region'], 64),\n city: label(raw['city'], 64),\n }\n return geo.country || geo.region || geo.city ? geo : null\n}\n\nfunction millisOf(value: unknown): number | null {\n return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null\n}\n\nfunction uidOf(value: unknown): string | null {\n return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : null\n}\n\n/** What a door knows when it records an account. */\nexport interface AccountAcquisitionInput {\n /** The first touch the capture kept — untrusted, from the device. */\n touch: unknown\n door: unknown\n provider?: unknown\n geo?: unknown\n invitedToOrgId?: unknown\n /** When the auth record says the account was created. */\n accountCreatedAtMs?: number | null\n recordedBy: AcquisitionRecordedBy\n nowMs: number\n}\n\n/**\n * The record for an account being created now.\n *\n * A missing or unusable touch still produces a record — `source: 'unknown'`\n * with whatever the door knows — because \"we asked and nothing came back\" is\n * a fact about the account too, and a card that renders blank would read as\n * \"never asked\".\n */\nexport function buildAccountAcquisition(input: AccountAcquisitionInput): AccountAcquisition {\n const touch = sanitizeFirstTouch(input.touch, input.nowMs)\n const base = {\n door: doorOf(input.door),\n provider: providerOf(input.provider),\n invitedToOrgId: uidOf(input.invitedToOrgId),\n geo: geoOf(input.geo),\n accountCreatedAt: millisOf(input.accountCreatedAtMs),\n recordedAt: input.nowMs,\n recordedBy: input.recordedBy,\n }\n if (!touch) return unknownAccountAcquisition(base)\n const { source, medium } = sourceAndMedium(touch)\n return {\n v: 1,\n source,\n medium,\n campaign: touch.utm?.campaign ?? null,\n channel: classifyChannel(touch),\n capturedAt: touch.at,\n landing: { host: touch.host, path: touch.path },\n referrerHost: touch.ref,\n viaHost: touch.via ?? null,\n utm: touch.utm ?? null,\n clickIds: touch.click ?? [],\n adCampaignId: touch.adCampaign ?? null,\n ...base,\n }\n}\n\n/** A record for an account nothing was captured for. */\nexport function unknownAccountAcquisition(input: {\n recordedAt: number\n recordedBy: AcquisitionRecordedBy\n door?: AcquisitionDoor\n provider?: string | null\n invitedToOrgId?: string | null\n geo?: AcquisitionGeo | null\n accountCreatedAt?: number | null\n}): AccountAcquisition {\n return {\n v: 1,\n source: 'unknown',\n medium: null,\n campaign: null,\n channel: 'unknown',\n capturedAt: null,\n landing: null,\n referrerHost: null,\n viaHost: null,\n utm: null,\n clickIds: [],\n adCampaignId: null,\n door: input.door ?? 'unknown',\n provider: input.provider ?? null,\n invitedToOrgId: input.invitedToOrgId ?? null,\n geo: input.geo ?? null,\n accountCreatedAt: input.accountCreatedAt ?? null,\n recordedAt: input.recordedAt,\n recordedBy: input.recordedBy,\n }\n}\n\n/**\n * The organization's copy of its creator's record, or an `unknown` one naming\n * the creator when the account has none — so an organization's card is never\n * blank either, and always says whose record it is.\n */\nexport function organizationAcquisition(\n creatorRecord: unknown,\n creatorUid: string,\n nowMs: number,\n): AccountAcquisition {\n const record = readAccountAcquisition(creatorRecord)\n const copied = record\n ? { ...record }\n : unknownAccountAcquisition({ recordedAt: nowMs, recordedBy: 'org-creation' })\n delete copied.copiedFromUid\n return { ...copied, copiedFromUid: creatorUid }\n}\n\n/**\n * A stored record as a reader should trust it: re-validated field by field,\n * so a document written by an older version, a backfill or a hand edit still\n * renders, and renders nothing it should not. Null when the value is not a\n * record at all.\n */\nexport function readAccountAcquisition(value: unknown): AccountAcquisition | null {\n if (!value || typeof value !== 'object') return null\n const raw = value as Record<string, unknown>\n const source = label(raw['source'])\n if (!source) return null\n const channel = raw['channel']\n const landing = raw['landing'] as Record<string, unknown> | null\n const utmRaw = raw['utm'] as Record<string, unknown> | null\n const utm: FirstTouchUtm = {}\n for (const key of ['source', 'medium', 'campaign', 'content', 'term'] as const) {\n const cleaned = label(utmRaw?.[key])\n if (cleaned) utm[key] = cleaned\n }\n const clickIds = Array.isArray(raw['clickIds'])\n ? (['gclid', 'fbclid', 'msclkid'] as const).filter((id) =>\n (raw['clickIds'] as unknown[]).includes(id),\n )\n : []\n const recordedBy = RECORDED_BY.includes(raw['recordedBy'] as AcquisitionRecordedBy)\n ? (raw['recordedBy'] as AcquisitionRecordedBy)\n : 'backfill'\n const record: AccountAcquisition = {\n v: 1,\n source,\n medium: label(raw['medium']),\n campaign: label(raw['campaign']),\n channel:\n typeof channel === 'string' &&\n [\n 'organic-search',\n 'paid-search',\n 'social',\n 'referral',\n 'email',\n 'direct',\n 'unknown',\n ].includes(channel)\n ? (channel as AcquisitionChannel)\n : 'unknown',\n capturedAt:\n typeof raw['capturedAt'] === 'number' && raw['capturedAt'] > 0\n ? (raw['capturedAt'] as number)\n : null,\n landing:\n landing && label(landing['host'], 253)\n ? { host: label(landing['host'], 253) as string, path: label(landing['path'], 200) ?? '/' }\n : null,\n referrerHost: label(raw['referrerHost'], 253),\n viaHost: label(raw['viaHost'], 253),\n utm: Object.keys(utm).length ? utm : null,\n clickIds,\n adCampaignId:\n typeof raw['adCampaignId'] === 'string' && /^\\d{1,20}$/.test(raw['adCampaignId'])\n ? raw['adCampaignId']\n : null,\n door: doorOf(raw['door']),\n provider: providerOf(raw['provider']),\n invitedToOrgId: uidOf(raw['invitedToOrgId']),\n geo: geoOf(raw['geo']),\n accountCreatedAt: millisOf(raw['accountCreatedAt']),\n recordedAt:\n typeof raw['recordedAt'] === 'number' ? (raw['recordedAt'] as number) : 0,\n recordedBy,\n }\n const copiedFrom = uidOf(raw['copiedFromUid'])\n if (copiedFrom) record.copiedFromUid = copiedFrom\n return record\n}\n\n/** The Google Ads page for a campaign id, opened in whichever account is signed in. */\nexport function googleAdsCampaignUrl(campaignId: string): string {\n return `https://ads.google.com/aw/campaigns?campaignId=${encodeURIComponent(campaignId)}`\n}\n\n/** A sign-in provider id as a person reads it. */\nexport function providerLabel(provider: string | null | undefined): string | null {\n if (!provider) return null\n if (provider === 'password') return 'password'\n if (provider === 'google.com') return 'Google'\n if (provider === 'apple.com') return 'Apple'\n if (provider === 'microsoft.com') return 'Microsoft'\n if (provider === 'github.com') return 'GitHub'\n if (provider.startsWith('saml.') || provider.startsWith('oidc.')) return 'single sign-on'\n return provider\n}\n\nfunction channelPhrase(record: AccountAcquisition): string {\n const from = displayHost(record.referrerHost) || record.source\n const named = record.campaign ?? (record.adCampaignId ? `campaign ${record.adCampaignId}` : null)\n const campaign = named ? `, ${named}` : ''\n switch (record.channel) {\n case 'referral':\n return `Referral from ${from}${campaign}`\n case 'organic-search':\n return `Organic search from ${from}`\n case 'paid-search':\n return `Paid search (${record.source}${campaign})`\n case 'social':\n return `Social from ${from}${campaign}`\n case 'email':\n return `Email (${record.source}${campaign})`\n case 'direct':\n return record.viaHost\n ? `Direct, first seen arriving from ${record.viaHost}`\n : 'Direct'\n default:\n return record.recordedBy === 'backfill'\n ? 'Source unknown — the account predates capture'\n : 'Source unknown — nothing was captured'\n }\n}\n\nfunction doorPhrase(record: AccountAcquisition): string {\n const provider = providerLabel(record.provider)\n switch (record.door) {\n case 'signup-password':\n return 'signed up with password'\n case 'signup-google':\n return 'signed up with Google'\n case 'signup-google-redirect':\n return 'signed up with Google, by redirect'\n case 'invite':\n return provider\n ? `signed up from an invitation, with ${provider}`\n : 'signed up from an invitation'\n case 'sso':\n return 'signed in with single sign-on'\n default:\n return provider ? `created with ${provider}` : 'created'\n }\n}\n\n/**\n * The record as one sentence a staff member reads at a glance:\n * \"Referral from g2.com → /pricing → signed up with password\".\n *\n * The landing is a bare path when it was on `primaryHost` — the platform's own\n * home — and host-and-path anywhere else, so a docs landing says so.\n */\nexport function describeAccountAcquisition(\n record: AccountAcquisition | null | undefined,\n options: { primaryHost?: string | null } = {},\n): string {\n if (!record) return 'No acquisition record'\n const parts = [channelPhrase(record)]\n if (record.landing) {\n const onPrimary =\n options.primaryHost &&\n displayHost(record.landing.host) === displayHost(options.primaryHost)\n parts.push(\n onPrimary ? record.landing.path : `${displayHost(record.landing.host)}${record.landing.path}`,\n )\n }\n parts.push(doorPhrase(record))\n return parts.join(' → ')\n}\n"],"names":["classifyChannel","displayHost","sanitizeFirstTouch","sourceAndMedium","ACCOUNT_ACQUISITION_FIELD","ACQUISITION_DOORS","signUpDoorFor","provider","hint","MAX_LABEL","PROVIDER_SHAPE","RECORDED_BY","label","value","max","clean","replace","trim","slice","doorOf","includes","providerOf","test","toLowerCase","geoOf","raw","geo","country","toUpperCase","region","city","millisOf","Number","isFinite","uidOf","buildAccountAcquisition","input","touch","nowMs","base","door","invitedToOrgId","accountCreatedAt","accountCreatedAtMs","recordedAt","recordedBy","unknownAccountAcquisition","source","medium","v","campaign","utm","channel","capturedAt","at","landing","host","path","referrerHost","ref","viaHost","via","clickIds","click","adCampaignId","adCampaign","organizationAcquisition","creatorRecord","creatorUid","record","readAccountAcquisition","copied","copiedFromUid","utmRaw","key","cleaned","Array","isArray","filter","id","Object","keys","length","copiedFrom","googleAdsCampaignUrl","campaignId","encodeURIComponent","providerLabel","startsWith","channelPhrase","from","named","doorPhrase","describeAccountAcquisition","options","parts","onPrimary","primaryHost","push","join"],"mappings":"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"}
|
|
@@ -101,4 +101,6 @@ export declare function adminAuditIndexFields(entry: AdminAuditIndexSource): Adm
|
|
|
101
101
|
* The row as it is stored: the entry, with the fields its lists query.
|
|
102
102
|
* Every write to `adminAudit` passes its data through this.
|
|
103
103
|
*/
|
|
104
|
-
export declare function withAdminAuditIndex<Entry extends AdminAuditIndexSource>(entry: Entry): Entry & AdminAuditIndexFields
|
|
104
|
+
export declare function withAdminAuditIndex<Entry extends AdminAuditIndexSource>(entry: Entry): Entry & AdminAuditIndexFields & {
|
|
105
|
+
scope: unknown;
|
|
106
|
+
};
|
|
@@ -171,7 +171,15 @@ import { nameSearchTokens } from "./name-search.js";
|
|
|
171
171
|
* The row as it is stored: the entry, with the fields its lists query.
|
|
172
172
|
* Every write to `adminAudit` passes its data through this.
|
|
173
173
|
*/ export function withAdminAuditIndex(entry) {
|
|
174
|
-
|
|
174
|
+
var _entry_scope;
|
|
175
|
+
/*
|
|
176
|
+
* `scope` stored on EVERY row, null when the writer has none (AGL-3680):
|
|
177
|
+
* the audit page sorts by it, and an `orderBy` drops every document that
|
|
178
|
+
* lacks the field. Only the scope-aware writers name one; the rows written
|
|
179
|
+
* before are stamped by `tools/scripts/backfill-staff-list-sort-fields.mjs`.
|
|
180
|
+
*/ return _extends({}, entry, {
|
|
181
|
+
scope: (_entry_scope = entry.scope) != null ? _entry_scope : null
|
|
182
|
+
}, adminAuditIndexFields(entry));
|
|
175
183
|
}
|
|
176
184
|
|
|
177
185
|
//# sourceMappingURL=admin-audit-index.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/admin-audit-index.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 isPluginStaffAuditAccess,\n pluginStaffAuditActionGroup,\n} from '../plugin-manager/plugin-activity-actions'\nimport { nameSearchTokens } from './name-search'\n\n/*\n * WHAT THE STAFF AUDIT LOG IS QUERIED BY, WRITTEN WITH EVERY ROW (AGL-3321).\n *\n * The staff audit page and the audit tables on a staff account's page filter\n * `adminAudit` by fields no writer used to store — the action's group, the\n * kind of thing acted on, the site, whether the act only looked — and search\n * it. All of that used to be answered by reading the log in batches and\n * keeping the rows that matched, and a match read that way stops wherever\n * the batches stop, so an entry past them was reported as not there. On an\n * audit trail that is the one wrong answer that matters.\n *\n * So each is stored on the row as it is written, and the query asks for it:\n *\n * actionGroup the facet's group for the action, answered by the plugin\n * activity registry (`pluginStaffAuditActionGroup`): a\n * registered group by code or by `staffAuditPrefixes`,\n * otherwise the action's leading namespace.\n * kind `access` for an act that only looked (a core read action,\n * or one a plugin declares in `staffAuditAccessActions`),\n * `change` for everything else.\n * targetKind the kind of record acted on: the first segment of the\n * target path (`orgs`, `users`, `hosts`, `lockdowns`).\n * targetHostId the site acted on, when the target is a site or a record\n * under one (`hosts/{id}/…`, `orgs/{org}/hosts/{id}/…`);\n * null otherwise, so \"no site\" is a value a query can ask for.\n * searchTokens word-prefix tokens (`nameSearchTokens`) of the fields a\n * reviewer searches by, for `array-contains` on one word.\n *\n * Every write goes through `withAdminAuditIndex`, on the server through\n * `addAdminAudit` / `setAdminAudit` / `recordAdminAudit` in\n * `@aglyn/tenant-data-admin`. `apps/console/specs/admin-audit-writes-are-stamped.spec.ts`\n * refuses a write to the collection anywhere else, and\n * `apps/console/specs/admin-audit-action-groups.spec.ts` pins the registry's\n * groups and reads to `tools/scripts/lib/admin-audit-index.fixtures.json`,\n * the mapping `tools/scripts/backfill-admin-audit-index.mjs` restamps old\n * rows with. A plugin adding a code, a prefix or a read action therefore\n * fails CI until the fixture names it, and the backfill is re-run.\n */\n\n/** The row's group, as the Action group filter asks for it. */\nexport const ADMIN_AUDIT_GROUP_FIELD = 'actionGroup'\n\n/** Whether the row only looked, as the account page's two tables ask for it. */\nexport const ADMIN_AUDIT_KIND_FIELD = 'kind'\n\n/** The kind of record acted on, as the Target type filter asks for it. */\nexport const ADMIN_AUDIT_TARGET_KIND_FIELD = 'targetKind'\n\n/** The site acted on, as the Site filter asks for it. */\nexport const ADMIN_AUDIT_SITE_FIELD = 'targetHostId'\n\n/** The row's search tokens, as the search asks for one. */\nexport const ADMIN_AUDIT_SEARCH_FIELD = 'searchTokens'\n\n/**\n * The fields a search reaches, in the order they claim the token budget:\n * what was done, who did it, what it was done to, then why.\n */\nexport const ADMIN_AUDIT_SEARCHED_FIELDS = [\n 'action',\n 'actorEmail',\n 'target',\n 'actorUid',\n 'scope',\n 'reason',\n 'note',\n] as const\n\n/**\n * The most tokens one row stores. A free-text note is the only field that\n * can run long, and it is searched last, so it is the one that loses reach\n * past the cap.\n */\nexport const ADMIN_AUDIT_SEARCH_TOKEN_LIMIT = 200\n\n/** Access looked at data; change altered something or acted on someone. */\nexport type AdminAuditKind = 'access' | 'change'\n\n/**\n * The core actions that only LOOKED.\n *\n * An exception list, not a classification of everything, and the default\n * matters more than the membership: anything absent is a `change`. A change\n * is the louder half of the console's audit card, so an action nobody has\n * classified yet gets the MORE prominent treatment rather than the quieter\n * one. The failure mode of the opposite default is an unclassified\n * impersonation rendering as routine browsing.\n *\n * An export is deliberately NOT here. Data leaving the platform is a\n * high-consequence act even though it mutates nothing, and it belongs beside\n * the impersonations rather than beside the record views.\n */\nexport const ADMIN_AUDIT_ACCESS_ACTIONS: readonly string[] = [\n 'email.message-viewed',\n // The acquisition card (AGL-3289). No longer written; the rows already in\n // the log still classify as reads.\n 'user.acquisition-viewed',\n 'org.acquisition-viewed',\n]\n\n/** What a row carries that the stamped fields are derived from. */\nexport type AdminAuditIndexSource = Partial<\n Record<(typeof ADMIN_AUDIT_SEARCHED_FIELDS)[number], unknown>\n>\n\n/** The fields the lists query. */\nexport interface AdminAuditIndexFields {\n actionGroup: string\n kind: AdminAuditKind\n targetKind: string\n targetHostId: string | null\n searchTokens: string[]\n}\n\n/** Separators inside a value: an address's `@` and `.`, a path's `/`, a code's `.`. */\nconst SEPARATORS = /[^\\p{L}\\p{N}]+/gu\n\n/**\n * The search tokens for one row.\n *\n * Each value is tokenized twice: as written, so a typed address or code\n * (`jane@acme`, `org.override`) matches from its start, and split at its\n * separators, so a reader finds `org.override` by `override` and an address\n * by its domain.\n */\nexport function adminAuditSearchTokens(entry: AdminAuditIndexSource): string[] {\n const tokens = new Set<string>()\n for (const field of ADMIN_AUDIT_SEARCHED_FIELDS) {\n const value = entry[field]\n if (typeof value !== 'string' || !value.trim()) continue\n const words = [\n ...nameSearchTokens(value),\n ...nameSearchTokens(value.replace(SEPARATORS, ' ')),\n ]\n for (const token of words) {\n tokens.add(token)\n if (tokens.size >= ADMIN_AUDIT_SEARCH_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The group an action is filed under — the same answer the page's facet\n * offers. Empty for a row with no action.\n */\nexport function adminAuditActionGroup(action: unknown): string {\n return pluginStaffAuditActionGroup(action)\n}\n\n/**\n * An access when the platform or a plugin declares the action a read — a\n * plugin's staff card opening on an org or an account names its own read\n * actions through its activity group (AGL-2939) — and a change otherwise.\n */\nexport function adminAuditKind(action: unknown): AdminAuditKind {\n return typeof action === 'string' &&\n action &&\n (ADMIN_AUDIT_ACCESS_ACTIONS.includes(action) || isPluginStaffAuditAccess(action))\n ? 'access'\n : 'change'\n}\n\n/** The segments of a target path; empty for a target that is not one. */\nconst segmentsOf = (target: unknown): string[] =>\n typeof target === 'string' ? target.trim().split('/').filter(Boolean) : []\n\n/**\n * The kind of record acted on: the target's first segment, which for every\n * path-shaped target is its collection (`orgs/{id}` → `orgs`). A target\n * that is an identifier rather than a path (`sso-domains:acme.com`) is its\n * own type up to the first `:`. Empty for a row with no target.\n */\nexport function adminAuditTargetKind(target: unknown): string {\n const [first = ''] = segmentsOf(target)\n const colon = first.indexOf(':')\n return colon > 0 ? first.slice(0, colon) : first\n}\n\n/**\n * The site acted on: the id after a `hosts` segment in the target path —\n * `hosts/{id}` and anything under it, and a site filed under its\n * organization (`orgs/{org}/hosts/{id}`). Null when the act was not on a site.\n */\nexport function adminAuditTargetHostId(target: unknown): string | null {\n const segments = segmentsOf(target)\n for (let at = 0; at < segments.length - 1; at += 2) {\n if (segments[at] === 'hosts') return segments[at + 1] || null\n }\n return null\n}\n\n/** Every stamped field for one row. */\nexport function adminAuditIndexFields(\n entry: AdminAuditIndexSource,\n): AdminAuditIndexFields {\n return {\n actionGroup: adminAuditActionGroup(entry.action),\n kind: adminAuditKind(entry.action),\n targetKind: adminAuditTargetKind(entry.target),\n targetHostId: adminAuditTargetHostId(entry.target),\n searchTokens: adminAuditSearchTokens(entry),\n }\n}\n\n/**\n * The row as it is stored: the entry, with the fields its lists query.\n * Every write to `adminAudit` passes its data through this.\n */\nexport function withAdminAuditIndex<Entry extends AdminAuditIndexSource>(\n entry: Entry,\n): Entry & AdminAuditIndexFields {\n return { ...entry, ...adminAuditIndexFields(entry) }\n}\n"],"names":["isPluginStaffAuditAccess","pluginStaffAuditActionGroup","nameSearchTokens","ADMIN_AUDIT_GROUP_FIELD","ADMIN_AUDIT_KIND_FIELD","ADMIN_AUDIT_TARGET_KIND_FIELD","ADMIN_AUDIT_SITE_FIELD","ADMIN_AUDIT_SEARCH_FIELD","ADMIN_AUDIT_SEARCHED_FIELDS","ADMIN_AUDIT_SEARCH_TOKEN_LIMIT","ADMIN_AUDIT_ACCESS_ACTIONS","SEPARATORS","adminAuditSearchTokens","entry","tokens","Set","field","value","trim","words","replace","token","add","size","adminAuditActionGroup","action","adminAuditKind","includes","segmentsOf","target","split","filter","Boolean","adminAuditTargetKind","first","colon","indexOf","slice","adminAuditTargetHostId","segments","at","length","adminAuditIndexFields","actionGroup","kind","targetKind","targetHostId","searchTokens","withAdminAuditIndex"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,2BAA2B,QACtB,+CAA2C;AAClD,SAASC,gBAAgB,QAAQ,mBAAe;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GAED,6DAA6D,GAC7D,OAAO,MAAMC,0BAA0B,cAAa;AAEpD,8EAA8E,GAC9E,OAAO,MAAMC,yBAAyB,OAAM;AAE5C,wEAAwE,GACxE,OAAO,MAAMC,gCAAgC,aAAY;AAEzD,uDAAuD,GACvD,OAAO,MAAMC,yBAAyB,eAAc;AAEpD,yDAAyD,GACzD,OAAO,MAAMC,2BAA2B,eAAc;AAEtD;;;CAGC,GACD,OAAO,MAAMC,8BAA8B;IACzC;IACA;IACA;IACA;IACA;IACA;IACA;CACD,CAAS;AAEV;;;;CAIC,GACD,OAAO,MAAMC,iCAAiC,IAAG;AAKjD;;;;;;;;;;;;;CAaC,GACD,OAAO,MAAMC,6BAAgD;IAC3D;IACA,0EAA0E;IAC1E,mCAAmC;IACnC;IACA;CACD,CAAA;AAgBD,qFAAqF,GACrF,MAAMC,aAAa;AAEnB;;;;;;;CAOC,GACD,OAAO,SAASC,uBAAuBC,KAA4B;IACjE,MAAMC,SAAS,IAAIC;IACnB,KAAK,MAAMC,SAASR,4BAA6B;QAC/C,MAAMS,QAAQJ,KAAK,CAACG,MAAM;QAC1B,IAAI,OAAOC,UAAU,YAAY,CAACA,MAAMC,IAAI,IAAI;QAChD,MAAMC,QAAQ;eACTjB,iBAAiBe;eACjBf,iBAAiBe,MAAMG,OAAO,CAACT,YAAY;SAC/C;QACD,KAAK,MAAMU,SAASF,MAAO;YACzBL,OAAOQ,GAAG,CAACD;YACX,IAAIP,OAAOS,IAAI,IAAId,gCAAgC,OAAO;mBAAIK;aAAO;QACvE;IACF;IACA,OAAO;WAAIA;KAAO;AACpB;AAEA;;;CAGC,GACD,OAAO,SAASU,sBAAsBC,MAAe;IACnD,OAAOxB,4BAA4BwB;AACrC;AAEA;;;;CAIC,GACD,OAAO,SAASC,eAAeD,MAAe;IAC5C,OAAO,OAAOA,WAAW,YACvBA,UACCf,CAAAA,2BAA2BiB,QAAQ,CAACF,WAAWzB,yBAAyByB,OAAM,IAC7E,WACA;AACN;AAEA,uEAAuE,GACvE,MAAMG,aAAa,CAACC,SAClB,OAAOA,WAAW,WAAWA,OAAOX,IAAI,GAAGY,KAAK,CAAC,KAAKC,MAAM,CAACC,WAAW,EAAE;AAE5E;;;;;CAKC,GACD,OAAO,SAASC,qBAAqBJ,MAAe;IAClD,MAAM,CAACK,QAAQ,EAAE,CAAC,GAAGN,WAAWC;IAChC,MAAMM,QAAQD,MAAME,OAAO,CAAC;IAC5B,OAAOD,QAAQ,IAAID,MAAMG,KAAK,CAAC,GAAGF,SAASD;AAC7C;AAEA;;;;CAIC,GACD,OAAO,SAASI,uBAAuBT,MAAe;IACpD,MAAMU,WAAWX,WAAWC;IAC5B,IAAK,IAAIW,KAAK,GAAGA,KAAKD,SAASE,MAAM,GAAG,GAAGD,MAAM,EAAG;QAClD,IAAID,QAAQ,CAACC,GAAG,KAAK,SAAS,OAAOD,QAAQ,CAACC,KAAK,EAAE,IAAI;IAC3D;IACA,OAAO;AACT;AAEA,qCAAqC,GACrC,OAAO,SAASE,sBACd7B,KAA4B;IAE5B,OAAO;QACL8B,aAAanB,sBAAsBX,MAAMY,MAAM;QAC/CmB,MAAMlB,eAAeb,MAAMY,MAAM;QACjCoB,YAAYZ,qBAAqBpB,MAAMgB,MAAM;QAC7CiB,cAAcR,uBAAuBzB,MAAMgB,MAAM;QACjDkB,cAAcnC,uBAAuBC;IACvC;AACF;AAEA;;;CAGC,GACD,OAAO,SAASmC,oBACdnC,KAAY;IAEZ,OAAO,aAAKA,OAAU6B,sBAAsB7B;AAC9C"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/admin-audit-index.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 isPluginStaffAuditAccess,\n pluginStaffAuditActionGroup,\n} from '../plugin-manager/plugin-activity-actions'\nimport { nameSearchTokens } from './name-search'\n\n/*\n * WHAT THE STAFF AUDIT LOG IS QUERIED BY, WRITTEN WITH EVERY ROW (AGL-3321).\n *\n * The staff audit page and the audit tables on a staff account's page filter\n * `adminAudit` by fields no writer used to store — the action's group, the\n * kind of thing acted on, the site, whether the act only looked — and search\n * it. All of that used to be answered by reading the log in batches and\n * keeping the rows that matched, and a match read that way stops wherever\n * the batches stop, so an entry past them was reported as not there. On an\n * audit trail that is the one wrong answer that matters.\n *\n * So each is stored on the row as it is written, and the query asks for it:\n *\n * actionGroup the facet's group for the action, answered by the plugin\n * activity registry (`pluginStaffAuditActionGroup`): a\n * registered group by code or by `staffAuditPrefixes`,\n * otherwise the action's leading namespace.\n * kind `access` for an act that only looked (a core read action,\n * or one a plugin declares in `staffAuditAccessActions`),\n * `change` for everything else.\n * targetKind the kind of record acted on: the first segment of the\n * target path (`orgs`, `users`, `hosts`, `lockdowns`).\n * targetHostId the site acted on, when the target is a site or a record\n * under one (`hosts/{id}/…`, `orgs/{org}/hosts/{id}/…`);\n * null otherwise, so \"no site\" is a value a query can ask for.\n * searchTokens word-prefix tokens (`nameSearchTokens`) of the fields a\n * reviewer searches by, for `array-contains` on one word.\n *\n * Every write goes through `withAdminAuditIndex`, on the server through\n * `addAdminAudit` / `setAdminAudit` / `recordAdminAudit` in\n * `@aglyn/tenant-data-admin`. `apps/console/specs/admin-audit-writes-are-stamped.spec.ts`\n * refuses a write to the collection anywhere else, and\n * `apps/console/specs/admin-audit-action-groups.spec.ts` pins the registry's\n * groups and reads to `tools/scripts/lib/admin-audit-index.fixtures.json`,\n * the mapping `tools/scripts/backfill-admin-audit-index.mjs` restamps old\n * rows with. A plugin adding a code, a prefix or a read action therefore\n * fails CI until the fixture names it, and the backfill is re-run.\n */\n\n/** The row's group, as the Action group filter asks for it. */\nexport const ADMIN_AUDIT_GROUP_FIELD = 'actionGroup'\n\n/** Whether the row only looked, as the account page's two tables ask for it. */\nexport const ADMIN_AUDIT_KIND_FIELD = 'kind'\n\n/** The kind of record acted on, as the Target type filter asks for it. */\nexport const ADMIN_AUDIT_TARGET_KIND_FIELD = 'targetKind'\n\n/** The site acted on, as the Site filter asks for it. */\nexport const ADMIN_AUDIT_SITE_FIELD = 'targetHostId'\n\n/** The row's search tokens, as the search asks for one. */\nexport const ADMIN_AUDIT_SEARCH_FIELD = 'searchTokens'\n\n/**\n * The fields a search reaches, in the order they claim the token budget:\n * what was done, who did it, what it was done to, then why.\n */\nexport const ADMIN_AUDIT_SEARCHED_FIELDS = [\n 'action',\n 'actorEmail',\n 'target',\n 'actorUid',\n 'scope',\n 'reason',\n 'note',\n] as const\n\n/**\n * The most tokens one row stores. A free-text note is the only field that\n * can run long, and it is searched last, so it is the one that loses reach\n * past the cap.\n */\nexport const ADMIN_AUDIT_SEARCH_TOKEN_LIMIT = 200\n\n/** Access looked at data; change altered something or acted on someone. */\nexport type AdminAuditKind = 'access' | 'change'\n\n/**\n * The core actions that only LOOKED.\n *\n * An exception list, not a classification of everything, and the default\n * matters more than the membership: anything absent is a `change`. A change\n * is the louder half of the console's audit card, so an action nobody has\n * classified yet gets the MORE prominent treatment rather than the quieter\n * one. The failure mode of the opposite default is an unclassified\n * impersonation rendering as routine browsing.\n *\n * An export is deliberately NOT here. Data leaving the platform is a\n * high-consequence act even though it mutates nothing, and it belongs beside\n * the impersonations rather than beside the record views.\n */\nexport const ADMIN_AUDIT_ACCESS_ACTIONS: readonly string[] = [\n 'email.message-viewed',\n // The acquisition card (AGL-3289). No longer written; the rows already in\n // the log still classify as reads.\n 'user.acquisition-viewed',\n 'org.acquisition-viewed',\n]\n\n/** What a row carries that the stamped fields are derived from. */\nexport type AdminAuditIndexSource = Partial<\n Record<(typeof ADMIN_AUDIT_SEARCHED_FIELDS)[number], unknown>\n>\n\n/** The fields the lists query. */\nexport interface AdminAuditIndexFields {\n actionGroup: string\n kind: AdminAuditKind\n targetKind: string\n targetHostId: string | null\n searchTokens: string[]\n}\n\n/** Separators inside a value: an address's `@` and `.`, a path's `/`, a code's `.`. */\nconst SEPARATORS = /[^\\p{L}\\p{N}]+/gu\n\n/**\n * The search tokens for one row.\n *\n * Each value is tokenized twice: as written, so a typed address or code\n * (`jane@acme`, `org.override`) matches from its start, and split at its\n * separators, so a reader finds `org.override` by `override` and an address\n * by its domain.\n */\nexport function adminAuditSearchTokens(entry: AdminAuditIndexSource): string[] {\n const tokens = new Set<string>()\n for (const field of ADMIN_AUDIT_SEARCHED_FIELDS) {\n const value = entry[field]\n if (typeof value !== 'string' || !value.trim()) continue\n const words = [\n ...nameSearchTokens(value),\n ...nameSearchTokens(value.replace(SEPARATORS, ' ')),\n ]\n for (const token of words) {\n tokens.add(token)\n if (tokens.size >= ADMIN_AUDIT_SEARCH_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The group an action is filed under — the same answer the page's facet\n * offers. Empty for a row with no action.\n */\nexport function adminAuditActionGroup(action: unknown): string {\n return pluginStaffAuditActionGroup(action)\n}\n\n/**\n * An access when the platform or a plugin declares the action a read — a\n * plugin's staff card opening on an org or an account names its own read\n * actions through its activity group (AGL-2939) — and a change otherwise.\n */\nexport function adminAuditKind(action: unknown): AdminAuditKind {\n return typeof action === 'string' &&\n action &&\n (ADMIN_AUDIT_ACCESS_ACTIONS.includes(action) || isPluginStaffAuditAccess(action))\n ? 'access'\n : 'change'\n}\n\n/** The segments of a target path; empty for a target that is not one. */\nconst segmentsOf = (target: unknown): string[] =>\n typeof target === 'string' ? target.trim().split('/').filter(Boolean) : []\n\n/**\n * The kind of record acted on: the target's first segment, which for every\n * path-shaped target is its collection (`orgs/{id}` → `orgs`). A target\n * that is an identifier rather than a path (`sso-domains:acme.com`) is its\n * own type up to the first `:`. Empty for a row with no target.\n */\nexport function adminAuditTargetKind(target: unknown): string {\n const [first = ''] = segmentsOf(target)\n const colon = first.indexOf(':')\n return colon > 0 ? first.slice(0, colon) : first\n}\n\n/**\n * The site acted on: the id after a `hosts` segment in the target path —\n * `hosts/{id}` and anything under it, and a site filed under its\n * organization (`orgs/{org}/hosts/{id}`). Null when the act was not on a site.\n */\nexport function adminAuditTargetHostId(target: unknown): string | null {\n const segments = segmentsOf(target)\n for (let at = 0; at < segments.length - 1; at += 2) {\n if (segments[at] === 'hosts') return segments[at + 1] || null\n }\n return null\n}\n\n/** Every stamped field for one row. */\nexport function adminAuditIndexFields(\n entry: AdminAuditIndexSource,\n): AdminAuditIndexFields {\n return {\n actionGroup: adminAuditActionGroup(entry.action),\n kind: adminAuditKind(entry.action),\n targetKind: adminAuditTargetKind(entry.target),\n targetHostId: adminAuditTargetHostId(entry.target),\n searchTokens: adminAuditSearchTokens(entry),\n }\n}\n\n/**\n * The row as it is stored: the entry, with the fields its lists query.\n * Every write to `adminAudit` passes its data through this.\n */\nexport function withAdminAuditIndex<Entry extends AdminAuditIndexSource>(\n entry: Entry,\n): Entry & AdminAuditIndexFields & { scope: unknown } {\n /*\n * `scope` stored on EVERY row, null when the writer has none (AGL-3680):\n * the audit page sorts by it, and an `orderBy` drops every document that\n * lacks the field. Only the scope-aware writers name one; the rows written\n * before are stamped by `tools/scripts/backfill-staff-list-sort-fields.mjs`.\n */\n return { ...entry, scope: entry.scope ?? null, ...adminAuditIndexFields(entry) }\n}\n"],"names":["isPluginStaffAuditAccess","pluginStaffAuditActionGroup","nameSearchTokens","ADMIN_AUDIT_GROUP_FIELD","ADMIN_AUDIT_KIND_FIELD","ADMIN_AUDIT_TARGET_KIND_FIELD","ADMIN_AUDIT_SITE_FIELD","ADMIN_AUDIT_SEARCH_FIELD","ADMIN_AUDIT_SEARCHED_FIELDS","ADMIN_AUDIT_SEARCH_TOKEN_LIMIT","ADMIN_AUDIT_ACCESS_ACTIONS","SEPARATORS","adminAuditSearchTokens","entry","tokens","Set","field","value","trim","words","replace","token","add","size","adminAuditActionGroup","action","adminAuditKind","includes","segmentsOf","target","split","filter","Boolean","adminAuditTargetKind","first","colon","indexOf","slice","adminAuditTargetHostId","segments","at","length","adminAuditIndexFields","actionGroup","kind","targetKind","targetHostId","searchTokens","withAdminAuditIndex","scope"],"mappings":"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"}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The arithmetic behind the traffic tiles `/product/analytics` advertises
|
|
19
|
+
* (AGL-2160).
|
|
20
|
+
*
|
|
21
|
+
* Its mockup shows four tiles — `Page views 48,210 / +12% vs prior`,
|
|
22
|
+
* `Mobile / Desktop 61% / 39%`, `Top page`, `Top referrer`. The card had
|
|
23
|
+
* `Pageviews`, a separate `Week over week` tile pinned to 7-vs-7 whatever
|
|
24
|
+
* the range selector said, and a one-word `Top device`.
|
|
25
|
+
*/
|
|
26
|
+
/** One day counter document, as the traffic card reads it. */
|
|
27
|
+
export interface TrafficDay {
|
|
28
|
+
day: string;
|
|
29
|
+
total: number;
|
|
30
|
+
visitors: number;
|
|
31
|
+
paths: Record<string, number>;
|
|
32
|
+
referrers: Record<string, number>;
|
|
33
|
+
devices: Record<string, number>;
|
|
34
|
+
}
|
|
35
|
+
export interface TrafficWindows<T> {
|
|
36
|
+
/** The newest `windowSize` days, oldest first — what the chart plots. */
|
|
37
|
+
current: T[];
|
|
38
|
+
/** The `windowSize` days before those, for the comparison only. */
|
|
39
|
+
prior: T[];
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Splits a `windowSize * 2` run of days into the displayed window and the
|
|
43
|
+
* one behind it.
|
|
44
|
+
*
|
|
45
|
+
* Both callers pass days OLDEST FIRST, which is the order the chart reads
|
|
46
|
+
* and the order the fetch produces. Getting this backwards is silent — the
|
|
47
|
+
* delta simply comes out negated — so the split is here with a test rather
|
|
48
|
+
* than inline in a 400-line component.
|
|
49
|
+
*/
|
|
50
|
+
export declare function splitTrafficWindows<T>(days: readonly T[], windowSize: number): TrafficWindows<T>;
|
|
51
|
+
/**
|
|
52
|
+
* Percentage change between two window totals, to one decimal.
|
|
53
|
+
*
|
|
54
|
+
* `null` when the prior window recorded nothing. A site's first week has no
|
|
55
|
+
* growth rate, and `+100%` — or `+∞`, or `+0%` — all say something the data
|
|
56
|
+
* does not. The tile renders nothing instead.
|
|
57
|
+
*/
|
|
58
|
+
export declare function trafficDeltaPct(current: number, prior: number): number | null;
|
|
59
|
+
export interface DeviceSplitEntry {
|
|
60
|
+
device: string;
|
|
61
|
+
count: number;
|
|
62
|
+
/** Whole percent of the window's device-classified views. */
|
|
63
|
+
percent: number;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The device split as the mockup shows it: `Mobile / Desktop`, `61% / 39%`.
|
|
67
|
+
*
|
|
68
|
+
* Ordered by share, and NOT padded with zero-count devices — a site with no
|
|
69
|
+
* tablet traffic should not carry a `Tablet 0%` label, which reads as a
|
|
70
|
+
* measurement rather than an absence.
|
|
71
|
+
*
|
|
72
|
+
* Percentages are rounded independently and therefore need not total 100.
|
|
73
|
+
* That is deliberate: forcing the largest share to absorb the rounding
|
|
74
|
+
* error makes one number wrong to make a sum right, and nothing here sums
|
|
75
|
+
* them.
|
|
76
|
+
*/
|
|
77
|
+
export declare function deviceSplit(devices: Record<string, number>): DeviceSplitEntry[];
|
|
78
|
+
/** `Mobile / Desktop` — the labels, title-cased, for the tile's caption. */
|
|
79
|
+
export declare function deviceSplitLabel(split: readonly DeviceSplitEntry[]): string;
|
|
80
|
+
/** `61% / 39%` — the figures, in the same order as the labels. */
|
|
81
|
+
export declare function deviceSplitValue(split: readonly DeviceSplitEntry[]): string;
|
|
82
|
+
/**
|
|
83
|
+
* Sums a per-day map across a window, returning entries by descending
|
|
84
|
+
* count. Shared by paths and referrers, which had two copies of it.
|
|
85
|
+
*/
|
|
86
|
+
export declare function rollUp(days: readonly Partial<Record<'paths' | 'referrers', Record<string, number>>>[], field: 'paths' | 'referrers'): [string, number][];
|
|
87
|
+
/**
|
|
88
|
+
* `2m 04s` — the dwell format `/product/analytics`'s per-screen mockup
|
|
89
|
+
* shows (AGL-2182).
|
|
90
|
+
*
|
|
91
|
+
* Seconds are zero-padded so a column of these stays aligned, and the
|
|
92
|
+
* minute part is dropped below a minute rather than rendering `0m 04s`,
|
|
93
|
+
* which reads like a broken clock.
|
|
94
|
+
*/
|
|
95
|
+
export declare function formatDwell(ms: number): string;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ /**
|
|
17
|
+
* The arithmetic behind the traffic tiles `/product/analytics` advertises
|
|
18
|
+
* (AGL-2160).
|
|
19
|
+
*
|
|
20
|
+
* Its mockup shows four tiles — `Page views 48,210 / +12% vs prior`,
|
|
21
|
+
* `Mobile / Desktop 61% / 39%`, `Top page`, `Top referrer`. The card had
|
|
22
|
+
* `Pageviews`, a separate `Week over week` tile pinned to 7-vs-7 whatever
|
|
23
|
+
* the range selector said, and a one-word `Top device`.
|
|
24
|
+
*/ /** One day counter document, as the traffic card reads it. */ /**
|
|
25
|
+
* Splits a `windowSize * 2` run of days into the displayed window and the
|
|
26
|
+
* one behind it.
|
|
27
|
+
*
|
|
28
|
+
* Both callers pass days OLDEST FIRST, which is the order the chart reads
|
|
29
|
+
* and the order the fetch produces. Getting this backwards is silent — the
|
|
30
|
+
* delta simply comes out negated — so the split is here with a test rather
|
|
31
|
+
* than inline in a 400-line component.
|
|
32
|
+
*/ export function splitTrafficWindows(days, windowSize) {
|
|
33
|
+
if (windowSize <= 0) return {
|
|
34
|
+
current: [],
|
|
35
|
+
prior: []
|
|
36
|
+
};
|
|
37
|
+
const current = days.slice(-windowSize);
|
|
38
|
+
const prior = days.slice(-windowSize * 2, -windowSize);
|
|
39
|
+
return {
|
|
40
|
+
current,
|
|
41
|
+
prior
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Percentage change between two window totals, to one decimal.
|
|
46
|
+
*
|
|
47
|
+
* `null` when the prior window recorded nothing. A site's first week has no
|
|
48
|
+
* growth rate, and `+100%` — or `+∞`, or `+0%` — all say something the data
|
|
49
|
+
* does not. The tile renders nothing instead.
|
|
50
|
+
*/ export function trafficDeltaPct(current, prior) {
|
|
51
|
+
if (!prior) return null;
|
|
52
|
+
return Math.round((current - prior) / prior * 1000) / 10;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The device split as the mockup shows it: `Mobile / Desktop`, `61% / 39%`.
|
|
56
|
+
*
|
|
57
|
+
* Ordered by share, and NOT padded with zero-count devices — a site with no
|
|
58
|
+
* tablet traffic should not carry a `Tablet 0%` label, which reads as a
|
|
59
|
+
* measurement rather than an absence.
|
|
60
|
+
*
|
|
61
|
+
* Percentages are rounded independently and therefore need not total 100.
|
|
62
|
+
* That is deliberate: forcing the largest share to absorb the rounding
|
|
63
|
+
* error makes one number wrong to make a sum right, and nothing here sums
|
|
64
|
+
* them.
|
|
65
|
+
*/ export function deviceSplit(devices) {
|
|
66
|
+
const entries = Object.entries(devices != null ? devices : {}).filter(([, count])=>Number.isFinite(count) && count > 0);
|
|
67
|
+
const sum = entries.reduce((total, [, count])=>total + count, 0);
|
|
68
|
+
if (!sum) return [];
|
|
69
|
+
return entries.sort(([, a], [, b])=>b - a).map(([device, count])=>({
|
|
70
|
+
device,
|
|
71
|
+
count,
|
|
72
|
+
percent: Math.round(count / sum * 100)
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
/** `Mobile / Desktop` — the labels, title-cased, for the tile's caption. */ export function deviceSplitLabel(split) {
|
|
76
|
+
return split.map((entry)=>entry.device.charAt(0).toUpperCase() + entry.device.slice(1)).join(' / ');
|
|
77
|
+
}
|
|
78
|
+
/** `61% / 39%` — the figures, in the same order as the labels. */ export function deviceSplitValue(split) {
|
|
79
|
+
return split.map((entry)=>`${entry.percent}%`).join(' / ');
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Sums a per-day map across a window, returning entries by descending
|
|
83
|
+
* count. Shared by paths and referrers, which had two copies of it.
|
|
84
|
+
*/ export function rollUp(days, field) {
|
|
85
|
+
const totals = {};
|
|
86
|
+
for (const day of days){
|
|
87
|
+
var _day_field;
|
|
88
|
+
const map = (_day_field = day[field]) != null ? _day_field : {};
|
|
89
|
+
for (const [key, count] of Object.entries(map)){
|
|
90
|
+
var _totals_key;
|
|
91
|
+
totals[key] = ((_totals_key = totals[key]) != null ? _totals_key : 0) + count;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return Object.entries(totals).sort(([, a], [, b])=>b - a);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* `2m 04s` — the dwell format `/product/analytics`'s per-screen mockup
|
|
98
|
+
* shows (AGL-2182).
|
|
99
|
+
*
|
|
100
|
+
* Seconds are zero-padded so a column of these stays aligned, and the
|
|
101
|
+
* minute part is dropped below a minute rather than rendering `0m 04s`,
|
|
102
|
+
* which reads like a broken clock.
|
|
103
|
+
*/ export function formatDwell(ms) {
|
|
104
|
+
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
105
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
106
|
+
const seconds = totalSeconds % 60;
|
|
107
|
+
if (minutes === 0) return `${seconds}s`;
|
|
108
|
+
if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
|
|
109
|
+
const hours = Math.floor(minutes / 60);
|
|
110
|
+
return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
//# sourceMappingURL=analytics-summary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/analytics-summary.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The arithmetic behind the traffic tiles `/product/analytics` advertises\n * (AGL-2160).\n *\n * Its mockup shows four tiles — `Page views 48,210 / +12% vs prior`,\n * `Mobile / Desktop 61% / 39%`, `Top page`, `Top referrer`. The card had\n * `Pageviews`, a separate `Week over week` tile pinned to 7-vs-7 whatever\n * the range selector said, and a one-word `Top device`.\n */\n\n/** One day counter document, as the traffic card reads it. */\nexport interface TrafficDay {\n day: string\n total: number\n visitors: number\n paths: Record<string, number>\n referrers: Record<string, number>\n devices: Record<string, number>\n}\n\nexport interface TrafficWindows<T> {\n /** The newest `windowSize` days, oldest first — what the chart plots. */\n current: T[]\n /** The `windowSize` days before those, for the comparison only. */\n prior: T[]\n}\n\n/**\n * Splits a `windowSize * 2` run of days into the displayed window and the\n * one behind it.\n *\n * Both callers pass days OLDEST FIRST, which is the order the chart reads\n * and the order the fetch produces. Getting this backwards is silent — the\n * delta simply comes out negated — so the split is here with a test rather\n * than inline in a 400-line component.\n */\nexport function splitTrafficWindows<T>(\n days: readonly T[],\n windowSize: number,\n): TrafficWindows<T> {\n if (windowSize <= 0) return { current: [], prior: [] }\n const current = days.slice(-windowSize)\n const prior = days.slice(-windowSize * 2, -windowSize)\n return { current, prior }\n}\n\n/**\n * Percentage change between two window totals, to one decimal.\n *\n * `null` when the prior window recorded nothing. A site's first week has no\n * growth rate, and `+100%` — or `+∞`, or `+0%` — all say something the data\n * does not. The tile renders nothing instead.\n */\nexport function trafficDeltaPct(\n current: number,\n prior: number,\n): number | null {\n if (!prior) return null\n return Math.round(((current - prior) / prior) * 1000) / 10\n}\n\nexport interface DeviceSplitEntry {\n device: string\n count: number\n /** Whole percent of the window's device-classified views. */\n percent: number\n}\n\n/**\n * The device split as the mockup shows it: `Mobile / Desktop`, `61% / 39%`.\n *\n * Ordered by share, and NOT padded with zero-count devices — a site with no\n * tablet traffic should not carry a `Tablet 0%` label, which reads as a\n * measurement rather than an absence.\n *\n * Percentages are rounded independently and therefore need not total 100.\n * That is deliberate: forcing the largest share to absorb the rounding\n * error makes one number wrong to make a sum right, and nothing here sums\n * them.\n */\nexport function deviceSplit(\n devices: Record<string, number>,\n): DeviceSplitEntry[] {\n const entries = Object.entries(devices ?? {}).filter(\n ([, count]) => Number.isFinite(count) && count > 0,\n )\n const sum = entries.reduce((total, [, count]) => total + count, 0)\n if (!sum) return []\n return entries\n .sort(([, a], [, b]) => b - a)\n .map(([device, count]) => ({\n device,\n count,\n percent: Math.round((count / sum) * 100),\n }))\n}\n\n/** `Mobile / Desktop` — the labels, title-cased, for the tile's caption. */\nexport function deviceSplitLabel(split: readonly DeviceSplitEntry[]): string {\n return split\n .map((entry) => entry.device.charAt(0).toUpperCase() + entry.device.slice(1))\n .join(' / ')\n}\n\n/** `61% / 39%` — the figures, in the same order as the labels. */\nexport function deviceSplitValue(split: readonly DeviceSplitEntry[]): string {\n return split.map((entry) => `${entry.percent}%`).join(' / ')\n}\n\n/**\n * Sums a per-day map across a window, returning entries by descending\n * count. Shared by paths and referrers, which had two copies of it.\n */\nexport function rollUp(\n days: readonly Partial<\n Record<'paths' | 'referrers', Record<string, number>>\n >[],\n field: 'paths' | 'referrers',\n): [string, number][] {\n const totals: Record<string, number> = {}\n for (const day of days) {\n const map = day[field] ?? {}\n for (const [key, count] of Object.entries(map)) {\n totals[key] = (totals[key] ?? 0) + count\n }\n }\n return Object.entries(totals).sort(([, a], [, b]) => b - a)\n}\n\n/**\n * `2m 04s` — the dwell format `/product/analytics`'s per-screen mockup\n * shows (AGL-2182).\n *\n * Seconds are zero-padded so a column of these stays aligned, and the\n * minute part is dropped below a minute rather than rendering `0m 04s`,\n * which reads like a broken clock.\n */\nexport function formatDwell(ms: number): string {\n const totalSeconds = Math.max(0, Math.round(ms / 1000))\n const minutes = Math.floor(totalSeconds / 60)\n const seconds = totalSeconds % 60\n if (minutes === 0) return `${seconds}s`\n if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, '0')}s`\n const hours = Math.floor(minutes / 60)\n return `${hours}h ${String(minutes % 60).padStart(2, '0')}m`\n}\n"],"names":["splitTrafficWindows","days","windowSize","current","prior","slice","trafficDeltaPct","Math","round","deviceSplit","devices","entries","Object","filter","count","Number","isFinite","sum","reduce","total","sort","a","b","map","device","percent","deviceSplitLabel","split","entry","charAt","toUpperCase","join","deviceSplitValue","rollUp","field","totals","day","key","formatDwell","ms","totalSeconds","max","minutes","floor","seconds","String","padStart","hours"],"mappings":"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"}
|
|
@@ -39,6 +39,11 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
39
39
|
* so does a deleted template (`artifactDeleteListKeys`), which
|
|
40
40
|
* is how the library's query leaves tombstones out;
|
|
41
41
|
*
|
|
42
|
+
* description on a layout or component that has none, `null` — the
|
|
43
|
+
* lists sort by it (AGL-3680), and an `orderBy` drops every
|
|
44
|
+
* document that lacks the field. A writer that passes a
|
|
45
|
+
* partial document here must pass its `description` too, or
|
|
46
|
+
* the null would overwrite the one it writes;
|
|
42
47
|
* deletedAt on a screen, `null` — the flag a campaign's screens list
|
|
43
48
|
* asks for (`deletedAt == null`) to leave tombstones out. A
|
|
44
49
|
* query cannot ask for a field to be absent, so a live
|
|
@@ -102,7 +107,14 @@ const record = (value)=>value && typeof value === 'object' ? value : {};
|
|
|
102
107
|
// Stored, not omitted: `deletedAt == null` matches only a document that
|
|
103
108
|
// holds the field (AGL-3321, a campaign's screens).
|
|
104
109
|
if (collection === 'screens') keys['deletedAt'] = null;
|
|
110
|
+
// A live layout too: its list asks for `deletedAt == null` rather than
|
|
111
|
+
// dropping tombstones from the page they fall in (AGL-3680).
|
|
112
|
+
if (collection === 'layouts' && doc['deletedAt'] == null) keys['deletedAt'] = null;
|
|
105
113
|
if (collection === 'components' && doc['kind'] !== 'email') keys['kind'] = 'site';
|
|
114
|
+
// Stored, not omitted: the Description header orders by it (AGL-3680).
|
|
115
|
+
if ((collection === 'layouts' || collection === 'components') && doc['description'] == null) {
|
|
116
|
+
keys['description'] = null;
|
|
117
|
+
}
|
|
106
118
|
if (collection === 'templates') {
|
|
107
119
|
if (!TEMPLATE_KINDS.includes(String(doc['kind']))) keys['kind'] = 'page';
|
|
108
120
|
// Provenance is server-managed (AGL-666), and a writer that states it is
|