diff --git a/docs/MEMORY.md b/docs/MEMORY.md index c37991c..8e0184c 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -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/`) 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. diff --git a/src/lib/recipient.ts b/src/lib/recipient.ts index 3344982..45d6b7b 100644 --- a/src/lib/recipient.ts +++ b/src/lib/recipient.ts @@ -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; @@ -35,8 +37,9 @@ function debugRecipient(message: string): void { type RecipientCache = Record; 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 {}; @@ -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`, ); diff --git a/src/lib/url-parser.ts b/src/lib/url-parser.ts index 678bb7d..7d32415 100644 --- a/src/lib/url-parser.ts +++ b/src/lib/url-parser.ts @@ -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", @@ -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; } @@ -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. * diff --git a/tests/unit/recipient.test.ts b/tests/unit/recipient.test.ts index c7d7c5b..1ee959a 100644 --- a/tests/unit/recipient.test.ts +++ b/tests/unit/recipient.test.ts @@ -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"; @@ -28,10 +31,19 @@ describe("recipient", () => { getCredentials: ReturnType; }; 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(), @@ -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", () => { @@ -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: () => @@ -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({ diff --git a/tests/unit/url-parser.test.ts b/tests/unit/url-parser.test.ts index ec309c7..5edf56f 100644 --- a/tests/unit/url-parser.test.ts +++ b/tests/unit/url-parser.test.ts @@ -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"); @@ -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:"); diff --git a/vitest.config.ts b/vitest.config.ts index 08c7b45..d392169 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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',