@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 +15 -0
- package/README.md +92 -10
- package/dist/adapter-smoke-test.d.ts +3 -0
- package/dist/adapter-smoke-test.d.ts.map +1 -0
- package/dist/adapter-smoke-test.js +92 -0
- package/dist/adapter-smoke-test.js.map +1 -0
- package/dist/adapter.d.ts +1 -1
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js +148 -26
- package/dist/adapter.js.map +1 -1
- package/dist/client.d.ts +34 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +323 -12
- package/dist/client.js.map +1 -1
- package/dist/sandbox-test.js +15 -1
- package/dist/sandbox-test.js.map +1 -1
- package/dist/types.d.ts +94 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/webhooks.d.ts.map +1 -1
- package/dist/webhooks.js +11 -3
- package/dist/webhooks.js.map +1 -1
- package/package.json +3 -7
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.
|
|
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:
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
{
|
|
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.
|
|
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 @@
|
|
|
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;
|
package/dist/adapter.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
|
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
|
-
|
|
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
|
-
},
|
|
271
|
+
}, status);
|
|
203
272
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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 === "
|
|
231
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|