@robono/server 0.5.0 → 0.7.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
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - Added authenticated guardian messaging to the protected backend adapter.
6
+ - Added machine-readable directory identifier rules and aligned public pending-status handling.
7
+ - Added credential-free CLI help and contract checks for the corrected OpenAPI profile fields.
8
+ - Expanded the receiving-app, fail-closed authorization, capabilities, media, receipt, and reaction documentation.
9
+
10
+ ## 0.6.0
11
+
12
+ - Fixed `health()` to use the production `GET /health` contract.
13
+ - Added normalized connection peers and complete endpoint-neutral connection and message namespaces.
14
+ - Added app-to-app profile propagation, delta message cursors, push diagnostics through the adapter, and an adapter smoke-test command.
15
+ - Added atomic webhook event claims, stronger webhook validation, paginated receipt authorization, and sanitized server errors.
16
+ - Corrected the generated OpenAPI contract and removed misleading package source links.
17
+
3
18
  ## 0.5.0
4
19
 
5
20
  - Added typed message and standalone speech transform contracts, operation-level billing details, generated-media URL expiration, and typed webhook event unions.
package/README.md CHANGED
@@ -2,6 +2,10 @@
2
2
 
3
3
  Headless Node/TypeScript SDK for connecting an existing app or closed ecosystem to Robono Bridge. It provides typed API calls, bounded retries, idempotency, restriction checks, speech tools, webhook verification, and the protected client adapter.
4
4
 
5
+ ## License in plain English
6
+
7
+ You may use this SDK to build, test, and operate an authorized application that connects to Robono. You may not redistribute it as a standalone SDK, use it to bypass Robono, or use it to build a competing bridge service. Service access, pricing, maintenance, and support are separate. If you and Robono sign a written agreement that expressly replaces this SDK license, that agreement controls to the extent it says so. The included `LICENSE` is the complete, controlling text.
8
+
5
9
  Robono's own app and third-party connected apps appear as endpoints in the same directory. Your product keeps its existing accounts, screens, storage, safety rules, and notifications.
6
10
 
7
11
  ## Install
@@ -16,7 +20,7 @@ The package is ESM-only and supports Node.js 18+ and Deno 2.x through npm compat
16
20
  import {
17
21
  createRobonoBackendAdapter,
18
22
  RobonoServer,
19
- } from "npm:@robono/server@0.5.0";
23
+ } from "npm:@robono/server@0.7.0";
20
24
  ```
21
25
 
22
26
  ## Discover endpoints
@@ -39,6 +43,9 @@ for (const endpoint of directory) {
39
43
  ```
40
44
 
41
45
  Show the returned endpoints in your app and ask for the identifier described by the selected endpoint. Do not hard-code Robono as a separate integration path.
46
+ Each `accepted_identifier` also includes `input_type`, `pattern`, length,
47
+ normalization, and case rules so the same value can be validated consistently
48
+ before lookup.
42
49
 
43
50
  Use the same methods after any endpoint is selected:
44
51
 
@@ -53,7 +60,11 @@ const connection = await robono.connections.connect({
53
60
  external_user_id: user.id,
54
61
  external_display_name: user.displayName,
55
62
  target_identifier: identifierEnteredByUser,
56
- capabilities: yourRules.forConnection(user.id),
63
+ capabilities: {
64
+ allowed_outbound_message_kinds: ["text"],
65
+ allowed_inbound_message_kinds: ["text"],
66
+ text: { max_characters: 1000 },
67
+ },
57
68
  });
58
69
 
