Stage 3 partial: LinkedIn MCP server (OAuth, 9 tools, kill-switch, refresh lock)

What ships (testable without live LinkedIn, 27 new tests):
- apps/mcp-linkedin/src/oauth.ts: auth URL builder + HMAC-signed state validation (CSRF + tamper + expiry)
- apps/mcp-linkedin/src/refresh-lock.ts: advisory-lock helper for token rotation (Plan TEA gap 3); concurrency test verifies 4 attempts → 1 succeeds + 3 denied
- apps/mcp-linkedin/src/kill-switch.ts: 30s-cached feature-flag query (Plan Objective 8 + TEA gap 10)
- apps/mcp-linkedin/src/tools.ts: 9 Zod tool schemas matching Plan §3.2 (whoami, auth_status, create_post, create_article, upload_media, create_post_with_media, delete_post, get_post_metrics, get_profile_stats)
- apps/mcp-linkedin/src/server.ts: validateToolCall + outletForAuthorUrn pure helpers

What defers to live-LinkedIn session (gate 0.9):
- 3.1 OAuth round-trip with real auth URL → callback → token row
- 3.4-3.7 Live throwaway test posts + delete-within-5min audit
- 3.9 Fail-safe halt with Telegram webhook
- 3.12 MCP stdio transport wired to @modelcontextprotocol/sdk

106/106 tests pass across all packages and apps.

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:57:00 -04:00
parent c73b7e4aad
commit 7aa3137a91
11 changed files with 677 additions and 28 deletions

View File

@@ -11,7 +11,6 @@
"lint": "echo 'lint pending'"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.0.3",
"@stargue/linkedin-client": "workspace:*",
"@stargue/schema": "workspace:*",
"@stargue/observability": "workspace:*",

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import { KillSwitch, KillSwitchError, type FeatureFlagStore } from "./kill-switch";
const makeStore = (state: Map<string, { enabled: boolean; reason: string | null }>): FeatureFlagStore => ({
async get(outlet) {
return state.get(outlet) ?? null;
},
});
describe("KillSwitch — Plan Objective 8 + TEA gap 10", () => {
it("defaults to enabled when outlet absent from store", async () => {
const ks = new KillSwitch(makeStore(new Map()));
await expect(ks.assertEnabled("linkedin.member")).resolves.toBeUndefined();
});
it("throws KillSwitchError when outlet disabled", async () => {
const state = new Map([["linkedin.member", { enabled: false, reason: "auth expired" }]]);
const ks = new KillSwitch(makeStore(state));
let err: unknown;
try {
await ks.assertEnabled("linkedin.member");
} catch (e) {
err = e;
}
expect(err).toBeInstanceOf(KillSwitchError);
expect((err as KillSwitchError).outlet).toBe("linkedin.member");
expect((err as KillSwitchError).reason).toBe("auth expired");
});
it("caches reads for cacheMs and re-fetches after expiry", async () => {
let calls = 0;
const dynStore: FeatureFlagStore = {
async get() {
calls++;
return { enabled: true, reason: null };
},
};
let now = 1_700_000_000_000;
const ks = new KillSwitch(dynStore, { cacheMs: 100, now: () => now });
await ks.read("linkedin.member");
await ks.read("linkedin.member");
expect(calls).toBe(1);
now += 200;
await ks.read("linkedin.member");
expect(calls).toBe(2);
});
it("reflects toggle within 30s window via invalidate()", async () => {
let state: { enabled: boolean; reason: string | null } = { enabled: true, reason: null };
const dynStore: FeatureFlagStore = { async get() { return state; } };
const ks = new KillSwitch(dynStore, { cacheMs: 30_000 });
await ks.assertEnabled("linkedin.member");
state = { enabled: false, reason: "ops toggle" };
ks.invalidate("linkedin.member");
await expect(ks.assertEnabled("linkedin.member")).rejects.toBeInstanceOf(KillSwitchError);
});
});

View File

