@vellumai/vellum-gateway 0.8.9-staging.4 → 0.8.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.8.9-staging.4",
3
+ "version": "0.8.9",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,6 +8,8 @@ import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join } from "node:path";
10
10
 
11
+ import { eq } from "drizzle-orm";
12
+
11
13
  import { initSigningKey, mintToken } from "../auth/token-service.js";
12
14
  import { CURRENT_POLICY_EPOCH } from "../auth/policy.js";
13
15
  import type { TokenClaims } from "../auth/types.js";
@@ -18,7 +20,7 @@ const { initGatewayDb, resetGatewayDb, getGatewayDb } =
18
20
  await import("../db/connection.js");
19
21
  const { actorTokenRecords } = await import("../db/schema.js");
20
22
  const { hashToken } = await import("../auth/guardian-bootstrap.js");
21
- const { isActorTokenRevoked } =
23
+ const { isActorTokenRevoked, actorTokenRecordHash } =
22
24
  await import("../auth/actor-token-revocation.js");
23
25
  const { createRuntimeProxyHandler } =
24
26
  await import("../http/routes/runtime-proxy.js");
@@ -231,6 +233,47 @@ describe("/auth/token revocation", () => {
231
233
 
232
234
  expect(res.status).toBe(401);
233
235
  });
236
+
237
+ test("records a derived token so device revocation invalidates it", async () => {
238
+ const { handleCreateToken } = await import("../http/routes/auth-token.js");
239
+ const { revokeActorTokensByDevice } =
240
+ await import("../auth/guardian-bootstrap.js");
241
+ const sourceJwt = mintToken({
242
+ aud: "vellum-gateway",
243
+ sub: ACTOR_SUB,
244
+ scope_profile: "actor_client_v1",
245
+ policy_epoch: CURRENT_POLICY_EPOCH,
246
+ ttlSeconds: 3600,
247
+ });
248
+ insertTokenRecord(sourceJwt, "active");
249
+
250
+ const res = await handleCreateToken(
251
+ new Request("http://127.0.0.1:7830/auth/token", {
252
+ method: "POST",
253
+ headers: {
254
+ authorization: `Bearer ${sourceJwt}`,
255
+ origin: "http://localhost:3000",
256
+ },
257
+ }),
258
+ makeLoopbackServer(),
259
+ );
260
+
261
+ expect(res.status).toBe(200);
262
+ const { token: derivedJwt } = (await res.json()) as { token: string };
263
+ const derivedRecord = getGatewayDb()
264
+ .select({ status: actorTokenRecords.status })
265
+ .from(actorTokenRecords)
266
+ .where(eq(actorTokenRecords.tokenHash, actorTokenRecordHash(derivedJwt)))
267
+ .get();
268
+
269
+ expect(derivedRecord?.status).toBe("derived");
270
+ expect(isActorTokenRevoked(derivedJwt, actorClaims)).toBe(false);
271
+
272
+ revokeActorTokensByDevice("guardian-001", hashToken("device-A"));
273
+
274
+ expect(isActorTokenRevoked(sourceJwt, actorClaims)).toBe(true);
275
+ expect(isActorTokenRevoked(derivedJwt, actorClaims)).toBe(true);
276
+ });
234
277
  });
235
278
 
