@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.219
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +156 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +144 -0
- package/src/lib/server/api-v1-resources.js +126 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +41 -16
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.d.ts +23 -0
- package/src/lib/server/crm-inbound-email.js +1 -16
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +4 -1
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +36 -1
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +1 -1
- package/src/lib/server/member-email-aliases.js +1 -1
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +8 -2
- package/src/lib/server/notifications.js +11 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +85 -18
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +4 -5
- package/src/tenancy.js +11 -6
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-kit.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * What every `/v1` resource handler shares (AGL-2606): the context the\n * pipeline hands it, the scope check, the cursor, serialization and\n * idempotency helpers, and the shape a usage figure is published in.\n *\n * One copy for every resource, whoever serves it. A second copy of the\n * cursor grammar or the idempotency claim in any handler is exactly the drift\n * these exist to prevent: a cursor one resource encodes and another cannot\n * decode, or a claim digest that collides across resources because one copy\n * forgot the org. They are the data layer's because a resource a plugin\n * serves (`api-v1-resources.ts`) is a handler too, and a plugin may not\n * import the console.\n */\nimport { type AttemptClaim, claimAttempt } from '@aglyn/aglyn/server'\nimport type { AglynOrganization } from '@aglyn/aglyn'\nimport { FieldPath, Timestamp } from 'firebase-admin/firestore'\nimport {\n apiJson,\n ApiErrors,\n decodeCursor,\n encodeCursor,\n parseLimit,\n} from './api-http'\nimport type { ApiScope } from './api-keys'\n\n// ── The context ─────────────────────────────────────────────────────────────\n\n/** What the pipeline resolved for one authenticated request. */\nexport interface ApiV1Context {\n orgId: string\n keyId: string\n /**\n * The key's name, as the organization labeled it, or null for a key with\n * none. What a write made with the key is attributed to in the console's\n * activity feeds (AGL-2632) — the key is the actor, and \"API key Zapier\"\n * is its name the way an address is a person's.\n */\n keyName: string | null\n scopes: ApiScope[]\n org: Partial<AglynOrganization>\n firestore: FirebaseFirestore.Firestore\n /** Rate-limit headers to echo on every response for this request. */\n headers: Record<string, string>\n}\n\n/** Return a 403 unless the key carries `scope`; otherwise `null` (proceed). */\nexport function requireScope(\n context: ApiV1Context,\n scope: ApiScope,\n): Response | null {\n return context.scopes.includes(scope)\n ? null\n : ApiErrors.insufficientScope(scope, context.headers)\n}\n\n// ── Serialization ───────────────────────────────────────────────────────────\n\n/** Firestore values → JSON-safe values (Timestamps become ISO strings). */\nexport function serialize(value: unknown): unknown {\n if (value instanceof Timestamp) return value.toDate().toISOString()\n if (Array.isArray(value)) return value.map(serialize)\n if (value && typeof value === 'object') {\n const out: Record<string, unknown> = {}\n for (const [k, v] of Object.entries(value)) out[k] = serialize(v)\n return out\n }\n return value\n}\n\n// ── Cursor pagination over a Firestore collection ───────────────────────────\n\nexport interface Paginated {\n docs: FirebaseFirestore.QueryDocumentSnapshot[]\n nextCursor: string | null\n}\n\nexport async function paginate(\n query: FirebaseFirestore.Query,\n url: URL,\n): Promise<Paginated> {\n const limit = parseLimit(url.searchParams.get('limit'))\n const cursor = decodeCursor(url.searchParams.get('cursor'))\n let q = query.orderBy(FieldPath.documentId()).limit(limit + 1)\n if (cursor) q = q.startAfter(cursor)\n const snap = await q.get()\n const docs = snap.docs.slice(0, limit)\n const nextCursor =\n snap.docs.length > limit && docs.length > 0\n ? encodeCursor(docs[docs.length - 1].id)\n : null\n return { docs, nextCursor }\n}\n\nexport async function readJsonBody(request: Request): Promise<Record<string, unknown>> {\n try {\n const body = await request.json()\n return body && typeof body === 'object' ? body : {}\n } catch {\n return {}\n }\n}\n\n// ── Idempotency ─────────────────────────────────────────────────────────────\n\n/**\n * The operations a claim can belong to. Hashed into the digest, so a key\n * reused across two of them is two separate attempts — see `claimWrite`.\n *\n * The CRM pairs (AGL-2606) follow the contact pair's shape: a create and a\n * delete per resource, sharing the organization as their scope suffix and\n * kept apart by the kind alone.\n */\nexport type ClaimKind =\n | 'records'\n | 'record-deletes'\n | 'datasets'\n | 'dataset-deletes'\n | 'form-submission-deletes'\n | 'contacts'\n | 'contact-deletes'\n // A merge destroys the source contact (AGL-2625), so it claims like a\n // delete and records the survivor's view as its receipt.\n | 'contact-merges'\n | 'media'\n | 'sites'\n | 'companies'\n | 'company-deletes'\n | 'deals'\n | 'deal-deletes'\n | 'tasks'\n | 'task-deletes'\n | 'activities'\n | 'activity-deletes'\n | 'lead-conversions'\n // A lead created over the API (AGL-3231) — the door updates an existing\n // address, so the receipt is the lead's view either way.\n | 'lead-creates'\n | 'email-templates'\n | 'email-template-deletes'\n\n/**\n * Take an exclusive claim on one write attempt (AGL-1709), and translate the\n * transport-agnostic result into a v1 `Response`.\n *\n * The claim itself is the shared `claimAttempt` (AGL-1697, `220217133`) — an\n * unconditional `create()`, where Firestore's rejection on an existing document\n * IS the dedupe primitive. This resource used to run its own read-then-create,\n * which is the exact race the mechanism exists to prevent: two concurrent\n * requests carrying one key both read \"no prior key\", both fall through, both\n * create. Worse than no check at all in one respect, because it made the path\n * look protected. Adopting the shared claim rather than fixing the local copy\n * is the point — POS, refunds and the REST API now answer this question once.\n *\n * `scopeId` carries the ORG as well as the dataset. The helper hashes\n * `{kind}:{scopeId}:{key}` and its plugin callers pass a globally unique host\n * id, but a dataset id is only meaningful under its org — and the claim stores\n * the response body, so a cross-org digest collision would replay one tenant's\n * record to another. The org belongs in the digest, not just in the swept\n * field. Scoping by the DATASET is what `apps/docs/api/conventions.md` already\n * publishes (\"replay is looked up within the dataset you're posting to\"); the\n * old org-only digest only reached that outcome by accident, by reading a\n * `recordId` that did not exist in the other dataset and falling through — and\n * having fallen through, its key write failed and was swallowed, so the key\n * never deduped there at all.\n *\n * NOTE: the digest changes, so keys stored under the old `orgId:key` shape are\n * orphaned and a retry straddling this deploy creates a duplicate. Accepted —\n * the API is pre-beta and the alternative is carrying the race forever.\n *\n * `kind` separates the create's claims from the delete's (AGL-1710). The\n * helper hashes it into the digest for exactly this reason, and the two record\n * different response BODIES — a record view versus a `{ deleted: true }`\n * receipt. Sharing one namespace would let a key reused across both operations\n * replay the create's record to a delete, which a client parses as a success\n * and which no amount of documentation makes safe. `POST` keeps `records`\n * verbatim: changing its digest orphans keys in flight, and AGL-1709 already\n * paid that cost once.\n *\n * `scopeSuffix` is the object the key is scoped WITHIN — a dataset id for the\n * record operations and for `DELETE /v1/datasets/{id}`, the site for\n * `DELETE …/form-submissions/{id}`, and `*` — the ORGANIZATION — for\n * `POST /v1/datasets` (AGL-2126), where there is no dataset id yet because\n * the request is what creates one, and for both contact writes (AGL-2276),\n * where the organization genuinely IS the containing object: contacts are\n * org-scoped (AGL-237), so there is no site or parent collection to scope to\n * and pretending otherwise would invent a boundary the data does not have.\n * `*` cannot collide with a resource id: `createResourceUid()` never emits\n * one, and the `kind` is separate anyway. The suffix is not the whole story —\n * `kind` is hashed in too, which is what keeps a create's key from replaying\n * into a delete, and what keeps `contacts` and `contact-deletes` apart while\n * they share a suffix.\n *\n * This paragraph used to say `*` covered \"the two that act on the dataset\n * collection itself\", which the delete has never done — it passes\n * `datasetRef.id`. The published contract in `apps/docs/api/conventions.md` was\n * written from this comment and inherited the error (AGL-2218), which is the\n * argument for reading the call rather than the docblock above it.\n */\nexport async function claimWrite(\n ctx: ApiV1Context,\n scopeSuffix: string,\n key: string | null,\n kind: ClaimKind,\n): Promise<{ claim: AttemptClaim } | { replay: Response }> {\n const result = await claimAttempt(ctx.firestore, {\n kind,\n scopeId: `${ctx.orgId}:${scopeSuffix}`,\n // The field `eraseOrgIdempotencyKeys` sweeps on (AGL-1448).\n orgId: ctx.orgId,\n key: key ?? '',\n busyMessage: 'A request with this Idempotency-Key is still in progress',\n })\n if ('claim' in result) return result\n // The helper is transport-agnostic and answers `{ status, body }`; its\n // plugin callers hand that straight to a pages-API `res.json()`. v1 publishes\n // a `{ error: { type, message, code } }` envelope that clients branch on, so\n // the refusal is rebuilt here rather than leaking the helper's bare\n // `{ error: '<sentence>' }` onto a documented surface.\n if (result.replay.status === 409) {\n return {\n replay: ApiErrors.conflict({\n message: 'A request with this Idempotency-Key is still in progress',\n code: 'idempotency_in_progress',\n headers: ctx.headers,\n }),\n }\n }\n // Settled: replay the ORIGINAL response, from the stored body rather than a\n // fresh read — which is what makes it survive a record since edited or\n // deleted. The old lookup replayed only while the record still existed and\n // fell through to a SECOND create otherwise.\n return {\n replay: apiJson(result.replay.body, {\n status: result.replay.status,\n headers: ctx.headers,\n }),\n }\n}\n\n// ── Usage ───────────────────────────────────────────────────────────────────\n\n/**\n * One metered dimension, as published (AGL-2277).\n *\n * `included`/`remaining` are `null` for an UNLIMITED band rather than the\n * sentinel itself: `UNLIMITED` is `Number.POSITIVE_INFINITY`, which\n * `JSON.stringify` silently turns into `null` anyway — so the choice is\n * between a `null` that means \"unlimited\" on purpose and the same `null`\n * arriving by accident, indistinguishable from a bug. Stated explicitly, and\n * documented, so an integrator can branch on it.\n *\n * `metered` is the field that actually answers \"what happens when I cross\n * this\" — true means the excess bills, false means the next call is refused.\n * It is read off the plan's own overage rate through the `check*Quota`\n * helpers rather than re-derived here, because a second copy of that rule\n * would drift from the one the enforcement path uses, and this endpoint's\n * whole value is telling a customer what the enforcement path will do.\n */\nexport function usageBand(\n used: number,\n included: number,\n remaining: number,\n overageRateUsd: number | null,\n) {\n const unlimited = !Number.isFinite(included)\n return {\n used,\n included: unlimited ? null : included,\n remaining: unlimited ? null : remaining,\n metered: overageRateUsd !== null,\n }\n}\n\n// ── Ownership ───────────────────────────────────────────────────────────────\n\n/** Whether `hostId` is one of the authenticated organization's own sites. */\nexport function orgOwnsHost(ctx: ApiV1Context, hostId: string): boolean {\n const hosts = (ctx.org.hosts ?? {}) as Record<string, unknown>\n return Boolean(hosts[hostId])\n}\n"],"names":["claimAttempt","FieldPath","Timestamp","apiJson","ApiErrors","decodeCursor","encodeCursor","parseLimit","requireScope","context","scope","scopes","includes","insufficientScope","headers","serialize","value","toDate","toISOString","Array","isArray","map","out","k","v","Object","entries","paginate","query","url","limit","searchParams","get","cursor","q","orderBy","documentId","startAfter","snap","docs","slice","nextCursor","length","id","readJsonBody","request","body","json","claimWrite","ctx","scopeSuffix","key","kind","result","firestore","scopeId","orgId","busyMessage","replay","status","conflict","message","code","usageBand","used","included","remaining","overageRateUsd","unlimited","Number","isFinite","metered","orgOwnsHost","hostId","hosts","org","Boolean"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;CAYC,GACD,SAA4BA,YAAY,QAAQ,sBAAqB;AAErE,SAASC,SAAS,EAAEC,SAAS,QAAQ,2BAA0B;AAC/D,SACEC,OAAO,EACPC,SAAS,EACTC,YAAY,EACZC,YAAY,EACZC,UAAU,QACL,gBAAY;AAuBnB,6EAA6E,GAC7E,OAAO,SAASC,aACdC,OAAqB,EACrBC,KAAe;IAEf,OAAOD,QAAQE,MAAM,CAACC,QAAQ,CAACF,SAC3B,OACAN,UAAUS,iBAAiB,CAACH,OAAOD,QAAQK,OAAO;AACxD;AAEA,+EAA+E;AAE/E,yEAAyE,GACzE,OAAO,SAASC,UAAUC,KAAc;IACtC,IAAIA,iBAAiBd,WAAW,OAAOc,MAAMC,MAAM,GAAGC,WAAW;IACjE,IAAIC,MAAMC,OAAO,CAACJ,QAAQ,OAAOA,MAAMK,GAAG,CAACN;IAC3C,IAAIC,SAAS,OAAOA,UAAU,UAAU;QACtC,MAAMM,MAA+B,CAAC;QACtC,KAAK,MAAM,CAACC,GAAGC,EAAE,IAAIC,OAAOC,OAAO,CAACV,OAAQM,GAAG,CAACC,EAAE,GAAGR,UAAUS;QAC/D,OAAOF;IACT;IACA,OAAON;AACT;AASA,OAAO,eAAeW,SACpBC,KAA8B,EAC9BC,GAAQ;IAER,MAAMC,QAAQvB,WAAWsB,IAAIE,YAAY,CAACC,GAAG,CAAC;IAC9C,MAAMC,SAAS5B,aAAawB,IAAIE,YAAY,CAACC,GAAG,CAAC;IACjD,IAAIE,IAAIN,MAAMO,OAAO,CAAClC,UAAUmC,UAAU,IAAIN,KAAK,CAACA,QAAQ;IAC5D,IAAIG,QAAQC,IAAIA,EAAEG,UAAU,CAACJ;IAC7B,MAAMK,OAAO,MAAMJ,EAAEF,GAAG;IACxB,MAAMO,OAAOD,KAAKC,IAAI,CAACC,KAAK,CAAC,GAAGV;IAChC,MAAMW,aACJH,KAAKC,IAAI,CAACG,MAAM,GAAGZ,SAASS,KAAKG,MAAM,GAAG,IACtCpC,aAAaiC,IAAI,CAACA,KAAKG,MAAM,GAAG,EAAE,CAACC,EAAE,IACrC;IACN,OAAO;QAAEJ;QAAME;IAAW;AAC5B;AAEA,OAAO,eAAeG,aAAaC,OAAgB;IACjD,IAAI;QACF,MAAMC,OAAO,MAAMD,QAAQE,IAAI;QAC/B,OAAOD,QAAQ,OAAOA,SAAS,WAAWA,OAAO,CAAC;IACpD,EAAE,eAAM;QACN,OAAO,CAAC;IACV;AACF;AAwCA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyDC,GACD,OAAO,eAAeE,WACpBC,GAAiB,EACjBC,WAAmB,EACnBC,GAAkB,EAClBC,IAAe;IAEf,MAAMC,SAAS,MAAMrD,aAAaiD,IAAIK,SAAS,EAAE;QAC/CF;QACAG,SAAS,GAAGN,IAAIO,KAAK,CAAC,CAAC,EAAEN,aAAa;QACtC,4DAA4D;QAC5DM,OAAOP,IAAIO,KAAK;QAChBL,GAAG,EAAEA,cAAAA,MAAO;QACZM,aAAa;IACf;IACA,IAAI,WAAWJ,QAAQ,OAAOA;IAC9B,uEAAuE;IACvE,8EAA8E;IAC9E,6EAA6E;IAC7E,oEAAoE;IACpE,uDAAuD;IACvD,IAAIA,OAAOK,MAAM,CAACC,MAAM,KAAK,KAAK;QAChC,OAAO;YACLD,QAAQtD,UAAUwD,QAAQ,CAAC;gBACzBC,SAAS;gBACTC,MAAM;gBACNhD,SAASmC,IAAInC,OAAO;YACtB;QACF;IACF;IACA,4EAA4E;IAC5E,uEAAuE;IACvE,2EAA2E;IAC3E,6CAA6C;IAC7C,OAAO;QACL4C,QAAQvD,QAAQkD,OAAOK,MAAM,CAACZ,IAAI,EAAE;YAClCa,QAAQN,OAAOK,MAAM,CAACC,MAAM;YAC5B7C,SAASmC,IAAInC,OAAO;QACtB;IACF;AACF;AAEA,+EAA+E;AAE/E;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASiD,UACdC,IAAY,EACZC,QAAgB,EAChBC,SAAiB,EACjBC,cAA6B;IAE7B,MAAMC,YAAY,CAACC,OAAOC,QAAQ,CAACL;IACnC,OAAO;QACLD;QACAC,UAAUG,YAAY,OAAOH;QAC7BC,WAAWE,YAAY,OAAOF;QAC9BK,SAASJ,mBAAmB;IAC9B;AACF;AAEA,+EAA+E;AAE/E,2EAA2E,GAC3E,OAAO,SAASK,YAAYvB,GAAiB,EAAEwB,MAAc;QAC5CxB;IAAf,MAAMyB,SAASzB,iBAAAA,IAAI0B,GAAG,CAACD,KAAK,YAAbzB,iBAAiB,CAAC;IACjC,OAAO2B,QAAQF,KAAK,CAACD,OAAO;AAC9B"}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { ApiV1ResourceDescription } from './api-v1-description';
|
|
18
|
+
import type { ApiV1Context } from './api-v1-kit';
|
|
19
|
+
/**
|
|
20
|
+
* A plugin's resources on the customer REST API, `/v1/<resource>/…`
|
|
21
|
+
* (AGL-3080).
|
|
22
|
+
*
|
|
23
|
+
* The console's `/v1` router owns the pipeline — the API key, the
|
|
24
|
+
* `apiAccess` entitlement, the request quota, the rate limit, the error
|
|
25
|
+
* envelope — and the platform's own resources (datasets, sites, the media
|
|
26
|
+
* library). A resource a plugin models is the plugin's: it registers a
|
|
27
|
+
* handler under the resource's first path segment, and the router hands it
|
|
28
|
+
* every request under that segment once the key is authenticated, with the
|
|
29
|
+
* context the pipeline resolved. The handler asks for its own scopes
|
|
30
|
+
* (`requireScope`) and answers in the published envelope.
|
|
31
|
+
*
|
|
32
|
+
* ## The published contract does not move
|
|
33
|
+
*
|
|
34
|
+
* A path, a field, a scope and a status are the same whichever module
|
|
35
|
+
* answers them. The OpenAPI document is still the router's to build, and a
|
|
36
|
+
* registration hands it the resource's description (`describe`), so the
|
|
37
|
+
* document — and the MCP tools derived from it — describe every resource the
|
|
38
|
+
* build serves and nothing it does not. Moving a resource into its plugin
|
|
39
|
+
* changes where it is written, never what an integrator sees.
|
|
40
|
+
*
|
|
41
|
+
* ## What the organization's usage reports
|
|
42
|
+
*
|
|
43
|
+
* `GET /v1/usage` is the platform's; a plugin whose records the plan bands
|
|
44
|
+
* or an integration sizes a sync by adds its own figures to it
|
|
45
|
+
* (`registerApiV1UsageFigures`), read with the platform's on every call.
|
|
46
|
+
*
|
|
47
|
+
* ## The plan, before the handler
|
|
48
|
+
*
|
|
49
|
+
* A registration may name the plan feature its resource needs
|
|
50
|
+
* (`entitlement`). The router refuses an organization without it —
|
|
51
|
+
* `402 plan_required` with the feature as the `code` and the registration's
|
|
52
|
+
* own sentence — BEFORE the handler runs and before any scope is asked, so a
|
|
53
|
+
* key minted while the plan carried the feature cannot outlive it.
|
|
54
|
+
*
|
|
55
|
+
* ## Registered at the console's boot
|
|
56
|
+
*
|
|
57
|
+
* From the plugin's console server declarations, with the handler loaded on
|
|
58
|
+
* the first request. The router asks for a resource it does not know once
|
|
59
|
+
* more after running the app's declarations step
|
|
60
|
+
* (`runPluginDeclarationsRepair`), so a process whose boot failed repairs
|
|
61
|
+
* itself rather than answering `404` for a resource it serves. No
|
|
62
|
+
* registration means no plugin in this build serves the resource: the
|
|
63
|
+
* router's `404` for an unknown endpoint is then the truth.
|
|
64
|
+
*
|
|
65
|
+
* ## One owner per resource
|
|
66
|
+
*
|
|
67
|
+
* A second plugin registering a resource another already serves is refused,
|
|
68
|
+
* naming both, and the incumbent keeps serving. The platform's own
|
|
69
|
+
* resources are not registrable.
|
|
70
|
+
*/
|
|
71
|
+
/** Answers one request under the resource, after the pipeline admitted it. */
|
|
72
|
+
export type ApiV1ResourceHandler = (request: Request, context: ApiV1Context,
|
|
73
|
+
/** The path under `/v1`, split — `['contacts', '<id>', 'merge']`. */
|
|
74
|
+
segments: string[], url: URL) => Promise<Response>;
|
|
75
|
+
export interface ApiV1Resource {
|
|
76
|
+
handle: ApiV1ResourceHandler;
|
|
77
|
+
/**
|
|
78
|
+
* The plan feature the resource needs, and the sentence a refusal says —
|
|
79
|
+
* `The CRM … is not included in this organization's plan`.
|
|
80
|
+
*/
|
|
81
|
+
entitlement?: {
|
|
82
|
+
feature: string;
|
|
83
|
+
message: string;
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* The resource as the OpenAPI document describes it, loaded when the
|
|
87
|
+
* document is built. Absent, the resource is served and undocumented.
|
|
88
|
+
*/
|
|
89
|
+
describe?: () => Promise<ApiV1ResourceDescription>;
|
|
90
|
+
}
|
|
91
|
+
export declare const API_V1_RESOURCES: import("@aglyn/aglyn/server").PluginServiceContract<ApiV1Resource>;
|
|
92
|
+
/** The resources the router answers itself, which no plugin may take. */
|
|
93
|
+
export declare const PLATFORM_API_V1_RESOURCES: ReadonlySet<string>;
|
|
94
|
+
/**
|
|
95
|
+
* Serves `/v1/<resource>` from a plugin. The owner is the loader's marker
|
|
96
|
+
* when a register fn is running, else `options.pluginId`; with neither the
|
|
97
|
+
* registration throws, as it does for a name that is not one lowercase path
|
|
98
|
+
* segment, a platform resource, and a resource another plugin serves. The
|
|
99
|
+
* same plugin registering again replaces its own.
|
|
100
|
+
*/
|
|
101
|
+
export declare function registerApiV1Resource(resource: string, registration: ApiV1Resource, options?: {
|
|
102
|
+
pluginId?: string;
|
|
103
|
+
}): void;
|
|
104
|
+
/** Every resource the plugins serve here, in registration order. */
|
|
105
|
+
export declare function apiV1Resources(): Array<{
|
|
106
|
+
name: string;
|
|
107
|
+
pluginId: string;
|
|
108
|
+
resource: ApiV1Resource;
|
|
109
|
+
}>;
|
|
110
|
+
/**
|
|
111
|
+
* The descriptions of every resource the plugins serve, in registration
|
|
112
|
+
* order. A description that fails to load is left out and logged, rather
|
|
113
|
+
* than taking the platform's own description with it.
|
|
114
|
+
*/
|
|
115
|
+
export declare function describeApiV1Resources(): Promise<ApiV1ResourceDescription[]>;
|
|
116
|
+
/** The plugin serving `/v1/<resource>` here, or `null` when none does. */
|
|
117
|
+
export declare function apiV1Resource(resource: string): {
|
|
118
|
+
pluginId: string;
|
|
119
|
+
resource: ApiV1Resource;
|
|
120
|
+
} | null;
|
|
121
|
+
/**
|
|
122
|
+
* A plugin's figures on `GET /v1/usage`, as top-level members of the usage
|
|
123
|
+
* object — a band the plan meters, or a size an integration plans a sync by
|
|
124
|
+
* (`usageBand` in `api-v1-kit.ts` gives either its published shape). Read on
|
|
125
|
+
* every call, with the request's context; a reader that throws fails the
|
|
126
|
+
* request, as a platform figure that cannot be read does.
|
|
127
|
+
*/
|
|
128
|
+
export type ApiV1UsageFigures = (context: ApiV1Context) => Promise<Record<string, unknown>>;
|
|
129
|
+
export declare const API_V1_USAGE_FIGURES: import("@aglyn/aglyn/server").PluginServiceContract<ApiV1UsageFigures>;
|
|
130
|
+
/**
|
|
131
|
+
* Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;
|
|
132
|
+
* registering again replaces it. The owner is the loader's marker when a
|
|
133
|
+
* register fn is running, else `options.pluginId`.
|
|
134
|
+
*/
|
|
135
|
+
export declare function registerApiV1UsageFigures(read: ApiV1UsageFigures, options?: {
|
|
136
|
+
pluginId?: string;
|
|
137
|
+
}): void;
|
|
138
|
+
/**
|
|
139
|
+
* Every plugin's figures for one request, merged in registration order. A
|
|
140
|
+
* member the platform's own figures (`reserved`) or an earlier plugin
|
|
141
|
+
* already names is dropped and logged: a plugin adds figures, never
|
|
142
|
+
* replaces one.
|
|
143
|
+
*/
|
|
144
|
+
export declare function readApiV1UsageFigures(context: ApiV1Context, reserved: ReadonlySet<string>): Promise<Record<string, unknown>>;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { getRegisteringPluginId } from "@aglyn/aglyn/app-utils/registering-plugin";
|
|
17
|
+
import { definePluginServiceContract, registerPluginService, resolvePluginServices } from "@aglyn/aglyn/plugin-manager/plugin-services";
|
|
18
|
+
export const API_V1_RESOURCES = definePluginServiceContract('core.api-v1-resources', {
|
|
19
|
+
multiple: true
|
|
20
|
+
});
|
|
21
|
+
/** The resources the router answers itself, which no plugin may take. */ export const PLATFORM_API_V1_RESOURCES = new Set([
|
|
22
|
+
'datasets',
|
|
23
|
+
'sites',
|
|
24
|
+
'media',
|
|
25
|
+
'usage',
|
|
26
|
+
'me',
|
|
27
|
+
'openapi.json'
|
|
28
|
+
]);
|
|
29
|
+
/** A resource name as a path segment carries it. */ const RESOURCE = /^[a-z][a-z0-9-]*$/;
|
|
30
|
+
/**
|
|
31
|
+
* Serves `/v1/<resource>` from a plugin. The owner is the loader's marker
|
|
32
|
+
* when a register fn is running, else `options.pluginId`; with neither the
|
|
33
|
+
* registration throws, as it does for a name that is not one lowercase path
|
|
34
|
+
* segment, a platform resource, and a resource another plugin serves. The
|
|
35
|
+
* same plugin registering again replaces its own.
|
|
36
|
+
*/ export function registerApiV1Resource(resource, registration, options) {
|
|
37
|
+
var _ref, _getRegisteringPluginId;
|
|
38
|
+
const key = String(resource != null ? resource : '').trim();
|
|
39
|
+
if (!RESOURCE.test(key)) {
|
|
40
|
+
throw new Error(`a /v1 resource needs a lowercase path segment for its name, not "${key}"`);
|
|
41
|
+
}
|
|
42
|
+
if (PLATFORM_API_V1_RESOURCES.has(key)) {
|
|
43
|
+
throw new Error(`/v1/${key} is the platform's own resource`);
|
|
44
|
+
}
|
|
45
|
+
const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref : '').trim();
|
|
46
|
+
if (!pluginId) throw new Error(`the /v1/${key} resource was registered with no owner`);
|
|
47
|
+
const incumbent = resolvePluginServices(API_V1_RESOURCES).find((entry)=>entry.key === key);
|
|
48
|
+
if (incumbent && incumbent.pluginId !== pluginId) {
|
|
49
|
+
throw new Error(`/v1/${key} is already served by "${incumbent.pluginId}"; refused "${pluginId}"`);
|
|
50
|
+
}
|
|
51
|
+
registerPluginService(API_V1_RESOURCES, registration, {
|
|
52
|
+
pluginId,
|
|
53
|
+
key
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
/** Every resource the plugins serve here, in registration order. */ export function apiV1Resources() {
|
|
57
|
+
return resolvePluginServices(API_V1_RESOURCES).map((entry)=>{
|
|
58
|
+
var _entry_key;
|
|
59
|
+
return {
|
|
60
|
+
name: (_entry_key = entry.key) != null ? _entry_key : '',
|
|
61
|
+
pluginId: entry.pluginId,
|
|
62
|
+
resource: entry.impl
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* The descriptions of every resource the plugins serve, in registration
|
|
68
|
+
* order. A description that fails to load is left out and logged, rather
|
|
69
|
+
* than taking the platform's own description with it.
|
|
70
|
+
*/ export async function describeApiV1Resources() {
|
|
71
|
+
const described = await Promise.all(apiV1Resources().map(async ({ name, pluginId, resource })=>{
|
|
72
|
+
if (!resource.describe) return null;
|
|
73
|
+
try {
|
|
74
|
+
return await resource.describe();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
console.error(`[api-v1] ${pluginId} could not describe /v1/${name}`, error);
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}));
|
|
80
|
+
return described.filter((one)=>one !== null);
|
|
81
|
+
}
|
|
82
|
+
/** The plugin serving `/v1/<resource>` here, or `null` when none does. */ export function apiV1Resource(resource) {
|
|
83
|
+
const key = String(resource != null ? resource : '').trim();
|
|
84
|
+
const entry = resolvePluginServices(API_V1_RESOURCES).find((one)=>one.key === key);
|
|
85
|
+
return entry ? {
|
|
86
|
+
pluginId: entry.pluginId,
|
|
87
|
+
resource: entry.impl
|
|
88
|
+
} : null;
|
|
89
|
+
}
|
|
90
|
+
export const API_V1_USAGE_FIGURES = definePluginServiceContract('core.api-v1-usage-figures', {
|
|
91
|
+
multiple: true
|
|
92
|
+
});
|
|
93
|
+
/**
|
|
94
|
+
* Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;
|
|
95
|
+
* registering again replaces it. The owner is the loader's marker when a
|
|
96
|
+
* register fn is running, else `options.pluginId`.
|
|
97
|
+
*/ export function registerApiV1UsageFigures(read, options) {
|
|
98
|
+
var _ref, _getRegisteringPluginId;
|
|
99
|
+
const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref : '').trim();
|
|
100
|
+
if (!pluginId) throw new Error('/v1 usage figures were registered with no owner');
|
|
101
|
+
registerPluginService(API_V1_USAGE_FIGURES, read, {
|
|
102
|
+
pluginId
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Every plugin's figures for one request, merged in registration order. A
|
|
107
|
+
* member the platform's own figures (`reserved`) or an earlier plugin
|
|
108
|
+
* already names is dropped and logged: a plugin adds figures, never
|
|
109
|
+
* replaces one.
|
|
110
|
+
*/ export async function readApiV1UsageFigures(context, reserved) {
|
|
111
|
+
const readers = resolvePluginServices(API_V1_USAGE_FIGURES);
|
|
112
|
+
const figures = await Promise.all(readers.map((entry)=>entry.impl(context)));
|
|
113
|
+
const merged = {};
|
|
114
|
+
figures.forEach((one, index)=>{
|
|
115
|
+
for (const [key, value] of Object.entries(one)){
|
|
116
|
+
if (reserved.has(key) || key in merged) {
|
|
117
|
+
console.error(`[api-v1] ${readers[index].pluginId} named usage figure "${key}", which is taken`);
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
merged[key] = value;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return merged;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
//# sourceMappingURL=api-v1-resources.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/api-v1-resources.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { getRegisteringPluginId } from '@aglyn/aglyn/app-utils/registering-plugin'\nimport {\n definePluginServiceContract,\n registerPluginService,\n resolvePluginServices,\n} from '@aglyn/aglyn/plugin-manager/plugin-services'\nimport type { ApiV1ResourceDescription } from './api-v1-description'\nimport type { ApiV1Context } from './api-v1-kit'\n\n/**\n * A plugin's resources on the customer REST API, `/v1/<resource>/…`\n * (AGL-3080).\n *\n * The console's `/v1` router owns the pipeline — the API key, the\n * `apiAccess` entitlement, the request quota, the rate limit, the error\n * envelope — and the platform's own resources (datasets, sites, the media\n * library). A resource a plugin models is the plugin's: it registers a\n * handler under the resource's first path segment, and the router hands it\n * every request under that segment once the key is authenticated, with the\n * context the pipeline resolved. The handler asks for its own scopes\n * (`requireScope`) and answers in the published envelope.\n *\n * ## The published contract does not move\n *\n * A path, a field, a scope and a status are the same whichever module\n * answers them. The OpenAPI document is still the router's to build, and a\n * registration hands it the resource's description (`describe`), so the\n * document — and the MCP tools derived from it — describe every resource the\n * build serves and nothing it does not. Moving a resource into its plugin\n * changes where it is written, never what an integrator sees.\n *\n * ## What the organization's usage reports\n *\n * `GET /v1/usage` is the platform's; a plugin whose records the plan bands\n * or an integration sizes a sync by adds its own figures to it\n * (`registerApiV1UsageFigures`), read with the platform's on every call.\n *\n * ## The plan, before the handler\n *\n * A registration may name the plan feature its resource needs\n * (`entitlement`). The router refuses an organization without it —\n * `402 plan_required` with the feature as the `code` and the registration's\n * own sentence — BEFORE the handler runs and before any scope is asked, so a\n * key minted while the plan carried the feature cannot outlive it.\n *\n * ## Registered at the console's boot\n *\n * From the plugin's console server declarations, with the handler loaded on\n * the first request. The router asks for a resource it does not know once\n * more after running the app's declarations step\n * (`runPluginDeclarationsRepair`), so a process whose boot failed repairs\n * itself rather than answering `404` for a resource it serves. No\n * registration means no plugin in this build serves the resource: the\n * router's `404` for an unknown endpoint is then the truth.\n *\n * ## One owner per resource\n *\n * A second plugin registering a resource another already serves is refused,\n * naming both, and the incumbent keeps serving. The platform's own\n * resources are not registrable.\n */\n\n/** Answers one request under the resource, after the pipeline admitted it. */\nexport type ApiV1ResourceHandler = (\n request: Request,\n context: ApiV1Context,\n /** The path under `/v1`, split — `['contacts', '<id>', 'merge']`. */\n segments: string[],\n url: URL,\n) => Promise<Response>\n\nexport interface ApiV1Resource {\n handle: ApiV1ResourceHandler\n /**\n * The plan feature the resource needs, and the sentence a refusal says —\n * `The CRM … is not included in this organization's plan`.\n */\n entitlement?: { feature: string; message: string }\n /**\n * The resource as the OpenAPI document describes it, loaded when the\n * document is built. Absent, the resource is served and undocumented.\n */\n describe?: () => Promise<ApiV1ResourceDescription>\n}\n\nexport const API_V1_RESOURCES = definePluginServiceContract<ApiV1Resource>(\n 'core.api-v1-resources',\n { multiple: true },\n)\n\n/** The resources the router answers itself, which no plugin may take. */\nexport const PLATFORM_API_V1_RESOURCES: ReadonlySet<string> = new Set([\n 'datasets',\n 'sites',\n 'media',\n 'usage',\n 'me',\n 'openapi.json',\n])\n\n/** A resource name as a path segment carries it. */\nconst RESOURCE = /^[a-z][a-z0-9-]*$/\n\n/**\n * Serves `/v1/<resource>` from a plugin. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, as it does for a name that is not one lowercase path\n * segment, a platform resource, and a resource another plugin serves. The\n * same plugin registering again replaces its own.\n */\nexport function registerApiV1Resource(\n resource: string,\n registration: ApiV1Resource,\n options?: { pluginId?: string },\n): void {\n const key = String(resource ?? '').trim()\n if (!RESOURCE.test(key)) {\n throw new Error(`a /v1 resource needs a lowercase path segment for its name, not \"${key}\"`)\n }\n if (PLATFORM_API_V1_RESOURCES.has(key)) {\n throw new Error(`/v1/${key} is the platform's own resource`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error(`the /v1/${key} resource was registered with no owner`)\n const incumbent = resolvePluginServices(API_V1_RESOURCES).find((entry) => entry.key === key)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `/v1/${key} is already served by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(API_V1_RESOURCES, registration, { pluginId, key })\n}\n\n/** Every resource the plugins serve here, in registration order. */\nexport function apiV1Resources(): Array<{ name: string; pluginId: string; resource: ApiV1Resource }> {\n return resolvePluginServices(API_V1_RESOURCES).map((entry) => ({\n name: entry.key ?? '',\n pluginId: entry.pluginId,\n resource: entry.impl,\n }))\n}\n\n/**\n * The descriptions of every resource the plugins serve, in registration\n * order. A description that fails to load is left out and logged, rather\n * than taking the platform's own description with it.\n */\nexport async function describeApiV1Resources(): Promise<ApiV1ResourceDescription[]> {\n const described = await Promise.all(\n apiV1Resources().map(async ({ name, pluginId, resource }) => {\n if (!resource.describe) return null\n try {\n return await resource.describe()\n } catch (error) {\n console.error(`[api-v1] ${pluginId} could not describe /v1/${name}`, error)\n return null\n }\n }),\n )\n return described.filter((one): one is ApiV1ResourceDescription => one !== null)\n}\n\n/** The plugin serving `/v1/<resource>` here, or `null` when none does. */\nexport function apiV1Resource(\n resource: string,\n): { pluginId: string; resource: ApiV1Resource } | null {\n const key = String(resource ?? '').trim()\n const entry = resolvePluginServices(API_V1_RESOURCES).find((one) => one.key === key)\n return entry ? { pluginId: entry.pluginId, resource: entry.impl } : null\n}\n\n// ── Usage ──────────────────────────────────────────────────────────────────\n\n/**\n * A plugin's figures on `GET /v1/usage`, as top-level members of the usage\n * object — a band the plan meters, or a size an integration plans a sync by\n * (`usageBand` in `api-v1-kit.ts` gives either its published shape). Read on\n * every call, with the request's context; a reader that throws fails the\n * request, as a platform figure that cannot be read does.\n */\nexport type ApiV1UsageFigures = (context: ApiV1Context) => Promise<Record<string, unknown>>\n\nexport const API_V1_USAGE_FIGURES = definePluginServiceContract<ApiV1UsageFigures>(\n 'core.api-v1-usage-figures',\n { multiple: true },\n)\n\n/**\n * Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;\n * registering again replaces it. The owner is the loader's marker when a\n * register fn is running, else `options.pluginId`.\n */\nexport function registerApiV1UsageFigures(\n read: ApiV1UsageFigures,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('/v1 usage figures were registered with no owner')\n registerPluginService(API_V1_USAGE_FIGURES, read, { pluginId })\n}\n\n/**\n * Every plugin's figures for one request, merged in registration order. A\n * member the platform's own figures (`reserved`) or an earlier plugin\n * already names is dropped and logged: a plugin adds figures, never\n * replaces one.\n */\nexport async function readApiV1UsageFigures(\n context: ApiV1Context,\n reserved: ReadonlySet<string>,\n): Promise<Record<string, unknown>> {\n const readers = resolvePluginServices(API_V1_USAGE_FIGURES)\n const figures = await Promise.all(readers.map((entry) => entry.impl(context)))\n const merged: Record<string, unknown> = {}\n figures.forEach((one, index) => {\n for (const [key, value] of Object.entries(one)) {\n if (reserved.has(key) || key in merged) {\n console.error(`[api-v1] ${readers[index].pluginId} named usage figure \"${key}\", which is taken`)\n continue\n }\n merged[key] = value\n }\n })\n return merged\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","API_V1_RESOURCES","multiple","PLATFORM_API_V1_RESOURCES","Set","RESOURCE","registerApiV1Resource","resource","registration","options","key","String","trim","test","Error","has","pluginId","incumbent","find","entry","apiV1Resources","map","name","impl","describeApiV1Resources","described","Promise","all","describe","error","console","filter","one","apiV1Resource","API_V1_USAGE_FIGURES","registerApiV1UsageFigures","read","readApiV1UsageFigures","context","reserved","readers","figures","merged","forEach","index","value","Object","entries"],"mappings":"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"}
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
* the one the editor rendered, and no other change is in flight. It sets
|
|
28
28
|
* the marker `consentGroupsChange` and creates the job document.
|
|
29
29
|
* 2. CARRY — every carry of the plan through the three platform stores
|
|
30
|
-
* (`consent-group-carry.ts`)
|
|
30
|
+
* (`consent-group-carry.ts`) and the organization's retained refusals
|
|
31
|
+
* (`retained-refusals.ts`), then every participant's carry phase.
|
|
31
32
|
* 3. CATCH UP — everything filed since the carry began, again.
|
|
32
33
|
* 4. DECLARE — one transaction: the marker still names this change in its
|
|
33
34
|
* carry phase and the stored declaration is still the one the job
|
|
@@ -27,7 +27,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
27
27
|
* the one the editor rendered, and no other change is in flight. It sets
|
|
28
28
|
* the marker `consentGroupsChange` and creates the job document.
|
|
29
29
|
* 2. CARRY — every carry of the plan through the three platform stores
|
|
30
|
-
* (`consent-group-carry.ts`)
|
|
30
|
+
* (`consent-group-carry.ts`) and the organization's retained refusals
|
|
31
|
+
* (`retained-refusals.ts`), then every participant's carry phase.
|
|
31
32
|
* 3. CATCH UP — everything filed since the carry began, again.
|
|
32
33
|
* 4. DECLARE — one transaction: the marker still names this change in its
|
|
33
34
|
* carry phase and the stored declaration is still the one the job
|
|
@@ -67,7 +68,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
67
68
|
import { CONSENT_GROUPS_FIELD, consentGroupsAwaitConfirmation, readConsentGroups } from "@aglyn/aglyn/app-utils/consent-groups";
|
|
68
69
|
import { createResourceUid } from "@aglyn/aglyn/app-utils/create-resource-uid";
|
|
69
70
|
import { DEFAULT_EMAIL_TOPICS, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
|
|
70
|
-
import { resolveMarketingConsentPolicy } from "@aglyn/aglyn/app-utils/marketing-consent";
|
|
71
|
+
import { MARKETING_CONSENT_BY_HOST_FIELD, MARKETING_CONSENT_FIELD, resolveMarketingConsentPolicy } from "@aglyn/aglyn/app-utils/marketing-consent";
|
|
71
72
|
import { isOrgWideMember } from "@aglyn/aglyn/app-utils/organizations";
|
|
72
73
|
import { listPluginConsentGroupParticipants, previewPluginConsentGroupChange } from "@aglyn/aglyn/plugin-manager/plugin-consent-group-change";
|
|
73
74
|
import { FieldValue } from "firebase-admin/firestore";
|
|
@@ -75,6 +76,7 @@ import { CONSENT_GROUP_CARRY_STORES, CONSENT_GROUP_CARRY_SUBCOLLECTIONS, carryCo
|
|
|
75
76
|
import firebaseAdmin from "./firebase-admin.js";
|
|
76
77
|
import { getLockdownVerdict } from "./lockdown.js";
|
|
77
78
|
import { logOrgActivity } from "./organizations.js";
|
|
79
|
+
import { carryRetainedRefusals, retainedRefusalsStore } from "./retained-refusals.js";
|
|
78
80
|
/** How long a claimed job stays claimed without a renewal. */ export const CONSENT_GROUP_CHANGE_LEASE_MS = 90000;
|
|
79
81
|
/** Consecutive failed units before a job is shown as stalled. */ export const CONSENT_GROUP_CHANGE_STALL_AFTER = 5;
|
|
80
82
|
/**
|
|
@@ -142,7 +144,10 @@ export function consentGroupChangeStatus(job, nowMs = Date.now()) {
|
|
|
142
144
|
failures: (_job_failures = job.failures) != null ? _job_failures : 0,
|
|
143
145
|
lastError: (_job_lastError = job.lastError) != null ? _job_lastError : null,
|
|
144
146
|
leaseUntilMs: job.lease && job.lease.untilMs > nowMs ? job.lease.untilMs : null,
|
|
145
|
-
|
|
147
|
+
// A job started before the retained store was carried has no count for it.
|
|
148
|
+
counts: _extends({
|
|
149
|
+
retainedRefusals: 0
|
|
150
|
+
}, job.counts),
|
|
146
151
|
sitesReceiving: (_ref = (_job_sitesReceiving = job.sitesReceiving) == null ? void 0 : _job_sitesReceiving.length) != null ? _ref : 0,
|
|
147
152
|
plugins: (_job_plugins = job.plugins) != null ? _job_plugins : {}
|
|
148
153
|
}
|
|
@@ -266,12 +271,14 @@ const describeLines = (lines, names)=>lines.map((line)=>describeConsentGroupChan
|
|
|
266
271
|
const suppressions = (hostId)=>cached(`s:${hostId}`, ()=>store(hostId, 'siteSuppressions'));
|
|
267
272
|
const optOuts = (hostId)=>cached(`t:${hostId}`, ()=>store(hostId, 'topicOptOuts'));
|
|
268
273
|
const paces = (hostId)=>cached(`p:${hostId}`, ()=>store(hostId, 'paces').where('cadence', '!=', null));
|
|
274
|
+
const retained = (hostId)=>cached(`r:${hostId}`, ()=>retainedRefusalsStore(orgDoc(ctx, input.orgId)).where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${MARKETING_CONSENT_FIELD}`, '==', false));
|
|
269
275
|
const carries = await Promise.all(plan.carries.map(async (carry)=>({
|
|
270
276
|
toHostId: carry.toHostId,
|
|
271
277
|
fromHostId: carry.fromHostId,
|
|
272
278
|
siteSuppressions: await suppressions(carry.fromHostId),
|
|
273
279
|
topicOptOuts: await optOuts(carry.fromHostId),
|
|
274
|
-
paces: await paces(carry.fromHostId)
|
|
280
|
+
paces: await paces(carry.fromHostId),
|
|
281
|
+
retainedRefusals: await retained(carry.fromHostId)
|
|
275
282
|
})));
|
|
276
283
|
const membersIn = (declaration, hostId)=>{
|
|
277
284
|
var _ref;
|
|
@@ -358,7 +365,7 @@ const describeLines = (lines, names)=>lines.map((line)=>describeConsentGroupChan
|
|
|
358
365
|
});
|
|
359
366
|
const names = await siteNamesFor(ctx, plannedSites(plan));
|
|
360
367
|
const texts = describeLines(plan.lines, names);
|
|
361
|
-
const documents = carries.reduce((total, carry)=>total + carry.siteSuppressions + carry.topicOptOuts + carry.paces, 0) + participants.reduce((total, entry)=>{
|
|
368
|
+
const documents = carries.reduce((total, carry)=>total + carry.siteSuppressions + carry.topicOptOuts + carry.paces + carry.retainedRefusals, 0) + participants.reduce((total, entry)=>{
|
|
362
369
|
var _entry_lines;
|
|
363
370
|
return total + ((_entry_lines = entry.lines) != null ? _entry_lines : []).reduce((sum, line)=>{
|
|
364
371
|
var _line_count;
|
|
@@ -457,7 +464,8 @@ const describeLines = (lines, names)=>lines.map((line)=>describeConsentGroupChan
|
|
|
457
464
|
counts: {
|
|
458
465
|
siteSuppressions: 0,
|
|
459
466
|
topicOptOuts: 0,
|
|
460
|
-
paces: 0
|
|
467
|
+
paces: 0,
|
|
468
|
+
retainedRefusals: 0
|
|
461
469
|
},
|
|
462
470
|
sitesReceiving: [],
|
|
463
471
|
plugins: {},
|
|
@@ -510,13 +518,21 @@ const describeLines = (lines, names)=>lines.map((line)=>describeConsentGroupChan
|
|
|
510
518
|
};
|
|
511
519
|
}
|
|
512
520
|
/** The current step's units, in the order they run. */ function unitsOf(job) {
|
|
513
|
-
const carries = (sinceMs)=>job.plan.carries.flatMap((_carry, carryIndex)=>
|
|
514
|
-
|
|
515
|
-
|
|
521
|
+
const carries = (sinceMs)=>job.plan.carries.flatMap((_carry, carryIndex)=>[
|
|
522
|
+
...CONSENT_GROUP_CARRY_STORES.map((store)=>({
|
|
523
|
+
kind: 'carry',
|
|
524
|
+
key: `c${carryIndex}:${store}`,
|
|
525
|
+
carryIndex,
|
|
526
|
+
store,
|
|
527
|
+
sinceMs
|
|
528
|
+
})),
|
|
529
|
+
{
|
|
530
|
+
kind: 'retained',
|
|
531
|
+
key: `c${carryIndex}:retainedRefusals`,
|
|
516
532
|
carryIndex,
|
|
517
|
-
store,
|
|
518
533
|
sinceMs
|
|
519
|
-
}
|
|
534
|
+
}
|
|
535
|
+
]);
|
|
520
536
|
const participants = (phase)=>job.participants.map((pluginId)=>({
|
|
521
537
|
kind: 'participant',
|
|
522
538
|
key: `p:${pluginId}`,
|
|
@@ -588,19 +604,27 @@ async function runUnit(ctx, jobRef, job, owner, unit, deadlineMs) {
|
|
|
588
604
|
cursor: null,
|
|
589
605
|
done: false
|
|
590
606
|
};
|
|
591
|
-
if (unit.kind === 'carry') {
|
|
592
|
-
var
|
|
607
|
+
if (unit.kind === 'carry' || unit.kind === 'retained') {
|
|
608
|
+
var _job_counts_counted;
|
|
593
609
|
const carry = job.plan.carries[unit.carryIndex];
|
|
594
|
-
const page = await carryConsentGroupRefusals({
|
|
610
|
+
const page = unit.kind === 'carry' ? await carryConsentGroupRefusals({
|
|
595
611
|
firestore: ctx.firestore,
|
|
596
612
|
store: unit.store,
|
|
597
613
|
carry,
|
|
598
614
|
changeId: job.changeId,
|
|
599
615
|
cursor: state.cursor,
|
|
600
616
|
sinceMs: unit.sinceMs
|
|
617
|
+
}) : await carryRetainedRefusals({
|
|
618
|
+
firestore: ctx.firestore,
|
|
619
|
+
orgId: job.orgId,
|
|
620
|
+
carry,
|
|
621
|
+
changeId: job.changeId,
|
|
622
|
+
cursor: state.cursor,
|
|
623
|
+
sinceMs: unit.sinceMs
|
|
601
624
|
});
|
|
625
|
+
const counted = unit.kind === 'carry' ? unit.store : 'retainedRefusals';
|
|
602
626
|
const counts = _extends({}, job.counts, {
|
|
603
|
-
[
|
|
627
|
+
[counted]: ((_job_counts_counted = job.counts[counted]) != null ? _job_counts_counted : 0) + page.written
|
|
604
628
|
});
|
|
605
629
|
const sitesReceiving = page.written > 0 && !job.sitesReceiving.includes(carry.toHostId) ? [
|
|
606
630
|
...job.sitesReceiving,
|
|
@@ -735,7 +759,8 @@ function participantFor(pluginId) {
|
|
|
735
759
|
return finished;
|
|
736
760
|
}
|
|
737
761
|
/** The "Finished" line: the carries' totals, then each participant's clause. */ function finishedLine(job) {
|
|
738
|
-
|
|
762
|
+
var _job_counts_retainedRefusals;
|
|
763
|
+
const copied = job.counts.siteSuppressions + job.counts.topicOptOuts + job.counts.paces + ((_job_counts_retainedRefusals = job.counts.retainedRefusals) != null ? _job_counts_retainedRefusals : 0);
|
|
739
764
|
const sites = job.sitesReceiving.length;
|
|
740
765
|
const clauses = [
|
|
741
766
|
`${copied} ${copied === 1 ? 'opt-out' : 'opt-outs'} copied to ${sites} ${sites === 1 ? 'site' : 'sites'}`
|