@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.220

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 (156) 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-description.d.ts +112 -0
  5. package/src/lib/server/api-v1-description.js +126 -0
  6. package/src/lib/server/api-v1-description.js.map +1 -0
  7. package/src/lib/server/api-v1-kit.d.ts +163 -0
  8. package/src/lib/server/api-v1-kit.js +191 -0
  9. package/src/lib/server/api-v1-kit.js.map +1 -0
  10. package/src/lib/server/api-v1-resources.d.ts +143 -0
  11. package/src/lib/server/api-v1-resources.js +125 -0
  12. package/src/lib/server/api-v1-resources.js.map +1 -0
  13. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  14. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  15. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  16. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  17. package/src/lib/server/card-payment-velocity.js +28 -2
  18. package/src/lib/server/card-payment-velocity.js.map +1 -1
  19. package/src/lib/server/consent-group-carry.js +1 -1
  20. package/src/lib/server/consent-group-carry.js.map +1 -1
  21. package/src/lib/server/consent-group-change.d.ts +2 -1
  22. package/src/lib/server/consent-group-change.js +43 -18
  23. package/src/lib/server/consent-group-change.js.map +1 -1
  24. package/src/lib/server/contact-email-index.d.ts +22 -0
  25. package/src/lib/server/contact-email-index.js +108 -0
  26. package/src/lib/server/contact-email-index.js.map +1 -1
  27. package/src/lib/server/crm-email-activity.d.ts +1 -1
  28. package/src/lib/server/crm-email-activity.js +1 -1
  29. package/src/lib/server/crm-email-activity.js.map +1 -1
  30. package/src/lib/server/crm-records.d.ts +9 -1
  31. package/src/lib/server/crm-records.js +19 -2
  32. package/src/lib/server/crm-records.js.map +1 -1
  33. package/src/lib/server/cron-auth.d.ts +44 -0
  34. package/src/lib/server/cron-auth.js +76 -0
  35. package/src/lib/server/cron-auth.js.map +1 -0
  36. package/src/lib/server/dataset-live-pages.js +6 -6
  37. package/src/lib/server/dataset-live-pages.js.map +1 -1
  38. package/src/lib/server/duplicate-resource.d.ts +26 -0
  39. package/src/lib/server/duplicate-resource.js +47 -25
  40. package/src/lib/server/duplicate-resource.js.map +1 -1
  41. package/src/lib/server/dynamic-list-materialize.js +6 -3
  42. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  43. package/src/lib/server/email-flow-gate.js +2 -2
  44. package/src/lib/server/email-flow-gate.js.map +1 -1
  45. package/src/lib/server/email-suppression.d.ts +11 -0
  46. package/src/lib/server/email-suppression.js +37 -2
  47. package/src/lib/server/email-suppression.js.map +1 -1
  48. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  49. package/src/lib/server/email-unsubscribe-link.js +7 -7
  50. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  51. package/src/lib/server/erase.d.ts +19 -33
  52. package/src/lib/server/erase.js +37 -162
  53. package/src/lib/server/erase.js.map +1 -1
  54. package/src/lib/server/host-email-tokens.js +1 -1
  55. package/src/lib/server/host-email-tokens.js.map +1 -1
  56. package/src/lib/server/host-memberships.js +3 -3
  57. package/src/lib/server/host-memberships.js.map +1 -1
  58. package/src/lib/server/host-visitor-records.d.ts +32 -4
  59. package/src/lib/server/host-visitor-records.js +73 -14
  60. package/src/lib/server/host-visitor-records.js.map +1 -1
  61. package/src/lib/server/hosted-page-review.d.ts +27 -0
  62. package/src/lib/server/hosted-page-review.js +149 -12
  63. package/src/lib/server/hosted-page-review.js.map +1 -1
  64. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  65. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  66. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  67. package/src/lib/server/member-email-aliases.d.ts +10 -2
  68. package/src/lib/server/member-email-aliases.js +26 -2
  69. package/src/lib/server/member-email-aliases.js.map +1 -1
  70. package/src/lib/server/new-account-announcement.d.ts +62 -0
  71. package/src/lib/server/new-account-announcement.js +77 -0
  72. package/src/lib/server/new-account-announcement.js.map +1 -0
  73. package/src/lib/server/notifications.d.ts +22 -2
  74. package/src/lib/server/notifications.js +40 -3
  75. package/src/lib/server/notifications.js.map +1 -1
  76. package/src/lib/server/operator-alerts.d.ts +7 -1
  77. package/src/lib/server/operator-alerts.js +5 -3
  78. package/src/lib/server/operator-alerts.js.map +1 -1
  79. package/src/lib/server/org-containers.d.ts +66 -0
  80. package/src/lib/server/org-containers.js +95 -0
  81. package/src/lib/server/org-containers.js.map +1 -0
  82. package/src/lib/server/org-member-notice.js +3 -2
  83. package/src/lib/server/org-member-notice.js.map +1 -1
  84. package/src/lib/server/payment-fraud-signal.js +5 -1
  85. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  86. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  87. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
  88. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  89. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  90. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  91. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  92. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  93. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  94. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  95. package/src/lib/server/payment-provider.d.ts +173 -0
  96. package/src/lib/server/payment-provider.js +70 -0
  97. package/src/lib/server/payment-provider.js.map +1 -0
  98. package/src/lib/server/payment-risk-record.d.ts +1 -1
  99. package/src/lib/server/payment-risk-record.js +3 -1
  100. package/src/lib/server/payment-risk-record.js.map +1 -1
  101. package/src/lib/server/personal-data-export.js +1 -1
  102. package/src/lib/server/personal-data-export.js.map +1 -1
  103. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  104. package/src/lib/server/platform-marketing-consent.js +1 -1
  105. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  106. package/src/lib/server/rate-limit-store.js +13 -3
  107. package/src/lib/server/rate-limit-store.js.map +1 -1
  108. package/src/lib/server/realm-plugins.js +8 -3
  109. package/src/lib/server/realm-plugins.js.map +1 -1
  110. package/src/lib/server/retained-refusals.d.ts +142 -0
  111. package/src/lib/server/retained-refusals.js +325 -0
  112. package/src/lib/server/retained-refusals.js.map +1 -0
  113. package/src/lib/server/risk-notice.d.ts +32 -1
  114. package/src/lib/server/risk-notice.js +90 -28
  115. package/src/lib/server/risk-notice.js.map +1 -1
  116. package/src/lib/server/sending-domains.d.ts +17 -0
  117. package/src/lib/server/sending-domains.js +28 -4
  118. package/src/lib/server/sending-domains.js.map +1 -1
  119. package/src/lib/server/sso-enforcement.js +7 -2
  120. package/src/lib/server/sso-enforcement.js.map +1 -1
  121. package/src/lib/server/staff-alert-email.js +4 -2
  122. package/src/lib/server/staff-alert-email.js.map +1 -1
  123. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  124. package/src/lib/server/test-firestore-queries.js +70 -27
  125. package/src/lib/server/test-firestore-queries.js.map +1 -1
  126. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  127. package/src/lib/server/topic-subscriptions.js.map +1 -0
  128. package/src/lib/server/upsert-contact.d.ts +46 -1
  129. package/src/lib/server/upsert-contact.js +87 -20
  130. package/src/lib/server/upsert-contact.js.map +1 -1
  131. package/src/tenancy.d.ts +5 -8
  132. package/src/tenancy.js +12 -13
  133. package/src/tenancy.js.map +1 -1
  134. package/src/lib/server/connect-account-status.js.map +0 -1
  135. package/src/lib/server/connect-payout-failure.js.map +0 -1
  136. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  137. package/src/lib/server/contact-email-engagement.js +0 -145
  138. package/src/lib/server/contact-email-engagement.js.map +0 -1
  139. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  140. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  141. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  142. package/src/lib/server/contact-merge.d.ts +0 -55
  143. package/src/lib/server/contact-merge.js +0 -288
  144. package/src/lib/server/contact-merge.js.map +0 -1
  145. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  146. package/src/lib/server/crm-booking-activity.js +0 -257
  147. package/src/lib/server/crm-booking-activity.js.map +0 -1
  148. package/src/lib/server/crm-inbound-email.d.ts +0 -145
  149. package/src/lib/server/crm-inbound-email.js +0 -297
  150. package/src/lib/server/crm-inbound-email.js.map +0 -1
  151. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  152. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  153. package/src/lib/server/form-dataset-binding-token.js +0 -107
  154. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  155. package/src/lib/server/stripe-account-mode.js.map +0 -1
  156. /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.218",
