@aglyn/tenant-data-admin 1.0.0-beta.176 → 1.0.0-beta.185

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.
Files changed (51) hide show
  1. package/package.json +7 -6
  2. package/src/lib/server/account-acquisition.d.ts +84 -0
  3. package/src/lib/server/account-acquisition.js +107 -0
  4. package/src/lib/server/account-acquisition.js.map +1 -0
  5. package/src/lib/server/admin-audit.js +5 -1
  6. package/src/lib/server/admin-audit.js.map +1 -1
  7. package/src/lib/server/duplicate-resource.js +8 -1
  8. package/src/lib/server/duplicate-resource.js.map +1 -1
  9. package/src/lib/server/email-flow-gate.js +3 -1
  10. package/src/lib/server/email-flow-gate.js.map +1 -1
  11. package/src/lib/server/email-marketing-gate.d.ts +14 -0
  12. package/src/lib/server/email-marketing-gate.js +132 -17
  13. package/src/lib/server/email-marketing-gate.js.map +1 -1
  14. package/src/lib/server/email-sender-reputation.d.ts +11 -0
  15. package/src/lib/server/email-sender-reputation.js +4 -1
  16. package/src/lib/server/email-sender-reputation.js.map +1 -1
  17. package/src/lib/server/email-suppression.d.ts +58 -7
  18. package/src/lib/server/email-suppression.js +113 -28
  19. package/src/lib/server/email-suppression.js.map +1 -1
  20. package/src/lib/server/erase.d.ts +2 -0
  21. package/src/lib/server/erase.js +7 -0
  22. package/src/lib/server/erase.js.map +1 -1
  23. package/src/lib/server/first-party-hosts.d.ts +61 -0
  24. package/src/lib/server/first-party-hosts.js +110 -0
  25. package/src/lib/server/first-party-hosts.js.map +1 -0
  26. package/src/lib/server/first-touch-handoff.d.ts +51 -0
  27. package/src/lib/server/first-touch-handoff.js +88 -0
  28. package/src/lib/server/first-touch-handoff.js.map +1 -0
  29. package/src/lib/server/first-touch-route.d.ts +48 -0
  30. package/src/lib/server/first-touch-route.js +164 -0
  31. package/src/lib/server/first-touch-route.js.map +1 -0
  32. package/src/lib/server/host-email-tokens.js +10 -1
  33. package/src/lib/server/host-email-tokens.js.map +1 -1
  34. package/src/lib/server/live-page-usage.d.ts +42 -9
  35. package/src/lib/server/live-page-usage.js +30 -9
  36. package/src/lib/server/live-page-usage.js.map +1 -1
  37. package/src/lib/server/organizations.js +9 -0
  38. package/src/lib/server/organizations.js.map +1 -1
  39. package/src/lib/server/personal-data-export.js +11 -0
  40. package/src/lib/server/personal-data-export.js.map +1 -1
  41. package/src/lib/server/platform-marketing-consent.d.ts +157 -8
  42. package/src/lib/server/platform-marketing-consent.js +240 -21
  43. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  44. package/src/lib/server/sending-domains.js +2 -2
  45. package/src/lib/server/sending-domains.js.map +1 -1
  46. package/src/lib/server/tracking-hosts.d.ts +98 -0
  47. package/src/lib/server/tracking-hosts.js +264 -0
  48. package/src/lib/server/tracking-hosts.js.map +1 -0
  49. package/src/lib/server/upsert-contact.d.ts +1 -1
  50. package/src/lib/server/upsert-contact.js +6 -0
  51. package/src/lib/server/upsert-contact.js.map +1 -1