59
70
  const result = await robono.messages.send({
@@ -69,6 +80,41 @@ The normalized `connection.connection_id` is valid for SDK state and subsequent
69
80
 
70
81
  The unified send validates negotiated message type, text length, media size, duration, and MIME type before making the API request. Validate capabilities before uploading media so unsupported work fails early.
71
82
 
83
+ New server-only integrations can use the normalized namespaces for the rest of the lifecycle without branching on endpoint type:
84
+
85
+ ```ts
86
+ const page = await robono.endpointConnections.list({
87
+ external_user_id: user.id,
88
+ limit: 50,
89
+ });
90
+
91
+ const history = await robono.endpointMessages.list({
92
+ connection,
93
+ external_user_id: user.id,
94
+ limit: 50,
95
+ });
96
+
97
+ await robono.endpointMessages.mark({
98
+ connection,
99
+ external_user_id: user.id,
100
+ message_id: history.messages[0].message_id,
101
+ event: "read",
102
+ });
103
+
104
+ await robono.endpointConnections.update({
105
+ connection,
106
+ external_user_id: user.id,
107
+ external_profile: currentProfile,
108
+ });
109
+
110
+ await robono.endpointConnections.disconnect({
111
+ connection,
112
+ external_user_id: user.id,
113
+ });
114
+ ```
115
+
116
+ Use `page.next_cursor` and `history.next_before` for older pages. Use `history.sync_cursor` as the next `after` value when checking only for newer messages.
117
+
72
118
  ## Protect client operations
73
119
 
74
120
  Keep the API key on your server. Mount the adapter at `/robono/*` behind your existing authentication:
@@ -77,12 +123,30 @@ Keep the API key on your server. Mount the adapter at `/robono/*` behind your ex
77
123
  const handleRobono = createRobonoBackendAdapter({
78
124
  robono,
79
125
  authenticate: request => yourAuth.requireUser(request).id,
80
- authorize: ({ action, userId, input }) =>
81
- yourPolicy.authorizeRobonoOperation({
82
- action,
83
- userId,
84
- input,
85
- }),
126
+ authorize: async ({ action, userId, input }) => {
127
+ const user = await accounts.findActiveUser(userId);
128
+ if (!user) return false;
129
+ if (action === "networks.list") return true;
130
+ if (action === "network_connections.respond") {
131
+ return guardianApprovals.mayRespond(user, input);
132
+ }
133
+ if (action.endsWith(".list")) return access.mayRead(user, input);
134
+ if (
135
+ action === "message_transforms.create" ||
136
+ action === "speech_transforms.create"
137
+ ) return processing.hasPermissionAndCredits(user, input);
138
+ if (
139
+ action.endsWith(".request") ||
140
+ action.endsWith(".create") ||
141
+ action.endsWith(".send") ||
142
+ action.endsWith(".mark") ||
143
+ action.endsWith(".update") ||
144
+ action.endsWith(".update_profile") ||
145
+ action.endsWith(".disconnect") ||
146
+ action === "push_diagnostics.report"
147
+ ) return access.mayChange(user, action, input);
148
+ return false;
149
+ },
86
150
  });
87
151
  ```
88
152
 
@@ -124,11 +188,14 @@ const verified = await verifyRobonoWebhook(
124
188
  rawBody,
125
189
  request.headers,
126
190
  process.env.ROBONO_WEBHOOK_SECRET!,
127
- { hasProcessedEvent: eventId => events.has(eventId) },
191
+ { claimEvent: eventId => eventStore.insertIfAbsent(eventId) },
128
192
  );
129
193
 
130
194
  if (!verified.duplicate) {
131
195
  switch (verified.event.event) {
196
+ case "bridge.connection_requested":
197
+ await pendingRequests.store(verified.event);
198
+ break;
132
199
  case "connection.status_changed":
133
200
  case "bridge.connection_status_changed":
134
201
  await existingMessagingSystem.updateConnection(verified.event);
@@ -145,7 +212,13 @@ if (!verified.duplicate) {
145
212
  }
146
213
  ```
147
214
 
148
- `verified.event` is a discriminated `RobonoWebhookEvent` union covering connection, message, receipt, guardian, transform, failure, and diagnostic events. Store processed event IDs so repeated delivery is harmless.
215
+ `verified.event` is a discriminated `RobonoWebhookEvent` union covering connection, message, receipt, guardian, transform, failure, and diagnostic events. `claimEvent` must atomically return `true` only for the first delivery so concurrent retries cannot process the same event twice.
216
+
217
+ For `bridge.connection_requested`, resolve `event.target.identifier` on your
218
+ backend, store the request pending any required guardian approval, and call
219
+ `networkConnections.respond()` only from an authorized server action. Use a
220
+ generic requester-facing result for `not_found`, rejection, and unauthorized
221
+ lookups so account existence cannot be tested.
149
222
 
150
223
  ## Run the sandbox lifecycle
151
224
 
@@ -158,4 +231,13 @@ npx --package @robono/server robono-sandbox-test
158
231
 
159
232
  The test runs the stateful network-to-network lifecycle, then validates the same unified connection and send methods against the test-only Robono endpoint. The Robono endpoint check cannot reach real users, SMS, billing, or production delivery.
160
233
 
234
+ The lifecycle runner tests Robono's sandbox API, not your authentication, CORS, or deployed adapter. Test those separately:
235
+
236
+ ```bash
237
+ ROBONO_ADAPTER_URL=https://your-backend.example \
238
+ ROBONO_ADAPTER_ACCESS_TOKEN=your_test_user_token \
239
+ ROBONO_ADAPTER_EXPECTED_ORIGIN=https://your-app.example \
240
+ npx --package @robono/server robono-adapter-test
241
+ ```
242
+
161
243
  See the complete build flow, REST reference, platform support, capabilities, delivery behavior, and testing guide at [robono.com/docs](https://robono.com/docs).
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=adapter-smoke-test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-smoke-test.d.ts","sourceRoot":"","sources":["../src/adapter-smoke-test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ if (process.argv.includes("--help") || process.argv.includes("-h")) {
3
+ console.log(`Usage: robono-adapter-test
4
+
5
+ Checks authentication, directory access, and optional CORS on your deployed
6
+ protected adapter.
7
+
8
+ Required environment variables:
9
+ ROBONO_ADAPTER_URL
10
+ ROBONO_ADAPTER_ACCESS_TOKEN
11
+
12
+ Optional:
13
+ ROBONO_ADAPTER_EXPECTED_ORIGIN`);
14
+ process.exit(0);
15
+ }
16
+ const baseUrl = requiredEnvironment("ROBONO_ADAPTER_URL").replace(/\/+$/, "");
17
+ const accessToken = requiredEnvironment("ROBONO_ADAPTER_ACCESS_TOKEN");
18
+ const expectedOrigin = process.env.ROBONO_ADAPTER_EXPECTED_ORIGIN?.trim();
19
+ const route = `${baseUrl}/robono/networks`;
20
+ await check("Unauthenticated requests are rejected", async () => {
21
+ const response = await request(route, {});
22
+ if (response.status !== 401) {
23
+ throw new Error(`expected HTTP 401, received ${response.status}`);
24
+ }
25
+ });
26
+ await check("Authenticated directory request succeeds", async () => {
27
+ const response = await request(route, {
28
+ authorization: `Bearer ${accessToken}`,
29
+ });
30
+ const payload = await response.json().catch(() => ({}));
31
+ if (!response.ok) {
32
+ throw new Error(payload.error?.message ?? `received HTTP ${response.status}`);
33
+ }
34
+ if (!Array.isArray(payload.directory)) {
35
+ throw new Error("response does not contain a directory");
36
+ }
37
+ });
38
+ if (expectedOrigin) {
39
+ await check("Configured browser origin is allowed", async () => {
40
+ const response = await fetchWithTimeout(route, {
41
+ method: "OPTIONS",
42
+ headers: {
43
+ origin: expectedOrigin,
44
+ "access-control-request-method": "POST",
45
+ "access-control-request-headers": "authorization,content-type",
46
+ },
47
+ });
48
+ const allowed = response.headers.get("access-control-allow-origin");
49
+ if (allowed !== expectedOrigin) {
50
+ throw new Error(`expected Access-Control-Allow-Origin ${expectedOrigin}, received ${allowed ?? "no header"}`);
51
+ }
52
+ });
53
+ }
54
+ console.log("Robono adapter smoke test passed.");
55
+ async function request(url, headers) {
56
+ return fetchWithTimeout(url, {
57
+ method: "POST",
58
+ headers: { "content-type": "application/json", ...headers },
59
+ body: "{}",
60
+ });
61
+ }
62
+ async function fetchWithTimeout(url, init) {
63
+ const controller = new AbortController();
64
+ const timeout = setTimeout(() => controller.abort(), 15_000);
65
+ try {
66
+ return await fetch(url, { ...init, signal: controller.signal });
67
+ }
68
+ finally {
69
+ clearTimeout(timeout);
70
+ }
71
+ }
72
+ async function check(label, operation) {
73
+ try {
74
+ await operation();
75
+ console.log(`PASS ${label}`);
76
+ }
77
+ catch (error) {
78
+ console.error(`FAIL ${label}: ${error instanceof Error ? error.message : "unknown error"}`);
79
+ process.exitCode = 1;
80
+ throw error;
81
+ }
82
+ }
83
+ function requiredEnvironment(name) {
84
+ const value = process.env[name]?.trim();
85
+ if (!value) {
86
+ console.error(`${name} is required.`);
87
+ process.exit(1);
88
+ }
89
+ return value;
90
+ }
91
+ export {};
92
+ //# sourceMappingURL=adapter-smoke-test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"adapter-smoke-test.js","sourceRoot":"","sources":["../src/adapter-smoke-test.ts"],"names":[],"mappings":"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"}
package/dist/adapter.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { RobonoServer } from "./client.js";
2
2
  import type { JsonObject } from "./types.js";
3
- export type RobonoAuthorizationAction = "networks.list" | "network_connections.request" | "network_connections.respond" | "network_connections.list" | "network_connections.disconnect" | "network_connections.update" | "robono_connections.create" | "robono_connections.list" | "robono_connections.update_profile" | "robono_connections.disconnect" | "network_messages.send" | "network_messages.list" | "network_messages.mark" | "robono_messages.send" | "robono_messages.list" | "robono_messages.mark" | "message_transforms.create" | "speech_transforms.create";
3
+ export type RobonoAuthorizationAction = "networks.list" | "network_connections.request" | "network_connections.respond" | "network_connections.list" | "network_connections.disconnect" | "network_connections.update" | "robono_connections.create" | "robono_connections.list" | "robono_connections.update_profile" | "robono_connections.disconnect" | "network_messages.send" | "network_messages.list" | "network_messages.mark" | "robono_messages.send" | "robono_messages.list" | "robono_messages.mark" | "guardian_messages.send" | "guardian_messages.list" | "guardian_messages.mark" | "message_transforms.create" | "speech_transforms.create" | "push_diagnostics.report";
4
4
  export interface RobonoAuthorizationContext {
5
5
  action: RobonoAuthorizationAction;
6
6
  userId: string;
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,MAAM,yBAAyB,GACjC,eAAe,GACf,6BAA6B,GAC7B,6BAA6B,GAC7B,0BAA0B,GAC1B,gCAAgC,GAChC,4BAA4B,GAC5B,2BAA2B,GAC3B,yBAAyB,GACzB,mCAAmC,GACnC,+BAA+B,GAC/B,uBAAuB,GACvB,uBAAuB,GACvB,uBAAuB,GACvB,sBAAsB,GACtB,sBAAsB,GACtB,sBAAsB,GACtB,2BAA2B,GAC3B,0BAA0B,CAAC;AAE/B,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,yBAAyB,CAAC;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,UAAU,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,2BAA2B;IAC1C,MAAM,EAAE,YAAY,CAAC;IACrB,YAAY,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7D,SAAS,EAAE,CACT,OAAO,EAAE,0BAA0B,KAChC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAChC,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAuBD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,2BAA2B,IAIP,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CA2TlE"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,MAAM,yBAAyB,GACjC,eAAe,GACf,6BAA6B,GAC7B,6BAA6B,GAC7B,0BAA0B,GAC1B,gCAAgC,GAChC,4BAA4B,GAC5B,2BAA2B,GAC3B,yBAAyB,GACzB,mCAAmC,GACnC,+BAA+B,GAC/B,uBAAuB,GACvB,uBAAuB,GACvB,uBAAuB,GACvB,sBAAsB,GACtB,sBAAsB,GACtB,sBAAsB,GACtB,wBAAwB,GACxB,wBAAwB,GACxB,wBAAwB,GACxB,2BAA2B,GAC3B,0BAA0B,GAC1B,yBAAyB,CAAC;AAE9B,MAAM,WAAW,0BAA0B;IACzC,MAAM,EAAE,yBAAyB,CAAC;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,UAAU,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,2BAA2B;IAC1C,MAAM,EAAE,YAAY,CAAC;IACrB,YAAY,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAC7D,SAAS,EAAE,CACT,OAAO,EAAE,0BAA0B,KAChC,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAChC,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AA2BD,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,2BAA2B,IAIP,SAAS,OAAO,KAAG,OAAO,CAAC,QAAQ,CAAC,CAsalE"}
package/dist/adapter.js CHANGED
@@ -16,8 +16,12 @@ const authorizationActions = {
16
16
  "/messages": "robono_messages.send",
17
17
  "/messages/list": "robono_messages.list",
18
18
  "/message-events": "robono_messages.mark",
19
+ "/guardian-messages": "guardian_messages.send",
20
+ "/guardian-messages/list": "guardian_messages.list",
21
+ "/guardian-messages/events": "guardian_messages.mark",
19
22
  "/transforms/message": "message_transforms.create",
20
23
  "/transforms/speech": "speech_transforms.create",
24
+ "/push-diagnostics/events": "push_diagnostics.report",
21
25
  };
22
26
  export function createRobonoBackendAdapter(options) {
23
27
  const maxRequestBytes = positiveInteger(options.maxRequestBytes, 1_000_000);
@@ -48,7 +52,11 @@ export function createRobonoBackendAdapter(options) {
48
52
  if (!authorized) {
49
53
  return responseError(403, "operation_not_authorized", "The child app did not authorize this operation for the signed-in user.", requestId);
50
54
  }
51
- const requestOptions = { requestId };
55
+ const idempotencyKey = optionalIdempotencyKey(request.headers.get("idempotency-key"));
56
+ const requestOptions = {
57
+ requestId,
58
+ ...(idempotencyKey ? { idempotencyKey } : {}),
59
+ };
52
60
  if (path === "/networks") {
53
61
  return responseJson(await options.robono.directory.list({}, requestOptions));
54
62
  }
@@ -72,11 +80,15 @@ export function createRobonoBackendAdapter(options) {
72
80
  const status = optionalString(body.status);
73
81
  const limit = optionalNumber(body.limit);
74
82
  const before = optionalString(body.before);
83
+ const bridgeConnectionId = optionalString(body.bridge_connection_id);
75
84
  return responseJson(await options.robono.networkConnections.list({
76
85
  external_user_id: externalUserId,
77
86
  ...(status ? { status } : {}),
78
87
  ...(limit ? { limit } : {}),
79
88
  ...(before ? { before } : {}),
89
+ ...(bridgeConnectionId
90
+ ? { bridge_connection_id: bridgeConnectionId }
91
+ : {}),
80
92
  }, requestOptions));
81
93
  }
82
94
  if (path === "/network-connections/disconnect") {
@@ -106,11 +118,13 @@ export function createRobonoBackendAdapter(options) {
106
118
  const status = optionalString(body.status);
107
119
  const limit = optionalNumber(body.limit);
108
120
  const before = optionalString(body.before);
121
+ const connectionId = optionalString(body.connection_id);
109
122
  return responseJson(await options.robono.connections.list({
110
123
  external_user_id: externalUserId,
111
124
  ...(status ? { status } : {}),
112
125
  ...(limit ? { limit } : {}),
113
126
  ...(before ? { before } : {}),
127
+ ...(connectionId ? { connection_id: connectionId } : {}),
114
128
  }, requestOptions));
115
129
  }
116
130
  if (path === "/connections/profile") {
@@ -139,21 +153,25 @@ export function createRobonoBackendAdapter(options) {
139
153
  if (path === "/messages/list") {
140
154
  const limit = optionalNumber(body.limit);
141
155
  const before = optionalString(body.before);
156
+ const after = optionalString(body.after);
142
157
  return responseJson(await options.robono.messages.listFromRobono({
143
158
  connection_id: requiredString(body.connection_id, "connection_id"),
144
159
  external_user_id: externalUserId,
145
160
  ...(limit ? { limit } : {}),
146
161
  ...(before ? { before } : {}),
162
+ ...(after ? { after } : {}),
147
163
  }, requestOptions));
148
164
  }
149
165
  if (path === "/network-messages/list") {
150
166
  const limit = optionalNumber(body.limit);
151
167
  const before = optionalString(body.before);
168
+ const after = optionalString(body.after);
152
169
  return responseJson(await options.robono.messages.list({
153
170
  bridge_connection_id: requiredString(body.bridge_connection_id, "bridge_connection_id"),
154
171
  external_user_id: externalUserId,
155
172
  ...(limit ? { limit } : {}),
156
173
  ...(before ? { before } : {}),
174
+ ...(after ? { after } : {}),
157
175
  }, requestOptions));
158
176
  }
159
177
  if (path === "/network-messages/events") {
@@ -185,25 +203,78 @@ export function createRobonoBackendAdapter(options) {
185
203
  ...(occurredAt ? { occurred_at: occurredAt } : {}),
186
204
  }, requestOptions));
187
205
  }
206
+ if (path === "/guardian-messages") {
207
+ return responseJson(await options.robono.messages.sendGuardian({
208
+ bridge_connection_id: requiredString(body.bridge_connection_id, "bridge_connection_id"),
209
+ external_guardian_id: externalUserId,
210
+ external_message_id: requiredString(body.external_message_id, "external_message_id"),
211
+ text_body: requiredString(body.text_body, "text_body"),
212
+ message_kind: "text",
213
+ }, requestOptions));
214
+ }
215
+ if (path === "/guardian-messages/list") {
216
+ const limit = optionalNumber(body.limit);
217
+ const before = optionalString(body.before);
218
+ return responseJson(await options.robono.messages.listGuardian({
219
+ bridge_connection_id: requiredString(body.bridge_connection_id, "bridge_connection_id"),
220
+ ...(limit ? { limit } : {}),
221
+ ...(before ? { before } : {}),
222
+ }, requestOptions));
223
+ }
224
+ if (path === "/guardian-messages/events") {
225
+ const occurredAt = optionalString(body.occurred_at);
226
+ return responseJson(await options.robono.messages.markGuardian({
227
+ bridge_connection_id: requiredString(body.bridge_connection_id, "bridge_connection_id"),
228
+ guardian_message_id: requiredString(body.guardian_message_id, "guardian_message_id"),
229
+ event: requiredGuardianEvent(body.event),
230
+ ...(occurredAt ? { occurred_at: occurredAt } : {}),
231
+ }, requestOptions));
232
+ }
188
233
  if (path === "/transforms/speech") {
189
234
  return responseJson(await options.robono.transforms.speech(body, requestOptions));
190
235
  }
191
236
  if (path === "/transforms/message") {
192
237
  return responseJson(await options.robono.transforms.message(body, requestOptions));
193
238
  }
194
- throw new Error("Authorized adapter route has no handler.");
239
+ if (path === "/push-diagnostics/events") {
240
+ const failureStage = optionalString(body.failure_stage);
241
+ const failureReason = optionalString(body.failure_reason);
242
+ const platform = optionalString(body.platform);
243
+ const provider = optionalString(body.provider);
244
+ const appVersion = optionalString(body.app_version);
245
+ const detail = optionalString(body.detail);
246
+ return responseJson(await options.robono.diagnostics.reportPush({
247
+ diagnostic_id: requiredString(body.diagnostic_id, "diagnostic_id"),
248
+ diagnostic_token: requiredString(body.diagnostic_token, "diagnostic_token"),
249
+ stage: requiredPushDiagnosticStage(body.stage),
250
+ ...(failureStage ? { failure_stage: failureStage } : {}),
251
+ ...(failureReason ? { failure_reason: failureReason } : {}),
252
+ ...(platform ? { platform } : {}),
253
+ ...(provider ? { provider } : {}),
254
+ ...(appVersion ? { app_version: appVersion } : {}),
255
+ ...(detail ? { detail } : {}),
256
+ }, requestOptions));
257
+ }
258
+ throw new AdapterRequestError(500, "adapter_handler_missing", "Authorized adapter route has no handler.");
195
259
  }
196
260
  catch (error) {
261
+ if (error instanceof AdapterRequestError) {
262
+ return responseError(error.status, error.code, error.message, requestId);
263
+ }
197
264
  if (error instanceof RobonoError) {
265
+ const status = error.status ??
266
+ (error.code === "request_timeout" ? 504 : 502);
198
267
  return responseJson({
199
268
  error: { code: error.code, message: error.message },
200
269
  request_id: error.requestId ?? requestId,
201
270
  ...(error.fields ? { fields: error.fields } : {}),
202
- }, error.status ?? 400);
271
+ }, status);
203
272
  }
204
- return responseError(400, "adapter_request_failed", error instanceof Error
205
- ? error.message
206
- : "Robono adapter request failed.", requestId);
273
+ console.error("Unexpected Robono adapter failure", {
274
+ request_id: requestId,
275
+ error,
276
+ });
277
+ return responseError(500, "adapter_internal_error", "The Robono adapter could not complete the request.", requestId);
207
278
  }
208
279
  };
209
280
  }
@@ -227,38 +298,57 @@ function inputWithAuthenticatedUser(action, input, userId) {
227
298
  action === "robono_messages.send"
228
299
  || action === "robono_messages.list"
229
300
  || action === "robono_messages.mark"
230
- || action === "message_transforms.create") {
231
- return { ...input, external_user_id: userId };
301
+ || action === "guardian_messages.send"
302
+ || action === "guardian_messages.list"
303
+ || action === "guardian_messages.mark"
304
+ || action === "message_transforms.create"
305
+ || action === "push_diagnostics.report") {
306
+ return action.startsWith("guardian_messages.")
307
+ ? { ...input, external_guardian_id: userId }
308
+ : { ...input, external_user_id: userId };
232
309
  }
233
310
  return { ...input };
234
311
  }
