Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/MEMORY.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
# Memory Log
Last Updated: 2026-02-25
Last Updated: 2026-02-27

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

## 2026-02-27
- 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`.
- Fix: updated URL parsing to normalize no-scheme LinkedIn URLs before route parsing, and added tests for no-scheme supported/unsupported paths.
- 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.

## 2026-02-25
- Correction: process guidance was fragmented across root markdown files and drifted over time.
- Fix: consolidated process rules into `AGENTS.md`, moved specification to `docs/SPEC.md`, and started managed freshness checks.
Expand Down
31 changes: 24 additions & 7 deletions src/lib/recipient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ import { parseLinkedInUrl } from "./url-parser.js";

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

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

function getRecipientCachePath(): string {
return process.env.LI_RECIPIENT_CACHE_PATH ?? path.join(os.tmpdir(), "li-recipient-cache.json");
}

function debugRecipient(message: string): void {
if (!DEBUG_RECIPIENT) {
return;
Expand All @@ -35,8 +37,9 @@ function debugRecipient(message: string): void {
type RecipientCache = Record<string, { urn: string; updatedAt: number }>;

function loadRecipientCache(): RecipientCache {
const cachePath = getRecipientCachePath();
try {
const raw = readFileSync(RECIPIENT_CACHE_PATH, "utf8");
const raw = readFileSync(cachePath, "utf8");
const parsed = JSON.parse(raw) as RecipientCache;
if (!parsed || typeof parsed !== "object") {
return {};
Expand All @@ -48,21 +51,35 @@ function loadRecipientCache(): RecipientCache {
}

function saveRecipientCache(cache: RecipientCache): void {
const cachePath = getRecipientCachePath();
try {
mkdirSync(path.dirname(RECIPIENT_CACHE_PATH), { recursive: true });
writeFileSync(RECIPIENT_CACHE_PATH, JSON.stringify(cache), "utf8");
mkdirSync(path.dirname(cachePath), { recursive: true });
writeFileSync(cachePath, JSON.stringify(cache), "utf8");
} catch {
// Best-effort cache; ignore failures.
}
}

function isCacheableRecipientUrn(urn: string): boolean {
if (!urn.startsWith("urn:li:")) {
return false;
}
if (urn.startsWith("urn:li:member:")) {
return /^urn:li:member:\d+$/.test(urn);
}
if (urn.startsWith("urn:li:fsd_profile:")) {
return /^urn:li:fsd_profile:ACo[A-Za-z0-9_-]+$/.test(urn);
}
return false;
}

function warnIfRecipientChanged(key: string, currentUrn: string): void {
if (!key || !currentUrn) {
if (!key || !currentUrn || !isCacheableRecipientUrn(currentUrn)) {
return;
}
const cache = loadRecipientCache();
const previous = cache[key]?.urn ?? "";
if (previous && previous !== currentUrn) {
if (isCacheableRecipientUrn(previous) && previous !== currentUrn) {
process.stderr.write(
`[li][recipient] warning=profile_urn_changed key=${key} prev=${previous} next=${currentUrn}\n`,
);
Expand Down
11 changes: 10 additions & 1 deletion src/lib/url-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export function parseLinkedInUrl(input: string): ParsedLinkedInUrl | null {
return parseUrl(trimmed);
}

// Handle LinkedIn URLs pasted without a scheme (e.g. linkedin.com/in/user)
if (isLikelyLinkedInUrlWithoutScheme(trimmed)) {
return parseUrl(`https://${trimmed}`);
}

