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:
@@ -11,7 +11,6 @@
|
|||||||
"lint": "echo 'lint pending'"
|
"lint": "echo 'lint pending'"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.0.3",
|
|
||||||
"@stargue/linkedin-client": "workspace:*",
|
"@stargue/linkedin-client": "workspace:*",
|
||||||
"@stargue/schema": "workspace:*",
|
"@stargue/schema": "workspace:*",
|
||||||
"@stargue/observability": "workspace:*",
|
"@stargue/observability": "workspace:*",
|
||||||
|
|||||||
57
apps/mcp-linkedin/src/kill-switch.test.ts
Normal file
57
apps/mcp-linkedin/src/kill-switch.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
51
apps/mcp-linkedin/src/kill-switch.ts
Normal file
51
apps/mcp-linkedin/src/kill-switch.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
107
apps/mcp-linkedin/src/oauth.test.ts
Normal file
107
apps/mcp-linkedin/src/oauth.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
124
apps/mcp-linkedin/src/oauth.ts
Normal file
124
apps/mcp-linkedin/src/oauth.ts
Normal 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,
|
||||||
|
});
|
||||||
43
apps/mcp-linkedin/src/refresh-lock.test.ts
Normal file
43
apps/mcp-linkedin/src/refresh-lock.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
45
apps/mcp-linkedin/src/refresh-lock.ts
Normal file
45
apps/mcp-linkedin/src/refresh-lock.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_SERVER_NAME = "@stargue/mcp-linkedin";
|
||||||
export const MCP_SPEC_VERSION = "2024-11-05";
|
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}`);
|
||||||
|
};
|
||||||
|
|||||||
99
apps/mcp-linkedin/src/tools.test.ts
Normal file
99
apps/mcp-linkedin/src/tools.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
84
apps/mcp-linkedin/src/tools.ts
Normal file
84
apps/mcp-linkedin/src/tools.ts
Normal 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;
|
||||||
25
bun.lock
25
bun.lock
@@ -37,7 +37,6 @@
|
|||||||
"name": "@stargue/mcp-linkedin",
|
"name": "@stargue/mcp-linkedin",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "1.0.3",
|
|
||||||
"@stargue/linkedin-client": "workspace:*",
|
"@stargue/linkedin-client": "workspace:*",
|
||||||
"@stargue/observability": "workspace:*",
|
"@stargue/observability": "workspace:*",
|
||||||
"@stargue/schema": "workspace:*",
|
"@stargue/schema": "workspace:*",
|
||||||
@@ -234,8 +233,6 @@
|
|||||||
|
|
||||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.0.3", "", { "dependencies": { "content-type": "^1.0.5", "raw-body": "^3.0.0", "zod": "^3.23.8" } }, "sha512-2as3cX/VJ0YBHGmdv3GFyTpoM8q2gqE98zh3Vf1NwnsSY0h3mvoO07MUzfygCKkWsFjcZm4otIiqD6Xh7kiSBQ=="],
|
|
||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
|
||||||
|
|
||||||
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
|
||||||
@@ -410,8 +407,6 @@
|
|||||||
|
|
||||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
||||||
|
|
||||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
|
||||||
|
|
||||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001791", "", {}, "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ=="],
|
||||||
@@ -434,8 +429,6 @@
|
|||||||
|
|
||||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
|
||||||
|
|
||||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||||
|
|
||||||
"cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
|
"cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
|
||||||
@@ -450,8 +443,6 @@
|
|||||||
|
|
||||||
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
|
||||||
|
|
||||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
|
||||||
|
|
||||||
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
@@ -494,12 +485,6 @@
|
|||||||
|
|
||||||
"headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="],
|
"headers-polyfill": ["headers-polyfill@5.0.1", "", { "dependencies": { "@types/set-cookie-parser": "^2.4.10", "set-cookie-parser": "^3.0.1" } }, "sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA=="],
|
||||||
|
|
||||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
|
||||||
|
|
||||||
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
|
|
||||||
|
|
||||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
|
||||||
|
|
||||||
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
|
"ioredis": ["ioredis@5.10.1", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA=="],
|
||||||
|
|
||||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
@@ -616,8 +601,6 @@
|
|||||||
|
|
||||||
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
|
||||||
|
|
||||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
|
||||||
|
|
||||||
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
"react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="],
|
||||||
|
|
||||||
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
|
||||||
@@ -642,16 +625,12 @@
|
|||||||
|
|
||||||
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
|
||||||
|
|
||||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
|
||||||
|
|
||||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||||
|
|
||||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||||
|
|
||||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
|
||||||
|
|
||||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||||
|
|
||||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||||
@@ -702,8 +681,6 @@
|
|||||||
|
|
||||||
"tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="],
|
"tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="],
|
||||||
|
|
||||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
|
||||||
|
|
||||||
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
"tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="],
|
||||||
|
|
||||||
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
|
||||||
@@ -728,8 +705,6 @@
|
|||||||
|
|
||||||
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
|
||||||
|
|
||||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
|
||||||
|
|
||||||
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
|
"until-async": ["until-async@3.0.2", "", {}, "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw=="],
|
||||||
|
|
||||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user