@hyperdrive.bot/fleet-server 0.3.149 → 0.3.151
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/dist/server/server/config.js +4 -0
- package/dist/server/server/ingestion/adapters.js +2 -0
- package/dist/server/server/ingestion/errors.d.ts +5 -1
- package/dist/server/server/ingestion/errors.js +8 -0
- package/dist/server/server/ingestion/health.js +10 -0
- package/dist/server/server/ingestion/self-hosted/gateway.d.ts +55 -0
- package/dist/server/server/ingestion/self-hosted/gateway.js +295 -0
- package/dist/server/server/ingestion/types.d.ts +14 -1
- package/dist/server/server/ingestion/types.js +1 -1
- package/dist/server/web-ui/_expo/static/js/web/{index-8747c529e5cb02149fe51570f7cb697b.js → index-29d10b030c09751bbc26003abd27f547.js} +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-29d10b030c09751bbc26003abd27f547.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-29d10b030c09751bbc26003abd27f547.js.gz +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-8747c529e5cb02149fe51570f7cb697b.js.map.br → index-29d10b030c09751bbc26003abd27f547.js.map.br} +0 -0
- package/dist/server/web-ui/_expo/static/js/web/{index-8747c529e5cb02149fe51570f7cb697b.js.map.gz → index-29d10b030c09751bbc26003abd27f547.js.map.gz} +0 -0
- package/dist/server/web-ui/index.html +1 -1
- package/dist/server/web-ui/index.html.br +0 -0
- package/dist/server/web-ui/index.html.gz +0 -0
- package/package.json +6 -6
- package/dist/server/web-ui/_expo/static/js/web/index-8747c529e5cb02149fe51570f7cb697b.js.br +0 -0
- package/dist/server/web-ui/_expo/static/js/web/index-8747c529e5cb02149fe51570f7cb697b.js.gz +0 -0
|
@@ -233,6 +233,10 @@ function resolveIngestionConfig(input) {
|
|
|
233
233
|
composio: {
|
|
234
234
|
apiKey: env.PASEO_COMPOSIO_API_KEY?.trim() || undefined,
|
|
235
235
|
},
|
|
236
|
+
selfHosted: {
|
|
237
|
+
url: env.PASEO_SELF_HOSTED_URL?.trim() || undefined,
|
|
238
|
+
token: env.PASEO_SELF_HOSTED_TOKEN?.trim() || undefined,
|
|
239
|
+
},
|
|
236
240
|
};
|
|
237
241
|
}
|
|
238
242
|
function parseTrustedProxiesEnv(value) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createComposioGateway } from "./composio/gateway.js";
|
|
2
2
|
import { createPipedreamGateway } from "./pipedream/gateway.js";
|
|
3
|
+
import { createSelfHostedGateway } from "./self-hosted/gateway.js";
|
|
3
4
|
/**
|
|
4
5
|
* The adapter factory for every source kind.
|
|
5
6
|
*
|
|
@@ -16,6 +17,7 @@ import { createPipedreamGateway } from "./pipedream/gateway.js";
|
|
|
16
17
|
export const SOURCE_ADAPTERS = {
|
|
17
18
|
pipedream: (deps) => createPipedreamGateway(deps.config.pipedream),
|
|
18
19
|
composio: (deps) => createComposioGateway(deps.config.composio),
|
|
20
|
+
"self-hosted": (deps) => createSelfHostedGateway(deps.config.selfHosted),
|
|
19
21
|
};
|
|
20
22
|
/**
|
|
21
23
|
* The gateway for one kind. Total by construction: SOURCE_ADAPTERS covers every
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* COMPOSIO_OFFLINE | 502 | Composio unreachable
|
|
14
14
|
* COMPOSIO_AUTH | 502 | Composio rejected our platform credential
|
|
15
15
|
* COMPOSIO_ERROR | 502 | Composio answered non-ok for any other reason
|
|
16
|
+
* SELF_HOSTED_OFF | 422 | no bridge URL/token, or the URL is malformed
|
|
17
|
+
* SELF_HOSTED_OFFLINE | 502 | the user's bridge is unreachable or timed out
|
|
18
|
+
* SELF_HOSTED_AUTH | 502 | the bridge rejected this daemon's bearer
|
|
19
|
+
* SELF_HOSTED_ERROR | 502 | the bridge answered non-ok, non-JSON or incomplete
|
|
16
20
|
* SOURCE_GONE | 502 | a resource we cached was deleted upstream
|
|
17
21
|
* SOURCE_ERROR | 502 | any other non-ok aggregator response
|
|
18
22
|
* FILTER_MISSING | 404 | no such filter on THIS daemon
|
|
@@ -37,7 +41,7 @@
|
|
|
37
41
|
* tell the caller to re-authenticate, which cannot fix anything, and would
|
|
38
42
|
* blame the wrong party for an operator's misconfiguration.
|
|
39
43
|
*/
|
|
40
|
-
export declare const INGESTION_ERROR_CODES: readonly ["PIPEDREAM_OFF", "COMPOSIO_OFF", "COMPOSIO_NEEDS_SETUP", "PIPEDREAM_OFFLINE", "PIPEDREAM_AUTH", "COMPOSIO_OFFLINE", "COMPOSIO_AUTH", "COMPOSIO_ERROR", "SOURCE_GONE", "SOURCE_ERROR", "FILTER_MISSING", "SOURCE_MISSING"];
|
|
44
|
+
export declare const INGESTION_ERROR_CODES: readonly ["PIPEDREAM_OFF", "COMPOSIO_OFF", "COMPOSIO_NEEDS_SETUP", "PIPEDREAM_OFFLINE", "PIPEDREAM_AUTH", "COMPOSIO_OFFLINE", "COMPOSIO_AUTH", "COMPOSIO_ERROR", "SELF_HOSTED_OFF", "SELF_HOSTED_OFFLINE", "SELF_HOSTED_AUTH", "SELF_HOSTED_ERROR", "SOURCE_GONE", "SOURCE_ERROR", "FILTER_MISSING", "SOURCE_MISSING"];
|
|
41
45
|
export type IngestionErrorCode = (typeof INGESTION_ERROR_CODES)[number];
|
|
42
46
|
/** A domain error from the ingestion seam, with a message ready for a client. */
|
|
43
47
|
export declare class IngestionError extends Error {
|
|
@@ -13,6 +13,10 @@
|
|
|
13
13
|
* COMPOSIO_OFFLINE | 502 | Composio unreachable
|
|
14
14
|
* COMPOSIO_AUTH | 502 | Composio rejected our platform credential
|
|
15
15
|
* COMPOSIO_ERROR | 502 | Composio answered non-ok for any other reason
|
|
16
|
+
* SELF_HOSTED_OFF | 422 | no bridge URL/token, or the URL is malformed
|
|
17
|
+
* SELF_HOSTED_OFFLINE | 502 | the user's bridge is unreachable or timed out
|
|
18
|
+
* SELF_HOSTED_AUTH | 502 | the bridge rejected this daemon's bearer
|
|
19
|
+
* SELF_HOSTED_ERROR | 502 | the bridge answered non-ok, non-JSON or incomplete
|
|
16
20
|
* SOURCE_GONE | 502 | a resource we cached was deleted upstream
|
|
17
21
|
* SOURCE_ERROR | 502 | any other non-ok aggregator response
|
|
18
22
|
* FILTER_MISSING | 404 | no such filter on THIS daemon
|
|
@@ -46,6 +50,10 @@ export const INGESTION_ERROR_CODES = [
|
|
|
46
50
|
"COMPOSIO_OFFLINE",
|
|
47
51
|
"COMPOSIO_AUTH",
|
|
48
52
|
"COMPOSIO_ERROR",
|
|
53
|
+
"SELF_HOSTED_OFF",
|
|
54
|
+
"SELF_HOSTED_OFFLINE",
|
|
55
|
+
"SELF_HOSTED_AUTH",
|
|
56
|
+
"SELF_HOSTED_ERROR",
|
|
49
57
|
"SOURCE_GONE",
|
|
50
58
|
"SOURCE_ERROR",
|
|
51
59
|
"FILTER_MISSING",
|
|
@@ -22,6 +22,16 @@ const ERROR_CODE_STATUS = {
|
|
|
22
22
|
COMPOSIO_OFFLINE: "degraded",
|
|
23
23
|
COMPOSIO_AUTH: "revoked",
|
|
24
24
|
COMPOSIO_ERROR: "degraded",
|
|
25
|
+
// Same split as the aggregators, for the same reason. OFF/OFFLINE/ERROR are
|
|
26
|
+
// states the next sweep may clear on its own; AUTH is not. For a vendor,
|
|
27
|
+
// AUTH means the user must re-authorize there; for a self-hosted bridge it
|
|
28
|
+
// means the bearer in this daemon's env no longer matches the one the bridge
|
|
29
|
+
// expects. Different cause, identical consequence: a human has to act, so
|
|
30
|
+
// reporting it as `degraded` would leave a dead source looking merely slow.
|
|
31
|
+
SELF_HOSTED_OFF: "degraded",
|
|
32
|
+
SELF_HOSTED_OFFLINE: "degraded",
|
|
33
|
+
SELF_HOSTED_AUTH: "revoked",
|
|
34
|
+
SELF_HOSTED_ERROR: "degraded",
|
|
25
35
|
SOURCE_GONE: "revoked",
|
|
26
36
|
SOURCE_ERROR: "degraded",
|
|
27
37
|
// The 404 pair cannot arise from a health check: `checkSource` is only ever
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The self-hosted source gateway.
|
|
3
|
+
*
|
|
4
|
+
* Composio and Pipedream are aggregators someone else runs: paseo holds a
|
|
5
|
+
* platform credential and talks to a fixed base URL baked into the adapter.
|
|
6
|
+
* This adapter inverts that. The user runs the aggregator, tells paseo where it
|
|
7
|
+
* is, and paseo holds nothing but a bearer token for that one endpoint.
|
|
8
|
+
*
|
|
9
|
+
* That inversion is the whole point. Nothing in this file names an app, a
|
|
10
|
+
* vendor or a protocol beyond HTTP+JSON: the bridge on the other end decides
|
|
11
|
+
* what it fronts. WhatsApp-over-wacli is the first one, but a Matrix bridge, an
|
|
12
|
+
* IMAP box or a signal-cli would implement the same six routes and paseo would
|
|
13
|
+
* not learn a new word.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THE BRIDGE OWES US (v1):
|
|
16
|
+
*
|
|
17
|
+
* GET {base}/v1/sources?query=&cursor= -> SourcesResponse
|
|
18
|
+
* POST {base}/v1/connect -> ConnectResponse
|
|
19
|
+
* GET {base}/v1/accounts?... -> AccountsResponse
|
|
20
|
+
* GET {base}/v1/items?... -> ItemsResponse
|
|
21
|
+
* POST {base}/v1/mcp-target -> McpTargetResponse
|
|
22
|
+
*
|
|
23
|
+
* `listTriggerComponents` / `deployTrigger` are deliberately NOT implemented.
|
|
24
|
+
* They are optional on `SourceGateway`, and `service.ts` already answers
|
|
25
|
+
* `${kind} cannot deploy triggers` for a gateway that lacks them. A bridge that
|
|
26
|
+
* fronts a local daemon has nothing to deploy: it is already watching, or it is
|
|
27
|
+
* not running at all. Pretending otherwise would report a source as "watching"
|
|
28
|
+
* when nothing is.
|
|
29
|
+
*
|
|
30
|
+
* LEDGER IDENTITY IS OURS, NOT THE BRIDGE'S.
|
|
31
|
+
*
|
|
32
|
+
* The bridge returns `externalId` and a raw `payload`; `key` and `contentHash`
|
|
33
|
+
* are computed HERE with `fingerprint()`, byte-identically to the Composio
|
|
34
|
+
* adapter. A bridge that minted its own keys would be a second identity
|
|
35
|
+
* function for the same items, and an identity that disagrees with the ledger
|
|
36
|
+
* re-dispatches a backlog the user already cleared.
|
|
37
|
+
*
|
|
38
|
+
* Note what `key` deliberately does NOT include: `accountId`. Two accounts on
|
|
39
|
+
* the same underlying inbox (the read-only and read-write wacli pair, both
|
|
40
|
+
* linked to one WhatsApp number) see the SAME messages. Keying without the
|
|
41
|
+
* account makes that one ledger row handled once, which is the correct
|
|
42
|
+
* behaviour, and it also means re-pairing an account does not invalidate a
|
|
43
|
+
* backlog the user already worked through.
|
|
44
|
+
*
|
|
45
|
+
* CREDENTIALS.
|
|
46
|
+
*
|
|
47
|
+
* The bearer token never leaves this module, and `mcpTarget` results are per
|
|
48
|
+
* turn: they carry live headers minted by the bridge and are never persisted,
|
|
49
|
+
* cached or returned to a client. A `SourceItem` has no credential field and
|
|
50
|
+
* must never grow one - it is projected into `BackfillItem` and crosses the
|
|
51
|
+
* wire to the app.
|
|
52
|
+
*/
|
|
53
|
+
import type { SelfHostedConfig, SourceGateway } from "../types.js";
|
|
54
|
+
export declare function createSelfHostedGateway(config?: SelfHostedConfig | undefined): SourceGateway;
|
|
55
|
+
//# sourceMappingURL=gateway.d.ts.map
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The self-hosted source gateway.
|
|
3
|
+
*
|
|
4
|
+
* Composio and Pipedream are aggregators someone else runs: paseo holds a
|
|
5
|
+
* platform credential and talks to a fixed base URL baked into the adapter.
|
|
6
|
+
* This adapter inverts that. The user runs the aggregator, tells paseo where it
|
|
7
|
+
* is, and paseo holds nothing but a bearer token for that one endpoint.
|
|
8
|
+
*
|
|
9
|
+
* That inversion is the whole point. Nothing in this file names an app, a
|
|
10
|
+
* vendor or a protocol beyond HTTP+JSON: the bridge on the other end decides
|
|
11
|
+
* what it fronts. WhatsApp-over-wacli is the first one, but a Matrix bridge, an
|
|
12
|
+
* IMAP box or a signal-cli would implement the same six routes and paseo would
|
|
13
|
+
* not learn a new word.
|
|
14
|
+
*
|
|
15
|
+
* WHAT THE BRIDGE OWES US (v1):
|
|
16
|
+
*
|
|
17
|
+
* GET {base}/v1/sources?query=&cursor= -> SourcesResponse
|
|
18
|
+
* POST {base}/v1/connect -> ConnectResponse
|
|
19
|
+
* GET {base}/v1/accounts?... -> AccountsResponse
|
|
20
|
+
* GET {base}/v1/items?... -> ItemsResponse
|
|
21
|
+
* POST {base}/v1/mcp-target -> McpTargetResponse
|
|
22
|
+
*
|
|
23
|
+
* `listTriggerComponents` / `deployTrigger` are deliberately NOT implemented.
|
|
24
|
+
* They are optional on `SourceGateway`, and `service.ts` already answers
|
|
25
|
+
* `${kind} cannot deploy triggers` for a gateway that lacks them. A bridge that
|
|
26
|
+
* fronts a local daemon has nothing to deploy: it is already watching, or it is
|
|
27
|
+
* not running at all. Pretending otherwise would report a source as "watching"
|
|
28
|
+
* when nothing is.
|
|
29
|
+
*
|
|
30
|
+
* LEDGER IDENTITY IS OURS, NOT THE BRIDGE'S.
|
|
31
|
+
*
|
|
32
|
+
* The bridge returns `externalId` and a raw `payload`; `key` and `contentHash`
|
|
33
|
+
* are computed HERE with `fingerprint()`, byte-identically to the Composio
|
|
34
|
+
* adapter. A bridge that minted its own keys would be a second identity
|
|
35
|
+
* function for the same items, and an identity that disagrees with the ledger
|
|
36
|
+
* re-dispatches a backlog the user already cleared.
|
|
37
|
+
*
|
|
38
|
+
* Note what `key` deliberately does NOT include: `accountId`. Two accounts on
|
|
39
|
+
* the same underlying inbox (the read-only and read-write wacli pair, both
|
|
40
|
+
* linked to one WhatsApp number) see the SAME messages. Keying without the
|
|
41
|
+
* account makes that one ledger row handled once, which is the correct
|
|
42
|
+
* behaviour, and it also means re-pairing an account does not invalidate a
|
|
43
|
+
* backlog the user already worked through.
|
|
44
|
+
*
|
|
45
|
+
* CREDENTIALS.
|
|
46
|
+
*
|
|
47
|
+
* The bearer token never leaves this module, and `mcpTarget` results are per
|
|
48
|
+
* turn: they carry live headers minted by the bridge and are never persisted,
|
|
49
|
+
* cached or returned to a client. A `SourceItem` has no credential field and
|
|
50
|
+
* must never grow one - it is projected into `BackfillItem` and crosses the
|
|
51
|
+
* wire to the app.
|
|
52
|
+
*/
|
|
53
|
+
import { IngestionError } from "../errors.js";
|
|
54
|
+
import { fingerprint } from "../fingerprint.js";
|
|
55
|
+
/** Hard ceiling on a bridge response body. A local bridge is not a reason to trust it. */
|
|
56
|
+
const MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
57
|
+
/** Per-request timeout. A wedged local daemon must not hang a paseo turn forever. */
|
|
58
|
+
const REQUEST_TIMEOUT_MS = 30000;
|
|
59
|
+
function notConfigured() {
|
|
60
|
+
throw new IngestionError("SELF_HOSTED_OFF", "This daemon has no self-hosted source bridge configured. Set PASEO_SELF_HOSTED_URL and PASEO_SELF_HOSTED_TOKEN.", 422);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Normalize the configured base so route joining is total.
|
|
64
|
+
*
|
|
65
|
+
* A trailing slash in the env var plus a leading slash in the route yields
|
|
66
|
+
* `//v1/sources`, which some servers route and some 404. Strip it once here
|
|
67
|
+
* rather than defending at five call sites.
|
|
68
|
+
*/
|
|
69
|
+
function normalizeBase(url) {
|
|
70
|
+
return url.replace(/\/+$/, "");
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Reject a base URL that is not http(s).
|
|
74
|
+
*
|
|
75
|
+
* The URL comes from the user's own environment, so this is not a trust
|
|
76
|
+
* boundary against an attacker. It is a boundary against a typo: a `file:` or
|
|
77
|
+
* `data:` base would make `fetch` do something surprising rather than fail
|
|
78
|
+
* with a readable message.
|
|
79
|
+
*/
|
|
80
|
+
function assertHttpUrl(raw) {
|
|
81
|
+
let parsed;
|
|
82
|
+
try {
|
|
83
|
+
parsed = new URL(raw);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
throw new IngestionError("SELF_HOSTED_OFF", `PASEO_SELF_HOSTED_URL is not a valid URL: ${raw}`, 422);
|
|
87
|
+
}
|
|
88
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
89
|
+
throw new IngestionError("SELF_HOSTED_OFF", `PASEO_SELF_HOSTED_URL must be http or https, got ${parsed.protocol}`, 422);
|
|
90
|
+
}
|
|
91
|
+
return parsed;
|
|
92
|
+
}
|
|
93
|
+
/** One catalogue page. Matches the Composio adapter's page size so the app's infinite scroll behaves identically. */
|
|
94
|
+
const CATALOGUE_LIMIT = 20;
|
|
95
|
+
function asString(value, fallback = "") {
|
|
96
|
+
return typeof value === "string" ? value : fallback;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Narrow a bridge's account status to the union the rest of the daemon reads.
|
|
100
|
+
*
|
|
101
|
+
* Anything unrecognized becomes "inactive" rather than throwing. A bridge that
|
|
102
|
+
* grows a new status must not take the whole sources list down with it, and
|
|
103
|
+
* "inactive" is the honest reading of "I do not know that this works".
|
|
104
|
+
*/
|
|
105
|
+
function asAccountStatus(value) {
|
|
106
|
+
return value === "active" || value === "pending" || value === "inactive" ? value : "inactive";
|
|
107
|
+
}
|
|
108
|
+
export function createSelfHostedGateway(config) {
|
|
109
|
+
// Tolerates an absent config on purpose. `hasConfiguredGateway` asks EVERY
|
|
110
|
+
// kind whether it is configured, so a gateway that throws when its block is
|
|
111
|
+
// missing does not fail its own kind: it takes the whole sources screen down
|
|
112
|
+
// with it. An absent block means exactly "no bridge here", which is what
|
|
113
|
+
// `configured: false` already says.
|
|
114
|
+
const rawUrl = config?.url?.trim();
|
|
115
|
+
const token = config?.token?.trim();
|
|
116
|
+
const configured = Boolean(rawUrl) && Boolean(token);
|
|
117
|
+
const base = rawUrl ? normalizeBase(rawUrl) : "";
|
|
118
|
+
/**
|
|
119
|
+
* One HTTP call to the bridge, with the error taxonomy applied once.
|
|
120
|
+
*
|
|
121
|
+
* The three failure shapes are kept apart on purpose, mirroring the Composio
|
|
122
|
+
* adapter: unreachable (the bridge is down or the URL is wrong), rejected
|
|
123
|
+
* (the token is wrong), and everything else. Collapsing them would send the
|
|
124
|
+
* next reader to restart a daemon when the real problem was a stale token.
|
|
125
|
+
*/
|
|
126
|
+
async function call(method, route, init) {
|
|
127
|
+
if (!configured) {
|
|
128
|
+
notConfigured();
|
|
129
|
+
}
|
|
130
|
+
assertHttpUrl(base);
|
|
131
|
+
const url = new URL(`${base}${route}`);
|
|
132
|
+
for (const [key, value] of Object.entries(init?.query ?? {})) {
|
|
133
|
+
if (value === null || value === undefined) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
url.searchParams.set(key, String(value));
|
|
137
|
+
}
|
|
138
|
+
const controller = new AbortController();
|
|
139
|
+
const timer = setTimeout(() => {
|
|
140
|
+
controller.abort();
|
|
141
|
+
}, REQUEST_TIMEOUT_MS);
|
|
142
|
+
let response;
|
|
143
|
+
try {
|
|
144
|
+
response = await fetch(url, {
|
|
145
|
+
method,
|
|
146
|
+
headers: {
|
|
147
|
+
authorization: `Bearer ${token}`,
|
|
148
|
+
accept: "application/json",
|
|
149
|
+
...(init?.body === undefined ? {} : { "content-type": "application/json" }),
|
|
150
|
+
},
|
|
151
|
+
...(init?.body === undefined ? {} : { body: JSON.stringify(init.body) }),
|
|
152
|
+
signal: controller.signal,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (cause) {
|
|
156
|
+
const reason = cause instanceof Error && cause.name === "AbortError" ? "timed out" : "is unreachable";
|
|
157
|
+
throw new IngestionError("SELF_HOSTED_OFFLINE", `The self-hosted source bridge at ${base} ${reason}.`, 502);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
}
|
|
162
|
+
if (response.status === 401 || response.status === 403) {
|
|
163
|
+
throw new IngestionError("SELF_HOSTED_AUTH", `The self-hosted source bridge at ${base} rejected this daemon's token.`, 502);
|
|
164
|
+
}
|
|
165
|
+
const text = await response.text();
|
|
166
|
+
if (text.length > MAX_BODY_BYTES) {
|
|
167
|
+
throw new IngestionError("SELF_HOSTED_ERROR", `The self-hosted source bridge returned a body over ${MAX_BODY_BYTES} bytes for ${route}.`, 502);
|
|
168
|
+
}
|
|
169
|
+
if (!response.ok) {
|
|
170
|
+
throw new IngestionError("SELF_HOSTED_ERROR", `The self-hosted source bridge answered ${response.status} for ${route}.`, 502);
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
return JSON.parse(text);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw new IngestionError("SELF_HOSTED_ERROR", `The self-hosted source bridge returned a non-JSON body for ${route}.`, 502);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
get configured() {
|
|
181
|
+
return configured;
|
|
182
|
+
},
|
|
183
|
+
async listSources(query, cursor) {
|
|
184
|
+
const raw = await call("GET", "/v1/sources", { query: { query, cursor: cursor ?? null, limit: CATALOGUE_LIMIT } });
|
|
185
|
+
const items = (Array.isArray(raw.items) ? raw.items : [])
|
|
186
|
+
.filter((entry) => Boolean(entry) && typeof entry === "object")
|
|
187
|
+
.map((entry) => ({
|
|
188
|
+
slug: asString(entry.slug),
|
|
189
|
+
name: asString(entry.name),
|
|
190
|
+
imgSrc: asString(entry.imgSrc),
|
|
191
|
+
description: asString(entry.description),
|
|
192
|
+
}))
|
|
193
|
+
// A catalogue entry without a slug cannot be connected to, so it is not
|
|
194
|
+
// an entry. Dropping it beats rendering a row that errors on click.
|
|
195
|
+
.filter((entry) => entry.slug.length > 0);
|
|
196
|
+
return {
|
|
197
|
+
items,
|
|
198
|
+
nextCursor: typeof raw.nextCursor === "string" && raw.nextCursor.length > 0 ? raw.nextCursor : null,
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
async connect(externalUserId, appSlug) {
|
|
202
|
+
const raw = await call("POST", "/v1/connect", {
|
|
203
|
+
body: { externalUserId, appSlug },
|
|
204
|
+
});
|
|
205
|
+
const redirectUrl = asString(raw.redirectUrl);
|
|
206
|
+
if (redirectUrl.length === 0) {
|
|
207
|
+
throw new IngestionError("SELF_HOSTED_ERROR", `The self-hosted source bridge returned no redirectUrl for ${appSlug}.`, 502);
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
redirectUrl,
|
|
211
|
+
expiresAt: typeof raw.expiresAt === "string" && raw.expiresAt.length > 0 ? raw.expiresAt : null,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
async listAccounts(externalUserId, appSlug) {
|
|
215
|
+
const raw = await call("GET", "/v1/accounts", {
|
|
216
|
+
query: { externalUserId, appSlug },
|
|
217
|
+
});
|
|
218
|
+
return (Array.isArray(raw.accounts) ? raw.accounts : [])
|
|
219
|
+
.filter((account) => Boolean(account) && typeof account === "object")
|
|
220
|
+
.map((account) => ({
|
|
221
|
+
id: asString(account.id),
|
|
222
|
+
status: asAccountStatus(account.status),
|
|
223
|
+
// `label` answers "WHICH account", the question a connected row could
|
|
224
|
+
// not answer before it existed. A bridge that omits it gets null, not "".
|
|
225
|
+
label: typeof account.label === "string" && account.label.length > 0 ? account.label : null,
|
|
226
|
+
}))
|
|
227
|
+
.filter((account) => account.id.length > 0);
|
|
228
|
+
},
|
|
229
|
+
async listItems(input) {
|
|
230
|
+
const raw = await call("GET", "/v1/items", {
|
|
231
|
+
query: {
|
|
232
|
+
externalUserId: input.externalUserId,
|
|
233
|
+
appSlug: input.appSlug,
|
|
234
|
+
accountId: input.accountId,
|
|
235
|
+
sinceMs: input.sinceMs,
|
|
236
|
+
cursor: input.cursor,
|
|
237
|
+
limit: input.limit,
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
const items = [];
|
|
241
|
+
for (const entry of Array.isArray(raw.items) ? raw.items : []) {
|
|
242
|
+
if (!entry || typeof entry !== "object") {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const externalId = asString(entry.externalId);
|
|
246
|
+
// No id means no stable ledger identity. Inventing one would re-dispatch
|
|
247
|
+
// the item on every read, so the item is dropped instead.
|
|
248
|
+
if (externalId.length === 0) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
// An adapter that cannot date an item drops it rather than inventing a
|
|
252
|
+
// timestamp: the backfill window is evaluated against this value.
|
|
253
|
+
if (typeof entry.timestampMs !== "number" || !Number.isFinite(entry.timestampMs)) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const payload = entry.payload && typeof entry.payload === "object" && !Array.isArray(entry.payload)
|
|
257
|
+
? entry.payload
|
|
258
|
+
: {};
|
|
259
|
+
items.push({
|
|
260
|
+
key: fingerprint("self-hosted", input.appSlug, externalId),
|
|
261
|
+
contentHash: fingerprint(JSON.stringify(payload)),
|
|
262
|
+
timestampMs: entry.timestampMs,
|
|
263
|
+
title: asString(entry.title, externalId),
|
|
264
|
+
subtitle: asString(entry.subtitle),
|
|
265
|
+
payload,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
return {
|
|
269
|
+
items,
|
|
270
|
+
cursor: typeof raw.cursor === "string" && raw.cursor.length > 0 ? raw.cursor : null,
|
|
271
|
+
};
|
|
272
|
+
},
|
|
273
|
+
async mcpTarget(externalUserId, appSlug) {
|
|
274
|
+
const raw = await call("POST", "/v1/mcp-target", {
|
|
275
|
+
body: { externalUserId, appSlug },
|
|
276
|
+
});
|
|
277
|
+
const url = asString(raw.url);
|
|
278
|
+
if (url.length === 0) {
|
|
279
|
+
throw new IngestionError("SELF_HOSTED_ERROR", `The self-hosted source bridge returned no MCP url for ${appSlug}.`, 502);
|
|
280
|
+
}
|
|
281
|
+
const headers = {};
|
|
282
|
+
if (raw.headers && typeof raw.headers === "object" && !Array.isArray(raw.headers)) {
|
|
283
|
+
for (const [key, value] of Object.entries(raw.headers)) {
|
|
284
|
+
if (typeof value === "string") {
|
|
285
|
+
headers[key] = value;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
// Never cached. The headers are a live credential minted by the bridge for
|
|
290
|
+
// this turn; a cache here would widen their reach past the turn that needs them.
|
|
291
|
+
return { url, headers };
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
//# sourceMappingURL=gateway.js.map
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `Record<SourceKind, ...>` registry in `adapters.ts` turns that silence into
|
|
15
15
|
* a compile error.
|
|
16
16
|
*/
|
|
17
|
-
export declare const SOURCE_CAPABLE_KINDS: readonly ["pipedream", "composio"];
|
|
17
|
+
export declare const SOURCE_CAPABLE_KINDS: readonly ["pipedream", "composio", "self-hosted"];
|
|
18
18
|
export type SourceKind = (typeof SOURCE_CAPABLE_KINDS)[number];
|
|
19
19
|
/** Where to reach an aggregator's remote MCP server for a single agent turn. */
|
|
20
20
|
export interface McpTarget {
|
|
@@ -101,9 +101,22 @@ export interface PipedreamConfig {
|
|
|
101
101
|
export interface ComposioConfig {
|
|
102
102
|
apiKey?: string | undefined;
|
|
103
103
|
}
|
|
104
|
+
/**
|
|
105
|
+
* Where this daemon's OWN source bridge lives, and the token that opens it.
|
|
106
|
+
*
|
|
107
|
+
* Unlike the aggregator configs above, both fields are user-supplied and
|
|
108
|
+
* user-hosted: paseo holds no platform credential for a self-hosted bridge, it
|
|
109
|
+
* holds a bearer for one endpoint the user runs. `configured` is false when
|
|
110
|
+
* either is missing, which is how the sources screen knows not to offer it.
|
|
111
|
+
*/
|
|
112
|
+
export interface SelfHostedConfig {
|
|
113
|
+
url?: string | undefined;
|
|
114
|
+
token?: string | undefined;
|
|
115
|
+
}
|
|
104
116
|
export interface IngestionConfig {
|
|
105
117
|
pipedream: PipedreamConfig;
|
|
106
118
|
composio: ComposioConfig;
|
|
119
|
+
selfHosted?: SelfHostedConfig | undefined;
|
|
107
120
|
}
|
|
108
121
|
/**
|
|
109
122
|
* A single-field object on purpose: later stories add a logger here without
|
|
@@ -14,5 +14,5 @@
|
|
|
14
14
|
* `Record<SourceKind, ...>` registry in `adapters.ts` turns that silence into
|
|
15
15
|
* a compile error.
|
|
16
16
|
*/
|
|
17
|
-
export const SOURCE_CAPABLE_KINDS = ["pipedream", "composio"];
|
|
17
|
+
export const SOURCE_CAPABLE_KINDS = ["pipedream", "composio", "self-hosted"];
|
|
18
18
|
//# sourceMappingURL=types.js.map
|