// Treat as plain username (for profile lookups)
return {
type: "profile",
Expand Down Expand Up @@ -104,7 +109,7 @@ function parseUrl(urlString: string): ParsedLinkedInUrl | null {

// Validate it's a LinkedIn domain
const hostname = url.hostname.toLowerCase();
if (!hostname.endsWith("linkedin.com") && hostname !== "linkedin.com") {
if (hostname !== "linkedin.com" && !hostname.endsWith(".linkedin.com")) {
return null;
}

Expand Down Expand Up @@ -146,6 +151,10 @@ function parseUrl(urlString: string): ParsedLinkedInUrl | null {
return null;
}

function isLikelyLinkedInUrlWithoutScheme(input: string): boolean {
return /^(?:[a-z0-9-]+\.)*linkedin\.com(?:\/|$)/i.test(input);
}

/**
* Extract the ID portion from a LinkedIn URN.
*
Expand Down
112 changes: 112 additions & 0 deletions tests/unit/recipient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
* - Profile URNs (e.g., "urn:li:fsd_profile:ACoAABcd1234")
*/

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

beforeEach(() => {
previousProfileViewEnv = process.env.LI_ENABLE_PROFILEVIEW;
previousRecipientCachePathEnv = process.env.LI_RECIPIENT_CACHE_PATH;
process.env.LI_ENABLE_PROFILEVIEW = "0";
recipientCachePath = path.join(
os.tmpdir(),
`li-recipient-test-cache-${process.pid}-${Date.now()}.json`,
);
process.env.LI_RECIPIENT_CACHE_PATH = recipientCachePath;
rmSync(recipientCachePath, { force: true });
mockClient = {
request: vi.fn(),
requestAbsolute: vi.fn(),
Expand All @@ -53,6 +65,12 @@ describe("recipient", () => {
} else {
process.env.LI_ENABLE_PROFILEVIEW = previousProfileViewEnv;
}
if (previousRecipientCachePathEnv === undefined) {
delete process.env.LI_RECIPIENT_CACHE_PATH;
} else {
process.env.LI_RECIPIENT_CACHE_PATH = previousRecipientCachePathEnv;
}
rmSync(recipientCachePath, { force: true });
});

describe("resolveRecipient", () => {
Expand Down Expand Up @@ -338,6 +356,30 @@ describe("recipient", () => {
});
});

it("resolves a profile URL without scheme", async () => {
mockClient.request.mockResolvedValueOnce({
json: () =>
Promise.resolve({
elements: [
{
entityUrn: "urn:li:fsd_profile:ACoAABcd1234",
publicIdentifier: "peggyrayzis",
},
],
}),
});

const result = await resolveRecipient(
mockClient as unknown as LinkedInClient,
"linkedin.com/in/peggyrayzis",
);

expect(result).toEqual({
username: "peggyrayzis",
urn: "urn:li:fsd_profile:ACoAABcd1234",
});
});

it("resolves a profile URL with trailing slash", async () => {
mockClient.request.mockResolvedValueOnce({
json: () =>
Expand Down Expand Up @@ -503,6 +545,76 @@ describe("recipient", () => {
});
});

describe("recipient cache warnings", () => {
it("skips warning when cached previous URN is a synthetic placeholder", async () => {
writeFileSync(
recipientCachePath,
JSON.stringify({
peggyrayzis: {
urn: "urn:li:fsd_profile:ABC123",
updatedAt: Date.now(),
},
}),
"utf8",
);
mockClient.request.mockResolvedValueOnce({
json: () =>
Promise.resolve({
elements: [
{
entityUrn: "urn:li:fsd_profile:ACoAABcd1234",
publicIdentifier: "peggyrayzis",
},
],
}),
});
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);

await resolveRecipient(mockClient as unknown as LinkedInClient, "peggyrayzis");

expect(
stderrSpy.mock.calls.some((call) =>
String(call[0]).includes("warning=profile_urn_changed"),
),
).toBe(false);
stderrSpy.mockRestore();
});

it("emits warning when cached previous URN is valid and changes", async () => {
writeFileSync(
recipientCachePath,
JSON.stringify({
peggyrayzis: {
urn: "urn:li:fsd_profile:ACoAABcd1111",
updatedAt: Date.now(),
},
}),
"utf8",
);
mockClient.request.mockResolvedValueOnce({
json: () =>
Promise.resolve({
elements: [
{
entityUrn: "urn:li:fsd_profile:ACoAABcd2222",
publicIdentifier: "peggyrayzis",
},
],
}),
});
const stderrSpy = vi.spyOn(process.stderr, "write").mockReturnValue(true);

await resolveRecipient(mockClient as unknown as LinkedInClient, "peggyrayzis");

expect(
stderrSpy.mock.calls.some((call) =>
String(call[0]).includes("warning=profile_urn_changed"),
),
).toBe(true);
stderrSpy.mockRestore();
});
});

describe("edge cases", () => {
it("handles response without publicIdentifier", async () => {
mockClient.request.mockResolvedValueOnce({
Expand Down
20 changes: 20 additions & 0 deletions tests/unit/url-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ describe("url-parser", () => {
});
});

it("handles linkedin.com profile URLs without scheme", () => {
const result = parseLinkedInUrl("linkedin.com/in/peggyrayzis");

expect(result).toEqual({
type: "profile",
identifier: "peggyrayzis",
});
});

it("handles mobile linkedin URLs (m.linkedin.com)", () => {
const result = parseLinkedInUrl("https://m.linkedin.com/in/peggyrayzis");

Expand Down Expand Up @@ -337,12 +346,23 @@ describe("url-parser", () => {
expect(result).toBeNull();
});

it("returns null for domains that only suffix-match linkedin.com", () => {
expect(parseLinkedInUrl("https://notlinkedin.com/in/peggyrayzis")).toBeNull();
expect(parseLinkedInUrl("https://evil-linkedin.com/in/peggyrayzis")).toBeNull();
});

it("returns null for LinkedIn URL with unsupported path", () => {
const result = parseLinkedInUrl("https://www.linkedin.com/learning");

expect(result).toBeNull();
});

it("returns null for no-scheme LinkedIn URL with unsupported path", () => {
const result = parseLinkedInUrl("linkedin.com/learning");

expect(result).toBeNull();
});

it("returns null for malformed URN", () => {
const result = parseLinkedInUrl("urn:li:");

Expand Down
7 changes: 6 additions & 1 deletion vitest.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import os from 'node:os'
import path from 'node:path'
import { defineConfig } from 'vitest/config'

export default defineConfig({
test: {
globals: true,
environment: 'node',
env: { NO_COLOR: '1' },
env: {
NO_COLOR: '1',
LI_RECIPIENT_CACHE_PATH: path.join(os.tmpdir(), `li-recipient-cache-vitest-${process.pid}.json`),
},
include: ['tests/**/*.test.ts'],
coverage: {
provider: 'v8',
Expand Down