@vellumai/vellum-gateway 0.10.11-dev.202607221933.baa2119 → 0.10.11-dev.202607222129.31c06e5
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/__tests__/binding-strength-contract.test.ts +122 -0
- package/node_modules/@vellumai/gateway-client/src/binding-strength-contract.ts +155 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +14 -0
- package/package.json +1 -1
- package/src/__tests__/contact-handlers.test.ts +109 -7
- package/src/__tests__/contact-store-mark-channel-verified.test.ts +96 -10
- package/src/db/contact-store.ts +41 -3
- package/src/feature-flag-registry.json +9 -0
- package/src/verification/contact-helpers.ts +45 -5
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the shared binding-strength vocabulary: the display ladder and the
|
|
3
|
+
* enforcement order (demotion guard — LUM-2505).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
bindingStrengthForVerifiedVia,
|
|
10
|
+
isBindingDemotion,
|
|
11
|
+
knownStrengthRank,
|
|
12
|
+
VERIFIED_VIA_CHALLENGE,
|
|
13
|
+
VERIFIED_VIA_CHANNEL_CLAIM,
|
|
14
|
+
VERIFIED_VIA_MANUAL,
|
|
15
|
+
} from "../binding-strength-contract.js";
|
|
16
|
+
|
|
17
|
+
describe("knownStrengthRank", () => {
|
|
18
|
+
test("unverified (null / undefined / empty) ranks 0", () => {
|
|
19
|
+
expect(knownStrengthRank(null)).toBe(0);
|
|
20
|
+
expect(knownStrengthRank(undefined)).toBe(0);
|
|
21
|
+
expect(knownStrengthRank("")).toBe(0);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("inbound channel claim ranks 1", () => {
|
|
25
|
+
expect(knownStrengthRank(VERIFIED_VIA_CHANNEL_CLAIM)).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("workspace-vouched (manual) ranks 2", () => {
|
|
29
|
+
expect(knownStrengthRank(VERIFIED_VIA_MANUAL)).toBe(2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("proven / possession values all rank 3", () => {
|
|
33
|
+
expect(knownStrengthRank(VERIFIED_VIA_CHALLENGE)).toBe(3);
|
|
34
|
+
expect(knownStrengthRank("invite")).toBe(3);
|
|
35
|
+
expect(knownStrengthRank("voice")).toBe(3);
|
|
36
|
+
expect(knownStrengthRank("bootstrap")).toBe(3);
|
|
37
|
+
// Guardian auto-registration bindings (createGuardianBinding writers).
|
|
38
|
+
expect(knownStrengthRank("platform_auto_register")).toBe(3);
|
|
39
|
+
expect(knownStrengthRank("webhook_registration")).toBe(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("unrecognized non-empty provenance is unknown (null)", () => {
|
|
43
|
+
expect(knownStrengthRank("guardian_approval")).toBeNull();
|
|
44
|
+
expect(knownStrengthRank("something_new")).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("bindingStrengthForVerifiedVia (display ladder)", () => {
|
|
49
|
+
test("maps the three introduction-card provenances", () => {
|
|
50
|
+
expect(bindingStrengthForVerifiedVia(VERIFIED_VIA_CHALLENGE)).toBe(
|
|
51
|
+
"verified_handshake",
|
|
52
|
+
);
|
|
53
|
+
expect(bindingStrengthForVerifiedVia(VERIFIED_VIA_MANUAL)).toBe(
|
|
54
|
+
"internal_workspace_match",
|
|
55
|
+
);
|
|
56
|
+
expect(bindingStrengthForVerifiedVia(VERIFIED_VIA_CHANNEL_CLAIM)).toBe(
|
|
57
|
+
"inbound_channel_claim",
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns null for provenance outside the ladder (card UI relies on this)", () => {
|
|
62
|
+
expect(bindingStrengthForVerifiedVia("invite")).toBeNull();
|
|
63
|
+
expect(bindingStrengthForVerifiedVia("bootstrap")).toBeNull();
|
|
64
|
+
expect(bindingStrengthForVerifiedVia("voice")).toBeNull();
|
|
65
|
+
expect(bindingStrengthForVerifiedVia(null)).toBeNull();
|
|
66
|
+
expect(bindingStrengthForVerifiedVia(undefined)).toBeNull();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("isBindingDemotion", () => {
|
|
71
|
+
test("a lower-strength source demoting a higher-strength binding is refused", () => {
|
|
72
|
+
// challenge (3) is the strongest — nothing weaker may overwrite it.
|
|
73
|
+
expect(isBindingDemotion(VERIFIED_VIA_CHALLENGE, VERIFIED_VIA_MANUAL)).toBe(
|
|
74
|
+
true,
|
|
75
|
+
);
|
|
76
|
+
expect(
|
|
77
|
+
isBindingDemotion(VERIFIED_VIA_CHALLENGE, VERIFIED_VIA_CHANNEL_CLAIM),
|
|
78
|
+
).toBe(true);
|
|
79
|
+
// manual (2) may not be demoted to an inbound claim (1).
|
|
80
|
+
expect(
|
|
81
|
+
isBindingDemotion(VERIFIED_VIA_MANUAL, VERIFIED_VIA_CHANNEL_CLAIM),
|
|
82
|
+
).toBe(true);
|
|
83
|
+
// A proven value demoted to null/unverified is a demotion too.
|
|
84
|
+
expect(isBindingDemotion(VERIFIED_VIA_CHALLENGE, null)).toBe(true);
|
|
85
|
+
// Guardian auto-registration bindings are proven — a later manual attest
|
|
86
|
+
// or channel claim can never demote them.
|
|
87
|
+
expect(
|
|
88
|
+
isBindingDemotion("platform_auto_register", VERIFIED_VIA_MANUAL),
|
|
89
|
+
).toBe(true);
|
|
90
|
+
expect(
|
|
91
|
+
isBindingDemotion("webhook_registration", VERIFIED_VIA_CHANNEL_CLAIM),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("equal-tier writes and lateral proven swaps are not demotions", () => {
|
|
96
|
+
expect(isBindingDemotion(VERIFIED_VIA_CHALLENGE, "invite")).toBe(false);
|
|
97
|
+
expect(isBindingDemotion("invite", VERIFIED_VIA_CHALLENGE)).toBe(false);
|
|
98
|
+
expect(isBindingDemotion(VERIFIED_VIA_MANUAL, VERIFIED_VIA_MANUAL)).toBe(
|
|
99
|
+
false,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("upgrades are not demotions", () => {
|
|
104
|
+
expect(
|
|
105
|
+
isBindingDemotion(VERIFIED_VIA_CHANNEL_CLAIM, VERIFIED_VIA_CHALLENGE),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
expect(isBindingDemotion(null, VERIFIED_VIA_CHANNEL_CLAIM)).toBe(false);
|
|
108
|
+
expect(isBindingDemotion(VERIFIED_VIA_MANUAL, VERIFIED_VIA_CHALLENGE)).toBe(
|
|
109
|
+
false,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("unknown provenance on either side is never a demotion (fail-open)", () => {
|
|
114
|
+
expect(isBindingDemotion(VERIFIED_VIA_CHALLENGE, "guardian_approval")).toBe(
|
|
115
|
+
false,
|
|
116
|
+
);
|
|
117
|
+
expect(
|
|
118
|
+
isBindingDemotion("guardian_approval", VERIFIED_VIA_CHANNEL_CLAIM),
|
|
119
|
+
).toBe(false);
|
|
120
|
+
expect(isBindingDemotion("unknown_a", "unknown_b")).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared binding-strength vocabulary for the contact channel `verified_via`
|
|
3
|
+
* provenance column.
|
|
4
|
+
*
|
|
5
|
+
* Two distinct notions live here, both keyed on the free-text `verified_via`
|
|
6
|
+
* audit value:
|
|
7
|
+
*
|
|
8
|
+
* 1. The 3-tier *display* ladder (`BindingStrength` +
|
|
9
|
+
* `bindingStrengthForVerifiedVia`) the introduction card renders from. It
|
|
10
|
+
* covers only the three introduction-card provenances and returns `null`
|
|
11
|
+
* for everything else — the card UI depends on that exact shape.
|
|
12
|
+
*
|
|
13
|
+
* 2. The *enforcement* order (`knownStrengthRank` + `isBindingDemotion`) the
|
|
14
|
+
* gateway ACL write path uses to refuse demotions (LUM-2505): a total,
|
|
15
|
+
* conservative ranking over every real `verified_via` value, so a
|
|
16
|
+
* lower-strength source can never silently overwrite a higher-strength
|
|
17
|
+
* active binding.
|
|
18
|
+
*
|
|
19
|
+
* This lives in the shared package — not `assistant/` — because the gateway
|
|
20
|
+
* owns the contact ACL and is where the demotion guard runs. The assistant
|
|
21
|
+
* re-exports the display helpers from `runtime/introduction-policy.ts`, so its
|
|
22
|
+
* card/provenance call sites are unchanged.
|
|
23
|
+
*
|
|
24
|
+
* No import from `assistant/` or `gateway/`: pure vocabulary.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// verified_via constants
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/** `verified_via` written by the gateway when a verification code is redeemed. */
|
|
32
|
+
export const VERIFIED_VIA_CHALLENGE = "challenge";
|
|
33
|
+
|
|
34
|
+
/** `verified_via` written when the guardian direct-trusts a workspace-vouched identity. */
|
|
35
|
+
export const VERIFIED_VIA_MANUAL = "manual";
|
|
36
|
+
|
|
37
|
+
/** `verified_via` written when the guardian trusts an identity the platform is not vouching for. */
|
|
38
|
+
export const VERIFIED_VIA_CHANNEL_CLAIM = "manual_channel_claim";
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Display ladder (introduction card)
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Binding-strength ladder the introduction card renders:
|
|
46
|
+
*
|
|
47
|
+
* verified_handshake > internal_workspace_match > inbound_channel_claim
|
|
48
|
+
*
|
|
49
|
+
* - `verified_handshake` — the contact proved control of the channel by
|
|
50
|
+
* returning a verification code (`verified_via: "challenge"`).
|
|
51
|
+
* - `internal_workspace_match` — the platform authenticated the identity inside
|
|
52
|
+
* the guardian's own workspace; the guardian vouches directly
|
|
53
|
+
* (`verified_via: "manual"`).
|
|
54
|
+
* - `inbound_channel_claim` — the guardian trusts an identity the platform is
|
|
55
|
+
* NOT vouching for (`verified_via: "manual_channel_claim"`).
|
|
56
|
+
*/
|
|
57
|
+
export type BindingStrength =
|
|
58
|
+
| "verified_handshake"
|
|
59
|
+
| "internal_workspace_match"
|
|
60
|
+
| "inbound_channel_claim";
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Derive the display binding strength for a `verified_via`. Returns `null` for
|
|
64
|
+
* provenance outside the introduction ladder (e.g. `invite`, `bootstrap`),
|
|
65
|
+
* which predate it and keep their own audit meaning — the introduction card UI
|
|
66
|
+
* relies on this exact shape.
|
|
67
|
+
*
|
|
68
|
+
* This is the DISPLAY ladder, not the enforcement order: use
|
|
69
|
+
* {@link knownStrengthRank} / {@link isBindingDemotion} for the demotion guard.
|
|
70
|
+
*/
|
|
71
|
+
export function bindingStrengthForVerifiedVia(
|
|
72
|
+
verifiedVia: string | null | undefined,
|
|
73
|
+
): BindingStrength | null {
|
|
74
|
+
switch (verifiedVia) {
|
|
75
|
+
case VERIFIED_VIA_CHALLENGE:
|
|
76
|
+
return "verified_handshake";
|
|
77
|
+
case VERIFIED_VIA_MANUAL:
|
|
78
|
+
return "internal_workspace_match";
|
|
79
|
+
case VERIFIED_VIA_CHANNEL_CLAIM:
|
|
80
|
+
return "inbound_channel_claim";
|
|
81
|
+
default:
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Enforcement order (demotion guard — LUM-2505)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `verified_via` values where the actor demonstrably controls the channel (a
|
|
92
|
+
* code/token/possession proof) or the binding is a guardian binding (bootstrap
|
|
93
|
+
* or platform/webhook auto-registration). They share the top enforcement rank:
|
|
94
|
+
* none may be silently demoted, and a lateral swap between them is not a
|
|
95
|
+
* demotion. Every real production writer of `verified_via` that outranks
|
|
96
|
+
* `manual` must appear here, or the demotion guard would treat it as unknown
|
|
97
|
+
* (incomparable) and let a `manual` re-attest quietly demote it.
|
|
98
|
+
*/
|
|
99
|
+
const PROVEN_VERIFIED_VIA: ReadonlySet<string> = new Set([
|
|
100
|
+
VERIFIED_VIA_CHALLENGE, // returned a verification code
|
|
101
|
+
"invite", // redeemed an invite code / token
|
|
102
|
+
"voice", // returned a voice (DTMF) code
|
|
103
|
+
"bootstrap", // guardian binding (also protected by the guardian-downgrade guard)
|
|
104
|
+
"platform_auto_register", // guardian binding auto-registered by the platform
|
|
105
|
+
"webhook_registration", // guardian binding registered via a provider webhook
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Total, conservative strength rank for a `verified_via`, used by the gateway
|
|
110
|
+
* ACL write path to refuse demotions. Higher = stronger. Grouped into coarse
|
|
111
|
+
* tiers that hold under ANY intra-tier ordering the pending Slack-permissions
|
|
112
|
+
* PRD (D4) may land on:
|
|
113
|
+
*
|
|
114
|
+
* 3 proven / possession — {@link PROVEN_VERIFIED_VIA}.
|
|
115
|
+
* 2 workspace-vouched — the platform authenticated the identity but the
|
|
116
|
+
* actor did not actively prove control here (`manual`).
|
|
117
|
+
* 1 inbound channel claim — nobody vouches; the guardian trusted an
|
|
118
|
+
* unproven claim (`manual_channel_claim`).
|
|
119
|
+
* 0 unverified — no verification at all (`null` / empty).
|
|
120
|
+
*
|
|
121
|
+
* Returns `null` for an unrecognized non-empty provenance ("unknown"). The
|
|
122
|
+
* demotion guard treats unknown as incomparable and never refuses on it, so a
|
|
123
|
+
* forward-compatible new `verified_via` value is never mistaken for a demotion.
|
|
124
|
+
*/
|
|
125
|
+
export function knownStrengthRank(
|
|
126
|
+
verifiedVia: string | null | undefined,
|
|
127
|
+
): number | null {
|
|
128
|
+
if (verifiedVia == null || verifiedVia === "") return 0;
|
|
129
|
+
if (PROVEN_VERIFIED_VIA.has(verifiedVia)) return 3;
|
|
130
|
+
if (verifiedVia === VERIFIED_VIA_MANUAL) return 2;
|
|
131
|
+
if (verifiedVia === VERIFIED_VIA_CHANNEL_CLAIM) return 1;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Whether writing `incomingVia` over an existing `existingVia` binding would
|
|
137
|
+
* LOWER its strength — the demotion the gateway ACL write path refuses
|
|
138
|
+
* (LUM-2505).
|
|
139
|
+
*
|
|
140
|
+
* True only when BOTH provenances are known and the incoming rank is strictly
|
|
141
|
+
* lower than the existing. Unknown provenance on either side is incomparable
|
|
142
|
+
* and never counts as a demotion (fail-open), so the guard blocks only provable
|
|
143
|
+
* downgrades and never breaks a new/unrecognized `verified_via`. Equal ranks
|
|
144
|
+
* (including lateral swaps within the proven tier) and upgrades are not
|
|
145
|
+
* demotions.
|
|
146
|
+
*/
|
|
147
|
+
export function isBindingDemotion(
|
|
148
|
+
existingVia: string | null | undefined,
|
|
149
|
+
incomingVia: string | null | undefined,
|
|
150
|
+
): boolean {
|
|
151
|
+
const existing = knownStrengthRank(existingVia);
|
|
152
|
+
const incoming = knownStrengthRank(incomingVia);
|
|
153
|
+
if (existing == null || incoming == null) return false;
|
|
154
|
+
return incoming < existing;
|
|
155
|
+
}
|
|
@@ -321,3 +321,17 @@ export type {
|
|
|
321
321
|
ResolvedChannelPermission,
|
|
322
322
|
RiskThreshold,
|
|
323
323
|
} from "./channel-permission-contract.js";
|
|
324
|
+
|
|
325
|
+
// Binding-strength vocabulary for the contact `verified_via` provenance —
|
|
326
|
+
// display ladder (introduction card) + the enforcement order the gateway ACL
|
|
327
|
+
// write path uses to refuse demotions (LUM-2505).
|
|
328
|
+
export {
|
|
329
|
+
bindingStrengthForVerifiedVia,
|
|
330
|
+
isBindingDemotion,
|
|
331
|
+
knownStrengthRank,
|
|
332
|
+
VERIFIED_VIA_CHALLENGE,
|
|
333
|
+
VERIFIED_VIA_CHANNEL_CLAIM,
|
|
334
|
+
VERIFIED_VIA_MANUAL,
|
|
335
|
+
} from "./binding-strength-contract.js";
|
|
336
|
+
|
|
337
|
+
export type { BindingStrength } from "./binding-strength-contract.js";
|
package/package.json
CHANGED
|
@@ -8,7 +8,15 @@
|
|
|
8
8
|
* running daemon.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
describe,
|
|
13
|
+
test,
|
|
14
|
+
expect,
|
|
15
|
+
beforeAll,
|
|
16
|
+
beforeEach,
|
|
17
|
+
afterAll,
|
|
18
|
+
mock,
|
|
19
|
+
} from "bun:test";
|
|
12
20
|
|
|
13
21
|
import "./test-preload.js";
|
|
14
22
|
|
|
@@ -80,10 +88,7 @@ const mirrorIpcCalls: { method: string; body: unknown }[] = [];
|
|
|
80
88
|
mock.module("../ipc/assistant-client.js", () => ({
|
|
81
89
|
IpcHandlerError: class extends Error {},
|
|
82
90
|
IpcTransportError: class extends Error {},
|
|
83
|
-
ipcCallAssistant: async (
|
|
84
|
-
method: string,
|
|
85
|
-
params?: { body?: unknown },
|
|
86
|
-
) => {
|
|
91
|
+
ipcCallAssistant: async (method: string, params?: { body?: unknown }) => {
|
|
87
92
|
mirrorIpcCalls.push({ method, body: params?.body });
|
|
88
93
|
return { ok: true };
|
|
89
94
|
},
|
|
@@ -164,7 +169,13 @@ function seedContact(id: string, role: "guardian" | "contact" = "guardian") {
|
|
|
164
169
|
.run();
|
|
165
170
|
}
|
|
166
171
|
|
|
167
|
-
function seedChannel(opts: {
|
|
172
|
+
function seedChannel(opts: {
|
|
173
|
+
id: string;
|
|
174
|
+
contactId: string;
|
|
175
|
+
status?: string;
|
|
176
|
+
verifiedVia?: string | null;
|
|
177
|
+
verifiedAt?: number | null;
|
|
178
|
+
}) {
|
|
168
179
|
const now = Date.now();
|
|
169
180
|
getGatewayDb()
|
|
170
181
|
.insert(contactChannels)
|
|
@@ -176,6 +187,8 @@ function seedChannel(opts: { id: string; contactId: string; status?: string }) {
|
|
|
176
187
|
isPrimary: false,
|
|
177
188
|
status: opts.status ?? "unverified",
|
|
178
189
|
policy: "allow",
|
|
190
|
+
verifiedAt: opts.verifiedAt ?? null,
|
|
191
|
+
verifiedVia: opts.verifiedVia ?? null,
|
|
179
192
|
interactionCount: 0,
|
|
180
193
|
createdAt: now,
|
|
181
194
|
updatedAt: now,
|
|
@@ -457,7 +470,7 @@ describe("upsert_verified_channel IPC handler", () => {
|
|
|
457
470
|
expect(row!.status).toBe("blocked");
|
|
458
471
|
});
|
|
459
472
|
|
|
460
|
-
test(
|
|
473
|
+
test('round-trips verifiedVia="invite" (free string, not the restricted enum)', async () => {
|
|
461
474
|
const res = (await upsertVerifiedChannelHandler({
|
|
462
475
|
type: "vellum",
|
|
463
476
|
address: "addr-invite",
|
|
@@ -477,3 +490,92 @@ describe("upsert_verified_channel IPC handler", () => {
|
|
|
477
490
|
expect(row!.verifiedVia).toBe("invite");
|
|
478
491
|
});
|
|
479
492
|
});
|
|
493
|
+
|
|
494
|
+
describe("upsert_verified_channel binding-strength guard (LUM-2505)", () => {
|
|
495
|
+
test("refuses to demote an active challenge binding to a manual_channel_claim", async () => {
|
|
496
|
+
seedContact("c1", "contact");
|
|
497
|
+
seedChannel({
|
|
498
|
+
id: "ch1",
|
|
499
|
+
contactId: "c1",
|
|
500
|
+
status: "active",
|
|
501
|
+
verifiedVia: "challenge",
|
|
502
|
+
verifiedAt: 500,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const res = (await upsertVerifiedChannelHandler({
|
|
506
|
+
type: "vellum",
|
|
507
|
+
address: "addr-ch1",
|
|
508
|
+
externalChatId: "chat-1",
|
|
509
|
+
verifiedVia: "manual_channel_claim",
|
|
510
|
+
})) as { ok: boolean; verified: boolean; channel: { verifiedVia: string } };
|
|
511
|
+
|
|
512
|
+
// The write still succeeds, but the stronger provenance is preserved.
|
|
513
|
+
expect(res.ok).toBe(true);
|
|
514
|
+
expect(res.verified).toBe(true);
|
|
515
|
+
expect(res.channel.verifiedVia).toBe("challenge");
|
|
516
|
+
|
|
517
|
+
const row = getGatewayDb()
|
|
518
|
+
.select()
|
|
519
|
+
.from(contactChannels)
|
|
520
|
+
.where(eq(contactChannels.id, "ch1"))
|
|
521
|
+
.get();
|
|
522
|
+
expect(row!.verifiedVia).toBe("challenge");
|
|
523
|
+
expect(row!.verifiedAt).toBe(500);
|
|
524
|
+
expect(row!.status).toBe("active");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("refuses to demote an active challenge binding to a manual attest", async () => {
|
|
528
|
+
seedContact("c1", "contact");
|
|
529
|
+
seedChannel({
|
|
530
|
+
id: "ch1",
|
|
531
|
+
contactId: "c1",
|
|
532
|
+
status: "active",
|
|
533
|
+
verifiedVia: "challenge",
|
|
534
|
+
verifiedAt: 500,
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
const res = (await upsertVerifiedChannelHandler({
|
|
538
|
+
type: "vellum",
|
|
539
|
+
address: "addr-ch1",
|
|
540
|
+
externalChatId: "chat-1",
|
|
541
|
+
verifiedVia: "manual",
|
|
542
|
+
})) as { ok: boolean; channel: { verifiedVia: string } };
|
|
543
|
+
|
|
544
|
+
expect(res.channel.verifiedVia).toBe("challenge");
|
|
545
|
+
|
|
546
|
+
const row = getGatewayDb()
|
|
547
|
+
.select()
|
|
548
|
+
.from(contactChannels)
|
|
549
|
+
.where(eq(contactChannels.id, "ch1"))
|
|
550
|
+
.get();
|
|
551
|
+
expect(row!.verifiedVia).toBe("challenge");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("still applies an upgrade (manual_channel_claim -> challenge)", async () => {
|
|
555
|
+
seedContact("c1", "contact");
|
|
556
|
+
seedChannel({
|
|
557
|
+
id: "ch1",
|
|
558
|
+
contactId: "c1",
|
|
559
|
+
status: "active",
|
|
560
|
+
verifiedVia: "manual_channel_claim",
|
|
561
|
+
verifiedAt: 500,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const res = (await upsertVerifiedChannelHandler({
|
|
565
|
+
type: "vellum",
|
|
566
|
+
address: "addr-ch1",
|
|
567
|
+
externalChatId: "chat-1",
|
|
568
|
+
verifiedVia: "challenge",
|
|
569
|
+
})) as { ok: boolean; verified: boolean; channel: { verifiedVia: string } };
|
|
570
|
+
|
|
571
|
+
expect(res.verified).toBe(true);
|
|
572
|
+
expect(res.channel.verifiedVia).toBe("challenge");
|
|
573
|
+
|
|
574
|
+
const row = getGatewayDb()
|
|
575
|
+
.select()
|
|
576
|
+
.from(contactChannels)
|
|
577
|
+
.where(eq(contactChannels.id, "ch1"))
|
|
578
|
+
.get();
|
|
579
|
+
expect(row!.verifiedVia).toBe("challenge");
|
|
580
|
+
});
|
|
581
|
+
});
|
|
@@ -270,7 +270,7 @@ describe("ContactStore.markChannelVerified", () => {
|
|
|
270
270
|
expect(result!.channel.verifiedVia).toBe("manual");
|
|
271
271
|
});
|
|
272
272
|
|
|
273
|
-
test("
|
|
273
|
+
test("refuses to demote a challenge-verified channel to manual (LUM-2505)", async () => {
|
|
274
274
|
seedContact("c1");
|
|
275
275
|
seedChannel({
|
|
276
276
|
id: "ch1",
|
|
@@ -280,12 +280,33 @@ describe("ContactStore.markChannelVerified", () => {
|
|
|
280
280
|
verifiedVia: "challenge",
|
|
281
281
|
});
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
// A `manual` attest (e.g. a roster "Link account") must not lower the
|
|
284
|
+
// stronger `challenge` proof on an active binding — the existing binding
|
|
285
|
+
// stands as an idempotent no-op.
|
|
284
286
|
const result = await new ContactStore().markChannelVerified("ch1");
|
|
285
287
|
expect(result).not.toBeNull();
|
|
288
|
+
expect(result!.didWrite).toBe(false);
|
|
289
|
+
expect(result!.channel.verifiedVia).toBe("challenge");
|
|
290
|
+
expect(result!.channel.verifiedAt).toBe(500);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("applies an upgrade: manual -> challenge on an active channel", async () => {
|
|
294
|
+
seedContact("c1");
|
|
295
|
+
seedChannel({
|
|
296
|
+
id: "ch1",
|
|
297
|
+
contactId: "c1",
|
|
298
|
+
status: "active",
|
|
299
|
+
verifiedAt: 500,
|
|
300
|
+
verifiedVia: "manual",
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const result = await new ContactStore().markChannelVerified(
|
|
304
|
+
"ch1",
|
|
305
|
+
"challenge",
|
|
306
|
+
);
|
|
307
|
+
expect(result).not.toBeNull();
|
|
286
308
|
expect(result!.didWrite).toBe(true);
|
|
287
|
-
expect(result!.channel.verifiedVia).toBe("
|
|
288
|
-
expect(result!.channel.verifiedAt!).toBeGreaterThanOrEqual(before);
|
|
309
|
+
expect(result!.channel.verifiedVia).toBe("challenge");
|
|
289
310
|
});
|
|
290
311
|
|
|
291
312
|
test("re-activates a non-active channel that previously had verifiedAt", async () => {
|
|
@@ -511,9 +532,7 @@ describe("ContactStore.markChannelVerified", () => {
|
|
|
511
532
|
|
|
512
533
|
// Exactly one gateway channel row: the existing gateway row is verified in
|
|
513
534
|
// place, keyed by (type,address).
|
|
514
|
-
expect(
|
|
515
|
-
getGatewayDb().select().from(contactChannels).all().length,
|
|
516
|
-
).toBe(1);
|
|
535
|
+
expect(getGatewayDb().select().from(contactChannels).all().length).toBe(1);
|
|
517
536
|
});
|
|
518
537
|
|
|
519
538
|
test("legacy cross-contact: resolves the gateway row by (type,address) even under a different contact", async () => {
|
|
@@ -548,6 +567,75 @@ describe("ContactStore.markChannelVerified", () => {
|
|
|
548
567
|
});
|
|
549
568
|
});
|
|
550
569
|
|
|
570
|
+
describe("ContactStore.upsertContact binding-strength guard (LUM-2505)", () => {
|
|
571
|
+
test("does not demote an active challenge channel via a weaker verifiedVia upsert", async () => {
|
|
572
|
+
seedContact("c1", "contact");
|
|
573
|
+
seedChannel({
|
|
574
|
+
id: "ch1",
|
|
575
|
+
contactId: "c1",
|
|
576
|
+
status: "active",
|
|
577
|
+
verifiedAt: 500,
|
|
578
|
+
verifiedVia: "challenge",
|
|
579
|
+
address: "addr-ch1",
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
await new ContactStore().upsertContact({
|
|
583
|
+
id: "c1",
|
|
584
|
+
channels: [
|
|
585
|
+
{
|
|
586
|
+
type: "vellum",
|
|
587
|
+
address: "addr-ch1",
|
|
588
|
+
status: "active",
|
|
589
|
+
verifiedVia: "manual_channel_claim",
|
|
590
|
+
verifiedAt: 999,
|
|
591
|
+
},
|
|
592
|
+
],
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
const row = getGatewayDb()
|
|
596
|
+
.select()
|
|
597
|
+
.from(contactChannels)
|
|
598
|
+
.where(eq(contactChannels.id, "ch1"))
|
|
599
|
+
.get();
|
|
600
|
+
expect(row!.verifiedVia).toBe("challenge");
|
|
601
|
+
expect(row!.verifiedAt).toBe(500);
|
|
602
|
+
expect(row!.status).toBe("active");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("applies an upgrade via upsert (manual_channel_claim -> challenge)", async () => {
|
|
606
|
+
seedContact("c1", "contact");
|
|
607
|
+
seedChannel({
|
|
608
|
+
id: "ch1",
|
|
609
|
+
contactId: "c1",
|
|
610
|
+
status: "active",
|
|
611
|
+
verifiedAt: 500,
|
|
612
|
+
verifiedVia: "manual_channel_claim",
|
|
613
|
+
address: "addr-ch1",
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
await new ContactStore().upsertContact({
|
|
617
|
+
id: "c1",
|
|
618
|
+
channels: [
|
|
619
|
+
{
|
|
620
|
+
type: "vellum",
|
|
621
|
+
address: "addr-ch1",
|
|
622
|
+
status: "active",
|
|
623
|
+
verifiedVia: "challenge",
|
|
624
|
+
verifiedAt: 999,
|
|
625
|
+
},
|
|
626
|
+
],
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
const row = getGatewayDb()
|
|
630
|
+
.select()
|
|
631
|
+
.from(contactChannels)
|
|
632
|
+
.where(eq(contactChannels.id, "ch1"))
|
|
633
|
+
.get();
|
|
634
|
+
expect(row!.verifiedVia).toBe("challenge");
|
|
635
|
+
expect(row!.verifiedAt).toBe(999);
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
551
639
|
describe("ContactStore.markChannelRevoked", () => {
|
|
552
640
|
test("revokes a channel by its gateway id", async () => {
|
|
553
641
|
seedContact("c1", "contact");
|
|
@@ -588,9 +676,7 @@ describe("ContactStore.markChannelRevoked", () => {
|
|
|
588
676
|
|
|
589
677
|
// Exactly one gateway channel row: the existing gateway row is revoked in
|
|
590
678
|
// place, keyed by (type,address).
|
|
591
|
-
expect(
|
|
592
|
-
getGatewayDb().select().from(contactChannels).all().length,
|
|
593
|
-
).toBe(1);
|
|
679
|
+
expect(getGatewayDb().select().from(contactChannels).all().length).toBe(1);
|
|
594
680
|
});
|
|
595
681
|
});
|
|
596
682
|
|
package/src/db/contact-store.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Database } from "bun:sqlite";
|
|
|
2
2
|
|
|
3
3
|
import { and, desc, eq, gt, ne, sql } from "drizzle-orm";
|
|
4
4
|
|
|
5
|
+
import { isBindingDemotion } from "@vellumai/gateway-client";
|
|
5
6
|
import {
|
|
6
7
|
type AssistantContactMetadata,
|
|
7
8
|
type ContactRead,
|
|
@@ -723,6 +724,25 @@ export class ContactStore {
|
|
|
723
724
|
if (!gwChannel) return null;
|
|
724
725
|
const gwChannelId = gwChannel.id;
|
|
725
726
|
|
|
727
|
+
// Binding-strength guard (LUM-2505): never demote an active binding's
|
|
728
|
+
// provenance. A lower-strength verifiedVia (e.g. a `manual` roster attest)
|
|
729
|
+
// over an already-active, higher-strength channel (e.g. `challenge`) is
|
|
730
|
+
// refused — the stronger existing binding stands, as an idempotent no-op.
|
|
731
|
+
if (
|
|
732
|
+
gwChannel.status === "active" &&
|
|
733
|
+
isBindingDemotion(gwChannel.verifiedVia, verifiedVia)
|
|
734
|
+
) {
|
|
735
|
+
log.warn(
|
|
736
|
+
{
|
|
737
|
+
channelId: gwChannelId,
|
|
738
|
+
existingVerifiedVia: gwChannel.verifiedVia,
|
|
739
|
+
incomingVerifiedVia: verifiedVia,
|
|
740
|
+
},
|
|
741
|
+
"markChannelVerified: refusing binding-strength demotion; preserving stronger verified_via",
|
|
742
|
+
);
|
|
743
|
+
return { channel: gwChannel, didWrite: false };
|
|
744
|
+
}
|
|
745
|
+
|
|
726
746
|
const now = Date.now();
|
|
727
747
|
const raw = (this.db as unknown as { $client: Database }).$client;
|
|
728
748
|
const result = raw
|
|
@@ -1545,9 +1565,27 @@ export class ContactStore {
|
|
|
1545
1565
|
if (ch.blockedReason !== undefined)
|
|
1546
1566
|
updateSet.blockedReason = ch.blockedReason;
|
|
1547
1567
|
}
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1568
|
+
// Binding-strength guard (LUM-2505): a lower-strength verifiedVia must
|
|
1569
|
+
// not demote an active binding. When it would, preserve the stronger
|
|
1570
|
+
// existing verified_via/verified_at; benign field updates still apply.
|
|
1571
|
+
if (
|
|
1572
|
+
existing.status === "active" &&
|
|
1573
|
+
ch.verifiedVia !== undefined &&
|
|
1574
|
+
isBindingDemotion(existing.verifiedVia, ch.verifiedVia)
|
|
1575
|
+
) {
|
|
1576
|
+
log.warn(
|
|
1577
|
+
{
|
|
1578
|
+
channelId: existing.id,
|
|
1579
|
+
existingVerifiedVia: existing.verifiedVia,
|
|
1580
|
+
incomingVerifiedVia: ch.verifiedVia,
|
|
1581
|
+
},
|
|
1582
|
+
"syncChannels: refusing binding-strength demotion; preserving stronger verified_via",
|
|
1583
|
+
);
|
|
1584
|
+
} else {
|
|
1585
|
+
if (ch.verifiedAt !== undefined) updateSet.verifiedAt = ch.verifiedAt;
|
|
1586
|
+
if (ch.verifiedVia !== undefined)
|
|
1587
|
+
updateSet.verifiedVia = ch.verifiedVia;
|
|
1588
|
+
}
|
|
1551
1589
|
if (ch.inviteId !== undefined) updateSet.inviteId = ch.inviteId;
|
|
1552
1590
|
this.db
|
|
1553
1591
|
.update(contactChannels)
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 1,
|
|
3
3
|
"flags": [
|
|
4
|
+
{
|
|
5
|
+
"id": "in-chat-onboarding-tour",
|
|
6
|
+
"scope": "client",
|
|
7
|
+
"key": "in-chat-onboarding-tour",
|
|
8
|
+
"label": "In-Chat Onboarding Tour",
|
|
9
|
+
"description": "Multivariate experiment for the eyes-led in-chat onboarding tour. control = straight to chat after research onboarding (current behavior); tour = the extended tour auto-plays on first workspace entry, and the header replay button shows. Targeted via LaunchDarkly (planned 70% tour / 30% control).",
|
|
10
|
+
"defaultEnabled": "control",
|
|
11
|
+
"values": ["control", "tour"]
|
|
12
|
+
},
|
|
4
13
|
{
|
|
5
14
|
"id": "user-hosted-enabled",
|
|
6
15
|
"scope": "client",
|
|
@@ -15,6 +15,8 @@ import { existsSync } from "node:fs";
|
|
|
15
15
|
|
|
16
16
|
import { and, eq, sql } from "drizzle-orm";
|
|
17
17
|
|
|
18
|
+
import { isBindingDemotion } from "@vellumai/gateway-client";
|
|
19
|
+
|
|
18
20
|
import { getGatewayDb } from "../db/connection.js";
|
|
19
21
|
import {
|
|
20
22
|
contactChannels as gwContactChannels,
|
|
@@ -82,6 +84,10 @@ export async function findContactChannelByAddress(
|
|
|
82
84
|
* legacy/unmirrored channel may carry a different gateway id than the
|
|
83
85
|
* assistant id, and a blind id-keyed update would silently affect 0 rows.
|
|
84
86
|
*
|
|
87
|
+
* `verifiedAt` defaults to `now`; the caller passes an explicit value to
|
|
88
|
+
* preserve an existing timestamp when the binding-strength guard keeps a
|
|
89
|
+
* stronger `verifiedVia` (see {@link applyVerifiedChannelGatewayWrites}).
|
|
90
|
+
*
|
|
85
91
|
* Returns true if a gateway row was updated or inserted; false if the only
|
|
86
92
|
* matching row is blocked/revoked (so nothing was written).
|
|
87
93
|
*/
|
|
@@ -93,6 +99,7 @@ function writeVerifiedGatewayChannel(params: {
|
|
|
93
99
|
externalChatId: string;
|
|
94
100
|
verifiedVia: string;
|
|
95
101
|
now: number;
|
|
102
|
+
verifiedAt?: number;
|
|
96
103
|
allowRevokedReactivation?: boolean;
|
|
97
104
|
}): boolean {
|
|
98
105
|
const {
|
|
@@ -103,6 +110,7 @@ function writeVerifiedGatewayChannel(params: {
|
|
|
103
110
|
externalChatId,
|
|
104
111
|
verifiedVia,
|
|
105
112
|
now,
|
|
113
|
+
verifiedAt,
|
|
106
114
|
allowRevokedReactivation,
|
|
107
115
|
} = params;
|
|
108
116
|
const gwDb = getGatewayDb();
|
|
@@ -111,7 +119,7 @@ function writeVerifiedGatewayChannel(params: {
|
|
|
111
119
|
policy: "allow",
|
|
112
120
|
address,
|
|
113
121
|
externalChatId,
|
|
114
|
-
verifiedAt: now,
|
|
122
|
+
verifiedAt: verifiedAt ?? now,
|
|
115
123
|
verifiedVia,
|
|
116
124
|
revokedReason: null,
|
|
117
125
|
blockedReason: null,
|
|
@@ -523,8 +531,11 @@ export function applyVerifiedChannelGatewayWrites(params: {
|
|
|
523
531
|
// The gateway is the source of truth: a blocked/revoked gateway row rejects
|
|
524
532
|
// the verification, gating BOTH the existing-channel update and the
|
|
525
533
|
// new-insert path so no active mirror is created for a blocked actor. A
|
|
526
|
-
// missing gateway row is the legitimate happy path (legacy/unmirrored).
|
|
527
|
-
|
|
534
|
+
// missing gateway row is the legitimate happy path (legacy/unmirrored). The
|
|
535
|
+
// same authoritative read carries the existing binding provenance for the
|
|
536
|
+
// demotion guard below.
|
|
537
|
+
const gwRow = getGatewayChannelByKey(sourceChannel, address);
|
|
538
|
+
const gwStatus = gwRow?.status ?? null;
|
|
528
539
|
if (
|
|
529
540
|
gwStatus === "blocked" ||
|
|
530
541
|
(gwStatus === "revoked" && !allowRevokedReactivation)
|
|
@@ -536,6 +547,33 @@ export function applyVerifiedChannelGatewayWrites(params: {
|
|
|
536
547
|
return { verified: false };
|
|
537
548
|
}
|
|
538
549
|
|
|
550
|
+
// Binding-strength guard (LUM-2505): a lower-strength source must never
|
|
551
|
+
// demote the recorded provenance of an already-active binding. When the
|
|
552
|
+
// incoming write would weaken the ladder, preserve the stronger existing
|
|
553
|
+
// `verified_via` / `verified_at`; the write still refreshes identity/ACL
|
|
554
|
+
// fields, it just cannot lower the proof. Only active bindings are protected
|
|
555
|
+
// — reactivation from revoked is invite-gated (a proven, top-tier
|
|
556
|
+
// `verified_via`) and keeps the incoming provenance.
|
|
557
|
+
let effectiveVerifiedVia = verifiedVia;
|
|
558
|
+
let effectiveVerifiedAt: number | undefined;
|
|
559
|
+
if (
|
|
560
|
+
gwRow &&
|
|
561
|
+
gwRow.status === "active" &&
|
|
562
|
+
isBindingDemotion(gwRow.verifiedVia, verifiedVia)
|
|
563
|
+
) {
|
|
564
|
+
effectiveVerifiedVia = gwRow.verifiedVia ?? verifiedVia;
|
|
565
|
+
effectiveVerifiedAt = gwRow.verifiedAt ?? undefined;
|
|
566
|
+
log.warn(
|
|
567
|
+
{
|
|
568
|
+
sourceChannel,
|
|
569
|
+
address,
|
|
570
|
+
existingVerifiedVia: gwRow.verifiedVia,
|
|
571
|
+
incomingVerifiedVia: verifiedVia,
|
|
572
|
+
},
|
|
573
|
+
"Refusing binding-strength demotion: preserving stronger existing verified_via",
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
|
|
539
577
|
if (existing) {
|
|
540
578
|
const row = existing;
|
|
541
579
|
|
|
@@ -575,8 +613,9 @@ export function applyVerifiedChannelGatewayWrites(params: {
|
|
|
575
613
|
type: sourceChannel,
|
|
576
614
|
address,
|
|
577
615
|
externalChatId,
|
|
578
|
-
verifiedVia,
|
|
616
|
+
verifiedVia: effectiveVerifiedVia,
|
|
579
617
|
now,
|
|
618
|
+
verifiedAt: effectiveVerifiedAt,
|
|
580
619
|
allowRevokedReactivation,
|
|
581
620
|
});
|
|
582
621
|
// The pre-check passed, so a write is expected. A false means a
|
|
@@ -651,8 +690,9 @@ export function applyVerifiedChannelGatewayWrites(params: {
|
|
|
651
690
|
type: sourceChannel,
|
|
652
691
|
address,
|
|
653
692
|
externalChatId,
|
|
654
|
-
verifiedVia,
|
|
693
|
+
verifiedVia: effectiveVerifiedVia,
|
|
655
694
|
now,
|
|
695
|
+
verifiedAt: effectiveVerifiedAt,
|
|
656
696
|
allowRevokedReactivation,
|
|
657
697
|
});
|
|
658
698
|
// A blocked/revoked gateway row appeared after the pre-check — reject
|