@@ -0,0 +1,51 @@
/**
* Kill-switch (Plan Objective 8): per-outlet feature flag readable at runtime
* without redeploy. Backed by `outlet_feature_flags` table; cached for 30s.
*/
export interface FeatureFlagStore {
get(outlet: string): Promise<{ enabled: boolean; reason: string | null } | null>;
}
export interface KillSwitchOptions {
cacheMs?: number;
now?: () => number;
}
export class KillSwitch {
private readonly cache = new Map<string, { value: { enabled: boolean; reason: string | null }; expires: number }>();
private readonly cacheMs: number;
private readonly now: () => number;
constructor(private readonly store: FeatureFlagStore, opts: KillSwitchOptions = {}) {
this.cacheMs = opts.cacheMs ?? 30_000;
this.now = opts.now ?? Date.now;
}
async assertEnabled(outlet: string): Promise<void> {
const flag = await this.read(outlet);
if (!flag.enabled) {
throw new KillSwitchError(outlet, flag.reason ?? "no reason recorded");
}
}
async read(outlet: string): Promise<{ enabled: boolean; reason: string | null }> {
const cached = this.cache.get(outlet);
const now = this.now();
if (cached && cached.expires > now) return cached.value;
const fetched = (await this.store.get(outlet)) ?? { enabled: true, reason: null };
this.cache.set(outlet, { value: fetched, expires: now + this.cacheMs });
return fetched;
}
invalidate(outlet?: string): void {
if (outlet) this.cache.delete(outlet);
else this.cache.clear();
}
}
export class KillSwitchError extends Error {
constructor(public readonly outlet: string, public readonly reason: string) {
super(`Outlet ${outlet} is disabled: ${reason}`);
this.name = "KillSwitchError";
}
}

View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
buildAuthUrl,
buildRefreshForm,
buildTokenExchangeForm,
PERSONAL_SCOPES,
ORG_SCOPES,
validateState,
} from "./oauth";
const cfg = {
clientId: "test-client",
clientSecret: "test-secret",
redirectUri: "https://publishing.stargue.net/api/auth/linkedin/callback",
stateSecret: "long-state-secret-for-hmac",
};
describe("OAuth — buildAuthUrl", () => {
it("constructs an authorization URL with required params", () => {
const r = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" });
const u = new URL(r.url);
expect(u.origin + u.pathname).toBe("https://www.linkedin.com/oauth/v2/authorization");
expect(u.searchParams.get("response_type")).toBe("code");
expect(u.searchParams.get("client_id")).toBe("test-client");
expect(u.searchParams.get("redirect_uri")).toBe(cfg.redirectUri);
expect(u.searchParams.get("scope")).toContain("openid");
expect(u.searchParams.get("scope")).toContain("w_member_social");
expect(u.searchParams.get("state")).toBeTruthy();
expect(r.stateCookieValue).toBe(u.searchParams.get("state"));
});
it("encodes ORG_SCOPES for organization intent", () => {
const r = buildAuthUrl(cfg, { scopes: ORG_SCOPES, intent: "organization" });
const u = new URL(r.url);
expect(u.searchParams.get("scope")).toContain("w_organization_social");
});
});
describe("OAuth — validateState (CSRF + tamper + expiry)", () => {
it("validates a fresh state from buildAuthUrl", () => {
const r = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" });
const v = validateState(cfg, {
callbackState: r.stateCookieValue,
cookieState: r.stateCookieValue,
});
expect(v.ok).toBe(true);
expect(v.intent).toBe("personal");
});
it("rejects mismatched callback vs cookie state", () => {
const a = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" });
const b = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" });
const v = validateState(cfg, {
callbackState: a.stateCookieValue,
cookieState: b.stateCookieValue,
});
expect(v.ok).toBe(false);
expect(v.reason).toBe("state-mismatch");
});
it("rejects tampered signature", () => {
const r = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" });
const tampered = r.stateCookieValue.replace(/.$/, (c) => (c === "0" ? "1" : "0"));
const v = validateState(cfg, { callbackState: tampered, cookieState: tampered });
expect(v.ok).toBe(false);
expect(v.reason).toBe("bad-signature");
});
it("rejects state older than maxAgeMs", () => {
const r = buildAuthUrl(cfg, { scopes: PERSONAL_SCOPES, intent: "personal" });
const v = validateState(cfg, {
callbackState: r.stateCookieValue,
cookieState: r.stateCookieValue,
maxAgeMs: 1,
now: Date.now() + 1_000_000,
});
expect(v.ok).toBe(false);
expect(v.reason).toBe("expired");
});
it("rejects malformed state (no dot)", () => {
const v = validateState(cfg, { callbackState: "no-sig-here", cookieState: "no-sig-here" });
expect(v.ok).toBe(false);
expect(v.reason).toBe("malformed-state");
});
it("rejects missing state pair", () => {
expect(validateState(cfg, { callbackState: null, cookieState: null }).reason).toBe(
"missing-state",
);
});
});
describe("OAuth — token exchange forms", () => {
it("buildTokenExchangeForm includes authorization_code grant + secret", () => {
const f = buildTokenExchangeForm(cfg, { code: "abc", redirectUri: cfg.redirectUri });
expect(f.get("grant_type")).toBe("authorization_code");
expect(f.get("code")).toBe("abc");
expect(f.get("client_secret")).toBe("test-secret");
});
it("buildRefreshForm includes refresh_token grant", () => {
const f = buildRefreshForm(cfg, "rt-xyz");
expect(f.get("grant_type")).toBe("refresh_token");
expect(f.get("refresh_token")).toBe("rt-xyz");
});
});