235
312
  async function assertConnectionBelongsToUser(robono, connectionId, externalUserId, requestId) {
236
- const result = await robono.networkConnections.list({
237
- external_user_id: externalUserId,
238
- limit: 100,
239
- }, { requestId });
240
- if (!result.connections.some((item) => item.bridge_connection_id === connectionId)) {
241
- throw new RobonoError("Connection was not found for this child app user.", {
242
- code: "connection_not_found",
243
- status: 404,
244
- requestId,
245
- });
313
+ let before;
314
+ for (let page = 0; page < 1_000; page += 1) {
315
+ const result = await robono.networkConnections.list({
316
+ external_user_id: externalUserId,
317
+ limit: 100,
318
+ ...(before ? { before } : {}),
319
+ }, { requestId });
320
+ if (result.connections.some((item) => item.bridge_connection_id === connectionId))
321
+ return;
322
+ if (!result.has_more || !result.next_before)
323
+ break;
324
+ before = result.next_before;
246
325
  }
326
+ throw new RobonoError("Connection was not found for this child app user.", {
327
+ code: "connection_not_found",
328
+ status: 404,
329
+ requestId,
330
+ });
247
331
  }
248
332
  async function readBody(request, maxRequestBytes) {
249
333
  const length = Number(request.headers.get("content-length") ?? "0");
250
334
  if (Number.isFinite(length) && length > maxRequestBytes) {
251
- throw new Error("Request body is too large.");
335
+ throw new AdapterRequestError(413, "request_body_too_large", "Request body is too large.");
252
336
  }
253
337
  const text = await request.text();
254
338
  if (new TextEncoder().encode(text).byteLength > maxRequestBytes) {
255
- throw new Error("Request body is too large.");
339
+ throw new AdapterRequestError(413, "request_body_too_large", "Request body is too large.");
256
340
  }
257
341
  if (!text.trim())
258
342
  return {};
259
- const parsed = JSON.parse(text);
343
+ let parsed;
344
+ try {
345
+ parsed = JSON.parse(text);
346
+ }
347
+ catch {
348
+ throw new AdapterRequestError(400, "invalid_json", "Request body must contain valid JSON.");
349
+ }
260
350
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
261
- throw new Error("Request body must be a JSON object.");
351
+ throw new AdapterRequestError(400, "invalid_request_body", "Request body must be a JSON object.");
262
352
  }
263
353
  return parsed;
264
354
  }
