@vellumai/vellum-gateway 0.12.6 → 0.12.7-staging.1

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.
Files changed (55) hide show
  1. package/AGENTS.md +2 -2
  2. package/ARCHITECTURE.md +6 -1
  3. package/README.md +1 -0
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  6. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  8. package/node_modules/@vellumai/gateway-client/src/__tests__/route-trust-class.test.ts +111 -0
  9. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +5 -2
  10. package/node_modules/@vellumai/gateway-client/src/index.ts +9 -0
  11. package/node_modules/@vellumai/gateway-client/src/route-trust-class.ts +94 -0
  12. package/node_modules/@vellumai/service-contracts/src/__tests__/channels.test.ts +2 -0
  13. package/node_modules/@vellumai/service-contracts/src/channels.ts +8 -4
  14. package/package.json +1 -1
  15. package/src/__tests__/channel-admission-policy-routes.test.ts +18 -14
  16. package/src/__tests__/contact-store-bind-principal.test.ts +270 -0
  17. package/src/__tests__/contacts-control-plane-proxy.test.ts +32 -0
  18. package/src/__tests__/devices.test.ts +92 -2
  19. package/src/__tests__/guardian-binding-channel-reuse.test.ts +135 -2
  20. package/src/__tests__/guardian-bootstrap-lookups.test.ts +18 -0
  21. package/src/__tests__/guardian-integrity.test.ts +11 -9
  22. package/src/__tests__/guardian-request-decide.test.ts +31 -0
  23. package/src/__tests__/helpers/contact-fixtures.ts +13 -5
  24. package/src/__tests__/ipc-contact-routes.test.ts +52 -0
  25. package/src/__tests__/refresh-device-binding.test.ts +59 -0
  26. package/src/__tests__/remote-web-ingress-denylist.test.ts +1 -0
  27. package/src/__tests__/route-schema-guard.test.ts +3 -0
  28. package/src/__tests__/scopes.test.ts +36 -0
  29. package/src/__tests__/seed-admission-policy.test.ts +20 -0
  30. package/src/__tests__/shared-invite-redeem.test.ts +682 -0
  31. package/src/__tests__/shared-invite-registration.test.ts +332 -0
  32. package/src/__tests__/upsert-verified-contact-channel.test.ts +38 -1
  33. package/src/auth/guardian-bootstrap.ts +162 -12
  34. package/src/auth/guardian-integrity.ts +5 -3
  35. package/src/auth/guardian-refresh.ts +9 -1
  36. package/src/auth/scopes.ts +15 -0
  37. package/src/auth/types.ts +1 -0
  38. package/src/channels/types.ts +1 -0
  39. package/src/db/admission-policy-store.ts +2 -2
  40. package/src/db/contact-store.ts +134 -0
  41. package/src/db/schema.ts +12 -0
  42. package/src/db/seed-admission-policy.ts +9 -5
  43. package/src/feature-flag-registry.json +16 -8
  44. package/src/http/routes/contacts-control-plane-proxy.ts +137 -34
  45. package/src/http/routes/ipc-runtime-proxy.test.ts +339 -11
  46. package/src/http/routes/ipc-runtime-proxy.ts +86 -22
  47. package/src/http/routes/shared-invite-redeem.ts +140 -0
  48. package/src/index.ts +10 -0
  49. package/src/ipc/contact-handlers.ts +22 -0
  50. package/src/ipc/route-schema-cache.test.ts +36 -0
  51. package/src/ipc/route-schema-cache.ts +12 -6
  52. package/src/verification/contact-helpers.ts +12 -1
  53. package/src/verification/invite-redemption.ts +53 -8
  54. package/src/verification/shared-invite-redemption.ts +118 -0
  55. package/src/verification/shared-invite-registration.ts +121 -0
