Stage 0: governance scaffolding + monorepo bootstrap
Phase 1 foundation for the Stargue Publishing Engine (plan v2, BMAD
panel-reviewed 2026-04-19 — 1 APPROVE, 6 REVISE, 0 REJECT; all principles >=3).
- Governance doctrine adopted from DQMS
(.clinerules/12-foundational-principles.md,
.claude/hooks/gate-plan-exit.sh, .claude/skills/bmad-plan/SKILL.md)
- Bun workspaces + Turbo; apps/{mcp-linkedin,scheduler,admin};
packages/{schema,sanitize,linkedin-client,observability}
- Drizzle schema (content, publications, approvals, metrics,
linkedin_tokens, audit, outlet_feature_flags) with idempotency_key
UNIQUE and kill-switch table per TEA/dev panel revisions
- LinkedIn API canon: Posts API /rest/posts (not legacy UGC); OAuth
auth-code without PKCE; secretbox (not sealed-box); Community
Management API as separate approval gate from MDP
- Frontmatter Zod schema (status, language, outlets[], sanitize,
scheduled, version)
- Pino observability with PII redaction
- Expand-then-contract migration runbook
- Plan + panel verdicts mirrored to docs/plans/
- Deferred gates logged (Dokploy PaaS verification, LinkedIn Dev
Portal app registration)
bun install + bun run typecheck both exit 0 across 11 workspaces.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
25
packages/linkedin-client/package.json
Normal file
25
packages/linkedin-client/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@stargue/linkedin-client",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "echo 'lint pending'"
|
||||
},
|
||||
"dependencies": {
|
||||
"libsodium-wrappers-sumo": "^0.7.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0",
|
||||
"msw": "^2.6.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
6
packages/linkedin-client/src/index.ts
Normal file
6
packages/linkedin-client/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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";
|
||||
24
packages/observability/package.json
Normal file
24
packages/observability/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@stargue/observability",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "echo 'lint pending'"
|
||||
},
|
||||
"dependencies": {
|
||||
"pino": "^9.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
27
packages/observability/src/index.ts
Normal file
27
packages/observability/src/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import pino from "pino";
|
||||
|
||||
export const logger = pino({
|
||||
level: process.env.LOG_LEVEL ?? "info",
|
||||
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,
|
||||
});
|
||||
|
||||
export const newCorrelationId = (): string =>
|
||||
`corr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
export const child = (bindings: Record<string, unknown>) => logger.child(bindings);
|
||||
29
packages/sanitize/package.json
Normal file
29
packages/sanitize/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@stargue/sanitize",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "echo 'lint pending'"
|
||||
},
|
||||
"dependencies": {
|
||||
"unified": "^11.0.0",
|
||||
"remark-parse": "^11.0.0",
|
||||
"remark-stringify": "^11.0.0",
|
||||
"mdast-util-from-markdown": "^2.0.0",
|
||||
"unist-util-visit": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^2.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"@types/mdast": "^4.0.0"
|
||||
}
|
||||
}
|
||||
2
packages/sanitize/src/index.ts
Normal file
2
packages/sanitize/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const SANITIZE_PACKAGE_READY = false;
|
||||
// Implementation in Stage 1.2. See docs/plans/2026-04-19-phase1-plan.md Stage 1.
|
||||
30
packages/schema/package.json
Normal file
30
packages/schema/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@stargue/schema",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./db": "./src/db.ts",
|
||||
"./frontmatter": "./src/frontmatter.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"lint": "echo 'lint pending'",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db:migrate:rehearse": "echo 'rehearsal script pending Stage 2.2'"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^3.23.0",
|
||||
"drizzle-orm": "^0.36.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"drizzle-kit": "^0.28.0",
|
||||
"vitest": "^2.1.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
91
packages/schema/src/db.ts
Normal file
91
packages/schema/src/db.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { pgTable, serial, text, timestamp, integer, jsonb, boolean, uniqueIndex, index } from "drizzle-orm/pg-core";
|
||||
|
||||
export const content = pgTable("content", {
|
||||
id: serial("id").primaryKey(),
|
||||
vault_path: text("vault_path").notNull().unique(),
|
||||
slug: text("slug").notNull(),
|
||||
title: text("title").notNull(),
|
||||
body_sanitized: text("body_sanitized").notNull(),
|
||||
frontmatter_jsonb: jsonb("frontmatter_jsonb").notNull(),
|
||||
content_hash: text("content_hash").notNull(),
|
||||
version: integer("version").notNull().default(1),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
slugIdx: index("content_slug_idx").on(t.slug),
|
||||
}));
|
||||
|
||||
export const publications = pgTable("publications", {
|
||||
id: serial("id").primaryKey(),
|
||||
content_id: integer("content_id").notNull().references(() => content.id),
|
||||
outlet: text("outlet").notNull(),
|
||||
status: text("status").notNull(),
|
||||
scheduled_at: timestamp("scheduled_at", { withTimezone: true }),
|
||||
published_at: timestamp("published_at", { withTimezone: true }),
|
||||
external_id: text("external_id"),
|
||||
external_url: text("external_url"),
|
||||
idempotency_key: text("idempotency_key").notNull(),
|
||||
error: text("error"),
|
||||
metadata_jsonb: jsonb("metadata_jsonb"),
|
||||
}, (t) => ({
|
||||
idemIdx: uniqueIndex("publications_idempotency_key_idx").on(t.idempotency_key),
|
||||
contentOutletIdx: index("publications_content_outlet_idx").on(t.content_id, t.outlet),
|
||||
}));
|
||||
|
||||
export const approvals = pgTable("approvals", {
|
||||
id: serial("id").primaryKey(),
|
||||
content_id: integer("content_id").notNull().references(() => content.id),
|
||||
outlet: text("outlet").notNull(),
|
||||
approved_by: text("approved_by").notNull(),
|
||||
approved_at: timestamp("approved_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
notes: text("notes"),
|
||||
});
|
||||
|
||||
export const metrics = pgTable("metrics", {
|
||||
id: serial("id").primaryKey(),
|
||||
publication_id: integer("publication_id").notNull().references(() => publications.id),
|
||||
collected_at: timestamp("collected_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
impressions: integer("impressions").notNull().default(0),
|
||||
reactions: integer("reactions").notNull().default(0),
|
||||
comments: integer("comments").notNull().default(0),
|
||||
shares: integer("shares").notNull().default(0),
|
||||
clicks: integer("clicks").notNull().default(0),
|
||||
raw_jsonb: jsonb("raw_jsonb"),
|
||||
});
|
||||
|
||||
export const linkedin_tokens = pgTable("linkedin_tokens", {
|
||||
id: serial("id").primaryKey(),
|
||||
subject_type: text("subject_type").notNull(),
|
||||
subject_urn: text("subject_urn").notNull(),
|
||||
access_token_ct: text("access_token_ct").notNull(),
|
||||
refresh_token_ct: text("refresh_token_ct"),
|
||||
access_expires_at: timestamp("access_expires_at", { withTimezone: true }).notNull(),
|
||||
refresh_expires_at: timestamp("refresh_expires_at", { withTimezone: true }),
|
||||
scopes: text("scopes").notNull(),
|
||||
created_at: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, (t) => ({
|
||||
subjectIdx: uniqueIndex("linkedin_tokens_subject_idx").on(t.subject_urn),
|
||||
}));
|
||||
|
||||
export const audit = pgTable("audit", {
|
||||
id: serial("id").primaryKey(),
|
||||
ts: timestamp("ts", { withTimezone: true }).notNull().defaultNow(),
|
||||
actor: text("actor").notNull(),
|
||||
action: text("action").notNull(),
|
||||
subject_type: text("subject_type").notNull(),
|
||||
subject_id: text("subject_id").notNull(),
|
||||
correlation_id: text("correlation_id").notNull(),
|
||||
payload_jsonb: jsonb("payload_jsonb"),
|
||||
}, (t) => ({
|
||||
tsIdx: index("audit_ts_idx").on(t.ts),
|
||||
corrIdx: index("audit_correlation_idx").on(t.correlation_id),
|
||||
}));
|
||||
|
||||
export const outlet_feature_flags = pgTable("outlet_feature_flags", {
|
||||
outlet: text("outlet").primaryKey(),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
reason: text("reason"),
|
||||
updated_at: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updated_by: text("updated_by").notNull(),
|
||||
});
|
||||
50
packages/schema/src/frontmatter.ts
Normal file
50
packages/schema/src/frontmatter.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const OutletSchema = z.enum([
|
||||
"stargue.com",
|
||||
"stargue.net",
|
||||
"linkedin.member",
|
||||
"linkedin.org",
|
||||
]);
|
||||
export type Outlet = z.infer<typeof OutletSchema>;
|
||||
|
||||
export const LanguageSchema = z.enum(["en", "nl", "pap", "es"]);
|
||||
export type Language = z.infer<typeof LanguageSchema>;
|
||||
|
||||
export const OutletStatusSchema = z.enum([
|
||||
"pending",
|
||||
"queued",
|
||||
"approved",
|
||||
"published",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
export const PerOutletPublishSchema = z.object({
|
||||
outlet: OutletSchema,
|
||||
status: OutletStatusSchema,
|
||||
published_url: z.string().url().nullable(),
|
||||
});
|
||||
|
||||
export const PublishFrontmatterSchema = z.object({
|
||||
status: z.enum(["draft", "ready", "queued", "published", "partial", "failed"]),
|
||||
language: LanguageSchema.default("en"),
|
||||
outlets: z.array(PerOutletPublishSchema),
|
||||
scheduled: z.string().datetime({ offset: true }).nullable().default(null),
|
||||
slug: z.string().min(1).optional(),
|
||||
category: z.enum(["blog", "case-study", "white-paper", "research", "digest"]),
|
||||
canonical: z.string().default("stargue.com"),
|
||||
sanitize: z.boolean().default(true),
|
||||
version: z.number().int().positive().default(1),
|
||||
});
|
||||
export type PublishFrontmatter = z.infer<typeof PublishFrontmatterSchema>;
|
||||
|
||||
export const NoteFrontmatterSchema = z.object({
|
||||
created: z.string().datetime({ offset: true }),
|
||||
updated: z.string().datetime({ offset: true }),
|
||||
tags: z.array(z.string()).or(z.null()).optional(),
|
||||
publish: PublishFrontmatterSchema.optional(),
|
||||
});
|
||||
export type NoteFrontmatter = z.infer<typeof NoteFrontmatterSchema>;
|
||||
|
||||
export const parseFrontmatter = (raw: unknown): NoteFrontmatter =>
|
||||
NoteFrontmatterSchema.parse(raw);
|
||||
2
packages/schema/src/index.ts
Normal file
2
packages/schema/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./frontmatter";
|
||||
export * from "./db";
|
||||
Reference in New Issue
Block a user