@aglyn/tenant-data-admin 1.0.0-beta.219 → 1.0.0-beta.221

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 (102) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-kit.d.ts +7 -0
  5. package/src/lib/server/api-v1-kit.js.map +1 -1
  6. package/src/lib/server/api-v1-resources.d.ts +1 -2
  7. package/src/lib/server/api-v1-resources.js +0 -1
  8. package/src/lib/server/api-v1-resources.js.map +1 -1
  9. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  10. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  11. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  12. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  13. package/src/lib/server/card-payment-velocity.js +28 -2
  14. package/src/lib/server/card-payment-velocity.js.map +1 -1
  15. package/src/lib/server/consent-group-carry.js +1 -1
  16. package/src/lib/server/consent-group-carry.js.map +1 -1
  17. package/src/lib/server/consent-group-change.js +2 -2
  18. package/src/lib/server/consent-group-change.js.map +1 -1
  19. package/src/lib/server/dynamic-list-materialize.js +3 -3
  20. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  21. package/src/lib/server/email-flow-gate.js +2 -2
  22. package/src/lib/server/email-flow-gate.js.map +1 -1
  23. package/src/lib/server/email-suppression.js +1 -1
  24. package/src/lib/server/email-suppression.js.map +1 -1
  25. package/src/lib/server/host-visitor-records.d.ts +32 -4
  26. package/src/lib/server/host-visitor-records.js +73 -14
  27. package/src/lib/server/host-visitor-records.js.map +1 -1
  28. package/src/lib/server/hosted-page-review.d.ts +34 -0
  29. package/src/lib/server/hosted-page-review.js +270 -22
  30. package/src/lib/server/hosted-page-review.js.map +1 -1
  31. package/src/lib/server/leaving-notice.d.ts +98 -0
  32. package/src/lib/server/leaving-notice.js +250 -0
  33. package/src/lib/server/leaving-notice.js.map +1 -0
  34. package/src/lib/server/link-reputation-review.d.ts +54 -0
  35. package/src/lib/server/link-reputation-review.js +144 -0
  36. package/src/lib/server/link-reputation-review.js.map +1 -0
  37. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  38. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  39. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  40. package/src/lib/server/member-email-aliases.d.ts +9 -1
  41. package/src/lib/server/member-email-aliases.js +25 -1
  42. package/src/lib/server/member-email-aliases.js.map +1 -1
  43. package/src/lib/server/notifications.d.ts +14 -0
  44. package/src/lib/server/notifications.js +29 -0
  45. package/src/lib/server/notifications.js.map +1 -1
  46. package/src/lib/server/operator-alerts.d.ts +7 -1
  47. package/src/lib/server/operator-alerts.js +5 -3
  48. package/src/lib/server/operator-alerts.js.map +1 -1
  49. package/src/lib/server/org-age.d.ts +15 -6
  50. package/src/lib/server/org-age.js +20 -8
  51. package/src/lib/server/org-age.js.map +1 -1
  52. package/src/lib/server/org-containers.d.ts +66 -0
  53. package/src/lib/server/org-containers.js +95 -0
  54. package/src/lib/server/org-containers.js.map +1 -0
  55. package/src/lib/server/org-member-notice.js +3 -2
  56. package/src/lib/server/org-member-notice.js.map +1 -1
  57. package/src/lib/server/outbound-send-review.js +23 -6
  58. package/src/lib/server/outbound-send-review.js.map +1 -1
  59. package/src/lib/server/page-security-hold.d.ts +93 -0
  60. package/src/lib/server/page-security-hold.js +180 -0
  61. package/src/lib/server/page-security-hold.js.map +1 -0
  62. package/src/lib/server/payment-fraud-signal.js +5 -1
  63. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  64. package/src/lib/server/payment-provider-stripe-connect-payouts.js +35 -1
  65. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -1
  66. package/src/lib/server/payment-risk-record.d.ts +1 -1
  67. package/src/lib/server/payment-risk-record.js +3 -1
  68. package/src/lib/server/payment-risk-record.js.map +1 -1
  69. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  70. package/src/lib/server/platform-marketing-consent.js +1 -1
  71. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  72. package/src/lib/server/risk-notice.d.ts +32 -1
  73. package/src/lib/server/risk-notice.js +90 -28
  74. package/src/lib/server/risk-notice.js.map +1 -1
  75. package/src/lib/server/sending-domains.d.ts +17 -0
  76. package/src/lib/server/sending-domains.js +28 -4
  77. package/src/lib/server/sending-domains.js.map +1 -1
  78. package/src/lib/server/sso-enforcement.js +7 -2
  79. package/src/lib/server/sso-enforcement.js.map +1 -1
  80. package/src/lib/server/staff-alert-email.js +4 -2
  81. package/src/lib/server/staff-alert-email.js.map +1 -1
  82. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  83. package/src/lib/server/topic-subscriptions.js.map +1 -0
  84. package/src/lib/server/upsert-contact.js +2 -2
  85. package/src/lib/server/upsert-contact.js.map +1 -1
  86. package/src/lib/server/web-risk.d.ts +100 -0
  87. package/src/lib/server/web-risk.js +383 -0
  88. package/src/lib/server/web-risk.js.map +1 -0
  89. package/src/tenancy.d.ts +1 -3
  90. package/src/tenancy.js +1 -7
  91. package/src/tenancy.js.map +1 -1
  92. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  93. package/src/lib/server/contact-email-engagement.js +0 -145
  94. package/src/lib/server/contact-email-engagement.js.map +0 -1
  95. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  96. package/src/lib/server/crm-booking-activity.js +0 -257
  97. package/src/lib/server/crm-booking-activity.js.map +0 -1
  98. package/src/lib/server/crm-inbound-email.d.ts +0 -168
  99. package/src/lib/server/crm-inbound-email.js +0 -282
  100. package/src/lib/server/crm-inbound-email.js.map +0 -1
  101. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  102. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.219",
3
+ "version": "1.0.0-beta.221",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,12 +25,12 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.219",
29
- "@aglyn/shared-util-email": "1.0.0-beta.219",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.219",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.219",
32
- "@aglyn/shared-util-http": "1.0.0-beta.219",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.219",
28
+ "@aglyn/aglyn": "1.0.0-beta.221",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.221",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.221",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.221",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.221",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.221",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.5",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "peerDependencies": {
40
40
  "firebase-admin": "^14.3.0",
41
- "next": "16.3.3",
41
+ "next": "^16.3.3",
42
42
  "react": "^19.2.8"
43
43
  },