package/AGENTS.md CHANGED
@@ -169,7 +169,7 @@ A default row per enforced channel is **seeded at startup** (`seedAdmissionPolic
169
169
  | Policy | Floor | Notes |
170
170
  | ------------------ | ----- | ----------------------------------------------------------------------------------------------------------------------------- |
171
171
  | `no_one` | 5 | Hard-deny at gateway _before_ forwarding (kill switch in `handle-inbound.ts`). Includes the guardian — this channel is _OFF_. |
172
- | `guardian_only` | 4 | Seeded default for `vellum`. |
172
+ | `guardian_only` | 4 | Seeded default for `vellum` and `vellum-shared`. |
173
173
  | `trusted_contacts` | 3 | Seeded default for all other channels; also the read-path safety fallback. |
174
174
  | `any_contact` | 2 | May surface Slack DM / email upgrade challenge on deny. |
175
175
  | `strangers` | 1 | May surface upgrade challenge. |
@@ -183,7 +183,7 @@ A default row per enforced channel is **seeded at startup** (`seedAdmissionPolic
183
183
 
184
184
  For exempt ids, `PUT /v1/assistants/:id/channel-admission-policy/:channelType` returns **403**, the GET list omits them, and the runtime short-circuits `admitted: true` in `admission-policy.ts` (defense in depth). Codex finding from #35006 review: exemption checks must live in _both_ the gateway route handler AND the runtime stage — single-side enforcement creates a misuse wedge.
185
185
 
186
- **Hidden channels** (`ADMISSION_POLICY_HIDDEN_CHANNELS` = `vellum`, `whatsapp`) — managed automatically, **not** user-configurable, but (unlike exempt channels) **still enforced at runtime**:
186
+ **Hidden channels** (`ADMISSION_POLICY_HIDDEN_CHANNELS` = `vellum`, `vellum-shared`, `whatsapp`): managed automatically, **not** user-configurable, but (unlike exempt channels) **still enforced at runtime**:
187
187
 
188
188
  - The GET list omits them, and `PUT`/`DELETE` return **403** (`isAdmissionPolicyHiddenChannel`).
189
189
  - They are **not** exempt — the runtime still evaluates rank-vs-floor, so a real inbound channel like `whatsapp` keeps its admission floor.
package/ARCHITECTURE.md CHANGED
@@ -207,10 +207,11 @@ All contact paths are registered flat only (`/v1/contacts...`, `/v1/contact-chan
207
207
  | DELETE | `/v1/contacts/invites/:inviteId` |
208
208
  | POST | `/v1/contacts/invites/:inviteId/call` |
209
209
  | POST | `/v1/contacts/invites/redeem` |
210
+ | POST | `/v1/shared/invites/redeem` |
210
211
 
211
212
  **Authentication boundary:**
212
213
 
213
- - Gateway validates the caller's JWT bearer token.
214
+ - Gateway validates the caller's JWT bearer token, except on `/v1/shared/invites/redeem`, where the `vellum-shared` invite link token in the body is the credential (see Invite-based onboarding).
214
215
  - Native contact/invite endpoints are served from the gateway DB after that bearer-auth check; runtime-relayed requests (Telegram control plane, contact search reads) reach the runtime with a minted JWT (`gateway_ingress_v1` or `gateway_service_v1` scope profile).
215
216
  - Upstream 4xx/5xx responses are passed through, while connection errors return `502` and timeouts return `504`.
216
217
 
@@ -609,6 +610,8 @@ The channel inbound handler (`inbound-message-handler.ts`) enforces an access co
609
610
 
610
611
  **Invite-based onboarding:** Invite tokens are minted by the gateway via the invite HTTP API and stored SHA-256 hashed on the gateway DB's `ingress_invites` row -- the raw token is returned exactly once at creation time. External users redeem invites by sending the token as a channel message, which atomically creates a member record with `active` status and `allow` policy.
611
612
 
613
+ A `vellum-shared` invite is redeemed instead through `POST /v1/shared/invites/redeem`, which the platform calls on the invitee's behalf with the link token and a device id. In one gateway DB transaction it claims the invite's single use, mints a principal, binds it to the invite's contact-role contact, records it as that contact's active `vellum-shared` channel, and mints a device-bound `contact`-role token pair; a failure at any step rolls all of it back and leaves the invite redeemable. `writeSharedPrincipalChannel` is the only writer of `vellum-shared` rows. The route accepts only the link token, never the 6-digit code, answers 404 while `vellum-trusted-contacts` is off, limits failed attempts per client IP, and is denied at the remote web ingress. The principal then resolves as `trusted_contact` through its `vellum-shared` channel for as long as that channel stays active.
614
+
612
615
  **Relationship to guardian verification:** Guardian verification and ingress contact management are independent systems. Guardian verification establishes who controls the assistant on a channel (the trust anchor for approvals). Ingress contacts control who can interact with the assistant.
613
616
 
614
617
  #### SQLite Tables
@@ -639,6 +642,8 @@ The gateway declares `contacts` and `contact_channels` tables and exposes them v
639
642
  | `gateway/src/http/routes/contacts-control-plane-proxy.ts` | Gateway-native invite lifecycle (mint, list, revoke, redeem) shared by HTTP and IPC |
640
643
  | `gateway/src/ipc/invite-handlers.ts` | IPC routes relaying the daemon's invite surfaces to the native functions |
641
644
  | `gateway/src/verification/invite-redemption.ts` | Redemption engine — validation, atomic claim, ACL activation |
645
+ | `gateway/src/verification/shared-invite-redemption.ts` | `vellum-shared` invite redemption: claim, principal, channel and token pair in one transaction |
646
+ | `gateway/src/http/routes/shared-invite-redeem.ts` | `POST /v1/shared/invites/redeem`: flag gate, request schema, per-IP failure limit |
642
647
  | `assistant/src/contacts/contact-store.ts` | Contact and channel lookups (findContactChannel, guardian bindings) |
643
648
  | `assistant/src/contacts/contacts-write.ts` | Contact and channel identity/info writes (upsert, redemption info mirror — ACL is gateway-owned) |
644
649
  | `assistant/src/ipc/routes/invite-ipc-routes.ts` | `invite_redeemed` info mirror — local contact/channel identity upsert |
package/README.md CHANGED
@@ -156,6 +156,7 @@ Control-plane routes are listed with their flat paths. Clients emit assistant-sc
156
156
  | `/v1/contacts/invites/:id` | DELETE | Gateway-native invite revoke |
157
157
  | `/v1/contacts/invites/:id/call` | POST | Gateway-native invite-call relay — validates the invite row, then delegates the provider call to the assistant |
158
158
  | `/v1/contacts/invites/redeem` | POST | Gateway-native invite redemption (voice code and link token) |
159
+ | `/v1/shared/invites/redeem` | POST | Unauthenticated `vellum-shared` invite redemption for a contact principal and token pair; 404 while `vellum-trusted-contacts` is off |
159
160
  | `/v1/health` | GET | Authenticated runtime health proxy (`/v1/health` on runtime) |
160
161
  | `/healthz` | GET | Liveness probe |
161
162
  | `/readyz` | GET | Readiness probe |
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
24
24
  // so these must remain canonical for that assertion to mean anything.
25
25
  expect(isChannelId("platform")).toBe(true);
26
26
  expect(isChannelId("vellum")).toBe(true);
27
+ expect(isChannelId("vellum-shared")).toBe(true);
27
28
  });
28
29
 
29
30
  test("includes discord", () => {
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
58
59
  test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
60
  expect(botProviderForChannel("phone")).toBeUndefined();
60
61
  expect(botProviderForChannel("vellum")).toBeUndefined();
62
+ expect(botProviderForChannel("vellum-shared")).toBeUndefined();
61
63
  expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
64
  // `slack` is the person's integration standing beside the bot's key.
63
65
  expect(channelForBotProvider("slack")).toBeUndefined();
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * A "channel" is an external messaging surface an actor can reach the
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
- * internal ids (`vellum` for native app conversations, `platform` for the
8
- * internal control plane). This is the single source of truth for that set:
7
+ * internal ids (`vellum` for native app conversations, `vellum-shared` for
8
+ * conversations a guardian shares with a contact, `platform` for the internal
9
+ * control plane). This is the single source of truth for that set:
9
10
  *
10
11
  * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
12
  * brings. A plugin channel's real identity is the plugin, which is workspace
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
41
42
  "a2a",
42
43
  "discord",
43
44
  "plugin",
45
+ // Internal id for conversations a guardian shares with a contact. It has no
46
+ // transport: the contact's own client holds the connection.
47
+ "vellum-shared",
44
48
  ] as const;
45
49
 
46
50
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
78
82
  * here would be a second copy of a different fact.
79
83
  *
80
84
  * Channels absent from this map reach the assistant without a bot credential
81
- * of their own: `phone` through the voice provider, `vellum` and `platform`
82
- * internally.
85
+ * of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
86
+ * and `platform` internally.
83
87
  */
84
88
  export const CHANNEL_BOT_PROVIDER = {
85
89
  slack: "slack_channel",
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
24
24
  // so these must remain canonical for that assertion to mean anything.
25
25
  expect(isChannelId("platform")).toBe(true);
26
26
  expect(isChannelId("vellum")).toBe(true);
27
+ expect(isChannelId("vellum-shared")).toBe(true);
27
28
  });
28
29
 
29
30
  test("includes discord", () => {
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
58
59
  test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
60
  expect(botProviderForChannel("phone")).toBeUndefined();
60
61
  expect(botProviderForChannel("vellum")).toBeUndefined();
62
+ expect(botProviderForChannel("vellum-shared")).toBeUndefined();
61
63
  expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
64
  // `slack` is the person's integration standing beside the bot's key.
63
65
  expect(channelForBotProvider("slack")).toBeUndefined();
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * A "channel" is an external messaging surface an actor can reach the
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
- * internal ids (`vellum` for native app conversations, `platform` for the
8
- * internal control plane). This is the single source of truth for that set:
7
+ * internal ids (`vellum` for native app conversations, `vellum-shared` for
8
+ * conversations a guardian shares with a contact, `platform` for the internal
9
+ * control plane). This is the single source of truth for that set:
9
10
  *
10
11
  * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
12
  * brings. A plugin channel's real identity is the plugin, which is workspace
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
41
42
  "a2a",
42
43
  "discord",
43
44
  "plugin",
45
+ // Internal id for conversations a guardian shares with a contact. It has no
46
+ // transport: the contact's own client holds the connection.
47
+ "vellum-shared",
44
48
  ] as const;
45
49
 
46
50
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
78
82
  * here would be a second copy of a different fact.
79
83
  *
80
84
  * Channels absent from this map reach the assistant without a bot credential
81
- * of their own: `phone` through the voice provider, `vellum` and `platform`
82
- * internally.
85
+ * of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
86
+ * and `platform` internally.
83
87
  */
84
88
  export const CHANNEL_BOT_PROVIDER = {
85
89
  slack: "slack_channel",
@@ -0,0 +1,111 @@
1
+ import { describe, expect, mock, test } from "bun:test";
2
+
3
+ import {
4
+ isTrustCheckedScopeProfile,
5
+ routeAdmitsTrustClass,
6
+ tokenMayReachRoute,
7
+ TRUST_EXEMPT_SCOPE_PROFILES,
8
+ } from "../route-trust-class.js";
9
+
10
+ const CONTACT_ROUTE = ["guardian", "trusted_contact", "unverified_contact"];
11
+
12
+ const contactMayReach = (
13
+ allowed: readonly string[] | undefined,
14
+ resolve: () => Promise<string | undefined>,
15
+ ) => tokenMayReachRoute("contact_client_v1", allowed, resolve);
16
+
17
+ describe("routeAdmitsTrustClass", () => {
18
+ test("an absent list admits the guardian only", () => {
19
+ expect(routeAdmitsTrustClass(undefined, "guardian")).toBe(true);
20
+ expect(routeAdmitsTrustClass(undefined, "trusted_contact")).toBe(false);
21
+ expect(routeAdmitsTrustClass(undefined, "unknown")).toBe(false);
22
+ });
23
+
24
+ test("a listed class is admitted and an unlisted one refused", () => {
25
+ expect(routeAdmitsTrustClass(CONTACT_ROUTE, "trusted_contact")).toBe(true);
26
+ expect(routeAdmitsTrustClass(CONTACT_ROUTE, "unknown")).toBe(false);
27
+ });
28
+
29
+ test("a value outside the vocabulary is refused even when listed", () => {
30
+ expect(routeAdmitsTrustClass(["owner"], "owner")).toBe(false);
31
+ expect(routeAdmitsTrustClass(CONTACT_ROUTE, undefined)).toBe(false);
32
+ });
33
+ });
34
+
35
+ describe("tokenMayReachRoute for a contact token", () => {
36
+ test("a route admitting only the guardian refuses without resolving", async () => {
37
+ const resolve = mock(async () => "trusted_contact");
38
+ expect(await contactMayReach(undefined, resolve)).toBe(false);
39
+ expect(await contactMayReach(["guardian"], resolve)).toBe(false);
40
+ expect(resolve).not.toHaveBeenCalled();
41
+ });
42
+
43
+ test.each([
44
+ ["trusted_contact", true],
45
+ ["unverified_contact", true],
46
+ ["unknown", false],
47
+ ["guardian", false],
48
+ [undefined, false],
49
+ ] as const)(
50
+ "a contact route resolving %p admits: %p",
51
+ async (trustClass, admitted) => {
52
+ expect(await contactMayReach(CONTACT_ROUTE, async () => trustClass)).toBe(
53
+ admitted,
54
+ );
55
+ },
56
+ );
57
+
58
+ test("a class the route does not list is refused", async () => {
59
+ expect(
60
+ await contactMayReach(
61
+ ["guardian", "trusted_contact"],
62
+ async () => "unverified_contact",
63
+ ),
64
+ ).toBe(false);
65
+ });
66
+
67
+ test("a resolver that throws refuses", async () => {
68
+ expect(
69
+ await contactMayReach(CONTACT_ROUTE, async () => {
70
+ throw new Error("gateway unreachable");
71
+ }),
72
+ ).toBe(false);
73
+ });
74
+ });
75
+
76
+ describe("isTrustCheckedScopeProfile", () => {
77
+ test("an exempt profile is not trust-checked", () => {
78
+ for (const profile of TRUST_EXEMPT_SCOPE_PROFILES) {
79
+ expect(isTrustCheckedScopeProfile(profile)).toBe(false);
80
+ }
81
+ });
82
+
83
+ test("the contact profile, unknown profiles and prototype keys are", () => {
84
+ for (const profile of [
85
+ "contact_client_v1",
86
+ "bogus_v1",
87
+ "toString",
88
+ "constructor",
89
+ "__proto__",
90
+ ]) {
91
+ expect(isTrustCheckedScopeProfile(profile)).toBe(true);
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("tokenMayReachRoute for a trust-exempt token", () => {
97
+ test.each([...TRUST_EXEMPT_SCOPE_PROFILES])(
98
+ "%s counts as the guardian without a lookup",
99
+ async (profile) => {
100
+ const resolve = mock(async () => "trusted_contact");
101
+ expect(await tokenMayReachRoute(profile, undefined, resolve)).toBe(true);
102
+ expect(await tokenMayReachRoute(profile, CONTACT_ROUTE, resolve)).toBe(
103
+ true,
104
+ );
105
+ expect(
106
+ await tokenMayReachRoute(profile, ["trusted_contact"], resolve),
107
+ ).toBe(false);
108
+ expect(resolve).not.toHaveBeenCalled();
109
+ },
110
+ );
111
+ });
@@ -56,8 +56,8 @@ export const ADMISSION_FLOOR: Record<AdmissionPolicy, number> = {
56
56
  *
57
57
  * `phone` is NOT exempt — voice ingress enforces the admission floor.
58
58
  *
59
- * `vellum` / `whatsapp` are NOT exempt — their floors are still enforced at
60
- * runtime — but they are hidden from the configurable UI; see
59
+ * `vellum` / `vellum-shared` / `whatsapp` are NOT exempt: their floors are
60
+ * still enforced at runtime, but they are hidden from the configurable UI; see
61
61
  * {@link ADMISSION_POLICY_HIDDEN_CHANNELS}.
62
62
  */
63
63
  export const ADMISSION_POLICY_EXEMPT_CHANNELS: ReadonlySet<string> = new Set([
@@ -81,9 +81,12 @@ export function isAdmissionPolicyExemptChannel(channelType: string): boolean {
81
81
  * `vellum` is the local desktop/web client surface; the guardian is always
82
82
  * max-rank there, so the seed default admits them regardless of the floor.
83
83
  *
84
+ * `vellum-shared` carries conversations a guardian shares with a contact. Its
85
+ * seed default is `guardian_only`, which admits no contact.
84
86
  */
85
87
  export const ADMISSION_POLICY_HIDDEN_CHANNELS: ReadonlySet<string> = new Set([
86
88
  "vellum",
89
+ "vellum-shared",
87
90
  "whatsapp",
88
91
  ]);
89
92
 
@@ -132,6 +132,15 @@ export type {
132
132
  TrustVerdict,
133
133
  } from "./trust-verdict-contract.js";
134
134
 
135
+ // Route trust class, applied by both the daemon router and the gateway IPC proxy
136
+ export {
137
+ DEFAULT_ROUTE_TRUST_CLASSES,
138
+ isTrustCheckedScopeProfile,
139
+ routeAdmitsTrustClass,
140
+ tokenMayReachRoute,
141
+ TRUST_EXEMPT_SCOPE_PROFILES,
142
+ } from "./route-trust-class.js";
143
+
135
144
  // Invite contract (shared gateway ↔ daemon) — hash/generate helpers,
136
145
  // channel gating, redemption outcome, method map + invite IPC schemas
137
146
  export {
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Which trust classes a route admits, shared by the daemon's HTTP router and
3
+ * the gateway's IPC proxy so both enforcement points apply one rule.
4
+ *
5
+ * A route policy's `allowedTrustClasses` reaches the gateway on the IPC route
6
+ * schema as plain strings, and is absent from a null policy or a schema served
7
+ * by a daemon that predates the field. Absent means guardian only.
8
+ */
9
+
10
+ import { isTrustClass, type TrustClass } from "./trust-verdict-contract.js";
11
+
12
+ /**
13
+ * Scope profiles whose holders are not trust-checked: the guardian's own
14
+ * client, and service, local and single-route grants that route policy
15
+ * already governs. Any profile not listed here is trust-checked, so an
16
+ * unknown or newly added profile fails closed until it is judged exempt.
17
+ */
18
+ export const TRUST_EXEMPT_SCOPE_PROFILES: readonly string[] = [
19
+ "actor_client_v1",
20
+ "gateway_ingress_v1",
21
+ "gateway_service_v1",
22
+ "local_v1",
23
+ "oauth_proxy_v1",
24
+ "speech_relay_v1",
25
+ "ui_page_v1",
26
+ ];
27
+
28
+ const TRUST_EXEMPT_SET: ReadonlySet<string> = new Set(
29
+ TRUST_EXEMPT_SCOPE_PROFILES,
30
+ );
31
+
32
+ /**
33
+ * True when a token of `profile` reaches a route only if the route admits
34
+ * its holder's trust class.
35
+ */
36
+ export function isTrustCheckedScopeProfile(profile: string): boolean {
37
+ return !TRUST_EXEMPT_SET.has(profile);
38
+ }
39
+
40
+ /** Trust classes a route admits when its policy names none. */
41
+ export const DEFAULT_ROUTE_TRUST_CLASSES: readonly TrustClass[] = ["guardian"];
42
+
43
+ /**
44
+ * Whether a route admitting `allowedTrustClasses` admits a caller of
45
+ * `trustClass`. A value outside the vocabulary is refused, since a wire-sourced
46
+ * class can carry anything.
47
+ */
48
+ export function routeAdmitsTrustClass(
49
+ allowedTrustClasses: readonly string[] | undefined,
50
+ trustClass: string | undefined,
51
+ ): boolean {
52
+ if (typeof trustClass !== "string" || !isTrustClass(trustClass)) {
53
+ return false;
54
+ }
55
+ return (allowedTrustClasses ?? DEFAULT_ROUTE_TRUST_CLASSES).includes(
56
+ trustClass,
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Whether a caller whose token carries `scopeProfile` may reach a route
62
+ * admitting `allowedTrustClasses`.
63
+ *
64
+ * A trust-exempt profile stands for the guardian, so it reaches exactly the
65
+ * routes that admit `guardian`, with no lookup. That is every route naming no
66
+ * classes.
67
+ *
68
+ * A trust-checked token never speaks for the guardian, so a route admitting no
69
+ * other class refuses it without calling `resolveTrustClass`. Otherwise the
70
+ * resolved class must be one the route admits and must not be `guardian`. A
71
+ * resolver that throws or resolves nothing refuses.
72
+ */
73
+ export async function tokenMayReachRoute(
74
+ scopeProfile: string,
75
+ allowedTrustClasses: readonly string[] | undefined,
76
+ resolveTrustClass: () => Promise<string | undefined>,
77
+ ): Promise<boolean> {
78
+ if (!isTrustCheckedScopeProfile(scopeProfile)) {
79
+ return routeAdmitsTrustClass(allowedTrustClasses, "guardian");
80
+ }
81
+ const allowed = allowedTrustClasses ?? DEFAULT_ROUTE_TRUST_CLASSES;
82
+ if (!allowed.some((trustClass) => trustClass !== "guardian")) {
83
+ return false;
84
+ }
85
+ let trustClass: string | undefined;
86
+ try {
87
+ trustClass = await resolveTrustClass();
88
+ } catch {
89
+ return false;
90
+ }
91
+ return (
92
+ trustClass !== "guardian" && routeAdmitsTrustClass(allowed, trustClass)
93
+ );
94
+ }
@@ -24,6 +24,7 @@ describe("isChannelId", () => {
24
24
  // so these must remain canonical for that assertion to mean anything.
25
25
  expect(isChannelId("platform")).toBe(true);
26
26
  expect(isChannelId("vellum")).toBe(true);
27
+ expect(isChannelId("vellum-shared")).toBe(true);
27
28
  });
28
29
 
29
30
  test("includes discord", () => {
@@ -58,6 +59,7 @@ describe("botProviderForChannel and channelForBotProvider", () => {
58
59
  test("answer nothing for a channel without a bot, a person's grant, or an unknown key", () => {
59
60
  expect(botProviderForChannel("phone")).toBeUndefined();
60
61
  expect(botProviderForChannel("vellum")).toBeUndefined();
62
+ expect(botProviderForChannel("vellum-shared")).toBeUndefined();
61
63
  expect(botProviderForChannel("not-a-channel")).toBeUndefined();
62
64
  // `slack` is the person's integration standing beside the bot's key.
63
65
  expect(channelForBotProvider("slack")).toBeUndefined();
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * A "channel" is an external messaging surface an actor can reach the
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
- * internal ids (`vellum` for native app conversations, `platform` for the
8
- * internal control plane). This is the single source of truth for that set:
7
+ * internal ids (`vellum` for native app conversations, `vellum-shared` for
8
+ * conversations a guardian shares with a contact, `platform` for the internal
9
+ * control plane). This is the single source of truth for that set:
9
10
  *
10
11
  * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
12
  * brings. A plugin channel's real identity is the plugin, which is workspace
@@ -41,6 +42,9 @@ export const CHANNEL_IDS = [
41
42
  "a2a",
42
43
  "discord",
43
44
  "plugin",
45
+ // Internal id for conversations a guardian shares with a contact. It has no
46
+ // transport: the contact's own client holds the connection.
47
+ "vellum-shared",
44
48
  ] as const;
45
49
 
46
50
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -78,8 +82,8 @@ export function isChannelId(value: unknown): value is ChannelId {
78
82
  * here would be a second copy of a different fact.
79
83
  *
80
84
  * Channels absent from this map reach the assistant without a bot credential
81
- * of their own: `phone` through the voice provider, `vellum` and `platform`
82
- * internally.
85
+ * of their own: `phone` through the voice provider, `vellum`, `vellum-shared`
86
+ * and `platform` internally.
83
87
  */
84
88
  export const CHANNEL_BOT_PROVIDER = {
85
89
  slack: "slack_channel",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.12.6",
3
+ "version": "0.12.7-staging.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -133,6 +133,7 @@ describe("GET /v1/channel-admission-policy", () => {
133
133
  store.set("a2a", "guardian_only");
134
134
  store.set("vellum", "guardian_only");
135
135
  store.set("whatsapp", "guardian_only");
136
+ store.set("vellum-shared", "guardian_only");
136
137
 
137
138
  const handler = createChannelAdmissionPolicyListHandler();
138
139
  const res = await handler(
@@ -145,6 +146,7 @@ describe("GET /v1/channel-admission-policy", () => {
145
146
  expect(seen.has("a2a")).toBe(false);
146
147
  expect(seen.has("vellum")).toBe(false);
147
148
  expect(seen.has("whatsapp")).toBe(false);
149
+ expect(seen.has("vellum-shared")).toBe(false);
148
150
  // phone is enforced + visible; other visible channels still surface.
149
151
  expect(seen.has("phone")).toBe(true);
150
152
  expect(seen.has("telegram")).toBe(true);
@@ -161,11 +163,10 @@ describe("PUT /v1/channel-admission-policy/:channelType", () => {
161
163
  test("upserts a valid policy and invalidates the cache", async () => {
162
164
  const handler = createChannelAdmissionPolicySetHandler();
163
165
  const res = await handler(
164
- jsonRequest(
165
- "http://localhost/v1/channel-admission-policy/slack",
166
- "PUT",
167
- { policy: "guardian_only", note: "tight" },
168
- ),
166
+ jsonRequest("http://localhost/v1/channel-admission-policy/slack", "PUT", {
167
+ policy: "guardian_only",
168
+ note: "tight",
169
+ }),
169
170
  "slack",
170
171
  );
171
172
  expect(res.status).toBe(200);
@@ -199,11 +200,9 @@ describe("PUT /v1/channel-admission-policy/:channelType", () => {
199
200
  test("rejects invalid policy with 400", async () => {
200
201
  const handler = createChannelAdmissionPolicySetHandler();
201
202
  const res = await handler(
202
- jsonRequest(
203
- "http://localhost/v1/channel-admission-policy/email",
204
- "PUT",
205
- { policy: "lets-everyone-in" },
206
- ),
203
+ jsonRequest("http://localhost/v1/channel-admission-policy/email", "PUT", {
204
+ policy: "lets-everyone-in",
205
+ }),
207
206
  "email",
208
207
  );
209
208
  expect(res.status).toBe(400);
@@ -242,11 +241,11 @@ describe("PUT /v1/channel-admission-policy/:channelType", () => {
242
241
  expect(body.policy.note).toBeNull();
243
242
  });
244
243
 
245
- test("rejects PUT for hidden channels (`vellum`, `whatsapp`) with 403", async () => {
244
+ test("rejects PUT for hidden channels (`vellum`, `vellum-shared`, `whatsapp`) with 403", async () => {
246
245
  // Hidden channels are managed automatically and not user-configurable;
247
246
  // even a non-kill-switch policy must be rejected so no stranded row forms.
248
247
  const handler = createChannelAdmissionPolicySetHandler();
249
- for (const channel of ["vellum", "whatsapp"] as const) {
248
+ for (const channel of ["vellum", "vellum-shared", "whatsapp"] as const) {
250
249
  const res = await handler(
251
250
  jsonRequest(
252
251
  `http://localhost/v1/channel-admission-policy/${channel}`,
@@ -298,7 +297,10 @@ describe("DELETE /v1/channel-admission-policy/:channelType", () => {
298
297
  store.set("slack", "guardian_only");
299
298
  const handler = createChannelAdmissionPolicyDeleteHandler();
300
299
  const res = await handler(
301
- jsonRequest("http://localhost/v1/channel-admission-policy/slack", "DELETE"),
300
+ jsonRequest(
301
+ "http://localhost/v1/channel-admission-policy/slack",
302
+ "DELETE",
303
+ ),
302
304
  "slack",
303
305
  );
304
306
  expect(res.status).toBe(200);
@@ -335,7 +337,9 @@ describe("GET /v1/channel-admission-policy — phone enforced", () => {
335
337
  jsonRequest("http://localhost/v1/channel-admission-policy", "GET"),
336
338
  );
337
339
  expect(res.status).toBe(200);
338
- const body = (await res.json()) as { policies: Array<{ channelType: string }> };
340
+ const body = (await res.json()) as {
341
+ policies: Array<{ channelType: string }>;
342
+ };
339
343
  const seen = new Set(body.policies.map((p) => p.channelType));
340
344
  expect(seen.has("phone")).toBe(true);
341
345
  // Confirm other non-exempt channels still appear.