@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.
- package/package.json +8 -8
- package/src/lib/server/api-http.js +5 -1
- package/src/lib/server/api-http.js.map +1 -1
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +163 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +143 -0
- package/src/lib/server/api-v1-resources.js +125 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
- package/src/lib/server/campaign-conversion-attribution.js +3 -3
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/card-payment-velocity.d.ts +9 -0
- package/src/lib/server/card-payment-velocity.js +28 -2
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +1 -1
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +43 -18
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +6 -3
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +2 -2
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +37 -2
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +32 -4
- package/src/lib/server/host-visitor-records.js +73 -14
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +27 -0
- package/src/lib/server/hosted-page-review.js +149 -12
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
- package/src/lib/server/media-cdn-rate-limit.js +6 -3
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +10 -2
- package/src/lib/server/member-email-aliases.js +26 -2
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +22 -2
- package/src/lib/server/notifications.js +40 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -1
- package/src/lib/server/operator-alerts.js +5 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/org-containers.d.ts +66 -0
- package/src/lib/server/org-containers.js +95 -0
- package/src/lib/server/org-containers.js.map +1 -0
- package/src/lib/server/org-member-notice.js +3 -2
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +1 -1
- package/src/lib/server/payment-risk-record.js +3 -1
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +1 -1
- package/src/lib/server/platform-marketing-consent.js +1 -1
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +32 -1
- package/src/lib/server/risk-notice.js +90 -28
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +17 -0
- package/src/lib/server/sending-domains.js +28 -4
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +7 -2
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/staff-alert-email.js +4 -2
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
- package/src/lib/server/topic-subscriptions.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +87 -20
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +5 -8
- package/src/tenancy.js +12 -13
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-email-engagement.d.ts +0 -32
- package/src/lib/server/contact-email-engagement.js +0 -145
- package/src/lib/server/contact-email-engagement.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/crm-booking-activity.d.ts +0 -86
- package/src/lib/server/crm-booking-activity.js +0 -257
- package/src/lib/server/crm-booking-activity.js.map +0 -1
- package/src/lib/server/crm-inbound-email.d.ts +0 -145
- package/src/lib/server/crm-inbound-email.js +0 -297
- package/src/lib/server/crm-inbound-email.js.map +0 -1
- package/src/lib/server/email-topic-confirmation.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
- /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.
|
|
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.
|
|
29
|
-
"@aglyn/shared-util-email": "1.0.0-beta.
|
|
30
|
-
"@aglyn/shared-util-fbserver": "1.0.0-beta.
|
|
31
|
-
"@aglyn/shared-util-first-touch": "1.0.0-beta.
|
|
32
|
-
"@aglyn/shared-util-http": "1.0.0-beta.
|
|
33
|
-
"@aglyn/shared-util-tools": "1.0.0-beta.
|
|
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
|
-
|
|
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;
|