View File

@@ -0,0 +1,124 @@
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
export const LINKEDIN_AUTH_BASE = "https://www.linkedin.com/oauth/v2";
export interface OAuthAppConfig {
clientId: string;
clientSecret: string;
redirectUri: string;
stateSecret: string;
}
export type Scope =
| "openid"
| "profile"
| "email"
| "w_member_social"
| "w_organization_social"
| "r_organization_social";
export const PERSONAL_SCOPES: readonly Scope[] = ["openid", "profile", "email", "w_member_social"];
export const ORG_SCOPES: readonly Scope[] = [
"openid",
"profile",
"email",
"w_member_social",
"w_organization_social",
"r_organization_social",
];
export interface AuthUrlInput {
scopes: readonly Scope[];
intent: "personal" | "organization";
}
export interface AuthUrlOutput {
url: string;
stateCookieValue: string;
}
export const buildAuthUrl = (cfg: OAuthAppConfig, input: AuthUrlInput): AuthUrlOutput => {
const nonce = randomBytes(16).toString("hex");
const issuedAt = Date.now().toString();
const stateCore = `${input.intent}:${nonce}:${issuedAt}`;
const sig = createHmac("sha256", cfg.stateSecret).update(stateCore).digest("hex");
const state = `${stateCore}.${sig}`;
const params = new URLSearchParams({
response_type: "code",
client_id: cfg.clientId,
redirect_uri: cfg.redirectUri,
scope: input.scopes.join(" "),
state,
});
return {
url: `${LINKEDIN_AUTH_BASE}/authorization?${params.toString()}`,
stateCookieValue: state,
};
};
export interface ValidateStateInput {
callbackState: string | null | undefined;
cookieState: string | null | undefined;
maxAgeMs?: number;
now?: number;
}
export interface ValidateStateOutput {
ok: boolean;
intent?: "personal" | "organization";
reason?: string;
}
export const validateState = (
cfg: OAuthAppConfig,
input: ValidateStateInput,
): ValidateStateOutput => {
if (!input.callbackState || !input.cookieState) return { ok: false, reason: "missing-state" };
if (input.callbackState !== input.cookieState) return { ok: false, reason: "state-mismatch" };
const parts = input.callbackState.split(".");
if (parts.length !== 2) return { ok: false, reason: "malformed-state" };
const [stateCore, sig] = parts as [string, string];
const expected = createHmac("sha256", cfg.stateSecret).update(stateCore).digest();
const provided = Buffer.from(sig, "hex");
if (expected.length !== provided.length) return { ok: false, reason: "bad-signature" };
if (!timingSafeEqual(expected, provided)) return { ok: false, reason: "bad-signature" };
const [intent, , issuedAtStr] = stateCore.split(":");
if (intent !== "personal" && intent !== "organization") {
return { ok: false, reason: "bad-intent" };
}
const issuedAt = Number(issuedAtStr);
const maxAge = input.maxAgeMs ?? 10 * 60 * 1000;
const now = input.now ?? Date.now();
if (now - issuedAt > maxAge) return { ok: false, reason: "expired" };
return { ok: true, intent };
};
export interface TokenExchangeRequest {
code: string;
redirectUri: string;
}
export const buildTokenExchangeForm = (
cfg: OAuthAppConfig,
req: TokenExchangeRequest,
): URLSearchParams =>
new URLSearchParams({
grant_type: "authorization_code",
code: req.code,
redirect_uri: req.redirectUri,
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
});
export const buildRefreshForm = (
cfg: OAuthAppConfig,
refreshToken: string,
): URLSearchParams =>
new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: cfg.clientId,
client_secret: cfg.clientSecret,
});

