@vellumai/vellum-gateway 0.9.0-staging.1 → 0.9.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/node_modules/@vellumai/gateway-client/src/inbound-contract.ts +105 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +12 -0
- package/package.json +1 -1
- package/src/__tests__/remote-web-ingress-denylist.test.ts +1 -0
- package/src/__tests__/remote-web-pairing-challenge.test.ts +115 -0
- package/src/__tests__/route-schema-guard.test.ts +2 -0
- package/src/handlers/handle-inbound.ts +2 -1
- package/src/http/routes/email-webhook.ts +7 -3
- package/src/http/routes/remote-web-pairing-challenge.ts +56 -0
- package/src/index.ts +8 -0
- package/src/remote-web/pairing-challenge-store.ts +107 -0
- package/src/runtime/client.ts +3 -18
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway → daemon inbound payload contract.
|
|
3
|
+
*
|
|
4
|
+
* Zod schema defining the wire format for messages forwarded from the
|
|
5
|
+
* gateway to the daemon via `POST /v1/channels/inbound`. Both services
|
|
6
|
+
* import from here so the contract is enforced at compile time.
|
|
7
|
+
*
|
|
8
|
+
* The gateway constructs this payload in `forwardToRuntime()` from the
|
|
9
|
+
* normalized `GatewayInboundEvent`; the daemon validates and consumes
|
|
10
|
+
* it in `handleChannelInbound()`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { z } from "zod";
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Command intent (channel-initiated commands, e.g. Telegram /start)
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const CommandIntentSchema = z.object({
|
|
20
|
+
type: z.string(),
|
|
21
|
+
payload: z.string().optional(),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export type CommandIntent = z.infer<typeof CommandIntentSchema>;
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Source metadata — structured fields forwarded from the gateway's
|
|
28
|
+
// normalized inbound event. Replaces the untyped Record<string, unknown>.
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
export const SourceMetadataSchema = z
|
|
32
|
+
.object({
|
|
33
|
+
/** Provider-assigned update/event ID. */
|
|
34
|
+
updateId: z.string().optional(),
|
|
35
|
+
/** Provider message ID (e.g. Slack message `ts`). */
|
|
36
|
+
messageId: z.string().optional(),
|
|
37
|
+
/** Provider chat type (e.g. Telegram "private", "group"). */
|
|
38
|
+
chatType: z.string().optional(),
|
|
39
|
+
/** Thread/conversation-group ID (e.g. Slack `thread_ts`). */
|
|
40
|
+
threadId: z.string().optional(),
|
|
41
|
+
/** Channel name (e.g. Slack channel display name). */
|
|
42
|
+
channelName: z.string().optional(),
|
|
43
|
+
/** Actor's language code (e.g. "en", "es"). */
|
|
44
|
+
languageCode: z.string().optional(),
|
|
45
|
+
/** Whether the actor is a bot. */
|
|
46
|
+
isBot: z.boolean().optional(),
|
|
47
|
+
/** Actor's IANA timezone (e.g. "America/Los_Angeles"). */
|
|
48
|
+
timezone: z.string().optional(),
|
|
49
|
+
/** Human-readable timezone label (e.g. "Pacific Daylight Time"). */
|
|
50
|
+
timezoneLabel: z.string().optional(),
|
|
51
|
+
/** UTC offset in seconds. */
|
|
52
|
+
timezoneOffsetSeconds: z.number().optional(),
|
|
53
|
+
/** Slack-specific: actor is from an external workspace (Slack Connect). */
|
|
54
|
+
isStranger: z.boolean().optional(),
|
|
55
|
+
/** Slack-specific: actor is a guest / restricted account. */
|
|
56
|
+
isRestricted: z.boolean().optional(),
|
|
57
|
+
/** Transport-layer hints forwarded from the channel adapter. */
|
|
58
|
+
hints: z.array(z.string()).optional(),
|
|
59
|
+
/** Transport-layer UX brief. */
|
|
60
|
+
uxBrief: z.string().optional(),
|
|
61
|
+
/** Client-provided timezone for date formatting. */
|
|
62
|
+
clientTimezone: z.string().optional(),
|
|
63
|
+
/** Channel command intent (e.g. Telegram /start). */
|
|
64
|
+
commandIntent: CommandIntentSchema.optional(),
|
|
65
|
+
/** Slack-specific: whether the bot was @-mentioned. */
|
|
66
|
+
slackBotMentioned: z.boolean().optional(),
|
|
67
|
+
/** Slack workspace/team ID. */
|
|
68
|
+
account: z.string().optional(),
|
|
69
|
+
|
|
70
|
+
// Email-specific fields
|
|
71
|
+
/** Email subject line. */
|
|
72
|
+
emailSubject: z.string().optional(),
|
|
73
|
+
/** Email recipient address. */
|
|
74
|
+
emailRecipient: z.string().optional(),
|
|
75
|
+
/** Email In-Reply-To header. */
|
|
76
|
+
emailInReplyTo: z.string().optional(),
|
|
77
|
+
/** Email References header. */
|
|
78
|
+
emailReferences: z.string().optional(),
|
|
79
|
+
})
|
|
80
|
+
.passthrough();
|
|
81
|
+
|
|
82
|
+
export type SourceMetadata = z.infer<typeof SourceMetadataSchema>;
|
|
83
|
+
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Runtime inbound payload — the full wire format
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export const RuntimeInboundPayloadSchema = z.object({
|
|
89
|
+
sourceChannel: z.string(),
|
|
90
|
+
interface: z.string(),
|
|
91
|
+
conversationExternalId: z.string(),
|
|
92
|
+
externalMessageId: z.string(),
|
|
93
|
+
content: z.string(),
|
|
94
|
+
isEdit: z.boolean().optional(),
|
|
95
|
+
callbackQueryId: z.string().optional(),
|
|
96
|
+
callbackData: z.string().optional(),
|
|
97
|
+
actorDisplayName: z.string().optional(),
|
|
98
|
+
actorExternalId: z.string(),
|
|
99
|
+
actorUsername: z.string().optional(),
|
|
100
|
+
sourceMetadata: SourceMetadataSchema.optional(),
|
|
101
|
+
attachmentIds: z.array(z.string()).optional(),
|
|
102
|
+
replyCallbackUrl: z.string().optional(),
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export type RuntimeInboundPayload = z.infer<typeof RuntimeInboundPayloadSchema>;
|
|
@@ -32,3 +32,15 @@ export type {
|
|
|
32
32
|
} from "./types.js";
|
|
33
33
|
|
|
34
34
|
export { noopLogger } from "./types.js";
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
CommandIntentSchema,
|
|
38
|
+
RuntimeInboundPayloadSchema,
|
|
39
|
+
SourceMetadataSchema,
|
|
40
|
+
} from "./inbound-contract.js";
|
|
41
|
+
|
|
42
|
+
export type {
|
|
43
|
+
CommandIntent,
|
|
44
|
+
RuntimeInboundPayload,
|
|
45
|
+
SourceMetadata,
|
|
46
|
+
} from "./inbound-contract.js";
|
package/package.json
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const { handleCreateRemoteWebPairingChallenge } =
|
|
4
|
+
await import("../http/routes/remote-web-pairing-challenge.js");
|
|
5
|
+
const {
|
|
6
|
+
getRemoteWebPairingChallengeForTests,
|
|
7
|
+
resetRemoteWebPairingChallengesForTests,
|
|
8
|
+
setRemoteWebPairingChallengeNowForTests,
|
|
9
|
+
} = await import("../remote-web/pairing-challenge-store.js");
|
|
10
|
+
|
|
11
|
+
const LOOPBACK_IP = "127.0.0.1";
|
|
12
|
+
const PUBLIC_BASE_URL = "https://paired.example.com";
|
|
13
|
+
|
|
14
|
+
function makeRequest(
|
|
15
|
+
overrides: { publicBaseUrl?: string; edgeForwarded?: boolean } = {},
|
|
16
|
+
): Request {
|
|
17
|
+
const headers: Record<string, string> = {
|
|
18
|
+
host: "localhost:7830",
|
|
19
|
+
"content-type": "application/json",
|
|
20
|
+
};
|
|
21
|
+
if (overrides.edgeForwarded) {
|
|
22
|
+
headers["x-vellum-edge-forwarded"] = "1";
|
|
23
|
+
}
|
|
24
|
+
return new Request("http://localhost:7830/v1/remote-web/pairing-challenge", {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers,
|
|
27
|
+
body: JSON.stringify({
|
|
28
|
+
publicBaseUrl: overrides.publicBaseUrl ?? PUBLIC_BASE_URL,
|
|
29
|
+
}),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
resetRemoteWebPairingChallengesForTests();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("remote web pairing challenge", () => {
|
|
38
|
+
test("creates an RFC-style short-lived challenge over direct loopback", async () => {
|
|
39
|
+
setRemoteWebPairingChallengeNowForTests(() => 1_000);
|
|
40
|
+
|
|
41
|
+
const res = await handleCreateRemoteWebPairingChallenge(
|
|
42
|
+
makeRequest({ publicBaseUrl: `${PUBLIC_BASE_URL}/` }),
|
|
43
|
+
LOOPBACK_IP,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
expect(res.status).toBe(200);
|
|
47
|
+
expect(res.headers.get("Cache-Control")).toBe("no-store");
|
|
48
|
+
|
|
49
|
+
const body = (await res.json()) as {
|
|
50
|
+
deviceCode: string;
|
|
51
|
+
userCode: string;
|
|
52
|
+
verificationUri: string;
|
|
53
|
+
expiresAt: string;
|
|
54
|
+
expiresInSeconds: number;
|
|
55
|
+
intervalSeconds: number;
|
|
56
|
+
};
|
|
57
|
+
expect(body.deviceCode).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
58
|
+
expect(body.userCode).toMatch(/^[A-HJ-NP-Z2-9]{4}-[A-HJ-NP-Z2-9]{4}$/);
|
|
59
|
+
expect(body.verificationUri).toBe(`${PUBLIC_BASE_URL}/assistant/pair`);
|
|
60
|
+
expect(body.expiresAt).toBe("1970-01-01T00:10:01.000Z");
|
|
61
|
+
expect(body.expiresInSeconds).toBe(600);
|
|
62
|
+
expect(body.intervalSeconds).toBe(5);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("preserves path-prefixed public base URLs in the verification URI", async () => {
|
|
66
|
+
const publicBaseUrl = "https://velay.example.test/assistant-123/";
|
|
67
|
+
|
|
68
|
+
const res = await handleCreateRemoteWebPairingChallenge(
|
|
69
|
+
makeRequest({ publicBaseUrl }),
|
|
70
|
+
LOOPBACK_IP,
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
expect(res.status).toBe(200);
|
|
74
|
+
|
|
75
|
+
const body = (await res.json()) as {
|
|
76
|
+
verificationUri: string;
|
|
77
|
+
userCode: string;
|
|
78
|
+
};
|
|
79
|
+
expect(body.verificationUri).toBe(
|
|
80
|
+
"https://velay.example.test/assistant-123/assistant/pair",
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const record = getRemoteWebPairingChallengeForTests(body.userCode);
|
|
84
|
+
expect(record?.publicBaseUrl).toBe(
|
|
85
|
+
"https://velay.example.test/assistant-123",
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("stores only hashed challenge secrets", async () => {
|
|
90
|
+
const res = await handleCreateRemoteWebPairingChallenge(
|
|
91
|
+
makeRequest(),
|
|
92
|
+
LOOPBACK_IP,
|
|
93
|
+
);
|
|
94
|
+
const body = (await res.json()) as {
|
|
95
|
+
deviceCode: string;
|
|
96
|
+
userCode: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const record = getRemoteWebPairingChallengeForTests(body.userCode);
|
|
100
|
+
|
|
101
|
+
expect(record).toBeDefined();
|
|
102
|
+
expect(record?.deviceCodeHash).not.toBe(body.deviceCode);
|
|
103
|
+
expect(record?.userCodeHash).not.toBe(body.userCode);
|
|
104
|
+
expect(record?.publicBaseUrl).toBe(PUBLIC_BASE_URL);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("rejects challenge creation through the nginx edge", async () => {
|
|
108
|
+
const res = await handleCreateRemoteWebPairingChallenge(
|
|
109
|
+
makeRequest({ edgeForwarded: true }),
|
|
110
|
+
LOOPBACK_IP,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(res.status).toBe(403);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -171,6 +171,8 @@ const EXCLUDED_FROM_SCHEMA = new Set([
|
|
|
171
171
|
"catch-all",
|
|
172
172
|
// Loopback-only pairing endpoint — not part of the public gateway API
|
|
173
173
|
"/v1/pair",
|
|
174
|
+
// Loopback-only remote web pairing challenge — not part of the public gateway API
|
|
175
|
+
"/v1/remote-web/pairing-challenge",
|
|
174
176
|
// Loopback-only device management — not part of the public gateway API
|
|
175
177
|
"/v1/devices",
|
|
176
178
|
"/v1/devices/revoke",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
+
import type { SourceMetadata } from "@vellumai/gateway-client";
|
|
2
3
|
import type { GatewayConfig } from "../config.js";
|
|
3
4
|
import { ipcCallAssistant } from "../ipc/assistant-client.js";
|
|
4
5
|
import { resolveIpcSocketPath } from "../ipc/socket-path.js";
|
|
@@ -40,7 +41,7 @@ export type HandleInboundOptions = {
|
|
|
40
41
|
/** When provided, skip resolveAssistant() and use this pre-resolved route. */
|
|
41
42
|
routingOverride?: RouteResult;
|
|
42
43
|
/** Extra fields merged into sourceMetadata (e.g. commandIntent). */
|
|
43
|
-
sourceMetadata?:
|
|
44
|
+
sourceMetadata?: Partial<SourceMetadata>;
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
function normalizeTransportHints(hints: string[] | undefined): string[] {
|
|
@@ -199,8 +199,10 @@ export function createEmailWebhookHandler(
|
|
|
199
199
|
sourceMetadata: {
|
|
200
200
|
emailSubject: (payload.subject as string | undefined) ?? undefined,
|
|
201
201
|
emailRecipient: recipientAddress,
|
|
202
|
-
...(
|
|
203
|
-
|
|
202
|
+
...(typeof payload.inReplyTo === "string"
|
|
203
|
+
? { emailInReplyTo: payload.inReplyTo }
|
|
204
|
+
: {}),
|
|
205
|
+
...(typeof payload.references === "string"
|
|
204
206
|
? { emailReferences: payload.references }
|
|
205
207
|
: {}),
|
|
206
208
|
},
|
|
@@ -242,7 +244,9 @@ export function createEmailWebhookHandler(
|
|
|
242
244
|
|
|
243
245
|
if (!result.rejected) {
|
|
244
246
|
const denied = result.runtimeResponse?.denied ?? false;
|
|
245
|
-
const deniedReason = denied
|
|
247
|
+
const deniedReason = denied
|
|
248
|
+
? (result.runtimeResponse?.reason ?? "unknown")
|
|
249
|
+
: undefined;
|
|
246
250
|
tlog.info(
|
|
247
251
|
{
|
|
248
252
|
status: denied ? "denied" : "forwarded",
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createRemoteWebPairingChallenge } from "../../remote-web/pairing-challenge-store.js";
|
|
2
|
+
import { enforceLoopbackOnly } from "../loopback-guard.js";
|
|
3
|
+
|
|
4
|
+
function parsePublicBaseUrl(value: unknown): string | null {
|
|
5
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
6
|
+
try {
|
|
7
|
+
const url = new URL(value);
|
|
8
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
9
|
+
if (!url.host) return null;
|
|
10
|
+
const pathPrefix = url.pathname.replace(/\/+$/, "");
|
|
11
|
+
return `${url.origin}${pathPrefix}`;
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function jsonError(code: string, message: string, status: number): Response {
|
|
18
|
+
return Response.json({ error: { code, message } }, { status });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function handleCreateRemoteWebPairingChallenge(
|
|
22
|
+
req: Request,
|
|
23
|
+
clientIp: string,
|
|
24
|
+
): Promise<Response> {
|
|
25
|
+
if (req.method !== "POST") {
|
|
26
|
+
return new Response("method not allowed", {
|
|
27
|
+
status: 405,
|
|
28
|
+
headers: { Allow: "POST" },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const guardError = enforceLoopbackOnly(
|
|
33
|
+
req,
|
|
34
|
+
clientIp,
|
|
35
|
+
"remote-web-pairing-challenge",
|
|
36
|
+
);
|
|
37
|
+
if (guardError) return guardError;
|
|
38
|
+
|
|
39
|
+
let publicBaseUrl: string | null = null;
|
|
40
|
+
try {
|
|
41
|
+
const body = (await req.json()) as { publicBaseUrl?: unknown };
|
|
42
|
+
publicBaseUrl = parsePublicBaseUrl(body.publicBaseUrl);
|
|
43
|
+
} catch {
|
|
44
|
+
return jsonError("BAD_REQUEST", "invalid JSON body", 400);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!publicBaseUrl) {
|
|
48
|
+
return jsonError("BAD_REQUEST", "publicBaseUrl is required", 400);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const challenge = createRemoteWebPairingChallenge(publicBaseUrl);
|
|
52
|
+
|
|
53
|
+
return Response.json(challenge, {
|
|
54
|
+
headers: { "Cache-Control": "no-store" },
|
|
55
|
+
});
|
|
56
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
handleRevokeDevice,
|
|
95
95
|
} from "./http/routes/devices.js";
|
|
96
96
|
import { handlePair } from "./http/routes/pair.js";
|
|
97
|
+
import { handleCreateRemoteWebPairingChallenge } from "./http/routes/remote-web-pairing-challenge.js";
|
|
97
98
|
import { createSlackControlPlaneProxyHandler } from "./http/routes/slack-control-plane-proxy.js";
|
|
98
99
|
import { createOAuthAppsProxyHandler } from "./http/routes/oauth-apps-proxy.js";
|
|
99
100
|
import { createOAuthProvidersProxyHandler } from "./http/routes/oauth-providers-proxy.js";
|
|
@@ -830,6 +831,13 @@ async function main() {
|
|
|
830
831
|
auth: "none",
|
|
831
832
|
handler: (req, _params, getClientIp) => handlePair(req, getClientIp()),
|
|
832
833
|
},
|
|
834
|
+
{
|
|
835
|
+
path: "/v1/remote-web/pairing-challenge",
|
|
836
|
+
method: "POST",
|
|
837
|
+
auth: "none",
|
|
838
|
+
handler: (req, _params, getClientIp) =>
|
|
839
|
+
handleCreateRemoteWebPairingChallenge(req, getClientIp()),
|
|
840
|
+
},
|
|
833
841
|
// ── Device management (localhost-only, auth: none; self-guards loopback) ──
|
|
834
842
|
{
|
|
835
843
|
path: "/v1/devices",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomInt } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const CODE_TTL_MS = 10 * 60 * 1000;
|
|
4
|
+
const USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
5
|
+
const USER_CODE_LENGTH = 8;
|
|
6
|
+
const DEVICE_CODE_BYTES = 32;
|
|
7
|
+
const POLL_INTERVAL_SECONDS = 5;
|
|
8
|
+
|
|
9
|
+
export interface PendingRemoteWebPairingChallenge {
|
|
10
|
+
deviceCodeHash: string;
|
|
11
|
+
userCodeHash: string;
|
|
12
|
+
publicBaseUrl: string;
|
|
13
|
+
verificationUri: string;
|
|
14
|
+
expiresAtMs: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CreatedRemoteWebPairingChallenge {
|
|
18
|
+
deviceCode: string;
|
|
19
|
+
userCode: string;
|
|
20
|
+
verificationUri: string;
|
|
21
|
+
expiresAt: string;
|
|
22
|
+
expiresInSeconds: number;
|
|
23
|
+
intervalSeconds: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const challengesByUserCodeHash = new Map<
|
|
27
|
+
string,
|
|
28
|
+
PendingRemoteWebPairingChallenge
|
|
29
|
+
>();
|
|
30
|
+
let nowForTests: (() => number) | null = null;
|
|
31
|
+
|
|
32
|
+
function nowMs(): number {
|
|
33
|
+
return nowForTests?.() ?? Date.now();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hashSecret(value: string): string {
|
|
37
|
+
return createHash("sha256").update(value).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function randomUserCode(): string {
|
|
41
|
+
let code = "";
|
|
42
|
+
for (let i = 0; i < USER_CODE_LENGTH; i++) {
|
|
43
|
+
code += USER_CODE_ALPHABET[randomInt(USER_CODE_ALPHABET.length)];
|
|
44
|
+
}
|
|
45
|
+
return `${code.slice(0, 4)}-${code.slice(4)}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeUserCode(code: string): string {
|
|
49
|
+
return code.replace(/[^A-Z0-9]/gi, "").toUpperCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function cleanupExpiredChallenges(): void {
|
|
53
|
+
const now = nowMs();
|
|
54
|
+
for (const [hash, challenge] of challengesByUserCodeHash) {
|
|
55
|
+
if (challenge.expiresAtMs <= now) challengesByUserCodeHash.delete(hash);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createRemoteWebPairingChallenge(
|
|
60
|
+
publicBaseUrl: string,
|
|
61
|
+
): CreatedRemoteWebPairingChallenge {
|
|
62
|
+
cleanupExpiredChallenges();
|
|
63
|
+
|
|
64
|
+
const deviceCode = randomBytes(DEVICE_CODE_BYTES).toString("base64url");
|
|
65
|
+
let userCode = randomUserCode();
|
|
66
|
+
let userCodeHash = hashSecret(normalizeUserCode(userCode));
|
|
67
|
+
while (challengesByUserCodeHash.has(userCodeHash)) {
|
|
68
|
+
userCode = randomUserCode();
|
|
69
|
+
userCodeHash = hashSecret(normalizeUserCode(userCode));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const verificationUri = `${publicBaseUrl}/assistant/pair`;
|
|
73
|
+
const expiresAtMs = nowMs() + CODE_TTL_MS;
|
|
74
|
+
challengesByUserCodeHash.set(userCodeHash, {
|
|
75
|
+
deviceCodeHash: hashSecret(deviceCode),
|
|
76
|
+
userCodeHash,
|
|
77
|
+
publicBaseUrl,
|
|
78
|
+
verificationUri,
|
|
79
|
+
expiresAtMs,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
deviceCode,
|
|
84
|
+
userCode,
|
|
85
|
+
verificationUri,
|
|
86
|
+
expiresAt: new Date(expiresAtMs).toISOString(),
|
|
87
|
+
expiresInSeconds: Math.ceil(CODE_TTL_MS / 1000),
|
|
88
|
+
intervalSeconds: POLL_INTERVAL_SECONDS,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function resetRemoteWebPairingChallengesForTests(): void {
|
|
93
|
+
challengesByUserCodeHash.clear();
|
|
94
|
+
nowForTests = null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function setRemoteWebPairingChallengeNowForTests(
|
|
98
|
+
now: () => number,
|
|
99
|
+
): void {
|
|
100
|
+
nowForTests = now;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function getRemoteWebPairingChallengeForTests(
|
|
104
|
+
userCode: string,
|
|
105
|
+
): PendingRemoteWebPairingChallenge | undefined {
|
|
106
|
+
return challengesByUserCodeHash.get(hashSecret(normalizeUserCode(userCode)));
|
|
107
|
+
}
|
package/src/runtime/client.ts
CHANGED
|
@@ -7,7 +7,8 @@ import {
|
|
|
7
7
|
TWILIO_PUBLIC_BASE_WSS_PLACEHOLDER,
|
|
8
8
|
} from "@vellumai/service-contracts/twilio-ingress";
|
|
9
9
|
|
|
10
|
-
import type {
|
|
10
|
+
import type { RuntimeInboundPayload } from "@vellumai/gateway-client";
|
|
11
|
+
import type { ChannelId } from "../channels/types.js";
|
|
11
12
|
import type { ConfigFileCache } from "../config-file-cache.js";
|
|
12
13
|
import {
|
|
13
14
|
mintIngressToken,
|
|
@@ -165,23 +166,7 @@ export class AttachmentValidationError extends Error {
|
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
export type RuntimeInboundPayload
|
|
169
|
-
sourceChannel: ChannelId;
|
|
170
|
-
/** Explicit interface identifier forwarded to the assistant. */
|
|
171
|
-
interface: InterfaceId;
|
|
172
|
-
conversationExternalId: string;
|
|
173
|
-
externalMessageId: string;
|
|
174
|
-
content: string;
|
|
175
|
-
isEdit?: boolean;
|
|
176
|
-
callbackQueryId?: string;
|
|
177
|
-
callbackData?: string;
|
|
178
|
-
actorDisplayName?: string;
|
|
179
|
-
actorExternalId: string;
|
|
180
|
-
actorUsername?: string;
|
|
181
|
-
sourceMetadata?: Record<string, unknown>;
|
|
182
|
-
attachmentIds?: string[];
|
|
183
|
-
replyCallbackUrl?: string;
|
|
184
|
-
};
|
|
169
|
+
export type { RuntimeInboundPayload } from "@vellumai/gateway-client";
|
|
185
170
|
|
|
186
171
|
export type RuntimeAttachmentMeta = {
|
|
187
172
|
id: string;
|