44
44
  "sideEffects": [
@@ -36,7 +36,11 @@ function errorResponse(status, type, fallbackMessage, init) {
36
36
  }
37
37
  /** Canonical error responses. Pass `{ headers }` to carry rate-limit headers. */ export const ApiErrors = {
38
38
  unauthorized: (init)=>errorResponse(401, 'unauthorized', 'Invalid or missing API key', init),
39
- planRequired: (init)=>errorResponse(403, 'plan_required', 'API access requires the Business or Advanced plan', init),
39
+ // The plan named is the cheapest one carrying `apiAccess`, and every plan
40
+ // above it carries it too. Written out rather than looked up so this module
41
+ // stays import-free; `api-http.spec.ts` reads the plan table and fails when
42
+ // the two disagree.
43
+ planRequired: (init)=>errorResponse(403, 'plan_required', 'API access requires the Business plan or above', init),
40
44
  insufficientScope: (scope, headers)=>errorResponse(403, 'insufficient_scope', `Missing the "${scope}" scope`, {
41
45
  code: scope,
42
46
  headers
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-http.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 * HTTP building blocks for the customer REST API (`/api/v1`, AGL-617): a\n * consistent JSON error envelope, list/pagination helpers, and a per-key\n * rate limiter. Everything here is pure (Web `Response` + plain data), so it\n * is unit-tested directly.\n */\n\n/** Every error response is `{ error: { type, message, code?, fields? } }`. */\nexport interface ApiErrorBody {\n error: {\n type: string\n message: string\n code?: string\n /** Per-field reasons on a validation failure (AGL-901). */\n fields?: Record<string, string>\n }\n}\n\nexport interface ApiResponseInit {\n message?: string\n code?: string\n /**\n * Field id → reason, surfaced on `validation_failed`. Additive: clients\n * that only read `type`/`message` are unaffected.\n */\n fields?: Record<string, string>\n headers?: Record<string, string>\n}\n\nfunction errorResponse(\n status: number,\n type: string,\n fallbackMessage: string,\n init?: ApiResponseInit,\n): Response {\n const error: ApiErrorBody['error'] = {\n type,\n message: init?.message ?? fallbackMessage,\n }\n if (init?.code) error.code = init.code\n if (init?.fields && Object.keys(init.fields).length) error.fields = init.fields\n return Response.json({ error }, { status, headers: init?.headers })\n}\n\n/** Canonical error responses. Pass `{ headers }` to carry rate-limit headers. */\nexport const ApiErrors = {\n unauthorized: (init?: ApiResponseInit) =>\n errorResponse(401, 'unauthorized', 'Invalid or missing API key', init),\n planRequired: (init?: ApiResponseInit) =>\n errorResponse(\n 403,\n 'plan_required',\n 'API access requires the Business or Advanced plan',\n init,\n ),\n insufficientScope: (scope: string, headers?: Record<string, string>) =>\n errorResponse(403, 'insufficient_scope', `Missing the \"${scope}\" scope`, {\n code: scope,\n headers,\n }),\n forbidden: (init?: ApiResponseInit) =>\n errorResponse(403, 'forbidden', 'Not permitted', init),\n notFound: (init?: ApiResponseInit) =>\n errorResponse(404, 'not_found', 'Not found', init),\n badRequest: (init?: ApiResponseInit) =>\n errorResponse(400, 'bad_request', 'Bad request', init),\n /**\n * The request conflicts with work already in flight (AGL-1709). Used by the\n * idempotency claim for the one case it must fail CLOSED: an identical\n * attempt that has taken the claim but not yet settled. Letting the second\n * caller through is the duplicate the key exists to prevent, so it is\n * refused rather than served — and a client that reads `type` gets a\n * retriable answer rather than a mystery.\n */\n conflict: (init?: ApiResponseInit) =>\n errorResponse(409, 'conflict', 'Conflicts with the current state', init),\n methodNotAllowed: (init?: ApiResponseInit) =>\n errorResponse(405, 'method_not_allowed', 'Method not allowed', init),\n /**\n * The three refusals an upload can produce that a JSON-only API never could\n * (AGL-2463). Built here rather than at the call site so `/v1` keeps ONE\n * error envelope — a client branching on `error.type` must not meet a bare\n * `{ error: '<sentence>' }` on the one surface that handles bytes.\n */\n unsupportedMediaType: (init?: ApiResponseInit) =>\n errorResponse(415, 'unsupported_media_type', 'Unsupported file type', init),\n payloadTooLarge: (init?: ApiResponseInit) =>\n errorResponse(413, 'payload_too_large', 'File too large', init),\n /**\n * 451, matching the console's own media chokepoints: the asset is refused\n * because its content hash is on the takedown ledger, which is a legal\n * verdict rather than a permission or a quota.\n */\n unavailableForLegalReasons: (init?: ApiResponseInit) =>\n errorResponse(\n 451,\n 'unavailable_for_legal_reasons',\n 'This file has been removed and cannot be uploaded',\n init,\n ),\n rateLimited: (retryAfterSec: number, headers?: Record<string, string>) =>\n errorResponse(429, 'rate_limited', 'Too many requests', {\n headers: { 'Retry-After': String(Math.max(1, retryAfterSec)), ...headers },\n }),\n internal: (init?: ApiResponseInit) =>\n errorResponse(500, 'internal_error', 'Something went wrong', init),\n}\n\n/**\n * Re-emit `response` with `headers` merged in — the escape hatch for a\n * refusal built by a shared helper that knows nothing about this request's\n * rate-limit budget (the lockdown 423, AGL-1596). Status, body and the\n * response's own headers are preserved; `set` overwrites rather than\n * appends, so a header named on both sides appears exactly once (the passed\n * value wins) and no client ever reads a doubled `X-RateLimit-Remaining`.\n */\nexport function withResponseHeaders(\n response: Response,\n headers: Record<string, string>,\n): Response {\n const merged = new Headers(response.headers)\n for (const [name, value] of Object.entries(headers)) merged.set(name, value)\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers: merged,\n })\n}\n\n/** Success JSON with an optional status + headers (e.g. rate-limit headers). */\nexport function apiJson(\n data: unknown,\n init?: { status?: number; headers?: Record<string, string> },\n): Response {\n return Response.json(data, {\n status: init?.status ?? 200,\n headers: init?.headers,\n })\n}\n\n// ── Pagination ──────────────────────────────────────────────────────────────\n\nexport const DEFAULT_PAGE_LIMIT = 25\nexport const MAX_PAGE_LIMIT = 100\n\n/** Clamp a `?limit=` value to [1, MAX], defaulting when absent/invalid. */\nexport function parseLimit(raw: string | null | undefined): number {\n const n = Number(raw)\n if (!Number.isFinite(n) || n < 1) return DEFAULT_PAGE_LIMIT\n return Math.min(Math.floor(n), MAX_PAGE_LIMIT)\n}\n\n/** Opaque cursor = base64url of an underlying key (e.g. a Firestore doc id). */\nexport function encodeCursor(value: string): string {\n return Buffer.from(value, 'utf8').toString('base64url')\n}\n\nexport function decodeCursor(\n cursor: string | null | undefined,\n): string | undefined {\n if (!cursor) return undefined\n try {\n const decoded = Buffer.from(cursor, 'base64url').toString('utf8')\n return decoded.length > 0 ? decoded : undefined\n } catch {\n return undefined\n }\n}\n\n/** List envelope: `{ data, next_cursor, has_more }`. */\nexport function listResponse(\n data: unknown[],\n nextCursor: string | null,\n headers?: Record<string, string>,\n): Response {\n return apiJson(\n { object: 'list', data, next_cursor: nextCursor, has_more: nextCursor != null },\n { headers },\n )\n}\n\n// ── Rate limiting (fixed window, per key) ───────────────────────────────────\n\nexport interface RateLimitState {\n count: number\n windowStartMs: number\n}\n\nexport interface RateLimitResult {\n allowed: boolean\n limit: number\n remaining: number\n resetMs: number\n}\n\nexport interface RateLimitOptions {\n limit?: number\n windowMs?: number\n now?: number\n store?: Map<string, RateLimitState>\n}\n\nexport const DEFAULT_RATE_LIMIT = 120\nexport const DEFAULT_RATE_WINDOW_MS = 60_000\n\n// Per-instance store. Serverless instances are ephemeral, so this blunts\n// bursts rather than enforcing a global cap (matches the app's other\n// limiters); a durable per-key counter can back it later.\nconst defaultStore = new Map<string, RateLimitState>()\n\n/** Count one request against `key`'s fixed window. `now`/`store` injectable. */\nexport function checkRateLimit(\n key: string,\n options?: RateLimitOptions,\n): RateLimitResult {\n const limit = options?.limit ?? DEFAULT_RATE_LIMIT\n const windowMs = options?.windowMs ?? DEFAULT_RATE_WINDOW_MS\n const now = options?.now ?? Date.now()\n const store = options?.store ?? defaultStore\n\n const state = store.get(key)\n if (!state || now - state.windowStartMs >= windowMs) {\n store.set(key, { count: 1, windowStartMs: now })\n return { allowed: true, limit, remaining: limit - 1, resetMs: now + windowMs }\n }\n state.count += 1\n return {\n allowed: state.count <= limit,\n limit,\n remaining: Math.max(0, limit - state.count),\n resetMs: state.windowStartMs + windowMs,\n }\n}\n\n/**\n * Report `key`'s window WITHOUT counting a request against it (AGL-2414).\n *\n * `checkRateLimit` fuses \"is there budget\" with \"spend some\", which is the\n * right shape when every call is a request to be counted. It is the wrong\n * shape when the budget meters something the caller has not done *yet* and\n * may never do — `/api/v1` gates the Firestore key lookup on an IP's budget\n * of *unproductive* lookups, so it has to ask before the lookup and only\n * charge if the lookup came back empty. Asking with `checkRateLimit` would\n * charge every request, including the successful ones, which is precisely the\n * customer-visible cap that budget must not become.\n *\n * `allowed` answers \"would the NEXT call be admitted\", i.e. `count < limit` —\n * one off from `checkRateLimit`'s post-increment `count <= limit`, and the\n * same verdict for the same request. `remaining` is what is left now, so an\n * untouched or expired window reports the full `limit` rather than\n * `limit - 1`.\n */\nexport function peekRateLimit(\n key: string,\n options?: RateLimitOptions,\n): RateLimitResult {\n const limit = options?.limit ?? DEFAULT_RATE_LIMIT\n const windowMs = options?.windowMs ?? DEFAULT_RATE_WINDOW_MS\n const now = options?.now ?? Date.now()\n const store = options?.store ?? defaultStore\n\n const state = store.get(key)\n if (!state || now - state.windowStartMs >= windowMs) {\n return { allowed: true, limit, remaining: limit, resetMs: now + windowMs }\n }\n return {\n allowed: state.count < limit,\n limit,\n remaining: Math.max(0, limit - state.count),\n resetMs: state.windowStartMs + windowMs,\n }\n}\n\n/**\n * Rate-limit headers, in BOTH spellings (AGL-2727).\n *\n * `X-RateLimit-*` is the de-facto set this API has always sent, and it is kept\n * verbatim — including `Reset` as an epoch second — because integrations are\n * already reading it and a header that changes meaning under a client is worse\n * than one it has to learn.\n *\n * `RateLimit-*` is RFC 9331, which is what a caller that was not written\n * against this specific API looks for, and increasingly what an agent looks\n * for. Same numbers, standard spelling. Its `Reset` is SECONDS REMAINING, not\n * an epoch — that is the RFC's own choice, and the better one: a duration is\n * read against the caller's clock rather than ours, so a skewed clock cannot\n * turn \"wait 12 seconds\" into \"wait until 1970\".\n *\n * ⚠️ Both are computed from the SAME `result`, so they can never disagree.\n * Two independently-derived budgets on one response is a bug that reads as a\n * rounding error.\n */\nexport function rateLimitHeaders(\n result: RateLimitResult,\n now: number = Date.now(),\n): Record<string, string> {\n const remainingSeconds = Math.max(0, Math.ceil((result.resetMs - now) / 1000))\n return {\n 'X-RateLimit-Limit': String(result.limit),\n 'X-RateLimit-Remaining': String(result.remaining),\n 'X-RateLimit-Reset': String(Math.ceil(result.resetMs / 1000)),\n 'RateLimit-Limit': String(result.limit),\n 'RateLimit-Remaining': String(result.remaining),\n 'RateLimit-Reset': String(remainingSeconds),\n }\n}\n"],"names":["errorResponse","status","type","fallbackMessage","init","error","message","code","fields","Object","keys","length","Response","json","headers","ApiErrors","unauthorized","planRequired","insufficientScope","scope","forbidden","notFound","badRequest","conflict","methodNotAllowed","unsupportedMediaType","payloadTooLarge","unavailableForLegalReasons","rateLimited","retryAfterSec","String","Math","max","internal","withResponseHeaders","response","merged","Headers","name","value","entries","set","body","statusText","apiJson","data","DEFAULT_PAGE_LIMIT","MAX_PAGE_LIMIT","parseLimit","raw","n","Number","isFinite","min","floor","encodeCursor","Buffer","from","toString","decodeCursor","cursor","undefined","decoded","listResponse","nextCursor","object","next_cursor","has_more","DEFAULT_RATE_LIMIT","DEFAULT_RATE_WINDOW_MS","defaultStore","Map","checkRateLimit","key","options","limit","windowMs","now","Date","store","state","get","windowStartMs","count","allowed","remaining","resetMs","peekRateLimit","rateLimitHeaders","result","remainingSeconds","ceil"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-http.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 * HTTP building blocks for the customer REST API (`/api/v1`, AGL-617): a\n * consistent JSON error envelope, list/pagination helpers, and a per-key\n * rate limiter. Everything here is pure (Web `Response` + plain data), so it\n * is unit-tested directly.\n */\n\n/** Every error response is `{ error: { type, message, code?, fields? } }`. */\nexport interface ApiErrorBody {\n error: {\n type: string\n message: string\n code?: string\n /** Per-field reasons on a validation failure (AGL-901). */\n fields?: Record<string, string>\n }\n}\n\nexport interface ApiResponseInit {\n message?: string\n code?: string\n /**\n * Field id → reason, surfaced on `validation_failed`. Additive: clients\n * that only read `type`/`message` are unaffected.\n */\n fields?: Record<string, string>\n headers?: Record<string, string>\n}\n\nfunction errorResponse(\n status: number,\n type: string,\n fallbackMessage: string,\n init?: ApiResponseInit,\n): Response {\n const error: ApiErrorBody['error'] = {\n type,\n message: init?.message ?? fallbackMessage,\n }\n if (init?.code) error.code = init.code\n if (init?.fields && Object.keys(init.fields).length) error.fields = init.fields\n return Response.json({ error }, { status, headers: init?.headers })\n}\n\n/** Canonical error responses. Pass `{ headers }` to carry rate-limit headers. */\nexport const ApiErrors = {\n unauthorized: (init?: ApiResponseInit) =>\n errorResponse(401, 'unauthorized', 'Invalid or missing API key', init),\n // The plan named is the cheapest one carrying `apiAccess`, and every plan\n // above it carries it too. Written out rather than looked up so this module\n // stays import-free; `api-http.spec.ts` reads the plan table and fails when\n // the two disagree.\n planRequired: (init?: ApiResponseInit) =>\n errorResponse(\n 403,\n 'plan_required',\n 'API access requires the Business plan or above',\n init,\n ),\n insufficientScope: (scope: string, headers?: Record<string, string>) =>\n errorResponse(403, 'insufficient_scope', `Missing the \"${scope}\" scope`, {\n code: scope,\n headers,\n }),\n forbidden: (init?: ApiResponseInit) =>\n errorResponse(403, 'forbidden', 'Not permitted', init),\n notFound: (init?: ApiResponseInit) =>\n errorResponse(404, 'not_found', 'Not found', init),\n badRequest: (init?: ApiResponseInit) =>\n errorResponse(400, 'bad_request', 'Bad request', init),\n /**\n * The request conflicts with work already in flight (AGL-1709). Used by the\n * idempotency claim for the one case it must fail CLOSED: an identical\n * attempt that has taken the claim but not yet settled. Letting the second\n * caller through is the duplicate the key exists to prevent, so it is\n * refused rather than served — and a client that reads `type` gets a\n * retriable answer rather than a mystery.\n */\n conflict: (init?: ApiResponseInit) =>\n errorResponse(409, 'conflict', 'Conflicts with the current state', init),\n methodNotAllowed: (init?: ApiResponseInit) =>\n errorResponse(405, 'method_not_allowed', 'Method not allowed', init),\n /**\n * The three refusals an upload can produce that a JSON-only API never could\n * (AGL-2463). Built here rather than at the call site so `/v1` keeps ONE\n * error envelope — a client branching on `error.type` must not meet a bare\n * `{ error: '<sentence>' }` on the one surface that handles bytes.\n */\n unsupportedMediaType: (init?: ApiResponseInit) =>\n errorResponse(415, 'unsupported_media_type', 'Unsupported file type', init),\n payloadTooLarge: (init?: ApiResponseInit) =>\n errorResponse(413, 'payload_too_large', 'File too large', init),\n /**\n * 451, matching the console's own media chokepoints: the asset is refused\n * because its content hash is on the takedown ledger, which is a legal\n * verdict rather than a permission or a quota.\n */\n unavailableForLegalReasons: (init?: ApiResponseInit) =>\n errorResponse(\n 451,\n 'unavailable_for_legal_reasons',\n 'This file has been removed and cannot be uploaded',\n init,\n ),\n rateLimited: (retryAfterSec: number, headers?: Record<string, string>) =>\n errorResponse(429, 'rate_limited', 'Too many requests', {\n headers: { 'Retry-After': String(Math.max(1, retryAfterSec)), ...headers },\n }),\n internal: (init?: ApiResponseInit) =>\n errorResponse(500, 'internal_error', 'Something went wrong', init),\n}\n\n/**\n * Re-emit `response` with `headers` merged in — the escape hatch for a\n * refusal built by a shared helper that knows nothing about this request's\n * rate-limit budget (the lockdown 423, AGL-1596). Status, body and the\n * response's own headers are preserved; `set` overwrites rather than\n * appends, so a header named on both sides appears exactly once (the passed\n * value wins) and no client ever reads a doubled `X-RateLimit-Remaining`.\n */\nexport function withResponseHeaders(\n response: Response,\n headers: Record<string, string>,\n): Response {\n const merged = new Headers(response.headers)\n for (const [name, value] of Object.entries(headers)) merged.set(name, value)\n return new Response(response.body, {\n status: response.status,\n statusText: response.statusText,\n headers: merged,\n })\n}\n\n/** Success JSON with an optional status + headers (e.g. rate-limit headers). */\nexport function apiJson(\n data: unknown,\n init?: { status?: number; headers?: Record<string, string> },\n): Response {\n return Response.json(data, {\n status: init?.status ?? 200,\n headers: init?.headers,\n })\n}\n\n// ── Pagination ──────────────────────────────────────────────────────────────\n\nexport const DEFAULT_PAGE_LIMIT = 25\nexport const MAX_PAGE_LIMIT = 100\n\n/** Clamp a `?limit=` value to [1, MAX], defaulting when absent/invalid. */\nexport function parseLimit(raw: string | null | undefined): number {\n const n = Number(raw)\n if (!Number.isFinite(n) || n < 1) return DEFAULT_PAGE_LIMIT\n return Math.min(Math.floor(n), MAX_PAGE_LIMIT)\n}\n\n/** Opaque cursor = base64url of an underlying key (e.g. a Firestore doc id). */\nexport function encodeCursor(value: string): string {\n return Buffer.from(value, 'utf8').toString('base64url')\n}\n\nexport function decodeCursor(\n cursor: string | null | undefined,\n): string | undefined {\n if (!cursor) return undefined\n try {\n const decoded = Buffer.from(cursor, 'base64url').toString('utf8')\n return decoded.length > 0 ? decoded : undefined\n } catch {\n return undefined\n }\n}\n\n/** List envelope: `{ data, next_cursor, has_more }`. */\nexport function listResponse(\n data: unknown[],\n nextCursor: string | null,\n headers?: Record<string, string>,\n): Response {\n return apiJson(\n { object: 'list', data, next_cursor: nextCursor, has_more: nextCursor != null },\n { headers },\n )\n}\n\n// ── Rate limiting (fixed window, per key) ───────────────────────────────────\n\nexport interface RateLimitState {\n count: number\n windowStartMs: number\n}\n\nexport interface RateLimitResult {\n allowed: boolean\n limit: number\n remaining: number\n resetMs: number\n}\n\nexport interface RateLimitOptions {\n limit?: number\n windowMs?: number\n now?: number\n store?: Map<string, RateLimitState>\n}\n\nexport const DEFAULT_RATE_LIMIT = 120\nexport const DEFAULT_RATE_WINDOW_MS = 60_000\n\n// Per-instance store. Serverless instances are ephemeral, so this blunts\n// bursts rather than enforcing a global cap (matches the app's other\n// limiters); a durable per-key counter can back it later.\nconst defaultStore = new Map<string, RateLimitState>()\n\n/** Count one request against `key`'s fixed window. `now`/`store` injectable. */\nexport function checkRateLimit(\n key: string,\n options?: RateLimitOptions,\n): RateLimitResult {\n const limit = options?.limit ?? DEFAULT_RATE_LIMIT\n const windowMs = options?.windowMs ?? DEFAULT_RATE_WINDOW_MS\n const now = options?.now ?? Date.now()\n const store = options?.store ?? defaultStore\n\n const state = store.get(key)\n if (!state || now - state.windowStartMs >= windowMs) {\n store.set(key, { count: 1, windowStartMs: now })\n return { allowed: true, limit, remaining: limit - 1, resetMs: now + windowMs }\n }\n state.count += 1\n return {\n allowed: state.count <= limit,\n limit,\n remaining: Math.max(0, limit - state.count),\n resetMs: state.windowStartMs + windowMs,\n }\n}\n\n/**\n * Report `key`'s window WITHOUT counting a request against it (AGL-2414).\n *\n * `checkRateLimit` fuses \"is there budget\" with \"spend some\", which is the\n * right shape when every call is a request to be counted. It is the wrong\n * shape when the budget meters something the caller has not done *yet* and\n * may never do — `/api/v1` gates the Firestore key lookup on an IP's budget\n * of *unproductive* lookups, so it has to ask before the lookup and only\n * charge if the lookup came back empty. Asking with `checkRateLimit` would\n * charge every request, including the successful ones, which is precisely the\n * customer-visible cap that budget must not become.\n *\n * `allowed` answers \"would the NEXT call be admitted\", i.e. `count < limit` —\n * one off from `checkRateLimit`'s post-increment `count <= limit`, and the\n * same verdict for the same request. `remaining` is what is left now, so an\n * untouched or expired window reports the full `limit` rather than\n * `limit - 1`.\n */\nexport function peekRateLimit(\n key: string,\n options?: RateLimitOptions,\n): RateLimitResult {\n const limit = options?.limit ?? DEFAULT_RATE_LIMIT\n const windowMs = options?.windowMs ?? DEFAULT_RATE_WINDOW_MS\n const now = options?.now ?? Date.now()\n const store = options?.store ?? defaultStore\n\n const state = store.get(key)\n if (!state || now - state.windowStartMs >= windowMs) {\n return { allowed: true, limit, remaining: limit, resetMs: now + windowMs }\n }\n return {\n allowed: state.count < limit,\n limit,\n remaining: Math.max(0, limit - state.count),\n resetMs: state.windowStartMs + windowMs,\n }\n}\n\n/**\n * Rate-limit headers, in BOTH spellings (AGL-2727).\n *\n * `X-RateLimit-*` is the de-facto set this API has always sent, and it is kept\n * verbatim — including `Reset` as an epoch second — because integrations are\n * already reading it and a header that changes meaning under a client is worse\n * than one it has to learn.\n *\n * `RateLimit-*` is RFC 9331, which is what a caller that was not written\n * against this specific API looks for, and increasingly what an agent looks\n * for. Same numbers, standard spelling. Its `Reset` is SECONDS REMAINING, not\n * an epoch — that is the RFC's own choice, and the better one: a duration is\n * read against the caller's clock rather than ours, so a skewed clock cannot\n * turn \"wait 12 seconds\" into \"wait until 1970\".\n *\n * ⚠️ Both are computed from the SAME `result`, so they can never disagree.\n * Two independently-derived budgets on one response is a bug that reads as a\n * rounding error.\n */\nexport function rateLimitHeaders(\n result: RateLimitResult,\n now: number = Date.now(),\n): Record<string, string> {\n const remainingSeconds = Math.max(0, Math.ceil((result.resetMs - now) / 1000))\n return {\n 'X-RateLimit-Limit': String(result.limit),\n 'X-RateLimit-Remaining': String(result.remaining),\n 'X-RateLimit-Reset': String(Math.ceil(result.resetMs / 1000)),\n 'RateLimit-Limit': String(result.limit),\n 'RateLimit-Remaining': String(result.remaining),\n 'RateLimit-Reset': String(remainingSeconds),\n }\n}\n"],"names":["errorResponse","status","type","fallbackMessage","init","error","message","code","fields","Object","keys","length","Response","json","headers","ApiErrors","unauthorized","planRequired","insufficientScope","scope","forbidden","notFound","badRequest","conflict","methodNotAllowed","unsupportedMediaType","payloadTooLarge","unavailableForLegalReasons","rateLimited","retryAfterSec","String","Math","max","internal","withResponseHeaders","response","merged","Headers","name","value","entries","set","body","statusText","apiJson","data","DEFAULT_PAGE_LIMIT","MAX_PAGE_LIMIT","parseLimit","raw","n","Number","isFinite","min","floor","encodeCursor","Buffer","from","toString","decodeCursor","cursor","undefined","decoded","listResponse","nextCursor","object","next_cursor","has_more","DEFAULT_RATE_LIMIT","DEFAULT_RATE_WINDOW_MS","defaultStore","Map","checkRateLimit","key","options","limit","windowMs","now","Date","store","state","get","windowStartMs","count","allowed","remaining","resetMs","peekRateLimit","rateLimitHeaders","result","remainingSeconds","ceil"],"mappings":"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"}
@@ -46,6 +46,13 @@ export interface ApiV1Context {
46
46
  firestore: FirebaseFirestore.Firestore;
47
47
  /** Rate-limit headers to echo on every response for this request. */
48
48
  headers: Record<string, string>;
49
+ /**
50
+ * Loads every plugin's console server surface, for a handler that checks
51
+ * a write against something other plugins register there — a custom field
52
+ * type. Absent where the caller has no plugin loader, and then nothing
53
+ * beyond what is already registered is loaded.
54
+ */
55
+ loadPluginSurfaces?: () => Promise<void>;
49
56
  }
50
57
  /** Return a 403 unless the key carries `scope`; otherwise `null` (proceed). */
51
58
  export declare function requireScope(context: ApiV1Context, scope: ApiScope): Response | null;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-kit.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 * What every `/v1` resource handler shares (AGL-2606): the context the\n * pipeline hands it, the scope check, the cursor, serialization and\n * idempotency helpers, and the shape a usage figure is published in.\n *\n * One copy for every resource, whoever serves it. A second copy of the\n * cursor grammar or the idempotency claim in any handler is exactly the drift\n * these exist to prevent: a cursor one resource encodes and another cannot\n * decode, or a claim digest that collides across resources because one copy\n * forgot the org. They are the data layer's because a resource a plugin\n * serves (`api-v1-resources.ts`) is a handler too, and a plugin may not\n * import the console.\n */\nimport { type AttemptClaim, claimAttempt } from '@aglyn/aglyn/server'\nimport type { AglynOrganization } from '@aglyn/aglyn'\nimport { FieldPath, Timestamp } from 'firebase-admin/firestore'\nimport {\n apiJson,\n ApiErrors,\n decodeCursor,\n encodeCursor,\n parseLimit,\n} from './api-http'\nimport type { ApiScope } from './api-keys'\n\n// ── The context ─────────────────────────────────────────────────────────────\n\n/** What the pipeline resolved for one authenticated request. */\nexport interface ApiV1Context {\n orgId: string\n keyId: string\n /**\n * The key's name, as the organization labeled it, or null for a key with\n * none. What a write made with the key is attributed to in the console's\n * activity feeds (AGL-2632) — the key is the actor, and \"API key Zapier\"\n * is its name the way an address is a person's.\n */\n keyName: string | null\n scopes: ApiScope[]\n org: Partial<AglynOrganization>\n firestore: FirebaseFirestore.Firestore\n /** Rate-limit headers to echo on every response for this request. */\n headers: Record<string, string>\n}\n\n/** Return a 403 unless the key carries `scope`; otherwise `null` (proceed). */\nexport function requireScope(\n context: ApiV1Context,\n scope: ApiScope,\n): Response | null {\n return context.scopes.includes(scope)\n ? null\n : ApiErrors.insufficientScope(scope, context.headers)\n}\n\n// ── Serialization ───────────────────────────────────────────────────────────\n\n/** Firestore values → JSON-safe values (Timestamps become ISO strings). */\nexport function serialize(value: unknown): unknown {\n if (value instanceof Timestamp) return value.toDate().toISOString()\n if (Array.isArray(value)) return value.map(serialize)\n if (value && typeof value === 'object') {\n const out: Record<string, unknown> = {}\n for (const [k, v] of Object.entries(value)) out[k] = serialize(v)\n return out\n }\n return value\n}\n\n// ── Cursor pagination over a Firestore collection ───────────────────────────\n\nexport interface Paginated {\n docs: FirebaseFirestore.QueryDocumentSnapshot[]\n nextCursor: string | null\n}\n\nexport async function paginate(\n query: FirebaseFirestore.Query,\n url: URL,\n): Promise<Paginated> {\n const limit = parseLimit(url.searchParams.get('limit'))\n const cursor = decodeCursor(url.searchParams.get('cursor'))\n let q = query.orderBy(FieldPath.documentId()).limit(limit + 1)\n if (cursor) q = q.startAfter(cursor)\n const snap = await q.get()\n const docs = snap.docs.slice(0, limit)\n const nextCursor =\n snap.docs.length > limit && docs.length > 0\n ? encodeCursor(docs[docs.length - 1].id)\n : null\n return { docs, nextCursor }\n}\n\nexport async function readJsonBody(request: Request): Promise<Record<string, unknown>> {\n try {\n const body = await request.json()\n return body && typeof body === 'object' ? body : {}\n } catch {\n return {}\n }\n}\n\n// ── Idempotency ─────────────────────────────────────────────────────────────\n\n/**\n * The operations a claim can belong to. Hashed into the digest, so a key\n * reused across two of them is two separate attempts — see `claimWrite`.\n *\n * The CRM pairs (AGL-2606) follow the contact pair's shape: a create and a\n * delete per resource, sharing the organization as their scope suffix and\n * kept apart by the kind alone.\n */\nexport type ClaimKind =\n | 'records'\n | 'record-deletes'\n | 'datasets'\n | 'dataset-deletes'\n | 'form-submission-deletes'\n | 'contacts'\n | 'contact-deletes'\n // A merge destroys the source contact (AGL-2625), so it claims like a\n // delete and records the survivor's view as its receipt.\n | 'contact-merges'\n | 'media'\n | 'sites'\n | 'companies'\n | 'company-deletes'\n | 'deals'\n | 'deal-deletes'\n | 'tasks'\n | 'task-deletes'\n | 'activities'\n | 'activity-deletes'\n | 'lead-conversions'\n // A lead created over the API (AGL-3231) — the door updates an existing\n // address, so the receipt is the lead's view either way.\n | 'lead-creates'\n | 'email-templates'\n | 'email-template-deletes'\n\n/**\n * Take an exclusive claim on one write attempt (AGL-1709), and translate the\n * transport-agnostic result into a v1 `Response`.\n *\n * The claim itself is the shared `claimAttempt` (AGL-1697, `220217133`) — an\n * unconditional `create()`, where Firestore's rejection on an existing document\n * IS the dedupe primitive. This resource used to run its own read-then-create,\n * which is the exact race the mechanism exists to prevent: two concurrent\n * requests carrying one key both read \"no prior key\", both fall through, both\n * create. Worse than no check at all in one respect, because it made the path\n * look protected. Adopting the shared claim rather than fixing the local copy\n * is the point — POS, refunds and the REST API now answer this question once.\n *\n * `scopeId` carries the ORG as well as the dataset. The helper hashes\n * `{kind}:{scopeId}:{key}` and its plugin callers pass a globally unique host\n * id, but a dataset id is only meaningful under its org — and the claim stores\n * the response body, so a cross-org digest collision would replay one tenant's\n * record to another. The org belongs in the digest, not just in the swept\n * field. Scoping by the DATASET is what `apps/docs/api/conventions.md` already\n * publishes (\"replay is looked up within the dataset you're posting to\"); the\n * old org-only digest only reached that outcome by accident, by reading a\n * `recordId` that did not exist in the other dataset and falling through — and\n * having fallen through, its key write failed and was swallowed, so the key\n * never deduped there at all.\n *\n * NOTE: the digest changes, so keys stored under the old `orgId:key` shape are\n * orphaned and a retry straddling this deploy creates a duplicate. Accepted —\n * the API is pre-beta and the alternative is carrying the race forever.\n *\n * `kind` separates the create's claims from the delete's (AGL-1710). The\n * helper hashes it into the digest for exactly this reason, and the two record\n * different response BODIES — a record view versus a `{ deleted: true }`\n * receipt. Sharing one namespace would let a key reused across both operations\n * replay the create's record to a delete, which a client parses as a success\n * and which no amount of documentation makes safe. `POST` keeps `records`\n * verbatim: changing its digest orphans keys in flight, and AGL-1709 already\n * paid that cost once.\n *\n * `scopeSuffix` is the object the key is scoped WITHIN — a dataset id for the\n * record operations and for `DELETE /v1/datasets/{id}`, the site for\n * `DELETE …/form-submissions/{id}`, and `*` — the ORGANIZATION — for\n * `POST /v1/datasets` (AGL-2126), where there is no dataset id yet because\n * the request is what creates one, and for both contact writes (AGL-2276),\n * where the organization genuinely IS the containing object: contacts are\n * org-scoped (AGL-237), so there is no site or parent collection to scope to\n * and pretending otherwise would invent a boundary the data does not have.\n * `*` cannot collide with a resource id: `createResourceUid()` never emits\n * one, and the `kind` is separate anyway. The suffix is not the whole story —\n * `kind` is hashed in too, which is what keeps a create's key from replaying\n * into a delete, and what keeps `contacts` and `contact-deletes` apart while\n * they share a suffix.\n *\n * This paragraph used to say `*` covered \"the two that act on the dataset\n * collection itself\", which the delete has never done — it passes\n * `datasetRef.id`. The published contract in `apps/docs/api/conventions.md` was\n * written from this comment and inherited the error (AGL-2218), which is the\n * argument for reading the call rather than the docblock above it.\n */\nexport async function claimWrite(\n ctx: ApiV1Context,\n scopeSuffix: string,\n key: string | null,\n kind: ClaimKind,\n): Promise<{ claim: AttemptClaim } | { replay: Response }> {\n const result = await claimAttempt(ctx.firestore, {\n kind,\n scopeId: `${ctx.orgId}:${scopeSuffix}`,\n // The field `eraseOrgIdempotencyKeys` sweeps on (AGL-1448).\n orgId: ctx.orgId,\n key: key ?? '',\n busyMessage: 'A request with this Idempotency-Key is still in progress',\n })\n if ('claim' in result) return result\n // The helper is transport-agnostic and answers `{ status, body }`; its\n // plugin callers hand that straight to a pages-API `res.json()`. v1 publishes\n // a `{ error: { type, message, code } }` envelope that clients branch on, so\n // the refusal is rebuilt here rather than leaking the helper's bare\n // `{ error: '<sentence>' }` onto a documented surface.\n if (result.replay.status === 409) {\n return {\n replay: ApiErrors.conflict({\n message: 'A request with this Idempotency-Key is still in progress',\n code: 'idempotency_in_progress',\n headers: ctx.headers,\n }),\n }\n }\n // Settled: replay the ORIGINAL response, from the stored body rather than a\n // fresh read — which is what makes it survive a record since edited or\n // deleted. The old lookup replayed only while the record still existed and\n // fell through to a SECOND create otherwise.\n return {\n replay: apiJson(result.replay.body, {\n status: result.replay.status,\n headers: ctx.headers,\n }),\n }\n}\n\n// ── Usage ───────────────────────────────────────────────────────────────────\n\n/**\n * One metered dimension, as published (AGL-2277).\n *\n * `included`/`remaining` are `null` for an UNLIMITED band rather than the\n * sentinel itself: `UNLIMITED` is `Number.POSITIVE_INFINITY`, which\n * `JSON.stringify` silently turns into `null` anyway — so the choice is\n * between a `null` that means \"unlimited\" on purpose and the same `null`\n * arriving by accident, indistinguishable from a bug. Stated explicitly, and\n * documented, so an integrator can branch on it.\n *\n * `metered` is the field that actually answers \"what happens when I cross\n * this\" — true means the excess bills, false means the next call is refused.\n * It is read off the plan's own overage rate through the `check*Quota`\n * helpers rather than re-derived here, because a second copy of that rule\n * would drift from the one the enforcement path uses, and this endpoint's\n * whole value is telling a customer what the enforcement path will do.\n */\nexport function usageBand(\n used: number,\n included: number,\n remaining: number,\n overageRateUsd: number | null,\n) {\n const unlimited = !Number.isFinite(included)\n return {\n used,\n included: unlimited ? null : included,\n remaining: unlimited ? null : remaining,\n metered: overageRateUsd !== null,\n }\n}\n\n// ── Ownership ───────────────────────────────────────────────────────────────\n\n/** Whether `hostId` is one of the authenticated organization's own sites. */\nexport function orgOwnsHost(ctx: ApiV1Context, hostId: string): boolean {\n const hosts = (ctx.org.hosts ?? {}) as Record<string, unknown>\n return Boolean(hosts[hostId])\n}\n"],"names":["claimAttempt","FieldPath","Timestamp","apiJson","ApiErrors","decodeCursor","encodeCursor","parseLimit","requireScope","context","scope","scopes","includes","insufficientScope","headers","serialize","value","toDate","toISOString","Array","isArray","map","out","k","v","Object","entries","paginate","query","url","limit","searchParams","get","cursor","q","orderBy","documentId","startAfter","snap","docs","slice","nextCursor","length","id","readJsonBody","request","body","json","claimWrite","ctx","scopeSuffix","key","kind","result","firestore","scopeId","orgId","busyMessage","replay","status","conflict","message","code","usageBand","used","included","remaining","overageRateUsd","unlimited","Number","isFinite","metered","orgOwnsHost","hostId","hosts","org","Boolean"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-kit.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 * What every `/v1` resource handler shares (AGL-2606): the context the\n * pipeline hands it, the scope check, the cursor, serialization and\n * idempotency helpers, and the shape a usage figure is published in.\n *\n * One copy for every resource, whoever serves it. A second copy of the\n * cursor grammar or the idempotency claim in any handler is exactly the drift\n * these exist to prevent: a cursor one resource encodes and another cannot\n * decode, or a claim digest that collides across resources because one copy\n * forgot the org. They are the data layer's because a resource a plugin\n * serves (`api-v1-resources.ts`) is a handler too, and a plugin may not\n * import the console.\n */\nimport { type AttemptClaim, claimAttempt } from '@aglyn/aglyn/server'\nimport type { AglynOrganization } from '@aglyn/aglyn'\nimport { FieldPath, Timestamp } from 'firebase-admin/firestore'\nimport {\n apiJson,\n ApiErrors,\n decodeCursor,\n encodeCursor,\n parseLimit,\n} from './api-http'\nimport type { ApiScope } from './api-keys'\n\n// ── The context ─────────────────────────────────────────────────────────────\n\n/** What the pipeline resolved for one authenticated request. */\nexport interface ApiV1Context {\n orgId: string\n keyId: string\n /**\n * The key's name, as the organization labeled it, or null for a key with\n * none. What a write made with the key is attributed to in the console's\n * activity feeds (AGL-2632) — the key is the actor, and \"API key Zapier\"\n * is its name the way an address is a person's.\n */\n keyName: string | null\n scopes: ApiScope[]\n org: Partial<AglynOrganization>\n firestore: FirebaseFirestore.Firestore\n /** Rate-limit headers to echo on every response for this request. */\n headers: Record<string, string>\n /**\n * Loads every plugin's console server surface, for a handler that checks\n * a write against something other plugins register there — a custom field\n * type. Absent where the caller has no plugin loader, and then nothing\n * beyond what is already registered is loaded.\n */\n loadPluginSurfaces?: () => Promise<void>\n}\n\n/** Return a 403 unless the key carries `scope`; otherwise `null` (proceed). */\nexport function requireScope(\n context: ApiV1Context,\n scope: ApiScope,\n): Response | null {\n return context.scopes.includes(scope)\n ? null\n : ApiErrors.insufficientScope(scope, context.headers)\n}\n\n// ── Serialization ───────────────────────────────────────────────────────────\n\n/** Firestore values → JSON-safe values (Timestamps become ISO strings). */\nexport function serialize(value: unknown): unknown {\n if (value instanceof Timestamp) return value.toDate().toISOString()\n if (Array.isArray(value)) return value.map(serialize)\n if (value && typeof value === 'object') {\n const out: Record<string, unknown> = {}\n for (const [k, v] of Object.entries(value)) out[k] = serialize(v)\n return out\n }\n return value\n}\n\n// ── Cursor pagination over a Firestore collection ───────────────────────────\n\nexport interface Paginated {\n docs: FirebaseFirestore.QueryDocumentSnapshot[]\n nextCursor: string | null\n}\n\nexport async function paginate(\n query: FirebaseFirestore.Query,\n url: URL,\n): Promise<Paginated> {\n const limit = parseLimit(url.searchParams.get('limit'))\n const cursor = decodeCursor(url.searchParams.get('cursor'))\n let q = query.orderBy(FieldPath.documentId()).limit(limit + 1)\n if (cursor) q = q.startAfter(cursor)\n const snap = await q.get()\n const docs = snap.docs.slice(0, limit)\n const nextCursor =\n snap.docs.length > limit && docs.length > 0\n ? encodeCursor(docs[docs.length - 1].id)\n : null\n return { docs, nextCursor }\n}\n\nexport async function readJsonBody(request: Request): Promise<Record<string, unknown>> {\n try {\n const body = await request.json()\n return body && typeof body === 'object' ? body : {}\n } catch {\n return {}\n }\n}\n\n// ── Idempotency ─────────────────────────────────────────────────────────────\n\n/**\n * The operations a claim can belong to. Hashed into the digest, so a key\n * reused across two of them is two separate attempts — see `claimWrite`.\n *\n * The CRM pairs (AGL-2606) follow the contact pair's shape: a create and a\n * delete per resource, sharing the organization as their scope suffix and\n * kept apart by the kind alone.\n */\nexport type ClaimKind =\n | 'records'\n | 'record-deletes'\n | 'datasets'\n | 'dataset-deletes'\n | 'form-submission-deletes'\n | 'contacts'\n | 'contact-deletes'\n // A merge destroys the source contact (AGL-2625), so it claims like a\n // delete and records the survivor's view as its receipt.\n | 'contact-merges'\n | 'media'\n | 'sites'\n | 'companies'\n | 'company-deletes'\n | 'deals'\n | 'deal-deletes'\n | 'tasks'\n | 'task-deletes'\n | 'activities'\n | 'activity-deletes'\n | 'lead-conversions'\n // A lead created over the API (AGL-3231) — the door updates an existing\n // address, so the receipt is the lead's view either way.\n | 'lead-creates'\n | 'email-templates'\n | 'email-template-deletes'\n\n/**\n * Take an exclusive claim on one write attempt (AGL-1709), and translate the\n * transport-agnostic result into a v1 `Response`.\n *\n * The claim itself is the shared `claimAttempt` (AGL-1697, `220217133`) — an\n * unconditional `create()`, where Firestore's rejection on an existing document\n * IS the dedupe primitive. This resource used to run its own read-then-create,\n * which is the exact race the mechanism exists to prevent: two concurrent\n * requests carrying one key both read \"no prior key\", both fall through, both\n * create. Worse than no check at all in one respect, because it made the path\n * look protected. Adopting the shared claim rather than fixing the local copy\n * is the point — POS, refunds and the REST API now answer this question once.\n *\n * `scopeId` carries the ORG as well as the dataset. The helper hashes\n * `{kind}:{scopeId}:{key}` and its plugin callers pass a globally unique host\n * id, but a dataset id is only meaningful under its org — and the claim stores\n * the response body, so a cross-org digest collision would replay one tenant's\n * record to another. The org belongs in the digest, not just in the swept\n * field. Scoping by the DATASET is what `apps/docs/api/conventions.md` already\n * publishes (\"replay is looked up within the dataset you're posting to\"); the\n * old org-only digest only reached that outcome by accident, by reading a\n * `recordId` that did not exist in the other dataset and falling through — and\n * having fallen through, its key write failed and was swallowed, so the key\n * never deduped there at all.\n *\n * NOTE: the digest changes, so keys stored under the old `orgId:key` shape are\n * orphaned and a retry straddling this deploy creates a duplicate. Accepted —\n * the API is pre-beta and the alternative is carrying the race forever.\n *\n * `kind` separates the create's claims from the delete's (AGL-1710). The\n * helper hashes it into the digest for exactly this reason, and the two record\n * different response BODIES — a record view versus a `{ deleted: true }`\n * receipt. Sharing one namespace would let a key reused across both operations\n * replay the create's record to a delete, which a client parses as a success\n * and which no amount of documentation makes safe. `POST` keeps `records`\n * verbatim: changing its digest orphans keys in flight, and AGL-1709 already\n * paid that cost once.\n *\n * `scopeSuffix` is the object the key is scoped WITHIN — a dataset id for the\n * record operations and for `DELETE /v1/datasets/{id}`, the site for\n * `DELETE …/form-submissions/{id}`, and `*` — the ORGANIZATION — for\n * `POST /v1/datasets` (AGL-2126), where there is no dataset id yet because\n * the request is what creates one, and for both contact writes (AGL-2276),\n * where the organization genuinely IS the containing object: contacts are\n * org-scoped (AGL-237), so there is no site or parent collection to scope to\n * and pretending otherwise would invent a boundary the data does not have.\n * `*` cannot collide with a resource id: `createResourceUid()` never emits\n * one, and the `kind` is separate anyway. The suffix is not the whole story —\n * `kind` is hashed in too, which is what keeps a create's key from replaying\n * into a delete, and what keeps `contacts` and `contact-deletes` apart while\n * they share a suffix.\n *\n * This paragraph used to say `*` covered \"the two that act on the dataset\n * collection itself\", which the delete has never done — it passes\n * `datasetRef.id`. The published contract in `apps/docs/api/conventions.md` was\n * written from this comment and inherited the error (AGL-2218), which is the\n * argument for reading the call rather than the docblock above it.\n */\nexport async function claimWrite(\n ctx: ApiV1Context,\n scopeSuffix: string,\n key: string | null,\n kind: ClaimKind,\n): Promise<{ claim: AttemptClaim } | { replay: Response }> {\n const result = await claimAttempt(ctx.firestore, {\n kind,\n scopeId: `${ctx.orgId}:${scopeSuffix}`,\n // The field `eraseOrgIdempotencyKeys` sweeps on (AGL-1448).\n orgId: ctx.orgId,\n key: key ?? '',\n busyMessage: 'A request with this Idempotency-Key is still in progress',\n })\n if ('claim' in result) return result\n // The helper is transport-agnostic and answers `{ status, body }`; its\n // plugin callers hand that straight to a pages-API `res.json()`. v1 publishes\n // a `{ error: { type, message, code } }` envelope that clients branch on, so\n // the refusal is rebuilt here rather than leaking the helper's bare\n // `{ error: '<sentence>' }` onto a documented surface.\n if (result.replay.status === 409) {\n return {\n replay: ApiErrors.conflict({\n message: 'A request with this Idempotency-Key is still in progress',\n code: 'idempotency_in_progress',\n headers: ctx.headers,\n }),\n }\n }\n // Settled: replay the ORIGINAL response, from the stored body rather than a\n // fresh read — which is what makes it survive a record since edited or\n // deleted. The old lookup replayed only while the record still existed and\n // fell through to a SECOND create otherwise.\n return {\n replay: apiJson(result.replay.body, {\n status: result.replay.status,\n headers: ctx.headers,\n }),\n }\n}\n\n// ── Usage ───────────────────────────────────────────────────────────────────\n\n/**\n * One metered dimension, as published (AGL-2277).\n *\n * `included`/`remaining` are `null` for an UNLIMITED band rather than the\n * sentinel itself: `UNLIMITED` is `Number.POSITIVE_INFINITY`, which\n * `JSON.stringify` silently turns into `null` anyway — so the choice is\n * between a `null` that means \"unlimited\" on purpose and the same `null`\n * arriving by accident, indistinguishable from a bug. Stated explicitly, and\n * documented, so an integrator can branch on it.\n *\n * `metered` is the field that actually answers \"what happens when I cross\n * this\" — true means the excess bills, false means the next call is refused.\n * It is read off the plan's own overage rate through the `check*Quota`\n * helpers rather than re-derived here, because a second copy of that rule\n * would drift from the one the enforcement path uses, and this endpoint's\n * whole value is telling a customer what the enforcement path will do.\n */\nexport function usageBand(\n used: number,\n included: number,\n remaining: number,\n overageRateUsd: number | null,\n) {\n const unlimited = !Number.isFinite(included)\n return {\n used,\n included: unlimited ? null : included,\n remaining: unlimited ? null : remaining,\n metered: overageRateUsd !== null,\n }\n}\n\n// ── Ownership ───────────────────────────────────────────────────────────────\n\n/** Whether `hostId` is one of the authenticated organization's own sites. */\nexport function orgOwnsHost(ctx: ApiV1Context, hostId: string): boolean {\n const hosts = (ctx.org.hosts ?? {}) as Record<string, unknown>\n return Boolean(hosts[hostId])\n}\n"],"names":["claimAttempt","FieldPath","Timestamp","apiJson","ApiErrors","decodeCursor","encodeCursor","parseLimit","requireScope","context","scope","scopes","includes","insufficientScope","headers","serialize","value","toDate","toISOString","Array","isArray","map","out","k","v","Object","entries","paginate","query","url","limit","searchParams","get","cursor","q","orderBy","documentId","startAfter","snap","docs","slice","nextCursor","length","id","readJsonBody","request","body","json","claimWrite","ctx","scopeSuffix","key","kind","result","firestore","scopeId","orgId","busyMessage","replay","status","conflict","message","code","usageBand","used","included","remaining","overageRateUsd","unlimited","Number","isFinite","metered","orgOwnsHost","hostId","hosts","org","Boolean"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GACD,SAA4BA,YAAY,QAAQ,sBAAqB;AAErE,SAASC,SAAS,EAAEC,SAAS,QAAQ,2BAA0B;AAC/D,SACEC,OAAO,EACPC,SAAS,EACTC,YAAY,EACZC,YAAY,EACZC,UAAU,QACL,gBAAY;AA8BnB,6EAA6E,GAC7E,OAAO,SAASC,aACdC,OAAqB,EACrBC,KAAe;IAEf,OAAOD,QAAQE,MAAM,CAACC,QAAQ,CAACF,SAC3B,OACAN,UAAUS,iBAAiB,CAACH,OAAOD,QAAQK,OAAO;AACxD;AAEA,+EAA+E;AAE/E,yEAAyE,GACzE,OAAO,SAASC,UAAUC,KAAc;IACtC,IAAIA,iBAAiBd,WAAW,OAAOc,MAAMC,MAAM,GAAGC,WAAW;IACjE,IAAIC,MAAMC,OAAO,CAACJ,QAAQ,OAAOA,MAAMK,GAAG,CAACN;IAC3C,IAAIC,SAAS,OAAOA,UAAU,UAAU;QACtC,MAAMM,MAA+B,CAAC;QACtC,KAAK,MAAM,CAACC,GAAGC,EAAE,IAAIC,OAAOC,OAAO,CAACV,OAAQM,GAAG,CAACC,EAAE,GAAGR,UAAUS;QAC/D,OAAOF;IACT;IACA,OAAON;AACT;AASA,OAAO,eAAeW,SACpBC,KAA8B,EAC9BC,GAAQ;IAER,MAAMC,QAAQvB,WAAWsB,IAAIE,YAAY,CAACC,GAAG,CAAC;IAC9C,MAAMC,SAAS5B,aAAawB,IAAIE,YAAY,CAACC,GAAG,CAAC;IACjD,IAAIE,IAAIN,MAAMO,OAAO,CAAClC,UAAUmC,UAAU,IAAIN,KAAK,CAACA,QAAQ;IAC5D,IAAIG,QAAQC,IAAIA,EAAEG,UAAU,CAACJ;IAC7B,MAAMK,OAAO,MAAMJ,EAAEF,GAAG;IACxB,MAAMO,OAAOD,KAAKC,IAAI,CAACC,KAAK,CAAC,GAAGV;IAChC,MAAMW,aACJH,KAAKC,IAAI,CAACG,MAAM,GAAGZ,SAASS,KAAKG,MAAM,GAAG,IACtCpC,aAAaiC,IAAI,CAACA,KAAKG,MAAM,GAAG,EAAE,CAACC,EAAE,IACrC;IACN,OAAO;QAAEJ;QAAME;IAAW;AAC5B;AAEA,OAAO,eAAeG,aAAaC,OAAgB;IACjD,IAAI;QACF,MAAMC,OAAO,MAAMD,QAAQE,IAAI;QAC/B,OAAOD,QAAQ,OAAOA,SAAS,WAAWA,OAAO,CAAC;IACpD,EAAE,eAAM;QACN,OAAO,CAAC;IACV;AACF;AAwCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyDC,GACD,OAAO,eAAeE,WACpBC,GAAiB,EACjBC,WAAmB,EACnBC,GAAkB,EAClBC,IAAe;IAEf,MAAMC,SAAS,MAAMrD,aAAaiD,IAAIK,SAAS,EAAE;QAC/CF;QACAG,SAAS,GAAGN,IAAIO,KAAK,CAAC,CAAC,EAAEN,aAAa;QACtC,4DAA4D;QAC5DM,OAAOP,IAAIO,KAAK;QAChBL,GAAG,EAAEA,cAAAA,MAAO;QACZM,aAAa;IACf;IACA,IAAI,WAAWJ,QAAQ,OAAOA;IAC9B,uEAAuE;IACvE,8EAA8E;IAC9E,6EAA6E;IAC7E,oEAAoE;IACpE,uDAAuD;IACvD,IAAIA,OAAOK,MAAM,CAACC,MAAM,KAAK,KAAK;QAChC,OAAO;YACLD,QAAQtD,UAAUwD,QAAQ,CAAC;gBACzBC,SAAS;gBACTC,MAAM;gBACNhD,SAASmC,IAAInC,OAAO;YACtB;QACF;IACF;IACA,4EAA4E;IAC5E,uEAAuE;IACvE,2EAA2E;IAC3E,6CAA6C;IAC7C,OAAO;QACL4C,QAAQvD,QAAQkD,OAAOK,MAAM,CAACZ,IAAI,EAAE;YAClCa,QAAQN,OAAOK,MAAM,CAACC,MAAM;YAC5B7C,SAASmC,IAAInC,OAAO;QACtB;IACF;AACF;AAEA,+EAA+E;AAE/E;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASiD,UACdC,IAAY,EACZC,QAAgB,EAChBC,SAAiB,EACjBC,cAA6B;IAE7B,MAAMC,YAAY,CAACC,OAAOC,QAAQ,CAACL;IACnC,OAAO;QACLD;QACAC,UAAUG,YAAY,OAAOH;QAC7BC,WAAWE,YAAY,OAAOF;QAC9BK,SAASJ,mBAAmB;IAC9B;AACF;AAEA,+EAA+E;AAE/E,2EAA2E,GAC3E,OAAO,SAASK,YAAYvB,GAAiB,EAAEwB,MAAc;QAC5CxB;IAAf,MAAMyB,SAASzB,iBAAAA,IAAI0B,GAAG,CAACD,KAAK,YAAbzB,iBAAiB,CAAC;IACjC,OAAO2B,QAAQF,KAAK,CAACD,OAAO;AAC9B"}
@@ -22,8 +22,7 @@ import type { ApiV1Context } from './api-v1-kit';
22
22
  *
23
23
  * The console's `/v1` router owns the pipeline — the API key, the
24
24
  * `apiAccess` entitlement, the request quota, the rate limit, the error
25
- * envelope — and the platform's own resources (datasets, sites, the media
26
- * library). A resource a plugin models is the plugin's: it registers a
25
+ * envelope — and the platform's own resources (sites, the media library). A resource a plugin models is the plugin's: it registers a
27
26
  * handler under the resource's first path segment, and the router hands it