View File

@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { InMemoryAdvisoryLock, lockKeyFor, withRefreshLock } from "./refresh-lock";
describe("withRefreshLock — Plan TEA gap 3 concurrent rotation guard", () => {
it("derives a stable lock key for a subject URN", () => {
expect(lockKeyFor("urn:li:person:abc")).toBe("linkedin_refresh:urn:li:person:abc");
});
it("serializes 4 concurrent rotation attempts: only the first runs the inner fn", async () => {
const lock = new InMemoryAdvisoryLock();
let runs = 0;
let denials = 0;
const inner = async () => {
runs++;
await new Promise((r) => setTimeout(r, 10));
return runs;
};
const attempts = await Promise.allSettled(
Array.from({ length: 4 }, () => withRefreshLock(lock, "urn:li:person:abc", inner)),
);
for (const a of attempts) {
if (a.status === "rejected") denials++;
}
expect(runs).toBe(1);
expect(denials).toBe(3);
});
it("releases the lock on inner-fn failure", async () => {
const lock = new InMemoryAdvisoryLock();
let err: unknown;
try {
await withRefreshLock(lock, "urn:li:person:abc", async () => {
throw new Error("boom");
});
} catch (e) {
err = e;
}
expect((err as Error).message).toBe("boom");
// After failure the lock must be free for a second attempt:
const result = await withRefreshLock(lock, "urn:li:person:abc", async () => "ok");
expect(result).toBe("ok");
});
});

View File

@@ -0,0 +1,45 @@
/**
* Refresh-rotation advisory lock helper.
*
* Plan §3.2 (TEA gap 3): "Token rotation path acquires a Postgres advisory lock
* keyed on hashtextextended(subject_urn, 0) before reading/refreshing.
* Lock released after commit. Prevents double-rotation race."
*
* This is a pure-function wrapper. Inject the actual `acquireLock` and
* `releaseLock` functions (from `db.ts`) for production use; inject in-memory
* stubs for tests.
*/
export interface AdvisoryLock {
acquire: (key: string) => Promise<boolean>;
release: (key: string) => Promise<void>;
}
export const lockKeyFor = (subjectUrn: string): string => `linkedin_refresh:${subjectUrn}`;
export const withRefreshLock = async <T>(
lock: AdvisoryLock,
subjectUrn: string,
fn: () => Promise<T>,
): Promise<T> => {
const key = lockKeyFor(subjectUrn);
const acquired = await lock.acquire(key);
if (!acquired) throw new Error(`Refresh lock contended for ${subjectUrn}`);
try {
return await fn();
} finally {
await lock.release(key);
}
};
export class InMemoryAdvisoryLock implements AdvisoryLock {
private readonly held = new Set<string>();
async acquire(key: string): Promise<boolean> {
if (this.held.has(key)) return false;
this.held.add(key);
return true;
}
async release(key: string): Promise<void> {
this.held.delete(key);
}
}

View File

