@aglyn/tenant-data-admin 1.0.0-beta.218 → 1.0.0-beta.220
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -8
- package/src/lib/server/api-http.js +5 -1
- package/src/lib/server/api-http.js.map +1 -1
- package/src/lib/server/api-v1-description.d.ts +112 -0
- package/src/lib/server/api-v1-description.js +126 -0
- package/src/lib/server/api-v1-description.js.map +1 -0
- package/src/lib/server/api-v1-kit.d.ts +163 -0
- package/src/lib/server/api-v1-kit.js +191 -0
- package/src/lib/server/api-v1-kit.js.map +1 -0
- package/src/lib/server/api-v1-resources.d.ts +143 -0
- package/src/lib/server/api-v1-resources.js +125 -0
- package/src/lib/server/api-v1-resources.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
- package/src/lib/server/campaign-conversion-attribution.js +3 -3
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
- package/src/lib/server/card-payment-velocity.d.ts +9 -0
- package/src/lib/server/card-payment-velocity.js +28 -2
- package/src/lib/server/card-payment-velocity.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +1 -1
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/consent-group-change.d.ts +2 -1
- package/src/lib/server/consent-group-change.js +43 -18
- package/src/lib/server/consent-group-change.js.map +1 -1
- package/src/lib/server/contact-email-index.d.ts +22 -0
- package/src/lib/server/contact-email-index.js +108 -0
- package/src/lib/server/contact-email-index.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +1 -1
- package/src/lib/server/crm-email-activity.js +1 -1
- package/src/lib/server/crm-email-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +9 -1
- package/src/lib/server/crm-records.js +19 -2
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/cron-auth.d.ts +44 -0
- package/src/lib/server/cron-auth.js +76 -0
- package/src/lib/server/cron-auth.js.map +1 -0
- package/src/lib/server/dataset-live-pages.js +6 -6
- package/src/lib/server/dataset-live-pages.js.map +1 -1
- package/src/lib/server/duplicate-resource.d.ts +26 -0
- package/src/lib/server/duplicate-resource.js +47 -25
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/dynamic-list-materialize.js +6 -3
- package/src/lib/server/dynamic-list-materialize.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +2 -2
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -0
- package/src/lib/server/email-suppression.js +37 -2
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/email-unsubscribe-link.d.ts +8 -5
- package/src/lib/server/email-unsubscribe-link.js +7 -7
- package/src/lib/server/email-unsubscribe-link.js.map +1 -1
- package/src/lib/server/erase.d.ts +19 -33
- package/src/lib/server/erase.js +37 -162
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-email-tokens.js +1 -1
- package/src/lib/server/host-email-tokens.js.map +1 -1
- package/src/lib/server/host-memberships.js +3 -3
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +32 -4
- package/src/lib/server/host-visitor-records.js +73 -14
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.d.ts +27 -0
- package/src/lib/server/hosted-page-review.js +149 -12
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
- package/src/lib/server/media-cdn-rate-limit.js +6 -3
- package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
- package/src/lib/server/member-email-aliases.d.ts +10 -2
- package/src/lib/server/member-email-aliases.js +26 -2
- package/src/lib/server/member-email-aliases.js.map +1 -1
- package/src/lib/server/new-account-announcement.d.ts +62 -0
- package/src/lib/server/new-account-announcement.js +77 -0
- package/src/lib/server/new-account-announcement.js.map +1 -0
- package/src/lib/server/notifications.d.ts +22 -2
- package/src/lib/server/notifications.js +40 -3
- package/src/lib/server/notifications.js.map +1 -1
- package/src/lib/server/operator-alerts.d.ts +7 -1
- package/src/lib/server/operator-alerts.js +5 -3
- package/src/lib/server/operator-alerts.js.map +1 -1
- package/src/lib/server/org-containers.d.ts +66 -0
- package/src/lib/server/org-containers.js +95 -0
- package/src/lib/server/org-containers.js.map +1 -0
- package/src/lib/server/org-member-notice.js +3 -2
- package/src/lib/server/org-member-notice.js.map +1 -1
- package/src/lib/server/payment-fraud-signal.js +5 -1
- package/src/lib/server/payment-fraud-signal.js.map +1 -1
- package/src/lib/server/{connect-payout-failure.d.ts → payment-provider-stripe-connect-payouts.d.ts} +3 -0
- package/src/lib/server/{connect-payout-failure.js → payment-provider-stripe-connect-payouts.js} +36 -2
- package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -0
- package/src/lib/server/{connect-account-status.d.ts → payment-provider-stripe-connect-status.d.ts} +3 -0
- package/src/lib/server/{connect-account-status.js → payment-provider-stripe-connect-status.js} +1 -1
- package/src/lib/server/payment-provider-stripe-connect-status.js.map +1 -0
- package/src/lib/server/{stripe-account-mode.d.ts → payment-provider-stripe-connect.d.ts} +45 -70
- package/src/lib/server/{stripe-account-mode.js → payment-provider-stripe-connect.js} +78 -52
- package/src/lib/server/payment-provider-stripe-connect.js.map +1 -0
- package/src/lib/server/payment-provider.d.ts +173 -0
- package/src/lib/server/payment-provider.js +70 -0
- package/src/lib/server/payment-provider.js.map +1 -0
- package/src/lib/server/payment-risk-record.d.ts +1 -1
- package/src/lib/server/payment-risk-record.js +3 -1
- package/src/lib/server/payment-risk-record.js.map +1 -1
- package/src/lib/server/personal-data-export.js +1 -1
- package/src/lib/server/personal-data-export.js.map +1 -1
- package/src/lib/server/platform-marketing-consent.d.ts +1 -1
- package/src/lib/server/platform-marketing-consent.js +1 -1
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/rate-limit-store.js +13 -3
- package/src/lib/server/rate-limit-store.js.map +1 -1
- package/src/lib/server/realm-plugins.js +8 -3
- package/src/lib/server/realm-plugins.js.map +1 -1
- package/src/lib/server/retained-refusals.d.ts +142 -0
- package/src/lib/server/retained-refusals.js +325 -0
- package/src/lib/server/retained-refusals.js.map +1 -0
- package/src/lib/server/risk-notice.d.ts +32 -1
- package/src/lib/server/risk-notice.js +90 -28
- package/src/lib/server/risk-notice.js.map +1 -1
- package/src/lib/server/sending-domains.d.ts +17 -0
- package/src/lib/server/sending-domains.js +28 -4
- package/src/lib/server/sending-domains.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +7 -2
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/staff-alert-email.js +4 -2
- package/src/lib/server/staff-alert-email.js.map +1 -1
- package/src/lib/server/test-firestore-queries.d.ts +7 -0
- package/src/lib/server/test-firestore-queries.js +70 -27
- package/src/lib/server/test-firestore-queries.js.map +1 -1
- package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
- package/src/lib/server/topic-subscriptions.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +46 -1
- package/src/lib/server/upsert-contact.js +87 -20
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/tenancy.d.ts +5 -8
- package/src/tenancy.js +12 -13
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/connect-account-status.js.map +0 -1
- package/src/lib/server/connect-payout-failure.js.map +0 -1
- package/src/lib/server/contact-email-engagement.d.ts +0 -32
- package/src/lib/server/contact-email-engagement.js +0 -145
- package/src/lib/server/contact-email-engagement.js.map +0 -1
- package/src/lib/server/contact-lifecycle-floor.d.ts +0 -89
- package/src/lib/server/contact-lifecycle-floor.js +0 -91
- package/src/lib/server/contact-lifecycle-floor.js.map +0 -1
- package/src/lib/server/contact-merge.d.ts +0 -55
- package/src/lib/server/contact-merge.js +0 -288
- package/src/lib/server/contact-merge.js.map +0 -1
- package/src/lib/server/crm-booking-activity.d.ts +0 -86
- package/src/lib/server/crm-booking-activity.js +0 -257
- package/src/lib/server/crm-booking-activity.js.map +0 -1
- package/src/lib/server/crm-inbound-email.d.ts +0 -145
- package/src/lib/server/crm-inbound-email.js +0 -297
- package/src/lib/server/crm-inbound-email.js.map +0 -1
- package/src/lib/server/email-topic-confirmation.js.map +0 -1
- package/src/lib/server/form-dataset-binding-token.d.ts +0 -35
- package/src/lib/server/form-dataset-binding-token.js +0 -107
- package/src/lib/server/form-dataset-binding-token.js.map +0 -1
- package/src/lib/server/stripe-account-mode.js.map +0 -1
- /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
|
@@ -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 (`
|
|
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 {
|
|
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 (`
|
|
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 =
|
|
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
|