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";
}
}

View File

@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import pino from "pino";
import { newCorrelationId } from "./index";
const captureLogs = (n: number, mutate: (log: pino.Logger, i: number) => void): unknown[] => {
const lines: unknown[] = [];
const stream = {
write: (chunk: string) => {
const trimmed = chunk.trim();
if (trimmed) lines.push(JSON.parse(trimmed));
return true;
},
};
const log = pino(
{
level: "trace",
redact: {
paths: [
"access_token",
"refresh_token",
"access_token_ct",
"refresh_token_ct",
"client_secret",
"*.access_token",
"*.refresh_token",
"*.client_secret",
],
censor: "[REDACTED]",
},
formatters: { level: (label) => ({ level: label }) },
timestamp: pino.stdTimeFunctions.isoTime,
},
stream as pino.DestinationStream,
);
for (let i = 0; i < n; i++) mutate(log, i);
return lines;
};
const REQUIRED_KEYS = ["level", "msg", "time"];
describe("logger — JSON-schema check across 100 sample logs", () => {
it("emits 100 well-formed JSON lines with required keys", () => {
const logs = captureLogs(100, (log, i) => {
const child = log.child({
correlation_id: newCorrelationId(),
subject: `subject-${i}`,
});
child.info({ event: "publish.scheduled", outlet: i % 2 === 0 ? "linkedin.member" : "stargue.com" }, `event ${i}`);
});
expect(logs).toHaveLength(100);
for (const line of logs) {
const obj = line as Record<string, unknown>;
for (const k of REQUIRED_KEYS) expect(obj).toHaveProperty(k);
expect(obj).toHaveProperty("correlation_id");
expect(obj).toHaveProperty("subject");
expect(typeof obj.correlation_id).toBe("string");
expect((obj.correlation_id as string).startsWith("corr_")).toBe(true);
expect(typeof obj.time).toBe("string");
expect(obj.level).toBe("info");
}
});
it("redacts sensitive token fields at top-level and nested", () => {
const [line] = captureLogs(1, (log) => {
log.info(
{
access_token: "should-be-hidden",
refresh_token_ct: "encrypted-blob",
client_secret: "shhh",
token: { access_token: "nested-hidden", refresh_token: "nested-also" },
},
"redaction-test",
);
});
const obj = line as Record<string, unknown>;
expect(obj.access_token).toBe("[REDACTED]");
expect(obj.refresh_token_ct).toBe("[REDACTED]");
expect(obj.client_secret).toBe("[REDACTED]");
const nested = obj.token as Record<string, unknown>;
expect(nested.access_token).toBe("[REDACTED]");
expect(nested.refresh_token).toBe("[REDACTED]");
});
it("emits levels as text labels not numbers", () => {
const logs = captureLogs(4, (log, i) => {
const fns: Array<keyof pino.Logger> = ["debug", "info", "warn", "error"];
const fn = fns[i] as keyof pino.Logger;
(log[fn] as (msg: string) => void)("level-test");
});
const labels = (logs as Array<Record<string, unknown>>).map((l) => l.level);
expect(labels).toEqual(["debug", "info", "warn", "error"]);
});
});
describe("newCorrelationId", () => {
it("returns unique IDs across 1000 calls", () => {
const set = new Set<string>();
for (let i = 0; i < 1000; i++) set.add(newCorrelationId());
expect(set.size).toBe(1000);
});
it("matches expected shape: corr_<base36-time>_<base36-rand>", () => {
const id = newCorrelationId();
expect(id).toMatch(/^corr_[a-z0-9]+_[a-z0-9]{8}$/);
});
});

View File