@@ -270,13 +360,23 @@ function normalizedAdapterPath(pathname) {
270
360
  }
271
361
  function requiredString(value, field) {
272
362
  const normalized = optionalString(value);
273
- if (!normalized)
274
- throw new Error(`${field} is required.`);
363
+ if (!normalized) {
364
+ throw new AdapterRequestError(422, "required_field_missing", `${field} is required.`);
365
+ }
275
366
  return normalized;
276
367
  }
277
368
  function optionalString(value) {
278
369
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
279
370
  }
371
+ function optionalIdempotencyKey(value) {
372
+ if (!value)
373
+ return undefined;
374
+ const key = value.trim();
375
+ if (key.length < 8 || key.length > 200) {
376
+ throw new AdapterRequestError(422, "idempotency_key_invalid", "Idempotency-Key must contain 8 to 200 characters.");
377
+ }
378
+ return key;
379
+ }
280
380
  function optionalNumber(value) {
281
381
  return typeof value === "number" && Number.isFinite(value)
282
382
  ? value
@@ -286,13 +386,35 @@ function requiredEvent(value) {
286
386
  if (value === "delivered" || value === "read" || value === "heard") {
287
387
  return value;
288
388
  }
289
- throw new Error("event must be delivered, read, or heard.");
389
+ throw new AdapterRequestError(422, "event_invalid", "event must be delivered, read, or heard.");
390
+ }
391
+ function requiredGuardianEvent(value) {
392
+ if (value === "delivered" || value === "read")
393
+ return value;
394
+ throw new AdapterRequestError(422, "event_invalid", "event must be delivered or read.");
290
395
  }
291
396
  function requiredConnectionResponseStatus(value) {
292
397
  if (value === "accepted" || value === "rejected" || value === "not_found") {
293
398
  return value;
294
399
  }
295
- throw new Error("status must be accepted, rejected, or not_found.");
400
+ throw new AdapterRequestError(422, "connection_status_invalid", "status must be accepted, rejected, or not_found.");
401
+ }
402
+ function requiredPushDiagnosticStage(value) {
403
+ if (value === "provider_accepted" || value === "device_received" ||
404
+ value === "content_fetched" || value === "rendered" ||
405
+ value === "polling_recovered" || value === "failed")
406
+ return value;
407
+ throw new AdapterRequestError(422, "push_diagnostic_stage_invalid", "stage is not a supported push diagnostic stage.");
408
+ }
409
+ class AdapterRequestError extends Error {
410
+ status;
411
+ code;
412
+ constructor(status, code, message) {
413
+ super(message);
414
+ this.status = status;
415
+ this.code = code;
416
+ this.name = "AdapterRequestError";
417
+ }
296
418
  }
297
419
  function positiveInteger(value, fallback) {
298
420
  return typeof value === "number" && Number.isFinite(value) && value > 0