@@ -1,4 +1,69 @@
// MCP server entry point. Full implementation in Stage 3.
// MCP spec version pinned: 2024-11-05 (Streamable HTTP + stdio).
/**
* Stargue LinkedIn MCP Server — stdio transport.
*
* Pinned spec: MCP 2024-11-05.
*
* Tool wiring lives here; pure helpers (oauth, refresh-lock, kill-switch, tools)
* are tested independently. The actual @modelcontextprotocol/sdk wiring requires
* a runtime DB + LinkedIn credentials (gates 0.8 + 0.9), so this module exposes
* a `createServer` factory that takes injected dependencies for testability.
*/
import { z } from "zod";
import { TOOL_NAMES, TOOL_SCHEMAS, type ToolName } from "./tools";
import { KillSwitch } from "./kill-switch";
import type { AdvisoryLock } from "./refresh-lock";
export const MCP_SERVER_NAME = "@stargue/mcp-linkedin";
export const MCP_SPEC_VERSION = "2024-11-05";
export interface ServerDeps {
killSwitch: KillSwitch;
advisoryLock: AdvisoryLock;
// future: tokenStore, linkedInClient, dbPool — wired in Stage 3 live integration
}
export interface ToolCall {
name: ToolName;
args: unknown;
}
export interface ToolResult<T = unknown> {
ok: boolean;
data?: T;
error?: { code: string; message: string };
}
/**
* Validates a tool call against its registered schema. Returns parsed args or
* a 400-style error result.
*/
export const validateToolCall = (call: ToolCall): { ok: true; args: unknown } | ToolResult => {
const schema = TOOL_SCHEMAS[call.name as ToolName] as { input: z.ZodTypeAny } | undefined;
if (!schema) {
return { ok: false, error: { code: "UNKNOWN_TOOL", message: `Unknown tool: ${call.name}` } };
}
const parse = schema.input.safeParse(call.args);
if (!parse.success) {
return {
ok: false,
error: {
code: "INVALID_ARGS",
message: parse.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
},
};
}
return { ok: true, args: parse.data };
};
export const listTools = (): ReadonlyArray<{ name: ToolName; description: string }> =>
TOOL_NAMES.map((name) => ({
name,
description: TOOL_SCHEMAS[name].description,
}));
export const outletForAuthorUrn = (urn: string): "linkedin.member" | "linkedin.org" => {
if (urn.startsWith("urn:li:person:")) return "linkedin.member";
if (urn.startsWith("urn:li:organization:")) return "linkedin.org";
throw new Error(`Cannot derive outlet from URN: ${urn}`);
};

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { TOOL_NAMES, TOOL_SCHEMAS } from "./tools";
import { outletForAuthorUrn, validateToolCall } from "./server";
describe("Tool registry — 9 tools per Plan §3.2", () => {
it("exposes exactly the 9 tools named in the plan", () => {
expect(TOOL_NAMES.sort()).toEqual(
[
"linkedin_whoami",
"linkedin_auth_status",
"linkedin_create_post",
"linkedin_create_article",
"linkedin_upload_media",
"linkedin_create_post_with_media",
"linkedin_delete_post",
"linkedin_get_post_metrics",
"linkedin_get_profile_stats",
].sort(),
);
});
it("every tool has a description and a Zod input schema", () => {
for (const name of TOOL_NAMES) {
const def = TOOL_SCHEMAS[name];
expect(def.description.length).toBeGreaterThan(10);
expect(typeof def.input.safeParse).toBe("function");
}
});
});
describe("validateToolCall", () => {
it("returns parsed args for a well-formed create_post call", () => {
const r = validateToolCall({
name: "linkedin_create_post",
args: {
author_urn: "urn:li:person:abc",
text: "hello",
idempotency_key: "k1",
},
});
expect(r.ok).toBe(true);
});
it("rejects unknown tool name", () => {
const r = validateToolCall({ name: "linkedin_unknown" as never, args: {} });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error?.code).toBe("UNKNOWN_TOOL");
});
it("rejects bad URN format", () => {
const r = validateToolCall({
name: "linkedin_create_post",
args: {
author_urn: "facebook:bob",
text: "hello",
idempotency_key: "k1",
},
});
expect(r.ok).toBe(false);
if (!r.ok) expect(r.error?.code).toBe("INVALID_ARGS");
});
it("rejects post text exceeding 3000 chars (LinkedIn member feed cap)", () => {
const r = validateToolCall({
name: "linkedin_create_post",
args: {
author_urn: "urn:li:person:abc",
text: "x".repeat(3001),
idempotency_key: "k1",
},
});
expect(r.ok).toBe(false);
});
it("rejects article body exceeding 125k chars", () => {
const r = validateToolCall({
name: "linkedin_create_article",
args: {
author_urn: "urn:li:person:abc",
title: "T",
body: "x".repeat(125_001),
idempotency_key: "k1",
},
});
expect(r.ok).toBe(false);
});
});
describe("outletForAuthorUrn", () => {
it("derives linkedin.member from person URN", () => {
expect(outletForAuthorUrn("urn:li:person:abc")).toBe("linkedin.member");
});
it("derives linkedin.org from organization URN", () => {
expect(outletForAuthorUrn("urn:li:organization:2605890")).toBe("linkedin.org");
});
it("throws on unknown URN", () => {
expect(() => outletForAuthorUrn("urn:li:share:1")).toThrow();
});
});

