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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-description.d.ts +112 -0
  5. package/src/lib/server/api-v1-description.js +126 -0
  6. package/src/lib/server/api-v1-description.js.map +1 -0
  7. package/src/lib/server/api-v1-kit.d.ts +163 -0
  8. package/src/lib/server/api-v1-kit.js +191 -0
  9. package/src/lib/server/api-v1-kit.js.map +1 -0
  10. package/src/lib/server/api-v1-resources.d.ts +143 -0
  11. package/src/lib/server/api-v1-resources.js +125 -0
  12. package/src/lib/server/api-v1-resources.js.map +1 -0
  13. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  14. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  15. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  16. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  17. package/src/lib/server/card-payment-velocity.js +28 -2
  18. package/src/lib/server/card-payment-velocity.js.map +1 -1
  19. package/src/lib/server/consent-group-carry.js +1 -1
  20. package/src/lib/server/consent-group-carry.js.map +1 -1
  21. package/src/lib/server/consent-group-change.d.ts +2 -1
  22. package/src/lib/server/consent-group-change.js +43 -18
  23. package/src/lib/server/consent-group-change.js.map +1 -1
  24. package/src/lib/server/contact-email-index.d.ts +22 -0
  25. package/src/lib/server/contact-email-index.js +108 -0
  26. package/src/lib/server/contact-email-index.js.map +1 -1
  27. package/src/lib/server/crm-email-activity.d.ts +1 -1
  28. package/src/lib/server/crm-email-activity.js +1 -1
  29. package/src/lib/server/crm-email-activity.js.map +1 -1
  30. package/src/lib/server/crm-records.d.ts +9 -1
  31. package/src/lib/server/crm-records.js +19 -2
  32. package/src/lib/server/crm-records.js.map +1 -1
  33. package/src/lib/server/cron-auth.d.ts +44 -0
  34. package/src/lib/server/cron-auth.js +76 -0
  35. package/src/lib/server/cron-auth.js.map +1 -0
  36. package/src/lib/server/dataset-live-pages.js +6 -6
  37. package/src/lib/server/dataset-live-pages.js.map +1 -1
  38. package/src/lib/server/duplicate-resource.d.ts +26 -0
  39. package/src/lib/server/duplicate-resource.js +47 -25
  40. package/src/lib/server/duplicate-resource.js.map +1 -1
  41. package/src/lib/server/dynamic-list-materialize.js +6 -3
  42. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  43. package/src/lib/server/email-flow-gate.js +2 -2
  44. package/src/lib/server/email-flow-gate.js.map +1 -1
  45. package/src/lib/server/email-suppression.d.ts +11 -0
  46. package/src/lib/server/email-suppression.js +37 -2
  47. package/src/lib/server/email-suppression.js.map +1 -1
  48. package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
  49. package/src/lib/server/email-unsubscribe-link.js +7 -7
  50. package/src/lib/server/email-unsubscribe-link.js.map +1 -1
  51. package/src/lib/server/erase.d.ts +19 -33
  52. package/src/lib/server/erase.js +37 -162
  53. package/src/lib/server/erase.js.map +1 -1
  54. package/src/lib/server/host-email-tokens.js +1 -1
  55. package/src/lib/server/host-email-tokens.js.map +1 -1
  56. package/src/lib/server/host-memberships.js +3 -3
  57. package/src/lib/server/host-memberships.js.map +1 -1
  58. package/src/lib/server/host-visitor-records.d.ts +32 -4
  59. package/src/lib/server/host-visitor-records.js +73 -14
  60. package/src/lib/server/host-visitor-records.js.map +1 -1
  61. package/src/lib/server/hosted-page-review.d.ts +27 -0
  62. package/src/lib/server/hosted-page-review.js +149 -12
  63. package/src/lib/server/hosted-page-review.js.map +1 -1
  64. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  65. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  66. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  67. package/src/lib/server/member-email-aliases.d.ts +10 -2
  68. package/src/lib/server/member-email-aliases.js +26 -2
  69. package/src/lib/server/member-email-aliases.js.map +1 -1
  70. package/src/lib/server/new-account-announcement.d.ts +62 -0
  71. package/src/lib/server/new-account-announcement.js +77 -0
  72. package/src/lib/server/new-account-announcement.js.map +1 -0
  73. package/src/lib/server/notifications.d.ts +22 -2
  74. package/src/lib/server/notifications.js +40 -3
  75. package/src/lib/server/notifications.js.map +1 -1
  76. package/src/lib/server/operator-alerts.d.ts +7 -1
  77. package/src/lib/server/operator-alerts.js +5 -3
  78. package/src/lib/server/operator-alerts.js.map +1 -1
  79. package/src/lib/server/org-containers.d.ts +66 -0
  80. package/src/lib/server/org-containers.js +95 -0
  81. package/src/lib/server/org-containers.js.map +1 -0
  82. package/src/lib/server/org-member-notice.js +3 -2
  83. package/src/lib/server/org-member-notice.js.map +1 -1
  84. package/src/lib/server/payment-fraud-signal.js +5 -1
  85. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  86. package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
  87. package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
  88. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
  89. package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
  90. package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
  91. package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
  92. package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
  93. package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
  94. package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
  95. package/src/lib/server/payment-provider.d.ts +173 -0
  96. package/src/lib/server/payment-provider.js +70 -0
  97. package/src/lib/server/payment-provider.js.map +1 -0
  98. package/src/lib/server/payment-risk-record.d.ts +1 -1
  99. package/src/lib/server/payment-risk-record.js +3 -1
  100. package/src/lib/server/payment-risk-record.js.map +1 -1
  101. package/src/lib/server/personal-data-export.js +1 -1
  102. package/src/lib/server/personal-data-export.js.map +1 -1
  103. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  104. package/src/lib/server/platform-marketing-consent.js +1 -1
  105. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  106. package/src/lib/server/rate-limit-store.js +13 -3
  107. package/src/lib/server/rate-limit-store.js.map +1 -1
  108. package/src/lib/server/realm-plugins.js +8 -3
  109. package/src/lib/server/realm-plugins.js.map +1 -1
  110. package/src/lib/server/retained-refusals.d.ts +142 -0
  111. package/src/lib/server/retained-refusals.js +325 -0
  112. package/src/lib/server/retained-refusals.js.map +1 -0
  113. package/src/lib/server/risk-notice.d.ts +32 -1
  114. package/src/lib/server/risk-notice.js +90 -28
  115. package/src/lib/server/risk-notice.js.map +1 -1
  116. package/src/lib/server/sending-domains.d.ts +17 -0
  117. package/src/lib/server/sending-domains.js +28 -4
  118. package/src/lib/server/sending-domains.js.map +1 -1
  119. package/src/lib/server/sso-enforcement.js +7 -2
  120. package/src/lib/server/sso-enforcement.js.map +1 -1
  121. package/src/lib/server/staff-alert-email.js +4 -2
  122. package/src/lib/server/staff-alert-email.js.map +1 -1
  123. package/src/lib/server/test-firestore-queries.d.ts +7 -0
  124. package/src/lib/server/test-firestore-queries.js +70 -27
  125. package/src/lib/server/test-firestore-queries.js.map +1 -1
  126. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  127. package/src/lib/server/topic-subscriptions.js.map +1 -0
  128. package/src/lib/server/upsert-contact.d.ts +46 -1
  129. package/src/lib/server/upsert-contact.js +87 -20
  130. package/src/lib/server/upsert-contact.js.map +1 -1
  131. package/src/tenancy.d.ts +5 -8
  132. package/src/tenancy.js +12 -13
  133. package/src/tenancy.js.map +1 -1
  134. package/src/lib/server/connect-account-status.js.map +0 -1
  135. package/src/lib/server/connect-payout-failure.js.map +0 -1
  136. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  137. package/src/lib/server/contact-email-engagement.js +0 -145
  138. package/src/lib/server/contact-email-engagement.js.map +0 -1
  139. package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
  140. package/src/lib/server/contact-lifecycle-floor.js +0 -91
  141. package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
  142. package/src/lib/server/contact-merge.d.ts +0 -55
  143. package/src/lib/server/contact-merge.js +0 -288
  144. package/src/lib/server/contact-merge.js.map +0 -1
  145. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  146. package/src/lib/server/crm-booking-activity.js +0 -257
  147. package/src/lib/server/crm-booking-activity.js.map +0 -1
  148. package/src/lib/server/crm-inbound-email.d.ts +0 -145
  149. package/src/lib/server/crm-inbound-email.js +0 -297
  150. package/src/lib/server/crm-inbound-email.js.map +0 -1
  151. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  152. package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
  153. package/src/lib/server/form-dataset-binding-token.js +0 -107
  154. package/src/lib/server/form-dataset-binding-token.js.map +0 -1
  155. package/src/lib/server/stripe-account-mode.js.map +0 -1
  156. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