236
279
  describe("m0004 token-hash index migration", () => {
@@ -73,6 +73,17 @@ function canonicalizeTokenForHash(rawToken: string): string {
73
73
  }
74
74
  }
75
75
 
76
+ /**
77
+ * Hash a caller-supplied actor token exactly as revocation lookups do.
78
+ *
79
+ * Use this for DB writes/reads that need to line up with
80
+ * `isActorTokenRevoked`, including token-mint paths that persist derived
81
+ * actor tokens for later device revocation.
82
+ */
83
+ export function actorTokenRecordHash(rawToken: string): string {
84
+ return hashToken(canonicalizeTokenForHash(rawToken));
85
+ }
86
+
76
87
  /**
77
88
  * True only when `rawToken` is an actor token with an explicitly revoked record.
78
89
  * Fail-open in every other case (non-actor, no record, DB error).
@@ -86,7 +97,7 @@ export function isActorTokenRevoked(
86
97
  return false;
87
98
  }
88
99
 
89
- const tokenHash = hashToken(canonicalizeTokenForHash(rawToken));
100
+ const tokenHash = actorTokenRecordHash(rawToken);
90
101
 
91
102
  try {
92
103
  const record = getGatewayDb()
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { createHash, randomBytes } from "node:crypto";
10
10
 
11
- import { and, eq } from "drizzle-orm";
11
+ import { and, eq, inArray } from "drizzle-orm";
12
12
 
13
13
  import { getGatewayDb } from "../db/connection.js";
14
14
  import {
@@ -427,7 +427,7 @@ export function revokeActorTokensByDevice(
427
427
  and(
428
428
  eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
429
429
  eq(actorTokenRecords.hashedDeviceId, hashedDeviceId),
430
- eq(actorTokenRecords.status, "active"),
430
+ inArray(actorTokenRecords.status, ["active", "derived"]),
431
431
  ),
432
432
  )
433
433
  .run();
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { randomBytes } from "node:crypto";
7
7
 
8
- import { and, eq } from "drizzle-orm";
8
+ import { and, eq, inArray } from "drizzle-orm";
9
9
 
10
10
  import { getGatewayDb } from "../db/connection.js";
11
11
  import { actorRefreshTokenRecords, actorTokenRecords } from "../db/schema.js";
@@ -81,7 +81,7 @@ function revokeFamily(familyId: string): void {
81
81
  .run();
82
82
  }
83
83
 
84
- function revokeActorTokensByDevice(
84
+ function revokeActiveActorTokensByDevice(
85
85
  guardianPrincipalId: string,
86
86
  hashedDeviceId: string,
87
87
  ): void {
@@ -99,6 +99,24 @@ function revokeActorTokensByDevice(
99
99
  .run();
100
100
  }
101
101
 
102
+ function revokeAllActorTokensByDevice(
103
+ guardianPrincipalId: string,
104
+ hashedDeviceId: string,
105
+ ): void {
106
+ const now = Date.now();
107
+ getGatewayDb()
108
+ .update(actorTokenRecords)
109
+ .set({ status: "revoked", updatedAt: now })
110
+ .where(
111
+ and(
112
+ eq(actorTokenRecords.guardianPrincipalId, guardianPrincipalId),
113
+ eq(actorTokenRecords.hashedDeviceId, hashedDeviceId),
114
+ inArray(actorTokenRecords.status, ["active", "derived"]),
115
+ ),
116
+ )
117
+ .run();
118
+ }
119
+
102
120
  // ---------------------------------------------------------------------------
103
121
  // Token minting (gateway DB)
104
122
  // ---------------------------------------------------------------------------
@@ -232,7 +250,7 @@ export function rotateCredentials(params: {
232
250
  "Refresh token reuse detected — revoking entire family",
233
251
  );
234
252
  revokeFamily(record.familyId);
235
- revokeActorTokensByDevice(
253
+ revokeAllActorTokensByDevice(
236
254
  record.guardianPrincipalId,
237
255
  record.hashedDeviceId,
238
256
  );
@@ -261,7 +279,7 @@ export function rotateCredentials(params: {
261
279
  return { ok: false as const, error: "refresh_reuse_detected" as const };
262
280
  }
263
281
 
264
- revokeActorTokensByDevice(
282
+ revokeActiveActorTokensByDevice(
265
283
  record.guardianPrincipalId,
266
284
  record.hashedDeviceId,
267
285
  );
@@ -1,9 +1,16 @@
1
1
  import type { Server } from "bun";
2
2
 
3
- import { isActorTokenRevoked } from "../../auth/actor-token-revocation.js";
3
+ import { eq } from "drizzle-orm";
4
+
5
+ import {
6
+ actorTokenRecordHash,
7
+ isActorTokenRevoked,
8
+ } from "../../auth/actor-token-revocation.js";
4
9
  import { ensureVellumGuardianBinding } from "../../auth/guardian-bootstrap.js";
5
10
  import { CURRENT_POLICY_EPOCH } from "../../auth/policy.js";
6
11
  import { mintToken, verifyToken } from "../../auth/token-service.js";
12
+ import { getGatewayDb } from "../../db/connection.js";
13
+ import { actorTokenRecords } from "../../db/schema.js";
7
14
  import { getLogger } from "../../logger.js";
8
15
  import { isLoopbackPeer } from "../../util/is-loopback-address.js";
9
16
 
@@ -13,6 +20,69 @@ const WEB_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/;
13
20
 
14
21
  const TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
15
22
 
23
+ interface SourceActorTokenRecord {
24
+ guardianPrincipalId: string;
25
+ hashedDeviceId: string;
26
+ platform: string;
27
+ }
28
+
29
+ function findSourceActorTokenRecord(
30
+ sourceToken: string,
31
+ ): SourceActorTokenRecord | null {
32
+ try {
33
+ return (
34
+ getGatewayDb()
35
+ .select({
36
+ guardianPrincipalId: actorTokenRecords.guardianPrincipalId,
37
+ hashedDeviceId: actorTokenRecords.hashedDeviceId,
38
+ platform: actorTokenRecords.platform,
39
+ })
40
+ .from(actorTokenRecords)
41
+ .where(
42
+ eq(actorTokenRecords.tokenHash, actorTokenRecordHash(sourceToken)),
43
+ )
44
+ .get() ?? null
45
+ );
46
+ } catch (err) {
47
+ log.warn(
48
+ { err: err instanceof Error ? err.message : String(err) },
49
+ "Source actor-token lookup failed — minting unrecorded compatibility token",
50
+ );
51
+ return null;
52
+ }
53
+ }
54
+
55
+ function recordDerivedActorToken(
56
+ sourceRecord: SourceActorTokenRecord | null,
57
+ derivedToken: string,
58
+ ): void {
59
+ if (!sourceRecord) return;
60
+
61
+ const now = Date.now();
62
+ try {
63
+ getGatewayDb()
64
+ .insert(actorTokenRecords)
65
+ .values({
66
+ id: crypto.randomUUID(),
67
+ tokenHash: actorTokenRecordHash(derivedToken),
68
+ guardianPrincipalId: sourceRecord.guardianPrincipalId,
69
+ hashedDeviceId: sourceRecord.hashedDeviceId,
70
+ platform: sourceRecord.platform,
71
+ status: "derived",
72
+ issuedAt: now,
73
+ expiresAt: now + TOKEN_TTL_SECONDS * 1000,
74
+ createdAt: now,
75
+ updatedAt: now,
76
+ })
77
+ .run();
78
+ } catch (err) {
79
+ log.warn(
80
+ { err: err instanceof Error ? err.message : String(err) },
81
+ "Derived actor-token record insert failed — minted token remains compatible but unrecorded",
82
+ );
83
+ }
84
+ }
85
+
16
86
  export async function handleCreateToken(
17
87
  req: Request,
18
88
  server: Server<unknown> | undefined,
@@ -59,7 +129,9 @@ export async function handleCreateToken(
59
129
  return Response.json({ error: "Unauthorized" }, { status: 401 });
60
130
  }
61
131
 
62
- const guardianPrincipalId = await ensureVellumGuardianBinding();
132
+ const sourceRecord = findSourceActorTokenRecord(bearerToken);
133
+ const guardianPrincipalId =
134
+ sourceRecord?.guardianPrincipalId ?? (await ensureVellumGuardianBinding());
63
135
 
64
136
  const token = mintToken({
65
137
  aud: "vellum-gateway",
@@ -69,6 +141,8 @@ export async function handleCreateToken(
69
141
  ttlSeconds: TOKEN_TTL_SECONDS,
70
142
  });
71
143
 
144
+ recordDerivedActorToken(sourceRecord, token);
145
+
72
146
  const expiresAt = Math.floor(Date.now() / 1000) + TOKEN_TTL_SECONDS;
73
147
  log.info("Bearer token minted for web local mode");
74
148