@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.
Files changed (99) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-v1-description.d.ts +112 -0
  3. package/src/lib/server/api-v1-description.js +126 -0
  4. package/src/lib/server/api-v1-description.js.map +1 -0
  5. package/src/lib/server/api-v1-kit.d.ts +156 -0
  6. package/src/lib/server/api-v1-kit.js +191 -0
  7. package/src/lib/server/api-v1-kit.js.map +1 -0
  8. package/src/lib/server/api-v1-resources.d.ts +144 -0
  9. package/src/lib/server/api-v1-resources.js +126 -0
  10. package/src/lib/server/api-v1-resources.js.map +1 -0
  11. package/src/lib/server/consent-group-change.d.ts +2 -1
  12. package/src/lib/server/consent-group-change.js +41 -16
  13. package/src/lib/server/consent-group-change.js.map +1 -1
  14. package/src/lib/server/contact-email-index.d.ts +22 -0
  15. package/src/lib/server/contact-email-index.js +108 -0
  16. package/src/lib/server/contact-email-index.js.map +1 -1
  17. package/src/lib/server/crm-email-activity.d.ts +1 -1
  18. package/src/lib/server/crm-email-activity.js +1 -1
  19. package/src/lib/server/crm-email-activity.js.map +1 -1
  20. package/src/lib/server/crm-inbound-email.d.ts +23 -0
  21. package/src/lib/server/crm-inbound-email.js +1 -16
  22. package/src/lib/server/crm-inbound-email.js.map +1 -1
  23. package/src/lib/server/crm-records.d.ts +9 -1
  24. package/src/lib/server/crm-records.js +19 -2
  25. package/src/lib/server/crm-records.js.map +1 -1
  26. package/src/lib/server/cron-auth.d.ts +44 -0
  27. package/src/lib/server/cron-auth.js +76 -0
  28. package/src/lib/server/cron-auth.js.map +1 -0
  29. package/src/lib/server/dataset-live-pages.js +6 -6
  30. package/src/lib/server/dataset-live-pages.js.map +1 -1
  31. package/src/lib/server/duplicate-resource.d.ts +26 -0
  32. package/src/lib/server/duplicate-resource.js +47 -25
  33. package/src/lib/server/duplicate-resource.js.map +1 -1
  34. package/src/lib/server/dynamic-list-materialize.js +4 -1
  35. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  36. package/src/lib/server/email-suppression.d.ts +11 -0
  37. package/src/lib/server/email-suppression.js +36 -1
  38. package/src/lib/server/email-suppression.js.map +1 -1
  39. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  40. package/src/lib/server/email-unsubscribe-link.js +7 -7
  41. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  42. package/src/lib/server/erase.d.ts +19 -33
  43. package/src/lib/server/erase.js +37 -162
  44. package/src/lib/server/erase.js.map +1 -1
  45. package/src/lib/server/host-email-tokens.js +1 -1
  46. package/src/lib/server/host-email-tokens.js.map +1 -1
  47. package/src/lib/server/host-memberships.js +3 -3
  48. package/src/lib/server/host-memberships.js.map +1 -1
  49. package/src/lib/server/member-email-aliases.d.ts +1 -1
  50. package/src/lib/server/member-email-aliases.js +1 -1
  51. package/src/lib/server/member-email-aliases.js.map +1 -1
  52. package/src/lib/server/new-account-announcement.d.ts +62 -0
  53. package/src/lib/server/new-account-announcement.js +77 -0
  54. package/src/lib/server/new-account-announcement.js.map +1 -0
  55. package/src/lib/server/notifications.d.ts +8 -2
  56. package/src/lib/server/notifications.js +11 -3
  57. package/src/lib/server/notifications.js.map +1 -1
  58. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  59. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +1 -1
  60. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  61. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  62. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  63. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  64. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  65. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  66. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  67. package/src/lib/server/payment-provider.d.ts +173 -0
  68. package/src/lib/server/payment-provider.js +70 -0
  69. package/src/lib/server/payment-provider.js.map +1 -0
  70. package/src/lib/server/personal-data-export.js +1 -1
  71. package/src/lib/server/personal-data-export.js.map +1 -1
  72. package/src/lib/server/rate-limit-store.js +13 -3
  73. package/src/lib/server/rate-limit-store.js.map +1 -1
  74. package/src/lib/server/realm-plugins.js +8 -3
  75. package/src/lib/server/realm-plugins.js.map +1 -1
  76. package/src/lib/server/retained-refusals.d.ts +142 -0
  77. package/src/lib/server/retained-refusals.js +325 -0
  78. package/src/lib/server/retained-refusals.js.map +1 -0
  79. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  80. package/src/lib/server/test-firestore-queries.js +70 -27
  81. package/src/lib/server/test-firestore-queries.js.map +1 -1
  82. package/src/lib/server/upsert-contact.d.ts +46 -1
  83. package/src/lib/server/upsert-contact.js +85 -18
  84. package/src/lib/server/upsert-contact.js.map +1 -1
  85. package/src/tenancy.d.ts +4 -5
  86. package/src/tenancy.js +11 -6
  87. package/src/tenancy.js.map +1 -1
  88. package/src/lib/server/connect-account-status.js.map +0 -1
  89. package/src/lib/server/connect-payout-failure.js.map +0 -1
  90. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  91. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  92. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  93. package/src/lib/server/contact-merge.d.ts +0 -55
  94. package/src/lib/server/contact-merge.js +0 -288
  95. package/src/lib/server/contact-merge.js.map +0 -1
  96. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  97. package/src/lib/server/form-dataset-binding-token.js +0 -107
  98. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  99. 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":"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"}
@@ -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`), then every participant's carry phase.
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`), then every participant's carry phase.
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
- counts: _extends({}, job.counts),
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)=>CONSENT_GROUP_CARRY_STORES.map((store)=>({
514
- kind: 'carry',
515
- key: `c${carryIndex}:${store}`,
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 _job_counts_unit_store;
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
- [unit.store]: ((_job_counts_unit_store = job.counts[unit.store]) != null ? _job_counts_unit_store : 0) + page.written
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
- const copied = job.counts.siteSuppressions + job.counts.topicOptOuts + job.counts.paces;
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'}`