Skip to content

Commit 5bb0e32

Browse files
authored
fix(profile): resolve no-scheme URLs and ignore placeholder URN drift (#21)
1 parent b4ab4c3 commit 5bb0e32

File tree

6 files changed

+178
-10
lines changed

6 files changed

+178
-10
lines changed

docs/MEMORY.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
# Memory Log
2-
Last Updated: 2026-02-25
2+
Last Updated: 2026-02-27
33

44
Append-only log. Add entries; do not rewrite historical entries except typo fixes.
55

6+
## 2026-02-27
7+
- Correction: `profile` treated no-scheme LinkedIn URLs (for example `linkedin.com/in/<handle>`) as plain usernames, so recipient resolution requested invalid member identities and returned `Invalid request`.
8+
- Fix: updated URL parsing to normalize no-scheme LinkedIn URLs before route parsing, and added tests for no-scheme supported/unsupported paths.
9+
- Guardrail: recipient cache warnings now ignore synthetic/non-canonical URNs (for example fixture-style placeholders) and tests use isolated cache paths to prevent cross-run cache contamination.
10+
611
## 2026-02-25
712
- Correction: process guidance was fragmented across root markdown files and drifted over time.
813
- Fix: consolidated process rules into `AGENTS.md`, moved specification to `docs/SPEC.md`, and started managed freshness checks.

src/lib/recipient.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ import { parseLinkedInUrl } from "./url-parser.js";
1818

1919
const DEBUG_RECIPIENT =
2020
process.env.LI_DEBUG_RECIPIENT === "1" || process.env.LI_DEBUG_RECIPIENT === "true";
21-
const RECIPIENT_CACHE_PATH =
22-
process.env.LI_RECIPIENT_CACHE_PATH ?? path.join(os.tmpdir(), "li-recipient-cache.json");
2321

2422
function isProfileViewEnabled(): boolean {
2523
return process.env.LI_ENABLE_PROFILEVIEW === "1" || process.env.LI_ENABLE_PROFILEVIEW === "true";
2624
}
2725

26+
function getRecipientCachePath(): string {
27+
return process.env.LI_RECIPIENT_CACHE_PATH ?? path.join(os.tmpdir(), "li-recipient-cache.json");
28+
}
29+
2830
function debugRecipient(message: string): void {
2931
if (!DEBUG_RECIPIENT) {
3032
return;
@@ -35,8 +37,9 @@ function debugRecipient(message: string): void {
3537
type RecipientCache = Record<string, { urn: string; updatedAt: number }>;
3638

3739
function loadRecipientCache(): RecipientCache {
40+
const cachePath = getRecipientCachePath();
3841
try {
39-
const raw = readFileSync(RECIPIENT_CACHE_PATH, "utf8");
42+
const raw = readFileSync(cachePath, "utf8");
4043
const parsed = JSON.parse(raw) as RecipientCache;
4144
if (!parsed || typeof parsed !== "object") {
4245
return {};
@@ -48,21 +51,35 @@ function loadRecipientCache(): RecipientCache {
4851
}
4952

5053
function saveRecipientCache(cache: RecipientCache): void {
54+
const cachePath = getRecipientCachePath();
5155
try {
52-
mkdirSync(path.dirname(RECIPIENT_CACHE_PATH), { recursive: true });
53-
writeFileSync(RECIPIENT_CACHE_PATH, JSON.stringify(cache), "utf8");
56+
mkdirSync(path.dirname(cachePath), { recursive: true });
57+
writeFileSync(cachePath, JSON.stringify(cache), "utf8");
5458
} catch {
5559
// Best-effort cache; ignore failures.
5660
}
5761
}
5862

63+
function isCacheableRecipientUrn(urn: string): boolean {
64+
if (!urn.startsWith("urn:li:")) {
65+
return false;
66+
}
67+
if (urn.startsWith("urn:li:member:")) {
68+
return /^urn:li:member:\d+$/.test(urn);
69+
}
70+
if (urn.startsWith("urn:li:fsd_profile:")) {
71+
return /^urn:li:fsd_profile:ACo[A-Za-z0-9_-]+$/.test(urn);
72+
}
73+
return false;
74+
}
75+
5976
function warnIfRecipientChanged(key: string, currentUrn: string): void {
60-
if (!key || !currentUrn) {
77+
if (!key || !currentUrn || !isCacheableRecipientUrn(currentUrn)) {
6178
return;
6279
}
6380
const cache = loadRecipientCache();
6481
const previous = cache[key]?.urn ?? "";
65-
if (previous && previous !== currentUrn) {
82+
if (isCacheableRecipientUrn(previous) && previous !== currentUrn) {
6683
process.stderr.write(
6784
`[li][recipient] warning=profile_urn_changed key=${key} prev=${previous} next=${currentUrn}\n`,
6885
);

src/lib/url-parser.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,11 @@ export function parseLinkedInUrl(input: string): ParsedLinkedInUrl | null {
4747
return parseUrl(trimmed);
4848
}
4949

50+
// Handle LinkedIn URLs pasted without a scheme (e.g. linkedin.com/in/user)
51+
if (isLikelyLinkedInUrlWithoutScheme(trimmed)) {
52+
return parseUrl(`https://${trimmed}`);
53+
}
54+
5055
// Treat as plain username (for profile lookups)
5156
return {
5257
type: "profile",
@@ -104,7 +109,7 @@ function parseUrl(urlString: string): ParsedLinkedInUrl | null {
104109

105110
// Validate it's a LinkedIn domain
106111
const hostname = url.hostname.toLowerCase();
107-
if (!hostname.endsWith("linkedin.com") && hostname !== "linkedin.com") {
112+
if (hostname !== "linkedin.com" && !hostname.endsWith(".linkedin.com")) {
108113
return null;
109114
}
110115

@@ -146,6 +151,10 @@ function parseUrl(urlString: string): ParsedLinkedInUrl | null {
146151
return null;
147152
}
148153

154+
function isLikelyLinkedInUrlWithoutScheme(input: string): boolean {
155+
return /^(?:[a-z0-9-]+\.)*linkedin\.com(?:\/|$)/i.test(input);
156+
}
157+
149158
/**
150159
* Extract the ID portion from a LinkedIn URN.
151160
*

tests/unit/recipient.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
* - Profile URNs (e.g., "urn:li:fsd_profile:ACoAABcd1234")
88
*/
99

10+
import { rmSync, writeFileSync } from "node:fs";
11+
import os from "node:os";
12+
import path from "node:path";
1013
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
1114
import { LinkedInClient } from "../../src/lib/client.js";
1215
import { resolveRecipient } from "../../src/lib/recipient.js";
@@ -28,10 +31,19 @@ describe("recipient", () => {
2831
getCredentials: ReturnType<typeof vi.fn>;
2932
};
3033
let previousProfileViewEnv: string | undefined;
34+
let previousRecipientCachePathEnv: string | undefined;
35+
let recipientCachePath: string;
3136

3237
beforeEach(() => {
3338
previousProfileViewEnv = process.env.LI_ENABLE_PROFILEVIEW;
39+
previousRecipientCachePathEnv = process.env.LI_RECIPIENT_CACHE_PATH;
3440
process.env.LI_ENABLE_PROFILEVIEW = "0";
41+
recipientCachePath = path.join(
42+
os.tmpdir(),
43+
`li-recipient-test-cache-${process.pid}-${Date.now()}.json`,
44+
);
45+
process.env.LI_RECIPIENT_CACHE_PATH = recipientCachePath;
46+
rmSync(recipientCachePath, { force: true });
3547
mockClient = {
3648
request: vi.fn(),
3749
requestAbsolute: vi.fn(),
@@ -53,6 +65,12 @@ describe("recipient", () => {
5365
} else {
5466
process.env.LI_ENABLE_PROFILEVIEW = previousProfileViewEnv;
5567
}
68+
if (previousRecipientCachePathEnv === undefined) {
69+
delete process.env.LI_RECIPIENT_CACHE_PATH;
70+
} else {
71+
process.env.LI_RECIPIENT_CACHE_PATH = previousRecipientCachePathEnv;
72+
}
73+
rmSync(recipientCachePath, { force: true });
5674
});
5775

5876
describe("resolveRecipient", () => {
@@ -338,6 +356,30 @@ describe("recipient", () => {
338356
});
339357
});
340358

359+
it("resolves a profile URL without scheme", async () => {
360+
mockClient.request.mockResolvedValueOnce({
361+
json: () =>
362+
Promise.resolve({
363+
elements: [
364+
{
365+
entityUrn: "urn:li:fsd_profile:ACoAABcd1234",
366+
publicIdentifier: "peggyrayzis",
367+
},
368+
],
369+
}),
370+
});
371+
372+
const result = await resolveRecipient(
373+
mockClient as unknown as LinkedInClient,
374+
"linkedin.com/in/peggyrayzis",
375+
);
376+
377+
expect(result).toEqual({
378+
username: "peggyrayzis",
379+
urn: "urn:li:fsd_profile:ACoAABcd1234",
380+
});
381+
});
382+
341383
it("resolves a profile URL with trailing slash", async () => {
342384
mockClient.request.mockResolvedValueOnce({
343385
json: () =>
@@ -503,6 +545,76 @@ describe("recipient", () => {
503545
});
504546
});
505547

548+
describe("recipient cache warnings", () => {
549+
it("skips warning when cached previous URN is a synthetic placeholder", async () => {
550+
writeFileSync(
551+
recipientCachePath,
552+
JSON.stringify({
553+
peggyrayzis: {
554+
urn: "urn:li:fsd_profile:ABC123",
555+
updatedAt: Date.now(),
556+
},
557+
}),
558+
"utf8",
559+
);
560+
mockClient.request.mockResolvedValueOnce({
561+
json: () =>
562+
Promise.resolve({
563+
elements: [
564+
{
565+
entityUrn: "urn:li:fsd_profile:ACoAABcd1234",
566+
publicIdentifier: "peggyrayzis",
567+
},
568+
],
569+
}),
570+
});
571+
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
572+
573+
await resolveRecipient(mockClient as unknown as LinkedInClient, "peggyrayzis");
574+
575+
expect(
576+
stderrSpy.mock.calls.some((call) =>
577+
String(call[0]).includes("warning=profile_urn_changed"),
578+
),
579+
).toBe(false);
580+
stderrSpy.mockRestore();
581+
});
582+
583+
it("emits warning when cached previous URN is valid and changes", async () => {
584+
writeFileSync(
585+
recipientCachePath,
586+
JSON.stringify({
587+
peggyrayzis: {
588+
urn: "urn:li:fsd_profile:ACoAABcd1111",
589+
updatedAt: Date.now(),
590+
},
591+
}),
592+
"utf8",
593+
);
594+
mockClient.request.mockResolvedValueOnce({
595+
json: () =>
596+
Promise.resolve({
597+
elements: [
598+
{
599+
entityUrn: "urn:li:fsd_profile:ACoAABcd2222",
600+
publicIdentifier: "peggyrayzis",
601+
},
602+
],
603+
}),
604+
});
605+
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);
606+
607+
await resolveRecipient(mockClient as unknown as LinkedInClient, "peggyrayzis");
608+
609+
expect(
610+
stderrSpy.mock.calls.some((call) =>
611+
String(call[0]).includes("warning=profile_urn_changed"),
612+
),
613+
).toBe(true);
614+
stderrSpy.mockRestore();
615+
});
616+
});
617+
506618
describe("edge cases", () => {
507619
it("handles response without publicIdentifier", async () => {
508620
mockClient.request.mockResolvedValueOnce({

tests/unit/url-parser.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ describe("url-parser", () => {
5555
});
5656
});
5757

58+
it("handles linkedin.com profile URLs without scheme", () => {
59+
const result = parseLinkedInUrl("linkedin.com/in/peggyrayzis");
60+
61+
expect(result).toEqual({
62+
type: "profile",
63+
identifier: "peggyrayzis",
64+
});
65+
});
66+
5867
it("handles mobile linkedin URLs (m.linkedin.com)", () => {
5968
const result = parseLinkedInUrl("https://m.linkedin.com/in/peggyrayzis");
6069

@@ -337,12 +346,23 @@ describe("url-parser", () => {
337346
expect(result).toBeNull();
338347
});
339348

349+
it("returns null for domains that only suffix-match linkedin.com", () => {
350+
expect(parseLinkedInUrl("https://notlinkedin.com/in/peggyrayzis")).toBeNull();
351+
expect(parseLinkedInUrl("https://evil-linkedin.com/in/peggyrayzis")).toBeNull();
352+
});
353+
340354
it("returns null for LinkedIn URL with unsupported path", () => {
341355
const result = parseLinkedInUrl("https://www.linkedin.com/learning");
342356

343357
expect(result).toBeNull();
344358
});
345359

360+
it("returns null for no-scheme LinkedIn URL with unsupported path", () => {
361+
const result = parseLinkedInUrl("linkedin.com/learning");
362+
363+
expect(result).toBeNull();
364+
});
365+
346366
it("returns null for malformed URN", () => {
347367
const result = parseLinkedInUrl("urn:li:");
348368

vitest.config.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import os from 'node:os'
2+
import path from 'node:path'
13
import { defineConfig } from 'vitest/config'
24

35
export default defineConfig({
46
test: {
57
globals: true,
68
environment: 'node',
7-
env: { NO_COLOR: '1' },
9+
env: {
10+
NO_COLOR: '1',
11+
LI_RECIPIENT_CACHE_PATH: path.join(os.tmpdir(), `li-recipient-cache-vitest-${process.pid}.json`),
12+
},
813
include: ['tests/**/*.test.ts'],
914
coverage: {
1015
provider: 'v8',

0 commit comments

Comments
 (0)