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:
@@ -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",
|
||||
|
||||
135
packages/linkedin-client/src/client.test.ts
Normal file
135
packages/linkedin-client/src/client.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
120
packages/linkedin-client/src/client.ts
Normal file
120
packages/linkedin-client/src/client.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
41
packages/linkedin-client/src/token-store.test.ts
Normal file
41
packages/linkedin-client/src/token-store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
38
packages/linkedin-client/src/token-store.ts
Normal file
38
packages/linkedin-client/src/token-store.ts
Normal 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");
|
||||
50
packages/linkedin-client/src/types.ts
Normal file
50
packages/linkedin-client/src/types.ts
Normal 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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user