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

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 (114) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/host-visitor-records.d.ts +12 -4
  19. package/src/lib/server/host-visitor-records.js +20 -10
  20. package/src/lib/server/host-visitor-records.js.map +1 -1
  21. package/src/lib/server/hosted-page-review.d.ts +7 -0
  22. package/src/lib/server/hosted-page-review.js +144 -20
  23. package/src/lib/server/hosted-page-review.js.map +1 -1
  24. package/src/lib/server/leaving-notice.d.ts +98 -0
  25. package/src/lib/server/leaving-notice.js +250 -0
  26. package/src/lib/server/leaving-notice.js.map +1 -0
  27. package/src/lib/server/link-reputation-review.d.ts +58 -0
  28. package/src/lib/server/link-reputation-review.js +185 -0
  29. package/src/lib/server/link-reputation-review.js.map +1 -0
  30. package/src/lib/server/live-page-drops.d.ts +67 -0
  31. package/src/lib/server/live-page-drops.js +57 -0
  32. package/src/lib/server/live-page-drops.js.map +1 -0
  33. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  34. package/src/lib/server/media-bandwidth-cap.js +180 -0
  35. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  36. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  37. package/src/lib/server/media-cdn-icon.js +140 -0
  38. package/src/lib/server/media-cdn-icon.js.map +1 -0
  39. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  40. package/src/lib/server/media-delivery-regeneration.js +216 -0
  41. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  42. package/src/lib/server/media-delivery.d.ts +2 -0
  43. package/src/lib/server/media-delivery.js +2 -1
  44. package/src/lib/server/media-delivery.js.map +1 -1
  45. package/src/lib/server/media-serve-count.d.ts +50 -0
  46. package/src/lib/server/media-serve-count.js +62 -0
  47. package/src/lib/server/media-serve-count.js.map +1 -0
  48. package/src/lib/server/media-signing.d.ts +13 -2
  49. package/src/lib/server/media-signing.js +12 -8
  50. package/src/lib/server/media-signing.js.map +1 -1
  51. package/src/lib/server/media-tombstone.js +7 -5
  52. package/src/lib/server/media-tombstone.js.map +1 -1
  53. package/src/lib/server/media-variants.d.ts +151 -20
  54. package/src/lib/server/media-variants.js +204 -27
  55. package/src/lib/server/media-variants.js.map +1 -1
  56. package/src/lib/server/org-age.d.ts +15 -6
  57. package/src/lib/server/org-age.js +20 -8
  58. package/src/lib/server/org-age.js.map +1 -1
  59. package/src/lib/server/organizations.d.ts +102 -2
  60. package/src/lib/server/organizations.js +388 -58
  61. package/src/lib/server/organizations.js.map +1 -1
  62. package/src/lib/server/outbound-send-review.js +24 -6
  63. package/src/lib/server/outbound-send-review.js.map +1 -1
  64. package/src/lib/server/page-security-hold.d.ts +93 -0
  65. package/src/lib/server/page-security-hold.js +180 -0
  66. package/src/lib/server/page-security-hold.js.map +1 -0
  67. package/src/lib/server/paged-sweeps.d.ts +51 -0
  68. package/src/lib/server/paged-sweeps.js +79 -0
  69. package/src/lib/server/paged-sweeps.js.map +1 -0
  70. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  71. package/src/lib/server/platform-marketing-consent.js +27 -11
  72. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  73. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  74. package/src/lib/server/serve-media-cdn.js +424 -70
  75. package/src/lib/server/serve-media-cdn.js.map +1 -1
  76. package/src/lib/server/site-icon-render.d.ts +68 -0
  77. package/src/lib/server/site-icon-render.js +157 -0
  78. package/src/lib/server/site-icon-render.js.map +1 -0
  79. package/src/lib/server/staff-seat.d.ts +50 -0
  80. package/src/lib/server/staff-seat.js +118 -0
  81. package/src/lib/server/staff-seat.js.map +1 -0
  82. package/src/lib/server/transfer-export.d.ts +46 -0
  83. package/src/lib/server/transfer-export.js +221 -0
  84. package/src/lib/server/transfer-export.js.map +1 -0
  85. package/src/lib/server/transfer-jobs.d.ts +385 -0
  86. package/src/lib/server/transfer-jobs.js +2368 -0
  87. package/src/lib/server/transfer-jobs.js.map +1 -0
  88. package/src/lib/server/transfer-packages.d.ts +127 -0
  89. package/src/lib/server/transfer-packages.js +1036 -0
  90. package/src/lib/server/transfer-packages.js.map +1 -0
  91. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  92. package/src/lib/server/upgrade-proposal.js +312 -0
  93. package/src/lib/server/upgrade-proposal.js.map +1 -0
  94. package/src/lib/server/upsert-contact.d.ts +17 -8
  95. package/src/lib/server/upsert-contact.js +82 -16
  96. package/src/lib/server/upsert-contact.js.map +1 -1
  97. package/src/lib/server/web-risk.d.ts +139 -0
  98. package/src/lib/server/web-risk.js +521 -0
  99. package/src/lib/server/web-risk.js.map +1 -0
  100. package/src/tenancy.d.ts +3 -1
  101. package/src/tenancy.js +5 -1
  102. package/src/tenancy.js.map +1 -1
  103. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  104. package/src/lib/server/campaign-attribution-store.js +0 -95
  105. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  106. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  107. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  108. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  109. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  110. package/src/lib/server/dataset-live-pages.js +0 -421
  111. package/src/lib/server/dataset-live-pages.js.map +0 -1
  112. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  113. package/src/lib/server/email-revenue-attribution.js +0 -321
  114. package/src/lib/server/email-revenue-attribution.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-data-admin",
3
- "version": "1.0.0-beta.220",
3
+ "version": "1.0.0-beta.222",
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.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",
28
+ "@aglyn/aglyn": "1.0.0-beta.222",
29
+ "@aglyn/shared-util-email": "1.0.0-beta.222",
30
+ "@aglyn/shared-util-fbserver": "1.0.0-beta.222",
31
+ "@aglyn/shared-util-first-touch": "1.0.0-beta.222",
32
+ "@aglyn/shared-util-http": "1.0.0-beta.222",
33
+ "@aglyn/shared-util-tools": "1.0.0-beta.222",
34
34
  "@msgpack/msgpack": "^3.1.3",
35
35
  "@swc/helpers": "0.5.23",
36
36
  "sharp": "^0.35.5",
@@ -117,9 +117,9 @@ import { firebaseAdmin } from "./firebase-admin.js";
117
117
  // Fulfilment ONLY. `cancelled` and `refunded` are refused by name with a
118
118
  // 400: those move stock and money through their own transactions, and this
119
119
  // scope must never become the door around them. The transition rule itself
120
- // is not re-implemented here at all — the write goes through the commerce
121
- // plugin's own transaction via the order-fulfilment capability registry,
122
- // so the API can never write a status the console forbids.
120
+ // is not re-implemented here at all — the commerce plugin serves the
121
+ // resource and writes through the same transaction its console route
122
+ // takes, so the API can never write a status the console forbids.
123
123
  'orders:write',
124
124
  'products:read',
