Skip to content

Commit eade670

Browse files
fix linking
1 parent ada7567 commit eade670

4 files changed

Lines changed: 65 additions & 5 deletions

File tree

src/app/api/auth/link/accept/route.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,31 @@ export async function POST(req: NextRequest) {
3838
}
3939

4040
const code = parsed.data.code;
41+
42+
const pending = (await db.execute(sql`
43+
SELECT source_local_id AS "sourceLocalId"
44+
FROM link_tokens
45+
WHERE code = ${code} AND expires_at > now()
46+
LIMIT 1;
47+
`)) as unknown as { rows: { sourceLocalId: string }[] };
48+
49+
const pendingRow = pending.rows?.[0];
50+
if (!pendingRow) {
51+
logApiReject("auth/link/accept", "invalid_code", { codeLen: code.length });
52+
return NextResponse.json({ error: "invalid_code" }, { status: 400 });
53+
}
54+
55+
const sourceExists = (await db.execute(sql`
56+
SELECT 1 FROM nodes WHERE local_id = ${pendingRow.sourceLocalId} LIMIT 1;
57+
`)) as unknown as { rows: unknown[] };
58+
if (!sourceExists.rows?.[0]) {
59+
logApiReject("auth/link/accept", "source_missing", {
60+
sourcePresent: false,
61+
targetPresent: true,
62+
});
63+
return NextResponse.json({ error: "source_missing" }, { status: 400 });
64+
}
65+
4166
const consumed = (await db.execute(sql`
4267
DELETE FROM link_tokens
4368
WHERE code = ${code} AND expires_at > now()

src/app/api/auth/link/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2+
import { sql } from "drizzle-orm";
23
import { db } from "@/db";
34
import { linkTokens } from "@/db/schema";
45
import { newLinkCode } from "@/lib/codes";
@@ -24,6 +25,14 @@ export async function POST(req: NextRequest) {
2425
return NextResponse.json({ error: "rate_limited" }, { status: 429 });
2526
}
2627

28+
const registered = (await db.execute(sql`
29+
SELECT 1 FROM nodes WHERE local_id = ${localId} LIMIT 1;
30+
`)) as unknown as { rows: unknown[] };
31+
if (!registered.rows?.[0]) {
32+
logApiReject("auth/link", "not_registered", { hasCookie: true });
33+
return NextResponse.json({ error: "not_registered" }, { status: 403 });
34+
}
35+
2736
const expiresAt = new Date(Date.now() + LINK_TTL_MS);
2837
let code = newLinkCode();
2938

src/app/api/node/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { nodeSignals } from "@/db/schema";
55
import {
66
createNode,
77
resolveNode,
8+
relinkNodeLocalId,
89
nodeByCode,
910
metricsForNode,
1011
bumpAncestors,
@@ -79,6 +80,9 @@ export async function POST(req: NextRequest) {
7980
// ── Existing identity? localId → fingerprint fallback. No new node, no re-parent. ──
8081
const existing = await resolveNode(localId, fpHash);
8182
if (existing) {
83+
if (existing.localId !== localId) {
84+
await relinkNodeLocalId(existing.id, localId);
85+
}
8286
const metrics = await metricsForNode(existing.id);
8387
const res = NextResponse.json({
8488
code: existing.code,
@@ -124,6 +128,9 @@ export async function POST(req: NextRequest) {
124128
// unique(local_id) race → treat as existing
125129
const retry = await resolveNode(localId, fpHash);
126130
if (retry) {
131+
if (retry.localId !== localId) {
132+
await relinkNodeLocalId(retry.id, localId);
133+
}
127134
const metrics = await metricsForNode(retry.id);
128135
const res = NextResponse.json({ code: retry.code, isNew: false, metrics });
129136
setCookie(res, localId);

src/db/graph.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,27 +124,46 @@ export async function bumpAncestors(
124124
`);
125125
}
126126

127+
type ResolvedNode = { id: number; code: string; referrerId: number | null; localId: string };
128+
127129
/** Resolve an existing node by localId (primary) then fingerprint (fallback). */
128130
export async function resolveNode(
129131
localId: string,
130132
fingerprint: string | null,
131-
): Promise<{ id: number; code: string; referrerId: number | null } | null> {
133+
): Promise<ResolvedNode | null> {
132134
const byLocal = (await db.execute(sql`
133-
SELECT id, code, referrer_id AS "referrerId" FROM nodes WHERE local_id = ${localId} LIMIT 1;
134-
`)) as unknown as { rows: { id: number; code: string; referrerId: number | null }[] };
135+
SELECT id, code, referrer_id AS "referrerId", local_id AS "localId"
136+
FROM nodes WHERE local_id = ${localId} LIMIT 1;
137+
`)) as unknown as { rows: ResolvedNode[] };
135138
if (byLocal.rows?.[0]) return byLocal.rows[0];
136139

137140
if (fingerprint) {
138141
const byFp = (await db.execute(sql`
139-
SELECT id, code, referrer_id AS "referrerId" FROM nodes
142+
SELECT id, code, referrer_id AS "referrerId", local_id AS "localId"
143+
FROM nodes
140144
WHERE fingerprint = ${fingerprint} AND class = 'human'
141145
ORDER BY created_at ASC LIMIT 1;
142-
`)) as unknown as { rows: { id: number; code: string; referrerId: number | null }[] };
146+
`)) as unknown as { rows: ResolvedNode[] };
143147
if (byFp.rows?.[0]) return byFp.rows[0];
144148
}
145149
return null;
146150
}
147151

152+
/**
153+
* Move a node onto a freshly issued client localId (DESIGN §2 fingerprint re-link).
154+
* Cookie and DB must agree so auth routes can resolve the node by wh_lid alone.
155+
*/
156+
export async function relinkNodeLocalId(nodeId: number, newLocalId: string): Promise<boolean> {
157+
const updated = (await db.execute(sql`
158+
UPDATE nodes SET local_id = ${newLocalId}
159+
WHERE id = ${nodeId}
160+
AND local_id <> ${newLocalId}
161+
AND NOT EXISTS (SELECT 1 FROM nodes WHERE local_id = ${newLocalId})
162+
RETURNING id;
163+
`)) as unknown as { rows: { id: number }[] };
164+
return !!updated.rows?.[0];
165+
}
166+
148167
/** Look up a node id by its share code (the ?ref target). */
149168
export async function nodeByCode(
150169
code: string,

0 commit comments

Comments
 (0)