3
+ "version": "1.0.0-beta.220",
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.218",
29
- "@aglyn/shared-util-email": "1.0.0-beta.218",
30
- "@aglyn/shared-util-fbserver": "1.0.0-beta.218",
31
- "@aglyn/shared-util-first-touch": "1.0.0-beta.218",
32
- "@aglyn/shared-util-http": "1.0.0-beta.218",
33
- "@aglyn/shared-util-tools": "1.0.0-beta.218",
28
+ "@aglyn/aglyn": "1.0.0-beta.220",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.220",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.220",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.220",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.220",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.220",
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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"}
@@ -0,0 +1,112 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * How a `/v1` resource is described in the customer API's OpenAPI document
19
+ * (AGL-2733), in the shape the console's builder turns into paths, tags and
20
+ * schemas — whoever serves the resource.
21
+ *
22
+ * The platform describes its own resources with these; a plugin that serves
23
+ * one (`api-v1-resources.ts`) hands the builder its description through the
24
+ * registration, so the document, and the MCP tools derived from it, describe
25
+ * every resource the build serves and nothing it does not.
26
+ *
27
+ * Pure data and pure functions: nothing here reads a request or a store.
28
+ */
29
+ /** A JSON Schema / OpenAPI fragment. */
30
+ export type ApiV1Schema = Record<string, unknown>;
31
+ export interface ApiV1Operation {
32
+ /** Path relative to the server, e.g. `/v1/contacts/{contactId}`. */
33
+ readonly path: string;
34
+ readonly method: 'get' | 'post' | 'patch' | 'delete';
35
+ readonly operationId: string;
36
+ readonly summary: string;
37
+ readonly description?: string;
38
+ /** `true` when the response is the paginated list envelope. */
39
+ readonly list?: boolean;
40
+ /** Schema name for the response body, when it is a single record. */
41
+ readonly returns?: string;
42
+ /** Schema name for the request body. */
43
+ readonly accepts?: string;
44
+ /** Extra query parameters beyond the pagination pair. */
45
+ readonly filters?: readonly ApiV1Schema[];
46
+ /** Path parameters, in order. */
47
+ readonly pathParams?: readonly {
48
+ name: string;
49
+ description: string;
50
+ }[];
51
+ /**
52
+ * `true` for a create: a fresh one answers `201`, and a replay of the same
53
+ * `Idempotency-Key` answers `200` with the record the original create made.
54
+ */
55
+ readonly creates?: boolean;
56
+ /** Entitlement the call needs, named in the 403. */
57
+ readonly entitlement?: string;
58
+ }
59
+ export interface ApiV1ResourceDescription {
60
+ readonly tag: string;
61
+ readonly description: string;
62
+ readonly schemaName: string;
63
+ readonly fields: Record<string, ApiV1Schema>;
64
+ readonly required: readonly string[];
65
+ readonly ops: readonly ApiV1Operation[];
66
+ /**
67
+ * The members a write body may carry: the handler's own writable set, in the
68
+ * order they are described. A record returns members nothing can write —
69
+ * `siteId`, `sources`, the stamps — and a closed body listing them would tell
70
+ * a generated client to send what the handler refuses.
71
+ */
72
+ readonly writable?: readonly string[];
73
+ /** Schemas for writable members the record does not return, or returns differently. */
74
+ readonly writeOnly?: Record<string, ApiV1Schema>;
75
+ /** What a create accepts that an update does not, in words. */
76
+ readonly writeNote?: string;
77
+ /** Members a write must carry. Only for a body a single method takes. */
78
+ readonly writeRequired?: readonly string[];
79
+ /**
80
+ * Further named schemas the operations reference — a request body that is
81
+ * not the record's write shape (`ContactMerge`), a response that is not the
82
+ * record (`LeadConversion`). Each replaces a schema of the same name the
83
+ * builder would otherwise derive.
84
+ */
85
+ readonly components?: Record<string, ApiV1Schema>;
86
+ }
87
+ export declare const isoField: (description: string) => ApiV1Schema;
88
+ export declare const stringField: (description: string) => ApiV1Schema;
89
+ export declare const integerField: (description: string) => ApiV1Schema;
90
+ export declare const booleanField: (description: string) => ApiV1Schema;
91
+ export declare const stringListField: (description: string) => ApiV1Schema;
92
+ /**
93
+ * A field the API may answer with `null`.
94
+ *
95
+ * OpenAPI 3.1 is JSON Schema 2020-12, where nullability is a type UNION and
96
+ * not the 3.0 `nullable: true` keyword — a generator reading `nullable` here
97
+ * would silently produce a non-optional field, which is the whole class of bug
98
+ * the document exists to remove.
99
+ */
100
+ export declare const nullableField: (base: ApiV1Schema) => ApiV1Schema;
101
+ export declare const openObjectField: (description: string) => ApiV1Schema;
102
+ /** A record's `object` member, which names its kind. */
103
+ export declare const objectKindField: (name: string) => ApiV1Schema;
104
+ export declare const postalAddressField: () => ApiV1Schema;
105
+ /** Every record carries these two. */
106
+ export declare const RECORD_STAMPS: {
107
+ created: ApiV1Schema;
108
+ updated: ApiV1Schema;
109
+ };
110
+ export declare const UPDATED_AFTER_PARAM: ApiV1Schema;
111
+ /** An optional query parameter a list filters by. */
112
+ export declare const queryParam: (name: string, description: string, schema?: ApiV1Schema) => ApiV1Schema;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * How a `/v1` resource is described in the customer API's OpenAPI document
18
+ * (AGL-2733), in the shape the console's builder turns into paths, tags and
19
+ * schemas — whoever serves the resource.
20
+ *
21
+ * The platform describes its own resources with these; a plugin that serves
22
+ * one (`api-v1-resources.ts`) hands the builder its description through the
23
+ * registration, so the document, and the MCP tools derived from it, describe
24
+ * every resource the build serves and nothing it does not.
25
+ *
26
+ * Pure data and pure functions: nothing here reads a request or a store.
27
+ */ /** A JSON Schema / OpenAPI fragment. */ import { _ as _extends } from "@swc/helpers/_/_extends";
28
+ // ── Field schemas ──────────────────────────────────────────────────────────
29
+ export const isoField = (description)=>({
30
+ type: 'string',
31
+ format: 'date-time',
32
+ description
33
+ });
34
+ export const stringField = (description)=>({
35
+ type: 'string',
36
+ description
37
+ });
38
+ export const integerField = (description)=>({
39
+ type: 'integer',
40
+ description
41
+ });
42
+ export const booleanField = (description)=>({
43
+ type: 'boolean',
44
+ description
45
+ });
46
+ export const stringListField = (description)=>({
47
+ type: 'array',
48
+ items: {
49
+ type: 'string'
50
+ },
51
+ description
52
+ });
53
+ /**
54
+ * A field the API may answer with `null`.
55
+ *
56
+ * OpenAPI 3.1 is JSON Schema 2020-12, where nullability is a type UNION and
57
+ * not the 3.0 `nullable: true` keyword — a generator reading `nullable` here
58
+ * would silently produce a non-optional field, which is the whole class of bug
59
+ * the document exists to remove.
60
+ */ export const nullableField = (base)=>_extends({}, base, {
61
+ type: [
62
+ base['type'],
63
+ 'null'
64
+ ]
65
+ });
66
+ export const openObjectField = (description)=>({
67
+ type: 'object',
68
+ description,
69
+ additionalProperties: true
70
+ });
71
+ /** A record's `object` member, which names its kind. */ export const objectKindField = (name)=>({
72
+ type: 'string',
73
+ const: name,
74
+ description: `Always \`${name}\`. Lets a client discriminate a mixed array.`
75
+ });
76
+ export const postalAddressField = ()=>({
77
+ type: 'object',
78
+ description: 'Postal address. Members are optional and free-form.',
79
+ properties: {
80
+ line1: {
81
+ type: 'string'
82
+ },
83
+ line2: {
84
+ type: 'string'
85
+ },
86
+ city: {
87
+ type: 'string'
88
+ },
89
+ region: {
90
+ type: 'string'
91
+ },
92
+ postalCode: {
93
+ type: 'string'
94
+ },
95
+ country: {
96
+ type: 'string'
97
+ }
98
+ },
99
+ additionalProperties: true
100
+ });
101
+ /** Every record carries these two. */ export const RECORD_STAMPS = {
102
+ created: isoField('When the record was created.'),
103
+ updated: isoField('When the record last changed. CRM lists can be walked by it.')
104
+ };
105
+ // ── Query parameters ───────────────────────────────────────────────────────
106
+ export const UPDATED_AFTER_PARAM = {
107
+ name: 'updatedAfter',
108
+ in: 'query',
109
+ required: false,
110
+ description: 'ISO 8601 instant WITH an offset (`2026-09-01T00:00:00Z`). A bare date ' + 'is a 400 — midnight in whose zone is not a question this API can ' + 'answer. Reorders the list by `updated` ascending, and moves every other ' + 'filter out of the query and onto the page, so pages can come back short.',
111
+ schema: {
112
+ type: 'string',
113
+ format: 'date-time'
114
+ }
115
+ };
116
+ /** An optional query parameter a list filters by. */ export const queryParam = (name, description, schema = {
117
+ type: 'string'
118
+ })=>({
119
+ name,
120
+ in: 'query',
121
+ required: false,
122
+ description,
123
+ schema
124
+ });
125
+
126
+ //# sourceMappingURL=api-v1-description.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-description.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 * How a `/v1` resource is described in the customer API's OpenAPI document\n * (AGL-2733), in the shape the console's builder turns into paths, tags and\n * schemas — whoever serves the resource.\n *\n * The platform describes its own resources with these; a plugin that serves\n * one (`api-v1-resources.ts`) hands the builder its description through the\n * registration, so the document, and the MCP tools derived from it, describe\n * every resource the build serves and nothing it does not.\n *\n * Pure data and pure functions: nothing here reads a request or a store.\n */\n\n/** A JSON Schema / OpenAPI fragment. */\nexport type ApiV1Schema = Record<string, unknown>\n\nexport interface ApiV1Operation {\n /** Path relative to the server, e.g. `/v1/contacts/{contactId}`. */\n readonly path: string\n readonly method: 'get' | 'post' | 'patch' | 'delete'\n readonly operationId: string\n readonly summary: string\n readonly description?: string\n /** `true` when the response is the paginated list envelope. */\n readonly list?: boolean\n /** Schema name for the response body, when it is a single record. */\n readonly returns?: string\n /** Schema name for the request body. */\n readonly accepts?: string\n /** Extra query parameters beyond the pagination pair. */\n readonly filters?: readonly ApiV1Schema[]\n /** Path parameters, in order. */\n readonly pathParams?: readonly { name: string; description: string }[]\n /**\n * `true` for a create: a fresh one answers `201`, and a replay of the same\n * `Idempotency-Key` answers `200` with the record the original create made.\n */\n readonly creates?: boolean\n /** Entitlement the call needs, named in the 403. */\n readonly entitlement?: string\n}\n\nexport interface ApiV1ResourceDescription {\n readonly tag: string\n readonly description: string\n readonly schemaName: string\n readonly fields: Record<string, ApiV1Schema>\n readonly required: readonly string[]\n readonly ops: readonly ApiV1Operation[]\n /**\n * The members a write body may carry: the handler's own writable set, in the\n * order they are described. A record returns members nothing can write —\n * `siteId`, `sources`, the stamps — and a closed body listing them would tell\n * a generated client to send what the handler refuses.\n */\n readonly writable?: readonly string[]\n /** Schemas for writable members the record does not return, or returns differently. */\n readonly writeOnly?: Record<string, ApiV1Schema>\n /** What a create accepts that an update does not, in words. */\n readonly writeNote?: string\n /** Members a write must carry. Only for a body a single method takes. */\n readonly writeRequired?: readonly string[]\n /**\n * Further named schemas the operations reference — a request body that is\n * not the record's write shape (`ContactMerge`), a response that is not the\n * record (`LeadConversion`). Each replaces a schema of the same name the\n * builder would otherwise derive.\n */\n readonly components?: Record<string, ApiV1Schema>\n}\n\n// ── Field schemas ──────────────────────────────────────────────────────────\n\nexport const isoField = (description: string): ApiV1Schema => ({\n type: 'string',\n format: 'date-time',\n description,\n})\n\nexport const stringField = (description: string): ApiV1Schema => ({ type: 'string', description })\nexport const integerField = (description: string): ApiV1Schema => ({ type: 'integer', description })\nexport const booleanField = (description: string): ApiV1Schema => ({ type: 'boolean', description })\nexport const stringListField = (description: string): ApiV1Schema => ({\n type: 'array',\n items: { type: 'string' },\n description,\n})\n\n/**\n * A field the API may answer with `null`.\n *\n * OpenAPI 3.1 is JSON Schema 2020-12, where nullability is a type UNION and\n * not the 3.0 `nullable: true` keyword — a generator reading `nullable` here\n * would silently produce a non-optional field, which is the whole class of bug\n * the document exists to remove.\n */\nexport const nullableField = (base: ApiV1Schema): ApiV1Schema => ({\n ...base,\n type: [base['type'], 'null'],\n})\n\nexport const openObjectField = (description: string): ApiV1Schema => ({\n type: 'object',\n description,\n additionalProperties: true,\n})\n\n/** A record's `object` member, which names its kind. */\nexport const objectKindField = (name: string): ApiV1Schema => ({\n type: 'string',\n const: name,\n description: `Always \\`${name}\\`. Lets a client discriminate a mixed array.`,\n})\n\nexport const postalAddressField = (): ApiV1Schema => ({\n type: 'object',\n description: 'Postal address. Members are optional and free-form.',\n properties: {\n line1: { type: 'string' },\n line2: { type: 'string' },\n city: { type: 'string' },\n region: { type: 'string' },\n postalCode: { type: 'string' },\n country: { type: 'string' },\n },\n additionalProperties: true,\n})\n\n/** Every record carries these two. */\nexport const RECORD_STAMPS = {\n created: isoField('When the record was created.'),\n updated: isoField('When the record last changed. CRM lists can be walked by it.'),\n}\n\n// ── Query parameters ───────────────────────────────────────────────────────\n\nexport const UPDATED_AFTER_PARAM: ApiV1Schema = {\n name: 'updatedAfter',\n in: 'query',\n required: false,\n description:\n 'ISO 8601 instant WITH an offset (`2026-09-01T00:00:00Z`). A bare date ' +\n 'is a 400 — midnight in whose zone is not a question this API can ' +\n 'answer. Reorders the list by `updated` ascending, and moves every other ' +\n 'filter out of the query and onto the page, so pages can come back short.',\n schema: { type: 'string', format: 'date-time' },\n}\n\n/** An optional query parameter a list filters by. */\nexport const queryParam = (\n name: string,\n description: string,\n schema: ApiV1Schema = { type: 'string' },\n): ApiV1Schema => ({\n name,\n in: 'query',\n required: false,\n description,\n schema,\n})\n"],"names":["isoField","description","type","format","stringField","integerField","booleanField","stringListField","items","nullableField","base","openObjectField","additionalProperties","objectKindField","name","const","postalAddressField","properties","line1","line2","city","region","postalCode","country","RECORD_STAMPS","created","updated","UPDATED_AFTER_PARAM","in","required","schema","queryParam"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;CAWC,GAED,sCAAsC;AA0DtC,8EAA8E;AAE9E,OAAO,MAAMA,WAAW,CAACC,cAAsC,CAAA;QAC7DC,MAAM;QACNC,QAAQ;QACRF;IACF,CAAA,EAAE;AAEF,OAAO,MAAMG,cAAc,CAACH,cAAsC,CAAA;QAAEC,MAAM;QAAUD;IAAY,CAAA,EAAE;AAClG,OAAO,MAAMI,eAAe,CAACJ,cAAsC,CAAA;QAAEC,MAAM;QAAWD;IAAY,CAAA,EAAE;AACpG,OAAO,MAAMK,eAAe,CAACL,cAAsC,CAAA;QAAEC,MAAM;QAAWD;IAAY,CAAA,EAAE;AACpG,OAAO,MAAMM,kBAAkB,CAACN,cAAsC,CAAA;QACpEC,MAAM;QACNM,OAAO;YAAEN,MAAM;QAAS;QACxBD;IACF,CAAA,EAAE;AAEF;;;;;;;CAOC,GACD,OAAO,MAAMQ,gBAAgB,CAACC,OAAoC,aAC7DA;QACHR,MAAM;YAACQ,IAAI,CAAC,OAAO;YAAE;SAAO;OAC5B;AAEF,OAAO,MAAMC,kBAAkB,CAACV,cAAsC,CAAA;QACpEC,MAAM;QACND;QACAW,sBAAsB;IACxB,CAAA,EAAE;AAEF,sDAAsD,GACtD,OAAO,MAAMC,kBAAkB,CAACC,OAA+B,CAAA;QAC7DZ,MAAM;QACNa,OAAOD;QACPb,aAAa,CAAC,SAAS,EAAEa,KAAK,6CAA6C,CAAC;IAC9E,CAAA,EAAE;AAEF,OAAO,MAAME,qBAAqB,IAAoB,CAAA;QACpDd,MAAM;QACND,aAAa;QACbgB,YAAY;YACVC,OAAO;gBAAEhB,MAAM;YAAS;YACxBiB,OAAO;gBAAEjB,MAAM;YAAS;YACxBkB,MAAM;gBAAElB,MAAM;YAAS;YACvBmB,QAAQ;gBAAEnB,MAAM;YAAS;YACzBoB,YAAY;gBAAEpB,MAAM;YAAS;YAC7BqB,SAAS;gBAAErB,MAAM;YAAS;QAC5B;QACAU,sBAAsB;IACxB,CAAA,EAAE;AAEF,oCAAoC,GACpC,OAAO,MAAMY,gBAAgB;IAC3BC,SAASzB,SAAS;IAClB0B,SAAS1B,SAAS;AACpB,EAAC;AAED,8EAA8E;AAE9E,OAAO,MAAM2B,sBAAmC;IAC9Cb,MAAM;IACNc,IAAI;IACJC,UAAU;IACV5B,aACE,2EACA,sEACA,6EACA;IACF6B,QAAQ;QAAE5B,MAAM;QAAUC,QAAQ;IAAY;AAChD,EAAC;AAED,mDAAmD,GACnD,OAAO,MAAM4B,aAAa,CACxBjB,MACAb,aACA6B,SAAsB;IAAE5B,MAAM;AAAS,CAAC,GACvB,CAAA;QACjBY;QACAc,IAAI;QACJC,UAAU;QACV5B;QACA6B;IACF,CAAA,EAAE"}
@@ -0,0 +1,163 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * What every `/v1` resource handler shares (AGL-2606): the context the
19
+ * pipeline hands it, the scope check, the cursor, serialization and
20
+ * idempotency helpers, and the shape a usage figure is published in.
21
+ *
22
+ * One copy for every resource, whoever serves it. A second copy of the
23
+ * cursor grammar or the idempotency claim in any handler is exactly the drift
24
+ * these exist to prevent: a cursor one resource encodes and another cannot
25
+ * decode, or a claim digest that collides across resources because one copy
26
+ * forgot the org. They are the data layer's because a resource a plugin
27
+ * serves (`api-v1-resources.ts`) is a handler too, and a plugin may not
28
+ * import the console.
29
+ */
30
+ import { type AttemptClaim } from '@aglyn/aglyn/server';
31
+ import type { AglynOrganization } from '@aglyn/aglyn';
32
+ import type { ApiScope } from './api-keys';
33
+ /** What the pipeline resolved for one authenticated request. */
34
+ export interface ApiV1Context {
35
+ orgId: string;
36
+ keyId: string;
37
+ /**
38
+ * The key's name, as the organization labeled it, or null for a key with
39
+ * none. What a write made with the key is attributed to in the console's
40
+ * activity feeds (AGL-2632) — the key is the actor, and "API key Zapier"
41
+ * is its name the way an address is a person's.
42
+ */
43
+ keyName: string | null;
44
+ scopes: ApiScope[];
45
+ org: Partial<AglynOrganization>;
46
+ firestore: FirebaseFirestore.Firestore;
47
+ /** Rate-limit headers to echo on every response for this request. */
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>;
56
+ }
57
+ /** Return a 403 unless the key carries `scope`; otherwise `null` (proceed). */
58
+ export declare function requireScope(context: ApiV1Context, scope: ApiScope): Response | null;
59
+ /** Firestore values → JSON-safe values (Timestamps become ISO strings). */
60
+ export declare function serialize(value: unknown): unknown;
61
+ export interface Paginated {
62
+ docs: FirebaseFirestore.QueryDocumentSnapshot[];
63
+ nextCursor: string | null;
64
+ }
65
+ export declare function paginate(query: FirebaseFirestore.Query, url: URL): Promise<Paginated>;
66
+ export declare function readJsonBody(request: Request): Promise<Record<string, unknown>>;
67
+ /**
68
+ * The operations a claim can belong to. Hashed into the digest, so a key
69
+ * reused across two of them is two separate attempts — see `claimWrite`.
70
+ *
71
+ * The CRM pairs (AGL-2606) follow the contact pair's shape: a create and a
72
+ * delete per resource, sharing the organization as their scope suffix and
73
+ * kept apart by the kind alone.
74
+ */
75
+ export type ClaimKind = 'records' | 'record-deletes' | 'datasets' | 'dataset-deletes' | 'form-submission-deletes' | 'contacts' | 'contact-deletes' | 'contact-merges' | 'media' | 'sites' | 'companies' | 'company-deletes' | 'deals' | 'deal-deletes' | 'tasks' | 'task-deletes' | 'activities' | 'activity-deletes' | 'lead-conversions' | 'lead-creates' | 'email-templates' | 'email-template-deletes';
76
+ /**
77
+ * Take an exclusive claim on one write attempt (AGL-1709), and translate the
78
+ * transport-agnostic result into a v1 `Response`.
79
+ *
80
+ * The claim itself is the shared `claimAttempt` (AGL-1697, `220217133`) — an
81
+ * unconditional `create()`, where Firestore's rejection on an existing document
82
+ * IS the dedupe primitive. This resource used to run its own read-then-create,
83
+ * which is the exact race the mechanism exists to prevent: two concurrent
84
+ * requests carrying one key both read "no prior key", both fall through, both
85
+ * create. Worse than no check at all in one respect, because it made the path
86
+ * look protected. Adopting the shared claim rather than fixing the local copy
87
+ * is the point — POS, refunds and the REST API now answer this question once.
88
+ *
89
+ * `scopeId` carries the ORG as well as the dataset. The helper hashes
90
+ * `{kind}:{scopeId}:{key}` and its plugin callers pass a globally unique host
91
+ * id, but a dataset id is only meaningful under its org — and the claim stores
92
+ * the response body, so a cross-org digest collision would replay one tenant's
93
+ * record to another. The org belongs in the digest, not just in the swept
94
+ * field. Scoping by the DATASET is what `apps/docs/api/conventions.md` already
95
+ * publishes ("replay is looked up within the dataset you're posting to"); the
96
+ * old org-only digest only reached that outcome by accident, by reading a
97
+ * `recordId` that did not exist in the other dataset and falling through — and
98
+ * having fallen through, its key write failed and was swallowed, so the key
99
+ * never deduped there at all.
100
+ *
101
+ * NOTE: the digest changes, so keys stored under the old `orgId:key` shape are
102
+ * orphaned and a retry straddling this deploy creates a duplicate. Accepted —
103
+ * the API is pre-beta and the alternative is carrying the race forever.
104
+ *
105
+ * `kind` separates the create's claims from the delete's (AGL-1710). The
106
+ * helper hashes it into the digest for exactly this reason, and the two record
107
+ * different response BODIES — a record view versus a `{ deleted: true }`
108
+ * receipt. Sharing one namespace would let a key reused across both operations
109
+ * replay the create's record to a delete, which a client parses as a success
110
+ * and which no amount of documentation makes safe. `POST` keeps `records`
111
+ * verbatim: changing its digest orphans keys in flight, and AGL-1709 already
112
+ * paid that cost once.
113
+ *
114
+ * `scopeSuffix` is the object the key is scoped WITHIN — a dataset id for the
115
+ * record operations and for `DELETE /v1/datasets/{id}`, the site for
116
+ * `DELETE …/form-submissions/{id}`, and `*` — the ORGANIZATION — for
117
+ * `POST /v1/datasets` (AGL-2126), where there is no dataset id yet because
118
+ * the request is what creates one, and for both contact writes (AGL-2276),
119
+ * where the organization genuinely IS the containing object: contacts are
120
+ * org-scoped (AGL-237), so there is no site or parent collection to scope to
121
+ * and pretending otherwise would invent a boundary the data does not have.
122
+ * `*` cannot collide with a resource id: `createResourceUid()` never emits
123
+ * one, and the `kind` is separate anyway. The suffix is not the whole story —
124
+ * `kind` is hashed in too, which is what keeps a create's key from replaying
125
+ * into a delete, and what keeps `contacts` and `contact-deletes` apart while
126
+ * they share a suffix.
127
+ *
128
+ * This paragraph used to say `*` covered "the two that act on the dataset
129
+ * collection itself", which the delete has never done — it passes
130
+ * `datasetRef.id`. The published contract in `apps/docs/api/conventions.md` was
131
+ * written from this comment and inherited the error (AGL-2218), which is the
132
+ * argument for reading the call rather than the docblock above it.
133
+ */
134
+ export declare function claimWrite(ctx: ApiV1Context, scopeSuffix: string, key: string | null, kind: ClaimKind): Promise<{
135
+ claim: AttemptClaim;
136
+ } | {
137
+ replay: Response;
138
+ }>;
139
+ /**
140
+ * One metered dimension, as published (AGL-2277).
141
+ *
142
+ * `included`/`remaining` are `null` for an UNLIMITED band rather than the
143
+ * sentinel itself: `UNLIMITED` is `Number.POSITIVE_INFINITY`, which
144
+ * `JSON.stringify` silently turns into `null` anyway — so the choice is
145
+ * between a `null` that means "unlimited" on purpose and the same `null`
146
+ * arriving by accident, indistinguishable from a bug. Stated explicitly, and
147
+ * documented, so an integrator can branch on it.
148
+ *
149
+ * `metered` is the field that actually answers "what happens when I cross
150
+ * this" — true means the excess bills, false means the next call is refused.
151
+ * It is read off the plan's own overage rate through the `check*Quota`
152
+ * helpers rather than re-derived here, because a second copy of that rule
153
+ * would drift from the one the enforcement path uses, and this endpoint's
154
+ * whole value is telling a customer what the enforcement path will do.
155
+ */
156
+ export declare function usageBand(used: number, included: number, remaining: number, overageRateUsd: number | null): {
157
+ used: number;
158
+ included: number;
159
+ remaining: number;
160
+ metered: boolean;
161
+ };
162
+ /** Whether `hostId` is one of the authenticated organization's own sites. */
163
+ export declare function orgOwnsHost(ctx: ApiV1Context, hostId: string): boolean;