@vellumai/assistant 0.12.6-dev.202609301018.08b7375 → 0.12.6-dev.202609301115.aafbefc

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/ARCHITECTURE.md CHANGED
@@ -83,6 +83,7 @@ All HTTP API requests use a single `Authorization: Bearer <jwt>` header for auth
83
83
  | Profile | Scopes | Used by |
84
84
  | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- |
85
85
  | `actor_client_v1` | `chat.{read,write}`, `approval.{read,write}`, `settings.{read,write}`, `attachments.{read,write}`, `calls.{read,write}`, `feature_flags.{read,write}` | Desktop, CLI clients |
86
+ | `contact_client_v1` | `chat.write`, `approval.write`, `attachments.write` | Non-guardian contact clients |
86
87
  | `gateway_ingress_v1` | `ingress.write`, `internal.write` | Gateway channel inbound + webhook forwarding |
87
88
  | `gateway_service_v1` | `chat.{read,write}`, `settings.{read,write}`, `attachments.{read,write}`, `internal.write` | Gateway service-to-daemon calls |
88
89
  | `local_v1` | `local.all` | Local (loopback) conversation sessions |
@@ -98,6 +99,8 @@ All HTTP API requests use a single `Authorization: Bearer <jwt>` header for auth
98
99
 
99
100
  3. **Local identity** — Local connections use deterministic identity resolution without tokens.
100
101
 
102
+ **Token role:** Each access and refresh token records the principal it belongs to, `guardian` or `contact`. The role picks the scope profile at mint time, and rotation carries it forward.
103
+
101
104
  **Route policy enforcement:** Every protected endpoint declares required scopes and allowed principal types in `src/runtime/auth/route-policy.ts`. The `enforcePolicy()` function checks the AuthContext against these requirements and returns 403 when access is denied. A guard test ensures every dispatched endpoint has a corresponding policy entry.
102
105
 
103
106
  **Credential storage:** Only hashed tokens are persisted. Access token hashes go in `credential_records`; refresh token hashes in `refresh_token_records`. Raw tokens are returned once and never stored server-side.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.6-dev.202609301018.08b7375",
3
+ "version": "0.12.6-dev.202609301115.aafbefc",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,13 +8,15 @@
8
8
  * 2. No X-Actor-Token references in production code.
9
9
  * 3. No legacy gateway-origin proof in production code.
10
10
  * 4. Scope profile contract — every profile resolves to the expected scopes.
11
+ * 5. Contact profile agreement: the gateway mints what this runtime honors.
11
12
  */
12
13
 
13
14
  import { execSync } from "node:child_process";
15
+ import { readFileSync } from "node:fs";
14
16
  import { resolve } from "node:path";
15
17
  import { describe, expect, test } from "bun:test";
16
18
 
17
- import { resolveScopeProfile } from "../scopes.js";
19
+ import { isNarrowScopeProfile, resolveScopeProfile } from "../scopes.js";
18
20
  import type { Scope, ScopeProfile } from "../types.js";
19
21
 
20
22
  // ---------------------------------------------------------------------------
@@ -253,6 +255,7 @@ describe("scope profile contract", () => {
253
255
  "feature_flags.read",
254
256
  "feature_flags.write",
255
257
  ],
258
+ contact_client_v1: ["chat.write", "approval.write", "attachments.write"],
256
259
  gateway_ingress_v1: ["ingress.write", "internal.write"],
257
260
  gateway_service_v1: [
258
261
  "chat.read",
@@ -290,3 +293,50 @@ describe("scope profile contract", () => {
290
293
  }
291
294
  });
292
295
  });
