@whooshbang/contract-mock 2.4.0 → 3.0.0
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/CHANGELOG.md +11 -0
- package/README.md +45 -73
- package/dist/core.js +115 -32
- package/dist/core.js.map +1 -1
- package/package.json +3 -3
- package/scenarios/v1/manifest.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,17 @@ recorded here.
|
|
|
5
5
|
|
|
6
6
|
## Unreleased
|
|
7
7
|
|
|
8
|
+
## 3.0.0
|
|
9
|
+
|
|
10
|
+
- Adopt contracts 3.0.0 and SDK 4.0.0 for the breaking removal of retired rollout problem codes (FXO-3133).
|
|
11
|
+
|
|
12
|
+
## 2.5.0
|
|
13
|
+
|
|
14
|
+
- Accept the published project configuration scopes while retaining organization and tenant boundaries (FXO-3028).
|
|
15
|
+
- Accept developer-friendly message and question shorthands, optional idempotency keys, inferred types and setup defaults from contracts 2.6.0.
|
|
16
|
+
- Create active webhook endpoints without the retired echo handshake, and expose credential-scoped flat endpoint routes.
|
|
17
|
+
- Preserve explicit request forms, replay semantics, consent evidence and optimistic group updates.
|
|
18
|
+
|
|
8
19
|
## 2.4.0
|
|
9
20
|
|
|
10
21
|
- Use contracts 2.5.0 and serve the billing promotion routes, including the approved-price refusal for credit packs (FXO-2994).
|
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# `@whooshbang/contract-mock`
|
|
2
2
|
|
|
3
3
|
Deterministic, in-memory WhooshBang V1 mock for local contract tests.
|
|
4
|
-
It implements the WhooshBang
|
|
4
|
+
It implements the published WhooshBang contract without a cloud
|
|
5
5
|
organization, network service, database, or production provider.
|
|
6
6
|
|
|
7
|
-
Run the current release
|
|
7
|
+
Run the current release directly from the public npm registry:
|
|
8
8
|
|
|
9
9
|
```sh
|
|
10
10
|
npx @whooshbang/contract-mock --port 8787
|
|
@@ -31,7 +31,7 @@ const response = await mock.fetch(
|
|
|
31
31
|
);
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
Install contracts, SDK and mock
|
|
34
|
+
Install compatible published contracts, SDK and mock versions.
|
|
35
35
|
The tenant rename has no old-route or old-field aliases.
|
|
36
36
|
|
|
37
37
|
The mock also executes the organization-scoped administration contract. Use
|
|
@@ -42,50 +42,33 @@ The mock also executes the organization-scoped administration contract. Use
|
|
|
42
42
|
|
|
43
43
|
## Billing consumer tests
|
|
44
44
|
|
|
45
|
-
The mock
|
|
46
|
-
automatic top-up settings
|
|
47
|
-
|
|
48
|
-
`billing:write`. Project
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
The
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
extra charges. Payment replay cannot fund the same Checkout twice.
|
|
68
|
-
|
|
69
|
-
These are synthetic payments. Creating Checkout returns a local fixture URL;
|
|
70
|
-
reconciliation applies the fixture's payment outcome, not a query parameter
|
|
71
|
-
or caller-supplied `paid` flag. No Stripe request, card charge or webhook proof
|
|
72
|
-
occurs. Set `billing.checkoutOutcome` to a callback returning `paid`, `unpaid`
|
|
73
|
-
or `expired` to exercise recovery. `billing.accounts` may seed exact decimal
|
|
74
|
-
balances for boundary tests. State resets with `control.reset()`.
|
|
75
|
-
|
|
76
|
-
Automatic top-up starts disabled and requires a confirmed pack, threshold and
|
|
77
|
-
gross monthly cap covering that pack. The mock's card setup is synthetic:
|
|
78
|
-
`billing.autoTopUpSetupOutcome` returns `ready`, `unpaid` or `expired`. It models
|
|
79
|
-
owned setup recovery and rejects stale completion after settings change; it
|
|
80
|
-
does not run a bank charge or a scheduled automatic-purchase engine. Saving a
|
|
81
|
-
card does not grant delivery credit. Actual automatic payment capture, pending
|
|
82
|
-
reservations and gross monthly cap enforcement are tested against the owned
|
|
83
|
-
storage and Stripe SDK boundaries.
|
|
45
|
+
The mock implements saved-card setup, credit purchases, subscription changes,
|
|
46
|
+
automatic top-up settings, project slots and attribution. Use a synthetic human
|
|
47
|
+
session for organization administration; OAuth grants also need `billing:read`
|
|
48
|
+
or `billing:write`. Project and machine credentials cannot perform financial
|
|
49
|
+
operations.
|
|
50
|
+
|
|
51
|
+
Each organization starts with $2 of welcome credit and one project slot. The fixture
|
|
52
|
+
project already occupies that slot. Assign slots with `assignBillingProjectSlots`
|
|
53
|
+
when testing multiple projects. Test access remains available without a live slot.
|
|
54
|
+
The default recurring catalog contains the $5 attribution and project add-ons.
|
|
55
|
+
Pass synthetic `billing.offers` when a test needs Team or Agency allowances.
|
|
56
|
+
|
|
57
|
+
Wallet purchases use the $5/$10/$25/$50/$100 packs, granting
|
|
58
|
+
$5/$10/$30/$65/$150 total credit. Purchased and bonus funds stay separate. One
|
|
59
|
+
accepted live message costs $0.003 for core delivery; shared email adds $0.001
|
|
60
|
+
from purchased or welcome credit. Paid allowances are used first. Test sends,
|
|
61
|
+
provider retries, later delivery failures and answers add no charges.
|
|
62
|
+
|
|
63
|
+
These are synthetic payments. `billing.cardOutcome` returns `ready`, `open` or
|
|
64
|
+
`expired`; `billing.chargeOutcome` returns `paid`, `declined` or `requires_action`.
|
|
65
|
+
No Stripe request or real card charge occurs. Seed decimal balances with
|
|
66
|
+
`billing.accounts` for boundary tests. `control.reset()` clears state.
|
|
84
67
|
|
|
85
68
|
```ts
|
|
86
69
|
const mock = createWhooshBangContractMock({
|
|
87
70
|
billing: {
|
|
88
|
-
|
|
71
|
+
chargeOutcome: () => "declined",
|
|
89
72
|
accounts: [{
|
|
90
73
|
organizationId: "organization_synthetic",
|
|
91
74
|
starterCreditUsd: "0.003000",
|
|
@@ -94,30 +77,18 @@ const mock = createWhooshBangContractMock({
|
|
|
94
77
|
});
|
|
95
78
|
```
|
|
96
79
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
`message_credit`, `shared_emails` or `branding` grant; `[]` offers none).
|
|
110
|
-
Codes ignore case, spaces, hyphens and underscores. Redemption adds a
|
|
111
|
-
`source: "code"` grant to that organization only, updates the summary totals
|
|
112
|
-
and `branding_removed_until`, and replays by `Idempotency-Key` (201, then 200).
|
|
113
|
-
A second redemption of a code by the same organization and every unknown code
|
|
114
|
-
get the same 404 `resource_not_found`; five refusals in a row answer 429
|
|
115
|
-
`rate_limited` until `control.reset()`. Grants expire at their end time. The
|
|
116
|
-
mock does not spend promotional credit on sends, apply discounts, or model
|
|
117
|
-
operator-created offers.
|
|
118
|
-
|
|
119
|
-
Each project has one webhook endpoint across its environments. Paused and
|
|
120
|
-
unverified endpoints still occupy it; disabling one releases it. Only a
|
|
80
|
+
Automatic top-up starts disabled. Enabling it requires a confirmed pack,
|
|
81
|
+
threshold, saved card and monthly cap. Saving a card alone grants no credit.
|
|
82
|
+
The mock does not run an automatic-purchase scheduler or prove bank settlement.
|
|
83
|
+
|
|
84
|
+
Attribution preferences take effect while the paid entitlement is active.
|
|
85
|
+
Expiry preserves the preference but restores branding. A paid wallet purchase
|
|
86
|
+
or current recurring period selects 90-day content retention; welcome-credit-only
|
|
87
|
+
accounts select seven days. The mock projects the entitlement without simulating
|
|
88
|
+
storage purge jobs or proving recipient rendering.
|
|
89
|
+
|
|
90
|
+
Each project has one webhook endpoint across its environments. Active and
|
|
91
|
+
paused endpoints still occupy it; disabling one releases it. Only a
|
|
121
92
|
terminal failed automatic cycle can receive the one included manual replay.
|
|
122
93
|
`control.runPending()` executes that cycle using the same eight-attempt bound.
|
|
123
94
|
The original cycle and manual recovery together cannot exceed 16 attempts.
|
|
@@ -133,7 +104,7 @@ values are local fixtures, not production credential material.
|
|
|
133
104
|
It can also configure the project recipient experience, upload a bounded PNG
|
|
134
105
|
logo, and manage sparse recipient-copy overrides. The mock normalizes the logo
|
|
135
106
|
before storing it, atomically replaces the prior asset, projects its ID and the
|
|
136
|
-
ten
|
|
107
|
+
ten customer-editable panel values into recipient sessions, and serves the PNG only
|
|
137
108
|
to that session's exact configured origin. System-owned safety, refusal, pause,
|
|
138
109
|
unavailable, and unsubscribe copy is rejected just as it is by the service.
|
|
139
110
|
|
|
@@ -157,8 +128,9 @@ customer-managed provider resource that still needs manual retirement.
|
|
|
157
128
|
|
|
158
129
|
Project credentials also execute the customer-endpoint lifecycle under
|
|
159
130
|
`/v1/projects/{project_id}/environments/{environment_id}/customer-endpoints`:
|
|
160
|
-
create with a one-time secret reveal,
|
|
161
|
-
resume, disable, and attempt inspection.
|
|
131
|
+
create with a one-time secret reveal, signed diagnostic ping, bounded-overlap rotation, pause,
|
|
132
|
+
resume, disable, and attempt inspection. Endpoints are active immediately; no challenge echo is needed.
|
|
133
|
+
Flat `/v1/customer-endpoints` routes derive scope from a project credential. Environment-scoped paths need an
|
|
162
134
|
environment identifier the bearer does not carry, so
|
|
163
135
|
`CONTRACT_MOCK_FIXTURE_ENVIRONMENTS` publishes the synthetic project and
|
|
164
136
|
environment identifiers the fixture credentials authenticate against. A path
|
|
@@ -166,7 +138,7 @@ naming another project or environment is refused as `resource_not_found`.
|
|
|
166
138
|
|
|
167
139
|
The same credentials execute the Channel Connections surface under
|
|
168
140
|
`/v1/projects/{project_id}/environments/{environment_id}/connection-providers`
|
|
169
|
-
and `.../channel-connections`: read the
|
|
141
|
+
and `.../channel-connections`: read the catalog of providers and the
|
|
170
142
|
WhooshBang-owned identities an environment may select, then create a
|
|
171
143
|
connection in `pending`, read it, list it, rename or disable it under the
|
|
172
144
|
`row_version` that was read, and archive it. Archiving is terminal, so a
|
|
@@ -174,7 +146,7 @@ second archive or an edit to an archived connection is refused. Credential
|
|
|
174
146
|
material and every reference to one are absent from these responses, including
|
|
175
147
|
for the shared identity WhooshBang operates.
|
|
176
148
|
|
|
177
|
-
The
|
|
149
|
+
The catalog presents hosted Slack first as
|
|
178
150
|
`WhooshBang for Slack — Recommended`. Creating that arm returns a durable
|
|
179
151
|
`authorization_setup`; fetching its mock authorization URL completes the
|
|
180
152
|
provider browser round trip and returns to the Dashboard with the same
|
|
@@ -184,7 +156,7 @@ verification/delivery timestamps. `.../{connection_id}/reauthorize` repeats
|
|
|
184
156
|
the flow on the exact connection, health verifies it, archive is repeat-safe,
|
|
185
157
|
and purge is refused because the tenant does not own the platform installation.
|
|
186
158
|
|
|
187
|
-
When
|
|
159
|
+
When Slack is enabled and exactly one eligible Slack connection
|
|
188
160
|
is attached to the notifier, a recipient session offers Slack. Its provider
|
|
189
161
|
handoff binds that exact connection; ambiguity or a lifecycle change fails
|
|
190
162
|
closed. Environment-scoped agent sends may include `connection_id`, and the
|
package/dist/core.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import schema from "@whooshbang/contracts/schemas/whooshbang-v1.schema.json" with { type: "json" };
|
|
2
|
+
const CUSTOMER_EVENT_TYPES = schema.$defs.CustomerEventType.enum;
|
|
3
|
+
import { normalizeMessageEnvelope, normalizeCreateMessageRequest, } from "@whooshbang/contracts";
|
|
1
4
|
import { resolveInteractionExpiry, resolveInteractionRequest, } from "@whooshbang/contracts";
|
|
2
5
|
import { contractVersion, validateAssertEmailConsentRequest, validateBindingSummary, validateMessageListPage, validateMessageDeliveryHistory, validateMessageNotificationImage, validateInteractionTimeline, validateCustomerEventDeliveryHistory, validateCustomerEventDeliveryAttempt, validateDeliveryHolds, validateMachineClientOperationsList, validateRecipientCopyCatalogue, validateUpdateEmailSenderRequest, validateCreateConnectionTestRequest, validateConnectionTest, validateConnectionTestCollection, } from "@whooshbang/contracts";
|
|
3
6
|
import { Hono } from "hono";
|
|
@@ -437,6 +440,8 @@ function codePointLength(value) {
|
|
|
437
440
|
return Array.from(value).length;
|
|
438
441
|
}
|
|
439
442
|
const projectScopes = [
|
|
443
|
+
"projects:read",
|
|
444
|
+
"projects:write",
|
|
440
445
|
"subscriptions:write",
|
|
441
446
|
"subscriptions:read",
|
|
442
447
|
"messages:write",
|
|
@@ -458,7 +463,7 @@ const organizationCredentialScopes = [
|
|
|
458
463
|
...projectScopes,
|
|
459
464
|
];
|
|
460
465
|
/** Every scope an OAuth grant may carry: organization-level, then project-level. */
|
|
461
|
-
const oauthScopes = [...organizationScopes, ...projectScopes];
|
|
466
|
+
const oauthScopes = [...new Set([...organizationScopes, ...projectScopes])];
|
|
462
467
|
/**
|
|
463
468
|
* The synthetic project and environment identifiers the fixture project
|
|
464
469
|
* credentials authenticate against.
|
|
@@ -1287,9 +1292,9 @@ function problem(code, status, diagnosticId, overrides = {}) {
|
|
|
1287
1292
|
title: "Idempotency conflict",
|
|
1288
1293
|
},
|
|
1289
1294
|
idempotency_key_required: {
|
|
1290
|
-
detail: "
|
|
1295
|
+
detail: "Idempotency-Key must be 8 to 255 visible ASCII characters.",
|
|
1291
1296
|
retryable: false,
|
|
1292
|
-
title: "
|
|
1297
|
+
title: "Invalid idempotency key",
|
|
1293
1298
|
},
|
|
1294
1299
|
preferred_binding_unavailable: {
|
|
1295
1300
|
detail: "The selected binding is unavailable; choose another active binding explicitly.",
|
|
@@ -2037,13 +2042,14 @@ function recordDiagnostic(state, identity, operation, details = {}) {
|
|
|
2037
2042
|
function normalizeLinkRequest(request) {
|
|
2038
2043
|
return {
|
|
2039
2044
|
...request,
|
|
2045
|
+
channels: request.channels ?? ["telegram"],
|
|
2040
2046
|
expires_in_seconds: request.expires_in_seconds ?? 43_200,
|
|
2041
2047
|
notifier_id: request.notifier_id ?? "default",
|
|
2042
2048
|
};
|
|
2043
2049
|
}
|
|
2044
2050
|
function normalizeMessageRequest(request) {
|
|
2045
2051
|
return {
|
|
2046
|
-
...request,
|
|
2052
|
+
...normalizeCreateMessageRequest(request),
|
|
2047
2053
|
notifier_id: request.notifier_id ?? "default",
|
|
2048
2054
|
};
|
|
2049
2055
|
}
|
|
@@ -2140,7 +2146,7 @@ function connectionBinding(state, credential, subscriberId, notifierId, connecti
|
|
|
2140
2146
|
}
|
|
2141
2147
|
function messageTemporalInputIsValid(request, acceptedAt) {
|
|
2142
2148
|
const acceptedAtMilliseconds = Date.parse(acceptedAt);
|
|
2143
|
-
const latestMilliseconds =
|
|
2149
|
+
const latestMilliseconds = 253_402_300_799_999;
|
|
2144
2150
|
if (request.expires_at !== undefined) {
|
|
2145
2151
|
const expiresAtMilliseconds = Date.parse(request.expires_at);
|
|
2146
2152
|
if (expiresAtMilliseconds <= acceptedAtMilliseconds ||
|
|
@@ -2221,11 +2227,8 @@ function transitionMessage(record, state, options, nextState) {
|
|
|
2221
2227
|
});
|
|
2222
2228
|
}
|
|
2223
2229
|
function requireIdempotency(request, state) {
|
|
2224
|
-
const key = request.headers.get("idempotency-key");
|
|
2225
|
-
if (key
|
|
2226
|
-
key.length < 8 ||
|
|
2227
|
-
key.length > 255 ||
|
|
2228
|
-
!/^[\u0021-\u007e]+$/u.test(key)) {
|
|
2230
|
+
const key = request.headers.get("idempotency-key") ?? crypto.randomUUID();
|
|
2231
|
+
if (key.length < 8 || key.length > 255 || !/^[\u0021-\u007e]+$/u.test(key)) {
|
|
2229
2232
|
return {
|
|
2230
2233
|
response: problemResponse(problem("idempotency_key_required", 400, state.idGenerator("diagnostic"))),
|
|
2231
2234
|
};
|
|
@@ -4566,11 +4569,22 @@ function addPublicRoutes(app, getState, options) {
|
|
|
4566
4569
|
: parsed.value.consented_at);
|
|
4567
4570
|
if (!Number.isFinite(consentedAt.getTime()) || consentedAt > new Date(now))
|
|
4568
4571
|
return problemResponse(problem("request_invalid", 400, state.idGenerator("diagnostic")));
|
|
4572
|
+
const candidates = (options.emailConsentAuthorities ?? []).filter((c) => c.organizationId === identity.credential.organizationId &&
|
|
4573
|
+
c.projectId === identity.credential.projectId &&
|
|
4574
|
+
c.environment === identity.credential.environment &&
|
|
4575
|
+
c.mode === "customer_managed" &&
|
|
4576
|
+
(parsed.value.connection_id === undefined ||
|
|
4577
|
+
c.connectionId === parsed.value.connection_id));
|
|
4578
|
+
const chosen = candidates.length === 1 ? candidates[0] : undefined;
|
|
4579
|
+
if (!chosen)
|
|
4580
|
+
return parsed.value.connection_id === undefined
|
|
4581
|
+
? requestInvalid(state)
|
|
4582
|
+
: notFound(state);
|
|
4569
4583
|
const request = {
|
|
4570
4584
|
subscriber_id: parsed.value.subscriber_id,
|
|
4571
|
-
notifier_id: parsed.value.notifier_id,
|
|
4572
|
-
connection_id: parsed.value.connection_id,
|
|
4573
|
-
domain_id: parsed.value.domain_id,
|
|
4585
|
+
notifier_id: parsed.value.notifier_id ?? "default",
|
|
4586
|
+
connection_id: parsed.value.connection_id ?? chosen.connectionId,
|
|
4587
|
+
domain_id: parsed.value.domain_id ?? chosen.domainId,
|
|
4574
4588
|
address: parsed.value.address,
|
|
4575
4589
|
...(service
|
|
4576
4590
|
? {
|
|
@@ -5411,7 +5425,7 @@ function addPublicRoutes(app, getState, options) {
|
|
|
5411
5425
|
? {}
|
|
5412
5426
|
: { description: parsed.value.description }),
|
|
5413
5427
|
diagnostic_id: state.idGenerator("diagnostic"),
|
|
5414
|
-
display_name: parsed.value.display_name,
|
|
5428
|
+
display_name: parsed.value.display_name ?? parsed.value.key,
|
|
5415
5429
|
environment: identity.credential.environment,
|
|
5416
5430
|
id: state.idGenerator("subscriber_group"),
|
|
5417
5431
|
key: parsed.value.key,
|
|
@@ -5471,7 +5485,8 @@ function addPublicRoutes(app, getState, options) {
|
|
|
5471
5485
|
const parsed = await readJson(context.req.raw, validateUpdateSubscriberGroupRequest, state.idGenerator("diagnostic"));
|
|
5472
5486
|
if ("response" in parsed)
|
|
5473
5487
|
return parsed.response;
|
|
5474
|
-
if (parsed.value.row_version !==
|
|
5488
|
+
if (parsed.value.row_version !== undefined &&
|
|
5489
|
+
parsed.value.row_version !== record.group.row_version) {
|
|
5475
5490
|
return problemResponse(problem("subscriber_group_conflict", 409, state.idGenerator("diagnostic")));
|
|
5476
5491
|
}
|
|
5477
5492
|
const description = parsed.value.description === null
|
|
@@ -5673,9 +5688,10 @@ function addPublicRoutes(app, getState, options) {
|
|
|
5673
5688
|
const idempotency = requireIdempotency(context.req.raw, state);
|
|
5674
5689
|
if ("response" in idempotency)
|
|
5675
5690
|
return idempotency.response;
|
|
5676
|
-
const
|
|
5677
|
-
if ("response" in
|
|
5678
|
-
return
|
|
5691
|
+
const raw = await readJson(context.req.raw, validateCreateGroupBroadcastRequest, state.idGenerator("diagnostic"));
|
|
5692
|
+
if ("response" in raw)
|
|
5693
|
+
return raw.response;
|
|
5694
|
+
const parsed = { value: normalizeMessageEnvelope(raw.value) };
|
|
5679
5695
|
const now = state.now();
|
|
5680
5696
|
const requestFingerprint = fingerprint(parsed.value);
|
|
5681
5697
|
const replay = idempotentReplay(state, identity, "createGroupBroadcast", idempotency.key, requestFingerprint);
|
|
@@ -6023,7 +6039,50 @@ function addPublicRoutes(app, getState, options) {
|
|
|
6023
6039
|
if ("response" in parsed) {
|
|
6024
6040
|
return parsed.response;
|
|
6025
6041
|
}
|
|
6026
|
-
const
|
|
6042
|
+
const input = parsed.value;
|
|
6043
|
+
if (input.channels?.[0] === "email" &&
|
|
6044
|
+
identity.credential.environment === "test") {
|
|
6045
|
+
return problemResponse(problem("request_invalid", 400, state.idGenerator("diagnostic"), {
|
|
6046
|
+
field_errors: [
|
|
6047
|
+
{
|
|
6048
|
+
field: "/channels",
|
|
6049
|
+
code: "channel_requires_live",
|
|
6050
|
+
detail: "Email subscription links require a live environment. Use a live credential, or the test simulator to test delivery.",
|
|
6051
|
+
},
|
|
6052
|
+
],
|
|
6053
|
+
}));
|
|
6054
|
+
}
|
|
6055
|
+
const available = [...state.channelConnections.values()].filter((record) => record.ownerOrganizationId === identity.credential.organizationId &&
|
|
6056
|
+
record.connection.project_id === identity.credential.projectId &&
|
|
6057
|
+
record.connection.environment === identity.credential.environment &&
|
|
6058
|
+
(record.connection.status === "active" ||
|
|
6059
|
+
record.connection.status === "degraded") &&
|
|
6060
|
+
record.connection.installation?.status === "active");
|
|
6061
|
+
const candidates = available.filter((record) => (input.connection_id === undefined ||
|
|
6062
|
+
record.connection.id === input.connection_id) &&
|
|
6063
|
+
(input.channels === undefined ||
|
|
6064
|
+
record.connection.provider === input.channels[0]));
|
|
6065
|
+
const project = state.projects.get(identity.credential.projectId);
|
|
6066
|
+
const notifierId = input.notifier_id === undefined || input.notifier_id === "default"
|
|
6067
|
+
? project?.environments.find((env) => env.kind === identity.credential.environment)?.default_notifier.id
|
|
6068
|
+
: input.notifier_id;
|
|
6069
|
+
const defaultId = notifierId === undefined
|
|
6070
|
+
? undefined
|
|
6071
|
+
: state.notifierDefaultConnections.get(notifierConnectionKey(identity.credential.projectId, identity.credential.environment, notifierId));
|
|
6072
|
+
const chosen = candidates.find((record) => record.connection.id === defaultId) ??
|
|
6073
|
+
(candidates.length === 1 ? candidates[0] : undefined);
|
|
6074
|
+
if ((input.channels === undefined ||
|
|
6075
|
+
(input.channels[0] === "slack" && input.connection_id === undefined)) &&
|
|
6076
|
+
available.length > 0 &&
|
|
6077
|
+
chosen === undefined)
|
|
6078
|
+
return requestInvalid(state);
|
|
6079
|
+
const normalized = normalizeLinkRequest(chosen === undefined
|
|
6080
|
+
? input
|
|
6081
|
+
: {
|
|
6082
|
+
...input,
|
|
6083
|
+
channels: [chosen.connection.provider],
|
|
6084
|
+
connection_id: chosen.connection.id,
|
|
6085
|
+
});
|
|
6027
6086
|
const requestFingerprint = fingerprint(normalized);
|
|
6028
6087
|
const replay = idempotentReplay(state, identity, "createSubscriptionLink", idempotency.key, requestFingerprint);
|
|
6029
6088
|
if (replay !== undefined) {
|
|
@@ -6032,15 +6091,26 @@ function addPublicRoutes(app, getState, options) {
|
|
|
6032
6091
|
const now = timestamp(options);
|
|
6033
6092
|
const linkId = state.idGenerator("subscription_link");
|
|
6034
6093
|
const authorizationCapability = await deterministicSubscriptionCapability(linkId);
|
|
6035
|
-
const connection =
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
6094
|
+
const connection = chosen === undefined
|
|
6095
|
+
? {
|
|
6096
|
+
display_name: "WhooshBang",
|
|
6097
|
+
id: normalized.connection_id ?? "connection_mock_default_telegram",
|
|
6098
|
+
identity: {
|
|
6099
|
+
handle: "WhooshBangSyntheticBot",
|
|
6100
|
+
provider: "telegram",
|
|
6101
|
+
},
|
|
6102
|
+
mode: "whooshbang_shared",
|
|
6103
|
+
}
|
|
6104
|
+
: {
|
|
6105
|
+
display_name: chosen.connection.display_name,
|
|
6106
|
+
id: chosen.connection.id,
|
|
6107
|
+
identity: {
|
|
6108
|
+
handle: chosen.connection.identity?.handle ??
|
|
6109
|
+
chosen.connection.display_name,
|
|
6110
|
+
provider: chosen.connection.provider,
|
|
6111
|
+
},
|
|
6112
|
+
mode: chosen.connection.mode,
|
|
6113
|
+
};
|
|
6044
6114
|
const link = {
|
|
6045
6115
|
authorization_url: `https://t.me/WhooshBangSyntheticBot?start=${authorizationCapability}`,
|
|
6046
6116
|
channels: normalized.channels,
|
|
@@ -6816,6 +6886,17 @@ function addPublicRoutes(app, getState, options) {
|
|
|
6816
6886
|
storeIdempotentResponse(state, identity, "ackMachineEvent", idempotency.key, requestFingerprint, 200, response, eventId, `cursor:${previousCommitted ?? "none"}->${committedCursor ?? "none"}`);
|
|
6817
6887
|
return validatedResponse(response, validateMachineEventAckResponse, 200, state, responseDiagnosticId);
|
|
6818
6888
|
});
|
|
6889
|
+
// Flat endpoints derive their exact authority from the project credential.
|
|
6890
|
+
// Forward internally so validation, replay and tenant isolation stay identical.
|
|
6891
|
+
app.use("/v1/customer-endpoints*", async (context) => {
|
|
6892
|
+
const state = getState();
|
|
6893
|
+
const identity = requireProjectScope(context.req.raw, state, context.req.method === "GET" ? "messages:read" : "messages:write");
|
|
6894
|
+
if (isAuthFailure(identity))
|
|
6895
|
+
return identity.response;
|
|
6896
|
+
const url = new URL(context.req.raw.url);
|
|
6897
|
+
url.pathname = `/v1/projects/${identity.credential.projectId}/environments/${credentialEnvironmentId(identity.credential)}${url.pathname.slice(3)}`;
|
|
6898
|
+
return app.fetch(new Request(url, context.req.raw));
|
|
6899
|
+
});
|
|
6819
6900
|
const customerEndpointRoot = "/v1/projects/:projectId/environments/:environmentId/customer-endpoints";
|
|
6820
6901
|
/**
|
|
6821
6902
|
* Authenticates an environment-scoped customer-endpoint request.
|
|
@@ -7205,7 +7286,7 @@ function addPublicRoutes(app, getState, options) {
|
|
|
7205
7286
|
// Sorting matches the service's documented normalization, so the same
|
|
7206
7287
|
// event-type set in a different order replays instead of conflicting.
|
|
7207
7288
|
const eventTypes = [
|
|
7208
|
-
...request.event_types,
|
|
7289
|
+
...(request.event_types ?? CUSTOMER_EVENT_TYPES),
|
|
7209
7290
|
].sort();
|
|
7210
7291
|
const normalized = {
|
|
7211
7292
|
event_types: eventTypes,
|
|
@@ -7240,9 +7321,11 @@ function addPublicRoutes(app, getState, options) {
|
|
|
7240
7321
|
id: state.idGenerator("customer_endpoint"),
|
|
7241
7322
|
project_id: identity.credential.projectId,
|
|
7242
7323
|
secret_version: 1,
|
|
7243
|
-
status: "
|
|
7324
|
+
status: "active",
|
|
7244
7325
|
updated_at: now,
|
|
7245
|
-
verification_status: "
|
|
7326
|
+
verification_status: "verified",
|
|
7327
|
+
verification_attempted_at: now,
|
|
7328
|
+
verified_at: now,
|
|
7246
7329
|
};
|
|
7247
7330
|
const result = {
|
|
7248
7331
|
endpoint,
|
|
@@ -7343,7 +7426,7 @@ function addPublicRoutes(app, getState, options) {
|
|
|
7343
7426
|
const version = record.endpoint.secret_version + 1;
|
|
7344
7427
|
record.previousSecret = record.secret;
|
|
7345
7428
|
record.rotationOverlapEndsAt =
|
|
7346
|
-
nowMilliseconds + request.overlap_seconds * 1_000;
|
|
7429
|
+
nowMilliseconds + (request.overlap_seconds ?? 86400) * 1_000;
|
|
7347
7430
|
record.secret = syntheticEndpointSecret(record.sequence, version);
|
|
7348
7431
|
record.endpoint = {
|
|
7349
7432
|
...record.endpoint,
|