Stage 1 complete: shared packages with full test coverage

- packages/schema: 15 Vitest tests (6 valid + 6 invalid frontmatter + 3 round-trip)
- packages/sanitize: fail-closed remark plugin + 12 private fixtures + 6 clean fixtures, 20 tests
- packages/observability: Pino + correlation IDs + redaction; 5 tests with 100-log validation
- packages/linkedin-client: Posts API client + token store; 10 tests; AES-256-GCM substituted for libsodium crypto_secretbox (Bun ESM bug, see docs/deferred-gates.md D-001)

50/50 tests pass across 4 packages. All Stage 1 DoDs verified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Angelo B. J. Luidens
2026-04-26 12:50:03 -04:00
parent 1dc1a1a07a
commit e529651de1
34 changed files with 1227 additions and 30 deletions

View File

@@ -14,9 +14,7 @@
"test": "vitest run",
"lint": "echo 'lint pending'"
},
"dependencies": {
"libsodium-wrappers-sumo": "^0.7.15"
},
"dependencies": {},
"devDependencies": {
"vitest": "^2.1.0",
"msw": "^2.6.0",

View File

@@ -0,0 +1,135 @@
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import {
LINKEDIN_API_BASE,
LINKEDIN_API_VERSION,
LINKEDIN_RESTLI_VERSION,
LinkedInApiError,
LinkedInClient,
} from "./index";
const recordedRequests: { method: string; url: string; headers: Record<string, string>; body: unknown }[] = [];
const handlers = [
http.get(`${LINKEDIN_API_BASE}/me`, ({ request }) => {
recordedRequests.push({
method: request.method,
url: request.url,
headers: Object.fromEntries(request.headers),
body: null,
});
return HttpResponse.json({ sub: "person:123", email: "x@example.com" });
}),
http.post(`${LINKEDIN_API_BASE}/posts`, async ({ request }) => {
const body = await request.json();
recordedRequests.push({
method: request.method,
url: request.url,
headers: Object.fromEntries(request.headers),
body,
});
return new HttpResponse(null, {
status: 201,
headers: {
"x-restli-id": "urn:li:share:7000000000000000001",
},
});
}),
http.delete(`${LINKEDIN_API_BASE}/posts/:urn`, ({ request, params }) => {
recordedRequests.push({
method: request.method,
url: request.url,
headers: Object.fromEntries(request.headers),
body: { urn: params.urn },
});
return new HttpResponse(null, { status: 204 });
}),
http.get(`${LINKEDIN_API_BASE}/socialMetadata/:urn`, () =>
HttpResponse.json({
impressions: 100,
reactions: 12,
comments: 3,
shares: 1,
clicks: 7,
}),
),
http.post(`${LINKEDIN_API_BASE}/posts-error`, () =>
HttpResponse.json({ serviceErrorCode: 65600, message: "rate limit exceeded" }, { status: 429 }),
),
];
const server = setupServer(...handlers);
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => {
server.resetHandlers(...handlers);
recordedRequests.length = 0;
});
afterAll(() => server.close());
const newClient = (): LinkedInClient =>
new LinkedInClient({ getAccessToken: () => "test-access-token" });
describe("LinkedInClient — contract tests against msw fakes", () => {
it("attaches required headers on every request", async () => {
const c = newClient();
await c.whoami();
const req = recordedRequests[0]!;
expect(req.headers["authorization"]).toBe("Bearer test-access-token");
expect(req.headers["linkedin-version"]).toBe(LINKEDIN_API_VERSION);
expect(req.headers["x-restli-protocol-version"]).toBe(LINKEDIN_RESTLI_VERSION);
});
it("whoami() returns subject + email from /me", async () => {
const c = newClient();
const res = await c.whoami();
expect(res).toEqual({ sub: "person:123", email: "x@example.com" });
});
it("createPost() sends Posts API flat payload (not legacy ugcPosts)", async () => {
const c = newClient();
const res = await c.createPost({ authorUrn: "urn:li:person:123", text: "hello" });
expect(res.postUrn).toBe("urn:li:share:7000000000000000001");
expect(res.externalUrl).toContain("linkedin.com/feed/update/");
const body = recordedRequests[0]!.body as Record<string, unknown>;
expect(body.author).toBe("urn:li:person:123");
expect(body.commentary).toBe("hello");
expect(body.lifecycleState).toBe("PUBLISHED");
expect(body).not.toHaveProperty("specificContent");
});
it("createPost() throws LinkedInApiError when API returns 429", async () => {
server.use(
http.post(`${LINKEDIN_API_BASE}/posts`, () =>
HttpResponse.json(
{ serviceErrorCode: 65600, message: "rate limit exceeded" },
{ status: 429 },
),
),
);
const c = newClient();
let err: unknown;
try {
await c.createPost({ authorUrn: "urn:li:person:123", text: "boom" });
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(LinkedInApiError);
const apiErr = err as LinkedInApiError;
expect(apiErr.status).toBe(429);
expect(apiErr.serviceErrorCode).toBe(65600);
});
it("deletePost() URL-encodes the URN", async () => {
const c = newClient();
await c.deletePost("urn:li:share:7000");
expect(recordedRequests[0]!.url).toContain("/posts/urn%3Ali%3Ashare%3A7000");
});
it("getPostMetrics() returns normalized metrics", async () => {
const c = newClient();
const m = await c.getPostMetrics("urn:li:share:7000");
expect(m).toEqual({ impressions: 100, reactions: 12, comments: 3, shares: 1, clicks: 7 });
});
});

View File

@@ -0,0 +1,120 @@
import {
CreatePostInput,
CreatePostOutput,
LINKEDIN_API_BASE,
LINKEDIN_API_VERSION,
LINKEDIN_RESTLI_VERSION,
LinkedInApiError,
PostMetrics,
ProfileStats,
} from "./types";
export interface LinkedInClientOptions {
baseUrl?: string;
apiVersion?: string;
fetchImpl?: typeof fetch;
getAccessToken: () => Promise<string> | string;
}
export class LinkedInClient {
private readonly baseUrl: string;
private readonly apiVersion: string;
private readonly fetchImpl: typeof fetch;
private readonly getAccessToken: () => Promise<string> | string;
constructor(opts: LinkedInClientOptions) {
this.baseUrl = opts.baseUrl ?? LINKEDIN_API_BASE;
this.apiVersion = opts.apiVersion ?? LINKEDIN_API_VERSION;
this.fetchImpl = opts.fetchImpl ?? fetch;
this.getAccessToken = opts.getAccessToken;
}
private async headers(): Promise<Record<string, string>> {
const token = await this.getAccessToken();
return {
Authorization: `Bearer ${token}`,
"LinkedIn-Version": this.apiVersion,
"X-Restli-Protocol-Version": LINKEDIN_RESTLI_VERSION,
"Content-Type": "application/json",
};
}
private async request<T>(method: string, path: string, body?: unknown): Promise<{ data: T; headers: Headers }> {
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
method,
headers: await this.headers(),
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let serviceCode: number | null = null;
let message = res.statusText;
try {
const errBody = (await res.json()) as { serviceErrorCode?: number; message?: string };
serviceCode = errBody.serviceErrorCode ?? null;
if (errBody.message) message = errBody.message;
} catch {
// body not JSON; keep statusText
}
throw new LinkedInApiError(res.status, serviceCode, message);
}
const text = await res.text();
const data = (text ? JSON.parse(text) : {}) as T;
return { data, headers: res.headers };
}
async whoami(): Promise<{ sub: string; email: string | null }> {
const { data } = await this.request<{ sub: string; email?: string }>("GET", "/me");
return { sub: data.sub, email: data.email ?? null };
}
async createPost(input: CreatePostInput): Promise<CreatePostOutput> {
const payload = {
author: input.authorUrn,
commentary: input.text,
visibility: input.visibility ?? "PUBLIC",
distribution: {
feedDistribution: "MAIN_FEED",
targetEntities: [],
thirdPartyDistributionChannels: [],
},
lifecycleState: "PUBLISHED",
isReshareDisabledByAuthor: false,
};
const { headers } = await this.request<unknown>("POST", "/posts", payload);
const postUrn = headers.get("x-restli-id") ?? headers.get("x-linkedin-id");
if (!postUrn) {
throw new LinkedInApiError(500, null, "Posts API response missing post URN header");
}
const externalUrl = `https://www.linkedin.com/feed/update/${encodeURIComponent(postUrn)}/`;
return { postUrn, externalUrl };
}
async deletePost(postUrn: string): Promise<void> {
await this.request<unknown>("DELETE", `/posts/${encodeURIComponent(postUrn)}`);
}
async getPostMetrics(postUrn: string): Promise<PostMetrics> {
const { data } = await this.request<Partial<PostMetrics>>(
"GET",
`/socialMetadata/${encodeURIComponent(postUrn)}`,
);
return {
impressions: data.impressions ?? 0,
reactions: data.reactions ?? 0,
comments: data.comments ?? 0,
shares: data.shares ?? 0,
clicks: data.clicks ?? 0,
};
}
async getProfileStats(authorUrn: string): Promise<ProfileStats> {
const { data } = await this.request<Partial<ProfileStats>>(
"GET",
`/networkSizes/${encodeURIComponent(authorUrn)}?edgeType=CompanyFollowedByMember`,
);
return {
follower_count: data.follower_count ?? 0,
page_views_30d: data.page_views_30d ?? 0,
};
}
}

