@aglyn/aglyn 1.0.0-beta.285 → 1.0.0-beta.288
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 +14 -0
- package/src/lib/app-utils/account-acquisition.js +19 -0
- package/src/lib/app-utils/account-acquisition.js.map +1 -1
- package/src/lib/app-utils/api-plugins.d.ts +25 -0
- package/src/lib/app-utils/api-plugins.js +13 -0
- package/src/lib/app-utils/api-plugins.js.map +1 -1
- package/src/lib/app-utils/business-profile-values.d.ts +15 -3
- package/src/lib/app-utils/business-profile-values.js +19 -3
- package/src/lib/app-utils/business-profile-values.js.map +1 -1
- package/src/lib/app-utils/business-profile.d.ts +5 -3
- package/src/lib/app-utils/business-profile.js +2 -1
- package/src/lib/app-utils/business-profile.js.map +1 -1
- package/src/lib/app-utils/contacts.d.ts +11 -0
- package/src/lib/app-utils/contacts.js +18 -0
- package/src/lib/app-utils/contacts.js.map +1 -1
- package/src/lib/app-utils/docs-deflection.d.ts +1 -1
- package/src/lib/app-utils/docs-deflection.js +17 -0
- package/src/lib/app-utils/docs-deflection.js.map +1 -1
- package/src/lib/app-utils/docs-help-sections.generated.js +5 -2
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +9 -9
- package/src/lib/app-utils/docs-help.generated.js +20 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +168 -63
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/docs-retrieval.d.ts +17 -0
- package/src/lib/app-utils/docs-retrieval.js +28 -0
- package/src/lib/app-utils/docs-retrieval.js.map +1 -1
- package/src/lib/app-utils/host-naming.d.ts +2 -1
- package/src/lib/app-utils/host-naming.js +65 -2
- package/src/lib/app-utils/host-naming.js.map +1 -1
- package/src/lib/app-utils/host-permissions.d.ts +159 -3
- package/src/lib/app-utils/host-permissions.js +384 -12
- package/src/lib/app-utils/host-permissions.js.map +1 -1
- package/src/lib/app-utils/org-permission-editor.d.ts +128 -1
- package/src/lib/app-utils/org-permission-editor.js +176 -1
- package/src/lib/app-utils/org-permission-editor.js.map +1 -1
- package/src/lib/app-utils/org-permissions.d.ts +62 -1
- package/src/lib/app-utils/org-permissions.js +55 -5
- package/src/lib/app-utils/org-permissions.js.map +1 -1
- package/src/lib/app-utils/org-role-grants.d.ts +28 -0
- package/src/lib/app-utils/org-role-grants.js +180 -2
- package/src/lib/app-utils/org-role-grants.js.map +1 -1
- package/src/lib/app-utils/org-roles.js +11 -2
- package/src/lib/app-utils/org-roles.js.map +1 -1
- package/src/lib/app-utils/redirect-query.d.ts +39 -0
- package/src/lib/app-utils/redirect-query.js +57 -0
- package/src/lib/app-utils/redirect-query.js.map +1 -0
- package/src/lib/app-utils/release-flags.js +2 -2
- package/src/lib/app-utils/release-flags.js.map +1 -1
- package/src/lib/app-utils/site-starter-links.d.ts +42 -0
- package/src/lib/app-utils/site-starter-links.js +79 -0
- package/src/lib/app-utils/site-starter-links.js.map +1 -0
- package/src/lib/app-utils/site-starter-nodes.d.ts +91 -0
- package/src/lib/app-utils/site-starter-nodes.js +210 -0
- package/src/lib/app-utils/site-starter-nodes.js.map +1 -0
- package/src/lib/app-utils/site-starter-theme.d.ts +90 -0
- package/src/lib/app-utils/site-starter-theme.js +241 -0
- package/src/lib/app-utils/site-starter-theme.js.map +1 -0
- package/src/lib/app-utils/webhook-delivery.js +6 -1
- package/src/lib/app-utils/webhook-delivery.js.map +1 -1
- package/src/lib/foundation/definitions/organization.types.d.ts +12 -2
- package/src/lib/foundation/definitions/organization.types.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +39 -0
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-closure-notices.d.ts +88 -0
- package/src/lib/plugin-manager/plugin-closure-notices.js +123 -0
- package/src/lib/plugin-manager/plugin-closure-notices.js.map +1 -0
- package/src/lib/plugin-manager/plugin-contact-capture.d.ts +15 -0
- package/src/lib/plugin-manager/plugin-contact-capture.js +24 -1
- package/src/lib/plugin-manager/plugin-contact-capture.js.map +1 -1
- package/src/lib/plugin-manager/plugin-customer-display-verifier.d.ts +77 -0
- package/src/lib/plugin-manager/plugin-customer-display-verifier.js +74 -0
- package/src/lib/plugin-manager/plugin-customer-display-verifier.js.map +1 -0
- package/src/lib/plugin-manager/plugin-customer-display.d.ts +135 -0
- package/src/lib/plugin-manager/plugin-customer-display.js +189 -0
- package/src/lib/plugin-manager/plugin-customer-display.js.map +1 -0
- package/src/lib/plugin-manager/plugin-entitlements.d.ts +59 -0
- package/src/lib/plugin-manager/plugin-entitlements.js +32 -3
- package/src/lib/plugin-manager/plugin-entitlements.js.map +1 -1
- package/src/lib/plugin-manager/plugin-events.d.ts +45 -0
- package/src/lib/plugin-manager/plugin-events.js.map +1 -1
- package/src/lib/plugin-manager/plugin-permissions.d.ts +74 -0
- package/src/lib/plugin-manager/plugin-permissions.js +91 -1
- package/src/lib/plugin-manager/plugin-permissions.js.map +1 -1
- package/src/lib/plugin-manager/plugin-site-starters.d.ts +7 -0
- package/src/lib/plugin-manager/plugin-site-starters.js.map +1 -1
- package/src/lib/plugin-manager/plugin-tax-return-sources.d.ts +87 -4
- package/src/lib/plugin-manager/plugin-tax-return-sources.js +155 -35
- package/src/lib/plugin-manager/plugin-tax-return-sources.js.map +1 -1
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.288",
|
|
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.288",
|
|
41
|
+
"@aglyn/shared-data-mdi": "1.0.0-beta.288",
|
|
42
|
+
"@aglyn/shared-data-types": "1.0.0-beta.288",
|
|
43
|
+
"@aglyn/shared-util-email": "1.0.0-beta.288",
|
|
44
|
+
"@aglyn/shared-util-first-touch": "1.0.0-beta.288",
|
|
45
|
+
"@aglyn/shared-util-http": "1.0.0-beta.288",
|
|
46
|
+
"@aglyn/shared-util-logger": "1.0.0-beta.288",
|
|
47
|
+
"@aglyn/shared-util-timestamp": "1.0.0-beta.288",
|
|
48
|
+
"@aglyn/shared-util-tools": "1.0.0-beta.288",
|
|
49
|
+
"@aglyn/shared-util-vendor": "1.0.0-beta.288",
|
|
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",
|
|
@@ -64,6 +64,20 @@ export type SignUpAcquisitionDoor = Extract<AcquisitionDoor, 'signup-password' |
|
|
|
64
64
|
* a Google one.
|
|
65
65
|
*/
|
|
66
66
|
export declare function signUpDoorFor(provider: string | null | undefined, hint?: unknown): SignUpAcquisitionDoor | null;
|
|
67
|
+
/**
|
|
68
|
+
* The provider a sign-up came through, when the token in hand is not the
|
|
69
|
+
* one the sign-up minted (AGL-3706).
|
|
70
|
+
*
|
|
71
|
+
* The console restores a session on another `.aglyn.com` host by minting a
|
|
72
|
+
* custom token, so every request after that hop carries `custom` as its
|
|
73
|
+
* `sign_in_provider`. A sign-up whose own page was torn down before it
|
|
74
|
+
* recorded anything reaches its workspace backstop on the far side of that
|
|
75
|
+
* hop, where the token can no longer name a door. The auth record still can:
|
|
76
|
+
* a brand-new account holds the one provider it was created with. Only a
|
|
77
|
+
* `custom` token defers to it, and only when the record names exactly one
|
|
78
|
+
* sign-up provider; every other token decides on its own, as before.
|
|
79
|
+
*/
|
|
80
|
+
export declare function signUpProviderFor(tokenProvider: string | null | undefined, recordProviderIds: readonly string[]): string | null;
|
|
67
81
|
/** What wrote a record: a door at creation, an organization copying its creator's, or the backfill. */
|
|
68
82
|
export type AcquisitionRecordedBy = 'signup' | 'sso' | 'org-creation' | 'backfill';
|
|
69
83
|
/** Where the account-creating request came from, as the edge reported it. */
|
|
@@ -69,6 +69,25 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
69
69
|
}
|
|
70
70
|
return null;
|
|
71
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* The provider a sign-up came through, when the token in hand is not the
|
|
74
|
+
* one the sign-up minted (AGL-3706).
|
|
75
|
+
*
|
|
76
|
+
* The console restores a session on another `.aglyn.com` host by minting a
|
|
77
|
+
* custom token, so every request after that hop carries `custom` as its
|
|
78
|
+
* `sign_in_provider`. A sign-up whose own page was torn down before it
|
|
79
|
+
* recorded anything reaches its workspace backstop on the far side of that
|
|
80
|
+
* hop, where the token can no longer name a door. The auth record still can:
|
|
81
|
+
* a brand-new account holds the one provider it was created with. Only a
|
|
82
|
+
* `custom` token defers to it, and only when the record names exactly one
|
|
83
|
+
* sign-up provider; every other token decides on its own, as before.
|
|
84
|
+
*/ export function signUpProviderFor(tokenProvider, recordProviderIds) {
|
|
85
|
+
if (tokenProvider !== 'custom') return tokenProvider != null ? tokenProvider : null;
|
|
86
|
+
const doors = [
|
|
87
|
+
...new Set(recordProviderIds)
|
|
88
|
+
].filter((id)=>signUpDoorFor(id) !== null);
|
|
89
|
+
return doors.length === 1 ? doors[0] : tokenProvider;
|
|
90
|
+
}
|
|
72
91
|
const MAX_LABEL = 100;
|
|
73
92
|
const PROVIDER_SHAPE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
74
93
|
const RECORDED_BY = [
|
|
@@ -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 — 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\n/** A UTC instant as a staff reader writes it: \"Oct 16, 2026, 14:03 UTC\". */\nfunction acquisitionTime(ms: number | null): string | null {\n if (!ms || !Number.isFinite(ms)) return null\n return `${new Date(ms).toLocaleString('en-US', {\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n hour: '2-digit',\n minute: '2-digit',\n hour12: false,\n timeZone: 'UTC',\n })} UTC`\n}\n\n/**\n * The record as rows a staff email or card lays out label beside value\n * (AGL-3897): how the person found the platform, where they landed, what\n * the visit carried, how and where they signed up. A row the record has no\n * value for is left out, so an account with nothing captured reads as one\n * honest row rather than a column of blanks.\n */\nexport function accountAcquisitionDetails(\n record: AccountAcquisition | null | undefined,\n options: { primaryHost?: string | null } = {},\n): Array<{ label: string; value: string }> {\n if (!record) return [{ label: 'How they found us', value: 'No acquisition record' }]\n const rows: Array<{ label: string; value: string | null }> = [\n { label: 'How they found us', value: channelPhrase(record) },\n {\n label: 'Landing page',\n value: record.landing\n ? options.primaryHost && displayHost(record.landing.host) === displayHost(options.primaryHost)\n ? record.landing.path\n : `${displayHost(record.landing.host)}${record.landing.path}`\n : null,\n },\n { label: 'Referrer', value: record.referrerHost ? displayHost(record.referrerHost) : null },\n {\n label: 'Source / medium',\n value: record.source && record.source !== 'unknown' ? [record.source, record.medium].filter(Boolean).join(' / ') : null,\n },\n { label: 'Campaign', value: record.campaign },\n { label: 'Google Ads campaign', value: record.adCampaignId },\n {\n label: 'Ad click',\n value: record.clickIds.length ? record.clickIds.join(', ') : null,\n },\n {\n label: 'UTM content / term',\n value: [record.utm?.content, record.utm?.term].filter(Boolean).join(' / ') || null,\n },\n { label: 'First visit', value: acquisitionTime(record.capturedAt) },\n { label: 'Signed up', value: doorPhrase(record).replace(/^\\w/, (first) => first.toUpperCase()) },\n {\n label: 'From',\n value: record.geo ? [record.geo.city, record.geo.region, record.geo.country].filter(Boolean).join(', ') || null : null,\n },\n ]\n return rows.filter((row): row is { label: string; value: string } => Boolean(row.value))\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","acquisitionTime","ms","Date","toLocaleString","month","day","year","hour","minute","hour12","timeZone","accountAcquisitionDetails","rows","Boolean","content","term","first","row"],"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/**\n * The provider a sign-up came through, when the token in hand is not the\n * one the sign-up minted (AGL-3706).\n *\n * The console restores a session on another `.aglyn.com` host by minting a\n * custom token, so every request after that hop carries `custom` as its\n * `sign_in_provider`. A sign-up whose own page was torn down before it\n * recorded anything reaches its workspace backstop on the far side of that\n * hop, where the token can no longer name a door. The auth record still can:\n * a brand-new account holds the one provider it was created with. Only a\n * `custom` token defers to it, and only when the record names exactly one\n * sign-up provider; every other token decides on its own, as before.\n */\nexport function signUpProviderFor(\n tokenProvider: string | null | undefined,\n recordProviderIds: readonly string[],\n): string | null {\n if (tokenProvider !== 'custom') return tokenProvider ?? null\n const doors = [...new Set(recordProviderIds)].filter(\n (id) => signUpDoorFor(id) !== null,\n )\n return doors.length === 1 ? doors[0] : tokenProvider\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\n/** A UTC instant as a staff reader writes it: \"Oct 16, 2026, 14:03 UTC\". */\nfunction acquisitionTime(ms: number | null): string | null {\n if (!ms || !Number.isFinite(ms)) return null\n return `${new Date(ms).toLocaleString('en-US', {\n month: 'short',\n day: 'numeric',\n year: 'numeric',\n hour: '2-digit',\n minute: '2-digit',\n hour12: false,\n timeZone: 'UTC',\n })} UTC`\n}\n\n/**\n * The record as rows a staff email or card lays out label beside value\n * (AGL-3897): how the person found the platform, where they landed, what\n * the visit carried, how and where they signed up. A row the record has no\n * value for is left out, so an account with nothing captured reads as one\n * honest row rather than a column of blanks.\n */\nexport function accountAcquisitionDetails(\n record: AccountAcquisition | null | undefined,\n options: { primaryHost?: string | null } = {},\n): Array<{ label: string; value: string }> {\n if (!record) return [{ label: 'How they found us', value: 'No acquisition record' }]\n const rows: Array<{ label: string; value: string | null }> = [\n { label: 'How they found us', value: channelPhrase(record) },\n {\n label: 'Landing page',\n value: record.landing\n ? options.primaryHost && displayHost(record.landing.host) === displayHost(options.primaryHost)\n ? record.landing.path\n : `${displayHost(record.landing.host)}${record.landing.path}`\n : null,\n },\n { label: 'Referrer', value: record.referrerHost ? displayHost(record.referrerHost) : null },\n {\n label: 'Source / medium',\n value: record.source && record.source !== 'unknown' ? [record.source, record.medium].filter(Boolean).join(' / ') : null,\n },\n { label: 'Campaign', value: record.campaign },\n { label: 'Google Ads campaign', value: record.adCampaignId },\n {\n label: 'Ad click',\n value: record.clickIds.length ? record.clickIds.join(', ') : null,\n },\n {\n label: 'UTM content / term',\n value: [record.utm?.content, record.utm?.term].filter(Boolean).join(' / ') || null,\n },\n { label: 'First visit', value: acquisitionTime(record.capturedAt) },\n { label: 'Signed up', value: doorPhrase(record).replace(/^\\w/, (first) => first.toUpperCase()) },\n {\n label: 'From',\n value: record.geo ? [record.geo.city, record.geo.region, record.geo.country].filter(Boolean).join(', ') || null : null,\n },\n ]\n return rows.filter((row): row is { label: string; value: string } => Boolean(row.value))\n}\n"],"names":["classifyChannel","displayHost","sanitizeFirstTouch","sourceAndMedium","ACCOUNT_ACQUISITION_FIELD","ACQUISITION_DOORS","signUpDoorFor","provider","hint","signUpProviderFor","tokenProvider","recordProviderIds","doors","Set","filter","id","length","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","Object","keys","copiedFrom","googleAdsCampaignUrl","campaignId","encodeURIComponent","providerLabel","startsWith","channelPhrase","from","named","doorPhrase","describeAccountAcquisition","options","parts","onPrimary","primaryHost","push","join","acquisitionTime","ms","Date","toLocaleString","month","day","year","hour","minute","hour12","timeZone","accountAcquisitionDetails","rows","Boolean","content","term","first","row"],"mappings":"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"}
|
|
@@ -208,6 +208,20 @@ export interface PluginApiRouteOptions {
|
|
|
208
208
|
* not read it.
|
|
209
209
|
*/
|
|
210
210
|
ownVisitorGates?: boolean;
|
|
211
|
+
/**
|
|
212
|
+
* The route draws images with the platform's image library (`sharp`),
|
|
213
|
+
* whose native library is too large to ship with every plugin door
|
|
214
|
+
* (AGL-4048). The console serves it from a function of its own,
|
|
215
|
+
* `/api/{PLUGIN_IMAGES_API_PREFIX}/{path}`, the only one that ships that
|
|
216
|
+
* library, and its plugin dispatcher answers 404 for it, so it never runs
|
|
217
|
+
* where it cannot draw.
|
|
218
|
+
*
|
|
219
|
+
* Honored only beside {@link machine}: that function runs none of the
|
|
220
|
+
* dispatcher's member gates, so its route is a machine's door that
|
|
221
|
+
* verifies its own caller (our own servers, signed), and a plugin's door
|
|
222
|
+
* that draws for a member asks it rather than drawing itself.
|
|
223
|
+
*/
|
|
224
|
+
images?: boolean;
|
|
211
225
|
}
|
|
212
226
|
export { setRegisteringPluginId, getRegisteringPluginId, } from './registering-plugin';
|
|
213
227
|
/**
|
|
@@ -258,6 +272,17 @@ export declare function isPluginRecipientLinkRoute(path: string): boolean;
|
|
|
258
272
|
* they count (AGL-3080).
|
|
259
273
|
*/
|
|
260
274
|
export declare function isPluginMachineRoute(path: string): boolean;
|
|
275
|
+
/**
|
|
276
|
+
* Where the console serves the plugin routes that draw images
|
|
277
|
+
* ({@link PluginApiRouteOptions.images}): `/api/plugin-images/{path}`.
|
|
278
|
+
*/
|
|
279
|
+
export declare const PLUGIN_IMAGES_API_PREFIX = "plugin-images";
|
|
280
|
+
/**
|
|
281
|
+
* Whether a request path resolves to a route registered to draw images: an
|
|
282
|
+
* {@link PluginApiRouteOptions.images} machine's door (AGL-4048), which only
|
|
283
|
+
* the console's images function serves.
|
|
284
|
+
*/
|
|
285
|
+
export declare function isPluginImagesRoute(path: string): boolean;
|
|
261
286
|
/**
|
|
262
287
|
* Whether a request path resolves to a route registered as a
|
|
263
288
|
* {@link PluginApiRouteOptions.portability} export — what both dispatchers
|
|
@@ -181,6 +181,19 @@ export function unregisterPluginApiRoute(path) {
|
|
|
181
181
|
const matched = matchRegisteredApiKey(path);
|
|
182
182
|
return matched ? ((_apiRouteOptions_get = apiRouteOptions.get(matched.key)) == null ? void 0 : _apiRouteOptions_get.machine) === true : false;
|
|
183
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Where the console serves the plugin routes that draw images
|
|
186
|
+
* ({@link PluginApiRouteOptions.images}): `/api/plugin-images/{path}`.
|
|
187
|
+
*/ export const PLUGIN_IMAGES_API_PREFIX = 'plugin-images';
|
|
188
|
+
/**
|
|
189
|
+
* Whether a request path resolves to a route registered to draw images: an
|
|
190
|
+
* {@link PluginApiRouteOptions.images} machine's door (AGL-4048), which only
|
|
191
|
+
* the console's images function serves.
|
|
192
|
+
*/ export function isPluginImagesRoute(path) {
|
|
193
|
+
const matched = matchRegisteredApiKey(path);
|
|
194
|
+
const options = matched ? apiRouteOptions.get(matched.key) : undefined;
|
|
195
|
+
return (options == null ? void 0 : options.images) === true && options.machine === true;
|
|
196
|
+
}
|
|
184
197
|
/**
|
|
185
198
|
* Whether a request path resolves to a route registered as a
|
|
186
199
|
* {@link PluginApiRouteOptions.portability} export — what both dispatchers
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/api-plugins.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Server half of the plugin pattern (AGL-396): the API-route registry, the\n * server counterpart to the ConsoleExtension registry. A feature plugin\n * registers request handlers here from its `/server` entry point (never its\n * client barrel, so firebase-admin and other server-only deps stay out of\n * the browser bundle). The app ships one catch-all dispatcher route per\n * Next app that resolves a request path to a registered handler — so moving\n * a feature's API into its plugin needs no new app route and keeps the same\n * URL. Reference implementation: events-calendar `events/list` (AGL-396).\n *\n * The request/response shapes are structural (not `next` types) so plugins\n * stay framework-light; `NextApiRequest`/`NextApiResponse` satisfy them, so\n * the dispatcher passes Next's objects straight through.\n */\n\nexport interface PluginApiRequest {\n method?: string\n query: Partial<Record<string, string | string[]>>\n // `any` mirrors NextApiRequest.body (parsed JSON/form/text); handlers\n // validate it themselves, as they did as Next routes.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n body: any\n /**\n * The unparsed request text, for handlers that verify signatures over the\n * exact payload (Stripe/Svix webhooks registered as plugin API paths).\n * UTF-8 re-encoding of the text is byte-identical for valid UTF-8 JSON.\n */\n rawBody?: string\n headers: Partial<Record<string, string | string[]>>\n cookies: Partial<Record<string, string>>\n /**\n * Node socket — the client-address fallback (`remoteAddress`).\n *\n * On the console's Pages Router this is the real transport peer. Behind the\n * App Router adapter there is no socket, and `api-adapter.ts` fills it from\n * the shared client-address reader so a handler falling back to it gets the\n * same trusted hop rather than a caller-supplied one.\n */\n socket: { remoteAddress?: string }\n}\n\nexport interface PluginApiResponse {\n status(code: number): PluginApiResponse\n json(body: unknown): void\n send(body: unknown): void\n setHeader(name: string, value: string | number | readonly string[]): void\n redirect(url: string): void\n redirect(status: number, url: string): void\n end(): void\n}\n\nexport type PluginApiHandler = (\n req: PluginApiRequest,\n res: PluginApiResponse,\n) => void | Promise<void>\n\n/**\n * A handler in the Web shape (AGL-2939): the dispatcher's own `Request`\n * in, a `Response` out — with a streamed body when the door streams, as\n * the assistant's chat and a job's event feed do. `params` carries the\n * dispatcher's path segments and any `:name` segment the registered path\n * declared.\n */\nexport type PluginWebApiHandler = (\n request: Request,\n context: { params: Record<string, string | string[]> },\n) => Response | Promise<Response>\n\n/** What a route registers: the (req, res) shape, or a Web handler. */\nexport type PluginApiRoute = PluginApiHandler | { web: PluginWebApiHandler }\n\n/** A registered route matched to a request path, with its `:name` segments filled. */\nexport interface PluginApiMatch {\n route: PluginApiRoute\n params: Record<string, string>\n}\n\n/**\n * Who a request is FOR, as far as the dispatchers' release gate is concerned\n * (AGL-2978).\n *\n * The gate buckets a rollout and reads per-org overrides on an org id, and a\n * dispatcher learns that id from the site a request names (`hostId`). Two\n * kinds of request name no site and still belong to one organization:\n *\n * - a request to an ORGANIZATION-level surface (`ConsoleExtension.orgNavItems`,\n * AGL-2974), which names its org rather than a site;\n * - a request that arrives with no bearer token by design — a provider's\n * OAuth redirect back to the platform, which carries only what the platform\n * signed into it.\n *\n * Without a subject the gate treats both as anonymous, so a plugin released to\n * one organization by override refused that organization's own requests, and\n * a staff member previewing a dark plugin could not complete a redirect their\n * own session had started.\n */\nexport interface PluginApiRequestSubject {\n /** The organization: the rollout bucket and the override key. */\n orgId: string | null\n /**\n * The account the request acts for, read ONLY when the request carries no\n * bearer token — a token speaks for itself. A route may name one only when\n * it verified a signature binding the uid to the request, because the gate\n * lets a staff account named here preview a released-off plugin.\n */\n uid?: string | null\n}\n\n/** Reads a request's subject; answers `null` when the request names none. */\nexport type PluginApiSubjectResolver = (\n request: Request,\n) => PluginApiRequestSubject | null | Promise<PluginApiRequestSubject | null>\n\n/** What a registration may say about its route beyond the handler. */\nexport interface PluginApiRouteOptions {\n /**\n * How a request to this route names its subject when it names no site.\n * Consulted by both dispatchers before their release gate, and only for a\n * request with no `hostId`: a named site always decides the org.\n */\n subject?: PluginApiSubjectResolver\n /**\n * The route answers a link the platform MAILED to somebody — an\n * unsubscribe in a message's `List-Unsubscribe` header — rather than a door\n * a member uses (AGL-2981).\n *\n * Such a link has to keep working whether or not its plugin is released,\n * or switched on, for the workspace NOW. A recipient's way out does not\n * close when a rollout is paused or a workspace turns the plugin off:\n * CAN-SPAM holds an opt-out open for thirty days after the send, and an\n * unsubscribe that 404s the day a kill switch is flipped is one nobody can\n * use. So both dispatchers skip their per-site enablement and release\n * gates for it — and nothing else: lockdown and the rate limit still\n * apply. The flag says nothing about who may act, so the route\n * authenticates the link itself, by a signature it verifies.\n */\n recipientLink?: boolean\n /**\n * The route is called by a MACHINE that proves itself with a credential\n * the route verifies — a scheduler's shared secret, a provider's signed\n * webhook — and names no site and no member (AGL-3080).\n *\n * Such a request has no subject the dispatcher could gate on before the\n * route has verified it: a sweep spans every organization, and a webhook's\n * workspace is inside a payload only a verified signature makes worth\n * reading. Gated anonymously, a plugin on a partial rollout would refuse\n * every delivery. So both dispatchers skip their per-site enablement and\n * release gates for it, and — because a chunked sweep posts from one\n * address, and a provider retries in bursts — their write limit and their\n * cross-origin check (`isMachinePluginApiPath`); lockdown still applies.\n * The flag grants nothing by itself: the route authenticates its caller,\n * and judges the plugin's release and plan for each organization it\n * resolves from what it verified.\n */\n machine?: boolean\n /**\n * The route hands a workspace the records a plugin keeps for it — a\n * data-portability export — which the workspace is owed whether or not the\n * plugin is switched on or released for it now (AGL-3080).\n *\n * Exporting the people a workspace holds is an obligation, not a feature:\n * a workspace that switched the plugin off, or a plugin paused by its\n * kill switch, still has to be able to take its data away. So both\n * dispatchers skip their per-site enablement and release gates for it —\n * and nothing else: lockdown, the email-verification gate and the rate\n * limit still apply. The flag grants nothing by itself: the route\n * authenticates the member, and asks the plugin's release and plan itself\n * of anything it serves beyond what the workspace is owed.\n */\n portability?: boolean\n /**\n * The route OPENS A CARD PAYMENT for whoever calls it: a Checkout Session,\n * a PaymentIntent or a SetupIntent (AGL-3363).\n *\n * The tenant dispatcher holds such a route to the card-testing counters\n * (`card-payment-velocity.ts`) on top of its ordinary write limit, so a\n * plugin's payment door is covered by declaring itself here rather than by\n * carrying a limiter of its own. A console door (a signed-in merchant's\n * POS or draft order) does not declare it: a register ringing up a queue\n * is not a card tester. The 3-D Secure sweep\n * (`card-payments-request-3ds.spec.ts`) classifies every file that creates\n * a payment, so a new visitor door cannot skip the declaration.\n */\n cardPayment?: boolean\n /**\n * The route is a public VISITOR door that runs every visitor gate itself,\n * because its answers to them are a published contract the tenant\n * dispatcher's own would change (AGL-3080).\n *\n * The forms plugin's `forms/submit` is the case: one door for every form\n * and for a Marketing popup's capture, judged by the switch of the plugin\n * whose door a submission came through (not the route's owner), answering\n * a site with Forms off with its own \"not accepting\" notice, limiting per\n * site and address and capping a flood per month with its own bodies and\n * `Retry-After`s, and placing lockdown among those where its comments say\n * why. The dispatcher's enablement 404, its write limit, its cross-origin\n * refusal and its lockdown would each answer first, and differently.\n *\n * So the tenant dispatcher hands such a route the request untouched: no\n * cross-origin check, no site read from the body, no per-site enablement or\n * release gate, no lockdown, no write limit, no card velocity. Declaring it\n * takes ALL of those on — a door that skips lockdown is one a paused site\n * keeps open — so it is honored only for a FIRST-PARTY plugin's\n * registration, made under the loader's marker; anyone else's keeps the\n * dispatcher's gates, and the drop is logged. The console's dispatcher does\n * not read it.\n */\n ownVisitorGates?: boolean\n}\n\n/** Leading/trailing slashes stripped so '/events/list' and 'events/list' key alike. */\nfunction normalizeApiPath(path: string): string {\n return path.replace(/^\\/+|\\/+$/g, '')\n}\n\nconst apiRoutes = new Map<string, PluginApiRoute>()\nconst apiRouteOwners = new Map<string, string>()\nconst apiRouteOptions = new Map<string, PluginApiRouteOptions>()\n\n/**\n * The \"currently registering plugin\" marker moved to a leaf module\n * (`registering-plugin`) so `site-page-hooks` can read it too — this file is\n * only reachable through the `/server` entry, and importing it from the shared\n * plugin-manager barrel formed a cycle (AGL-1289). Re-exported here so the\n * plugin loader's existing import keeps working unchanged.\n */\nimport { getRegisteringPluginId } from './registering-plugin'\nimport { isFirstPartyPlugin } from '../plugin-manager/enabled-plugins'\n\nexport {\n setRegisteringPluginId,\n getRegisteringPluginId,\n} from './registering-plugin'\n\n/**\n * Registers a plugin API handler at a host-relative path (e.g.\n * 'events/list'), served by the app dispatcher at `/api/events/list`.\n *\n * Idempotent by path FOR ITS OWNER — re-registration by the plugin that\n * holds the path replaces the previous handler, which is what a hot reload,\n * a second surface, and a repeated init all depend on.\n *\n * A different plugin is REFUSED (AGL-2484). This map is process-global and\n * was last-writer-wins on the path alone, while Aglyn's own commission-taking\n * handlers sit at `marketplace/checkout` and `commerce/checkout` — and the\n * tenant dispatcher calls `ensureRemoteServerBundles()` after the first-party\n * loader has run, so a remote bundle registering either path was simply the\n * later writer. Getting there needs `PLUGIN_REMOTE_SERVER=enabled` (off by\n * default), realm trust and a valid signature, so this is the last of several\n * doors rather than the only one; it is also the one the registry could close\n * by itself, because it already recorded who registered what.\n *\n * Anonymous registration — no loader marker — is an IDENTITY here, not a\n * wildcard: it may replace itself and nothing else may take it.\n */\nexport function registerPluginApiRoute(\n path: string,\n handler: PluginApiRoute,\n options?: PluginApiRouteOptions,\n): void {\n const key = normalizeApiPath(path)\n const owner = getRegisteringPluginId() ?? ANONYMOUS_OWNER\n const incumbent = apiRouteOwners.get(key)\n if (incumbent !== undefined && incumbent !== owner) {\n // Loud, because the silent version of this is a payment route quietly\n // answering from someone else's code. Refusing is the safe direction:\n // the incumbent keeps serving.\n console.error(\n `[plugins] refused API route \"${key}\" to \"${owner}\": already ` +\n `registered by \"${incumbent}\"`,\n )\n return\n }\n apiRoutes.set(key, handler)\n apiRouteOwners.set(key, owner)\n // Replaced with the handler, never merged: a re-registration that drops its\n // subject resolver must not keep answering with the previous one.\n if (options) apiRouteOptions.set(key, ownGatesAllowed(key, owner, options))\n else apiRouteOptions.delete(key)\n}\n\n/**\n * The options as registered, less `ownVisitorGates` where the registration\n * may not take the visitor gates on itself: only a first-party plugin's,\n * under the loader's marker (AGL-3080).\n */\nfunction ownGatesAllowed(\n key: string,\n owner: string,\n options: PluginApiRouteOptions,\n): PluginApiRouteOptions {\n if (!options.ownVisitorGates || (owner !== ANONYMOUS_OWNER && isFirstPartyPlugin(owner))) {\n return options\n }\n console.error(\n `[plugins] API route \"${key}\" keeps the dispatcher's visitor gates: only a ` +\n `first-party plugin may run its own, and \"${owner || 'an anonymous registration'}\" is not one`,\n )\n return { ...options, ownVisitorGates: false }\n}\n\n/** The marker for a registration made outside any loader context. */\nconst ANONYMOUS_OWNER = ''\n\n/** The plugin that registered a path, for per-org enablement gating. */\nexport function pluginIdForRegisteredApiPath(path: string): string | undefined {\n const owner = apiRouteOwners.get(normalizeApiPath(path))\n // An anonymous registration has no plugin to gate on, and this has always\n // answered `undefined` for one — the sentinel must not leak out as an id.\n return owner === undefined || owner === ANONYMOUS_OWNER ? undefined : owner\n}\n\nexport function unregisterPluginApiRoute(path: string): void {\n const key = normalizeApiPath(path)\n apiRoutes.delete(key)\n // Release the CLAIM too, or the refusal above outlives the route and the\n // path can never be re-registered for the life of the process.\n apiRouteOwners.delete(key)\n apiRouteOptions.delete(key)\n}\n\n/**\n * The registered key a request path resolves to, with its `:name` segments.\n *\n * An exact registration wins; otherwise a registration whose path carries\n * `:name` segments matches segment for segment. One matcher for the route and\n * for its options, so a request can never be answered by one registration and\n * gated with another's subject.\n */\nfunction matchRegisteredApiKey(\n path: string,\n): { key: string; params: Record<string, string> } | undefined {\n const key = normalizeApiPath(path)\n if (apiRoutes.has(key)) return { key, params: {} }\n const segments = key.split('/')\n for (const registered of apiRoutes.keys()) {\n if (!registered.includes(':')) continue\n const pattern = registered.split('/')\n if (pattern.length !== segments.length) continue\n const params: Record<string, string> = {}\n let matched = true\n for (let index = 0; index < pattern.length; index += 1) {\n const part = pattern[index]\n if (part.startsWith(':') && segments[index]) {\n params[part.slice(1)] = segments[index]\n } else if (part !== segments[index]) {\n matched = false\n break\n }\n }\n if (matched) return { key: registered, params }\n }\n return undefined\n}\n\n/**\n * The route for a request path, or undefined when nothing is registered.\n *\n * An exact registration wins; otherwise a registration whose path carries\n * `:name` segments matches segment for segment (`ai/jobs/:jobId/cancel`\n * answers `ai/jobs/abc/cancel`), and the named segments come back as\n * `params`. Patterns are tried in registration order and a static segment\n * beats a named one only by being registered exactly.\n */\nexport function resolvePluginApiMatch(path: string): PluginApiMatch | undefined {\n const matched = matchRegisteredApiKey(path)\n if (!matched) return undefined\n const route = apiRoutes.get(matched.key)\n return route ? { route, params: matched.params } : undefined\n}\n\n/**\n * Whether a request path resolves to a route registered as a\n * {@link PluginApiRouteOptions.recipientLink} — what both dispatchers ask\n * before their enablement and release gates (AGL-2981).\n */\nexport function isPluginRecipientLinkRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.recipientLink === true : false\n}\n\n/**\n * Whether a request path resolves to a route registered as a\n * {@link PluginApiRouteOptions.machine} caller's — what both dispatchers ask\n * before their enablement and release gates, and the write limits before\n * they count (AGL-3080).\n */\nexport function isPluginMachineRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.machine === true : false\n}\n\n/**\n * Whether a request path resolves to a route registered as a\n * {@link PluginApiRouteOptions.portability} export — what both dispatchers\n * ask before their enablement and release gates (AGL-3080).\n */\nexport function isPluginPortabilityRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.portability === true : false\n}\n\n/**\n * Whether a request path resolves to a route that keeps its own visitor\n * gates — {@link PluginApiRouteOptions.ownVisitorGates} — what the tenant\n * dispatcher asks before any gate of its own.\n */\nexport function isPluginOwnVisitorGatesRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.ownVisitorGates === true : false\n}\n\n/**\n * Whether a request path resolves to a route registered with\n * {@link PluginApiRouteOptions.cardPayment} — what the tenant dispatcher\n * asks before its card-testing counters (AGL-3363).\n */\nexport function isPluginCardPaymentRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.cardPayment === true : false\n}\n\n/** An id a subject may carry: a non-empty path segment, not a path. */\nconst SUBJECT_ID = /^[^/\\s]{1,128}$/\n\nconst subjectId = (value: unknown): string | null =>\n typeof value === 'string' && SUBJECT_ID.test(value) ? value : null\n\n/**\n * The subject a request to a registered route names, or `null` (AGL-2978).\n *\n * `null` when the route declared no resolver, when the resolver answered\n * nothing, and when it THREW: a resolver that cannot read its request has\n * named no subject, and the gate then treats the request as anonymous — the\n * conservative direction. The resolver reads a clone, so the handler still\n * receives the untouched body. Ids are held to a plain path segment, so a\n * resolver cannot hand the gate a path to read overrides from.\n */\nexport async function resolvePluginApiRequestSubject(\n path: string,\n request: Request,\n): Promise<PluginApiRequestSubject | null> {\n const matched = matchRegisteredApiKey(path)\n const resolve = matched ? apiRouteOptions.get(matched.key)?.subject : undefined\n if (!resolve) return null\n try {\n const subject = await resolve(request.clone())\n if (!subject) return null\n const orgId = subjectId(subject.orgId)\n const uid = subjectId(subject.uid)\n if (!orgId && !uid) return null\n return { orgId, ...(uid ? { uid } : {}) }\n } catch {\n return null\n }\n}\n\n/**\n * The legacy (node-style) handler at a request path, or undefined when\n * nothing is registered there or the route is a web handler — a web route\n * is reached through `resolvePluginApiMatch` and `runPluginApiMatch`, since\n * it takes the `Request` and the filled `:name` segments, not a `req`/`res`\n * pair.\n */\nexport function resolvePluginApiRoute(path: string): PluginApiHandler | undefined {\n const route = resolvePluginApiMatch(path)?.route\n return typeof route === 'function' ? route : undefined\n}\n\n/**\n * Runs a matched route against the dispatcher's request: a Web handler is\n * called as it is, and a (req, res) handler goes through the adapter the\n * caller supplies — the adapter lives on the `/server` entry, which this\n * module may not import.\n */\nexport async function runPluginApiMatch(\n match: PluginApiMatch,\n request: Request,\n params: Record<string, string | string[]>,\n runLegacy: (\n handler: PluginApiHandler,\n request: Request,\n params: Record<string, string | string[]>,\n ) => Promise<Response>,\n): Promise<Response> {\n const merged = { ...params, ...match.params }\n const route = match.route as { web?: PluginWebApiHandler }\n if (typeof route === 'object' && route !== null && typeof route.web === 'function') {\n return route.web(request, { params: merged })\n }\n return runLegacy(match.route as PluginApiHandler, request, merged)\n}\n\n/** Registered paths, for diagnostics. */\nexport function listPluginApiRoutes(): string[] {\n return Array.from(apiRoutes.keys())\n}\n"],"names":["normalizeApiPath","path","replace","apiRoutes","Map","apiRouteOwners","apiRouteOptions","getRegisteringPluginId","isFirstPartyPlugin","setRegisteringPluginId","registerPluginApiRoute","handler","options","key","owner","ANONYMOUS_OWNER","incumbent","get","undefined","console","error","set","ownGatesAllowed","delete","ownVisitorGates","pluginIdForRegisteredApiPath","unregisterPluginApiRoute","matchRegisteredApiKey","has","params","segments","split","registered","keys","includes","pattern","length","matched","index","part","startsWith","slice","resolvePluginApiMatch","route","isPluginRecipientLinkRoute","recipientLink","isPluginMachineRoute","machine","isPluginPortabilityRoute","portability","isPluginOwnVisitorGatesRoute","isPluginCardPaymentRoute","cardPayment","SUBJECT_ID","subjectId","value","test","resolvePluginApiRequestSubject","request","resolve","subject","clone","orgId","uid","resolvePluginApiRoute","runPluginApiMatch","match","runLegacy","merged","web","listPluginApiRoutes","Array","from"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/api-plugins.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * Server half of the plugin pattern (AGL-396): the API-route registry, the\n * server counterpart to the ConsoleExtension registry. A feature plugin\n * registers request handlers here from its `/server` entry point (never its\n * client barrel, so firebase-admin and other server-only deps stay out of\n * the browser bundle). The app ships one catch-all dispatcher route per\n * Next app that resolves a request path to a registered handler — so moving\n * a feature's API into its plugin needs no new app route and keeps the same\n * URL. Reference implementation: events-calendar `events/list` (AGL-396).\n *\n * The request/response shapes are structural (not `next` types) so plugins\n * stay framework-light; `NextApiRequest`/`NextApiResponse` satisfy them, so\n * the dispatcher passes Next's objects straight through.\n */\n\nexport interface PluginApiRequest {\n method?: string\n query: Partial<Record<string, string | string[]>>\n // `any` mirrors NextApiRequest.body (parsed JSON/form/text); handlers\n // validate it themselves, as they did as Next routes.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n body: any\n /**\n * The unparsed request text, for handlers that verify signatures over the\n * exact payload (Stripe/Svix webhooks registered as plugin API paths).\n * UTF-8 re-encoding of the text is byte-identical for valid UTF-8 JSON.\n */\n rawBody?: string\n headers: Partial<Record<string, string | string[]>>\n cookies: Partial<Record<string, string>>\n /**\n * Node socket — the client-address fallback (`remoteAddress`).\n *\n * On the console's Pages Router this is the real transport peer. Behind the\n * App Router adapter there is no socket, and `api-adapter.ts` fills it from\n * the shared client-address reader so a handler falling back to it gets the\n * same trusted hop rather than a caller-supplied one.\n */\n socket: { remoteAddress?: string }\n}\n\nexport interface PluginApiResponse {\n status(code: number): PluginApiResponse\n json(body: unknown): void\n send(body: unknown): void\n setHeader(name: string, value: string | number | readonly string[]): void\n redirect(url: string): void\n redirect(status: number, url: string): void\n end(): void\n}\n\nexport type PluginApiHandler = (\n req: PluginApiRequest,\n res: PluginApiResponse,\n) => void | Promise<void>\n\n/**\n * A handler in the Web shape (AGL-2939): the dispatcher's own `Request`\n * in, a `Response` out — with a streamed body when the door streams, as\n * the assistant's chat and a job's event feed do. `params` carries the\n * dispatcher's path segments and any `:name` segment the registered path\n * declared.\n */\nexport type PluginWebApiHandler = (\n request: Request,\n context: { params: Record<string, string | string[]> },\n) => Response | Promise<Response>\n\n/** What a route registers: the (req, res) shape, or a Web handler. */\nexport type PluginApiRoute = PluginApiHandler | { web: PluginWebApiHandler }\n\n/** A registered route matched to a request path, with its `:name` segments filled. */\nexport interface PluginApiMatch {\n route: PluginApiRoute\n params: Record<string, string>\n}\n\n/**\n * Who a request is FOR, as far as the dispatchers' release gate is concerned\n * (AGL-2978).\n *\n * The gate buckets a rollout and reads per-org overrides on an org id, and a\n * dispatcher learns that id from the site a request names (`hostId`). Two\n * kinds of request name no site and still belong to one organization:\n *\n * - a request to an ORGANIZATION-level surface (`ConsoleExtension.orgNavItems`,\n * AGL-2974), which names its org rather than a site;\n * - a request that arrives with no bearer token by design — a provider's\n * OAuth redirect back to the platform, which carries only what the platform\n * signed into it.\n *\n * Without a subject the gate treats both as anonymous, so a plugin released to\n * one organization by override refused that organization's own requests, and\n * a staff member previewing a dark plugin could not complete a redirect their\n * own session had started.\n */\nexport interface PluginApiRequestSubject {\n /** The organization: the rollout bucket and the override key. */\n orgId: string | null\n /**\n * The account the request acts for, read ONLY when the request carries no\n * bearer token — a token speaks for itself. A route may name one only when\n * it verified a signature binding the uid to the request, because the gate\n * lets a staff account named here preview a released-off plugin.\n */\n uid?: string | null\n}\n\n/** Reads a request's subject; answers `null` when the request names none. */\nexport type PluginApiSubjectResolver = (\n request: Request,\n) => PluginApiRequestSubject | null | Promise<PluginApiRequestSubject | null>\n\n/** What a registration may say about its route beyond the handler. */\nexport interface PluginApiRouteOptions {\n /**\n * How a request to this route names its subject when it names no site.\n * Consulted by both dispatchers before their release gate, and only for a\n * request with no `hostId`: a named site always decides the org.\n */\n subject?: PluginApiSubjectResolver\n /**\n * The route answers a link the platform MAILED to somebody — an\n * unsubscribe in a message's `List-Unsubscribe` header — rather than a door\n * a member uses (AGL-2981).\n *\n * Such a link has to keep working whether or not its plugin is released,\n * or switched on, for the workspace NOW. A recipient's way out does not\n * close when a rollout is paused or a workspace turns the plugin off:\n * CAN-SPAM holds an opt-out open for thirty days after the send, and an\n * unsubscribe that 404s the day a kill switch is flipped is one nobody can\n * use. So both dispatchers skip their per-site enablement and release\n * gates for it — and nothing else: lockdown and the rate limit still\n * apply. The flag says nothing about who may act, so the route\n * authenticates the link itself, by a signature it verifies.\n */\n recipientLink?: boolean\n /**\n * The route is called by a MACHINE that proves itself with a credential\n * the route verifies — a scheduler's shared secret, a provider's signed\n * webhook — and names no site and no member (AGL-3080).\n *\n * Such a request has no subject the dispatcher could gate on before the\n * route has verified it: a sweep spans every organization, and a webhook's\n * workspace is inside a payload only a verified signature makes worth\n * reading. Gated anonymously, a plugin on a partial rollout would refuse\n * every delivery. So both dispatchers skip their per-site enablement and\n * release gates for it, and — because a chunked sweep posts from one\n * address, and a provider retries in bursts — their write limit and their\n * cross-origin check (`isMachinePluginApiPath`); lockdown still applies.\n * The flag grants nothing by itself: the route authenticates its caller,\n * and judges the plugin's release and plan for each organization it\n * resolves from what it verified.\n */\n machine?: boolean\n /**\n * The route hands a workspace the records a plugin keeps for it — a\n * data-portability export — which the workspace is owed whether or not the\n * plugin is switched on or released for it now (AGL-3080).\n *\n * Exporting the people a workspace holds is an obligation, not a feature:\n * a workspace that switched the plugin off, or a plugin paused by its\n * kill switch, still has to be able to take its data away. So both\n * dispatchers skip their per-site enablement and release gates for it —\n * and nothing else: lockdown, the email-verification gate and the rate\n * limit still apply. The flag grants nothing by itself: the route\n * authenticates the member, and asks the plugin's release and plan itself\n * of anything it serves beyond what the workspace is owed.\n */\n portability?: boolean\n /**\n * The route OPENS A CARD PAYMENT for whoever calls it: a Checkout Session,\n * a PaymentIntent or a SetupIntent (AGL-3363).\n *\n * The tenant dispatcher holds such a route to the card-testing counters\n * (`card-payment-velocity.ts`) on top of its ordinary write limit, so a\n * plugin's payment door is covered by declaring itself here rather than by\n * carrying a limiter of its own. A console door (a signed-in merchant's\n * POS or draft order) does not declare it: a register ringing up a queue\n * is not a card tester. The 3-D Secure sweep\n * (`card-payments-request-3ds.spec.ts`) classifies every file that creates\n * a payment, so a new visitor door cannot skip the declaration.\n */\n cardPayment?: boolean\n /**\n * The route is a public VISITOR door that runs every visitor gate itself,\n * because its answers to them are a published contract the tenant\n * dispatcher's own would change (AGL-3080).\n *\n * The forms plugin's `forms/submit` is the case: one door for every form\n * and for a Marketing popup's capture, judged by the switch of the plugin\n * whose door a submission came through (not the route's owner), answering\n * a site with Forms off with its own \"not accepting\" notice, limiting per\n * site and address and capping a flood per month with its own bodies and\n * `Retry-After`s, and placing lockdown among those where its comments say\n * why. The dispatcher's enablement 404, its write limit, its cross-origin\n * refusal and its lockdown would each answer first, and differently.\n *\n * So the tenant dispatcher hands such a route the request untouched: no\n * cross-origin check, no site read from the body, no per-site enablement or\n * release gate, no lockdown, no write limit, no card velocity. Declaring it\n * takes ALL of those on — a door that skips lockdown is one a paused site\n * keeps open — so it is honored only for a FIRST-PARTY plugin's\n * registration, made under the loader's marker; anyone else's keeps the\n * dispatcher's gates, and the drop is logged. The console's dispatcher does\n * not read it.\n */\n ownVisitorGates?: boolean\n /**\n * The route draws images with the platform's image library (`sharp`),\n * whose native library is too large to ship with every plugin door\n * (AGL-4048). The console serves it from a function of its own,\n * `/api/{PLUGIN_IMAGES_API_PREFIX}/{path}`, the only one that ships that\n * library, and its plugin dispatcher answers 404 for it, so it never runs\n * where it cannot draw.\n *\n * Honored only beside {@link machine}: that function runs none of the\n * dispatcher's member gates, so its route is a machine's door that\n * verifies its own caller (our own servers, signed), and a plugin's door\n * that draws for a member asks it rather than drawing itself.\n */\n images?: boolean\n}\n\n/** Leading/trailing slashes stripped so '/events/list' and 'events/list' key alike. */\nfunction normalizeApiPath(path: string): string {\n return path.replace(/^\\/+|\\/+$/g, '')\n}\n\nconst apiRoutes = new Map<string, PluginApiRoute>()\nconst apiRouteOwners = new Map<string, string>()\nconst apiRouteOptions = new Map<string, PluginApiRouteOptions>()\n\n/**\n * The \"currently registering plugin\" marker moved to a leaf module\n * (`registering-plugin`) so `site-page-hooks` can read it too — this file is\n * only reachable through the `/server` entry, and importing it from the shared\n * plugin-manager barrel formed a cycle (AGL-1289). Re-exported here so the\n * plugin loader's existing import keeps working unchanged.\n */\nimport { getRegisteringPluginId } from './registering-plugin'\nimport { isFirstPartyPlugin } from '../plugin-manager/enabled-plugins'\n\nexport {\n setRegisteringPluginId,\n getRegisteringPluginId,\n} from './registering-plugin'\n\n/**\n * Registers a plugin API handler at a host-relative path (e.g.\n * 'events/list'), served by the app dispatcher at `/api/events/list`.\n *\n * Idempotent by path FOR ITS OWNER — re-registration by the plugin that\n * holds the path replaces the previous handler, which is what a hot reload,\n * a second surface, and a repeated init all depend on.\n *\n * A different plugin is REFUSED (AGL-2484). This map is process-global and\n * was last-writer-wins on the path alone, while Aglyn's own commission-taking\n * handlers sit at `marketplace/checkout` and `commerce/checkout` — and the\n * tenant dispatcher calls `ensureRemoteServerBundles()` after the first-party\n * loader has run, so a remote bundle registering either path was simply the\n * later writer. Getting there needs `PLUGIN_REMOTE_SERVER=enabled` (off by\n * default), realm trust and a valid signature, so this is the last of several\n * doors rather than the only one; it is also the one the registry could close\n * by itself, because it already recorded who registered what.\n *\n * Anonymous registration — no loader marker — is an IDENTITY here, not a\n * wildcard: it may replace itself and nothing else may take it.\n */\nexport function registerPluginApiRoute(\n path: string,\n handler: PluginApiRoute,\n options?: PluginApiRouteOptions,\n): void {\n const key = normalizeApiPath(path)\n const owner = getRegisteringPluginId() ?? ANONYMOUS_OWNER\n const incumbent = apiRouteOwners.get(key)\n if (incumbent !== undefined && incumbent !== owner) {\n // Loud, because the silent version of this is a payment route quietly\n // answering from someone else's code. Refusing is the safe direction:\n // the incumbent keeps serving.\n console.error(\n `[plugins] refused API route \"${key}\" to \"${owner}\": already ` +\n `registered by \"${incumbent}\"`,\n )\n return\n }\n apiRoutes.set(key, handler)\n apiRouteOwners.set(key, owner)\n // Replaced with the handler, never merged: a re-registration that drops its\n // subject resolver must not keep answering with the previous one.\n if (options) apiRouteOptions.set(key, ownGatesAllowed(key, owner, options))\n else apiRouteOptions.delete(key)\n}\n\n/**\n * The options as registered, less `ownVisitorGates` where the registration\n * may not take the visitor gates on itself: only a first-party plugin's,\n * under the loader's marker (AGL-3080).\n */\nfunction ownGatesAllowed(\n key: string,\n owner: string,\n options: PluginApiRouteOptions,\n): PluginApiRouteOptions {\n if (!options.ownVisitorGates || (owner !== ANONYMOUS_OWNER && isFirstPartyPlugin(owner))) {\n return options\n }\n console.error(\n `[plugins] API route \"${key}\" keeps the dispatcher's visitor gates: only a ` +\n `first-party plugin may run its own, and \"${owner || 'an anonymous registration'}\" is not one`,\n )\n return { ...options, ownVisitorGates: false }\n}\n\n/** The marker for a registration made outside any loader context. */\nconst ANONYMOUS_OWNER = ''\n\n/** The plugin that registered a path, for per-org enablement gating. */\nexport function pluginIdForRegisteredApiPath(path: string): string | undefined {\n const owner = apiRouteOwners.get(normalizeApiPath(path))\n // An anonymous registration has no plugin to gate on, and this has always\n // answered `undefined` for one — the sentinel must not leak out as an id.\n return owner === undefined || owner === ANONYMOUS_OWNER ? undefined : owner\n}\n\nexport function unregisterPluginApiRoute(path: string): void {\n const key = normalizeApiPath(path)\n apiRoutes.delete(key)\n // Release the CLAIM too, or the refusal above outlives the route and the\n // path can never be re-registered for the life of the process.\n apiRouteOwners.delete(key)\n apiRouteOptions.delete(key)\n}\n\n/**\n * The registered key a request path resolves to, with its `:name` segments.\n *\n * An exact registration wins; otherwise a registration whose path carries\n * `:name` segments matches segment for segment. One matcher for the route and\n * for its options, so a request can never be answered by one registration and\n * gated with another's subject.\n */\nfunction matchRegisteredApiKey(\n path: string,\n): { key: string; params: Record<string, string> } | undefined {\n const key = normalizeApiPath(path)\n if (apiRoutes.has(key)) return { key, params: {} }\n const segments = key.split('/')\n for (const registered of apiRoutes.keys()) {\n if (!registered.includes(':')) continue\n const pattern = registered.split('/')\n if (pattern.length !== segments.length) continue\n const params: Record<string, string> = {}\n let matched = true\n for (let index = 0; index < pattern.length; index += 1) {\n const part = pattern[index]\n if (part.startsWith(':') && segments[index]) {\n params[part.slice(1)] = segments[index]\n } else if (part !== segments[index]) {\n matched = false\n break\n }\n }\n if (matched) return { key: registered, params }\n }\n return undefined\n}\n\n/**\n * The route for a request path, or undefined when nothing is registered.\n *\n * An exact registration wins; otherwise a registration whose path carries\n * `:name` segments matches segment for segment (`ai/jobs/:jobId/cancel`\n * answers `ai/jobs/abc/cancel`), and the named segments come back as\n * `params`. Patterns are tried in registration order and a static segment\n * beats a named one only by being registered exactly.\n */\nexport function resolvePluginApiMatch(path: string): PluginApiMatch | undefined {\n const matched = matchRegisteredApiKey(path)\n if (!matched) return undefined\n const route = apiRoutes.get(matched.key)\n return route ? { route, params: matched.params } : undefined\n}\n\n/**\n * Whether a request path resolves to a route registered as a\n * {@link PluginApiRouteOptions.recipientLink} — what both dispatchers ask\n * before their enablement and release gates (AGL-2981).\n */\nexport function isPluginRecipientLinkRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.recipientLink === true : false\n}\n\n/**\n * Whether a request path resolves to a route registered as a\n * {@link PluginApiRouteOptions.machine} caller's — what both dispatchers ask\n * before their enablement and release gates, and the write limits before\n * they count (AGL-3080).\n */\nexport function isPluginMachineRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.machine === true : false\n}\n\n/**\n * Where the console serves the plugin routes that draw images\n * ({@link PluginApiRouteOptions.images}): `/api/plugin-images/{path}`.\n */\nexport const PLUGIN_IMAGES_API_PREFIX = 'plugin-images'\n\n/**\n * Whether a request path resolves to a route registered to draw images: an\n * {@link PluginApiRouteOptions.images} machine's door (AGL-4048), which only\n * the console's images function serves.\n */\nexport function isPluginImagesRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n const options = matched ? apiRouteOptions.get(matched.key) : undefined\n return options?.images === true && options.machine === true\n}\n\n/**\n * Whether a request path resolves to a route registered as a\n * {@link PluginApiRouteOptions.portability} export — what both dispatchers\n * ask before their enablement and release gates (AGL-3080).\n */\nexport function isPluginPortabilityRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.portability === true : false\n}\n\n/**\n * Whether a request path resolves to a route that keeps its own visitor\n * gates — {@link PluginApiRouteOptions.ownVisitorGates} — what the tenant\n * dispatcher asks before any gate of its own.\n */\nexport function isPluginOwnVisitorGatesRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.ownVisitorGates === true : false\n}\n\n/**\n * Whether a request path resolves to a route registered with\n * {@link PluginApiRouteOptions.cardPayment} — what the tenant dispatcher\n * asks before its card-testing counters (AGL-3363).\n */\nexport function isPluginCardPaymentRoute(path: string): boolean {\n const matched = matchRegisteredApiKey(path)\n return matched ? apiRouteOptions.get(matched.key)?.cardPayment === true : false\n}\n\n/** An id a subject may carry: a non-empty path segment, not a path. */\nconst SUBJECT_ID = /^[^/\\s]{1,128}$/\n\nconst subjectId = (value: unknown): string | null =>\n typeof value === 'string' && SUBJECT_ID.test(value) ? value : null\n\n/**\n * The subject a request to a registered route names, or `null` (AGL-2978).\n *\n * `null` when the route declared no resolver, when the resolver answered\n * nothing, and when it THREW: a resolver that cannot read its request has\n * named no subject, and the gate then treats the request as anonymous — the\n * conservative direction. The resolver reads a clone, so the handler still\n * receives the untouched body. Ids are held to a plain path segment, so a\n * resolver cannot hand the gate a path to read overrides from.\n */\nexport async function resolvePluginApiRequestSubject(\n path: string,\n request: Request,\n): Promise<PluginApiRequestSubject | null> {\n const matched = matchRegisteredApiKey(path)\n const resolve = matched ? apiRouteOptions.get(matched.key)?.subject : undefined\n if (!resolve) return null\n try {\n const subject = await resolve(request.clone())\n if (!subject) return null\n const orgId = subjectId(subject.orgId)\n const uid = subjectId(subject.uid)\n if (!orgId && !uid) return null\n return { orgId, ...(uid ? { uid } : {}) }\n } catch {\n return null\n }\n}\n\n/**\n * The legacy (node-style) handler at a request path, or undefined when\n * nothing is registered there or the route is a web handler — a web route\n * is reached through `resolvePluginApiMatch` and `runPluginApiMatch`, since\n * it takes the `Request` and the filled `:name` segments, not a `req`/`res`\n * pair.\n */\nexport function resolvePluginApiRoute(path: string): PluginApiHandler | undefined {\n const route = resolvePluginApiMatch(path)?.route\n return typeof route === 'function' ? route : undefined\n}\n\n/**\n * Runs a matched route against the dispatcher's request: a Web handler is\n * called as it is, and a (req, res) handler goes through the adapter the\n * caller supplies — the adapter lives on the `/server` entry, which this\n * module may not import.\n */\nexport async function runPluginApiMatch(\n match: PluginApiMatch,\n request: Request,\n params: Record<string, string | string[]>,\n runLegacy: (\n handler: PluginApiHandler,\n request: Request,\n params: Record<string, string | string[]>,\n ) => Promise<Response>,\n): Promise<Response> {\n const merged = { ...params, ...match.params }\n const route = match.route as { web?: PluginWebApiHandler }\n if (typeof route === 'object' && route !== null && typeof route.web === 'function') {\n return route.web(request, { params: merged })\n }\n return runLegacy(match.route as PluginApiHandler, request, merged)\n}\n\n/** Registered paths, for diagnostics. */\nexport function listPluginApiRoutes(): string[] {\n return Array.from(apiRoutes.keys())\n}\n"],"names":["normalizeApiPath","path","replace","apiRoutes","Map","apiRouteOwners","apiRouteOptions","getRegisteringPluginId","isFirstPartyPlugin","setRegisteringPluginId","registerPluginApiRoute","handler","options","key","owner","ANONYMOUS_OWNER","incumbent","get","undefined","console","error","set","ownGatesAllowed","delete","ownVisitorGates","pluginIdForRegisteredApiPath","unregisterPluginApiRoute","matchRegisteredApiKey","has","params","segments","split","registered","keys","includes","pattern","length","matched","index","part","startsWith","slice","resolvePluginApiMatch","route","isPluginRecipientLinkRoute","recipientLink","isPluginMachineRoute","machine","PLUGIN_IMAGES_API_PREFIX","isPluginImagesRoute","images","isPluginPortabilityRoute","portability","isPluginOwnVisitorGatesRoute","isPluginCardPaymentRoute","cardPayment","SUBJECT_ID","subjectId","value","test","resolvePluginApiRequestSubject","request","resolve","subject","clone","orgId","uid","resolvePluginApiRoute","runPluginApiMatch","match","runLegacy","merged","web","listPluginApiRoutes","Array","from"],"mappings":"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"}
|
|
@@ -38,8 +38,12 @@ export declare const BUSINESS_TONES: readonly ["friendly", "professional", "play
|
|
|
38
38
|
export type BusinessTone = (typeof BUSINESS_TONES)[number];
|
|
39
39
|
/** Each tone as the owner picks it and as a writer is told it. */
|
|
40
40
|
export declare const BUSINESS_TONE_LABELS: Readonly<Record<BusinessTone, string>>;
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* The fields the profile document stores itself. `founded` is the year the
|
|
43
|
+
* business started (AGL-4092): the guided start asks it, and every job that
|
|
44
|
+
* writes about the business reads it here rather than marking a gap for it.
|
|
45
|
+
*/
|
|
46
|
+
export declare const BUSINESS_PROFILE_FIELDS: readonly ["whatYouDo", "services", "serviceArea", "audience", "tone", "toneNotes", "founded"];
|
|
43
47
|
export type BusinessProfileField = (typeof BUSINESS_PROFILE_FIELDS)[number];
|
|
44
48
|
/** Each field's ceiling, in characters; `services` is per item. */
|
|
45
49
|
export declare const BUSINESS_PROFILE_MAX_CHARS: Readonly<Record<Exclude<BusinessProfileField, 'tone'>, number>>;
|
|
@@ -53,16 +57,24 @@ export interface BusinessProfileDoc {
|
|
|
53
57
|
audience?: string;
|
|
54
58
|
tone?: BusinessTone | null;
|
|
55
59
|
toneNotes?: string;
|
|
60
|
+
/** The year the business started, four digits: `2019`. */
|
|
61
|
+
founded?: string;
|
|
56
62
|
/** Who wrote each stored value; a value without one is the owner's. */
|
|
57
63
|
sources?: Partial<Record<BusinessProfileField, BusinessProfileSource>>;
|
|
58
64
|
updatedAt?: unknown;
|
|
59
65
|
updatedBy?: string | null;
|
|
60
66
|
}
|
|
61
67
|
/** The values a write carries, before they are sanitized. */
|
|
62
|
-
export type BusinessProfileValues = Partial<Pick<BusinessProfileDoc, 'whatYouDo' | 'services' | 'serviceArea' | 'audience' | 'tone' | 'toneNotes'>>;
|
|
68
|
+
export type BusinessProfileValues = Partial<Pick<BusinessProfileDoc, 'whatYouDo' | 'services' | 'serviceArea' | 'audience' | 'tone' | 'toneNotes' | 'founded'>>;
|
|
63
69
|
export declare const text: (value: unknown, max: number) => string;
|
|
64
70
|
/** One field's stored value, sanitized; empty is `''`, `[]` or `null`. */
|
|
65
71
|
export declare function fieldValue(doc: BusinessProfileValues | null | undefined, field: BusinessProfileField): unknown;
|
|
72
|
+
/**
|
|
73
|
+
* What is wrong with a year founded as the owner typed it (AGL-4092), or
|
|
74
|
+
* `null` when nothing is: empty, or four digits no later than this year —
|
|
75
|
+
* the guided start's own rule.
|
|
76
|
+
*/
|
|
77
|
+
export declare function businessProfileFoundedProblem(value: unknown, now?: Date): string | null;
|
|
66
78
|
export declare const isEmpty: (value: unknown) => boolean;
|
|
67
79
|
/** The source a stored, non-empty value carries; the owner's when none is recorded. */
|
|
68
80
|
export declare function businessProfileSourceOf(doc: BusinessProfileDoc | null | undefined, field: BusinessProfileField): BusinessProfileSource | null;
|
|
@@ -42,20 +42,26 @@ export const BUSINESS_TONES = [
|
|
|
42
42
|
premium: 'Premium and refined',
|
|
43
43
|
plain: 'Plain and direct'
|
|
44
44
|
};
|
|
45
|
-
/**
|
|
45
|
+
/**
|
|
46
|
+
* The fields the profile document stores itself. `founded` is the year the
|
|
47
|
+
* business started (AGL-4092): the guided start asks it, and every job that
|
|
48
|
+
* writes about the business reads it here rather than marking a gap for it.
|
|
49
|
+
*/ export const BUSINESS_PROFILE_FIELDS = [
|
|
46
50
|
'whatYouDo',
|
|
47
51
|
'services',
|
|
48
52
|
'serviceArea',
|
|
49
53
|
'audience',
|
|
50
54
|
'tone',
|
|
51
|
-
'toneNotes'
|
|
55
|
+
'toneNotes',
|
|
56
|
+
'founded'
|
|
52
57
|
];
|
|
53
58
|
/** Each field's ceiling, in characters; `services` is per item. */ export const BUSINESS_PROFILE_MAX_CHARS = {
|
|
54
59
|
whatYouDo: 200,
|
|
55
60
|
services: 60,
|
|
56
61
|
serviceArea: 120,
|
|
57
62
|
audience: 160,
|
|
58
|
-
toneNotes: 200
|
|
63
|
+
toneNotes: 200,
|
|
64
|
+
/** A year, four digits, as the guided start asks it. */ founded: 4
|
|
59
65
|
};
|
|
60
66
|
/** At most this many services are kept. */ export const BUSINESS_PROFILE_MAX_SERVICES = 12;
|
|
61
67
|
export const text = (value, max)=>typeof value === 'string' ? value.replace(/\s+/g, ' ').trim().slice(0, max) : '';
|
|
@@ -84,6 +90,16 @@ const services = (value)=>{
|
|
|
84
90
|
return text(doc == null ? void 0 : doc[field], BUSINESS_PROFILE_MAX_CHARS[field]);
|
|
85
91
|
}
|
|
86
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* What is wrong with a year founded as the owner typed it (AGL-4092), or
|
|
95
|
+
* `null` when nothing is: empty, or four digits no later than this year —
|
|
96
|
+
* the guided start's own rule.
|
|
97
|
+
*/ export function businessProfileFoundedProblem(value, now = new Date()) {
|
|
98
|
+
const year = typeof value === 'string' ? value.trim() : '';
|
|
99
|
+
if (!year) return null;
|
|
100
|
+
const latest = now.getFullYear();
|
|
101
|
+
return /^\d{4}$/.test(year) && Number(year) >= 1000 && Number(year) <= latest ? null : `Enter the year as four digits, like ${latest - 10}.`;
|
|
102
|
+
}
|
|
87
103
|
export const isEmpty = (value)=>value === null || value === '' || Array.isArray(value) && value.length === 0;
|
|
88
104
|
/** The source a stored, non-empty value carries; the owner's when none is recorded. */ export function businessProfileSourceOf(doc, field) {
|
|
89
105
|
var _doc_sources;
|