@@ -0,0 +1,191 @@
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
+ * What every `/v1` resource handler shares (AGL-2606): the context the
18
+ * pipeline hands it, the scope check, the cursor, serialization and
19
+ * idempotency helpers, and the shape a usage figure is published in.
20
+ *
21
+ * One copy for every resource, whoever serves it. A second copy of the
22
+ * cursor grammar or the idempotency claim in any handler is exactly the drift
23
+ * these exist to prevent: a cursor one resource encodes and another cannot
24
+ * decode, or a claim digest that collides across resources because one copy
25
+ * forgot the org. They are the data layer's because a resource a plugin
26
+ * serves (`api-v1-resources.ts`) is a handler too, and a plugin may not
27
+ * import the console.
28
+ */ import { claimAttempt } from "@aglyn/aglyn/server";
29
+ import { FieldPath, Timestamp } from "firebase-admin/firestore";
30
+ import { apiJson, ApiErrors, decodeCursor, encodeCursor, parseLimit } from "./api-http.js";
31
+ /** Return a 403 unless the key carries `scope`; otherwise `null` (proceed). */ export function requireScope(context, scope) {
32
+ return context.scopes.includes(scope) ? null : ApiErrors.insufficientScope(scope, context.headers);
33
+ }
34
+ // ── Serialization ───────────────────────────────────────────────────────────
35
+ /** Firestore values → JSON-safe values (Timestamps become ISO strings). */ export function serialize(value) {
36
+ if (value instanceof Timestamp) return value.toDate().toISOString();
37
+ if (Array.isArray(value)) return value.map(serialize);
38
+ if (value && typeof value === 'object') {
39
+ const out = {};
40
+ for (const [k, v] of Object.entries(value))out[k] = serialize(v);
41
+ return out;
42
+ }
43
+ return value;
44
+ }
45
+ export async function paginate(query, url) {
46
+ const limit = parseLimit(url.searchParams.get('limit'));
47
+ const cursor = decodeCursor(url.searchParams.get('cursor'));
48
+ let q = query.orderBy(FieldPath.documentId()).limit(limit + 1);
49
+ if (cursor) q = q.startAfter(cursor);
50
+ const snap = await q.get();
51
+ const docs = snap.docs.slice(0, limit);
52
+ const nextCursor = snap.docs.length > limit && docs.length > 0 ? encodeCursor(docs[docs.length - 1].id) : null;
53
+ return {
54
+ docs,
55
+ nextCursor
56
+ };
57
+ }
58
+ export async function readJsonBody(request) {
59
+ try {
60
+ const body = await request.json();
61
+ return body && typeof body === 'object' ? body : {};
62
+ } catch (unused) {
63
+ return {};
64
+ }
65
+ }
66
+ /**
67
+ * Take an exclusive claim on one write attempt (AGL-1709), and translate the
68
+ * transport-agnostic result into a v1 `Response`.
69
+ *
70
+ * The claim itself is the shared `claimAttempt` (AGL-1697, `220217133`) — an
71
+ * unconditional `create()`, where Firestore's rejection on an existing document
72
+ * IS the dedupe primitive. This resource used to run its own read-then-create,
73
+ * which is the exact race the mechanism exists to prevent: two concurrent
74
+ * requests carrying one key both read "no prior key", both fall through, both
75
+ * create. Worse than no check at all in one respect, because it made the path
76
+ * look protected. Adopting the shared claim rather than fixing the local copy
77
+ * is the point — POS, refunds and the REST API now answer this question once.
78
+ *
79
+ * `scopeId` carries the ORG as well as the dataset. The helper hashes
80
+ * `{kind}:{scopeId}:{key}` and its plugin callers pass a globally unique host
81
+ * id, but a dataset id is only meaningful under its org — and the claim stores
82
+ * the response body, so a cross-org digest collision would replay one tenant's
83
+ * record to another. The org belongs in the digest, not just in the swept
84
+ * field. Scoping by the DATASET is what `apps/docs/api/conventions.md` already
85
+ * publishes ("replay is looked up within the dataset you're posting to"); the
86
+ * old org-only digest only reached that outcome by accident, by reading a
87
+ * `recordId` that did not exist in the other dataset and falling through — and
88
+ * having fallen through, its key write failed and was swallowed, so the key
89
+ * never deduped there at all.
90
+ *
91
+ * NOTE: the digest changes, so keys stored under the old `orgId:key` shape are
92
+ * orphaned and a retry straddling this deploy creates a duplicate. Accepted —
93
+ * the API is pre-beta and the alternative is carrying the race forever.
94
+ *
95
+ * `kind` separates the create's claims from the delete's (AGL-1710). The
96
+ * helper hashes it into the digest for exactly this reason, and the two record
97
+ * different response BODIES — a record view versus a `{ deleted: true }`
98
+ * receipt. Sharing one namespace would let a key reused across both operations
99
+ * replay the create's record to a delete, which a client parses as a success
100
+ * and which no amount of documentation makes safe. `POST` keeps `records`
101
+ * verbatim: changing its digest orphans keys in flight, and AGL-1709 already
102
+ * paid that cost once.
103
+ *
104
+ * `scopeSuffix` is the object the key is scoped WITHIN — a dataset id for the
105
+ * record operations and for `DELETE /v1/datasets/{id}`, the site for
106
+ * `DELETE …/form-submissions/{id}`, and `*` — the ORGANIZATION — for
107
+ * `POST /v1/datasets` (AGL-2126), where there is no dataset id yet because
108
+ * the request is what creates one, and for both contact writes (AGL-2276),
109
+ * where the organization genuinely IS the containing object: contacts are
110
+ * org-scoped (AGL-237), so there is no site or parent collection to scope to
111
+ * and pretending otherwise would invent a boundary the data does not have.
112
+ * `*` cannot collide with a resource id: `createResourceUid()` never emits
113
+ * one, and the `kind` is separate anyway. The suffix is not the whole story —
114
+ * `kind` is hashed in too, which is what keeps a create's key from replaying
115
+ * into a delete, and what keeps `contacts` and `contact-deletes` apart while
116
+ * they share a suffix.
117
+ *
118
+ * This paragraph used to say `*` covered "the two that act on the dataset
119
+ * collection itself", which the delete has never done — it passes
120
+ * `datasetRef.id`. The published contract in `apps/docs/api/conventions.md` was
121
+ * written from this comment and inherited the error (AGL-2218), which is the
122
+ * argument for reading the call rather than the docblock above it.
123
+ */ export async function claimWrite(ctx, scopeSuffix, key, kind) {
124
+ const result = await claimAttempt(ctx.firestore, {
125
+ kind,
126
+ scopeId: `${ctx.orgId}:${scopeSuffix}`,
127
+ // The field `eraseOrgIdempotencyKeys` sweeps on (AGL-1448).
128
+ orgId: ctx.orgId,
129
+ key: key != null ? key : '',
130
+ busyMessage: 'A request with this Idempotency-Key is still in progress'
131
+ });
132
+ if ('claim' in result) return result;
133
+ // The helper is transport-agnostic and answers `{ status, body }`; its
134
+ // plugin callers hand that straight to a pages-API `res.json()`. v1 publishes
135
+ // a `{ error: { type, message, code } }` envelope that clients branch on, so
136
+ // the refusal is rebuilt here rather than leaking the helper's bare
137
+ // `{ error: '<sentence>' }` onto a documented surface.
138
+ if (result.replay.status === 409) {
139
+ return {
140
+ replay: ApiErrors.conflict({
141
+ message: 'A request with this Idempotency-Key is still in progress',
142
+ code: 'idempotency_in_progress',
143
+ headers: ctx.headers
144
+ })
145
+ };
146
+ }
147
+ // Settled: replay the ORIGINAL response, from the stored body rather than a
148
+ // fresh read — which is what makes it survive a record since edited or
149
+ // deleted. The old lookup replayed only while the record still existed and
150
+ // fell through to a SECOND create otherwise.
151
+ return {
152
+ replay: apiJson(result.replay.body, {
153
+ status: result.replay.status,
154
+ headers: ctx.headers
155
+ })
156
+ };
157
+ }
158
+ // ── Usage ───────────────────────────────────────────────────────────────────
159
+ /**
160
+ * One metered dimension, as published (AGL-2277).
161
+ *
162
+ * `included`/`remaining` are `null` for an UNLIMITED band rather than the
163
+ * sentinel itself: `UNLIMITED` is `Number.POSITIVE_INFINITY`, which
164
+ * `JSON.stringify` silently turns into `null` anyway — so the choice is
165
+ * between a `null` that means "unlimited" on purpose and the same `null`
166
+ * arriving by accident, indistinguishable from a bug. Stated explicitly, and
167
+ * documented, so an integrator can branch on it.
168
+ *
169
+ * `metered` is the field that actually answers "what happens when I cross
170
+ * this" — true means the excess bills, false means the next call is refused.
171
+ * It is read off the plan's own overage rate through the `check*Quota`
172
+ * helpers rather than re-derived here, because a second copy of that rule
173
+ * would drift from the one the enforcement path uses, and this endpoint's
174
+ * whole value is telling a customer what the enforcement path will do.
175
+ */ export function usageBand(used, included, remaining, overageRateUsd) {
176
+ const unlimited = !Number.isFinite(included);
177
+ return {
178
+ used,
179
+ included: unlimited ? null : included,
180
+ remaining: unlimited ? null : remaining,
181
+ metered: overageRateUsd !== null
182
+ };
183
+ }
184
+ // ── Ownership ───────────────────────────────────────────────────────────────
185
+ /** Whether `hostId` is one of the authenticated organization's own sites. */ export function orgOwnsHost(ctx, hostId) {
186
+ var _ctx_org_hosts;
187
+ const hosts = (_ctx_org_hosts = ctx.org.hosts) != null ? _ctx_org_hosts : {};
188
+ return Boolean(hosts[hostId]);
189
+ }
190
+
191
+ //# sourceMappingURL=api-v1-kit.js.map
@@ -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 * Loads every plugin's console server surface, for a handler that checks\n * a write against something other plugins register there — a custom field\n * type. Absent where the caller has no plugin loader, and then nothing\n * beyond what is already registered is loaded.\n */\n loadPluginSurfaces?: () => Promise<void>\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,143 @@
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 (sites, the media library). A resource a plugin models is the plugin's: it registers a
26
+ * handler under the resource's first path segment, and the router hands it
27
+ * every request under that segment once the key is authenticated, with the
28
+ * context the pipeline resolved. The handler asks for its own scopes
29
+ * (`requireScope`) and answers in the published envelope.
30
+ *
31
+ * ## The published contract does not move
32
+ *
33
+ * A path, a field, a scope and a status are the same whichever module
34
+ * answers them. The OpenAPI document is still the router's to build, and a
35
+ * registration hands it the resource's description (`describe`), so the
36
+ * document — and the MCP tools derived from it — describe every resource the
37
+ * build serves and nothing it does not. Moving a resource into its plugin
38
+ * changes where it is written, never what an integrator sees.
39
+ *
40
+ * ## What the organization's usage reports
41
+ *
42
+ * `GET /v1/usage` is the platform's; a plugin whose records the plan bands
43
+ * or an integration sizes a sync by adds its own figures to it
44
+ * (`registerApiV1UsageFigures`), read with the platform's on every call.
45
+ *
46
+ * ## The plan, before the handler
47
+ *
48
+ * A registration may name the plan feature its resource needs
49
+ * (`entitlement`). The router refuses an organization without it —
50
+ * `402 plan_required` with the feature as the `code` and the registration's
51
+ * own sentence — BEFORE the handler runs and before any scope is asked, so a
52
+ * key minted while the plan carried the feature cannot outlive it.
53
+ *
54
+ * ## Registered at the console's boot
55
+ *
56
+ * From the plugin's console server declarations, with the handler loaded on
57
+ * the first request. The router asks for a resource it does not know once
58
+ * more after running the app's declarations step
59
+ * (`runPluginDeclarationsRepair`), so a process whose boot failed repairs
60
+ * itself rather than answering `404` for a resource it serves. No
61
+ * registration means no plugin in this build serves the resource: the
62
+ * router's `404` for an unknown endpoint is then the truth.
63
+ *
64
+ * ## One owner per resource
65
+ *
66
+ * A second plugin registering a resource another already serves is refused,
67
+ * naming both, and the incumbent keeps serving. The platform's own
68
+ * resources are not registrable.
69
+ */
70
+ /** Answers one request under the resource, after the pipeline admitted it. */
71
+ export type ApiV1ResourceHandler = (request: Request, context: ApiV1Context,
72
+ /** The path under `/v1`, split — `['contacts', '<id>', 'merge']`. */
73
+ segments: string[], url: URL) => Promise<Response>;
74
+ export interface ApiV1Resource {
75
+ handle: ApiV1ResourceHandler;
76
+ /**
77
+ * The plan feature the resource needs, and the sentence a refusal says —
78
+ * `The CRM … is not included in this organization's plan`.
79
+ */
80
+ entitlement?: {
81
+ feature: string;
82
+ message: string;
83
+ };
84
+ /**
85
+ * The resource as the OpenAPI document describes it, loaded when the
86
+ * document is built. Absent, the resource is served and undocumented.
87
+ */
88
+ describe?: () => Promise<ApiV1ResourceDescription>;
89
+ }
90
+ export declare const API_V1_RESOURCES: import("@aglyn/aglyn/server").PluginServiceContract<ApiV1Resource>;
91
+ /** The resources the router answers itself, which no plugin may take. */
92
+ export declare const PLATFORM_API_V1_RESOURCES: ReadonlySet<string>;
93
+ /**
94
+ * Serves `/v1/<resource>` from a plugin. The owner is the loader's marker
95
+ * when a register fn is running, else `options.pluginId`; with neither the
96
+ * registration throws, as it does for a name that is not one lowercase path
97
+ * segment, a platform resource, and a resource another plugin serves. The
98
+ * same plugin registering again replaces its own.
99
+ */
100
+ export declare function registerApiV1Resource(resource: string, registration: ApiV1Resource, options?: {
101
+ pluginId?: string;
102
+ }): void;
103
+ /** Every resource the plugins serve here, in registration order. */
104
+ export declare function apiV1Resources(): Array<{
105
+ name: string;
106
+ pluginId: string;
107
+ resource: ApiV1Resource;
108
+ }>;
109
+ /**
110
+ * The descriptions of every resource the plugins serve, in registration
111
+ * order. A description that fails to load is left out and logged, rather
112
+ * than taking the platform's own description with it.
113
+ */
114
+ export declare function describeApiV1Resources(): Promise<ApiV1ResourceDescription[]>;
115
+ /** The plugin serving `/v1/<resource>` here, or `null` when none does. */
116
+ export declare function apiV1Resource(resource: string): {
117
+ pluginId: string;
118
+ resource: ApiV1Resource;
119
+ } | null;
120
+ /**
121
+ * A plugin's figures on `GET /v1/usage`, as top-level members of the usage
122
+ * object — a band the plan meters, or a size an integration plans a sync by
123
+ * (`usageBand` in `api-v1-kit.ts` gives either its published shape). Read on
124
+ * every call, with the request's context; a reader that throws fails the
125
+ * request, as a platform figure that cannot be read does.
126
+ */
127
+ export type ApiV1UsageFigures = (context: ApiV1Context) => Promise<Record<string, unknown>>;
128
+ export declare const API_V1_USAGE_FIGURES: import("@aglyn/aglyn/server").PluginServiceContract<ApiV1UsageFigures>;
129
+ /**
130
+ * Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;
131
+ * registering again replaces it. The owner is the loader's marker when a
132
+ * register fn is running, else `options.pluginId`.
133
+ */
134
+ export declare function registerApiV1UsageFigures(read: ApiV1UsageFigures, options?: {
135
+ pluginId?: string;
136
+ }): void;
137
+ /**
138
+ * Every plugin's figures for one request, merged in registration order. A
139
+ * member the platform's own figures (`reserved`) or an earlier plugin
140
+ * already names is dropped and logged: a plugin adds figures, never
141
+ * replaces one.
142
+ */
143
+ export declare function readApiV1UsageFigures(context: ApiV1Context, reserved: ReadonlySet<string>): Promise<Record<string, unknown>>;
@@ -0,0 +1,125 @@
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
+ 'sites',
23
+ 'media',
24
+ 'usage',
25
+ 'me',
26
+ 'openapi.json'
27
+ ]);
28
+ /** A resource name as a path segment carries it. */ const RESOURCE = /^[a-z][a-z0-9-]*$/;
29
+ /**
30
+ * Serves `/v1/<resource>` from a plugin. The owner is the loader's marker
31
+ * when a register fn is running, else `options.pluginId`; with neither the
32
+ * registration throws, as it does for a name that is not one lowercase path
33
+ * segment, a platform resource, and a resource another plugin serves. The
34
+ * same plugin registering again replaces its own.
35
+ */ export function registerApiV1Resource(resource, registration, options) {
36
+ var _ref, _getRegisteringPluginId;
37
+ const key = String(resource != null ? resource : '').trim();
38
+ if (!RESOURCE.test(key)) {
39
+ throw new Error(`a /v1 resource needs a lowercase path segment for its name, not "${key}"`);
40
+ }
41
+ if (PLATFORM_API_V1_RESOURCES.has(key)) {
42
+ throw new Error(`/v1/${key} is the platform's own resource`);
43
+ }
44
+ const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref : '').trim();
45
+ if (!pluginId) throw new Error(`the /v1/${key} resource was registered with no owner`);
46
+ const incumbent = resolvePluginServices(API_V1_RESOURCES).find((entry)=>entry.key === key);
47
+ if (incumbent && incumbent.pluginId !== pluginId) {
48
+ throw new Error(`/v1/${key} is already served by "${incumbent.pluginId}"; refused "${pluginId}"`);
49
+ }
50
+ registerPluginService(API_V1_RESOURCES, registration, {
51
+ pluginId,
52
+ key
53
+ });
54
+ }
55
+ /** Every resource the plugins serve here, in registration order. */ export function apiV1Resources() {
56
+ return resolvePluginServices(API_V1_RESOURCES).map((entry)=>{
57
+ var _entry_key;
58
+ return {
59
+ name: (_entry_key = entry.key) != null ? _entry_key : '',
60
+ pluginId: entry.pluginId,
61
+ resource: entry.impl
62
+ };
63
+ });
64
+ }
65
+ /**
66
+ * The descriptions of every resource the plugins serve, in registration
67
+ * order. A description that fails to load is left out and logged, rather
68
+ * than taking the platform's own description with it.
69
+ */ export async function describeApiV1Resources() {
70
+ const described = await Promise.all(apiV1Resources().map(async ({ name, pluginId, resource })=>{
71
+ if (!resource.describe) return null;
72
+ try {
73
+ return await resource.describe();
74
+ } catch (error) {
75
+ console.error(`[api-v1] ${pluginId} could not describe /v1/${name}`, error);
76
+ return null;
77
+ }
78
+ }));
79
+ return described.filter((one)=>one !== null);
80
+ }
81
+ /** The plugin serving `/v1/<resource>` here, or `null` when none does. */ export function apiV1Resource(resource) {
82
+ const key = String(resource != null ? resource : '').trim();
83
+ const entry = resolvePluginServices(API_V1_RESOURCES).find((one)=>one.key === key);
84
+ return entry ? {
85
+ pluginId: entry.pluginId,
86
+ resource: entry.impl
87
+ } : null;
88
+ }
89
+ export const API_V1_USAGE_FIGURES = definePluginServiceContract('core.api-v1-usage-figures', {
90
+ multiple: true
91
+ });
92
+ /**
93
+ * Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;
94
+ * registering again replaces it. The owner is the loader's marker when a
95
+ * register fn is running, else `options.pluginId`.
96
+ */ export function registerApiV1UsageFigures(read, options) {
97
+ var _ref, _getRegisteringPluginId;
98
+ const pluginId = ((_ref = (_getRegisteringPluginId = getRegisteringPluginId()) != null ? _getRegisteringPluginId : options == null ? void 0 : options.pluginId) != null ? _ref : '').trim();
99
+ if (!pluginId) throw new Error('/v1 usage figures were registered with no owner');
100
+ registerPluginService(API_V1_USAGE_FIGURES, read, {
101
+ pluginId
102
+ });
103
+ }
104
+ /**
105
+ * Every plugin's figures for one request, merged in registration order. A
106
+ * member the platform's own figures (`reserved`) or an earlier plugin
107
+ * already names is dropped and logged: a plugin adds figures, never
108
+ * replaces one.
109
+ */ export async function readApiV1UsageFigures(context, reserved) {
110
+ const readers = resolvePluginServices(API_V1_USAGE_FIGURES);
111
+ const figures = await Promise.all(readers.map((entry)=>entry.impl(context)));
112
+ const merged = {};
113
+ figures.forEach((one, index)=>{
114
+ for (const [key, value] of Object.entries(one)){
115
+ if (reserved.has(key) || key in merged) {
116
+ console.error(`[api-v1] ${readers[index].pluginId} named usage figure "${key}", which is taken`);
117
+ continue;
118
+ }
119
+ merged[key] = value;
120
+ }
121
+ });
122
+ return merged;
123
+ }
124
+
125
+ //# 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 (sites, the media library). A resource a plugin models is the plugin's: it registers a\n * handler under the resource's first path segment, and the router hands it\n * every request under that segment once the key is authenticated, with the\n * context the pipeline resolved. The handler asks for its own scopes\n * (`requireScope`) and answers in the published envelope.\n *\n * ## The published contract does not move\n *\n * A path, a field, a scope and a status are the same whichever module\n * answers them. The OpenAPI document is still the router's to build, and a\n * registration hands it the resource's description (`describe`), so the\n * document — and the MCP tools derived from it — describe every resource the\n * build serves and nothing it does not. Moving a resource into its plugin\n * changes where it is written, never what an integrator sees.\n *\n * ## What the organization's usage reports\n *\n * `GET /v1/usage` is the platform's; a plugin whose records the plan bands\n * or an integration sizes a sync by adds its own figures to it\n * (`registerApiV1UsageFigures`), read with the platform's on every call.\n *\n * ## The plan, before the handler\n *\n * A registration may name the plan feature its resource needs\n * (`entitlement`). The router refuses an organization without it —\n * `402 plan_required` with the feature as the `code` and the registration's\n * own sentence — BEFORE the handler runs and before any scope is asked, so a\n * key minted while the plan carried the feature cannot outlive it.\n *\n * ## Registered at the console's boot\n *\n * From the plugin's console server declarations, with the handler loaded on\n * the first request. The router asks for a resource it does not know once\n * more after running the app's declarations step\n * (`runPluginDeclarationsRepair`), so a process whose boot failed repairs\n * itself rather than answering `404` for a resource it serves. No\n * registration means no plugin in this build serves the resource: the\n * router's `404` for an unknown endpoint is then the truth.\n *\n * ## One owner per resource\n *\n * A second plugin registering a resource another already serves is refused,\n * naming both, and the incumbent keeps serving. The platform's own\n * resources are not registrable.\n */\n\n/** Answers one request under the resource, after the pipeline admitted it. */\nexport type ApiV1ResourceHandler = (\n request: Request,\n context: ApiV1Context,\n /** The path under `/v1`, split — `['contacts', '<id>', 'merge']`. */\n segments: string[],\n url: URL,\n) => Promise<Response>\n\nexport interface ApiV1Resource {\n handle: ApiV1ResourceHandler\n /**\n * The plan feature the resource needs, and the sentence a refusal says —\n * `The CRM … is not included in this organization's plan`.\n */\n entitlement?: { feature: string; message: string }\n /**\n * The resource as the OpenAPI document describes it, loaded when the\n * document is built. Absent, the resource is served and undocumented.\n */\n describe?: () => Promise<ApiV1ResourceDescription>\n}\n\nexport const API_V1_RESOURCES = definePluginServiceContract<ApiV1Resource>(\n 'core.api-v1-resources',\n { multiple: true },\n)\n\n/** The resources the router answers itself, which no plugin may take. */\nexport const PLATFORM_API_V1_RESOURCES: ReadonlySet<string> = new Set([\n 'sites',\n 'media',\n 'usage',\n 'me',\n 'openapi.json',\n])\n\n/** A resource name as a path segment carries it. */\nconst RESOURCE = /^[a-z][a-z0-9-]*$/\n\n/**\n * Serves `/v1/<resource>` from a plugin. The owner is the loader's marker\n * when a register fn is running, else `options.pluginId`; with neither the\n * registration throws, as it does for a name that is not one lowercase path\n * segment, a platform resource, and a resource another plugin serves. The\n * same plugin registering again replaces its own.\n */\nexport function registerApiV1Resource(\n resource: string,\n registration: ApiV1Resource,\n options?: { pluginId?: string },\n): void {\n const key = String(resource ?? '').trim()\n if (!RESOURCE.test(key)) {\n throw new Error(`a /v1 resource needs a lowercase path segment for its name, not \"${key}\"`)\n }\n if (PLATFORM_API_V1_RESOURCES.has(key)) {\n throw new Error(`/v1/${key} is the platform's own resource`)\n }\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error(`the /v1/${key} resource was registered with no owner`)\n const incumbent = resolvePluginServices(API_V1_RESOURCES).find((entry) => entry.key === key)\n if (incumbent && incumbent.pluginId !== pluginId) {\n throw new Error(\n `/v1/${key} is already served by \"${incumbent.pluginId}\"; refused \"${pluginId}\"`,\n )\n }\n registerPluginService(API_V1_RESOURCES, registration, { pluginId, key })\n}\n\n/** Every resource the plugins serve here, in registration order. */\nexport function apiV1Resources(): Array<{ name: string; pluginId: string; resource: ApiV1Resource }> {\n return resolvePluginServices(API_V1_RESOURCES).map((entry) => ({\n name: entry.key ?? '',\n pluginId: entry.pluginId,\n resource: entry.impl,\n }))\n}\n\n/**\n * The descriptions of every resource the plugins serve, in registration\n * order. A description that fails to load is left out and logged, rather\n * than taking the platform's own description with it.\n */\nexport async function describeApiV1Resources(): Promise<ApiV1ResourceDescription[]> {\n const described = await Promise.all(\n apiV1Resources().map(async ({ name, pluginId, resource }) => {\n if (!resource.describe) return null\n try {\n return await resource.describe()\n } catch (error) {\n console.error(`[api-v1] ${pluginId} could not describe /v1/${name}`, error)\n return null\n }\n }),\n )\n return described.filter((one): one is ApiV1ResourceDescription => one !== null)\n}\n\n/** The plugin serving `/v1/<resource>` here, or `null` when none does. */\nexport function apiV1Resource(\n resource: string,\n): { pluginId: string; resource: ApiV1Resource } | null {\n const key = String(resource ?? '').trim()\n const entry = resolvePluginServices(API_V1_RESOURCES).find((one) => one.key === key)\n return entry ? { pluginId: entry.pluginId, resource: entry.impl } : null\n}\n\n// ── Usage ──────────────────────────────────────────────────────────────────\n\n/**\n * A plugin's figures on `GET /v1/usage`, as top-level members of the usage\n * object — a band the plan meters, or a size an integration plans a sync by\n * (`usageBand` in `api-v1-kit.ts` gives either its published shape). Read on\n * every call, with the request's context; a reader that throws fails the\n * request, as a platform figure that cannot be read does.\n */\nexport type ApiV1UsageFigures = (context: ApiV1Context) => Promise<Record<string, unknown>>\n\nexport const API_V1_USAGE_FIGURES = definePluginServiceContract<ApiV1UsageFigures>(\n 'core.api-v1-usage-figures',\n { multiple: true },\n)\n\n/**\n * Adds a plugin's figures to `GET /v1/usage`. One reader per plugin;\n * registering again replaces it. The owner is the loader's marker when a\n * register fn is running, else `options.pluginId`.\n */\nexport function registerApiV1UsageFigures(\n read: ApiV1UsageFigures,\n options?: { pluginId?: string },\n): void {\n const pluginId = (getRegisteringPluginId() ?? options?.pluginId ?? '').trim()\n if (!pluginId) throw new Error('/v1 usage figures were registered with no owner')\n registerPluginService(API_V1_USAGE_FIGURES, read, { pluginId })\n}\n\n/**\n * Every plugin's figures for one request, merged in registration order. A\n * member the platform's own figures (`reserved`) or an earlier plugin\n * already names is dropped and logged: a plugin adds figures, never\n * replaces one.\n */\nexport async function readApiV1UsageFigures(\n context: ApiV1Context,\n reserved: ReadonlySet<string>,\n): Promise<Record<string, unknown>> {\n const readers = resolvePluginServices(API_V1_USAGE_FIGURES)\n const figures = await Promise.all(readers.map((entry) => entry.impl(context)))\n const merged: Record<string, unknown> = {}\n figures.forEach((one, index) => {\n for (const [key, value] of Object.entries(one)) {\n if (reserved.has(key) || key in merged) {\n console.error(`[api-v1] ${readers[index].pluginId} named usage figure \"${key}\", which is taken`)\n continue\n }\n merged[key] = value\n }\n })\n return merged\n}\n"],"names":["getRegisteringPluginId","definePluginServiceContract","registerPluginService","resolvePluginServices","API_V1_RESOURCES","multiple","PLATFORM_API_V1_RESOURCES","Set","RESOURCE","registerApiV1Resource","resource","registration","options","key","String","trim","test","Error","has","pluginId","incumbent","find","entry","apiV1Resources","map","name","impl","describeApiV1Resources","described","Promise","all","describe","error","console","filter","one","apiV1Resource","API_V1_USAGE_FIGURES","registerApiV1UsageFigures","read","readApiV1UsageFigures","context","reserved","readers","figures","merged","forEach","index","value","Object","entries"],"mappings":"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"}
@@ -30,7 +30,7 @@ import { CAMPAIGN_ATTRIBUTIONS_COLLECTION, eraseCampaignAttributionsForPersonKey
30
30
  * a form, sign up, book or check out. There is no address to look up until
31
31
  * the very moment being attributed.
32
32
  *
33
- * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached
33
+ * So the touch is carried on the VISITOR (`utm-touch.ts`) and attached
34
34
  * here, at each moment they become somebody. What is NOT rebuilt is the
35
35
  * model: the same seven-day window, the same last-click rule, the same
36
36
  * `model`/`windowDays` stamped onto every record so a report can say what it
@@ -15,7 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { FieldValue } from "firebase-admin/firestore";
18
- import { parseCampaignTouch } from "@aglyn/aglyn/app-utils/campaign-touch";
18
+ import { parseUtmTouch } from "@aglyn/aglyn/app-utils/utm-touch";
19
19
  /*
20
20
  * Stamped from the REVENUE join's constants, aliased at the import.
21
21
  *
@@ -46,7 +46,7 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
46
46
  * a form, sign up, book or check out. There is no address to look up until
47
47
  * the very moment being attributed.
48
48
  *
49
- * So the touch is carried on the VISITOR (`campaign-touch.ts`) and attached
49
+ * So the touch is carried on the VISITOR (`utm-touch.ts`) and attached
50
50
  * here, at each moment they become somebody. What is NOT rebuilt is the
51
51
  * model: the same seven-day window, the same last-click rule, the same
52
52
  * `model`/`windowDays` stamped onto every record so a report can say what it
@@ -130,7 +130,7 @@ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
130
130
  // The window is enforced inside the parser, so an expired or
131
131
  // future-dated wire value answers null here exactly as it does in the
132
132
  // browser that decided whether to send it.
133
- const web = parseCampaignTouch(options.wire, convertedAtMs);
133
+ const web = parseUtmTouch(options.wire, convertedAtMs);
134
134
  const key = personKey(options.email);
135
135
  /*
136
136
  * The email channel is only askable of somebody who named an address, and