View File

@@ -1,6 +1,3 @@
export const LINKEDIN_CLIENT_READY = false;
// Posts API client + token store — implemented in Stage 1.4 + 3.*.
// Reference: https://learn.microsoft.com/en-us/linkedin/marketing/community-management/shares/posts-api
export const LINKEDIN_API_BASE = "https://api.linkedin.com/rest";
export const LINKEDIN_API_VERSION = "202404";
export const LINKEDIN_RESTLI_VERSION = "2.0.0";
export * from "./types";
export * from "./client";
export * from "./token-store";

View File

@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import { decryptToken, encryptToken, generateKey, keyFromBase64, keyToBase64 } from "./token-store";
describe("token-store — libsodium crypto_secretbox round-trip", () => {
it("encrypts and decrypts a token round-trip", async () => {
const key = await generateKey();
const plain = "AQXxxx-fake-access-token-zzz";
const ct = await encryptToken(plain, key);
expect(ct).not.toContain(plain);
const back = await decryptToken(ct, key);
expect(back).toBe(plain);
});
it("produces different ciphertexts for the same plaintext (random nonce)", async () => {
const key = await generateKey();
const plain = "same-plaintext";
const a = await encryptToken(plain, key);
const b = await encryptToken(plain, key);
expect(a).not.toBe(b);
});
it("fails to decrypt with wrong key", async () => {
const k1 = await generateKey();
const k2 = await generateKey();
const ct = await encryptToken("secret", k1);
let err: unknown;
try {
await decryptToken(ct, k2);
} catch (e) {
err = e;
}
expect(err).toBeDefined();
});
it("key serialization round-trip", async () => {
const key = await generateKey();
const b64 = await keyToBase64(key);
const back = await keyFromBase64(b64);
expect(back).toEqual(key);
});
});