@@ -0,0 +1,127 @@
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { SanitizeError, sanitize, type SanitizeOptions } from "./index";
const CORPUS_ROOT = join(__dirname, "..", "..", "..", "test", "corpus");
const PRIVATE_DIR = join(CORPUS_ROOT, "private");
const CLEAN_DIR = join(CORPUS_ROOT, "clean");
interface FixtureMeta {
vault_path: string;
outlet: string;
expected_error_code?: string;
length_target?: number;
embed_strategy?: "resolve" | "strip";
expected_frontmatter_tags?: string[];
}
interface Fixture {
name: string;
meta: FixtureMeta;
body: string;
}
const parseFixture = (name: string, raw: string): Fixture => {
const m = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
if (!m) throw new Error(`Fixture ${name} missing frontmatter`);
const fmRaw = m[1]!;
const body = m[2]!;
const meta = parseSimpleYaml(fmRaw) as FixtureMeta;
return { name, meta, body };
};
const parseSimpleYaml = (text: string): Record<string, unknown> => {
const out: Record<string, unknown> = {};
let currentListKey: string | null = null;
for (const line of text.split("\n")) {
if (!line.trim()) {
currentListKey = null;
continue;
}
if (line.startsWith(" - ") && currentListKey) {
const arr = (out[currentListKey] as string[]) ?? [];
arr.push(line.replace(" - ", "").trim());
out[currentListKey] = arr;
continue;
}
const idx = line.indexOf(":");
if (idx < 0) continue;
const key = line.slice(0, idx).trim();
const val = line.slice(idx + 1).trim();
if (val === "") {
currentListKey = key;
out[key] = [];
continue;
}
currentListKey = null;
if (/^\d+$/.test(val)) out[key] = Number(val);
else out[key] = val;
}
return out;
};
const loadFixtures = (dir: string): Fixture[] =>
readdirSync(dir)
.filter((f) => f.endsWith(".md"))
.map((f) => parseFixture(f, readFileSync(join(dir, f), "utf8")));
const inflateLengthBody = (body: string, target: number): string => {
if (!body.includes("[GENERATED_BODY_")) return body;
const filler = "lorem ipsum dolor sit amet ";
const repeats = Math.ceil(target / filler.length) + 5;
return filler.repeat(repeats);
};
const buildOptions = (meta: FixtureMeta): SanitizeOptions => {
const tags: string[] = [];
if (meta.expected_frontmatter_tags) tags.push(...meta.expected_frontmatter_tags);
return {
vaultPath: meta.vault_path,
outlet: meta.outlet,
embedStrategy: meta.embed_strategy ?? "strip",
tags,
};
};
describe("Private corpus — 12 fixtures must FAIL closed", () => {
const fixtures = loadFixtures(PRIVATE_DIR);
it("loads exactly 12 private fixtures", () => {
expect(fixtures).toHaveLength(12);
});
for (const fx of fixtures) {
it(`${fx.name}${fx.meta.expected_error_code}`, () => {
const body = inflateLengthBody(fx.body, fx.meta.length_target ?? 0);
let caught: SanitizeError | null = null;
try {
sanitize(body, buildOptions(fx.meta));
} catch (e) {
caught = e as SanitizeError;
}
expect(caught).not.toBeNull();
expect(caught).toBeInstanceOf(SanitizeError);
expect(caught!.code).toBe(fx.meta.expected_error_code);
});
}
});
describe("Clean corpus — 6 fixtures must round-trip without error", () => {
const fixtures = loadFixtures(CLEAN_DIR);
it("loads exactly 6 clean fixtures", () => {
expect(fixtures).toHaveLength(6);
});
for (const fx of fixtures) {
it(`${fx.name} sanitizes cleanly`, () => {
const result = sanitize(fx.body, buildOptions(fx.meta));
expect(result.body.length).toBeGreaterThan(0);
expect(result.contentHash).toMatch(/^[0-9a-f]{64}$/);
expect(result.body).not.toMatch(/\[\[/);
expect(result.body).not.toMatch(/^>\s*\[!/m);
expect(result.body).not.toMatch(/```dataview/);
});
}
});

View File

@@ -0,0 +1,20 @@
export class SanitizeError extends Error {
constructor(
public readonly code: SanitizeErrorCode,
message: string,
public readonly detail?: Record<string, unknown>,
) {
super(message);
this.name = "SanitizeError";
}
}
export type SanitizeErrorCode =
| "PRIVATE_PATH_BLOCKED"
| "PRIVATE_TAG_BLOCKED"
| "WIKILINK_TO_PRIVATE_PATH"
| "OUTLET_LENGTH_EXCEEDED"
| "FRONTMATTER_INVALID";
export const formatSanitizeError = (e: SanitizeError): string =>
`[${e.code}] ${e.message}${e.detail ? ` :: ${JSON.stringify(e.detail)}` : ""}`;

View File

@@ -1,2 +1,137 @@
export const SANITIZE_PACKAGE_READY = false;
// Implementation in Stage 1.2. See docs/plans/2026-04-19-phase1-plan.md Stage 1.
import { createHash } from "node:crypto";
import { SanitizeError } from "./errors";
import {
OUTLET_LENGTH_LIMITS,
PRIVATE_PATH_PREFIXES,
PRIVATE_PATH_PATTERNS,
PRIVATE_TAGS,
isPrivatePath,
isPrivateTag,
} from "./rules";
export { SanitizeError, formatSanitizeError } from "./errors";
export type { SanitizeErrorCode } from "./errors";
export {
OUTLET_LENGTH_LIMITS,
PRIVATE_PATH_PREFIXES,
PRIVATE_PATH_PATTERNS,
PRIVATE_TAGS,
isPrivatePath,
isPrivateTag,
};
export interface SanitizeOptions {
vaultPath: string;
outlet: keyof typeof OUTLET_LENGTH_LIMITS | string;
embedStrategy?: "resolve" | "strip";
tags?: readonly string[];
}
export interface SanitizeResult {
body: string;
contentHash: string;
warnings: readonly string[];
}
const WIKILINK_RE = /\[\[([^\]]+)\]\]/g;
const EMBED_RE = /!\[\[([^\]]+)\]\]/g;
const DATAVIEW_BLOCK_RE = /```dataview[\s\S]*?```/g;
const CALLOUT_LINE_RE = /^>\s*\[![^\]]+\][^\n]*$/gm;
const INLINE_TAG_RE = /(^|\s)#([\w/-]+)/g;
const stripDataview = (md: string): string => md.replace(DATAVIEW_BLOCK_RE, "").trimStart();
const stripCallouts = (md: string): string =>
md
.split("\n")
.filter((line) => !/^>\s*\[![^\]]+\]/.test(line))
.join("\n");
const replaceEmbeds = (md: string, opts: SanitizeOptions): string =>
md.replace(EMBED_RE, (_full, target) => {
if (opts.embedStrategy === "resolve") {
const trimmed = String(target).split("|")[0]!.trim();
return `![${trimmed}](/_assets/${trimmed.replace(/^\/+/, "")})`;
}
return "";
});
const replaceWikilinks = (md: string): string =>
md.replace(WIKILINK_RE, (_full, inside) => {
const raw = String(inside);
const [pathPart, displayPart] = raw.split("|");
const display = (displayPart ?? pathPart!.split("/").pop() ?? pathPart!).trim();
if (isPrivatePath(pathPart!.trim())) {
throw new SanitizeError(
"WIKILINK_TO_PRIVATE_PATH",
`Wikilink targets a private vault path: ${pathPart}`,
{ target: pathPart },
);
}
return display;
});
const collectTags = (md: string, frontmatterTags?: readonly string[]): string[] => {
const inline: string[] = [];
for (const m of md.matchAll(INLINE_TAG_RE)) {
const t = m[2];
if (t) inline.push(`#${t}`);
}
const fm = (frontmatterTags ?? []).map((t) => (t.startsWith("#") ? t : `#${t}`));
return [...inline, ...fm];
};
const enforceTagFirewall = (tags: readonly string[]): void => {
for (const tag of tags) {
if (isPrivateTag(tag)) {
throw new SanitizeError(
"PRIVATE_TAG_BLOCKED",
`Private tag detected: ${tag}`,
{ tag },
);
}
}
};
const enforceLength = (body: string, outlet: string): void => {
const limit = OUTLET_LENGTH_LIMITS[outlet];
if (limit === undefined) return;
if (body.length > limit) {
throw new SanitizeError(
"OUTLET_LENGTH_EXCEEDED",
`Sanitized body length ${body.length} exceeds outlet limit ${limit} for ${outlet}`,
{ outlet, limit, actual: body.length },
);
}
};
const computeHash = (body: string): string =>
createHash("sha256").update(body, "utf8").digest("hex");
export const sanitize = (markdown: string, opts: SanitizeOptions): SanitizeResult => {
if (isPrivatePath(opts.vaultPath)) {
throw new SanitizeError(
"PRIVATE_PATH_BLOCKED",
`Vault path is in private blocklist: ${opts.vaultPath}`,
{ vaultPath: opts.vaultPath },
);
}
const tags = collectTags(markdown, opts.tags);
enforceTagFirewall(tags);
let out = markdown;
out = stripDataview(out);
out = stripCallouts(out);
out = replaceEmbeds(out, opts);
out = replaceWikilinks(out);
out = out.replace(/\n{3,}/g, "\n\n").trim();
enforceLength(out, opts.outlet);
return {
body: out,
contentHash: computeHash(out),
warnings: [],
};
};

View File

@@ -0,0 +1,42 @@
export const PRIVATE_PATH_PREFIXES: readonly string[] = [
"Family Matters/",
"Financial Matters/",
"Journal/",
"Day Planners/",
"People/",
"Clients/",
];
export const PRIVATE_PATH_PATTERNS: readonly RegExp[] = [
/(^|\/)Clients\/[^\/]*\[NDA\][^\/]*\//i,
/(^|\/)\.private\//,
];
export const PRIVATE_TAGS: readonly string[] = [
"#private",
"#heal-internal",
"#confidential",
"#ndA",
"#nda",
"#draft-only",
];
export const OUTLET_LENGTH_LIMITS: Record<string, number> = {
"linkedin.member": 3000,
"linkedin.org": 3000,
"linkedin.article": 125_000,
"twitter": 280,
"stargue.com": 100_000,
"stargue.net": 100_000,
};
export const isPrivatePath = (path: string): boolean => {
const normalized = path.replace(/^\/+/, "");
if (PRIVATE_PATH_PREFIXES.some((p) => normalized.startsWith(p))) return true;
return PRIVATE_PATH_PATTERNS.some((re) => re.test(normalized));
};
export const isPrivateTag = (tag: string): boolean => {
const normalized = tag.startsWith("#") ? tag.toLowerCase() : `#${tag.toLowerCase()}`;
return PRIVATE_TAGS.map((t) => t.toLowerCase()).includes(normalized);
};

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from "vitest";
import {
NoteFrontmatterSchema,
PublishFrontmatterSchema,
parseFrontmatter,
} from "./frontmatter";
const baseTimestamps = {
created: "2026-04-01T10:00:00+00:00",
updated: "2026-04-26T14:00:00+00:00",
};
describe("PublishFrontmatterSchema — 6 valid cases", () => {
it("accepts a minimal published blog post", () => {
const parsed = PublishFrontmatterSchema.parse({
status: "published",
outlets: [
{ outlet: "stargue.com", status: "published", published_url: "https://stargue.com/blog/post" },
],
category: "blog",
});
expect(parsed.language).toBe("en");
expect(parsed.canonical).toBe("stargue.com");
expect(parsed.sanitize).toBe(true);
expect(parsed.version).toBe(1);
});
it("accepts a draft with no outlets yet", () => {
const parsed = PublishFrontmatterSchema.parse({
status: "draft",
outlets: [],
category: "research",
});
expect(parsed.scheduled).toBeNull();
});
it("accepts a queued post with future scheduled time", () => {
const parsed = PublishFrontmatterSchema.parse({
status: "queued",
outlets: [
{ outlet: "linkedin.member", status: "queued", published_url: null },
],
scheduled: "2026-05-01T08:30:00-04:00",
category: "blog",
});
expect(parsed.scheduled).toBe("2026-05-01T08:30:00-04:00");
});
it("accepts multi-outlet partial publish", () => {
const parsed = PublishFrontmatterSchema.parse({
status: "partial",
outlets: [
{ outlet: "stargue.com", status: "published", published_url: "https://stargue.com/x" },
{ outlet: "linkedin.org", status: "failed", published_url: null },
],
category: "case-study",
slug: "x",
});
expect(parsed.outlets).toHaveLength(2);
});
it("accepts non-English language with explicit canonical", () => {
const parsed = PublishFrontmatterSchema.parse({
status: "published",
language: "pap",
canonical: "stargue.com",
outlets: [
{ outlet: "stargue.com", status: "published", published_url: "https://stargue.com/pap/x" },
],
category: "blog",
});
expect(parsed.language).toBe("pap");
});
it("accepts sanitize=false explicit override", () => {
const parsed = PublishFrontmatterSchema.parse({
status: "ready",
outlets: [],
category: "white-paper",
sanitize: false,
version: 3,
});
expect(parsed.sanitize).toBe(false);
expect(parsed.version).toBe(3);
});
});
describe("PublishFrontmatterSchema — 6 invalid cases", () => {
it("rejects unknown status", () => {
expect(() =>
PublishFrontmatterSchema.parse({
status: "drafted",
outlets: [],
category: "blog",
}),
).toThrow();
});
it("rejects unknown outlet", () => {
expect(() =>
PublishFrontmatterSchema.parse({
status: "published",
outlets: [
{ outlet: "facebook", status: "published", published_url: "https://fb.com/x" },
],
category: "blog",
}),
).toThrow();
});
it("rejects unknown language", () => {
expect(() =>
PublishFrontmatterSchema.parse({
status: "draft",
language: "fr",
outlets: [],
category: "blog",
}),
).toThrow();
});
it("rejects malformed scheduled timestamp (no offset)", () => {
expect(() =>
PublishFrontmatterSchema.parse({
status: "queued",
outlets: [],
scheduled: "2026-05-01T08:30:00",
category: "blog",
}),
).toThrow();
});
it("rejects negative version", () => {
expect(() =>
PublishFrontmatterSchema.parse({
status: "draft",
outlets: [],
category: "blog",
version: -1,
}),
).toThrow();
});
it("rejects unknown category", () => {
expect(() =>
PublishFrontmatterSchema.parse({
status: "draft",
outlets: [],
category: "newsletter",
}),
).toThrow();
});
});
describe("NoteFrontmatterSchema — round-trip", () => {
it("round-trips a full vault note frontmatter", () => {
const input = {
...baseTimestamps,
tags: ["MOC"],
publish: {
status: "published",
outlets: [
{ outlet: "stargue.com", status: "published", published_url: "https://stargue.com/x" },
],
category: "blog",
},
};
const parsed = NoteFrontmatterSchema.parse(input);
expect(parsed.publish?.status).toBe("published");
expect(parsed.publish?.canonical).toBe("stargue.com");
});
it("accepts a note without publish block (private vault notes)", () => {
const parsed = parseFrontmatter({ ...baseTimestamps, tags: null });
expect(parsed.publish).toBeUndefined();
});
it("rejects a note missing required timestamps", () => {
expect(() =>
NoteFrontmatterSchema.parse({ tags: [] }),
).toThrow();
});
});

View File

@@ -0,0 +1,18 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
coverage: {
provider: "v8",
reporter: ["text", "json-summary"],
include: ["src/**/*.ts"],
exclude: ["src/db.ts", "src/index.ts", "src/**/*.test.ts"],
thresholds: {
lines: 100,
functions: 100,
branches: 100,
statements: 100,
},
},
},
});