@whooshbang/contract-mock 2.4.0 → 2.5.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 CHANGED
@@ -5,6 +5,13 @@ recorded here.
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 2.5.0
9
+
10
+ - Accept the published project configuration scopes while retaining organization and tenant boundaries (FXO-3028).
11
+ - Accept developer-friendly message and question shorthands, optional idempotency keys, inferred types and setup defaults from contracts 2.6.0.
12
+ - Create active webhook endpoints without the retired echo handshake, and expose credential-scoped flat endpoint routes.
13
+ - Preserve explicit request forms, replay semantics, consent evidence and optimistic group updates.
14
+
8
15
  ## 2.4.0
9
16
 
10
17
  - 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-owned release candidate without a cloud
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 candidate directly from the public npm registry:
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 from the same published candidate batch.
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 executes the public wallet, recurring Checkout, billing portal,
46
- automatic top-up settings/card setup, project slot and attribution routes. Organization administrators may use a
47
- human session; OAuth grants also need explicit `billing:read` or
48
- `billing:write`. Project, organization API and machine credentials cannot
49
- perform financial operations. Attribution uses the organization-level
50
- `projects:read` and `projects:write` scopes, with administrator membership for
51
- changes. The MCP tools retain their own scope and action-confirmation checks.
52
-
53
- Each account starts with the once-only $2 starter credit and one project slot.
54
- The fixture project already occupies that slot. Multi-project consumer tests
55
- must purchase extra slots through recurring Checkout or release the old slot
56
- with `assignBillingProjectSlots` before creating its replacement. Old projects
57
- retain their data and recorded test access; new live sends require an active
58
- slot. The default recurring catalog contains the $5 attribution and project
59
- add-ons. Team/Agency prices and allowances are not invented: pass synthetic
60
- offers through `billing.offers` when a consumer test needs a base plan.
61
-
62
- Wallet purchases use the approved $5/$10/$25/$50/$100 packs and
63
- $5/$10/$30/$65/$150 total core credit. Purchased and bonus funds remain
64
- separate. One accepted live notification debits $0.003 for core delivery;
65
- shared email adds $0.001 from purchased or starter credit. Paid allowances are
66
- used first. Tests, provider retries, later failures and answers do not create
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
- checkoutOutcome: () => "unpaid",
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
- Paid add-on reconciliation enables the server-enforced attribution preference
98
- until its paid boundary. Restoring the preference restores visible attribution;
99
- expiry preserves the preference but removes its paid effect. Paid wallet
100
- purchases or current recurring periods select 90-day content retention;
101
- starter-only accounts select seven days. The mock projects that entitlement;
102
- it does not simulate the storage purge jobs or prove real recipient rendering.
103
-
104
- Promotions (`GET /v1/billing/promotions`, `POST /v1/billing/promotions/redeem`)
105
- are modeled honestly but simply. Every mock offers one synthetic code,
106
- `MOCK-CREDIT`, worth $10 of message credit for 30 days; seed your own with
107
- `billing.promotionCodes`
108
- (`{ code, name, benefit, benefit_days }`, where `benefit` is a
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 owner-voiced panel values into recipient sessions, and serves the PNG only
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, verify, bounded-overlap rotation, pause,
161
- resume, disable, and attempt inspection. Environment-scoped paths need an
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 catalogue of providers and 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 catalogue presents hosted Slack first as
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 the owner enables Slack in N17 and exactly one eligible Slack connection
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: "Provide an Idempotency-Key containing 8 to 255 visible ASCII characters.",
1295
+ detail: "Idempotency-Key must be 8 to 255 visible ASCII characters.",
1291
1296
  retryable: false,
1292
- title: "Idempotency key required",
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 = acceptedAtMilliseconds + 24 * 60 * 60 * 1000;
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 === null ||
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 !== record.group.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 parsed = await readJson(context.req.raw, validateCreateGroupBroadcastRequest, state.idGenerator("diagnostic"));
5677
- if ("response" in parsed)
5678
- return parsed.response;
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 normalized = normalizeLinkRequest(parsed.value);
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
- display_name: "WhooshBang",
6037
- id: normalized.connection_id ?? "connection_mock_default_telegram",
6038
- identity: {
6039
- handle: "WhooshBangSyntheticBot",
6040
- provider: "telegram",
6041
- },
6042
- mode: "whooshbang_shared",
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: "unverified",
7324
+ status: "active",
7244
7325
  updated_at: now,
7245
- verification_status: "pending",
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,