125
125
  'media:read',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-keys.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 * Customer API keys (AGL-616). Keys authenticate the public REST API\n * (`/api/v1/**`). Only a SHA-256 hash of the key is ever stored — the raw\n * token is shown to the user exactly once, at creation. The Firestore doc id\n * IS the hash, so verification is an O(1) `get()` and the hash never leaves\n * the server (listings return metadata only). The `apiKeys` collection is\n * deny-all in the security rules; all access is Admin-SDK only.\n *\n * The token/hash/format/scope helpers are pure and unit-tested; the storage\n * functions wrap the Admin SDK.\n */\nimport { createHash, randomBytes } from 'node:crypto'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * Scopes a key can be granted, as `resource:action`. Every entry must be\n * enforced by a real endpoint — a mintable scope that grants nothing reads\n * as a broken permission (AGL-899 removed `contacts:write` on those grounds,\n * and AGL-2276 re-added it in the same change that shipped the writes, which\n * is the condition AGL-899 set).\n */\nexport const API_SCOPES = [\n 'datasets:read',\n 'datasets:write',\n 'contacts:read',\n // AGL-2276. Contacts used to arrive only through the four site capture\n // points, so an integration that OWNS the customer list upstream — a CRM,\n // a mailing tool, a migration off another platform — could read the\n // audience and never add to it. The write is metered against the same\n // audience bands capture is (AGL-890): `checkContactQuota` on the create,\n // and the monthly rollup counts `orgs/{orgId}/contacts` whatever wrote\n // them, so an API-created contact bills exactly like a form-captured one.\n // The scope covers the create, the tag/notes edit the console's Contacts\n // page already makes, and the delete — never `email`, which is the dedupe\n // key, and never `sources`/`interactions`, which are provenance.\n 'contacts:write',\n 'sites:read',\n // AGL-2462. An integration could write a dataset record over /v1 and had no\n // way to make a live page show it: `getDatasets` caches for 60s behind\n // `tenant-data:{hostId}` and the catch-all page is `revalidate = 60`, and\n // time-based ISR is stale-while-revalidate — so the visitor AFTER the window\n // can still get the old copy. A cache expiring is not a publish.\n //\n // Its OWN scope rather than a `sites:write` covering two very different\n // powers. Publishing is not a site edit: it changes no stored field, and it\n // is the one /v1 call whose cost lands on a different service — up to 250\n // dropped pages, each re-rendering at roughly 40 Firestore reads. A key that\n // syncs a catalogue should not thereby be able to spend that, and an\n // integration that only needs to flush its own writes should not have to\n // hold a scope that could rename the site. Bounded by a per-HOST hourly\n // budget on top of the per-key request limit, because one call and 250\n // dropped pages are not the same unit of cost.\n 'sites:publish',\n // AGL-2465. Everything downstream of a site was automatable and creating\n // the site was not: `handleSites` 405'd every non-GET, so an agency\n // onboarding a client could push its datasets, contacts and media and still\n // had to open a browser to make the site those things go in.\n //\n // CREATE ONLY — no rename, no delete, and the omissions are deliberate\n // rather than unfinished. `hosts/rename` authorizes off the host projection\n // alone — the member's own entry in `memberRoles`, which must equal\n // `admin` — and an API key is an ORG credential with no uid, so that gate\n // has no translation at all; giving it one is a permission decision, not a\n // port. `hosts/delete` runs\n // `recursiveDelete` over the whole site plus a storage prefix wipe with no\n // hold and no soft-delete, and the type-the-name confirmation that guards it\n // is console UI only — a one-field irreversible delete is the last thing to\n // put behind a bearer token, and whether it should exist at all is a product\n // decision that has not been made.\n //\n // Replay-safe, which is what actually blocked this: site creation is the\n // most expensive object here to duplicate — it takes a `hostLimit` slot,\n // writes `hostIndex` and runs `syncOrgAuthProjections` across every member —\n // and `/v1` publishes idempotency as a contract. A POST that succeeded\n // server-side but lost its response now replays the original site rather\n // than minting a second one.\n 'sites:write',\n 'forms:read',\n // AGL-2127. A lead sync could READ a submission and never record that it\n // had — so it re-pushed the same lead on every poll, or kept its own\n // high-water mark against a list ordered by document id rather than by\n // time. `read` is the flag the console already toggles on the same\n // document, so the scope grants exactly the state the integration needs\n // (plus the delete that lets it purge after export), and nothing that\n // rewrites what a visitor actually typed.\n 'forms:write',\n // Commerce and media reads (AGL-1928). Each is separate from `sites:read`\n // for the same reason `forms:read` is: a key handed to a fulfilment or\n // accounting integration needs the order history and nothing else, and a\n // key that syncs a catalogue into a PIM should not thereby be able to read\n // every form submission a site has ever taken. Read-only on purpose —\n // writes here move money or stock and want their own decision, in the same\n // change that ships them (the AGL-899 rule for `contacts:write`).\n 'orders:read',\n // AGL-2461. The read-only note above says commerce writes wait for \"their\n // own decision, in the same change that ships them\" because they \"move\n // money or stock\". Recording a shipment does NEITHER — `fulfill-order.ts`'s\n // own header says so: \"a forward status flip plus a timeline entry, no\n // stock moved, no money moved\" — so it is precisely the write that rule\n // admits, and it is the day-one commerce automation: a 3PL, ShipStation, or\n // a warehouse's own system marking an order shipped with a tracking number.\n // A public API that can take an order and never ship it is a fulfilment\n // dead end.\n //\n // Fulfilment ONLY. `cancelled` and `refunded` are refused by name with a\n // 400: those move stock and money through their own transactions, and this\n // scope must never become the door around them. The transition rule itself\n // is not re-implemented here at all — the write goes through the commerce\n // plugin's own transaction via the order-fulfilment capability registry,\n // so the API can never write a status the console forbids.\n 'orders:write',\n 'products:read',\n 'media:read',\n // AGL-2463. `media:read` was the only media scope, so an agency onboarding a\n // client site could automate everything about that site except putting its\n // images in it — an integration could LIST the library and never add to it,\n // which stops the asset pipeline at the one step that matters.\n //\n // Create only: no replace, no delete, no edit of an existing file's metadata.\n // A key handed to a migration tool should be able to fill a library and not\n // to empty one, and the destructive half wants its own decision in the change\n // that ships it — the same rule that kept this scope out until now.\n //\n // Charged, not free: the create goes through `mediaStorageGate`, the same\n // helper the console's four ingress routes use, and consumes the existing\n // per-GB storage dimension rather than inventing a per-upload one.\n 'media:write',\n // AGL-2606. The CRM v2 collections (AGL-2595) — companies, pipelines,\n // deals, tasks and activities — sit beside `contacts` and were reachable\n // only from the console, so the integration that owns the sales process\n // upstream (a CRM sync, a dialer logging calls, a billing system closing\n // deals) could read the people and nothing they were doing with them.\n //\n // ONE pair for the five resources rather than five, because a key that\n // needs any of them needs the set: a deal names a company and a contact, a\n // task names a deal, an activity names all three, and a key that could\n // read deals but not the pipelines whose stages they cite would hand back\n // stage ids it cannot explain. The contact's own CRM profile — phone, job\n // title, company, owner, lifecycle stage — stays under `contacts:*`, since\n // it is a field of the contact and reads with the contact.\n //\n // Reads are org-wide, as every read on this surface is: a key is an\n // organization credential. Writes name the site they belong to and are\n // stamped in that site's scope (`crmCreateStamp`), so an API-created deal\n // lands exactly where a console-created one would, and never wider.\n 'crm:read',\n 'crm:write',\n] as const\n\nexport type ApiScope = (typeof API_SCOPES)[number]\n\n/** Live-key token prefix. A `_test_` variant can follow the same shape later. */\nexport const API_KEY_TOKEN_PREFIX = 'aglyn_sk_'\n\n/** Top-level collection; doc id = the token hash. */\nexport const API_KEYS_COLLECTION = 'apiKeys'\n\n/** How much of the raw token is retained (plaintext) for display/identification. */\nconst DISPLAY_BODY_CHARS = 6\n\n/** Stored document. `createdAt`/`lastUsedAt`/etc. are Firestore Timestamps. */\nexport interface ApiKeyDocument {\n /** Public, stable id shown in the UI and used for revoke (never the hash). */\n keyId: string\n orgId: string\n name: string\n /** e.g. `aglyn_sk_ab12cd…` — enough to recognize a key, not to use it. */\n keyPrefix: string\n scopes: ApiScope[]\n createdBy: string\n createdAt: FirebaseFirestore.Timestamp\n lastUsedAt: FirebaseFirestore.Timestamp | null\n revokedAt: FirebaseFirestore.Timestamp | null\n expiresAt: FirebaseFirestore.Timestamp | null\n}\n\n/** Metadata safe to return to a client (no hash, no raw token). */\nexport interface PublicApiKey {\n keyId: string\n name: string\n keyPrefix: string\n scopes: ApiScope[]\n createdAt: string | null\n lastUsedAt: string | null\n revokedAt: string | null\n expiresAt: string | null\n}\n\n/** The resolved principal after a successful key verification. */\nexport interface VerifiedApiKey {\n orgId: string\n keyId: string\n scopes: ApiScope[]\n /**\n * The name the organization gave the key — what a feed attributes the\n * key's writes to (AGL-2632). Every minted key carries one; empty only\n * for a document written before the field was required.\n */\n name: string\n}\n\n// ── Pure helpers (unit-tested; no I/O) ──────────────────────────────────────\n\n/** True if `value` is one of the known scopes. */\nexport function isApiScope(value: string): value is ApiScope {\n return (API_SCOPES as readonly string[]).includes(value)\n}\n\n/** Keep only valid, de-duplicated scopes, preserving `API_SCOPES` order. */\nexport function normalizeScopes(scopes: readonly string[]): ApiScope[] {\n const set = new Set(scopes.filter(isApiScope) as ApiScope[])\n return API_SCOPES.filter((scope) => set.has(scope))\n}\n\n/** SHA-256 hex of a raw token — the Firestore doc id and lookup key. */\nexport function hashApiKey(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/** Structural check before hashing/looking up — cheap reject of junk input. */\nexport function isValidApiKeyFormat(token: string): boolean {\n return (\n typeof token === 'string' &&\n token.startsWith(API_KEY_TOKEN_PREFIX) &&\n /^[A-Za-z0-9_-]{24,}$/.test(token.slice(API_KEY_TOKEN_PREFIX.length))\n )\n}\n\n/**\n * Generate a fresh token plus the derived hash and display prefix. The raw\n * `token` is the only time the secret exists in plaintext — persist the hash,\n * hand the token to the user once.\n */\nexport function generateApiKeyToken(): {\n token: string\n hash: string\n keyPrefix: string\n} {\n const body = randomBytes(24).toString('base64url')\n const token = `${API_KEY_TOKEN_PREFIX}${body}`\n return {\n token,\n hash: hashApiKey(token),\n keyPrefix: `${API_KEY_TOKEN_PREFIX}${body.slice(0, DISPLAY_BODY_CHARS)}…`,\n }\n}\n\n/** True when the granted scopes include the one an operation requires. */\nexport function hasScope(\n granted: readonly ApiScope[],\n required: ApiScope,\n): boolean {\n return granted.includes(required)\n}\n\n/** A random public key id (`key_…`), distinct from the secret and its hash. */\nexport function generateKeyId(): string {\n return `key_${randomBytes(9).toString('base64url')}`\n}\n\n// ── Storage (Admin SDK) ─────────────────────────────────────────────────────\n\nfunction collection() {\n return firebaseAdmin.app().firestore().collection(API_KEYS_COLLECTION)\n}\n\nconst tsToIso = (\n value: FirebaseFirestore.Timestamp | null | undefined,\n): string | null => (value ? value.toDate().toISOString() : null)\n\n/** Public projection of a stored key — never includes the hash/doc id. */\nexport function toPublicApiKey(doc: ApiKeyDocument): PublicApiKey {\n return {\n keyId: doc.keyId,\n name: doc.name,\n keyPrefix: doc.keyPrefix,\n scopes: doc.scopes ?? [],\n createdAt: tsToIso(doc.createdAt),\n lastUsedAt: tsToIso(doc.lastUsedAt),\n revokedAt: tsToIso(doc.revokedAt),\n expiresAt: tsToIso(doc.expiresAt),\n }\n}\n\nexport interface MintApiKeyInput {\n orgId: string\n name: string\n scopes: readonly string[]\n createdBy: string\n /** Optional absolute expiry, epoch ms. */\n expiresAtMs?: number | null\n}\n\n/**\n * Create a key for an org. Returns the raw `token` (show once) and the public\n * metadata. Invalid scopes are dropped; at least one valid scope is required.\n */\nexport async function mintApiKey(\n input: MintApiKeyInput,\n): Promise<{ token: string; key: PublicApiKey }> {\n const scopes = normalizeScopes(input.scopes)\n if (scopes.length === 0) {\n throw new Error('An API key needs at least one valid scope')\n }\n const { Timestamp, FieldValue } = firebaseAdmin.firestore\n const { token, hash, keyPrefix } = generateApiKeyToken()\n const keyId = generateKeyId()\n const expiresAt =\n input.expiresAtMs != null ? Timestamp.fromMillis(input.expiresAtMs) : null\n\n const doc = {\n keyId,\n orgId: input.orgId,\n name: input.name.slice(0, 120),\n keyPrefix,\n scopes,\n createdBy: input.createdBy,\n createdAt: FieldValue.serverTimestamp(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt,\n }\n // `create` (not set) so a hash collision can never overwrite another key.\n await collection().doc(hash).create(doc)\n\n return {\n token,\n key: {\n keyId,\n name: doc.name,\n keyPrefix,\n scopes,\n createdAt: new Date().toISOString(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt: expiresAt ? expiresAt.toDate().toISOString() : null,\n },\n }\n}\n\n/**\n * Resolve a raw token to its org/scopes, or `null` if it is malformed,\n * unknown, revoked, or expired. Refreshes `lastUsedAt` at most once a minute\n * (fire-and-forget) to avoid a write on every request.\n */\nexport async function verifyApiKey(\n token: string,\n): Promise<VerifiedApiKey | null> {\n if (!isValidApiKeyFormat(token)) return null\n const snap = await collection().doc(hashApiKey(token)).get()\n if (!snap.exists) return null\n const data = snap.data() as ApiKeyDocument\n\n if (data.revokedAt) return null\n const now = Date.now()\n if (data.expiresAt && data.expiresAt.toMillis() <= now) return null\n\n const lastUsedMs = data.lastUsedAt ? data.lastUsedAt.toMillis() : 0\n if (now - lastUsedMs > 60_000) {\n snap.ref\n .update({ lastUsedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp() })\n .catch(() => undefined)\n }\n\n return {\n orgId: data.orgId,\n keyId: data.keyId,\n scopes: data.scopes ?? [],\n name: String(data.name ?? ''),\n }\n}\n\n/** All keys for an org, newest first (metadata only). */\nexport async function listApiKeys(orgId: string): Promise<PublicApiKey[]> {\n const snap = await collection().where('orgId', '==', orgId).get()\n return snap.docs\n .map((doc) => toPublicApiKey(doc.data() as ApiKeyDocument))\n .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))\n}\n\n/** Revoke a key by its public id. Returns false if not found/already revoked. */\nexport async function revokeApiKey(\n orgId: string,\n keyId: string,\n): Promise<boolean> {\n const snap = await collection().where('orgId', '==', orgId).get()\n const match = snap.docs.find(\n (doc) => (doc.data() as ApiKeyDocument).keyId === keyId,\n )\n if (!match || (match.data() as ApiKeyDocument).revokedAt) return false\n await match.ref.update({\n revokedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return true\n}\n"],"names":["createHash","randomBytes","firebaseAdmin","API_SCOPES","API_KEY_TOKEN_PREFIX","API_KEYS_COLLECTION","DISPLAY_BODY_CHARS","isApiScope","value","includes","normalizeScopes","scopes","set","Set","filter","scope","has","hashApiKey","token","update","digest","isValidApiKeyFormat","startsWith","test","slice","length","generateApiKeyToken","body","toString","hash","keyPrefix","hasScope","granted","required","generateKeyId","collection","app","firestore","tsToIso","toDate","toISOString","toPublicApiKey","doc","keyId","name","createdAt","lastUsedAt","revokedAt","expiresAt","mintApiKey","input","Error","Timestamp","FieldValue","expiresAtMs","fromMillis","orgId","createdBy","serverTimestamp","create","key","Date","verifyApiKey","data","snap","get","exists","now","toMillis","lastUsedMs","ref","catch","undefined","String","listApiKeys","where","docs","map","sort","a","b","localeCompare","revokeApiKey","match","find"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-keys.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 * Customer API keys (AGL-616). Keys authenticate the public REST API\n * (`/api/v1/**`). Only a SHA-256 hash of the key is ever stored — the raw\n * token is shown to the user exactly once, at creation. The Firestore doc id\n * IS the hash, so verification is an O(1) `get()` and the hash never leaves\n * the server (listings return metadata only). The `apiKeys` collection is\n * deny-all in the security rules; all access is Admin-SDK only.\n *\n * The token/hash/format/scope helpers are pure and unit-tested; the storage\n * functions wrap the Admin SDK.\n */\nimport { createHash, randomBytes } from 'node:crypto'\nimport { firebaseAdmin } from './firebase-admin'\n\n/**\n * Scopes a key can be granted, as `resource:action`. Every entry must be\n * enforced by a real endpoint — a mintable scope that grants nothing reads\n * as a broken permission (AGL-899 removed `contacts:write` on those grounds,\n * and AGL-2276 re-added it in the same change that shipped the writes, which\n * is the condition AGL-899 set).\n */\nexport const API_SCOPES = [\n 'datasets:read',\n 'datasets:write',\n 'contacts:read',\n // AGL-2276. Contacts used to arrive only through the four site capture\n // points, so an integration that OWNS the customer list upstream — a CRM,\n // a mailing tool, a migration off another platform — could read the\n // audience and never add to it. The write is metered against the same\n // audience bands capture is (AGL-890): `checkContactQuota` on the create,\n // and the monthly rollup counts `orgs/{orgId}/contacts` whatever wrote\n // them, so an API-created contact bills exactly like a form-captured one.\n // The scope covers the create, the tag/notes edit the console's Contacts\n // page already makes, and the delete — never `email`, which is the dedupe\n // key, and never `sources`/`interactions`, which are provenance.\n 'contacts:write',\n 'sites:read',\n // AGL-2462. An integration could write a dataset record over /v1 and had no\n // way to make a live page show it: `getDatasets` caches for 60s behind\n // `tenant-data:{hostId}` and the catch-all page is `revalidate = 60`, and\n // time-based ISR is stale-while-revalidate — so the visitor AFTER the window\n // can still get the old copy. A cache expiring is not a publish.\n //\n // Its OWN scope rather than a `sites:write` covering two very different\n // powers. Publishing is not a site edit: it changes no stored field, and it\n // is the one /v1 call whose cost lands on a different service — up to 250\n // dropped pages, each re-rendering at roughly 40 Firestore reads. A key that\n // syncs a catalogue should not thereby be able to spend that, and an\n // integration that only needs to flush its own writes should not have to\n // hold a scope that could rename the site. Bounded by a per-HOST hourly\n // budget on top of the per-key request limit, because one call and 250\n // dropped pages are not the same unit of cost.\n 'sites:publish',\n // AGL-2465. Everything downstream of a site was automatable and creating\n // the site was not: `handleSites` 405'd every non-GET, so an agency\n // onboarding a client could push its datasets, contacts and media and still\n // had to open a browser to make the site those things go in.\n //\n // CREATE ONLY — no rename, no delete, and the omissions are deliberate\n // rather than unfinished. `hosts/rename` authorizes off the host projection\n // alone — the member's own entry in `memberRoles`, which must equal\n // `admin` — and an API key is an ORG credential with no uid, so that gate\n // has no translation at all; giving it one is a permission decision, not a\n // port. `hosts/delete` runs\n // `recursiveDelete` over the whole site plus a storage prefix wipe with no\n // hold and no soft-delete, and the type-the-name confirmation that guards it\n // is console UI only — a one-field irreversible delete is the last thing to\n // put behind a bearer token, and whether it should exist at all is a product\n // decision that has not been made.\n //\n // Replay-safe, which is what actually blocked this: site creation is the\n // most expensive object here to duplicate — it takes a `hostLimit` slot,\n // writes `hostIndex` and runs `syncOrgAuthProjections` across every member —\n // and `/v1` publishes idempotency as a contract. A POST that succeeded\n // server-side but lost its response now replays the original site rather\n // than minting a second one.\n 'sites:write',\n 'forms:read',\n // AGL-2127. A lead sync could READ a submission and never record that it\n // had — so it re-pushed the same lead on every poll, or kept its own\n // high-water mark against a list ordered by document id rather than by\n // time. `read` is the flag the console already toggles on the same\n // document, so the scope grants exactly the state the integration needs\n // (plus the delete that lets it purge after export), and nothing that\n // rewrites what a visitor actually typed.\n 'forms:write',\n // Commerce and media reads (AGL-1928). Each is separate from `sites:read`\n // for the same reason `forms:read` is: a key handed to a fulfilment or\n // accounting integration needs the order history and nothing else, and a\n // key that syncs a catalogue into a PIM should not thereby be able to read\n // every form submission a site has ever taken. Read-only on purpose —\n // writes here move money or stock and want their own decision, in the same\n // change that ships them (the AGL-899 rule for `contacts:write`).\n 'orders:read',\n // AGL-2461. The read-only note above says commerce writes wait for \"their\n // own decision, in the same change that ships them\" because they \"move\n // money or stock\". Recording a shipment does NEITHER — `fulfill-order.ts`'s\n // own header says so: \"a forward status flip plus a timeline entry, no\n // stock moved, no money moved\" — so it is precisely the write that rule\n // admits, and it is the day-one commerce automation: a 3PL, ShipStation, or\n // a warehouse's own system marking an order shipped with a tracking number.\n // A public API that can take an order and never ship it is a fulfilment\n // dead end.\n //\n // Fulfilment ONLY. `cancelled` and `refunded` are refused by name with a\n // 400: those move stock and money through their own transactions, and this\n // scope must never become the door around them. The transition rule itself\n // is not re-implemented here at all — the commerce plugin serves the\n // resource and writes through the same transaction its console route\n // takes, so the API can never write a status the console forbids.\n 'orders:write',\n 'products:read',\n 'media:read',\n // AGL-2463. `media:read` was the only media scope, so an agency onboarding a\n // client site could automate everything about that site except putting its\n // images in it — an integration could LIST the library and never add to it,\n // which stops the asset pipeline at the one step that matters.\n //\n // Create only: no replace, no delete, no edit of an existing file's metadata.\n // A key handed to a migration tool should be able to fill a library and not\n // to empty one, and the destructive half wants its own decision in the change\n // that ships it — the same rule that kept this scope out until now.\n //\n // Charged, not free: the create goes through `mediaStorageGate`, the same\n // helper the console's four ingress routes use, and consumes the existing\n // per-GB storage dimension rather than inventing a per-upload one.\n 'media:write',\n // AGL-2606. The CRM v2 collections (AGL-2595) — companies, pipelines,\n // deals, tasks and activities — sit beside `contacts` and were reachable\n // only from the console, so the integration that owns the sales process\n // upstream (a CRM sync, a dialer logging calls, a billing system closing\n // deals) could read the people and nothing they were doing with them.\n //\n // ONE pair for the five resources rather than five, because a key that\n // needs any of them needs the set: a deal names a company and a contact, a\n // task names a deal, an activity names all three, and a key that could\n // read deals but not the pipelines whose stages they cite would hand back\n // stage ids it cannot explain. The contact's own CRM profile — phone, job\n // title, company, owner, lifecycle stage — stays under `contacts:*`, since\n // it is a field of the contact and reads with the contact.\n //\n // Reads are org-wide, as every read on this surface is: a key is an\n // organization credential. Writes name the site they belong to and are\n // stamped in that site's scope (`crmCreateStamp`), so an API-created deal\n // lands exactly where a console-created one would, and never wider.\n 'crm:read',\n 'crm:write',\n] as const\n\nexport type ApiScope = (typeof API_SCOPES)[number]\n\n/** Live-key token prefix. A `_test_` variant can follow the same shape later. */\nexport const API_KEY_TOKEN_PREFIX = 'aglyn_sk_'\n\n/** Top-level collection; doc id = the token hash. */\nexport const API_KEYS_COLLECTION = 'apiKeys'\n\n/** How much of the raw token is retained (plaintext) for display/identification. */\nconst DISPLAY_BODY_CHARS = 6\n\n/** Stored document. `createdAt`/`lastUsedAt`/etc. are Firestore Timestamps. */\nexport interface ApiKeyDocument {\n /** Public, stable id shown in the UI and used for revoke (never the hash). */\n keyId: string\n orgId: string\n name: string\n /** e.g. `aglyn_sk_ab12cd…` — enough to recognize a key, not to use it. */\n keyPrefix: string\n scopes: ApiScope[]\n createdBy: string\n createdAt: FirebaseFirestore.Timestamp\n lastUsedAt: FirebaseFirestore.Timestamp | null\n revokedAt: FirebaseFirestore.Timestamp | null\n expiresAt: FirebaseFirestore.Timestamp | null\n}\n\n/** Metadata safe to return to a client (no hash, no raw token). */\nexport interface PublicApiKey {\n keyId: string\n name: string\n keyPrefix: string\n scopes: ApiScope[]\n createdAt: string | null\n lastUsedAt: string | null\n revokedAt: string | null\n expiresAt: string | null\n}\n\n/** The resolved principal after a successful key verification. */\nexport interface VerifiedApiKey {\n orgId: string\n keyId: string\n scopes: ApiScope[]\n /**\n * The name the organization gave the key — what a feed attributes the\n * key's writes to (AGL-2632). Every minted key carries one; empty only\n * for a document written before the field was required.\n */\n name: string\n}\n\n// ── Pure helpers (unit-tested; no I/O) ──────────────────────────────────────\n\n/** True if `value` is one of the known scopes. */\nexport function isApiScope(value: string): value is ApiScope {\n return (API_SCOPES as readonly string[]).includes(value)\n}\n\n/** Keep only valid, de-duplicated scopes, preserving `API_SCOPES` order. */\nexport function normalizeScopes(scopes: readonly string[]): ApiScope[] {\n const set = new Set(scopes.filter(isApiScope) as ApiScope[])\n return API_SCOPES.filter((scope) => set.has(scope))\n}\n\n/** SHA-256 hex of a raw token — the Firestore doc id and lookup key. */\nexport function hashApiKey(token: string): string {\n return createHash('sha256').update(token, 'utf8').digest('hex')\n}\n\n/** Structural check before hashing/looking up — cheap reject of junk input. */\nexport function isValidApiKeyFormat(token: string): boolean {\n return (\n typeof token === 'string' &&\n token.startsWith(API_KEY_TOKEN_PREFIX) &&\n /^[A-Za-z0-9_-]{24,}$/.test(token.slice(API_KEY_TOKEN_PREFIX.length))\n )\n}\n\n/**\n * Generate a fresh token plus the derived hash and display prefix. The raw\n * `token` is the only time the secret exists in plaintext — persist the hash,\n * hand the token to the user once.\n */\nexport function generateApiKeyToken(): {\n token: string\n hash: string\n keyPrefix: string\n} {\n const body = randomBytes(24).toString('base64url')\n const token = `${API_KEY_TOKEN_PREFIX}${body}`\n return {\n token,\n hash: hashApiKey(token),\n keyPrefix: `${API_KEY_TOKEN_PREFIX}${body.slice(0, DISPLAY_BODY_CHARS)}…`,\n }\n}\n\n/** True when the granted scopes include the one an operation requires. */\nexport function hasScope(\n granted: readonly ApiScope[],\n required: ApiScope,\n): boolean {\n return granted.includes(required)\n}\n\n/** A random public key id (`key_…`), distinct from the secret and its hash. */\nexport function generateKeyId(): string {\n return `key_${randomBytes(9).toString('base64url')}`\n}\n\n// ── Storage (Admin SDK) ─────────────────────────────────────────────────────\n\nfunction collection() {\n return firebaseAdmin.app().firestore().collection(API_KEYS_COLLECTION)\n}\n\nconst tsToIso = (\n value: FirebaseFirestore.Timestamp | null | undefined,\n): string | null => (value ? value.toDate().toISOString() : null)\n\n/** Public projection of a stored key — never includes the hash/doc id. */\nexport function toPublicApiKey(doc: ApiKeyDocument): PublicApiKey {\n return {\n keyId: doc.keyId,\n name: doc.name,\n keyPrefix: doc.keyPrefix,\n scopes: doc.scopes ?? [],\n createdAt: tsToIso(doc.createdAt),\n lastUsedAt: tsToIso(doc.lastUsedAt),\n revokedAt: tsToIso(doc.revokedAt),\n expiresAt: tsToIso(doc.expiresAt),\n }\n}\n\nexport interface MintApiKeyInput {\n orgId: string\n name: string\n scopes: readonly string[]\n createdBy: string\n /** Optional absolute expiry, epoch ms. */\n expiresAtMs?: number | null\n}\n\n/**\n * Create a key for an org. Returns the raw `token` (show once) and the public\n * metadata. Invalid scopes are dropped; at least one valid scope is required.\n */\nexport async function mintApiKey(\n input: MintApiKeyInput,\n): Promise<{ token: string; key: PublicApiKey }> {\n const scopes = normalizeScopes(input.scopes)\n if (scopes.length === 0) {\n throw new Error('An API key needs at least one valid scope')\n }\n const { Timestamp, FieldValue } = firebaseAdmin.firestore\n const { token, hash, keyPrefix } = generateApiKeyToken()\n const keyId = generateKeyId()\n const expiresAt =\n input.expiresAtMs != null ? Timestamp.fromMillis(input.expiresAtMs) : null\n\n const doc = {\n keyId,\n orgId: input.orgId,\n name: input.name.slice(0, 120),\n keyPrefix,\n scopes,\n createdBy: input.createdBy,\n createdAt: FieldValue.serverTimestamp(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt,\n }\n // `create` (not set) so a hash collision can never overwrite another key.\n await collection().doc(hash).create(doc)\n\n return {\n token,\n key: {\n keyId,\n name: doc.name,\n keyPrefix,\n scopes,\n createdAt: new Date().toISOString(),\n lastUsedAt: null,\n revokedAt: null,\n expiresAt: expiresAt ? expiresAt.toDate().toISOString() : null,\n },\n }\n}\n\n/**\n * Resolve a raw token to its org/scopes, or `null` if it is malformed,\n * unknown, revoked, or expired. Refreshes `lastUsedAt` at most once a minute\n * (fire-and-forget) to avoid a write on every request.\n */\nexport async function verifyApiKey(\n token: string,\n): Promise<VerifiedApiKey | null> {\n if (!isValidApiKeyFormat(token)) return null\n const snap = await collection().doc(hashApiKey(token)).get()\n if (!snap.exists) return null\n const data = snap.data() as ApiKeyDocument\n\n if (data.revokedAt) return null\n const now = Date.now()\n if (data.expiresAt && data.expiresAt.toMillis() <= now) return null\n\n const lastUsedMs = data.lastUsedAt ? data.lastUsedAt.toMillis() : 0\n if (now - lastUsedMs > 60_000) {\n snap.ref\n .update({ lastUsedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp() })\n .catch(() => undefined)\n }\n\n return {\n orgId: data.orgId,\n keyId: data.keyId,\n scopes: data.scopes ?? [],\n name: String(data.name ?? ''),\n }\n}\n\n/** All keys for an org, newest first (metadata only). */\nexport async function listApiKeys(orgId: string): Promise<PublicApiKey[]> {\n const snap = await collection().where('orgId', '==', orgId).get()\n return snap.docs\n .map((doc) => toPublicApiKey(doc.data() as ApiKeyDocument))\n .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))\n}\n\n/** Revoke a key by its public id. Returns false if not found/already revoked. */\nexport async function revokeApiKey(\n orgId: string,\n keyId: string,\n): Promise<boolean> {\n const snap = await collection().where('orgId', '==', orgId).get()\n const match = snap.docs.find(\n (doc) => (doc.data() as ApiKeyDocument).keyId === keyId,\n )\n if (!match || (match.data() as ApiKeyDocument).revokedAt) return false\n await match.ref.update({\n revokedAt: firebaseAdmin.firestore.FieldValue.serverTimestamp(),\n })\n return true\n}\n"],"names":["createHash","randomBytes","firebaseAdmin","API_SCOPES","API_KEY_TOKEN_PREFIX","API_KEYS_COLLECTION","DISPLAY_BODY_CHARS","isApiScope","value","includes","normalizeScopes","scopes","set","Set","filter","scope","has","hashApiKey","token","update","digest","isValidApiKeyFormat","startsWith","test","slice","length","generateApiKeyToken","body","toString","hash","keyPrefix","hasScope","granted","required","generateKeyId","collection","app","firestore","tsToIso","toDate","toISOString","toPublicApiKey","doc","keyId","name","createdAt","lastUsedAt","revokedAt","expiresAt","mintApiKey","input","Error","Timestamp","FieldValue","expiresAtMs","fromMillis","orgId","createdBy","serverTimestamp","create","key","Date","verifyApiKey","data","snap","get","exists","now","toMillis","lastUsedMs","ref","catch","undefined","String","listApiKeys","where","docs","map","sort","a","b","localeCompare","revokeApiKey","match","find"],"mappings":"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"}
@@ -107,9 +107,10 @@ export declare function apiV1Resources(): Array<{
107
107
  resource: ApiV1Resource;
108
108
  }>;
109
109
  /**
110
- * The descriptions of every resource the plugins serve, in registration
111
- * order. A description that fails to load is left out and logged, rather
112
- * than taking the platform's own description with it.
110
+ * The descriptions of every resource the plugins serve — the top-level ones,
111
+ * then those under a site — each in registration order. A description that
112
+ * fails to load is left out and logged, rather than taking the platform's
113
+ * own description with it.
113
114
  */
114
115
  export declare function describeApiV1Resources(): Promise<ApiV1ResourceDescription[]>;
115
116
  /** The plugin serving `/v1/<resource>` here, or `null` when none does. */
@@ -117,6 +118,46 @@ export declare function apiV1Resource(resource: string): {
117
118
  pluginId: string;
118
119
  resource: ApiV1Resource;
119
120
  } | null;
121
+ /**
122
+ * A plugin's resources UNDER a site, `/v1/sites/{siteId}/<resource>/…`
123
+ * (AGL-3080) — a site's orders, its products, its form submissions.
124
+ *
125
+ * The same registration a top-level resource takes, one level down. The
126
+ * router answers `/v1/sites/{siteId}` itself and refuses a site the key's
127
+ * organization does not own — `404 No such site` — BEFORE any registration
128
+ * is asked, so a handler is only ever handed a site its caller may reach,
129
+ * and asks nothing about ownership again. It is handed the whole path
130
+ * (`['sites', '<siteId>', 'orders', '<orderId>']`), asks for its own scopes
131
+ * and answers in the published envelope, as a top-level handler does.
132
+ *
133
+ * The platform's own (`media`, `publish`) are not registrable, a second
134
+ * plugin claiming a resource another serves is refused naming both, and
135
+ * `describe` reaches the OpenAPI document through
136
+ * {@link describeApiV1Resources} like every other description.
137
+ */
138
+ export declare const API_V1_SITE_RESOURCES: import("@aglyn/aglyn/server").PluginServiceContract<ApiV1Resource>;
139
+ /** The resources under a site the router answers itself, which no plugin may take. */
140
+ export declare const PLATFORM_API_V1_SITE_RESOURCES: ReadonlySet<string>;
141
+ /**
142
+ * Serves `/v1/sites/{siteId}/<resource>` from a plugin, on the terms
143
+ * {@link registerApiV1Resource} sets for a top-level one: the owner is the
144
+ * loader's marker or `options.pluginId`, the name one lowercase path
145
+ * segment, a platform resource and one another plugin serves refused.
146
+ */
147
+ export declare function registerApiV1SiteResource(resource: string, registration: ApiV1Resource, options?: {
148
+ pluginId?: string;
149
+ }): void;
150
+ /** Every resource under a site the plugins serve here, in registration order. */
151
+ export declare function apiV1SiteResources(): Array<{
152
+ name: string;
153
+ pluginId: string;
154
+ resource: ApiV1Resource;
155
+ }>;
156
+ /** The plugin serving `/v1/sites/{siteId}/<resource>` here, or `null` when none does. */
157
+ export declare function apiV1SiteResource(resource: string): {
158
+ pluginId: string;
159
+ resource: ApiV1Resource;
160
+ } | null;
120
161
  /**
121
162
  * A plugin's figures on `GET /v1/usage`, as top-level members of the usage
122
163
  * object — a band the plan meters, or a size an integration plans a sync by
@@ -1,3 +1,4 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
1
2
  /**
2
3
  * @license
3
4
  * Copyright 2026 Aglyn LLC
@@ -63,16 +64,25 @@ export const API_V1_RESOURCES = definePluginServiceContract('core.api-v1-resourc
63
64
  });
64
65
  }
65
66
  /**
66
- * The descriptions of every resource the plugins serve, in registration
67
- * order. A description that fails to load is left out and logged, rather
68
- * than taking the platform's own description with it.
67
+ * The descriptions of every resource the plugins serve — the top-level ones,
68
+ * then those under a site — each in registration order. A description that
69
+ * fails to load is left out and logged, rather than taking the platform's
70
+ * own description with it.
69
71
  */ export async function describeApiV1Resources() {
70
- const described = await Promise.all(apiV1Resources().map(async ({ name, pluginId, resource })=>{
72
+ const registered = [
73
+ ...apiV1Resources().map((entry)=>_extends({}, entry, {
74
+ path: `/v1/${entry.name}`
75
+ })),
76
+ ...apiV1SiteResources().map((entry)=>_extends({}, entry, {
77
+ path: `/v1/sites/{siteId}/${entry.name}`
78
+ }))
79
+ ];
80
+ const described = await Promise.all(registered.map(async ({ path, pluginId, resource })=>{
71
81
  if (!resource.describe) return null;
72
82
  try {
73
83
  return await resource.describe();
74
84
  } catch (error) {
75
- console.error(`[api-v1] ${pluginId} could not describe /v1/${name}`, error);
85
+ console.error(`[api-v1] ${pluginId} could not describe ${path}`, error);
76
86
  return null;
77
87
  }
78
88
  }));
@@ -86,6 +96,73 @@ export const API_V1_RESOURCES = definePluginServiceContract('core.api-v1-resourc
86
96
  resource: entry.impl
87
97
  } : null;
88
98
  }
99
+ // ── A site's resources ─────────────────────────────────────────────────────
100
+ /**
101
+ * A plugin's resources UNDER a site, `/v1/sites/{siteId}/<resource>/…`
102
+ * (AGL-3080) — a site's orders, its products, its form submissions.
103
+ *
104
+ * The same registration a top-level resource takes, one level down. The
105
+ * router answers `/v1/sites/{siteId}` itself and refuses a site the key's
106
+ * organization does not own — `404 No such site` — BEFORE any registration
107
+ * is asked, so a handler is only ever handed a site its caller may reach,
108
+ * and asks nothing about ownership again. It is handed the whole path
109
+ * (`['sites', '<siteId>', 'orders', '<orderId>']`), asks for its own scopes
110
+ * and answers in the published envelope, as a top-level handler does.
111
+ *
112
+ * The platform's own (`media`, `publish`) are not registrable, a second
113
+ * plugin claiming a resource another serves is refused naming both, and
114
+ * `describe` reaches the OpenAPI document through
115
+ * {@link describeApiV1Resources} like every other description.
116
+ */ export const API_V1_SITE_RESOURCES = definePluginServiceContract('core.api-v1-site-resources', {
117
+ multiple: true
118
+ });
119
+ /** The resources under a site the router answers itself, which no plugin may take. */ export const PLATFORM_API_V1_SITE_RESOURCES = new Set([
120
+ 'media',
121
+ 'publish'
122
+ ]);
123
+ /**
124
+ * Serves `/v1/sites/{siteId}/<resource>` from a plugin, on the terms
125
+ * {@link registerApiV1Resource} sets for a top-level one: the owner is the
126
+ * loader's marker or `options.pluginId`, the name one lowercase path
127
+ * segment, a platform resource and one another plugin serves refused.
128
+ */ export function registerApiV1SiteResource(resource, registration, options) {
129
+ var _ref, _getRegisteringPluginId;
130
+ const key = String(resource != null ? resource : '').trim();
131
+ if (!RESOURCE.test(key)) {
132
+ throw new Error(`a /v1 site resource needs a lowercase path segment for its name, not "${key}"`);
133
+ }
134
+ if (PLATFORM_API_V1_SITE_RESOURCES.has(key)) {
135
+ throw new Error(`/v1/sites/{siteId}/${key} is the platform's own resource`);
136
+ }
137
+ const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref : '').trim();
138
+ if (!pluginId) throw new Error(`the /v1/sites/{siteId}/${key} resource was registered with no owner`);
139
+ const incumbent = resolvePluginServices(API_V1_SITE_RESOURCES).find((entry)=>entry.key === key);
140
+ if (incumbent && incumbent.pluginId !== pluginId) {
141
+ throw new Error(`/v1/sites/{siteId}/${key} is already served by "${incumbent.pluginId}"; refused "${pluginId}"`);
142
+ }
143
+ registerPluginService(API_V1_SITE_RESOURCES, registration, {
144
+ pluginId,
145
+ key
146
+ });
147
+ }
148
+ /** Every resource under a site the plugins serve here, in registration order. */ export function apiV1SiteResources() {
149
+ return resolvePluginServices(API_V1_SITE_RESOURCES).map((entry)=>{
150
+ var _entry_key;
151
+ return {
152
+ name: (_entry_key = entry.key) != null ? _entry_key : '',
153
+ pluginId: entry.pluginId,
154
+ resource: entry.impl
155
+ };
156
+ });
157
+ }
158
+ /** The plugin serving `/v1/sites/{siteId}/<resource>` here, or `null` when none does. */ export function apiV1SiteResource(resource) {
159
+ const key = String(resource != null ? resource : '').trim();
160
+ const entry = resolvePluginServices(API_V1_SITE_RESOURCES).find((one)=>one.key === key);
161
+ return entry ? {
162
+ pluginId: entry.pluginId,
163
+ resource: entry.impl
164
+ } : null;
165
+ }
89
166
  export const API_V1_USAGE_FIGURES = definePluginServiceContract('core.api-v1-usage-figures', {
90
167
  multiple: true
91
168
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-resources.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getRegisteringPluginId } from '@aglyn/aglyn/app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from '@aglyn/aglyn/plugin-manager/plugin-services'\nimport type { ApiV1ResourceDescription } from './api-v1-description'\nimport type { ApiV1Context } from './api-v1-kit'\n\n/**\n * A plugin's resources on the customer REST API, `/v1/<resource>/…`\n * (AGL-3080).\n *\n * The console's `/v1` router owns the pipeline — the API key, the\n * `apiAccess` entitlement, the request quota, the rate limit, the error\n * envelope — and the platform's own resources (sites, the media library). A resource a plugin models is the plugin's: it registers a\n * handler under the resource's first path segment, and the router hands it\n * every request under that segment once the key is authenticated, with the\n * context the pipeline resolved. The handler asks for its own scopes\n * (`requireScope`) and answers in the published envelope.\n *\n * ## The published contract does not move\n *\n * A path, a field, a scope and a status are the same whichever module\n * answers them. The OpenAPI document is still the router's to build, and a\n * registration hands it the resource's description (`describe`), so the\n * document — and the MCP tools derived from it — describe every resource the\n * build serves and nothing it does not. Moving a resource into its plugin\n * changes where it is written, never what an integrator sees.\n *\n * ## What the organization's usage reports\n *\n * `GET /v1/usage` is the platform's; a plugin whose records the plan bands\n * or an integration sizes a sync by adds its own figures to it\n * (`registerApiV1UsageFigures`), read with the platform's on every call.\n *\n * ## The plan, before the handler\n *\n * A registration may name the plan feature its resource needs\n * (`entitlement`). The router refuses an organization without it —\n * `402 plan_required` with the feature as the `code` and the registration's\n * own sentence — BEFORE the handler runs and before any scope is asked, so a\n * key minted while the plan carried the feature cannot outlive it.\n *\n * ## Registered at the console's boot\n *\n * From the plugin's console server declarations, with the handler loaded on\n * the first request. The router asks for a resource it does not know once\n * more after running the app's declarations step\n * (`runPluginDeclarationsRepair`), so a process whose boot failed repairs\n * itself rather than answering `404` for a resource it serves. No\n * registration means no plugin in this build serves the resource: the\n * router's `404` for an unknown endpoint is then the truth.\n *\n * ## One owner per resource\n *\n * A second plugin registering a resource another already serves is refused,\n * naming both, and the incumbent keeps serving. The platform's own\n * resources are not registrable.\n */\n\n/** Answers one request under the resource, after the pipeline admitted it. */\nexport type ApiV1ResourceHandler = (\n request: Request,\n context: ApiV1Context,\n /** The path under `/v1`, split — `['contacts', '<id>', 'merge']`. */\n segments: string[],\n url: URL,\n) => Promise<Response>\n\nexport interface ApiV1Resource {\n handle: ApiV1ResourceHandler\n /**\n * The plan feature the resource needs, and the sentence a refusal says —\n * `The CRM … is not included in this organization's plan`.\n */\n entitlement?: { feature: string; message: string }\n /**\n * The resource as the OpenAPI document describes it, loaded when the\n * document is built. Absent, the resource is served and undocumented.\n */\n describe?: () => Promise<ApiV1ResourceDescription>\n}\n\nexport const API_V1_RESOURCES = definePluginServiceContract<ApiV1Resource>(\n 'core.api-v1-resources',\n { multiple: true },\n)\n\n/** The resources the router answers itself, which no plugin may take. */\nexport const PLATFORM_API_V1_RESOURCES: ReadonlySet<string> = new Set([\n 'sites',\n 'media',\n 'usage',\n 'me',\n 'openapi.json',\n])\n\n/** A resource name as a path segment carries it. */\nconst RESOURCE = /^[a-z][a-z0-9-]*$/\n\n/**\n * Serves `/v1/<resource>` from a plugin. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, as it does for a name that is not one lowercase path\n * segment, a platform resource, and a resource another plugin serves. The\n * same plugin registering again replaces its own.\n */\nexport function registerApiV1Resource(\n resource: string,\n registration: ApiV1Resource,\n options?: { pluginId?: string },\n): void {\n const key = String(resource ?? '').trim()\n if (!RESOURCE.test(key)) {\n throw new Error(`a /v1 resource needs a lowercase path segment for its name, not \"${key}\"`)\n }\n if (PLATFORM_API_V1_RESOURCES.has(key)) {\n throw new Error(`/v1/${key} is the platform's own resource`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error(`the /v1/${key} resource was registered with no owner`)\n const incumbent = resolvePluginServices(API_V1_RESOURCES).find((entry) => entry.key === key)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `/v1/${key} is already served by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(API_V1_RESOURCES, registration, { pluginId, key })\n}\n\n/** Every resource the plugins serve here, in registration order. */\nexport function apiV1Resources(): Array<{ name: string; pluginId: string; resource: ApiV1Resource }> {\n return resolvePluginServices(API_V1_RESOURCES).map((entry) => ({\n name: entry.key ?? '',\n pluginId: entry.pluginId,\n resource: entry.impl,\n }))\n}\n\n/**\n * The descriptions of every resource the plugins serve, in registration\n * order. A description that fails to load is left out and logged, rather\n * than taking the platform's own description with it.\n */\nexport async function describeApiV1Resources(): Promise<ApiV1ResourceDescription[]> {\n const described = await Promise.all(\n apiV1Resources().map(async ({ name, pluginId, resource }) => {\n if (!resource.describe) return null\n try {\n return await resource.describe()\n } catch (error) {\n console.error(`[api-v1] ${pluginId} could not describe /v1/${name}`, error)\n return null\n }\n }),\n )\n return described.filter((one): one is ApiV1ResourceDescription => one !== null)\n}\n\n/** The plugin serving `/v1/<resource>` here, or `null` when none does. */\nexport function apiV1Resource(\n resource: string,\n): { pluginId: string; resource: ApiV1Resource } | null {\n const key = String(resource ?? '').trim()\n const entry = resolvePluginServices(API_V1_RESOURCES).find((one) => one.key === key)\n return entry ? { pluginId: entry.pluginId, resource: entry.impl } : null\n}\n\n// ── Usage ──────────────────────────────────────────────────────────────────\n\n/**\n * A plugin's figures on `GET /v1/usage`, as top-level members of the usage\n * object — a band the plan meters, or a size an integration plans a sync by\n * (`usageBand` in `api-v1-kit.ts` gives either its published shape). Read on\n * every call, with the request's context; a reader that throws fails the\n * request, as a platform figure that cannot be read does.\n */\nexport type ApiV1UsageFigures = (context: ApiV1Context) => Promise<Record<string, unknown>>\n\nexport const API_V1_USAGE_FIGURES = definePluginServiceContract<ApiV1UsageFigures>(\n 'core.api-v1-usage-figures',\n { multiple: true },\n)\n\n/**\n * Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;\n * registering again replaces it. The owner is the loader's marker when a\n * register fn is running, else `options.pluginId`.\n */\nexport function registerApiV1UsageFigures(\n read: ApiV1UsageFigures,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('/v1 usage figures were registered with no owner')\n registerPluginService(API_V1_USAGE_FIGURES, read, { pluginId })\n}\n\n/**\n * Every plugin's figures for one request, merged in registration order. A\n * member the platform's own figures (`reserved`) or an earlier plugin\n * already names is dropped and logged: a plugin adds figures, never\n * replaces one.\n */\nexport async function readApiV1UsageFigures(\n context: ApiV1Context,\n reserved: ReadonlySet<string>,\n): Promise<Record<string, unknown>> {\n const readers = resolvePluginServices(API_V1_USAGE_FIGURES)\n const figures = await Promise.all(readers.map((entry) => entry.impl(context)))\n const merged: Record<string, unknown> = {}\n figures.forEach((one, index) => {\n for (const [key, value] of Object.entries(one)) {\n if (reserved.has(key) || key in merged) {\n console.error(`[api-v1] ${readers[index].pluginId} named usage figure \"${key}\", which is taken`)\n continue\n }\n merged[key] = value\n }\n })\n return merged\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","API_V1_RESOURCES","multiple","PLATFORM_API_V1_RESOURCES","Set","RESOURCE","registerApiV1Resource","resource","registration","options","key","String","trim","test","Error","has","pluginId","incumbent","find","entry","apiV1Resources","map","name","impl","describeApiV1Resources","described","Promise","all","describe","error","console","filter","one","apiV1Resource","API_V1_USAGE_FIGURES","registerApiV1UsageFigures","read","readApiV1UsageFigures","context","reserved","readers","figures","merged","forEach","index","value","Object","entries"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-resources.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getRegisteringPluginId } from '@aglyn/aglyn/app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from '@aglyn/aglyn/plugin-manager/plugin-services'\nimport type { ApiV1ResourceDescription } from './api-v1-description'\nimport type { ApiV1Context } from './api-v1-kit'\n\n/**\n * A plugin's resources on the customer REST API, `/v1/<resource>/…`\n * (AGL-3080).\n *\n * The console's `/v1` router owns the pipeline — the API key, the\n * `apiAccess` entitlement, the request quota, the rate limit, the error\n * envelope — and the platform's own resources (sites, the media library). A resource a plugin models is the plugin's: it registers a\n * handler under the resource's first path segment, and the router hands it\n * every request under that segment once the key is authenticated, with the\n * context the pipeline resolved. The handler asks for its own scopes\n * (`requireScope`) and answers in the published envelope.\n *\n * ## The published contract does not move\n *\n * A path, a field, a scope and a status are the same whichever module\n * answers them. The OpenAPI document is still the router's to build, and a\n * registration hands it the resource's description (`describe`), so the\n * document — and the MCP tools derived from it — describe every resource the\n * build serves and nothing it does not. Moving a resource into its plugin\n * changes where it is written, never what an integrator sees.\n *\n * ## What the organization's usage reports\n *\n * `GET /v1/usage` is the platform's; a plugin whose records the plan bands\n * or an integration sizes a sync by adds its own figures to it\n * (`registerApiV1UsageFigures`), read with the platform's on every call.\n *\n * ## The plan, before the handler\n *\n * A registration may name the plan feature its resource needs\n * (`entitlement`). The router refuses an organization without it —\n * `402 plan_required` with the feature as the `code` and the registration's\n * own sentence — BEFORE the handler runs and before any scope is asked, so a\n * key minted while the plan carried the feature cannot outlive it.\n *\n * ## Registered at the console's boot\n *\n * From the plugin's console server declarations, with the handler loaded on\n * the first request. The router asks for a resource it does not know once\n * more after running the app's declarations step\n * (`runPluginDeclarationsRepair`), so a process whose boot failed repairs\n * itself rather than answering `404` for a resource it serves. No\n * registration means no plugin in this build serves the resource: the\n * router's `404` for an unknown endpoint is then the truth.\n *\n * ## One owner per resource\n *\n * A second plugin registering a resource another already serves is refused,\n * naming both, and the incumbent keeps serving. The platform's own\n * resources are not registrable.\n */\n\n/** Answers one request under the resource, after the pipeline admitted it. */\nexport type ApiV1ResourceHandler = (\n request: Request,\n context: ApiV1Context,\n /** The path under `/v1`, split — `['contacts', '<id>', 'merge']`. */\n segments: string[],\n url: URL,\n) => Promise<Response>\n\nexport interface ApiV1Resource {\n handle: ApiV1ResourceHandler\n /**\n * The plan feature the resource needs, and the sentence a refusal says —\n * `The CRM … is not included in this organization's plan`.\n */\n entitlement?: { feature: string; message: string }\n /**\n * The resource as the OpenAPI document describes it, loaded when the\n * document is built. Absent, the resource is served and undocumented.\n */\n describe?: () => Promise<ApiV1ResourceDescription>\n}\n\nexport const API_V1_RESOURCES = definePluginServiceContract<ApiV1Resource>(\n 'core.api-v1-resources',\n { multiple: true },\n)\n\n/** The resources the router answers itself, which no plugin may take. */\nexport const PLATFORM_API_V1_RESOURCES: ReadonlySet<string> = new Set([\n 'sites',\n 'media',\n 'usage',\n 'me',\n 'openapi.json',\n])\n\n/** A resource name as a path segment carries it. */\nconst RESOURCE = /^[a-z][a-z0-9-]*$/\n\n/**\n * Serves `/v1/<resource>` from a plugin. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, as it does for a name that is not one lowercase path\n * segment, a platform resource, and a resource another plugin serves. The\n * same plugin registering again replaces its own.\n */\nexport function registerApiV1Resource(\n resource: string,\n registration: ApiV1Resource,\n options?: { pluginId?: string },\n): void {\n const key = String(resource ?? '').trim()\n if (!RESOURCE.test(key)) {\n throw new Error(`a /v1 resource needs a lowercase path segment for its name, not \"${key}\"`)\n }\n if (PLATFORM_API_V1_RESOURCES.has(key)) {\n throw new Error(`/v1/${key} is the platform's own resource`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error(`the /v1/${key} resource was registered with no owner`)\n const incumbent = resolvePluginServices(API_V1_RESOURCES).find((entry) => entry.key === key)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `/v1/${key} is already served by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(API_V1_RESOURCES, registration, { pluginId, key })\n}\n\n/** Every resource the plugins serve here, in registration order. */\nexport function apiV1Resources(): Array<{ name: string; pluginId: string; resource: ApiV1Resource }> {\n return resolvePluginServices(API_V1_RESOURCES).map((entry) => ({\n name: entry.key ?? '',\n pluginId: entry.pluginId,\n resource: entry.impl,\n }))\n}\n\n/**\n * The descriptions of every resource the plugins serve — the top-level ones,\n * then those under a site — each in registration order. A description that\n * fails to load is left out and logged, rather than taking the platform's\n * own description with it.\n */\nexport async function describeApiV1Resources(): Promise<ApiV1ResourceDescription[]> {\n const registered = [\n ...apiV1Resources().map((entry) => ({ ...entry, path: `/v1/${entry.name}` })),\n ...apiV1SiteResources().map((entry) => ({ ...entry, path: `/v1/sites/{siteId}/${entry.name}` })),\n ]\n const described = await Promise.all(\n registered.map(async ({ path, pluginId, resource }) => {\n if (!resource.describe) return null\n try {\n return await resource.describe()\n } catch (error) {\n console.error(`[api-v1] ${pluginId} could not describe ${path}`, error)\n return null\n }\n }),\n )\n return described.filter((one): one is ApiV1ResourceDescription => one !== null)\n}\n\n/** The plugin serving `/v1/<resource>` here, or `null` when none does. */\nexport function apiV1Resource(\n resource: string,\n): { pluginId: string; resource: ApiV1Resource } | null {\n const key = String(resource ?? '').trim()\n const entry = resolvePluginServices(API_V1_RESOURCES).find((one) => one.key === key)\n return entry ? { pluginId: entry.pluginId, resource: entry.impl } : null\n}\n\n// ── A site's resources ─────────────────────────────────────────────────────\n\n/**\n * A plugin's resources UNDER a site, `/v1/sites/{siteId}/<resource>/…`\n * (AGL-3080) — a site's orders, its products, its form submissions.\n *\n * The same registration a top-level resource takes, one level down. The\n * router answers `/v1/sites/{siteId}` itself and refuses a site the key's\n * organization does not own — `404 No such site` — BEFORE any registration\n * is asked, so a handler is only ever handed a site its caller may reach,\n * and asks nothing about ownership again. It is handed the whole path\n * (`['sites', '<siteId>', 'orders', '<orderId>']`), asks for its own scopes\n * and answers in the published envelope, as a top-level handler does.\n *\n * The platform's own (`media`, `publish`) are not registrable, a second\n * plugin claiming a resource another serves is refused naming both, and\n * `describe` reaches the OpenAPI document through\n * {@link describeApiV1Resources} like every other description.\n */\nexport const API_V1_SITE_RESOURCES = definePluginServiceContract<ApiV1Resource>(\n 'core.api-v1-site-resources',\n { multiple: true },\n)\n\n/** The resources under a site the router answers itself, which no plugin may take. */\nexport const PLATFORM_API_V1_SITE_RESOURCES: ReadonlySet<string> = new Set(['media', 'publish'])\n\n/**\n * Serves `/v1/sites/{siteId}/<resource>` from a plugin, on the terms\n * {@link registerApiV1Resource} sets for a top-level one: the owner is the\n * loader's marker or `options.pluginId`, the name one lowercase path\n * segment, a platform resource and one another plugin serves refused.\n */\nexport function registerApiV1SiteResource(\n resource: string,\n registration: ApiV1Resource,\n options?: { pluginId?: string },\n): void {\n const key = String(resource ?? '').trim()\n if (!RESOURCE.test(key)) {\n throw new Error(`a /v1 site resource needs a lowercase path segment for its name, not \"${key}\"`)\n }\n if (PLATFORM_API_V1_SITE_RESOURCES.has(key)) {\n throw new Error(`/v1/sites/{siteId}/${key} is the platform's own resource`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error(`the /v1/sites/{siteId}/${key} resource was registered with no owner`)\n const incumbent = resolvePluginServices(API_V1_SITE_RESOURCES).find((entry) => entry.key === key)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `/v1/sites/{siteId}/${key} is already served by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(API_V1_SITE_RESOURCES, registration, { pluginId, key })\n}\n\n/** Every resource under a site the plugins serve here, in registration order. */\nexport function apiV1SiteResources(): Array<{ name: string; pluginId: string; resource: ApiV1Resource }> {\n return resolvePluginServices(API_V1_SITE_RESOURCES).map((entry) => ({\n name: entry.key ?? '',\n pluginId: entry.pluginId,\n resource: entry.impl,\n }))\n}\n\n/** The plugin serving `/v1/sites/{siteId}/<resource>` here, or `null` when none does. */\nexport function apiV1SiteResource(\n resource: string,\n): { pluginId: string; resource: ApiV1Resource } | null {\n const key = String(resource ?? '').trim()\n const entry = resolvePluginServices(API_V1_SITE_RESOURCES).find((one) => one.key === key)\n return entry ? { pluginId: entry.pluginId, resource: entry.impl } : null\n}\n\n// ── Usage ──────────────────────────────────────────────────────────────────\n\n/**\n * A plugin's figures on `GET /v1/usage`, as top-level members of the usage\n * object — a band the plan meters, or a size an integration plans a sync by\n * (`usageBand` in `api-v1-kit.ts` gives either its published shape). Read on\n * every call, with the request's context; a reader that throws fails the\n * request, as a platform figure that cannot be read does.\n */\nexport type ApiV1UsageFigures = (context: ApiV1Context) => Promise<Record<string, unknown>>\n\nexport const API_V1_USAGE_FIGURES = definePluginServiceContract<ApiV1UsageFigures>(\n 'core.api-v1-usage-figures',\n { multiple: true },\n)\n\n/**\n * Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;\n * registering again replaces it. The owner is the loader's marker when a\n * register fn is running, else `options.pluginId`.\n */\nexport function registerApiV1UsageFigures(\n read: ApiV1UsageFigures,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('/v1 usage figures were registered with no owner')\n registerPluginService(API_V1_USAGE_FIGURES, read, { pluginId })\n}\n\n/**\n * Every plugin's figures for one request, merged in registration order. A\n * member the platform's own figures (`reserved`) or an earlier plugin\n * already names is dropped and logged: a plugin adds figures, never\n * replaces one.\n */\nexport async function readApiV1UsageFigures(\n context: ApiV1Context,\n reserved: ReadonlySet<string>,\n): Promise<Record<string, unknown>> {\n const readers = resolvePluginServices(API_V1_USAGE_FIGURES)\n const figures = await Promise.all(readers.map((entry) => entry.impl(context)))\n const merged: Record<string, unknown> = {}\n figures.forEach((one, index) => {\n for (const [key, value] of Object.entries(one)) {\n if (reserved.has(key) || key in merged) {\n console.error(`[api-v1] ${readers[index].pluginId} named usage figure \"${key}\", which is taken`)\n continue\n }\n merged[key] = value\n }\n })\n return merged\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","API_V1_RESOURCES","multiple","PLATFORM_API_V1_RESOURCES","Set","RESOURCE","registerApiV1Resource","resource","registration","options","key","String","trim","test","Error","has","pluginId","incumbent","find","entry","apiV1Resources","map","name","impl","describeApiV1Resources","registered","path","apiV1SiteResources","described","Promise","all","describe","error","console","filter","one","apiV1Resource","API_V1_SITE_RESOURCES","PLATFORM_API_V1_SITE_RESOURCES","registerApiV1SiteResource","apiV1SiteResource","API_V1_USAGE_FIGURES","registerApiV1UsageFigures","read","readApiV1UsageFigures","context","reserved","readers","figures","merged","forEach","index","value","Object","entries"],"mappings":"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"}
@@ -15,7 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import type { Firestore } from 'firebase-admin/firestore';
18
- import type { DatasetLivePageTarget } from './dataset-live-pages';
18
+ import type { LivePageTarget } from './live-page-drops';
19
19
  /**
20
20
  * How many documents per collection the placement scan reads.
21
21
  *
@@ -80,4 +80,4 @@ export declare function collectionLivePageTarget(options: {
80
80
  hostId: string;
81
81
  collectionId: string;
82
82
  entrySlugs: readonly string[];
83
- }): Promise<DatasetLivePageTarget | null>;
83
+ }): Promise<LivePageTarget | null>;
@@ -27,7 +27,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
27
27
  * both import it.
28
28
  *
29
29
  * Reads only. Announcing is the caller's, for the reason
30
- * `dataset-live-pages.ts` gives: the two runtimes drop caches differently.
30
+ * `live-page-drops.ts` gives: the two runtimes drop caches differently.
31
31
  */ import { COLLECTION_CATEGORIES_MAX, COLLECTION_LIST_PAGE_SIZE, COLLECTION_SOURCE_MAX, collectionCategorySlug, collectionListUrl, hostCollectionKind, screenRoutePathToUrl } from "@aglyn/aglyn/server";
32
32
  import { readUsageSources, screenIdsUsingCollectionDeep } from "./live-page-usage.js";
33
33
  /**
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/collection-live-pages.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 * Which live pages a content collection's change makes stale (AGL-3340).\n *\n * Two callers ask, from two runtimes. The console asks when somebody saves,\n * publishes, schedules, re-dates or deletes an entry, and announces the answer\n * to the tenant over the shared secret. The tenant asks when a SCHEDULED entry\n * comes due, which is a change nobody makes: no write from the console, no\n * request, only the clock — so the tenant's own beat has to find the pages and\n * drop them in process. Two copies of this answer are how a scheduled post\n * would come to reach `/blog` and miss the home page's rail, so it is here and\n * both import it.\n *\n * Reads only. Announcing is the caller's, for the reason\n * `dataset-live-pages.ts` gives: the two runtimes drop caches differently.\n */\n\nimport {\n COLLECTION_CATEGORIES_MAX,\n COLLECTION_LIST_PAGE_SIZE,\n COLLECTION_SOURCE_MAX,\n collectionCategorySlug,\n collectionListUrl,\n hostCollectionKind,\n screenRoutePathToUrl,\n} from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport type { DatasetLivePageTarget } from './dataset-live-pages'\nimport {\n readUsageSources,\n screenIdsUsingCollectionDeep,\n} from './live-page-usage'\n\n/**\n * How many documents per collection the placement scan reads.\n *\n * The number every other cache-dropping scan uses, for their reason\n * (AGL-1161): this decides which caches get dropped, so a prefix scan reports\n * a refreshed site and leaves real pages stale. When the bound bites it is\n * returned as `truncated`, never absorbed.\n */\nexport const COLLECTION_SCAN_LIMIT = 2000\n\nexport interface CollectionLivePageScope {\n /**\n * Site-absolute addresses derived from the collection's slug — the entry\n * pages, the listing and its pages, each category listing — most important\n * first, because the tenant's path cap keeps the FIRST paths it is handed.\n */\n paths: string[]\n /** Screens that render the collection somewhere else, by id. */\n screenIds: string[]\n /** The placement scan read fewer documents than the site holds. */\n truncated: boolean\n}\n\n/**\n * What an entry change makes stale, for one content collection.\n *\n * A content entry is not published through a version pointer, so nothing here\n * looks like the publish paths above: the write is a client Firestore write,\n * and the page that renders it is reached by ADDRESS rather than by a screen\n * document. `/blog`, `/blog/page/2`, `/blog/category/guides` and\n * `/blog/my-post` are served by the catch-all's collection fallback, which may\n * have no screen of its own at all, so a routing-map lookup finds nothing to\n * drop and the site keeps serving the old post.\n *\n * Two halves, therefore, and both are needed:\n *\n * - the collection's own ADDRESSES, derived here from its slug;\n * - the SCREENS that render the collection somewhere else — a rail on the\n * home page, category pills in a layout — which are found by searching node\n * trees, exactly as a form's placements are.\n *\n * Refuses anything that is not a CONTENT collection. Commerce shares\n * `hosts/{hostId}/collections`, and a product collection's pages are routed by\n * the store's templates rather than by these shapes, so building content\n * addresses from one would drop paths that belong to nothing.\n */\nexport async function collectionLivePageScope(options: {\n firestore: Firestore\n hostId: string\n collectionId: string\n entrySlugs: readonly string[]\n}): Promise<CollectionLivePageScope> {\n const { firestore, hostId, collectionId, entrySlugs } = options\n const empty: CollectionLivePageScope = {\n paths: [],\n screenIds: [],\n truncated: false,\n }\n const collectionSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('collections')\n .doc(collectionId)\n .get()\n if (!collectionSnapshot.exists) return empty\n const data = collectionSnapshot.data() ?? {}\n if (hostCollectionKind(data) !== 'content') return empty\n if (data['deletedAt']) return empty\n const collectionSlug = String(data['slug'] ?? '').trim()\n if (!collectionSlug || collectionSlug.includes('/')) return empty\n\n /**\n * Ordered by how much each address matters, because the tenant's path cap\n * takes the FIRST `MAX_PATHS` it is handed. A site whose collection is\n * rendered on more pages than the cap admits therefore loses its deepest\n * category listings rather than the post that was just edited.\n */\n const paths: string[] = []\n const add = (path: string) => {\n if (path && !paths.includes(path)) paths.push(path)\n }\n\n // The entry's own address first — the one page whose author is watching.\n // Both slugs when a save renamed it: the new address has never been\n // rendered, and the OLD one is a cached page that now belongs to nothing.\n for (const entrySlug of entrySlugs) add(`/${collectionSlug}/${entrySlug}`)\n add(collectionListUrl({ collectionSlug }))\n\n /**\n * Every page of the unfiltered listing that the CACHED read covers.\n *\n * A constant range rather than a count-derived one: dropping a page that\n * does not exist is a cache-key delete against a key nothing holds, which\n * costs nothing, while asking Firestore for the exact page count would trade\n * that for a read on every save and still be wrong the moment publishing an\n * entry adds a page.\n *\n * It used to be the whole range, because `COLLECTION_SOURCE_MAX` bounded the\n * LIVE SET and not merely one read of it — a listing had no eleventh page to\n * miss. AGL-3213 ended that: the constant still bounds the cached head every\n * listing address shares, but the collection now runs past it, and a page\n * that starts beyond the head is served by its own window read at its own\n * address.\n *\n * The range deliberately did not follow it there. `MAX_PATHS` takes the\n * FIRST paths it is handed, so extending this to a collection's real page\n * count would spend the budget on pages almost nobody opens and drop the\n * dependent screens below instead — and those deep pages are exactly the\n * ones whose entries did not change. They catch up on their own ISR window,\n * which is the argument the category listings below already make for\n * themselves.\n *\n * Since AGL-3219 there is nothing for them to catch up ON. A deep page is\n * addressed by the entry it continues from, so publishing at the head of\n * the collection does not change which entries it holds — a stale cursor\n * page is merely old, never wrong. It was POSITIONAL addressing that made\n * this lag visible: the head refreshed here, the tail did not, and the two\n * then disagreed by exactly the one entry that had been inserted above\n * them both.\n */\n const listPages = Math.ceil(COLLECTION_SOURCE_MAX / COLLECTION_LIST_PAGE_SIZE)\n for (let page = 2; page <= listPages; page += 1) {\n add(collectionListUrl({ collectionSlug, page }))\n }\n\n /**\n * Page one of every category listing.\n *\n * Every category rather than the changed entry's, because an entry can move\n * between two in one save and a delete leaves no entry to ask — so the set\n * that is certainly right is the collection's own, and it is bounded by\n * `COLLECTION_CATEGORIES_MAX`. Page one only: the same range applied to each\n * category is `COLLECTION_CATEGORIES_MAX` times as many paths as the\n * unfiltered listing, which would push the cap over on the categories alone\n * and take the dependent screens with it. Deeper category pages catch up on\n * their own ISR window.\n */\n const categories = Array.isArray(data['categories']) ? data['categories'] : []\n for (const category of categories.slice(0, COLLECTION_CATEGORIES_MAX)) {\n const name = String((category as { name?: unknown })?.name ?? '').trim()\n if (!name) continue\n const categorySlug = collectionCategorySlug(name)\n if (!categorySlug) continue\n add(collectionListUrl({ collectionSlug, categorySlug }))\n }\n\n const sources = await readUsageSources(\n firestore.collection('hosts').doc(hostId),\n COLLECTION_SCAN_LIMIT,\n )\n return {\n paths,\n screenIds: screenIdsUsingCollectionDeep(collectionSlug, sources.candidates),\n truncated: sources.truncated,\n }\n}\n\n/**\n * The same scope as ONE site's drop target: the collection's addresses with\n * the dependent screens resolved through the routing map, in the shape a\n * `LivePageDropper` takes.\n *\n * `null` when there is nothing to drop — a site with no subdomain has no\n * tenant deployment holding pages, and a collection that is gone or is the\n * store's has no content addresses.\n */\nexport async function collectionLivePageTarget(options: {\n firestore: Firestore\n hostId: string\n collectionId: string\n entrySlugs: readonly string[]\n}): Promise<DatasetLivePageTarget | null> {\n const { firestore, hostId } = options\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) return null\n const subdomain = String(hostSnapshot.get('subdomain') ?? '')\n if (!subdomain) return null\n\n const scope = await collectionLivePageScope(options)\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n // The derived addresses lead, as they do on the console route: they are the\n // pages the change is about, and the cap keeps what it is handed first.\n const paths = [\n ...scope.paths,\n ...scope.screenIds\n .map((screenId) => screens[screenId])\n .filter((path): path is string => Boolean(path))\n .map((path) => screenRoutePathToUrl(path)),\n ].filter((path, index, all) => all.indexOf(path) === index)\n if (!paths.length) return null\n\n const cname = String(hostSnapshot.get('cname') ?? '')\n return {\n hostId,\n subdomain,\n ...(cname ? { cname } : {}),\n paths,\n truncated: scope.truncated,\n }\n}\n"],"names":["COLLECTION_CATEGORIES_MAX","COLLECTION_LIST_PAGE_SIZE","COLLECTION_SOURCE_MAX","collectionCategorySlug","collectionListUrl","hostCollectionKind","screenRoutePathToUrl","readUsageSources","screenIdsUsingCollectionDeep","COLLECTION_SCAN_LIMIT","collectionLivePageScope","options","collectionSnapshot","data","firestore","hostId","collectionId","entrySlugs","empty","paths","screenIds","truncated","collection","doc","get","exists","collectionSlug","String","trim","includes","add","path","push","entrySlug","listPages","Math","ceil","page","categories","Array","isArray","category","slice","name","categorySlug","sources","candidates","collectionLivePageTarget","hostSnapshot","subdomain","scope","screens","map","screenId","filter","Boolean","index","all","indexOf","length","cname"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/collection-live-pages.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 * Which live pages a content collection's change makes stale (AGL-3340).\n *\n * Two callers ask, from two runtimes. The console asks when somebody saves,\n * publishes, schedules, re-dates or deletes an entry, and announces the answer\n * to the tenant over the shared secret. The tenant asks when a SCHEDULED entry\n * comes due, which is a change nobody makes: no write from the console, no\n * request, only the clock — so the tenant's own beat has to find the pages and\n * drop them in process. Two copies of this answer are how a scheduled post\n * would come to reach `/blog` and miss the home page's rail, so it is here and\n * both import it.\n *\n * Reads only. Announcing is the caller's, for the reason\n * `live-page-drops.ts` gives: the two runtimes drop caches differently.\n */\n\nimport {\n COLLECTION_CATEGORIES_MAX,\n COLLECTION_LIST_PAGE_SIZE,\n COLLECTION_SOURCE_MAX,\n collectionCategorySlug,\n collectionListUrl,\n hostCollectionKind,\n screenRoutePathToUrl,\n} from '@aglyn/aglyn/server'\nimport type { Firestore } from 'firebase-admin/firestore'\nimport type { LivePageTarget } from './live-page-drops'\nimport {\n readUsageSources,\n screenIdsUsingCollectionDeep,\n} from './live-page-usage'\n\n/**\n * How many documents per collection the placement scan reads.\n *\n * The number every other cache-dropping scan uses, for their reason\n * (AGL-1161): this decides which caches get dropped, so a prefix scan reports\n * a refreshed site and leaves real pages stale. When the bound bites it is\n * returned as `truncated`, never absorbed.\n */\nexport const COLLECTION_SCAN_LIMIT = 2000\n\nexport interface CollectionLivePageScope {\n /**\n * Site-absolute addresses derived from the collection's slug — the entry\n * pages, the listing and its pages, each category listing — most important\n * first, because the tenant's path cap keeps the FIRST paths it is handed.\n */\n paths: string[]\n /** Screens that render the collection somewhere else, by id. */\n screenIds: string[]\n /** The placement scan read fewer documents than the site holds. */\n truncated: boolean\n}\n\n/**\n * What an entry change makes stale, for one content collection.\n *\n * A content entry is not published through a version pointer, so nothing here\n * looks like the publish paths above: the write is a client Firestore write,\n * and the page that renders it is reached by ADDRESS rather than by a screen\n * document. `/blog`, `/blog/page/2`, `/blog/category/guides` and\n * `/blog/my-post` are served by the catch-all's collection fallback, which may\n * have no screen of its own at all, so a routing-map lookup finds nothing to\n * drop and the site keeps serving the old post.\n *\n * Two halves, therefore, and both are needed:\n *\n * - the collection's own ADDRESSES, derived here from its slug;\n * - the SCREENS that render the collection somewhere else — a rail on the\n * home page, category pills in a layout — which are found by searching node\n * trees, exactly as a form's placements are.\n *\n * Refuses anything that is not a CONTENT collection. Commerce shares\n * `hosts/{hostId}/collections`, and a product collection's pages are routed by\n * the store's templates rather than by these shapes, so building content\n * addresses from one would drop paths that belong to nothing.\n */\nexport async function collectionLivePageScope(options: {\n firestore: Firestore\n hostId: string\n collectionId: string\n entrySlugs: readonly string[]\n}): Promise<CollectionLivePageScope> {\n const { firestore, hostId, collectionId, entrySlugs } = options\n const empty: CollectionLivePageScope = {\n paths: [],\n screenIds: [],\n truncated: false,\n }\n const collectionSnapshot = await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('collections')\n .doc(collectionId)\n .get()\n if (!collectionSnapshot.exists) return empty\n const data = collectionSnapshot.data() ?? {}\n if (hostCollectionKind(data) !== 'content') return empty\n if (data['deletedAt']) return empty\n const collectionSlug = String(data['slug'] ?? '').trim()\n if (!collectionSlug || collectionSlug.includes('/')) return empty\n\n /**\n * Ordered by how much each address matters, because the tenant's path cap\n * takes the FIRST `MAX_PATHS` it is handed. A site whose collection is\n * rendered on more pages than the cap admits therefore loses its deepest\n * category listings rather than the post that was just edited.\n */\n const paths: string[] = []\n const add = (path: string) => {\n if (path && !paths.includes(path)) paths.push(path)\n }\n\n // The entry's own address first — the one page whose author is watching.\n // Both slugs when a save renamed it: the new address has never been\n // rendered, and the OLD one is a cached page that now belongs to nothing.\n for (const entrySlug of entrySlugs) add(`/${collectionSlug}/${entrySlug}`)\n add(collectionListUrl({ collectionSlug }))\n\n /**\n * Every page of the unfiltered listing that the CACHED read covers.\n *\n * A constant range rather than a count-derived one: dropping a page that\n * does not exist is a cache-key delete against a key nothing holds, which\n * costs nothing, while asking Firestore for the exact page count would trade\n * that for a read on every save and still be wrong the moment publishing an\n * entry adds a page.\n *\n * It used to be the whole range, because `COLLECTION_SOURCE_MAX` bounded the\n * LIVE SET and not merely one read of it — a listing had no eleventh page to\n * miss. AGL-3213 ended that: the constant still bounds the cached head every\n * listing address shares, but the collection now runs past it, and a page\n * that starts beyond the head is served by its own window read at its own\n * address.\n *\n * The range deliberately did not follow it there. `MAX_PATHS` takes the\n * FIRST paths it is handed, so extending this to a collection's real page\n * count would spend the budget on pages almost nobody opens and drop the\n * dependent screens below instead — and those deep pages are exactly the\n * ones whose entries did not change. They catch up on their own ISR window,\n * which is the argument the category listings below already make for\n * themselves.\n *\n * Since AGL-3219 there is nothing for them to catch up ON. A deep page is\n * addressed by the entry it continues from, so publishing at the head of\n * the collection does not change which entries it holds — a stale cursor\n * page is merely old, never wrong. It was POSITIONAL addressing that made\n * this lag visible: the head refreshed here, the tail did not, and the two\n * then disagreed by exactly the one entry that had been inserted above\n * them both.\n */\n const listPages = Math.ceil(COLLECTION_SOURCE_MAX / COLLECTION_LIST_PAGE_SIZE)\n for (let page = 2; page <= listPages; page += 1) {\n add(collectionListUrl({ collectionSlug, page }))\n }\n\n /**\n * Page one of every category listing.\n *\n * Every category rather than the changed entry's, because an entry can move\n * between two in one save and a delete leaves no entry to ask — so the set\n * that is certainly right is the collection's own, and it is bounded by\n * `COLLECTION_CATEGORIES_MAX`. Page one only: the same range applied to each\n * category is `COLLECTION_CATEGORIES_MAX` times as many paths as the\n * unfiltered listing, which would push the cap over on the categories alone\n * and take the dependent screens with it. Deeper category pages catch up on\n * their own ISR window.\n */\n const categories = Array.isArray(data['categories']) ? data['categories'] : []\n for (const category of categories.slice(0, COLLECTION_CATEGORIES_MAX)) {\n const name = String((category as { name?: unknown })?.name ?? '').trim()\n if (!name) continue\n const categorySlug = collectionCategorySlug(name)\n if (!categorySlug) continue\n add(collectionListUrl({ collectionSlug, categorySlug }))\n }\n\n const sources = await readUsageSources(\n firestore.collection('hosts').doc(hostId),\n COLLECTION_SCAN_LIMIT,\n )\n return {\n paths,\n screenIds: screenIdsUsingCollectionDeep(collectionSlug, sources.candidates),\n truncated: sources.truncated,\n }\n}\n\n/**\n * The same scope as ONE site's drop target: the collection's addresses with\n * the dependent screens resolved through the routing map, in the shape a\n * `LivePageDropper` takes.\n *\n * `null` when there is nothing to drop — a site with no subdomain has no\n * tenant deployment holding pages, and a collection that is gone or is the\n * store's has no content addresses.\n */\nexport async function collectionLivePageTarget(options: {\n firestore: Firestore\n hostId: string\n collectionId: string\n entrySlugs: readonly string[]\n}): Promise<LivePageTarget | null> {\n const { firestore, hostId } = options\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) return null\n const subdomain = String(hostSnapshot.get('subdomain') ?? '')\n if (!subdomain) return null\n\n const scope = await collectionLivePageScope(options)\n const screens = (hostSnapshot.get('screens') ?? {}) as Record<string, string>\n // The derived addresses lead, as they do on the console route: they are the\n // pages the change is about, and the cap keeps what it is handed first.\n const paths = [\n ...scope.paths,\n ...scope.screenIds\n .map((screenId) => screens[screenId])\n .filter((path): path is string => Boolean(path))\n .map((path) => screenRoutePathToUrl(path)),\n ].filter((path, index, all) => all.indexOf(path) === index)\n if (!paths.length) return null\n\n const cname = String(hostSnapshot.get('cname') ?? '')\n return {\n hostId,\n subdomain,\n ...(cname ? { cname } : {}),\n paths,\n truncated: scope.truncated,\n }\n}\n"],"names":["COLLECTION_CATEGORIES_MAX","COLLECTION_LIST_PAGE_SIZE","COLLECTION_SOURCE_MAX","collectionCategorySlug","collectionListUrl","hostCollectionKind","screenRoutePathToUrl","readUsageSources","screenIdsUsingCollectionDeep","COLLECTION_SCAN_LIMIT","collectionLivePageScope","options","collectionSnapshot","data","firestore","hostId","collectionId","entrySlugs","empty","paths","screenIds","truncated","collection","doc","get","exists","collectionSlug","String","trim","includes","add","path","push","entrySlug","listPages","Math","ceil","page","categories","Array","isArray","category","slice","name","categorySlug","sources","candidates","collectionLivePageTarget","hostSnapshot","subdomain","scope","screens","map","screenId","filter","Boolean","index","all","indexOf","length","cname"],"mappings":"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"}