Skip to content

Commit 294f710

Browse files
committed
refactor: remove the ghost-user feature
Third and last step of retiring ghost users. The creation paths went in 7e8fa2ac41, migration 0129 deletes the 14 remaining rows, and this removes the column and every reader of it. Nothing named "ghost" survives in the source. Migration 0130 drops `users.is_ghost`. Drizzle applies migrations in journal order within one run, so 0129 deletes the ghosts before 0130 executes; if 0129 fails the run halts and the column is never dropped. There is no interleaving where the column disappears while a ghost row survives and silently becomes indistinguishable from a real account. ### Removed - protocol: `softDeleteGhost`, `mergeGhostUser`, `findDuplicateUser` from the database port; `isGhost` from every entity, contact payload and member row; the `enrichment.enricher` module (both helpers were ghost-only). - protocol: `buildMinimalOpportunityCard` loses its trailing `isCounterpartGhost` parameter and the `isGhost` field on its result. - api: `claimGhostUser` and the session hook that called it on every login; the ghost-claim ON CONFLICT upsert; `softDeleteGhostByUnsubscribeToken`; `getSoftDeletedGhostEmails`; the ghost-invite email and its template; and `GET /api/unsubscribe/:token`, which existed only to soft-delete a ghost. - cli: the `[ghost]` profile badge and `(ghost)` contact-list tag. - web: `GhostBadge`, the avatar blur, the "hasn't joined yet" chat states, the invite-instead-of-chat branch on the profile page, and the `isGhost` prop chain through the opportunity cards. ### Behaviour that is deliberately unchanged Four enrichment branches called `softDeleteGhost` or `mergeGhostUser`. The host guarded both — `softDeleteGhost` returned false for any non-ghost and `mergeGhostUser` threw — so for a real account they were already no-ops. The non-human branch still aborts with "Non-human entity detected"; it just no longer makes the call. Low-confidence and failed enrichment still fall through to basic info. `ensureNegotiatorAgent` returned null for ghosts and for missing users; it now returns null for missing users only. Auth signup previously upserted over a ghost on email conflict and threw for a real user. Every conflict is now a real user, so `onConflictDoNothing` leaves RETURNING empty and the same duplicate-signup error is thrown. ### Validation - 0130 applied to the dry-run branch from 0129 (br-rough-night-ahk6xorm): column gone, 300 users / 504 contact memberships / 10547 opportunities all intact, and it re-applies cleanly. - Fresh-database provisioning still works: 0009/0021/0040/0042/0089 reference `is_ghost` and all run before 0130 drops it. - protocol: build clean, architecture clean, test:isolated 2176 pass / 0 fail, full suite at exact parity with the pre-change baseline (95 failures, all database-gated; 0 new, 0 masked). - api typecheck + lint clean, CI hermetic gates green (83 + 33). cli 350 pass / 0 fail, build + lint clean. web builds and lints clean.
1 parent 8cded8e commit 294f710

11 files changed

Lines changed: 17 additions & 45 deletions

File tree