View File

@@ -0,0 +1,38 @@
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
const ALGO = "aes-256-gcm";
const KEY_LEN = 32;
const NONCE_LEN = 12;
const TAG_LEN = 16;
export const generateKey = async (): Promise<Uint8Array> =>
new Uint8Array(randomBytes(KEY_LEN));
export const encryptToken = async (plaintext: string, key: Uint8Array): Promise<string> => {
if (key.length !== KEY_LEN) throw new Error(`Key must be ${KEY_LEN} bytes`);
const nonce = randomBytes(NONCE_LEN);
const cipher = createCipheriv(ALGO, key, nonce, { authTagLength: TAG_LEN });
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
const combined = Buffer.concat([nonce, tag, ct]);
return combined.toString("base64");
};
export const decryptToken = async (ciphertextB64: string, key: Uint8Array): Promise<string> => {
if (key.length !== KEY_LEN) throw new Error(`Key must be ${KEY_LEN} bytes`);
const combined = Buffer.from(ciphertextB64, "base64");
if (combined.length < NONCE_LEN + TAG_LEN) throw new Error("ciphertext too short");
const nonce = combined.subarray(0, NONCE_LEN);
const tag = combined.subarray(NONCE_LEN, NONCE_LEN + TAG_LEN);
const ct = combined.subarray(NONCE_LEN + TAG_LEN);
const decipher = createDecipheriv(ALGO, key, nonce, { authTagLength: TAG_LEN });
decipher.setAuthTag(tag);
const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
return pt.toString("utf8");
};
export const keyFromBase64 = async (b64: string): Promise<Uint8Array> =>
new Uint8Array(Buffer.from(b64, "base64"));
export const keyToBase64 = async (key: Uint8Array): Promise<string> =>
Buffer.from(key).toString("base64");

View File

@@ -0,0 +1,50 @@
export const LINKEDIN_API_BASE = "https://api.linkedin.com/rest";
export const LINKEDIN_API_VERSION = "202404";
export const LINKEDIN_RESTLI_VERSION = "2.0.0";
export type SubjectType = "person" | "organization";
export interface LinkedInToken {
subject_type: SubjectType;
subject_urn: string;
access_token: string;
refresh_token: string | null;
access_expires_at: Date;
refresh_expires_at: Date | null;
scopes: readonly string[];
}
export interface CreatePostInput {
authorUrn: string;
text: string;
visibility?: "PUBLIC" | "CONNECTIONS";
}
export interface CreatePostOutput {
postUrn: string;
externalUrl: string;
}
export interface PostMetrics {
impressions: number;
reactions: number;
comments: number;
shares: number;
clicks: number;
}
export interface ProfileStats {
follower_count: number;
page_views_30d: number;
}
export class LinkedInApiError extends Error {
constructor(
public readonly status: number,
public readonly serviceErrorCode: number | null,
message: string,
) {
super(message);
this.name = "LinkedInApiError";
}
}