@@ -0,0 +1,61 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * The first-party host registry as a server reads it (AGL-3289): the list
19
+ * built from this deployment's configuration, plus the list staff keep on the
20
+ * Platform settings page. Both apps serve the capture and read it here.
21
+ */
22
+ /** Where platform-wide settings live; the rules deny every client. */
23
+ export declare const FIRST_PARTY_HOSTS_COLLECTION = "platformSettings";
24
+ /** How long a process serves the list it read before reading again. */
25
+ export declare const FIRST_PARTY_HOSTS_CACHE_TTL_MS: number;
26
+ /** The list staff configured, and who last changed it. */
27
+ export interface ConfiguredFirstPartyHosts {
28
+ hosts: string[];
29
+ updatedAtMs: number | null;
30
+ updatedBy: string | null;
31
+ }
32
+ /** What staff configured, as stored. */
33
+ export declare function readConfiguredFirstPartyHosts(): Promise<ConfiguredFirstPartyHosts>;
34
+ /** Replace what staff configured. The caller audits and invalidates. */
35
+ export declare function writeConfiguredFirstPartyHosts(hosts: readonly string[], updatedBy: string | null, nowMs?: number): Promise<void>;
36
+ /** Forget the cached list, so the process that changed it serves the change. */
37
+ export declare function invalidateFirstPartyHostsCache(): void;
38
+ /** This deployment's built-in list, with nothing configured. */
39
+ export declare function builtInHosts(): string[];
40
+ /**
41
+ * The whole registry, cached per process.
42
+ *
43
+ * A failed read serves the built-in list with whatever this process last read
44
+ * configured, rather than failing: the capture keeps working on every host the
45
+ * configuration already names, and a registry that could not load must never
46
+ * switch the capture off everywhere at once.
47
+ */
48
+ export declare function resolveFirstPartyHosts(nowMs?: number): Promise<string[]>;
49
+ /**
50
+ * Whether a published site is one of the platform's OWN surfaces: its custom
51
+ * domain is a registered host. A site on the tenant domain is always a
52
+ * customer's, and so is any custom domain the registry does not name.
53
+ */
54
+ export declare function isFirstPartySite(site: {
55
+ cname?: unknown;
56
+ } | null | undefined): Promise<boolean>;
57
+ /**
58
+ * The origin to echo in `Access-Control-Allow-Origin`, or null to echo none:
59
+ * an https origin on a registered host, or http on localhost.
60
+ */
61
+ export declare function firstPartyOrigin(origin: string | null | undefined, hosts: readonly string[]): string | null;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { builtInFirstPartyHosts, FIRST_PARTY_HOSTS_SETTINGS_DOC, mergeFirstPartyHosts, platformHostInputs } from "@aglyn/aglyn/app-utils/first-party-hosts";
17
+ import { isFirstPartyHost } from "@aglyn/shared-util-first-touch";
18
+ import firebaseAdmin from "./firebase-admin.js";
19
+ /**
20
+ * The first-party host registry as a server reads it (AGL-3289): the list
21
+ * built from this deployment's configuration, plus the list staff keep on the
22
+ * Platform settings page. Both apps serve the capture and read it here.
23
+ */ /** Where platform-wide settings live; the rules deny every client. */ export const FIRST_PARTY_HOSTS_COLLECTION = 'platformSettings';
24
+ /** How long a process serves the list it read before reading again. */ export const FIRST_PARTY_HOSTS_CACHE_TTL_MS = 5 * 60 * 1000;
25
+ function settingsDoc() {
26
+ return firebaseAdmin.app().firestore().collection(FIRST_PARTY_HOSTS_COLLECTION).doc(FIRST_PARTY_HOSTS_SETTINGS_DOC);
27
+ }
28
+ /** What staff configured, as stored. */ export async function readConfiguredFirstPartyHosts() {
29
+ const snapshot = await settingsDoc().get();
30
+ const hosts = snapshot.get('hosts');
31
+ return {
32
+ hosts: Array.isArray(hosts) ? hosts.filter((entry)=>typeof entry === 'string') : [],
33
+ updatedAtMs: typeof snapshot.get('updatedAtMs') === 'number' ? snapshot.get('updatedAtMs') : null,
34
+ updatedBy: typeof snapshot.get('updatedBy') === 'string' ? snapshot.get('updatedBy') : null
35
+ };
36
+ }
37
+ /** Replace what staff configured. The caller audits and invalidates. */ export async function writeConfiguredFirstPartyHosts(hosts, updatedBy, nowMs = Date.now()) {
38
+ await settingsDoc().set({
39
+ hosts: [
40
+ ...hosts
41
+ ],
42
+ updatedAtMs: nowMs,
43
+ updatedBy
44
+ });
45
+ }
46
+ let cached = null;
47
+ let reading = null;
48
+ /** Forget the cached list, so the process that changed it serves the change. */ export function invalidateFirstPartyHostsCache() {
49
+ cached = null;
50
+ }
51
+ /** This deployment's built-in list, with nothing configured. */ export function builtInHosts() {
52
+ return builtInFirstPartyHosts(platformHostInputs());
53
+ }
54
+ /**
55
+ * The whole registry, cached per process.
56
+ *
57
+ * A failed read serves the built-in list with whatever this process last read
58
+ * configured, rather than failing: the capture keeps working on every host the
59
+ * configuration already names, and a registry that could not load must never
60
+ * switch the capture off everywhere at once.
61
+ */ export async function resolveFirstPartyHosts(nowMs = Date.now()) {
62
+ if (cached && nowMs - cached.at < FIRST_PARTY_HOSTS_CACHE_TTL_MS) return cached.hosts;
63
+ if (!reading) {
64
+ reading = (async ()=>{
65
+ var _ref;
66
+ let configured = (_ref = cached == null ? void 0 : cached.configured) != null ? _ref : [];
67
+ try {
68
+ configured = (await readConfiguredFirstPartyHosts()).hosts;
69
+ } catch (error) {
70
+ console.error('[first-party-hosts] read failed; serving the built-in list', error);
71
+ }
72
+ const hosts = mergeFirstPartyHosts(builtInHosts(), configured);
73
+ cached = {
74
+ hosts,
75
+ configured,
76
+ at: Date.now()
77
+ };
78
+ return hosts;
79
+ })().finally(()=>{
80
+ reading = null;
81
+ });
82
+ }
83
+ return reading;
84
+ }
85
+ /**
86
+ * Whether a published site is one of the platform's OWN surfaces: its custom
87
+ * domain is a registered host. A site on the tenant domain is always a
88
+ * customer's, and so is any custom domain the registry does not name.
89
+ */ export async function isFirstPartySite(site) {
90
+ const cname = typeof (site == null ? void 0 : site.cname) === 'string' ? site.cname.trim() : '';
91
+ if (!cname) return false;
92
+ return isFirstPartyHost(cname, await resolveFirstPartyHosts());
93
+ }
94
+ /**
95
+ * The origin to echo in `Access-Control-Allow-Origin`, or null to echo none:
96
+ * an https origin on a registered host, or http on localhost.
97
+ */ export function firstPartyOrigin(origin, hosts) {
98
+ if (!origin) return null;
99
+ let url;
100
+ try {
101
+ url = new URL(origin);
102
+ } catch (unused) {
103
+ return null;
104
+ }
105
+ const secure = url.protocol === 'https:' || url.protocol === 'http:' && url.hostname === 'localhost';
106
+ if (!secure || url.origin !== origin) return null;
107
+ return isFirstPartyHost(url.hostname, hosts) ? url.origin : null;
108
+ }
109
+
110
+ //# sourceMappingURL=first-party-hosts.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/first-party-hosts.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 builtInFirstPartyHosts,\n FIRST_PARTY_HOSTS_SETTINGS_DOC,\n mergeFirstPartyHosts,\n platformHostInputs,\n} from '@aglyn/aglyn/app-utils/first-party-hosts'\nimport { isFirstPartyHost } from '@aglyn/shared-util-first-touch'\nimport firebaseAdmin from './firebase-admin'\n\n/**\n * The first-party host registry as a server reads it (AGL-3289): the list\n * built from this deployment's configuration, plus the list staff keep on the\n * Platform settings page. Both apps serve the capture and read it here.\n */\n\n/** Where platform-wide settings live; the rules deny every client. */\nexport const FIRST_PARTY_HOSTS_COLLECTION = 'platformSettings'\n\n/** How long a process serves the list it read before reading again. */\nexport const FIRST_PARTY_HOSTS_CACHE_TTL_MS = 5 * 60 * 1000\n\n/** The list staff configured, and who last changed it. */\nexport interface ConfiguredFirstPartyHosts {\n hosts: string[]\n updatedAtMs: number | null\n updatedBy: string | null\n}\n\nfunction settingsDoc() {\n return firebaseAdmin\n .app()\n .firestore()\n .collection(FIRST_PARTY_HOSTS_COLLECTION)\n .doc(FIRST_PARTY_HOSTS_SETTINGS_DOC)\n}\n\n/** What staff configured, as stored. */\nexport async function readConfiguredFirstPartyHosts(): Promise<ConfiguredFirstPartyHosts> {\n const snapshot = await settingsDoc().get()\n const hosts = snapshot.get('hosts')\n return {\n hosts: Array.isArray(hosts) ? hosts.filter((entry): entry is string => typeof entry === 'string') : [],\n updatedAtMs: typeof snapshot.get('updatedAtMs') === 'number' ? snapshot.get('updatedAtMs') : null,\n updatedBy: typeof snapshot.get('updatedBy') === 'string' ? snapshot.get('updatedBy') : null,\n }\n}\n\n/** Replace what staff configured. The caller audits and invalidates. */\nexport async function writeConfiguredFirstPartyHosts(\n hosts: readonly string[],\n updatedBy: string | null,\n nowMs: number = Date.now(),\n): Promise<void> {\n await settingsDoc().set({ hosts: [...hosts], updatedAtMs: nowMs, updatedBy })\n}\n\nlet cached: { hosts: string[]; configured: string[]; at: number } | null = null\nlet reading: Promise<string[]> | null = null\n\n/** Forget the cached list, so the process that changed it serves the change. */\nexport function invalidateFirstPartyHostsCache(): void {\n cached = null\n}\n\n/** This deployment's built-in list, with nothing configured. */\nexport function builtInHosts(): string[] {\n return builtInFirstPartyHosts(platformHostInputs())\n}\n\n/**\n * The whole registry, cached per process.\n *\n * A failed read serves the built-in list with whatever this process last read\n * configured, rather than failing: the capture keeps working on every host the\n * configuration already names, and a registry that could not load must never\n * switch the capture off everywhere at once.\n */\nexport async function resolveFirstPartyHosts(nowMs: number = Date.now()): Promise<string[]> {\n if (cached && nowMs - cached.at < FIRST_PARTY_HOSTS_CACHE_TTL_MS) return cached.hosts\n if (!reading) {\n reading = (async () => {\n let configured = cached?.configured ?? []\n try {\n configured = (await readConfiguredFirstPartyHosts()).hosts\n } catch (error) {\n console.error('[first-party-hosts] read failed; serving the built-in list', error)\n }\n const hosts = mergeFirstPartyHosts(builtInHosts(), configured)\n cached = { hosts, configured, at: Date.now() }\n return hosts\n })().finally(() => {\n reading = null\n })\n }\n return reading\n}\n\n/**\n * Whether a published site is one of the platform's OWN surfaces: its custom\n * domain is a registered host. A site on the tenant domain is always a\n * customer's, and so is any custom domain the registry does not name.\n */\nexport async function isFirstPartySite(site: { cname?: unknown } | null | undefined): Promise<boolean> {\n const cname = typeof site?.cname === 'string' ? site.cname.trim() : ''\n if (!cname) return false\n return isFirstPartyHost(cname, await resolveFirstPartyHosts())\n}\n\n/**\n * The origin to echo in `Access-Control-Allow-Origin`, or null to echo none:\n * an https origin on a registered host, or http on localhost.\n */\nexport function firstPartyOrigin(\n origin: string | null | undefined,\n hosts: readonly string[],\n): string | null {\n if (!origin) return null\n let url: URL\n try {\n url = new URL(origin)\n } catch {\n return null\n }\n const secure = url.protocol === 'https:' || (url.protocol === 'http:' && url.hostname === 'localhost')\n if (!secure || url.origin !== origin) return null\n return isFirstPartyHost(url.hostname, hosts) ? url.origin : null\n}\n"],"names":["builtInFirstPartyHosts","FIRST_PARTY_HOSTS_SETTINGS_DOC","mergeFirstPartyHosts","platformHostInputs","isFirstPartyHost","firebaseAdmin","FIRST_PARTY_HOSTS_COLLECTION","FIRST_PARTY_HOSTS_CACHE_TTL_MS","settingsDoc","app","firestore","collection","doc","readConfiguredFirstPartyHosts","snapshot","get","hosts","Array","isArray","filter","entry","updatedAtMs","updatedBy","writeConfiguredFirstPartyHosts","nowMs","Date","now","set","cached","reading","invalidateFirstPartyHostsCache","builtInHosts","resolveFirstPartyHosts","at","configured","error","console","finally","isFirstPartySite","site","cname","trim","firstPartyOrigin","origin","url","URL","secure","protocol","hostname"],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type FirstTouch } from '@aglyn/shared-util-first-touch';
18
+ /**
19
+ * The sealed hand-off a first touch rides on between two of the platform's
20
+ * own hosts that share no cookie (AGL-3289).
21
+ *
22
+ * `{touch, exp}` HMAC-signed with the shared, fail-closed
23
+ * `TOKEN_SIGNING_SECRET` (docs/COMMERCE_TOKEN_SIGNING.md) — already held by
24
+ * the console and the tenant, which both serve the hand-off endpoint, so no
25
+ * install has a new secret to keep in step. The `first-touch:` context
26
+ * domain-separates the signature from every other token that secret signs.
27
+ *
28
+ * What a valid token proves is narrow: this install produced the record,
29
+ * recently, and nothing edited it since. Not that it is TRUE — a visitor can
30
+ * put any campaign they like on a URL. So a token grants nothing and is worth
31
+ * nothing but a label, and its short life is what keeps a pasted link from
32
+ * handing one visitor's first touch to everyone who opens it.
33
+ */
34
+ /** Thirty minutes: long enough to read a page and follow a link from it. */
35
+ export declare const FIRST_TOUCH_HANDOFF_TTL_MS: number;
36
+ /** A sealed token and when it stops opening. */
37
+ export interface SealedFirstTouch {
38
+ token: string;
39
+ exp: number;
40
+ }
41
+ /**
42
+ * Seal a record for a hop, or null when there is nothing valid to seal or no
43
+ * secret to seal it with — refusing, like every other signer of this secret,
44
+ * rather than minting something forgeable.
45
+ */
46
+ export declare function sealFirstTouch(value: unknown, nowMs?: number): SealedFirstTouch | null;
47
+ /**
48
+ * The record a token carries, re-scrubbed, or null for anything else —
49
+ * expired, tampered, malformed, another version, or no secret configured.
50
+ */
51
+ export declare function openFirstTouch(token: unknown, nowMs?: number): FirstTouch | null;
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { createHmac } from "crypto";
17
+ import { sanitizeFirstTouch } from "@aglyn/shared-util-first-touch";
18
+ import { tokenSigningSecret } from "./media-signing.js";
19
+ import { safeEqual } from "./safe-equal.js";
20
+ /**
21
+ * The sealed hand-off a first touch rides on between two of the platform's
22
+ * own hosts that share no cookie (AGL-3289).
23
+ *
24
+ * `{touch, exp}` HMAC-signed with the shared, fail-closed
25
+ * `TOKEN_SIGNING_SECRET` (docs/COMMERCE_TOKEN_SIGNING.md) — already held by
26
+ * the console and the tenant, which both serve the hand-off endpoint, so no
27
+ * install has a new secret to keep in step. The `first-touch:` context
28
+ * domain-separates the signature from every other token that secret signs.
29
+ *
30
+ * What a valid token proves is narrow: this install produced the record,
31
+ * recently, and nothing edited it since. Not that it is TRUE — a visitor can
32
+ * put any campaign they like on a URL. So a token grants nothing and is worth
33
+ * nothing but a label, and its short life is what keeps a pasted link from
34
+ * handing one visitor's first touch to everyone who opens it.
35
+ */ /** Thirty minutes: long enough to read a page and follow a link from it. */ export const FIRST_TOUCH_HANDOFF_TTL_MS = 30 * 60 * 1000;
36
+ /** Version tag, so a future shape is refused by name rather than by signature. */ const TOKEN_PREFIX = 'aglyn-ft-v1';
37
+ /** The longest token opened; the capture refuses longer ones on the page too. */ const MAX_TOKEN = 4096;
38
+ function signPayload(payload) {
39
+ return createHmac('sha256', tokenSigningSecret()).update(`first-touch:${payload}`).digest('base64url');
40
+ }
41
+ /**
42
+ * Seal a record for a hop, or null when there is nothing valid to seal or no
43
+ * secret to seal it with — refusing, like every other signer of this secret,
44
+ * rather than minting something forgeable.
45
+ */ export function sealFirstTouch(value, nowMs = Date.now()) {
46
+ const touch = sanitizeFirstTouch(value, nowMs);
47
+ if (!touch) return null;
48
+ const exp = nowMs + FIRST_TOUCH_HANDOFF_TTL_MS;
49
+ const payload = Buffer.from(JSON.stringify({
50
+ touch,
51
+ exp
52
+ }), 'utf8').toString('base64url');
53
+ try {
54
+ return {
55
+ token: `${TOKEN_PREFIX}.${payload}.${signPayload(payload)}`,
56
+ exp
57
+ };
58
+ } catch (unused) {
59
+ return null;
60
+ }
61
+ }
62
+ /**
63
+ * The record a token carries, re-scrubbed, or null for anything else —
64
+ * expired, tampered, malformed, another version, or no secret configured.
65
+ */ export function openFirstTouch(token, nowMs = Date.now()) {
66
+ if (typeof token !== 'string' || !token || token.length > MAX_TOKEN) return null;
67
+ const parts = token.split('.');
68
+ if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX) return null;
69
+ const [, payload, signature] = parts;
70
+ let expected;
71
+ try {
72
+ expected = signPayload(payload);
73
+ } catch (unused) {
74
+ return null;
75
+ }
76
+ if (!safeEqual(signature, expected)) return null;
77
+ let claims;
78
+ try {
79
+ claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
80
+ } catch (unused) {
81
+ return null;
82
+ }
83
+ const exp = Number(claims == null ? void 0 : claims.exp);
84
+ if (!Number.isFinite(exp) || exp <= nowMs) return null;
85
+ return sanitizeFirstTouch(claims.touch, nowMs);
86
+ }
87
+
88
+ //# sourceMappingURL=first-touch-handoff.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/first-touch-handoff.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 { createHmac } from 'crypto'\nimport { sanitizeFirstTouch, type FirstTouch } from '@aglyn/shared-util-first-touch'\nimport { tokenSigningSecret } from './media-signing'\nimport { safeEqual } from './safe-equal'\n\n/**\n * The sealed hand-off a first touch rides on between two of the platform's\n * own hosts that share no cookie (AGL-3289).\n *\n * `{touch, exp}` HMAC-signed with the shared, fail-closed\n * `TOKEN_SIGNING_SECRET` (docs/COMMERCE_TOKEN_SIGNING.md) — already held by\n * the console and the tenant, which both serve the hand-off endpoint, so no\n * install has a new secret to keep in step. The `first-touch:` context\n * domain-separates the signature from every other token that secret signs.\n *\n * What a valid token proves is narrow: this install produced the record,\n * recently, and nothing edited it since. Not that it is TRUE — a visitor can\n * put any campaign they like on a URL. So a token grants nothing and is worth\n * nothing but a label, and its short life is what keeps a pasted link from\n * handing one visitor's first touch to everyone who opens it.\n */\n\n/** Thirty minutes: long enough to read a page and follow a link from it. */\nexport const FIRST_TOUCH_HANDOFF_TTL_MS = 30 * 60 * 1000\n\n/** Version tag, so a future shape is refused by name rather than by signature. */\nconst TOKEN_PREFIX = 'aglyn-ft-v1'\n\n/** The longest token opened; the capture refuses longer ones on the page too. */\nconst MAX_TOKEN = 4096\n\nfunction signPayload(payload: string): string {\n return createHmac('sha256', tokenSigningSecret())\n .update(`first-touch:${payload}`)\n .digest('base64url')\n}\n\n/** A sealed token and when it stops opening. */\nexport interface SealedFirstTouch {\n token: string\n exp: number\n}\n\n/**\n * Seal a record for a hop, or null when there is nothing valid to seal or no\n * secret to seal it with — refusing, like every other signer of this secret,\n * rather than minting something forgeable.\n */\nexport function sealFirstTouch(\n value: unknown,\n nowMs: number = Date.now(),\n): SealedFirstTouch | null {\n const touch = sanitizeFirstTouch(value, nowMs)\n if (!touch) return null\n const exp = nowMs + FIRST_TOUCH_HANDOFF_TTL_MS\n const payload = Buffer.from(JSON.stringify({ touch, exp }), 'utf8').toString('base64url')\n try {\n return { token: `${TOKEN_PREFIX}.${payload}.${signPayload(payload)}`, exp }\n } catch {\n return null\n }\n}\n\n/**\n * The record a token carries, re-scrubbed, or null for anything else —\n * expired, tampered, malformed, another version, or no secret configured.\n */\nexport function openFirstTouch(token: unknown, nowMs: number = Date.now()): FirstTouch | null {\n if (typeof token !== 'string' || !token || token.length > MAX_TOKEN) return null\n const parts = token.split('.')\n if (parts.length !== 3 || parts[0] !== TOKEN_PREFIX) return null\n const [, payload, signature] = parts\n let expected: string\n try {\n expected = signPayload(payload)\n } catch {\n return null\n }\n if (!safeEqual(signature, expected)) return null\n let claims: { touch?: unknown; exp?: unknown }\n try {\n claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'))\n } catch {\n return null\n }\n const exp = Number(claims?.exp)\n if (!Number.isFinite(exp) || exp <= nowMs) return null\n return sanitizeFirstTouch(claims.touch, nowMs)\n}\n"],"names":["createHmac","sanitizeFirstTouch","tokenSigningSecret","safeEqual","FIRST_TOUCH_HANDOFF_TTL_MS","TOKEN_PREFIX","MAX_TOKEN","signPayload","payload","update","digest","sealFirstTouch","value","nowMs","Date","now","touch","exp","Buffer","from","JSON","stringify","toString","token","openFirstTouch","length","parts","split","signature","expected","claims","parse","Number","isFinite"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,SAAQ;AACnC,SAASC,kBAAkB,QAAyB,iCAAgC;AACpF,SAASC,kBAAkB,QAAQ,qBAAiB;AACpD,SAASC,SAAS,QAAQ,kBAAc;AAExC;;;;;;;;;;;;;;;CAeC,GAED,0EAA0E,GAC1E,OAAO,MAAMC,6BAA6B,KAAK,KAAK,KAAI;AAExD,gFAAgF,GAChF,MAAMC,eAAe;AAErB,+EAA+E,GAC/E,MAAMC,YAAY;AAElB,SAASC,YAAYC,OAAe;IAClC,OAAOR,WAAW,UAAUE,sBACzBO,MAAM,CAAC,CAAC,YAAY,EAAED,SAAS,EAC/BE,MAAM,CAAC;AACZ;AAQA;;;;CAIC,GACD,OAAO,SAASC,eACdC,KAAc,EACdC,QAAgBC,KAAKC,GAAG,EAAE;IAE1B,MAAMC,QAAQf,mBAAmBW,OAAOC;IACxC,IAAI,CAACG,OAAO,OAAO;IACnB,MAAMC,MAAMJ,QAAQT;IACpB,MAAMI,UAAUU,OAAOC,IAAI,CAACC,KAAKC,SAAS,CAAC;QAAEL;QAAOC;IAAI,IAAI,QAAQK,QAAQ,CAAC;IAC7E,IAAI;QACF,OAAO;YAAEC,OAAO,GAAGlB,aAAa,CAAC,EAAEG,QAAQ,CAAC,EAAED,YAAYC,UAAU;YAAES;QAAI;IAC5E,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA;;;CAGC,GACD,OAAO,SAASO,eAAeD,KAAc,EAAEV,QAAgBC,KAAKC,GAAG,EAAE;IACvE,IAAI,OAAOQ,UAAU,YAAY,CAACA,SAASA,MAAME,MAAM,GAAGnB,WAAW,OAAO;IAC5E,MAAMoB,QAAQH,MAAMI,KAAK,CAAC;IAC1B,IAAID,MAAMD,MAAM,KAAK,KAAKC,KAAK,CAAC,EAAE,KAAKrB,cAAc,OAAO;IAC5D,MAAM,GAAGG,SAASoB,UAAU,GAAGF;IAC/B,IAAIG;IACJ,IAAI;QACFA,WAAWtB,YAAYC;IACzB,EAAE,eAAM;QACN,OAAO;IACT;IACA,IAAI,CAACL,UAAUyB,WAAWC,WAAW,OAAO;IAC5C,IAAIC;IACJ,IAAI;QACFA,SAASV,KAAKW,KAAK,CAACb,OAAOC,IAAI,CAACX,SAAS,aAAac,QAAQ,CAAC;IACjE,EAAE,eAAM;QACN,OAAO;IACT;IACA,MAAML,MAAMe,OAAOF,0BAAAA,OAAQb,GAAG;IAC9B,IAAI,CAACe,OAAOC,QAAQ,CAAChB,QAAQA,OAAOJ,OAAO,OAAO;IAClD,OAAOZ,mBAAmB6B,OAAOd,KAAK,EAAEH;AAC1C"}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * `/api/first-touch` — the one URL a surface needs to capture first touches
19
+ * (AGL-3289). The console and the tenant each serve it from this module:
20
+ *
21
+ * - **GET** is the capture itself: the kit's source with this install's host
22
+ * registry, this endpoint as its hand-off URL, and a storage default for
23
+ * this visitor. A plain page includes it as one script tag.
24
+ * - **POST** seals a record for a hop to a host the cookie cannot reach
25
+ * (`{ seal }`), or opens one a hop brought (`{ open }`). Only for an origin
26
+ * on a registered host; the body is plain text so a cross-origin call needs
27
+ * no preflight.
28
+ *
29
+ * ## The storage default
30
+ *
31
+ * The platform's own consent posture, decided at the edge for a surface that
32
+ * has no consent code of its own: the visitor's recorded answer when the
33
+ * `aglyn_consent` mirror carries one, a refusal when the browser sends Global
34
+ * Privacy Control, and otherwise the region's posture — granted where implied
35
+ * consent is lawful, pending where the law asks first. A surface that runs
36
+ * its own consent tool marks its tag `data-consent="pending"` and forwards its
37
+ * own answer, which then wins.
38
+ */
39
+ /** The path both apps serve the capture at. */
40
+ export declare const FIRST_TOUCH_ROUTE_PATH = "/api/first-touch";
41
+ /** Whether this visitor's capture may use device storage, before any page code runs. */
42
+ export declare function firstTouchStorageDefault(headers: Headers): boolean | null;
43
+ /** GET — the capture script. */
44
+ export declare function firstTouchScriptResponse(request: Request): Promise<Response>;
45
+ /** OPTIONS — only for a client that sends a header plain text would not. */
46
+ export declare function firstTouchPreflightResponse(request: Request): Promise<Response>;
47
+ /** POST — seal a record for a hop, or open one a hop brought. */
48
+ export declare function firstTouchHandoffResponse(request: Request): Promise<Response>;
@@ -0,0 +1,164 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import { PLATFORM_CONSENT_COOKIE, platformConsentPosture } from "@aglyn/aglyn/app-utils/platform-visitor-consent";
18
+ import { readRequestGeo } from "@aglyn/aglyn/app-utils/request-geo";
19
+ import { parseStoredVisitorConsent } from "@aglyn/aglyn/app-utils/visitor-consent";
20
+ import { firstTouchScript } from "@aglyn/shared-util-first-touch/first-touch-script";
21
+ import { firstPartyOrigin, resolveFirstPartyHosts } from "./first-party-hosts.js";
22
+ import { openFirstTouch, sealFirstTouch } from "./first-touch-handoff.js";
23
+ /**
24
+ * `/api/first-touch` — the one URL a surface needs to capture first touches
25
+ * (AGL-3289). The console and the tenant each serve it from this module:
26
+ *
27
+ * - **GET** is the capture itself: the kit's source with this install's host
28
+ * registry, this endpoint as its hand-off URL, and a storage default for
29
+ * this visitor. A plain page includes it as one script tag.
30
+ * - **POST** seals a record for a hop to a host the cookie cannot reach
31
+ * (`{ seal }`), or opens one a hop brought (`{ open }`). Only for an origin
32
+ * on a registered host; the body is plain text so a cross-origin call needs
33
+ * no preflight.
34
+ *
35
+ * ## The storage default
36
+ *
37
+ * The platform's own consent posture, decided at the edge for a surface that
38
+ * has no consent code of its own: the visitor's recorded answer when the
39
+ * `aglyn_consent` mirror carries one, a refusal when the browser sends Global
40
+ * Privacy Control, and otherwise the region's posture — granted where implied
41
+ * consent is lawful, pending where the law asks first. A surface that runs
42
+ * its own consent tool marks its tag `data-consent="pending"` and forwards its
43
+ * own answer, which then wins.
44
+ */ /** The path both apps serve the capture at. */ export const FIRST_TOUCH_ROUTE_PATH = '/api/first-touch';
45
+ function cookieValue(header, name) {
46
+ for (const pair of String(header != null ? header : '').split(';')){
47
+ const cut = pair.indexOf('=');
48
+ if (cut < 0 || pair.slice(0, cut).trim() !== name) continue;
49
+ try {
50
+ return decodeURIComponent(pair.slice(cut + 1).trim());
51
+ } catch (unused) {
52
+ return null;
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+ /** Whether this visitor's capture may use device storage, before any page code runs. */ export function firstTouchStorageDefault(headers) {
58
+ const recorded = parseStoredVisitorConsent(cookieValue(headers.get('cookie'), PLATFORM_CONSENT_COOKIE));
59
+ if (recorded) return recorded.analytics;
60
+ if (headers.get('sec-gpc') === '1') return false;
61
+ return platformConsentPosture(readRequestGeo(headers).country) === 'opt-out' ? true : null;
62
+ }
63
+ /** The public origin a request was addressed to. */ function requestOrigin(request) {
64
+ const url = new URL(request.url);
65
+ const forwardedHost = request.headers.get('x-forwarded-host');
66
+ const forwardedProto = request.headers.get('x-forwarded-proto');
67
+ const host = (forwardedHost == null ? void 0 : forwardedHost.split(',')[0].trim()) || url.host;
68
+ const proto = ((forwardedProto == null ? void 0 : forwardedProto.split(',')[0].trim()) || url.protocol.replace(':', '')).toLowerCase();
69
+ return `${proto === 'http' ? 'http' : 'https'}://${host}`;
70
+ }
71
+ /** GET — the capture script. */ export async function firstTouchScriptResponse(request) {
72
+ const hosts = await resolveFirstPartyHosts();
73
+ const script = firstTouchScript({
74
+ hosts,
75
+ storage: firstTouchStorageDefault(request.headers),
76
+ handoffUrl: `${requestOrigin(request)}${FIRST_TOUCH_ROUTE_PATH}`
77
+ });
78
+ return new Response(script, {
79
+ status: 200,
80
+ headers: {
81
+ 'content-type': 'text/javascript; charset=utf-8',
82
+ // Per visitor: the storage default reads their consent cookie and
83
+ // their region, so no shared cache may hand one visitor's answer to
84
+ // another.
85
+ 'cache-control': 'private, no-store',
86
+ 'x-content-type-options': 'nosniff'
87
+ }
88
+ });
89
+ }
90
+ function corsHeaders(origin) {
91
+ return origin ? {
92
+ 'access-control-allow-origin': origin,
93
+ 'access-control-allow-methods': 'POST, OPTIONS',
94
+ 'access-control-allow-headers': 'content-type',
95
+ 'access-control-max-age': '600',
96
+ vary: 'Origin'
97
+ } : {
98
+ vary: 'Origin'
99
+ };
100
+ }
101
+ /** OPTIONS — only for a client that sends a header plain text would not. */ export async function firstTouchPreflightResponse(request) {
102
+ const origin = firstPartyOrigin(request.headers.get('origin'), await resolveFirstPartyHosts());
103
+ return new Response(null, {
104
+ status: origin ? 204 : 403,
105
+ headers: corsHeaders(origin)
106
+ });
107
+ }
108
+ /** The request's plain-text JSON body, or null for anything unreadable or oversized. */ async function readBody(request) {
109
+ try {
110
+ const text = await request.text();
111
+ return text.length <= 8192 ? JSON.parse(text) : null;
112
+ } catch (unused) {
113
+ return null;
114
+ }
115
+ }
116
+ /** POST — seal a record for a hop, or open one a hop brought. */ export async function firstTouchHandoffResponse(request) {
117
+ const origin = firstPartyOrigin(request.headers.get('origin'), await resolveFirstPartyHosts());
118
+ const headers = _extends({}, corsHeaders(origin), {
119
+ 'cache-control': 'no-store'
120
+ });
121
+ // Browsers name the origin on every POST a page makes. A request that
122
+ // names none, or one that is not ours, is not a hop between our hosts.
123
+ if (!origin) return Response.json({
124
+ error: 'Not a first-party origin'
125
+ }, {
126
+ status: 403,
127
+ headers
128
+ });
129
+ const body = await readBody(request);
130
+ if (body && 'seal' in body) {
131
+ const sealed = sealFirstTouch(body['seal']);
132
+ return sealed ? Response.json(sealed, {
133
+ status: 200,
134
+ headers
135
+ }) : Response.json({
136
+ error: 'Nothing to seal'
137
+ }, {
138
+ status: 400,
139
+ headers
140
+ });
141
+ }
142
+ if (body && 'open' in body) {
143
+ const touch = openFirstTouch(body['open']);
144
+ return touch ? Response.json({
145
+ touch
146
+ }, {
147
+ status: 200,
148
+ headers
149
+ }) : Response.json({
150
+ error: 'Not a valid hand-off'
151
+ }, {
152
+ status: 400,
153
+ headers
154
+ });
155
+ }
156
+ return Response.json({
157
+ error: 'Expected seal or open'
158
+ }, {
159
+ status: 400,
160
+ headers
161
+ });
162
+ }
163
+
164
+ //# sourceMappingURL=first-touch-route.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/first-touch-route.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 PLATFORM_CONSENT_COOKIE,\n platformConsentPosture,\n} from '@aglyn/aglyn/app-utils/platform-visitor-consent'\nimport { readRequestGeo } from '@aglyn/aglyn/app-utils/request-geo'\nimport { parseStoredVisitorConsent } from '@aglyn/aglyn/app-utils/visitor-consent'\nimport { firstTouchScript } from '@aglyn/shared-util-first-touch/first-touch-script'\nimport { firstPartyOrigin, resolveFirstPartyHosts } from './first-party-hosts'\nimport { openFirstTouch, sealFirstTouch } from './first-touch-handoff'\n\n/**\n * `/api/first-touch` — the one URL a surface needs to capture first touches\n * (AGL-3289). The console and the tenant each serve it from this module:\n *\n * - **GET** is the capture itself: the kit's source with this install's host\n * registry, this endpoint as its hand-off URL, and a storage default for\n * this visitor. A plain page includes it as one script tag.\n * - **POST** seals a record for a hop to a host the cookie cannot reach\n * (`{ seal }`), or opens one a hop brought (`{ open }`). Only for an origin\n * on a registered host; the body is plain text so a cross-origin call needs\n * no preflight.\n *\n * ## The storage default\n *\n * The platform's own consent posture, decided at the edge for a surface that\n * has no consent code of its own: the visitor's recorded answer when the\n * `aglyn_consent` mirror carries one, a refusal when the browser sends Global\n * Privacy Control, and otherwise the region's posture — granted where implied\n * consent is lawful, pending where the law asks first. A surface that runs\n * its own consent tool marks its tag `data-consent=\"pending\"` and forwards its\n * own answer, which then wins.\n */\n\n/** The path both apps serve the capture at. */\nexport const FIRST_TOUCH_ROUTE_PATH = '/api/first-touch'\n\nfunction cookieValue(header: string | null, name: string): string | null {\n for (const pair of String(header ?? '').split(';')) {\n const cut = pair.indexOf('=')\n if (cut < 0 || pair.slice(0, cut).trim() !== name) continue\n try {\n return decodeURIComponent(pair.slice(cut + 1).trim())\n } catch {\n return null\n }\n }\n return null\n}\n\n/** Whether this visitor's capture may use device storage, before any page code runs. */\nexport function firstTouchStorageDefault(headers: Headers): boolean | null {\n const recorded = parseStoredVisitorConsent(\n cookieValue(headers.get('cookie'), PLATFORM_CONSENT_COOKIE),\n )\n if (recorded) return recorded.analytics\n if (headers.get('sec-gpc') === '1') return false\n return platformConsentPosture(readRequestGeo(headers).country) === 'opt-out' ? true : null\n}\n\n/** The public origin a request was addressed to. */\nfunction requestOrigin(request: Request): string {\n const url = new URL(request.url)\n const forwardedHost = request.headers.get('x-forwarded-host')\n const forwardedProto = request.headers.get('x-forwarded-proto')\n const host = forwardedHost?.split(',')[0].trim() || url.host\n const proto = (forwardedProto?.split(',')[0].trim() || url.protocol.replace(':', '')).toLowerCase()\n return `${proto === 'http' ? 'http' : 'https'}://${host}`\n}\n\n/** GET — the capture script. */\nexport async function firstTouchScriptResponse(request: Request): Promise<Response> {\n const hosts = await resolveFirstPartyHosts()\n const script = firstTouchScript({\n hosts,\n storage: firstTouchStorageDefault(request.headers),\n handoffUrl: `${requestOrigin(request)}${FIRST_TOUCH_ROUTE_PATH}`,\n })\n return new Response(script, {\n status: 200,\n headers: {\n 'content-type': 'text/javascript; charset=utf-8',\n // Per visitor: the storage default reads their consent cookie and\n // their region, so no shared cache may hand one visitor's answer to\n // another.\n 'cache-control': 'private, no-store',\n 'x-content-type-options': 'nosniff',\n },\n })\n}\n\nfunction corsHeaders(origin: string | null): Record<string, string> {\n return origin\n ? {\n 'access-control-allow-origin': origin,\n 'access-control-allow-methods': 'POST, OPTIONS',\n 'access-control-allow-headers': 'content-type',\n 'access-control-max-age': '600',\n vary: 'Origin',\n }\n : { vary: 'Origin' }\n}\n\n/** OPTIONS — only for a client that sends a header plain text would not. */\nexport async function firstTouchPreflightResponse(request: Request): Promise<Response> {\n const origin = firstPartyOrigin(request.headers.get('origin'), await resolveFirstPartyHosts())\n return new Response(null, { status: origin ? 204 : 403, headers: corsHeaders(origin) })\n}\n\n/** The request's plain-text JSON body, or null for anything unreadable or oversized. */\nasync function readBody(request: Request): Promise<Record<string, unknown> | null> {\n try {\n const text = await request.text()\n return text.length <= 8192 ? JSON.parse(text) : null\n } catch {\n return null\n }\n}\n\n/** POST — seal a record for a hop, or open one a hop brought. */\nexport async function firstTouchHandoffResponse(request: Request): Promise<Response> {\n const origin = firstPartyOrigin(request.headers.get('origin'), await resolveFirstPartyHosts())\n const headers = { ...corsHeaders(origin), 'cache-control': 'no-store' }\n // Browsers name the origin on every POST a page makes. A request that\n // names none, or one that is not ours, is not a hop between our hosts.\n if (!origin) return Response.json({ error: 'Not a first-party origin' }, { status: 403, headers })\n const body = await readBody(request)\n if (body && 'seal' in body) {\n const sealed = sealFirstTouch(body['seal'])\n return sealed\n ? Response.json(sealed, { status: 200, headers })\n : Response.json({ error: 'Nothing to seal' }, { status: 400, headers })\n }\n if (body && 'open' in body) {\n const touch = openFirstTouch(body['open'])\n return touch\n ? Response.json({ touch }, { status: 200, headers })\n : Response.json({ error: 'Not a valid hand-off' }, { status: 400, headers })\n }\n return Response.json({ error: 'Expected seal or open' }, { status: 400, headers })\n}\n"],"names":["PLATFORM_CONSENT_COOKIE","platformConsentPosture","readRequestGeo","parseStoredVisitorConsent","firstTouchScript","firstPartyOrigin","resolveFirstPartyHosts","openFirstTouch","sealFirstTouch","FIRST_TOUCH_ROUTE_PATH","cookieValue","header","name","pair","String","split","cut","indexOf","slice","trim","decodeURIComponent","firstTouchStorageDefault","headers","recorded","get","analytics","country","requestOrigin","request","url","URL","forwardedHost","forwardedProto","host","proto","protocol","replace","toLowerCase","firstTouchScriptResponse","hosts","script","storage","handoffUrl","Response","status","corsHeaders","origin","vary","firstTouchPreflightResponse","readBody","text","length","JSON","parse","firstTouchHandoffResponse","json","error","body","sealed","touch"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,uBAAuB,EACvBC,sBAAsB,QACjB,kDAAiD;AACxD,SAASC,cAAc,QAAQ,qCAAoC;AACnE,SAASC,yBAAyB,QAAQ,yCAAwC;AAClF,SAASC,gBAAgB,QAAQ,oDAAmD;AACpF,SAASC,gBAAgB,EAAEC,sBAAsB,QAAQ,yBAAqB;AAC9E,SAASC,cAAc,EAAEC,cAAc,QAAQ,2BAAuB;AAEtE;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED,6CAA6C,GAC7C,OAAO,MAAMC,yBAAyB,mBAAkB;AAExD,SAASC,YAAYC,MAAqB,EAAEC,IAAY;IACtD,KAAK,MAAMC,QAAQC,OAAOH,iBAAAA,SAAU,IAAII,KAAK,CAAC,KAAM;QAClD,MAAMC,MAAMH,KAAKI,OAAO,CAAC;QACzB,IAAID,MAAM,KAAKH,KAAKK,KAAK,CAAC,GAAGF,KAAKG,IAAI,OAAOP,MAAM;QACnD,IAAI;YACF,OAAOQ,mBAAmBP,KAAKK,KAAK,CAACF,MAAM,GAAGG,IAAI;QACpD,EAAE,eAAM;YACN,OAAO;QACT;IACF;IACA,OAAO;AACT;AAEA,sFAAsF,GACtF,OAAO,SAASE,yBAAyBC,OAAgB;IACvD,MAAMC,WAAWpB,0BACfO,YAAYY,QAAQE,GAAG,CAAC,WAAWxB;IAErC,IAAIuB,UAAU,OAAOA,SAASE,SAAS;IACvC,IAAIH,QAAQE,GAAG,CAAC,eAAe,KAAK,OAAO;IAC3C,OAAOvB,uBAAuBC,eAAeoB,SAASI,OAAO,MAAM,YAAY,OAAO;AACxF;AAEA,kDAAkD,GAClD,SAASC,cAAcC,OAAgB;IACrC,MAAMC,MAAM,IAAIC,IAAIF,QAAQC,GAAG;IAC/B,MAAME,gBAAgBH,QAAQN,OAAO,CAACE,GAAG,CAAC;IAC1C,MAAMQ,iBAAiBJ,QAAQN,OAAO,CAACE,GAAG,CAAC;IAC3C,MAAMS,OAAOF,CAAAA,iCAAAA,cAAehB,KAAK,CAAC,IAAI,CAAC,EAAE,CAACI,IAAI,OAAMU,IAAII,IAAI;IAC5D,MAAMC,QAAQ,AAACF,CAAAA,CAAAA,kCAAAA,eAAgBjB,KAAK,CAAC,IAAI,CAAC,EAAE,CAACI,IAAI,OAAMU,IAAIM,QAAQ,CAACC,OAAO,CAAC,KAAK,GAAE,EAAGC,WAAW;IACjG,OAAO,GAAGH,UAAU,SAAS,SAAS,QAAQ,GAAG,EAAED,MAAM;AAC3D;AAEA,8BAA8B,GAC9B,OAAO,eAAeK,yBAAyBV,OAAgB;IAC7D,MAAMW,QAAQ,MAAMjC;IACpB,MAAMkC,SAASpC,iBAAiB;QAC9BmC;QACAE,SAASpB,yBAAyBO,QAAQN,OAAO;QACjDoB,YAAY,GAAGf,cAAcC,WAAWnB,wBAAwB;IAClE;IACA,OAAO,IAAIkC,SAASH,QAAQ;QAC1BI,QAAQ;QACRtB,SAAS;YACP,gBAAgB;YAChB,kEAAkE;YAClE,oEAAoE;YACpE,WAAW;YACX,iBAAiB;YACjB,0BAA0B;QAC5B;IACF;AACF;AAEA,SAASuB,YAAYC,MAAqB;IACxC,OAAOA,SACH;QACE,+BAA+BA;QAC/B,gCAAgC;QAChC,gCAAgC;QAChC,0BAA0B;QAC1BC,MAAM;IACR,IACA;QAAEA,MAAM;IAAS;AACvB;AAEA,0EAA0E,GAC1E,OAAO,eAAeC,4BAA4BpB,OAAgB;IAChE,MAAMkB,SAASzC,iBAAiBuB,QAAQN,OAAO,CAACE,GAAG,CAAC,WAAW,MAAMlB;IACrE,OAAO,IAAIqC,SAAS,MAAM;QAAEC,QAAQE,SAAS,MAAM;QAAKxB,SAASuB,YAAYC;IAAQ;AACvF;AAEA,sFAAsF,GACtF,eAAeG,SAASrB,OAAgB;IACtC,IAAI;QACF,MAAMsB,OAAO,MAAMtB,QAAQsB,IAAI;QAC/B,OAAOA,KAAKC,MAAM,IAAI,OAAOC,KAAKC,KAAK,CAACH,QAAQ;IAClD,EAAE,eAAM;QACN,OAAO;IACT;AACF;AAEA,+DAA+D,GAC/D,OAAO,eAAeI,0BAA0B1B,OAAgB;IAC9D,MAAMkB,SAASzC,iBAAiBuB,QAAQN,OAAO,CAACE,GAAG,CAAC,WAAW,MAAMlB;IACrE,MAAMgB,UAAU,aAAKuB,YAAYC;QAAS,iBAAiB;;IAC3D,sEAAsE;IACtE,uEAAuE;IACvE,IAAI,CAACA,QAAQ,OAAOH,SAASY,IAAI,CAAC;QAAEC,OAAO;IAA2B,GAAG;QAAEZ,QAAQ;QAAKtB;IAAQ;IAChG,MAAMmC,OAAO,MAAMR,SAASrB;IAC5B,IAAI6B,QAAQ,UAAUA,MAAM;QAC1B,MAAMC,SAASlD,eAAeiD,IAAI,CAAC,OAAO;QAC1C,OAAOC,SACHf,SAASY,IAAI,CAACG,QAAQ;YAAEd,QAAQ;YAAKtB;QAAQ,KAC7CqB,SAASY,IAAI,CAAC;YAAEC,OAAO;QAAkB,GAAG;YAAEZ,QAAQ;YAAKtB;QAAQ;IACzE;IACA,IAAImC,QAAQ,UAAUA,MAAM;QAC1B,MAAME,QAAQpD,eAAekD,IAAI,CAAC,OAAO;QACzC,OAAOE,QACHhB,SAASY,IAAI,CAAC;YAAEI;QAAM,GAAG;YAAEf,QAAQ;YAAKtB;QAAQ,KAChDqB,SAASY,IAAI,CAAC;YAAEC,OAAO;QAAuB,GAAG;YAAEZ,QAAQ;YAAKtB;QAAQ;IAC9E;IACA,OAAOqB,SAASY,IAAI,CAAC;QAAEC,OAAO;IAAwB,GAAG;QAAEZ,QAAQ;QAAKtB;IAAQ;AAClF"}
@@ -16,6 +16,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { sanitizeAuthorHtml } from "@aglyn/aglyn/app-utils/author-html";
18
18
  import { hostTokenMerge } from "@aglyn/aglyn/app-utils/host-tokens";
19
+ import { composeHostComponentNodes } from "@aglyn/aglyn/app-utils/load-referenced-components";
19
20
  import { renderHostEmail } from "@aglyn/shared-util-email";
20
21
  /**
21
22
  * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).
@@ -63,8 +64,16 @@ import { renderHostEmail } from "@aglyn/shared-util-email";
63
64
  // here rather than at each of the nine senders keeps their signatures
64
65
  // untouched and gives the tenth one the right policy without knowing it
65
66
  // needed to ask.
67
+ //
68
+ // The component graft joins on the same argument (AGL-3287): a header or
69
+ // footer the site owner placed is a reference the shared renderer cannot
70
+ // resolve, so every sender behind this function mails it expanded without
71
+ // having to know it exists. Style overrides do not survive into mail —
72
+ // they land in `sx`, which `renderEmailHtml` never reads — but property
73
+ // values and attribute overrides do.
66
74
  _extends({}, options, {
67
- sanitize: sanitizeAuthorHtml
75
+ sanitize: sanitizeAuthorHtml,
76
+ compose: composeHostComponentNodes
68
77
  }));
69
78
  }
70
79
  export default renderHostEmailWithTokens;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.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 { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport {\n renderHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n let host: HostTokenSource | null\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n host = snapshot.exists ? (snapshot.data() as HostTokenSource) : null\n } catch {\n // A failed read must not stop the send. Every token then resolves to its\n // empty behaviour, which is the same outcome as a site that set nothing —\n // an email with a gap where the address would be, rather than no email.\n host = null\n }\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n { ...options, sanitize: sanitizeAuthorHtml },\n )\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["sanitizeAuthorHtml","hostTokenMerge","renderHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","snapshot","collection","doc","get","exists","data","sanitize"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,qCAAoC;AACvE,SACEC,cAAc,QAET,qCAAoC;AAC3C,SACEC,eAAe,QAEV,2BAA0B;AAGjC;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,eAAeC,0BACpBC,SAAsC,EACtCC,MAAc,EACdC,WAAmB,EACnBC,QAAgC,CAAC,CAAC,EAClCC,UAA+B,CAAC,CAAC;IAEjC,IAAIC;IACJ,IAAI;QACF,MAAMC,WAAW,MAAMN,UAAUO,UAAU,CAAC,SAASC,GAAG,CAACP,QAAQQ,GAAG;QACpEJ,OAAOC,SAASI,MAAM,GAAIJ,SAASK,IAAI,KAAyB;IAClE,EAAE,eAAM;QACN,yEAAyE;QACzE,0EAA0E;QAC1E,wEAAwE;QACxEN,OAAO;IACT;IACA,OAAOP,gBACLE,WACAC,QACAC,aACA,qEAAqE;IACrE,2EAA2E;IAC3E,2EAA2E;IAC3E,MAAM;IACN,aAAKL,eAAeQ,OAAUF,QAC9B,yEAAyE;IACzE,wEAAwE;IACxE,wEAAwE;IACxE,sEAAsE;IACtE,wEAAwE;IACxE,iBAAiB;IACjB,aAAKC;QAASQ,UAAUhB;;AAE5B;AAEA,eAAeG,0BAAyB"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-email-tokens.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 { sanitizeAuthorHtml } from '@aglyn/aglyn/app-utils/author-html'\nimport {\n hostTokenMerge,\n type HostTokenSource,\n} from '@aglyn/aglyn/app-utils/host-tokens'\nimport { composeHostComponentNodes } from '@aglyn/aglyn/app-utils/load-referenced-components'\nimport {\n renderHostEmail,\n type RenderedHostEmail,\n} from '@aglyn/shared-util-email'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * `renderHostEmail` with `host.*` tokens resolved (AGL-1022).\n *\n * The whole point of host variables is that a publisher's email template can\n * say \"write to {{host.supportEmail}}\" and have the installing site fill it in.\n * That only happens if something resolves the tokens at SEND time, and the\n * email library cannot: `@aglyn/shared-util-email` is `scope:shared` and may\n * only depend on `scope:shared`, while the token registry is aglyn-scoped.\n *\n * So the join happens here, in the one lib that already sits above both. Three\n * alternatives were weighed and this is the one that cannot fail quietly:\n *\n * * A registration hook inside the email library would mean a provider that is\n * never registered resolves every token to nothing, silently, on a path whose\n * output has already been sent to a customer. This repo has shipped a\n * flawless security control with zero callers before; that is the shape.\n * * Threading the map through all eighteen call sites puts the burden on\n * whoever adds the nineteenth, and they will not know.\n * * Moving the registry into a `scope:shared` lib is the cleanest end state and\n * the right thing if a second shared consumer ever appears — but the only\n * candidate today is `shared/util/tools`, which is generic helpers, and\n * domain logic does not belong there.\n *\n * Costs one host read per send. Emails are not a hot path, and the read buys\n * the guarantee that a template's branding is right by construction.\n */\nexport async function renderHostEmailWithTokens(\n firestore: FirebaseFirestore.Firestore,\n hostId: string,\n templateKey: string,\n merge: Record<string, string> = {},\n options: { origin?: string } = {},\n): Promise<RenderedHostEmail | null> {\n let host: HostTokenSource | null\n try {\n const snapshot = await firestore.collection('hosts').doc(hostId).get()\n host = snapshot.exists ? (snapshot.data() as HostTokenSource) : null\n } catch {\n // A failed read must not stop the send. Every token then resolves to its\n // empty behaviour, which is the same outcome as a site that set nothing —\n // an email with a gap where the address would be, rather than no email.\n host = null\n }\n return renderHostEmail(\n firestore as never,\n hostId,\n templateKey,\n // Host tokens first: an explicit caller value WINS. A sender passing\n // `host.businessName` is naming a specific thing on purpose (a white-label\n // send, a preview with sample data), and this is not the place to overrule\n // it.\n { ...hostTokenMerge(host), ...merge },\n // The HTML policy joins here for the same reason the token map does, and\n // it is the same reason: `renderEmailHtml` requires one, `scope:shared`\n // cannot import one, and this lib already sits above both. Supplying it\n // here rather than at each of the nine senders keeps their signatures\n // untouched and gives the tenth one the right policy without knowing it\n // needed to ask.\n //\n // The component graft joins on the same argument (AGL-3287): a header or\n // footer the site owner placed is a reference the shared renderer cannot\n // resolve, so every sender behind this function mails it expanded without\n // having to know it exists. Style overrides do not survive into mail —\n // they land in `sx`, which `renderEmailHtml` never reads — but property\n // values and attribute overrides do.\n {\n ...options,\n sanitize: sanitizeAuthorHtml,\n compose: composeHostComponentNodes,\n },\n )\n}\n\nexport default renderHostEmailWithTokens\n"],"names":["sanitizeAuthorHtml","hostTokenMerge","composeHostComponentNodes","renderHostEmail","renderHostEmailWithTokens","firestore","hostId","templateKey","merge","options","host","snapshot","collection","doc","get","exists","data","sanitize","compose"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,kBAAkB,QAAQ,qCAAoC;AACvE,SACEC,cAAc,QAET,qCAAoC;AAC3C,SAASC,yBAAyB,QAAQ,oDAAmD;AAC7F,SACEC,eAAe,QAEV,2BAA0B;AAGjC;;;;;;;;;;;;;;;;;;;;;;;;;CAyBC,GACD,OAAO,eAAeC,0BACpBC,SAAsC,EACtCC,MAAc,EACdC,WAAmB,EACnBC,QAAgC,CAAC,CAAC,EAClCC,UAA+B,CAAC,CAAC;IAEjC,IAAIC;IACJ,IAAI;QACF,MAAMC,WAAW,MAAMN,UAAUO,UAAU,CAAC,SAASC,GAAG,CAACP,QAAQQ,GAAG;QACpEJ,OAAOC,SAASI,MAAM,GAAIJ,SAASK,IAAI,KAAyB;IAClE,EAAE,eAAM;QACN,yEAAyE;QACzE,0EAA0E;QAC1E,wEAAwE;QACxEN,OAAO;IACT;IACA,OAAOP,gBACLE,WACAC,QACAC,aACA,qEAAqE;IACrE,2EAA2E;IAC3E,2EAA2E;IAC3E,MAAM;IACN,aAAKN,eAAeS,OAAUF,QAC9B,yEAAyE;IACzE,wEAAwE;IACxE,wEAAwE;IACxE,sEAAsE;IACtE,wEAAwE;IACxE,iBAAiB;IACjB,EAAE;IACF,yEAAyE;IACzE,yEAAyE;IACzE,0EAA0E;IAC1E,uEAAuE;IACvE,wEAAwE;IACxE,qCAAqC;IACrC,aACKC;QACHQ,UAAUjB;QACVkB,SAAShB;;AAGf;AAEA,eAAeE,0BAAyB"}