@vellumai/vellum-gateway 0.7.0 → 0.7.2
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/AGENTS.md +4 -0
- package/ARCHITECTURE.md +67 -25
- package/Dockerfile +2 -0
- package/README.md +50 -13
- package/bun.lock +16 -2
- package/knip.json +3 -1
- package/package.json +3 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/config.test.ts +0 -1
- package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
- package/src/__tests__/credential-watcher.test.ts +30 -2
- package/src/__tests__/db-connection-isolation.test.ts +157 -0
- package/src/__tests__/fake-assistant-ipc.ts +39 -0
- package/src/__tests__/feature-flags-route.test.ts +8 -8
- package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
- package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
- package/src/__tests__/live-voice-websocket.test.ts +0 -1
- package/src/__tests__/load-guards.test.ts +0 -1
- package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
- package/src/__tests__/oauth-callback.test.ts +0 -1
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
- package/src/__tests__/resolve-assistant.test.ts +0 -1
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/runtime-client.test.ts +0 -1
- package/src/__tests__/runtime-health-proxy.test.ts +0 -1
- package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
- package/src/__tests__/runtime-proxy.test.ts +0 -1
- package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/slack-display-name.test.ts +66 -1
- package/src/__tests__/slack-normalize.test.ts +158 -4
- package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
- package/src/__tests__/stt-stream-websocket.test.ts +0 -1
- package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
- package/src/__tests__/telegram-send-attachments.test.ts +0 -1
- package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
- package/src/__tests__/text-verification-helpers.test.ts +136 -0
- package/src/__tests__/twilio-media-websocket.test.ts +0 -1
- package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
- package/src/__tests__/twilio-webhooks.test.ts +220 -3
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/__tests__/whatsapp-download.test.ts +0 -1
- package/src/__tests__/whatsapp-webhook.test.ts +0 -1
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +217 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/cli/enable-proxy.ts +0 -1
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -7
- package/src/db/connection.ts +65 -3
- package/src/db/contact-store.ts +30 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +92 -0
- package/src/db/slack-store.ts +144 -11
- package/src/feature-flag-registry.json +40 -152
- package/src/handlers/handle-inbound.ts +123 -0
- package/src/http/middleware/auth.ts +44 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/brain-graph-proxy.ts +1 -1
- package/src/http/routes/channel-readiness-proxy.ts +2 -2
- package/src/http/routes/channel-verification-session-proxy.ts +19 -37
- package/src/http/routes/contact-prompt.ts +149 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
- package/src/http/routes/email-webhook.test.ts +0 -1
- package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
- package/src/http/routes/ipc-runtime-proxy.ts +95 -0
- package/src/http/routes/log-export.test.ts +0 -1
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/migration-proxy.ts +1 -2
- package/src/http/routes/oauth-apps-proxy.ts +2 -2
- package/src/http/routes/oauth-providers-proxy.ts +2 -2
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-health-proxy.ts +2 -2
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/slack-control-plane-proxy.ts +3 -20
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
- package/src/http/routes/telegram-webhook.test.ts +0 -1
- package/src/http/routes/telegram-webhook.ts +6 -0
- package/src/http/routes/trust-rules.suggest.test.ts +25 -0
- package/src/http/routes/trust-rules.ts +7 -0
- package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
- package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
- package/src/http/routes/twilio-voice-webhook.ts +45 -1
- package/src/http/routes/whatsapp-webhook.test.ts +0 -1
- package/src/index.ts +357 -278
- package/src/ipc/assistant-client.ts +8 -4
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/post-assistant-ready.ts +5 -3
- package/src/risk/bash-risk-classifier.test.ts +35 -27
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +8 -19
- package/src/risk/command-registry.test.ts +0 -15
- package/src/risk/risk-classifier-parity.test.ts +1 -3
- package/src/runtime/client.ts +58 -3
- package/src/schema.ts +277 -104
- package/src/slack/normalize.test.ts +98 -0
- package/src/slack/normalize.ts +107 -32
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +701 -39
- package/src/telegram/send.test.ts +0 -1
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +555 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/binding-helpers.ts +107 -0
- package/src/verification/code-parsing.ts +44 -0
- package/src/verification/contact-helpers.ts +342 -0
- package/src/verification/identity-match.ts +68 -0
- package/src/verification/identity.ts +61 -0
- package/src/verification/rate-limit-helpers.ts +205 -0
- package/src/verification/reply-delivery.ts +109 -0
- package/src/verification/session-helpers.ts +164 -0
- package/src/verification/text-verification.ts +372 -0
- package/src/version.ts +35 -0
- package/src/voice/verification.ts +456 -0
- package/src/webhook-pipeline.ts +4 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -698
- package/src/__tests__/telegram-only-default.test.ts +0 -133
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
|
@@ -1,379 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Gateway-native pairing route handlers.
|
|
3
|
-
*
|
|
4
|
-
* Handles the full iOS pairing flow directly in the gateway:
|
|
5
|
-
* - POST /pairing/register — macOS pre-registers a pairing request (QR displayed)
|
|
6
|
-
* - POST /pairing/request — iOS initiates a pairing handshake
|
|
7
|
-
* - GET /pairing/status — iOS polls for approval status
|
|
8
|
-
*
|
|
9
|
-
* Credentials (JWT access token + opaque refresh token) are minted via
|
|
10
|
-
* guardian-bootstrap, which writes directly to the assistant's SQLite
|
|
11
|
-
* database with dual-writes to the gateway DB.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { bootstrapGuardian } from "../auth/guardian-bootstrap.js";
|
|
15
|
-
import { mintServiceToken } from "../auth/token-exchange.js";
|
|
16
|
-
import { getLogger } from "../logger.js";
|
|
17
|
-
|
|
18
|
-
import {
|
|
19
|
-
hashDeviceId,
|
|
20
|
-
isDeviceApproved,
|
|
21
|
-
refreshDevice,
|
|
22
|
-
} from "./approved-devices-store.js";
|
|
23
|
-
import { PairingStore } from "./pairing-store.js";
|
|
24
|
-
|
|
25
|
-
const log = getLogger("pairing-routes");
|
|
26
|
-
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// Credential types
|
|
29
|
-
// ---------------------------------------------------------------------------
|
|
30
|
-
|
|
31
|
-
interface PairingCredentials {
|
|
32
|
-
accessToken: string;
|
|
33
|
-
accessTokenExpiresAt: number;
|
|
34
|
-
refreshToken: string;
|
|
35
|
-
refreshTokenExpiresAt: number;
|
|
36
|
-
refreshAfter: number;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// ---------------------------------------------------------------------------
|
|
40
|
-
// Credential minting (delegates to gateway's guardian-bootstrap)
|
|
41
|
-
// ---------------------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
async function mintPairingCredentials(
|
|
44
|
-
deviceId: string,
|
|
45
|
-
platform: string,
|
|
46
|
-
): Promise<PairingCredentials | null> {
|
|
47
|
-
try {
|
|
48
|
-
const result = await bootstrapGuardian({ platform, deviceId });
|
|
49
|
-
log.info({ platform }, "Minted credentials during pairing");
|
|
50
|
-
return {
|
|
51
|
-
accessToken: result.accessToken,
|
|
52
|
-
accessTokenExpiresAt: result.accessTokenExpiresAt,
|
|
53
|
-
refreshToken: result.refreshToken,
|
|
54
|
-
refreshTokenExpiresAt: result.refreshTokenExpiresAt,
|
|
55
|
-
refreshAfter: result.refreshAfter,
|
|
56
|
-
};
|
|
57
|
-
} catch (err) {
|
|
58
|
-
log.warn(
|
|
59
|
-
{ err },
|
|
60
|
-
"Failed to mint credentials during pairing — continuing without them",
|
|
61
|
-
);
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
// ---------------------------------------------------------------------------
|
|
67
|
-
// Transient in-memory state
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
|
|
70
|
-
const PAIRING_PENDING_DEVICE_ID_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
|
71
|
-
const pendingDeviceIds = new Map<
|
|
72
|
-
string,
|
|
73
|
-
{ deviceId: string; createdAt: number }
|
|
74
|
-
>();
|
|
75
|
-
|
|
76
|
-
const PAIRING_CREDENTIAL_RETRIEVAL_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
77
|
-
const approvedCredentials = new Map<
|
|
78
|
-
string,
|
|
79
|
-
{ credentials: PairingCredentials; approvedAt: number }
|
|
80
|
-
>();
|
|
81
|
-
|
|
82
|
-
function sweepApprovedCredentials(): void {
|
|
83
|
-
const now = Date.now();
|
|
84
|
-
for (const [id, entry] of approvedCredentials) {
|
|
85
|
-
if (now - entry.approvedAt > PAIRING_CREDENTIAL_RETRIEVAL_TTL_MS) {
|
|
86
|
-
approvedCredentials.delete(id);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function sweepPendingDeviceIds(): void {
|
|
92
|
-
const now = Date.now();
|
|
93
|
-
for (const [id, entry] of pendingDeviceIds) {
|
|
94
|
-
if (now - entry.createdAt > PAIRING_PENDING_DEVICE_ID_TTL_MS) {
|
|
95
|
-
pendingDeviceIds.delete(id);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const mintingInFlight = new Set<string>();
|
|
101
|
-
|
|
102
|
-
function cleanupPairingState(pairingRequestId: string): void {
|
|
103
|
-
pendingDeviceIds.delete(pairingRequestId);
|
|
104
|
-
approvedCredentials.delete(pairingRequestId);
|
|
105
|
-
mintingInFlight.delete(pairingRequestId);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
// ---------------------------------------------------------------------------
|
|
109
|
-
// HTTP helpers
|
|
110
|
-
// ---------------------------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
const MAX_PAIRING_BODY_BYTES = 4096;
|
|
113
|
-
|
|
114
|
-
function jsonError(code: string, message: string, status: number): Response {
|
|
115
|
-
return Response.json({ error: { code, message } }, { status });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
// Route handler factory
|
|
120
|
-
// ---------------------------------------------------------------------------
|
|
121
|
-
|
|
122
|
-
export function createPairingHandler(deps: {
|
|
123
|
-
pairingStore: PairingStore;
|
|
124
|
-
getBearerToken?: () => string | undefined;
|
|
125
|
-
}) {
|
|
126
|
-
const { pairingStore } = deps;
|
|
127
|
-
|
|
128
|
-
function getOrMintBearerToken(): string | undefined {
|
|
129
|
-
if (deps.getBearerToken) {
|
|
130
|
-
const token = deps.getBearerToken();
|
|
131
|
-
if (token) return token;
|
|
132
|
-
}
|
|
133
|
-
try {
|
|
134
|
-
return mintServiceToken();
|
|
135
|
-
} catch {
|
|
136
|
-
return undefined;
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
/**
|
|
142
|
-
* POST /pairing/register — Bearer-authenticated.
|
|
143
|
-
* macOS pre-registers a pairing request when the QR is displayed.
|
|
144
|
-
*/
|
|
145
|
-
async handlePairingRegister(req: Request): Promise<Response> {
|
|
146
|
-
try {
|
|
147
|
-
const body = (await req.json()) as Record<string, unknown>;
|
|
148
|
-
const pairingRequestId =
|
|
149
|
-
typeof body.pairingRequestId === "string"
|
|
150
|
-
? body.pairingRequestId
|
|
151
|
-
: "";
|
|
152
|
-
const pairingSecret =
|
|
153
|
-
typeof body.pairingSecret === "string" ? body.pairingSecret : "";
|
|
154
|
-
const gatewayUrl =
|
|
155
|
-
typeof body.gatewayUrl === "string" ? body.gatewayUrl : "";
|
|
156
|
-
const localLanUrl =
|
|
157
|
-
typeof body.localLanUrl === "string" ? body.localLanUrl : null;
|
|
158
|
-
|
|
159
|
-
if (!pairingRequestId || !pairingSecret || !gatewayUrl) {
|
|
160
|
-
return jsonError(
|
|
161
|
-
"BAD_REQUEST",
|
|
162
|
-
"Missing required fields: pairingRequestId, pairingSecret, gatewayUrl",
|
|
163
|
-
400,
|
|
164
|
-
);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
const result = pairingStore.register({
|
|
168
|
-
pairingRequestId,
|
|
169
|
-
pairingSecret,
|
|
170
|
-
gatewayUrl,
|
|
171
|
-
localLanUrl,
|
|
172
|
-
});
|
|
173
|
-
if (!result.ok) {
|
|
174
|
-
const message =
|
|
175
|
-
result.reason === "active_pairing"
|
|
176
|
-
? "A pairing request is already in progress"
|
|
177
|
-
: "Conflict: pairingRequestId exists with different secret";
|
|
178
|
-
return jsonError("CONFLICT", message, 409);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return Response.json({ ok: true });
|
|
182
|
-
} catch (err) {
|
|
183
|
-
log.error({ err }, "Failed to register pairing request");
|
|
184
|
-
return jsonError("INTERNAL_ERROR", "Internal server error", 500);
|
|
185
|
-
}
|
|
186
|
-
},
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* POST /pairing/request — Unauthenticated (secret-gated).
|
|
190
|
-
* iOS initiates a pairing handshake.
|
|
191
|
-
*/
|
|
192
|
-
async handlePairingRequest(req: Request): Promise<Response> {
|
|
193
|
-
try {
|
|
194
|
-
const contentLength = req.headers.get("content-length");
|
|
195
|
-
if (contentLength && Number(contentLength) > MAX_PAIRING_BODY_BYTES) {
|
|
196
|
-
return jsonError("BAD_REQUEST", "Payload too large", 413);
|
|
197
|
-
}
|
|
198
|
-
const body = (await req.json()) as Record<string, unknown>;
|
|
199
|
-
const pairingRequestId =
|
|
200
|
-
typeof body.pairingRequestId === "string"
|
|
201
|
-
? body.pairingRequestId
|
|
202
|
-
: "";
|
|
203
|
-
const pairingSecret =
|
|
204
|
-
typeof body.pairingSecret === "string" ? body.pairingSecret : "";
|
|
205
|
-
const deviceId =
|
|
206
|
-
typeof body.deviceId === "string" ? body.deviceId.trim() : "";
|
|
207
|
-
const deviceName =
|
|
208
|
-
typeof body.deviceName === "string" ? body.deviceName.trim() : "";
|
|
209
|
-
|
|
210
|
-
log.info(
|
|
211
|
-
{ pairingRequestId, deviceName, hasDeviceId: !!deviceId },
|
|
212
|
-
"Pairing request received",
|
|
213
|
-
);
|
|
214
|
-
|
|
215
|
-
if (!deviceId || !deviceName) {
|
|
216
|
-
return jsonError(
|
|
217
|
-
"BAD_REQUEST",
|
|
218
|
-
"Missing required fields: deviceId, deviceName",
|
|
219
|
-
400,
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (!pairingRequestId || !pairingSecret) {
|
|
224
|
-
return jsonError(
|
|
225
|
-
"BAD_REQUEST",
|
|
226
|
-
"Missing required fields: pairingRequestId, pairingSecret",
|
|
227
|
-
400,
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const result = pairingStore.beginRequest({
|
|
232
|
-
pairingRequestId,
|
|
233
|
-
pairingSecret,
|
|
234
|
-
deviceId,
|
|
235
|
-
deviceName,
|
|
236
|
-
});
|
|
237
|
-
if (!result.ok) {
|
|
238
|
-
if (result.reason === "already_paired") {
|
|
239
|
-
return jsonError(
|
|
240
|
-
"CONFLICT",
|
|
241
|
-
"This pairing request is already bound to another device",
|
|
242
|
-
409,
|
|
243
|
-
);
|
|
244
|
-
}
|
|
245
|
-
const statusCode =
|
|
246
|
-
result.reason === "invalid_secret"
|
|
247
|
-
? 403
|
|
248
|
-
: result.reason === "not_found"
|
|
249
|
-
? 403
|
|
250
|
-
: 410;
|
|
251
|
-
return jsonError("FORBIDDEN", "Forbidden", statusCode);
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const entry = result.entry;
|
|
255
|
-
const hashedDeviceId = hashDeviceId(deviceId);
|
|
256
|
-
const bearerToken = getOrMintBearerToken();
|
|
257
|
-
|
|
258
|
-
// Auto-approve if device is in the allowlist
|
|
259
|
-
if (isDeviceApproved(hashedDeviceId) && bearerToken) {
|
|
260
|
-
refreshDevice(hashedDeviceId, deviceName);
|
|
261
|
-
pairingStore.approve(pairingRequestId, bearerToken);
|
|
262
|
-
log.info(
|
|
263
|
-
{ pairingRequestId, hashedDeviceId },
|
|
264
|
-
"Auto-approved allowlisted device",
|
|
265
|
-
);
|
|
266
|
-
const credentials = await mintPairingCredentials(deviceId, "ios");
|
|
267
|
-
return Response.json({
|
|
268
|
-
status: "approved",
|
|
269
|
-
bearerToken,
|
|
270
|
-
gatewayUrl: entry.gatewayUrl,
|
|
271
|
-
localLanUrl: entry.localLanUrl,
|
|
272
|
-
...(credentials
|
|
273
|
-
? {
|
|
274
|
-
accessToken: credentials.accessToken,
|
|
275
|
-
accessTokenExpiresAt: credentials.accessTokenExpiresAt,
|
|
276
|
-
refreshToken: credentials.refreshToken,
|
|
277
|
-
refreshTokenExpiresAt: credentials.refreshTokenExpiresAt,
|
|
278
|
-
refreshAfter: credentials.refreshAfter,
|
|
279
|
-
}
|
|
280
|
-
: {}),
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
// Store the raw deviceId transiently so we can mint the actor token
|
|
285
|
-
// later when the pairing is actually approved.
|
|
286
|
-
pendingDeviceIds.set(pairingRequestId, {
|
|
287
|
-
deviceId,
|
|
288
|
-
createdAt: Date.now(),
|
|
289
|
-
});
|
|
290
|
-
|
|
291
|
-
return Response.json({ status: "pending" });
|
|
292
|
-
} catch (err) {
|
|
293
|
-
log.error({ err }, "Failed to process pairing request");
|
|
294
|
-
return jsonError("INTERNAL_ERROR", "Internal server error", 500);
|
|
295
|
-
}
|
|
296
|
-
},
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* GET /pairing/status — Unauthenticated (secret-gated).
|
|
300
|
-
* iOS polls for approval status.
|
|
301
|
-
*/
|
|
302
|
-
async handlePairingStatus(req: Request): Promise<Response> {
|
|
303
|
-
const url = new URL(req.url);
|
|
304
|
-
const id = url.searchParams.get("id") ?? "";
|
|
305
|
-
const secret = url.searchParams.get("secret") ?? "";
|
|
306
|
-
const deviceId = (url.searchParams.get("deviceId") ?? "").trim();
|
|
307
|
-
|
|
308
|
-
if (!id || !secret) {
|
|
309
|
-
return jsonError(
|
|
310
|
-
"BAD_REQUEST",
|
|
311
|
-
"Missing required params: id, secret",
|
|
312
|
-
400,
|
|
313
|
-
);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (!pairingStore.validateSecret(id, secret)) {
|
|
317
|
-
return jsonError("FORBIDDEN", "Forbidden", 403);
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
sweepApprovedCredentials();
|
|
321
|
-
sweepPendingDeviceIds();
|
|
322
|
-
|
|
323
|
-
const entry = pairingStore.get(id);
|
|
324
|
-
if (!entry) {
|
|
325
|
-
pendingDeviceIds.delete(id);
|
|
326
|
-
return jsonError("NOT_FOUND", "Not found", 404);
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (entry.status === "approved") {
|
|
330
|
-
let credentialEntry = approvedCredentials.get(id);
|
|
331
|
-
if (!credentialEntry && !mintingInFlight.has(id)) {
|
|
332
|
-
const pending = pendingDeviceIds.get(id);
|
|
333
|
-
const deviceIdMatchesEntry = Boolean(
|
|
334
|
-
deviceId &&
|
|
335
|
-
entry.hashedDeviceId &&
|
|
336
|
-
hashDeviceId(deviceId) === entry.hashedDeviceId,
|
|
337
|
-
);
|
|
338
|
-
const mintDeviceId =
|
|
339
|
-
pending?.deviceId ?? (deviceIdMatchesEntry ? deviceId : undefined);
|
|
340
|
-
if (mintDeviceId) {
|
|
341
|
-
mintingInFlight.add(id);
|
|
342
|
-
try {
|
|
343
|
-
const credentials = await mintPairingCredentials(mintDeviceId, "ios");
|
|
344
|
-
if (credentials) {
|
|
345
|
-
pendingDeviceIds.delete(id);
|
|
346
|
-
credentialEntry = { credentials, approvedAt: Date.now() };
|
|
347
|
-
approvedCredentials.set(id, credentialEntry);
|
|
348
|
-
}
|
|
349
|
-
} finally {
|
|
350
|
-
mintingInFlight.delete(id);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return Response.json({
|
|
356
|
-
status: "approved",
|
|
357
|
-
bearerToken: entry.bearerToken,
|
|
358
|
-
gatewayUrl: entry.gatewayUrl,
|
|
359
|
-
localLanUrl: entry.localLanUrl,
|
|
360
|
-
...(credentialEntry
|
|
361
|
-
? {
|
|
362
|
-
accessToken: credentialEntry.credentials.accessToken,
|
|
363
|
-
accessTokenExpiresAt:
|
|
364
|
-
credentialEntry.credentials.accessTokenExpiresAt,
|
|
365
|
-
refreshToken: credentialEntry.credentials.refreshToken,
|
|
366
|
-
refreshTokenExpiresAt:
|
|
367
|
-
credentialEntry.credentials.refreshTokenExpiresAt,
|
|
368
|
-
refreshAfter: credentialEntry.credentials.refreshAfter,
|
|
369
|
-
}
|
|
370
|
-
: {}),
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
return Response.json({ status: entry.status });
|
|
375
|
-
},
|
|
376
|
-
|
|
377
|
-
cleanupPairingState,
|
|
378
|
-
};
|
|
379
|
-
}
|
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory pairing request store with TTL.
|
|
3
|
-
*
|
|
4
|
-
* Each pairing request lives for at most TTL_MS (5 minutes) before
|
|
5
|
-
* being swept as expired. Status transitions:
|
|
6
|
-
* registered → pending → approved | denied | expired
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { createHash, timingSafeEqual } from "node:crypto";
|
|
10
|
-
|
|
11
|
-
import { getLogger } from "../logger.js";
|
|
12
|
-
|
|
13
|
-
const log = getLogger("pairing-store");
|
|
14
|
-
|
|
15
|
-
const PAIRING_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
16
|
-
const PAIRING_SWEEP_INTERVAL_MS = 30_000; // 30 seconds
|
|
17
|
-
|
|
18
|
-
export type PairingStatus =
|
|
19
|
-
| "registered"
|
|
20
|
-
| "pending"
|
|
21
|
-
| "approved"
|
|
22
|
-
| "denied"
|
|
23
|
-
| "expired";
|
|
24
|
-
|
|
25
|
-
export interface PairingRequest {
|
|
26
|
-
pairingRequestId: string;
|
|
27
|
-
hashedPairingSecret: string;
|
|
28
|
-
hashedDeviceId?: string;
|
|
29
|
-
deviceName?: string;
|
|
30
|
-
status: PairingStatus;
|
|
31
|
-
gatewayUrl: string;
|
|
32
|
-
localLanUrl: string | null;
|
|
33
|
-
bearerToken?: string;
|
|
34
|
-
createdAt: number;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function hashValue(value: string): string {
|
|
38
|
-
return createHash("sha256").update(value).digest("hex");
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function timingSafeCompare(a: string, b: string): boolean {
|
|
42
|
-
const bufA = Buffer.from(a);
|
|
43
|
-
const bufB = Buffer.from(b);
|
|
44
|
-
if (bufA.length !== bufB.length) return false;
|
|
45
|
-
return timingSafeEqual(bufA, bufB);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export class PairingStore {
|
|
49
|
-
private requests = new Map<string, PairingRequest>();
|
|
50
|
-
private sweepTimer: ReturnType<typeof setInterval> | null = null;
|
|
51
|
-
|
|
52
|
-
start(): void {
|
|
53
|
-
this.sweepTimer = setInterval(
|
|
54
|
-
() => this.sweep(),
|
|
55
|
-
PAIRING_SWEEP_INTERVAL_MS,
|
|
56
|
-
);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
stop(): void {
|
|
60
|
-
if (this.sweepTimer) {
|
|
61
|
-
clearInterval(this.sweepTimer);
|
|
62
|
-
this.sweepTimer = null;
|
|
63
|
-
}
|
|
64
|
-
this.requests.clear();
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Pre-register a pairing request (called when QR is displayed).
|
|
69
|
-
* Idempotent: if the same ID exists and secret matches, overwrite.
|
|
70
|
-
* Returns false with 'conflict' if ID exists but secret doesn't match,
|
|
71
|
-
* or 'active_pairing' if another pairing request is already in progress.
|
|
72
|
-
*/
|
|
73
|
-
register(params: {
|
|
74
|
-
pairingRequestId: string;
|
|
75
|
-
pairingSecret: string;
|
|
76
|
-
gatewayUrl: string;
|
|
77
|
-
localLanUrl?: string | null;
|
|
78
|
-
}): { ok: true } | { ok: false; reason: "conflict" | "active_pairing" } {
|
|
79
|
-
const hashedSecret = hashValue(params.pairingSecret);
|
|
80
|
-
const existing = this.requests.get(params.pairingRequestId);
|
|
81
|
-
|
|
82
|
-
if (existing) {
|
|
83
|
-
if (!timingSafeCompare(existing.hashedPairingSecret, hashedSecret)) {
|
|
84
|
-
return { ok: false, reason: "conflict" };
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Reject if another pairing request is already active (registered or pending).
|
|
89
|
-
for (const entry of this.requests.values()) {
|
|
90
|
-
if (entry.pairingRequestId === params.pairingRequestId) continue;
|
|
91
|
-
if (entry.status === "registered" || entry.status === "pending") {
|
|
92
|
-
log.warn(
|
|
93
|
-
{
|
|
94
|
-
existingPairingRequestId: entry.pairingRequestId,
|
|
95
|
-
newPairingRequestId: params.pairingRequestId,
|
|
96
|
-
},
|
|
97
|
-
"Rejected pairing registration — another pairing is already in progress",
|
|
98
|
-
);
|
|
99
|
-
return { ok: false, reason: "active_pairing" };
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
this.requests.set(params.pairingRequestId, {
|
|
104
|
-
pairingRequestId: params.pairingRequestId,
|
|
105
|
-
hashedPairingSecret: hashedSecret,
|
|
106
|
-
status: "registered",
|
|
107
|
-
gatewayUrl: params.gatewayUrl,
|
|
108
|
-
localLanUrl: params.localLanUrl ?? null,
|
|
109
|
-
createdAt: Date.now(),
|
|
110
|
-
});
|
|
111
|
-
log.info(
|
|
112
|
-
{ pairingRequestId: params.pairingRequestId },
|
|
113
|
-
"Pairing request registered",
|
|
114
|
-
);
|
|
115
|
-
return { ok: true };
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* iOS initiates a pairing request. Validates the secret and transitions
|
|
120
|
-
* the entry to "pending" (or "approved" if auto-approved).
|
|
121
|
-
*/
|
|
122
|
-
beginRequest(params: {
|
|
123
|
-
pairingRequestId: string;
|
|
124
|
-
pairingSecret: string;
|
|
125
|
-
deviceId: string;
|
|
126
|
-
deviceName: string;
|
|
127
|
-
}):
|
|
128
|
-
| { ok: true; entry: PairingRequest }
|
|
129
|
-
| {
|
|
130
|
-
ok: false;
|
|
131
|
-
reason: "not_found" | "invalid_secret" | "expired" | "already_paired";
|
|
132
|
-
} {
|
|
133
|
-
const entry = this.requests.get(params.pairingRequestId);
|
|
134
|
-
if (!entry) {
|
|
135
|
-
return { ok: false, reason: "not_found" };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (entry.status === "expired" || entry.status === "denied") {
|
|
139
|
-
return { ok: false, reason: "expired" };
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const hashedSecret = hashValue(params.pairingSecret);
|
|
143
|
-
if (!timingSafeCompare(entry.hashedPairingSecret, hashedSecret)) {
|
|
144
|
-
return { ok: false, reason: "invalid_secret" };
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const hashedDeviceId = hashValue(params.deviceId);
|
|
148
|
-
|
|
149
|
-
if (
|
|
150
|
-
entry.hashedDeviceId &&
|
|
151
|
-
!timingSafeCompare(entry.hashedDeviceId, hashedDeviceId)
|
|
152
|
-
) {
|
|
153
|
-
log.warn(
|
|
154
|
-
{ pairingRequestId: params.pairingRequestId },
|
|
155
|
-
"Pairing request already bound to a different device",
|
|
156
|
-
);
|
|
157
|
-
return { ok: false, reason: "already_paired" };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
entry.hashedDeviceId = hashedDeviceId;
|
|
161
|
-
entry.deviceName = params.deviceName;
|
|
162
|
-
if (entry.status === "registered") {
|
|
163
|
-
entry.status = "pending";
|
|
164
|
-
}
|
|
165
|
-
return { ok: true, entry };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
approve(
|
|
169
|
-
pairingRequestId: string,
|
|
170
|
-
bearerToken: string,
|
|
171
|
-
): PairingRequest | null {
|
|
172
|
-
const entry = this.requests.get(pairingRequestId);
|
|
173
|
-
if (!entry) return null;
|
|
174
|
-
entry.status = "approved";
|
|
175
|
-
entry.bearerToken = bearerToken;
|
|
176
|
-
return entry;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
deny(pairingRequestId: string): PairingRequest | null {
|
|
180
|
-
const entry = this.requests.get(pairingRequestId);
|
|
181
|
-
if (!entry) return null;
|
|
182
|
-
entry.status = "denied";
|
|
183
|
-
return entry;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
get(pairingRequestId: string): PairingRequest | null {
|
|
187
|
-
return this.requests.get(pairingRequestId) ?? null;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
validateSecret(pairingRequestId: string, secret: string): boolean {
|
|
191
|
-
const entry = this.requests.get(pairingRequestId);
|
|
192
|
-
if (!entry) return false;
|
|
193
|
-
const hashedSecret = hashValue(secret);
|
|
194
|
-
return timingSafeCompare(entry.hashedPairingSecret, hashedSecret);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
private sweep(): void {
|
|
198
|
-
const now = Date.now();
|
|
199
|
-
let changed = false;
|
|
200
|
-
for (const [id, entry] of this.requests) {
|
|
201
|
-
if (now - entry.createdAt > PAIRING_TTL_MS) {
|
|
202
|
-
if (entry.status !== "approved") {
|
|
203
|
-
entry.status = "expired";
|
|
204
|
-
changed = true;
|
|
205
|
-
}
|
|
206
|
-
// Remove entries older than 2x TTL regardless of status
|
|
207
|
-
if (now - entry.createdAt > PAIRING_TTL_MS * 2) {
|
|
208
|
-
this.requests.delete(id);
|
|
209
|
-
changed = true;
|
|
210
|
-
log.debug({ pairingRequestId: id }, "Pairing request swept");
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (changed) {
|
|
215
|
-
log.debug("Sweep completed with changes");
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
}
|