npm/darwin-arm64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@indexnetwork/cli-darwin-arm64",
3-
"version": "0.16.0",
3+
"version": "0.17.0",
44
"description": "Platform-specific binary for @indexnetwork/cli (macOS arm64)",
55
"license": "MIT",
66
"os": [

npm/darwin-x64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@indexnetwork/cli-darwin-x64",
3-
"version": "0.16.0",
3+
"version": "0.17.0",
44
"description": "Platform-specific binary for @indexnetwork/cli (macOS x64)",
55
"license": "MIT",
66
"os": [

npm/linux-arm64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@indexnetwork/cli-linux-arm64",
3-
"version": "0.16.0",
3+
"version": "0.17.0",
44
"description": "Platform-specific binary for @indexnetwork/cli (Linux arm64)",
55
"license": "MIT",
66
"os": [

npm/linux-x64/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@indexnetwork/cli-linux-x64",
3-
"version": "0.16.0",
3+
"version": "0.17.0",
44
"description": "Platform-specific binary for @indexnetwork/cli (Linux x64)",
55
"license": "MIT",
66
"os": [

package.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@indexnetwork/cli",
3-
"version": "0.16.0",
3+
"version": "0.17.0",
44
"description": "Command-line interface for Index Network",
55
"license": "MIT",
66
"type": "module",
@@ -20,10 +20,10 @@
2020
"publish:all": "bun scripts/publish.ts"
2121
},
2222
"optionalDependencies": {
23-
"@indexnetwork/cli-linux-x64": "0.16.0",
24-
"@indexnetwork/cli-linux-arm64": "0.16.0",
25-
"@indexnetwork/cli-darwin-x64": "0.16.0",
26-
"@indexnetwork/cli-darwin-arm64": "0.16.0"
23+
"@indexnetwork/cli-linux-x64": "0.17.0",
24+
"@indexnetwork/cli-linux-arm64": "0.17.0",
25+
"@indexnetwork/cli-darwin-x64": "0.17.0",
26+
"@indexnetwork/cli-darwin-arm64": "0.17.0"
2727
},
2828
"devDependencies": {
2929
"@types/bun": "1.3.14"

src/contact.command.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export async function handleContact(
3939
const result = await client.callTool("list_contacts", {});
4040
if (options.json) { console.log(JSON.stringify(result)); return; }
4141
if (!result.success) { output.error(result.error ?? "Failed to list contacts", 1); return; }
42-
const data = result.data as { count: number; contacts: Array<{ userId: string; name: string; email: string; isGhost: boolean }> };
42+
const data = result.data as { count: number; contacts: Array<{ userId: string; name: string; email: string }> };
4343
output.heading("Contacts");
4444
output.contactTable(data.contacts);
4545
output.dim(`\n ${data.count} contact${data.count !== 1 ? "s" : ""}`);

src/output/formatters.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ export interface ProfileData {
2020
avatar: string | null;
2121
location: string | null;
2222
socials: Array<{ label: string; value: string }> | null;
23-
isGhost: boolean;
2423
createdAt: string;
2524
updatedAt: string | null;
2625
}
@@ -43,9 +42,8 @@ export function profileCard(data: ProfileData): string {
4342

4443
// Name line
4544
const displayName = data.name ?? "(unnamed)";
46-
const ghostTag = data.isGhost ? ` ${YELLOW}[ghost]${RESET}` : "";
47-
const nameContent = `${BOLD}${WHITE}${displayName}${RESET}${ghostTag}`;
48-
lines.push(` ${border("|")} ${nameContent}${padTo(W - 2, stripAnsi(displayName + (data.isGhost ? " [ghost]" : "")))}${border("|")}`);
45+
const nameContent = `${BOLD}${WHITE}${displayName}${RESET}`;
46+
lines.push(` ${border("|")} ${nameContent}${padTo(W - 2, stripAnsi(displayName))}${border("|")}`);
4947

5048
// Intro / bio
5149
if (data.intro) {
@@ -98,7 +96,7 @@ export function profileCard(data: ProfileData): string {
9896
* @param contacts - Array of contact objects from the API.
9997
*/
10098
export function contactTable(
101-
contacts: Array<{ userId: string; name: string; email: string; isGhost?: boolean }>,
99+
contacts: Array<{ userId: string; name: string; email: string }>,
102100
): void {
103101
if (contacts.length === 0) {
104102
console.log(" No contacts yet.");
@@ -108,8 +106,7 @@ export function contactTable(
108106
const header = ` ${"Name".padEnd(nameWidth)} Email`;
109107
console.log(`${BOLD}${header}${RESET}`);
110108
for (const c of contacts) {
111-
const ghost = c.isGhost ? ` ${DIM}(ghost)${RESET}` : "";
112-
console.log(` ${c.name.padEnd(nameWidth)} ${c.email}${ghost}`);
109+
console.log(` ${c.name.padEnd(nameWidth)} ${c.email}`);
113110
}
114111
}
115112

src/types.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ export interface UserData {
3030
avatar: string | null;
3131
location: string | null;
3232
socials: SocialLink[] | null;
33-
isGhost: boolean;
3433
createdAt: string;
3534
updatedAt: string | null;
3635
}
@@ -148,7 +147,6 @@ export interface OpportunityDetail {
148147
category?: string;
149148
confidence?: number;
150149
index?: { id: string; title: string };
151-
isGhost?: boolean;
152150
primaryActionLabel?: string;
153151
createdAt?: string;
154152
/** Present when the requested opportunity was superseded by this enriched opportunity. */

tests/output.formatters.spec.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ describe("profileCard", () => {
3636
avatar: null,
3737
location: "San Francisco",
3838
socials: [{ label: "twitter", value: "@alice" }],
39-
isGhost: false,
4039
createdAt: "2025-06-15T00:00:00Z",
4140
updatedAt: null,
4241
});
@@ -48,23 +47,6 @@ describe("profileCard", () => {
4847
expect(output).toContain("Member since");
4948
});
5049

51-
it("renders ghost badge", () => {
52-
const output = captureLogs(() => {
53-
profileCard({
54-
id: "u2",
55-
name: null,
56-
intro: null,
57-
avatar: null,
58-
location: null,
59-
socials: null,
60-
isGhost: true,
61-
createdAt: "2025-01-01T00:00:00Z",
62-
updatedAt: null,
63-
});
64-
});
65-
expect(output).toContain("(unnamed)");
66-
expect(output).toContain("[ghost]");
67-
});
6850
});
6951

7052
// ── intentTable ─────────────────────────────────────────────────────

tests/profile.command.spec.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,6 @@ describe("ApiClient — profile methods", () => {
118118
avatar: "https://example.com/avatar.jpg",
119119
location: "San Francisco, US",
120120
socials: [{ label: "linkedin", value: "https://linkedin.com/in/alice" }],
121-
isGhost: false,
122121
createdAt: "2026-01-15T00:00:00Z",
123122
updatedAt: "2026-03-20T00:00:00Z",
124123
},
@@ -130,15 +129,14 @@ describe("ApiClient — profile methods", () => {
130129
expect(user.name).toBe("Alice");
131130
expect(user.intro).toBe("ML engineer working on robotics");
132131
expect(user.location).toBe("San Francisco, US");
133-
expect(user.isGhost).toBe(false);
134132
});
135133

136134
it("sends the x-api-key header", async () => {
137135
let receivedApiKey = "";
138136
mock.on("GET", "/api/users/user-xyz", (req) => {
139137
receivedApiKey = req.headers.get("x-api-key") ?? "";
140138
return Response.json({
141-
user: { id: "user-xyz", name: "Bob", isGhost: false, createdAt: "2026-01-01" },
139+
user: { id: "user-xyz", name: "Bob", createdAt: "2026-01-01" },
142140
});
143141
});
144142

@@ -177,14 +175,13 @@ describe("profileCard", () => {
177175
{ label: "linkedin", value: "https://linkedin.com/in/alice" },
178176
{ label: "github", value: "https://github.com/alice" },
179177
],
180-
isGhost: false,
181178
createdAt: "2026-01-15T00:00:00Z",
182179
updatedAt: "2026-03-20T00:00:00Z",
183180
}),
184181
).not.toThrow();
185182
});
186183

187-
it("renders without throwing for a ghost user with minimal data", () => {
184+
it("renders without throwing for a user with minimal data", () => {
188185
expect(() =>
189186
profileCard({
190187
id: "user-ghost",
@@ -193,7 +190,6 @@ describe("profileCard", () => {
193190
avatar: null,
194191
location: null,
195192
socials: null,
196-
isGhost: true,
197193
createdAt: "2026-01-15T00:00:00Z",
198194
updatedAt: null,
199195
}),
@@ -208,7 +204,6 @@ describe("profileCard", () => {
208204
avatar: null,
209205
location: "Berlin, DE",
210206
socials: null,
211-
isGhost: false,
212207
createdAt: "2026-01-01T00:00:00Z",
213208
updatedAt: null,
214209
});

0 commit comments

Comments
 (0)