296
+
297
+ // ---------------------------------------------------------------------------
298
+ // 5. Contact profile agreement with the gateway
299
+ //
300
+ // The gateway mints `contact_client_v1` and this runtime decides what it may
301
+ // reach. The two registries are separate tables, so a contact token carries
302
+ // whichever scopes the narrower one grants: a scope missing here 403s a route
303
+ // the gateway believes it opened.
304
+ // ---------------------------------------------------------------------------
305
+
306
+ describe("contact_client_v1 agrees with the gateway registry", () => {
307
+ const GATEWAY_SCOPES_PATH = resolve(
308
+ PROJECT_ROOT,
309
+ "gateway/src/auth/scopes.ts",
310
+ );
311
+
312
+ function gatewaySource(): string {
313
+ return readFileSync(GATEWAY_SCOPES_PATH, "utf8");
314
+ }
315
+
316
+ test("grants the same scopes on both sides", () => {
317
+ const block = /contact_client_v1: new Set<Scope>\(\[([^\]]*)\]\)/.exec(
318
+ gatewaySource(),
319
+ );
320
+ expect(
321
+ block,
322
+ `contact_client_v1 not found in ${GATEWAY_SCOPES_PATH}`,
323
+ ).not.toBeNull();
324
+
325
+ const gatewayScopes = [...block![1].matchAll(/"([^"]+)"/g)]
326
+ .map((m) => m[1])
327
+ .sort();
328
+ const runtimeScopes: string[] = [
329
+ ...resolveScopeProfile("contact_client_v1"),
330
+ ].sort();
331
+
332
+ expect(gatewayScopes.length).toBeGreaterThan(0);
333
+ expect(runtimeScopes).toEqual(gatewayScopes);
334
+ });
335
+
336
+ test("is narrow on both sides", () => {
337
+ const broad = /contact_client_v1: (true|false)/.exec(gatewaySource());
338
+ expect(broad).not.toBeNull();
339
+ expect(broad![1]).toBe("false");
340
+ expect(isNarrowScopeProfile("contact_client_v1")).toBe(true);
341
+ });
342
+ });
@@ -11,6 +11,7 @@ import type { AuthContext, Scope, ScopeProfile } from "../types.js";
11
11
  /** Every profile name; the Record keeps this list exhaustive. */
12
12
  const KNOWN_PROFILES = Object.keys({
13
13
  actor_client_v1: true,
14
+ contact_client_v1: true,
14
15
  gateway_ingress_v1: true,
15
16
  gateway_service_v1: true,
16
17
  local_v1: true,
@@ -129,6 +130,7 @@ describe("isNarrowScopeProfile", () => {
129
130
  // flipped onto a single-route grant would open every unscoped route to it.
130
131
  const narrow: Record<ScopeProfile, boolean> = {
131
132
  actor_client_v1: false,
133
+ contact_client_v1: true,
132
134
  gateway_ingress_v1: false,
133
135
  gateway_service_v1: false,
134
136
  local_v1: false,
@@ -27,6 +27,18 @@ const PROFILE_SCOPES: Record<ScopeProfile, ReadonlySet<Scope>> = {
27
27
  "feature_flags.read",
28
28
  "feature_flags.write",
29
29
  ]),
30
+ // A non-guardian principal. Excludes the settings, feature-flag and call
31
+ // scopes `actor_client_v1` carries: those reach the control plane, which
32
+ // only the guardian may write.
33
+ // Carries no read scope. The routes behind `chat.read`, `approval.read` and
34
+ // `attachments.read` take a caller-supplied conversation or attachment id
35
+ // and never check who is asking, so they return the guardian's data to any
36
+ // holder. Each read is added back once its routes filter by caller.
37
+ contact_client_v1: new Set<Scope>([
38
+ "chat.write",
39
+ "approval.write",
40
+ "attachments.write",
41
+ ]),
30
42
  gateway_ingress_v1: new Set<Scope>(["ingress.write", "internal.write"]),
31
43
  gateway_service_v1: new Set<Scope>([
32
44
  "chat.read",
@@ -91,6 +103,9 @@ export function hasAllScopes(ctx: AuthContext, ...scopes: Scope[]): boolean {
91
103
  */
92
104
  const BROAD_SCOPE_PROFILES: Record<ScopeProfile, boolean> = {
93
105
  actor_client_v1: true,
106
+ // Narrow: a route naming no scope is a route no one has judged a contact
107
+ // against, so it refuses them rather than admitting any valid token.
108
+ contact_client_v1: false,
94
109
  gateway_ingress_v1: true,
95
110
  gateway_service_v1: true,
96
111
  local_v1: true,
@@ -11,6 +11,7 @@
11
11
 
12
12
  export type ScopeProfile =
13
13
  | "actor_client_v1"
14
+ | "contact_client_v1"
14
15
  | "gateway_ingress_v1"
15
16
  | "gateway_service_v1"
16
17
  | "local_v1"