28
27
  * every request under that segment once the key is authenticated, with the
29
28
  * context the pipeline resolved. The handler asks for its own scopes
@@ -19,7 +19,6 @@ export const API_V1_RESOURCES = definePluginServiceContract('core.api-v1-resourc
19
19
  multiple: true
20
20
  });
21
21
  /** The resources the router answers itself, which no plugin may take. */ export const PLATFORM_API_V1_RESOURCES = new Set([
22
- 'datasets',
23
22
  'sites',
24
23
  'media',
25
24
  'usage',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-resources.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 { getRegisteringPluginId } from '@aglyn/aglyn/app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from '@aglyn/aglyn/plugin-manager/plugin-services'\nimport type { ApiV1ResourceDescription } from './api-v1-description'\nimport type { ApiV1Context } from './api-v1-kit'\n\n/**\n * A plugin's resources on the customer REST API, `/v1/<resource>/…`\n * (AGL-3080).\n *\n * The console's `/v1` router owns the pipeline — the API key, the\n * `apiAccess` entitlement, the request quota, the rate limit, the error\n * envelope — and the platform's own resources (datasets, sites, the media\n * library). A resource a plugin models is the plugin's: it registers a\n * handler under the resource's first path segment, and the router hands it\n * every request under that segment once the key is authenticated, with the\n * context the pipeline resolved. The handler asks for its own scopes\n * (`requireScope`) and answers in the published envelope.\n *\n * ## The published contract does not move\n *\n * A path, a field, a scope and a status are the same whichever module\n * answers them. The OpenAPI document is still the router's to build, and a\n * registration hands it the resource's description (`describe`), so the\n * document — and the MCP tools derived from it — describe every resource the\n * build serves and nothing it does not. Moving a resource into its plugin\n * changes where it is written, never what an integrator sees.\n *\n * ## What the organization's usage reports\n *\n * `GET /v1/usage` is the platform's; a plugin whose records the plan bands\n * or an integration sizes a sync by adds its own figures to it\n * (`registerApiV1UsageFigures`), read with the platform's on every call.\n *\n * ## The plan, before the handler\n *\n * A registration may name the plan feature its resource needs\n * (`entitlement`). The router refuses an organization without it —\n * `402 plan_required` with the feature as the `code` and the registration's\n * own sentence — BEFORE the handler runs and before any scope is asked, so a\n * key minted while the plan carried the feature cannot outlive it.\n *\n * ## Registered at the console's boot\n *\n * From the plugin's console server declarations, with the handler loaded on\n * the first request. The router asks for a resource it does not know once\n * more after running the app's declarations step\n * (`runPluginDeclarationsRepair`), so a process whose boot failed repairs\n * itself rather than answering `404` for a resource it serves. No\n * registration means no plugin in this build serves the resource: the\n * router's `404` for an unknown endpoint is then the truth.\n *\n * ## One owner per resource\n *\n * A second plugin registering a resource another already serves is refused,\n * naming both, and the incumbent keeps serving. The platform's own\n * resources are not registrable.\n */\n\n/** Answers one request under the resource, after the pipeline admitted it. */\nexport type ApiV1ResourceHandler = (\n request: Request,\n context: ApiV1Context,\n /** The path under `/v1`, split — `['contacts', '<id>', 'merge']`. */\n segments: string[],\n url: URL,\n) => Promise<Response>\n\nexport interface ApiV1Resource {\n handle: ApiV1ResourceHandler\n /**\n * The plan feature the resource needs, and the sentence a refusal says —\n * `The CRM … is not included in this organization's plan`.\n */\n entitlement?: { feature: string; message: string }\n /**\n * The resource as the OpenAPI document describes it, loaded when the\n * document is built. Absent, the resource is served and undocumented.\n */\n describe?: () => Promise<ApiV1ResourceDescription>\n}\n\nexport const API_V1_RESOURCES = definePluginServiceContract<ApiV1Resource>(\n 'core.api-v1-resources',\n { multiple: true },\n)\n\n/** The resources the router answers itself, which no plugin may take. */\nexport const PLATFORM_API_V1_RESOURCES: ReadonlySet<string> = new Set([\n 'datasets',\n 'sites',\n 'media',\n 'usage',\n 'me',\n 'openapi.json',\n])\n\n/** A resource name as a path segment carries it. */\nconst RESOURCE = /^[a-z][a-z0-9-]*$/\n\n/**\n * Serves `/v1/<resource>` from a plugin. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, as it does for a name that is not one lowercase path\n * segment, a platform resource, and a resource another plugin serves. The\n * same plugin registering again replaces its own.\n */\nexport function registerApiV1Resource(\n resource: string,\n registration: ApiV1Resource,\n options?: { pluginId?: string },\n): void {\n const key = String(resource ?? '').trim()\n if (!RESOURCE.test(key)) {\n throw new Error(`a /v1 resource needs a lowercase path segment for its name, not \"${key}\"`)\n }\n if (PLATFORM_API_V1_RESOURCES.has(key)) {\n throw new Error(`/v1/${key} is the platform's own resource`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error(`the /v1/${key} resource was registered with no owner`)\n const incumbent = resolvePluginServices(API_V1_RESOURCES).find((entry) => entry.key === key)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `/v1/${key} is already served by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(API_V1_RESOURCES, registration, { pluginId, key })\n}\n\n/** Every resource the plugins serve here, in registration order. */\nexport function apiV1Resources(): Array<{ name: string; pluginId: string; resource: ApiV1Resource }> {\n return resolvePluginServices(API_V1_RESOURCES).map((entry) => ({\n name: entry.key ?? '',\n pluginId: entry.pluginId,\n resource: entry.impl,\n }))\n}\n\n/**\n * The descriptions of every resource the plugins serve, in registration\n * order. A description that fails to load is left out and logged, rather\n * than taking the platform's own description with it.\n */\nexport async function describeApiV1Resources(): Promise<ApiV1ResourceDescription[]> {\n const described = await Promise.all(\n apiV1Resources().map(async ({ name, pluginId, resource }) => {\n if (!resource.describe) return null\n try {\n return await resource.describe()\n } catch (error) {\n console.error(`[api-v1] ${pluginId} could not describe /v1/${name}`, error)\n return null\n }\n }),\n )\n return described.filter((one): one is ApiV1ResourceDescription => one !== null)\n}\n\n/** The plugin serving `/v1/<resource>` here, or `null` when none does. */\nexport function apiV1Resource(\n resource: string,\n): { pluginId: string; resource: ApiV1Resource } | null {\n const key = String(resource ?? '').trim()\n const entry = resolvePluginServices(API_V1_RESOURCES).find((one) => one.key === key)\n return entry ? { pluginId: entry.pluginId, resource: entry.impl } : null\n}\n\n// ── Usage ──────────────────────────────────────────────────────────────────\n\n/**\n * A plugin's figures on `GET /v1/usage`, as top-level members of the usage\n * object — a band the plan meters, or a size an integration plans a sync by\n * (`usageBand` in `api-v1-kit.ts` gives either its published shape). Read on\n * every call, with the request's context; a reader that throws fails the\n * request, as a platform figure that cannot be read does.\n */\nexport type ApiV1UsageFigures = (context: ApiV1Context) => Promise<Record<string, unknown>>\n\nexport const API_V1_USAGE_FIGURES = definePluginServiceContract<ApiV1UsageFigures>(\n 'core.api-v1-usage-figures',\n { multiple: true },\n)\n\n/**\n * Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;\n * registering again replaces it. The owner is the loader's marker when a\n * register fn is running, else `options.pluginId`.\n */\nexport function registerApiV1UsageFigures(\n read: ApiV1UsageFigures,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('/v1 usage figures were registered with no owner')\n registerPluginService(API_V1_USAGE_FIGURES, read, { pluginId })\n}\n\n/**\n * Every plugin's figures for one request, merged in registration order. A\n * member the platform's own figures (`reserved`) or an earlier plugin\n * already names is dropped and logged: a plugin adds figures, never\n * replaces one.\n */\nexport async function readApiV1UsageFigures(\n context: ApiV1Context,\n reserved: ReadonlySet<string>,\n): Promise<Record<string, unknown>> {\n const readers = resolvePluginServices(API_V1_USAGE_FIGURES)\n const figures = await Promise.all(readers.map((entry) => entry.impl(context)))\n const merged: Record<string, unknown> = {}\n figures.forEach((one, index) => {\n for (const [key, value] of Object.entries(one)) {\n if (reserved.has(key) || key in merged) {\n console.error(`[api-v1] ${readers[index].pluginId} named usage figure \"${key}\", which is taken`)\n continue\n }\n merged[key] = value\n }\n })\n return merged\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","API_V1_RESOURCES","multiple","PLATFORM_API_V1_RESOURCES","Set","RESOURCE","registerApiV1Resource","resource","registration","options","key","String","trim","test","Error","has","pluginId","incumbent","find","entry","apiV1Resources","map","name","impl","describeApiV1Resources","described","Promise","all","describe","error","console","filter","one","apiV1Resource","API_V1_USAGE_FIGURES","registerApiV1UsageFigures","read","readApiV1UsageFigures","context","reserved","readers","figures","merged","forEach","index","value","Object","entries"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-resources.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 { getRegisteringPluginId } from '@aglyn/aglyn/app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from '@aglyn/aglyn/plugin-manager/plugin-services'\nimport type { ApiV1ResourceDescription } from './api-v1-description'\nimport type { ApiV1Context } from './api-v1-kit'\n\n/**\n * A plugin's resources on the customer REST API, `/v1/<resource>/…`\n * (AGL-3080).\n *\n * The console's `/v1` router owns the pipeline — the API key, the\n * `apiAccess` entitlement, the request quota, the rate limit, the error\n * envelope — and the platform's own resources (sites, the media library). A resource a plugin models is the plugin's: it registers a\n * handler under the resource's first path segment, and the router hands it\n * every request under that segment once the key is authenticated, with the\n * context the pipeline resolved. The handler asks for its own scopes\n * (`requireScope`) and answers in the published envelope.\n *\n * ## The published contract does not move\n *\n * A path, a field, a scope and a status are the same whichever module\n * answers them. The OpenAPI document is still the router's to build, and a\n * registration hands it the resource's description (`describe`), so the\n * document — and the MCP tools derived from it — describe every resource the\n * build serves and nothing it does not. Moving a resource into its plugin\n * changes where it is written, never what an integrator sees.\n *\n * ## What the organization's usage reports\n *\n * `GET /v1/usage` is the platform's; a plugin whose records the plan bands\n * or an integration sizes a sync by adds its own figures to it\n * (`registerApiV1UsageFigures`), read with the platform's on every call.\n *\n * ## The plan, before the handler\n *\n * A registration may name the plan feature its resource needs\n * (`entitlement`). The router refuses an organization without it —\n * `402 plan_required` with the feature as the `code` and the registration's\n * own sentence — BEFORE the handler runs and before any scope is asked, so a\n * key minted while the plan carried the feature cannot outlive it.\n *\n * ## Registered at the console's boot\n *\n * From the plugin's console server declarations, with the handler loaded on\n * the first request. The router asks for a resource it does not know once\n * more after running the app's declarations step\n * (`runPluginDeclarationsRepair`), so a process whose boot failed repairs\n * itself rather than answering `404` for a resource it serves. No\n * registration means no plugin in this build serves the resource: the\n * router's `404` for an unknown endpoint is then the truth.\n *\n * ## One owner per resource\n *\n * A second plugin registering a resource another already serves is refused,\n * naming both, and the incumbent keeps serving. The platform's own\n * resources are not registrable.\n */\n\n/** Answers one request under the resource, after the pipeline admitted it. */\nexport type ApiV1ResourceHandler = (\n request: Request,\n context: ApiV1Context,\n /** The path under `/v1`, split — `['contacts', '<id>', 'merge']`. */\n segments: string[],\n url: URL,\n) => Promise<Response>\n\nexport interface ApiV1Resource {\n handle: ApiV1ResourceHandler\n /**\n * The plan feature the resource needs, and the sentence a refusal says —\n * `The CRM … is not included in this organization's plan`.\n */\n entitlement?: { feature: string; message: string }\n /**\n * The resource as the OpenAPI document describes it, loaded when the\n * document is built. Absent, the resource is served and undocumented.\n */\n describe?: () => Promise<ApiV1ResourceDescription>\n}\n\nexport const API_V1_RESOURCES = definePluginServiceContract<ApiV1Resource>(\n 'core.api-v1-resources',\n { multiple: true },\n)\n\n/** The resources the router answers itself, which no plugin may take. */\nexport const PLATFORM_API_V1_RESOURCES: ReadonlySet<string> = new Set([\n 'sites',\n 'media',\n 'usage',\n 'me',\n 'openapi.json',\n])\n\n/** A resource name as a path segment carries it. */\nconst RESOURCE = /^[a-z][a-z0-9-]*$/\n\n/**\n * Serves `/v1/<resource>` from a plugin. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, as it does for a name that is not one lowercase path\n * segment, a platform resource, and a resource another plugin serves. The\n * same plugin registering again replaces its own.\n */\nexport function registerApiV1Resource(\n resource: string,\n registration: ApiV1Resource,\n options?: { pluginId?: string },\n): void {\n const key = String(resource ?? '').trim()\n if (!RESOURCE.test(key)) {\n throw new Error(`a /v1 resource needs a lowercase path segment for its name, not \"${key}\"`)\n }\n if (PLATFORM_API_V1_RESOURCES.has(key)) {\n throw new Error(`/v1/${key} is the platform's own resource`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error(`the /v1/${key} resource was registered with no owner`)\n const incumbent = resolvePluginServices(API_V1_RESOURCES).find((entry) => entry.key === key)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `/v1/${key} is already served by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(API_V1_RESOURCES, registration, { pluginId, key })\n}\n\n/** Every resource the plugins serve here, in registration order. */\nexport function apiV1Resources(): Array<{ name: string; pluginId: string; resource: ApiV1Resource }> {\n return resolvePluginServices(API_V1_RESOURCES).map((entry) => ({\n name: entry.key ?? '',\n pluginId: entry.pluginId,\n resource: entry.impl,\n }))\n}\n\n/**\n * The descriptions of every resource the plugins serve, in registration\n * order. A description that fails to load is left out and logged, rather\n * than taking the platform's own description with it.\n */\nexport async function describeApiV1Resources(): Promise<ApiV1ResourceDescription[]> {\n const described = await Promise.all(\n apiV1Resources().map(async ({ name, pluginId, resource }) => {\n if (!resource.describe) return null\n try {\n return await resource.describe()\n } catch (error) {\n console.error(`[api-v1] ${pluginId} could not describe /v1/${name}`, error)\n return null\n }\n }),\n )\n return described.filter((one): one is ApiV1ResourceDescription => one !== null)\n}\n\n/** The plugin serving `/v1/<resource>` here, or `null` when none does. */\nexport function apiV1Resource(\n resource: string,\n): { pluginId: string; resource: ApiV1Resource } | null {\n const key = String(resource ?? '').trim()\n const entry = resolvePluginServices(API_V1_RESOURCES).find((one) => one.key === key)\n return entry ? { pluginId: entry.pluginId, resource: entry.impl } : null\n}\n\n// ── Usage ──────────────────────────────────────────────────────────────────\n\n/**\n * A plugin's figures on `GET /v1/usage`, as top-level members of the usage\n * object — a band the plan meters, or a size an integration plans a sync by\n * (`usageBand` in `api-v1-kit.ts` gives either its published shape). Read on\n * every call, with the request's context; a reader that throws fails the\n * request, as a platform figure that cannot be read does.\n */\nexport type ApiV1UsageFigures = (context: ApiV1Context) => Promise<Record<string, unknown>>\n\nexport const API_V1_USAGE_FIGURES = definePluginServiceContract<ApiV1UsageFigures>(\n 'core.api-v1-usage-figures',\n { multiple: true },\n)\n\n/**\n * Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;\n * registering again replaces it. The owner is the loader's marker when a\n * register fn is running, else `options.pluginId`.\n */\nexport function registerApiV1UsageFigures(\n read: ApiV1UsageFigures,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('/v1 usage figures were registered with no owner')\n registerPluginService(API_V1_USAGE_FIGURES, read, { pluginId })\n}\n\n/**\n * Every plugin's figures for one request, merged in registration order. A\n * member the platform's own figures (`reserved`) or an earlier plugin\n * already names is dropped and logged: a plugin adds figures, never\n * replaces one.\n */\nexport async function readApiV1UsageFigures(\n context: ApiV1Context,\n reserved: ReadonlySet<string>,\n): Promise<Record<string, unknown>> {\n const readers = resolvePluginServices(API_V1_USAGE_FIGURES)\n const figures = await Promise.all(readers.map((entry) => entry.impl(context)))\n const merged: Record<string, unknown> = {}\n figures.forEach((one, index) => {\n for (const [key, value] of Object.entries(one)) {\n if (reserved.has(key) || key in merged) {\n console.error(`[api-v1] ${readers[index].pluginId} named usage figure \"${key}\", which is taken`)\n continue\n }\n merged[key] = value\n }\n })\n return merged\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","API_V1_RESOURCES","multiple","PLATFORM_API_V1_RESOURCES","Set","RESOURCE","registerApiV1Resource","resource","registration","options","key","String","trim","test","Error","has","pluginId","incumbent","find","entry","apiV1Resources","map","name","impl","describeApiV1Resources","described","Promise","all","describe","error","console","filter","one","apiV1Resource","API_V1_USAGE_FIGURES","registerApiV1UsageFigures","read","readApiV1UsageFigures","context","reserved","readers","figures","merged","forEach","index","value","Object","entries"],"mappings":"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"}
@@ -30,7 +30,7 @@ import { CAMPAIGN_ATTRIBUTIONS_COLLECTION, eraseCampaignAttributionsForPersonKey
30
30
  * a form, sign up, book or check out. There is no address to look up until
31
31
  * the very moment being attributed.
32
32
  *
33
- * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached
33
+ * So the touch is carried on the VISITOR (`utm-touch.ts`) and attached
34
34
  * here, at each moment they become somebody. What is NOT rebuilt is the
35
35
  * model: the same seven-day window, the same last-click rule, the same
36
36
  * `model`/`windowDays` stamped onto every record so a report can say what it
@@ -15,7 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { FieldValue } from "firebase-admin/firestore";
18
- import { parseCampaignTouch } from "@aglyn/aglyn/app-utils/campaign-touch";
18
+ import { parseUtmTouch } from "@aglyn/aglyn/app-utils/utm-touch";
19
19
  /*
20
20
  * Stamped from the REVENUE join's constants, aliased at the import.
21
21
  *
@@ -46,7 +46,7 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
46
46
  * a form, sign up, book or check out. There is no address to look up until
47
47
  * the very moment being attributed.
48
48
  *
49
- * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached
49
+ * So the touch is carried on the VISITOR (`utm-touch.ts`) and attached
50
50
  * here, at each moment they become somebody. What is NOT rebuilt is the
51
51
  * model: the same seven-day window, the same last-click rule, the same
52
52
  * `model`/`windowDays` stamped onto every record so a report can say what it
@@ -130,7 +130,7 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
130
130
  // The window is enforced inside the parser, so an expired or
131
131
  // future-dated wire value answers null here exactly as it does in the
132
132
  // browser that decided whether to send it.
133
- const web = parseCampaignTouch(options.wire, convertedAtMs);
133
+ const web = parseUtmTouch(options.wire, convertedAtMs);
134
134
  const key = personKey(options.email);
135
135
  /*
136
136
  * The email channel is only askable of somebody who named an address, and
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/campaign-conversion-attribution.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 { FieldValue, type DocumentReference, type Firestore } from 'firebase-admin/firestore'\nimport {\n parseCampaignTouch,\n type CampaignTouch,\n} from '@aglyn/aglyn/app-utils/campaign-touch'\n/*\n * Stamped from the REVENUE join's constants, aliased at the import.\n *\n * They are spelled `EMAIL_` because that is where both halves of that join\n * could reach them, not because the window is an email fact — it is the\n * platform's one answer to how long a touch may be credited. Reading them\n * from there is what makes a lead's record and an order's record carry the\n * same rule rather than two that happen to agree.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL as ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS as ATTRIBUTION_WINDOW_DAYS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email/email-revenue-window'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n} from './campaign-attribution-store'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\nimport { resolveOrgIdForHost } from './organizations'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited\n * to the campaign that led to it.\n *\n * ## Why there is a second join at all, and why it is not a second SCHEME\n *\n * `email-revenue-attribution.ts` credits an ORDER by looking the buyer's\n * address up in the touch map a click wrote. That works because an order\n * names its buyer. Every other outcome a campaign causes is produced by\n * somebody who was anonymous until the instant they produced it: they arrive\n * from a campaign link, browse, and only become identifiable when they submit\n * a form, sign up, book or check out. There is no address to look up until\n * the very moment being attributed.\n *\n * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached\n * here, at each moment they become somebody. What is NOT rebuilt is the\n * model: the same seven-day window, the same last-click rule, the same\n * `model`/`windowDays` stamped onto every record so a report can say what it\n * counted. The email channel is not re-derived either — it is READ from the\n * touch the delivery webhook already wrote, so a campaign email and a\n * campaign ad are two inputs to one comparison rather than two schemes with\n * two answers.\n *\n * ## Last touch, across both channels\n *\n * At the identify moment there can be two candidate touches:\n *\n * - the WEB touch the visitor's device carried, from an ad, a partner link\n * or a social post; and\n * - the EMAIL touch on `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written server-side when they clicked a campaign's mail.\n *\n * Both are window-checked and the LATER one wins. Splitting the outcome\n * between them is the multi-touch model the revenue work rejected for the\n * reason that applies here unchanged: a rule the merchant did not choose\n * produces a figure nobody can check. One outcome, one campaign, and a rule\n * stated on the record.\n *\n * ## No campaign touch means NO RECORD\n *\n * Direct traffic writes nothing at all, and there is deliberately no fallback\n * — no \"most recent campaign on this site\", no referrer inference, no\n * `utm_source=direct`. A conversion nobody can be credited with is a\n * conversion nobody is credited with, and the absence of a record is how the\n * report says so. The cost of a miss is one uncounted outcome; the cost of a\n * guess is a campaign that reads as effective because it was the last one to\n * run.\n *\n * ## Three reads at most, and usually one\n *\n * Resolving a touch costs ONE keyed document read, and only when the visitor\n * gave an address — no query, no index, nothing that can be truncated. A door\n * resolves once per request and hands the result to every writer beneath it,\n * so a form submission that creates a submission, a contact and a lead pays\n * for the lookup once rather than three times.\n *\n * ## Never throws\n *\n * The same contract as the revenue join and for the same reason: the visitor\n * has already done the thing being attributed and the record of it is already\n * written. A lost attribution understates a campaign; a thrown one loses a\n * lead.\n */\n\n/**\n * The collection and the erasure sweep, re-exported so a caller needs one\n * import for the whole join. They are DEFINED in a leaf module because the\n * sweep runs from `email-delivery-log.ts`, which this file reads — see\n * `campaign-attribution-store.ts` for why that cycle is not merely untidy.\n */\nexport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n}\n\n/** The single conversion rollup document under an email campaign. */\nexport const CAMPAIGN_CONVERSIONS_REPORT_DOC = 'conversions'\n\n/**\n * Which identify moment a record credits.\n *\n * Kept apart rather than summed, which is the whole reason the kind is on the\n * record. One form submission by a new person creates a submission, a contact\n * and possibly a lead — three true statements about one visitor action — and\n * a report that added them would treble every campaign's conversions. A\n * reader asks for one kind at a time, exactly as the revenue rollup buckets\n * by currency and never totals across them.\n */\nexport type CampaignConversionKind = 'form' | 'lead' | 'contact' | 'booking'\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a click on, or the sending of, a one-to-one\n * email a rep's sequence sent. It is read off the same touch map as the\n * email channel — the Outreach click route stamps it there with the\n * sequence and the enrollment beside the campaign the sequence is in — so\n * a booking made from a sequence link is credited by the door that credits\n * every other booking, and nothing in this join has to know what a\n * sequence is.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence'\n\n/** The touch a conversion is credited to, once both channels have been asked. */\nexport interface ResolvedCampaignTouch {\n channel: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or\n * the campaign CONTAINER (`emailCampaigns/{id}`) a sequence is in, for a\n * sequence touch.\n */\n campaignId?: string\n /** The sequence the touch came through, for a `sequence` touch. */\n sequenceId?: string\n /** The enrollment the touch came through, for a `sequence` touch. */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs: number\n /**\n * `sha256` of the normalized address the touch was resolved against, when\n * the visitor gave one. The handle an erasure erases by — see\n * {@link eraseCampaignAttributionsForPersonKey}.\n */\n personKey?: string\n}\n\n/** What one conversion attribution record holds. */\nexport interface CampaignConversionRecord extends ResolvedCampaignTouch {\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId: string\n /** When the visitor became identifiable. */\n convertedAtMs: number\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/**\n * The document id for one conversion.\n *\n * `{kind}:{refId}`, so the same submission credited twice is the same\n * document — which is what makes {@link attributeCampaignConversion}'s\n * `create()` idempotent. Scoped by kind because the ids come from different\n * collections and a booking id has no reason to be distinct from a form\n * submission's.\n */\nexport function campaignConversionId(\n kind: CampaignConversionKind,\n refId: string,\n): string {\n return `${kind}:${refId}`\n}\n\n/**\n * The campaign touch to credit a conversion happening now, or `null`.\n *\n * Called ONCE per conversion request, at the door, and the result handed to\n * every writer beneath it. See the module docblock for the cost argument.\n *\n * @returns the winning touch, or `null` when neither channel has one inside\n * the window — which is the ordinary case and writes nothing.\n */\nexport async function resolveCampaignTouch(\n options: {\n hostId: string\n /**\n * The wire form the conversion request carried, when the door reports\n * one. Re-parsed through the allowlist that wrote it rather than trusted:\n * it arrives from the visitor's browser, so it may claim no more than an\n * inbound URL could.\n */\n wire?: unknown\n /** The address the visitor identified with, raw. */\n email?: unknown\n /** When they identified themselves. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<ResolvedCampaignTouch | null> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return null\n const convertedAtMs = Number(options.atMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n\n // The window is enforced inside the parser, so an expired or\n // future-dated wire value answers null here exactly as it does in the\n // browser that decided whether to send it.\n const web: CampaignTouch | null = parseCampaignTouch(\n options.wire,\n convertedAtMs,\n )\n\n const key = personKey(options.email)\n /*\n * The email channel is only askable of somebody who named an address, and\n * that is the whole reason this join needs the web channel at all. No\n * address means no keyed read: the ordinary anonymous conversion costs\n * nothing here.\n */\n const emailTouch = key\n ? await readEmailCampaignTouch(\n String(options.email ?? ''),\n hostId,\n firestore ?? defaultFirestore(),\n )\n : null\n const emailInWindow =\n emailTouch &&\n isDocumentId(emailTouch.campaignId) &&\n emailTouchIsInWindow(emailTouch.clickedAtMs, convertedAtMs)\n ? emailTouch\n : null\n\n /*\n * LAST TOUCH decides, and a tie goes to email. A tie is only reachable\n * when the two instants are the same millisecond, and in that case the\n * email touch is the better evidence: it was recorded by the provider's\n * own click event on the server, while the web touch is a value the\n * visitor's device supplied.\n */\n const emailWins =\n emailInWindow && (!web || emailInWindow.clickedAtMs >= web.atMs)\n if (emailWins) {\n const viaSequence = emailInWindow.sequenceId && emailInWindow.enrollmentId\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: emailInWindow.campaignId,\n ...(viaSequence\n ? { sequenceId: emailInWindow.sequenceId, enrollmentId: emailInWindow.enrollmentId }\n : {}),\n touchedAtMs: emailInWindow.clickedAtMs,\n ...(key ? { personKey: key } : {}),\n }\n }\n if (!web) return null\n return {\n channel: 'web',\n ...(web.source ? { source: web.source } : {}),\n ...(web.medium ? { medium: web.medium } : {}),\n ...(web.campaign ? { campaign: web.campaign } : {}),\n touchedAtMs: web.atMs,\n ...(key ? { personKey: key } : {}),\n }\n } catch (error) {\n console.error('resolveCampaignTouch failed', error)\n return null\n }\n}\n\n/**\n * Credits one conversion to the campaign the visitor last came from.\n *\n * ## Why the record is a document and not a field\n *\n * The revenue join's three reasons, and the first is decisive here too.\n * `create()` fails when the document already exists, which is exact\n * idempotency for free: a retried form POST, a redelivered booking webhook\n * and a re-run capture all land here a second time and must all leave the\n * rollup where they found it. Second, the four kinds live in four different\n * collections — a contact is org-scoped and shared across every site in the\n * org — so there is no one document a field could go on. Third, the\n * submission, the lead and the booking are read by the inbox, the export and\n * the fulfilment path, and none of them wants a field about marketing.\n *\n * ## The rollup exists for the EMAIL channel only\n *\n * A campaign document is a real entity with a real id, so its conversions\n * roll up under it beside the revenue the same join already credits it with.\n * A web campaign is a LABEL the marketer typed into a URL, with no document,\n * no id and no bound on how many distinct values exist — the same unbounded\n * key space the analytics collector caps its per-day label map against. A\n * rollup keyed on it would be a map anybody who can vary a query string can\n * grow. So the web channel's records stand on their own and a report reads\n * them; see the module's report seam note.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeCampaignConversion(\n options: {\n hostId: string\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking being credited. */\n refId: string\n /** The touch {@link resolveCampaignTouch} picked, or `null` for direct. */\n touch: ResolvedCampaignTouch | null | undefined\n /** When the visitor became identifiable. Defaults to now. */\n convertedAtMs?: number\n },\n firestore?: any,\n): Promise<CampaignConversionRecord | null> {\n try {\n const { touch } = options\n if (!touch) return null\n const hostId = String(options.hostId ?? '')\n const refId = String(options.refId ?? '')\n if (!isDocumentId(hostId) || !isDocumentId(refId)) return null\n const convertedAtMs = Number(options.convertedAtMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n // Re-checked at the write rather than trusted from the resolve. The two\n // are separated by the writes that produced the thing being credited, and\n // a touch that has aged out between them is a touch outside the window.\n if (!emailTouchIsInWindow(touch.touchedAtMs, convertedAtMs)) return null\n\n const record: CampaignConversionRecord = {\n kind: options.kind,\n refId,\n channel: touch.channel,\n ...(touch.campaignId ? { campaignId: touch.campaignId } : {}),\n ...(touch.sequenceId ? { sequenceId: touch.sequenceId } : {}),\n ...(touch.enrollmentId ? { enrollmentId: touch.enrollmentId } : {}),\n ...(touch.source ? { source: touch.source } : {}),\n ...(touch.medium ? { medium: touch.medium } : {}),\n ...(touch.campaign ? { campaign: touch.campaign } : {}),\n touchedAtMs: touch.touchedAtMs,\n convertedAtMs,\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n ...(touch.personKey ? { personKey: touch.personKey } : {}),\n }\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()` — the revenue join's argument, unchanged. The\n * ALREADY_EXISTS failure IS the idempotency, and the rollup below is\n * reached only when the create succeeded, so the two can never disagree\n * about whether this conversion was counted.\n */\n try {\n await hostRef\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .doc(campaignConversionId(options.kind, refId))\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n /*\n * The send the touch names (`touch.campaignId` is a SEND id on the email\n * channel), wherever it is: the org's `campaigns` collection, or the\n * site's for a send the migration has not reached. A send in neither\n * place was discarded, and its rollup is skipped — the record above\n * already says what was credited.\n */\n const sendRef =\n touch.channel === 'email' && touch.campaignId && isDocumentId(touch.campaignId)\n ? await resolveCampaignSendRef({ hostId, sendId: touch.campaignId, firestore: db })\n : null\n if (sendRef) {\n /*\n * A merge-set that CREATES, for the reason `reports/revenue` gives: this\n * writes a document UNDER a campaign, so a campaign that no longer\n * exists gains an orphaned report rather than being resurrected as a\n * husk in the merchant's history — and the campaign was proven to exist\n * when its click wrote the touch.\n *\n * Every figure is an increment, so two conversions settling at once\n * both land.\n */\n await sendRef\n .collection('reports')\n .doc(CAMPAIGN_CONVERSIONS_REPORT_DOC)\n .set(\n {\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n byKind: { [options.kind]: FieldValue.increment(1) },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n\n /*\n * A SEQUENCE touch rolls up under the campaign's sequences report, and\n * only a booking does: \"meetings booked from a sequence link\" is the one\n * identify moment a sequence's own outcomes do not already count. The\n * form, lead and contact kinds stand as records — visible on the record\n * they credit and on the site's conversions list — and never in\n * `byKind`, whose caveat promises the reader campaign emails only.\n */\n if (touch.channel === 'sequence' && touch.campaignId && options.kind === 'booking') {\n await creditCampaignSequenceOutcome(\n { hostId, campaignIds: [touch.campaignId], outcome: 'meetings', atMs: convertedAtMs },\n db,\n )\n }\n\n return record\n } catch (error) {\n console.error('attributeCampaignConversion failed', error)\n return null\n }\n}\n\n/*==========================================\n * WHAT A CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and the Outreach runtime\n * credits what each enrollment produced — enrolled, first email sent, a\n * reply, a meeting booked from a sequence link, an enrolled lead converted\n * — to every campaign the sequence was in when the person was enrolled. The\n * counts live in `orgs/{orgId}/campaignSequenceReports/{campaignId}`, beside\n * the org's containers: one document per campaign, server-only like every\n * other report here, and never inside the campaign container, which the\n * history list reads.\n *\n * Idempotency is the CALLER's: a plugin credits an outcome once per\n * enrollment from a state change that happens once — the enrollment is\n * created once, its first email is the first, its status moves to replied\n * once, its lead converts once — so this writer is a plain increment and\n * keeps no per-enrollment record of its own.\n *=========================================*/\n\n/**\n * The per-org collection of sequence rollups. Restated from the reader\n * (`campaign-report.ts` in the campaigns UI library, which this package may\n * not import) and asserted equal by the reader's spec.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** The outcomes the runtime credits, in funnel order. */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * Credits one outcome, for one enrollment, to every campaign named.\n *\n * Never throws, and never refuses the batch for one bad id: the outcome\n * has already happened, and a campaign that is gone gains an orphaned\n * report rather than blocking the credit to one that is not — the\n * conversions rollup's own argument for a merge-set that creates.\n *\n * @returns how many campaigns were credited.\n */\nexport async function creditCampaignSequenceOutcome(\n options: {\n /** The site the enrollment emails for; resolves the org when none is given. */\n hostId: string\n /**\n * The org whose containers the campaigns are, when the caller holds it —\n * an enrollment is an org document, so its routes do. Saves the\n * `hostIndex` read.\n */\n orgId?: string | null\n /** The campaigns the enrollment carries; unusable ids are skipped. */\n campaignIds: readonly string[]\n outcome: CampaignSequenceOutcome\n /** When the outcome happened. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<number> {\n try {\n if (!(CAMPAIGN_SEQUENCE_OUTCOMES as readonly string[]).includes(options.outcome)) return 0\n const atMs = Number(options.atMs ?? Date.now())\n const ids = [...new Set(options.campaignIds.map((id) => String(id ?? '').trim()))].filter(\n (id) => isDocumentId(id),\n )\n if (!ids.length) return 0\n const hostId = String(options.hostId ?? '')\n const given = String(options.orgId ?? '')\n const orgId = isDocumentId(given)\n ? given\n : isDocumentId(hostId)\n ? await resolveOrgIdForHost(hostId)\n : null\n if (!orgId || !isDocumentId(orgId)) return 0\n const db = firestore ?? defaultFirestore()\n await Promise.all(\n ids.map((campaignId) =>\n orgCampaignSequenceReportRef(orgId, campaignId, db).set(\n {\n byOutcome: { [options.outcome]: FieldValue.increment(1) },\n ...(Number.isFinite(atMs) && atMs > 0 ? { updatedAtMs: atMs } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n ),\n ),\n )\n return ids.length\n } catch (error) {\n console.error('creditCampaignSequenceOutcome failed', error)\n return 0\n }\n}\n\n/*==========================================\n * WHERE A SEND IS (AGL-3273)\n *=========================================*/\n\n/**\n * WHERE A SEND IS, given only what a link or a delivery event carries.\n *\n * Campaign containers and sends belong to the organization\n * (`orgs/{orgId}/emailCampaigns`, `orgs/{orgId}/campaigns`, AGL-3273). Every\n * reader that starts from inside the product already holds the org id and\n * addresses those collections directly. This module is for the readers that\n * start OUTSIDE it — the unsubscribe link, the provider's delivery webhook,\n * the conversion join — which hold a site id and a send id and nothing else,\n * because that is what was signed into the link or tagged on the message\n * when it went out, possibly years ago.\n *\n * ## The fallback, and why it stays\n *\n * The site path (`hosts/{hostId}/campaigns/{sendId}`) is where every send\n * lived before the move, and the migration copies each one up under its\n * existing id. Between a deployment of this code and the migration reaching\n * an org, the send is still only at the site; and an interrupted migration\n * leaves some sends behind. A reader that looked only at the org would, in\n * that window, drop an unsubscribe's count or a bounce — and a bounce that\n * is dropped is an address that gets mailed again. So the org is asked\n * first and the site second. After the migration the second read never\n * happens, because the first one answers.\n *\n * The collection names are spelled here rather than imported from the\n * campaign model (`@aglyn/shared-ui-email-campaigns`), which this package\n * does not depend on. They are the same strings; `campaign-container.ts`\n * documents them. The join above is this lookup's first reader, and the two\n * move together when the campaign code leaves this package.\n */\n\nconst sendRefFirestore = (): Firestore => firebaseAdmin.app().firestore()\n\n/** `orgs/{orgId}/campaigns` — one document per email send. */\nconst ORG_SENDS = 'campaigns'\n\n/** `orgs/{orgId}/campaignSequenceReports` — keyed by the container id. */\nconst ORG_SEQUENCE_REPORTS = 'campaignSequenceReports'\n\n/** `orgs/{orgId}/campaigns/{sendId}`. */\nexport function orgCampaignSendRef(\n orgId: string,\n sendId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore.collection('orgs').doc(orgId).collection(ORG_SENDS).doc(sendId)\n}\n\n/** `orgs/{orgId}/campaignSequenceReports/{campaignId}`. */\nexport function orgCampaignSequenceReportRef(\n orgId: string,\n campaignId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(ORG_SEQUENCE_REPORTS)\n .doc(campaignId)\n}\n\n/** The pre-AGL-3273 location, read only as the fallback described above. */\nfunction legacySiteSendRef(\n hostId: string,\n sendId: string,\n firestore: Firestore,\n): DocumentReference {\n return firestore.collection('hosts').doc(hostId).collection(ORG_SENDS).doc(sendId)\n}\n\nexport interface ResolveCampaignSendRefInput {\n /** The site the link or the message tag names. */\n hostId: string\n /** The send id (`cid` on a link, `campaignId` on a message tag). */\n sendId: string\n /**\n * The org, when the caller already has it — a message tag minted since\n * AGL-3273 carries it. Saves the `hostIndex` read.\n */\n orgId?: string | null\n firestore?: Firestore\n}\n\n/**\n * The send's document, wherever it currently is. Null when neither location\n * holds it — a send id from a link whose send was discarded, or a forged one.\n *\n * Costs one read after the migration (the org document), plus the\n * `hostIndex` lookup when the caller has no org id; that lookup is\n * request-cached.\n */\nexport async function resolveCampaignSendRef(\n input: ResolveCampaignSendRefInput,\n): Promise<DocumentReference | null> {\n const { hostId, sendId } = input\n if (!hostId || !sendId || sendId.includes('/') || hostId.includes('/')) return null\n const firestore = input.firestore ?? sendRefFirestore()\n const orgId = input.orgId || (await resolveOrgIdForHost(hostId))\n if (orgId) {\n const orgRef = orgCampaignSendRef(orgId, sendId, firestore)\n if ((await orgRef.get()).exists) return orgRef\n }\n const siteRef = legacySiteSendRef(hostId, sendId, firestore)\n if ((await siteRef.get()).exists) return siteRef\n return null\n}\n"],"names":["FieldValue","parseCampaignTouch","EMAIL_ATTRIBUTION_MODEL","ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_DAYS","emailTouchIsInWindow","personKey","CAMPAIGN_ATTRIBUTIONS_COLLECTION","eraseCampaignAttributionsForPersonKey","readEmailCampaignTouch","isDocumentId","firebaseAdmin","resolveOrgIdForHost","defaultFirestore","app","firestore","CAMPAIGN_CONVERSIONS_REPORT_DOC","campaignConversionId","kind","refId","resolveCampaignTouch","options","hostId","String","convertedAtMs","Number","atMs","Date","now","isFinite","web","wire","key","email","emailTouch","emailInWindow","campaignId","clickedAtMs","emailWins","viaSequence","sequenceId","enrollmentId","channel","touchedAtMs","source","medium","campaign","error","console","attributeCampaignConversion","touch","record","model","windowDays","db","hostRef","collection","doc","create","createdAt","serverTimestamp","sendRef","resolveCampaignSendRef","sendId","set","byKind","increment","updatedAt","merge","creditCampaignSequenceOutcome","campaignIds","outcome","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOMES","includes","ids","Set","map","id","trim","filter","length","given","orgId","Promise","all","orgCampaignSequenceReportRef","byOutcome","updatedAtMs","sendRefFirestore","ORG_SENDS","ORG_SEQUENCE_REPORTS","orgCampaignSendRef","legacySiteSendRef","input","orgRef","get","exists","siteRef"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/campaign-conversion-attribution.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 { FieldValue, type DocumentReference, type Firestore } from 'firebase-admin/firestore'\nimport {\n parseUtmTouch,\n type UtmTouch,\n} from '@aglyn/aglyn/app-utils/utm-touch'\n/*\n * Stamped from the REVENUE join's constants, aliased at the import.\n *\n * They are spelled `EMAIL_` because that is where both halves of that join\n * could reach them, not because the window is an email fact — it is the\n * platform's one answer to how long a touch may be credited. Reading them\n * from there is what makes a lead's record and an order's record carry the\n * same rule rather than two that happen to agree.\n */\nimport {\n EMAIL_ATTRIBUTION_MODEL as ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS as ATTRIBUTION_WINDOW_DAYS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email/email-revenue-window'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n} from './campaign-attribution-store'\nimport { readEmailCampaignTouch } from './email-delivery-log'\nimport { isDocumentId } from './document-id'\nimport firebaseAdmin from './firebase-admin'\nimport { resolveOrgIdForHost } from './organizations'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE IDENTIFY-MOMENT JOIN — a form, a lead, a contact or a booking, credited\n * to the campaign that led to it.\n *\n * ## Why there is a second join at all, and why it is not a second SCHEME\n *\n * `email-revenue-attribution.ts` credits an ORDER by looking the buyer's\n * address up in the touch map a click wrote. That works because an order\n * names its buyer. Every other outcome a campaign causes is produced by\n * somebody who was anonymous until the instant they produced it: they arrive\n * from a campaign link, browse, and only become identifiable when they submit\n * a form, sign up, book or check out. There is no address to look up until\n * the very moment being attributed.\n *\n * So the touch is carried on the VISITOR (`utm-touch.ts`) and attached\n * here, at each moment they become somebody. What is NOT rebuilt is the\n * model: the same seven-day window, the same last-click rule, the same\n * `model`/`windowDays` stamped onto every record so a report can say what it\n * counted. The email channel is not re-derived either — it is READ from the\n * touch the delivery webhook already wrote, so a campaign email and a\n * campaign ad are two inputs to one comparison rather than two schemes with\n * two answers.\n *\n * ## Last touch, across both channels\n *\n * At the identify moment there can be two candidate touches:\n *\n * - the WEB touch the visitor's device carried, from an ad, a partner link\n * or a social post; and\n * - the EMAIL touch on `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written server-side when they clicked a campaign's mail.\n *\n * Both are window-checked and the LATER one wins. Splitting the outcome\n * between them is the multi-touch model the revenue work rejected for the\n * reason that applies here unchanged: a rule the merchant did not choose\n * produces a figure nobody can check. One outcome, one campaign, and a rule\n * stated on the record.\n *\n * ## No campaign touch means NO RECORD\n *\n * Direct traffic writes nothing at all, and there is deliberately no fallback\n * — no \"most recent campaign on this site\", no referrer inference, no\n * `utm_source=direct`. A conversion nobody can be credited with is a\n * conversion nobody is credited with, and the absence of a record is how the\n * report says so. The cost of a miss is one uncounted outcome; the cost of a\n * guess is a campaign that reads as effective because it was the last one to\n * run.\n *\n * ## Three reads at most, and usually one\n *\n * Resolving a touch costs ONE keyed document read, and only when the visitor\n * gave an address — no query, no index, nothing that can be truncated. A door\n * resolves once per request and hands the result to every writer beneath it,\n * so a form submission that creates a submission, a contact and a lead pays\n * for the lookup once rather than three times.\n *\n * ## Never throws\n *\n * The same contract as the revenue join and for the same reason: the visitor\n * has already done the thing being attributed and the record of it is already\n * written. A lost attribution understates a campaign; a thrown one loses a\n * lead.\n */\n\n/**\n * The collection and the erasure sweep, re-exported so a caller needs one\n * import for the whole join. They are DEFINED in a leaf module because the\n * sweep runs from `email-delivery-log.ts`, which this file reads — see\n * `campaign-attribution-store.ts` for why that cycle is not merely untidy.\n */\nexport {\n CAMPAIGN_ATTRIBUTIONS_COLLECTION,\n eraseCampaignAttributionsForPersonKey,\n}\n\n/** The single conversion rollup document under an email campaign. */\nexport const CAMPAIGN_CONVERSIONS_REPORT_DOC = 'conversions'\n\n/**\n * Which identify moment a record credits.\n *\n * Kept apart rather than summed, which is the whole reason the kind is on the\n * record. One form submission by a new person creates a submission, a contact\n * and possibly a lead — three true statements about one visitor action — and\n * a report that added them would treble every campaign's conversions. A\n * reader asks for one kind at a time, exactly as the revenue rollup buckets\n * by currency and never totals across them.\n */\nexport type CampaignConversionKind = 'form' | 'lead' | 'contact' | 'booking'\n\n/**\n * Which channel the credited touch arrived through.\n *\n * `sequence` (AGL-3254) is a click on, or the sending of, a one-to-one\n * email a rep's sequence sent. It is read off the same touch map as the\n * email channel — the Outreach click route stamps it there with the\n * sequence and the enrollment beside the campaign the sequence is in — so\n * a booking made from a sequence link is credited by the door that credits\n * every other booking, and nothing in this join has to know what a\n * sequence is.\n */\nexport type CampaignTouchChannel = 'email' | 'web' | 'sequence'\n\n/** The touch a conversion is credited to, once both channels have been asked. */\nexport interface ResolvedCampaignTouch {\n channel: CampaignTouchChannel\n /**\n * The campaign document, when the touch was a click on our own mail — or\n * the campaign CONTAINER (`emailCampaigns/{id}`) a sequence is in, for a\n * sequence touch.\n */\n campaignId?: string\n /** The sequence the touch came through, for a `sequence` touch. */\n sequenceId?: string\n /** The enrollment the touch came through, for a `sequence` touch. */\n enrollmentId?: string\n /** `utm_source`, when the touch was a link on the web. */\n source?: string\n /** `utm_medium`, when the touch was a link on the web. */\n medium?: string\n /** `utm_campaign`, when the touch was a link on the web. */\n campaign?: string\n /** When the visitor followed the campaign link, epoch ms. */\n touchedAtMs: number\n /**\n * `sha256` of the normalized address the touch was resolved against, when\n * the visitor gave one. The handle an erasure erases by — see\n * {@link eraseCampaignAttributionsForPersonKey}.\n */\n personKey?: string\n}\n\n/** What one conversion attribution record holds. */\nexport interface CampaignConversionRecord extends ResolvedCampaignTouch {\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking this credits. */\n refId: string\n /** When the visitor became identifiable. */\n convertedAtMs: number\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/**\n * The document id for one conversion.\n *\n * `{kind}:{refId}`, so the same submission credited twice is the same\n * document — which is what makes {@link attributeCampaignConversion}'s\n * `create()` idempotent. Scoped by kind because the ids come from different\n * collections and a booking id has no reason to be distinct from a form\n * submission's.\n */\nexport function campaignConversionId(\n kind: CampaignConversionKind,\n refId: string,\n): string {\n return `${kind}:${refId}`\n}\n\n/**\n * The campaign touch to credit a conversion happening now, or `null`.\n *\n * Called ONCE per conversion request, at the door, and the result handed to\n * every writer beneath it. See the module docblock for the cost argument.\n *\n * @returns the winning touch, or `null` when neither channel has one inside\n * the window — which is the ordinary case and writes nothing.\n */\nexport async function resolveCampaignTouch(\n options: {\n hostId: string\n /**\n * The wire form the conversion request carried, when the door reports\n * one. Re-parsed through the allowlist that wrote it rather than trusted:\n * it arrives from the visitor's browser, so it may claim no more than an\n * inbound URL could.\n */\n wire?: unknown\n /** The address the visitor identified with, raw. */\n email?: unknown\n /** When they identified themselves. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<ResolvedCampaignTouch | null> {\n try {\n const hostId = String(options.hostId ?? '')\n if (!isDocumentId(hostId)) return null\n const convertedAtMs = Number(options.atMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n\n // The window is enforced inside the parser, so an expired or\n // future-dated wire value answers null here exactly as it does in the\n // browser that decided whether to send it.\n const web: UtmTouch | null = parseUtmTouch(\n options.wire,\n convertedAtMs,\n )\n\n const key = personKey(options.email)\n /*\n * The email channel is only askable of somebody who named an address, and\n * that is the whole reason this join needs the web channel at all. No\n * address means no keyed read: the ordinary anonymous conversion costs\n * nothing here.\n */\n const emailTouch = key\n ? await readEmailCampaignTouch(\n String(options.email ?? ''),\n hostId,\n firestore ?? defaultFirestore(),\n )\n : null\n const emailInWindow =\n emailTouch &&\n isDocumentId(emailTouch.campaignId) &&\n emailTouchIsInWindow(emailTouch.clickedAtMs, convertedAtMs)\n ? emailTouch\n : null\n\n /*\n * LAST TOUCH decides, and a tie goes to email. A tie is only reachable\n * when the two instants are the same millisecond, and in that case the\n * email touch is the better evidence: it was recorded by the provider's\n * own click event on the server, while the web touch is a value the\n * visitor's device supplied.\n */\n const emailWins =\n emailInWindow && (!web || emailInWindow.clickedAtMs >= web.atMs)\n if (emailWins) {\n const viaSequence = emailInWindow.sequenceId && emailInWindow.enrollmentId\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: emailInWindow.campaignId,\n ...(viaSequence\n ? { sequenceId: emailInWindow.sequenceId, enrollmentId: emailInWindow.enrollmentId }\n : {}),\n touchedAtMs: emailInWindow.clickedAtMs,\n ...(key ? { personKey: key } : {}),\n }\n }\n if (!web) return null\n return {\n channel: 'web',\n ...(web.source ? { source: web.source } : {}),\n ...(web.medium ? { medium: web.medium } : {}),\n ...(web.campaign ? { campaign: web.campaign } : {}),\n touchedAtMs: web.atMs,\n ...(key ? { personKey: key } : {}),\n }\n } catch (error) {\n console.error('resolveCampaignTouch failed', error)\n return null\n }\n}\n\n/**\n * Credits one conversion to the campaign the visitor last came from.\n *\n * ## Why the record is a document and not a field\n *\n * The revenue join's three reasons, and the first is decisive here too.\n * `create()` fails when the document already exists, which is exact\n * idempotency for free: a retried form POST, a redelivered booking webhook\n * and a re-run capture all land here a second time and must all leave the\n * rollup where they found it. Second, the four kinds live in four different\n * collections — a contact is org-scoped and shared across every site in the\n * org — so there is no one document a field could go on. Third, the\n * submission, the lead and the booking are read by the inbox, the export and\n * the fulfilment path, and none of them wants a field about marketing.\n *\n * ## The rollup exists for the EMAIL channel only\n *\n * A campaign document is a real entity with a real id, so its conversions\n * roll up under it beside the revenue the same join already credits it with.\n * A web campaign is a LABEL the marketer typed into a URL, with no document,\n * no id and no bound on how many distinct values exist — the same unbounded\n * key space the analytics collector caps its per-day label map against. A\n * rollup keyed on it would be a map anybody who can vary a query string can\n * grow. So the web channel's records stand on their own and a report reads\n * them; see the module's report seam note.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeCampaignConversion(\n options: {\n hostId: string\n kind: CampaignConversionKind\n /** The submission, lead, contact or booking being credited. */\n refId: string\n /** The touch {@link resolveCampaignTouch} picked, or `null` for direct. */\n touch: ResolvedCampaignTouch | null | undefined\n /** When the visitor became identifiable. Defaults to now. */\n convertedAtMs?: number\n },\n firestore?: any,\n): Promise<CampaignConversionRecord | null> {\n try {\n const { touch } = options\n if (!touch) return null\n const hostId = String(options.hostId ?? '')\n const refId = String(options.refId ?? '')\n if (!isDocumentId(hostId) || !isDocumentId(refId)) return null\n const convertedAtMs = Number(options.convertedAtMs ?? Date.now())\n if (!Number.isFinite(convertedAtMs) || convertedAtMs <= 0) return null\n // Re-checked at the write rather than trusted from the resolve. The two\n // are separated by the writes that produced the thing being credited, and\n // a touch that has aged out between them is a touch outside the window.\n if (!emailTouchIsInWindow(touch.touchedAtMs, convertedAtMs)) return null\n\n const record: CampaignConversionRecord = {\n kind: options.kind,\n refId,\n channel: touch.channel,\n ...(touch.campaignId ? { campaignId: touch.campaignId } : {}),\n ...(touch.sequenceId ? { sequenceId: touch.sequenceId } : {}),\n ...(touch.enrollmentId ? { enrollmentId: touch.enrollmentId } : {}),\n ...(touch.source ? { source: touch.source } : {}),\n ...(touch.medium ? { medium: touch.medium } : {}),\n ...(touch.campaign ? { campaign: touch.campaign } : {}),\n touchedAtMs: touch.touchedAtMs,\n convertedAtMs,\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n ...(touch.personKey ? { personKey: touch.personKey } : {}),\n }\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()` — the revenue join's argument, unchanged. The\n * ALREADY_EXISTS failure IS the idempotency, and the rollup below is\n * reached only when the create succeeded, so the two can never disagree\n * about whether this conversion was counted.\n */\n try {\n await hostRef\n .collection(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .doc(campaignConversionId(options.kind, refId))\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n /*\n * The send the touch names (`touch.campaignId` is a SEND id on the email\n * channel), wherever it is: the org's `campaigns` collection, or the\n * site's for a send the migration has not reached. A send in neither\n * place was discarded, and its rollup is skipped — the record above\n * already says what was credited.\n */\n const sendRef =\n touch.channel === 'email' && touch.campaignId && isDocumentId(touch.campaignId)\n ? await resolveCampaignSendRef({ hostId, sendId: touch.campaignId, firestore: db })\n : null\n if (sendRef) {\n /*\n * A merge-set that CREATES, for the reason `reports/revenue` gives: this\n * writes a document UNDER a campaign, so a campaign that no longer\n * exists gains an orphaned report rather than being resurrected as a\n * husk in the merchant's history — and the campaign was proven to exist\n * when its click wrote the touch.\n *\n * Every figure is an increment, so two conversions settling at once\n * both land.\n */\n await sendRef\n .collection('reports')\n .doc(CAMPAIGN_CONVERSIONS_REPORT_DOC)\n .set(\n {\n model: ATTRIBUTION_MODEL,\n windowDays: ATTRIBUTION_WINDOW_DAYS,\n byKind: { [options.kind]: FieldValue.increment(1) },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n }\n\n /*\n * A SEQUENCE touch rolls up under the campaign's sequences report, and\n * only a booking does: \"meetings booked from a sequence link\" is the one\n * identify moment a sequence's own outcomes do not already count. The\n * form, lead and contact kinds stand as records — visible on the record\n * they credit and on the site's conversions list — and never in\n * `byKind`, whose caveat promises the reader campaign emails only.\n */\n if (touch.channel === 'sequence' && touch.campaignId && options.kind === 'booking') {\n await creditCampaignSequenceOutcome(\n { hostId, campaignIds: [touch.campaignId], outcome: 'meetings', atMs: convertedAtMs },\n db,\n )\n }\n\n return record\n } catch (error) {\n console.error('attributeCampaignConversion failed', error)\n return null\n }\n}\n\n/*==========================================\n * WHAT A CAMPAIGN'S SEQUENCES PRODUCED (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and the Outreach runtime\n * credits what each enrollment produced — enrolled, first email sent, a\n * reply, a meeting booked from a sequence link, an enrolled lead converted\n * — to every campaign the sequence was in when the person was enrolled. The\n * counts live in `orgs/{orgId}/campaignSequenceReports/{campaignId}`, beside\n * the org's containers: one document per campaign, server-only like every\n * other report here, and never inside the campaign container, which the\n * history list reads.\n *\n * Idempotency is the CALLER's: a plugin credits an outcome once per\n * enrollment from a state change that happens once — the enrollment is\n * created once, its first email is the first, its status moves to replied\n * once, its lead converts once — so this writer is a plain increment and\n * keeps no per-enrollment record of its own.\n *=========================================*/\n\n/**\n * The per-org collection of sequence rollups. Restated from the reader\n * (`campaign-report.ts` in the campaigns UI library, which this package may\n * not import) and asserted equal by the reader's spec.\n */\nexport const CAMPAIGN_SEQUENCE_REPORTS_COLLECTION = 'campaignSequenceReports'\n\n/** The outcomes the runtime credits, in funnel order. */\nexport const CAMPAIGN_SEQUENCE_OUTCOMES = [\n 'enrolled',\n 'sent',\n 'replied',\n 'meetings',\n 'converted',\n] as const\n\nexport type CampaignSequenceOutcome = (typeof CAMPAIGN_SEQUENCE_OUTCOMES)[number]\n\n/**\n * Credits one outcome, for one enrollment, to every campaign named.\n *\n * Never throws, and never refuses the batch for one bad id: the outcome\n * has already happened, and a campaign that is gone gains an orphaned\n * report rather than blocking the credit to one that is not — the\n * conversions rollup's own argument for a merge-set that creates.\n *\n * @returns how many campaigns were credited.\n */\nexport async function creditCampaignSequenceOutcome(\n options: {\n /** The site the enrollment emails for; resolves the org when none is given. */\n hostId: string\n /**\n * The org whose containers the campaigns are, when the caller holds it —\n * an enrollment is an org document, so its routes do. Saves the\n * `hostIndex` read.\n */\n orgId?: string | null\n /** The campaigns the enrollment carries; unusable ids are skipped. */\n campaignIds: readonly string[]\n outcome: CampaignSequenceOutcome\n /** When the outcome happened. Defaults to now. */\n atMs?: number\n },\n firestore?: any,\n): Promise<number> {\n try {\n if (!(CAMPAIGN_SEQUENCE_OUTCOMES as readonly string[]).includes(options.outcome)) return 0\n const atMs = Number(options.atMs ?? Date.now())\n const ids = [...new Set(options.campaignIds.map((id) => String(id ?? '').trim()))].filter(\n (id) => isDocumentId(id),\n )\n if (!ids.length) return 0\n const hostId = String(options.hostId ?? '')\n const given = String(options.orgId ?? '')\n const orgId = isDocumentId(given)\n ? given\n : isDocumentId(hostId)\n ? await resolveOrgIdForHost(hostId)\n : null\n if (!orgId || !isDocumentId(orgId)) return 0\n const db = firestore ?? defaultFirestore()\n await Promise.all(\n ids.map((campaignId) =>\n orgCampaignSequenceReportRef(orgId, campaignId, db).set(\n {\n byOutcome: { [options.outcome]: FieldValue.increment(1) },\n ...(Number.isFinite(atMs) && atMs > 0 ? { updatedAtMs: atMs } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n ),\n ),\n )\n return ids.length\n } catch (error) {\n console.error('creditCampaignSequenceOutcome failed', error)\n return 0\n }\n}\n\n/*==========================================\n * WHERE A SEND IS (AGL-3273)\n *=========================================*/\n\n/**\n * WHERE A SEND IS, given only what a link or a delivery event carries.\n *\n * Campaign containers and sends belong to the organization\n * (`orgs/{orgId}/emailCampaigns`, `orgs/{orgId}/campaigns`, AGL-3273). Every\n * reader that starts from inside the product already holds the org id and\n * addresses those collections directly. This module is for the readers that\n * start OUTSIDE it — the unsubscribe link, the provider's delivery webhook,\n * the conversion join — which hold a site id and a send id and nothing else,\n * because that is what was signed into the link or tagged on the message\n * when it went out, possibly years ago.\n *\n * ## The fallback, and why it stays\n *\n * The site path (`hosts/{hostId}/campaigns/{sendId}`) is where every send\n * lived before the move, and the migration copies each one up under its\n * existing id. Between a deployment of this code and the migration reaching\n * an org, the send is still only at the site; and an interrupted migration\n * leaves some sends behind. A reader that looked only at the org would, in\n * that window, drop an unsubscribe's count or a bounce — and a bounce that\n * is dropped is an address that gets mailed again. So the org is asked\n * first and the site second. After the migration the second read never\n * happens, because the first one answers.\n *\n * The collection names are spelled here rather than imported from the\n * campaign model (`@aglyn/shared-ui-email-campaigns`), which this package\n * does not depend on. They are the same strings; `campaign-container.ts`\n * documents them. The join above is this lookup's first reader, and the two\n * move together when the campaign code leaves this package.\n */\n\nconst sendRefFirestore = (): Firestore => firebaseAdmin.app().firestore()\n\n/** `orgs/{orgId}/campaigns` — one document per email send. */\nconst ORG_SENDS = 'campaigns'\n\n/** `orgs/{orgId}/campaignSequenceReports` — keyed by the container id. */\nconst ORG_SEQUENCE_REPORTS = 'campaignSequenceReports'\n\n/** `orgs/{orgId}/campaigns/{sendId}`. */\nexport function orgCampaignSendRef(\n orgId: string,\n sendId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore.collection('orgs').doc(orgId).collection(ORG_SENDS).doc(sendId)\n}\n\n/** `orgs/{orgId}/campaignSequenceReports/{campaignId}`. */\nexport function orgCampaignSequenceReportRef(\n orgId: string,\n campaignId: string,\n firestore: Firestore = sendRefFirestore(),\n): DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(ORG_SEQUENCE_REPORTS)\n .doc(campaignId)\n}\n\n/** The pre-AGL-3273 location, read only as the fallback described above. */\nfunction legacySiteSendRef(\n hostId: string,\n sendId: string,\n firestore: Firestore,\n): DocumentReference {\n return firestore.collection('hosts').doc(hostId).collection(ORG_SENDS).doc(sendId)\n}\n\nexport interface ResolveCampaignSendRefInput {\n /** The site the link or the message tag names. */\n hostId: string\n /** The send id (`cid` on a link, `campaignId` on a message tag). */\n sendId: string\n /**\n * The org, when the caller already has it — a message tag minted since\n * AGL-3273 carries it. Saves the `hostIndex` read.\n */\n orgId?: string | null\n firestore?: Firestore\n}\n\n/**\n * The send's document, wherever it currently is. Null when neither location\n * holds it — a send id from a link whose send was discarded, or a forged one.\n *\n * Costs one read after the migration (the org document), plus the\n * `hostIndex` lookup when the caller has no org id; that lookup is\n * request-cached.\n */\nexport async function resolveCampaignSendRef(\n input: ResolveCampaignSendRefInput,\n): Promise<DocumentReference | null> {\n const { hostId, sendId } = input\n if (!hostId || !sendId || sendId.includes('/') || hostId.includes('/')) return null\n const firestore = input.firestore ?? sendRefFirestore()\n const orgId = input.orgId || (await resolveOrgIdForHost(hostId))\n if (orgId) {\n const orgRef = orgCampaignSendRef(orgId, sendId, firestore)\n if ((await orgRef.get()).exists) return orgRef\n }\n const siteRef = legacySiteSendRef(hostId, sendId, firestore)\n if ((await siteRef.get()).exists) return siteRef\n return null\n}\n"],"names":["FieldValue","parseUtmTouch","EMAIL_ATTRIBUTION_MODEL","ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","ATTRIBUTION_WINDOW_DAYS","emailTouchIsInWindow","personKey","CAMPAIGN_ATTRIBUTIONS_COLLECTION","eraseCampaignAttributionsForPersonKey","readEmailCampaignTouch","isDocumentId","firebaseAdmin","resolveOrgIdForHost","defaultFirestore","app","firestore","CAMPAIGN_CONVERSIONS_REPORT_DOC","campaignConversionId","kind","refId","resolveCampaignTouch","options","hostId","String","convertedAtMs","Number","atMs","Date","now","isFinite","web","wire","key","email","emailTouch","emailInWindow","campaignId","clickedAtMs","emailWins","viaSequence","sequenceId","enrollmentId","channel","touchedAtMs","source","medium","campaign","error","console","attributeCampaignConversion","touch","record","model","windowDays","db","hostRef","collection","doc","create","createdAt","serverTimestamp","sendRef","resolveCampaignSendRef","sendId","set","byKind","increment","updatedAt","merge","creditCampaignSequenceOutcome","campaignIds","outcome","CAMPAIGN_SEQUENCE_REPORTS_COLLECTION","CAMPAIGN_SEQUENCE_OUTCOMES","includes","ids","Set","map","id","trim","filter","length","given","orgId","Promise","all","orgCampaignSequenceReportRef","byOutcome","updatedAtMs","sendRefFirestore","ORG_SENDS","ORG_SEQUENCE_REPORTS","orgCampaignSendRef","legacySiteSendRef","input","orgRef","get","exists","siteRef"],"mappings":"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"}
@@ -38,6 +38,13 @@ export interface CardPaymentVelocityOptions {
38
38
  }
39
39
  /** The abuse-queue row id for a site's alarm on one day. Hex, like every row. */
40
40
  export declare function cardPaymentAlarmReviewId(hostId: string, day: string): string;
41
+ /**
42
+ * What the alarm is about, in the owner's words:
43
+ * `checkout on the site "Harbor View"`. The label rides on the row, so the
44
+ * review's closing notice and the staff alert name the site too — a
45
+ * workspace with several stores reads which orders to look at. Pure.
46
+ */
47
+ export declare function cardTestingItemLabel(siteName: string | null | undefined): string;
41
48
  /**
42
49
  * Tell staff and the site's managers and the workspace's owners that a site
43
50
  * crossed the card-payment window (AGL-3363), through the risk notice seam
@@ -50,6 +57,8 @@ export declare function notifyCardTestingVelocity(input: {
50
57
  orgId: string | null;
51
58
  reference: string;
52
59
  reviewId?: string | null;
60
+ /** The site's display name, for the label. */
61
+ siteName?: string | null;
53
62
  }, deps: {
54
63
  notifyRisk: (input: RiskEventInput) => Promise<unknown>;
55
64
  }): Promise<void>;