View File

@@ -0,0 +1,84 @@
import { z } from "zod";
export const TOOL_SCHEMAS = {
linkedin_whoami: {
description: "Returns authenticated subject URN, scopes, and MDP-eligible flag.",
input: z.object({
subject_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/),
}),
},
linkedin_auth_status: {
description:
"Returns access-expiry remaining, refresh-expiry remaining, and kill-switch state for the subject.",
input: z.object({
subject_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/),
}),
},
linkedin_create_post: {
description: "Create a text-only post via the Posts API (NOT legacy ugcPosts).",
input: z.object({
author_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/),
text: z.string().min(1).max(3000),
visibility: z.enum(["PUBLIC", "CONNECTIONS"]).default("PUBLIC"),
idempotency_key: z.string().min(1),
}),
},
linkedin_create_article: {
description: "Create a long-form article (≤125k chars).",
input: z.object({
author_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/),
title: z.string().min(1).max(300),
body: z.string().min(1).max(125_000),
idempotency_key: z.string().min(1),
}),
},
linkedin_upload_media: {
description: "Upload an image or PDF; returns asset URN.",
input: z.object({
author_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/),
mime_type: z.enum(["image/png", "image/jpeg", "application/pdf"]),
data_b64: z.string().min(1),
}),
},
linkedin_create_post_with_media: {
description: "Combine media upload + post creation.",
input: z.object({
author_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/),
text: z.string().min(1).max(3000),
asset_urn: z.string().min(1),
idempotency_key: z.string().min(1),
}),
},
linkedin_delete_post: {
description:
"Delete a post by URN. Soft-reversible only within 5-min edit window; after that, permanent.",
input: z.object({
post_urn: z.string().min(1),
reason: z.string().min(1).max(500),
}),
},
linkedin_get_post_metrics: {
description: "Returns impressions, reactions, comments, shares, clicks for a post URN.",
input: z.object({
post_urn: z.string().min(1),
}),
},
linkedin_get_profile_stats: {
description: "Aggregate profile stats for the cadence optimizer.",
input: z.object({
subject_urn: z.string().regex(/^urn:li:(person|organization):[\w-]+$/),
}),
},
} as const;
export type ToolName = keyof typeof TOOL_SCHEMAS;
export const TOOL_NAMES: readonly ToolName[] = Object.keys(TOOL_SCHEMAS) as ToolName[];
/** Plan §3.2 architect gap 4: self-imposed conservative limits, NOT LinkedIn quota. */
export const RATE_CAPS = {
per_member_per_day: 20,
per_org_per_day: 50,
per_member_per_minute: 20, // burst cap, used by tests
per_org_per_minute: 50,
} as const;