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:
Angelo B. J. Luidens
2026-04-19 07:22:07 -04:00
parent 9022d81dd4
commit 1dc1a1a07a
30 changed files with 2596 additions and 0 deletions

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

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

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

View 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);

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

View 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.

View 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
View 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(),
});

View 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);

View File

@@ -0,0 +1,2 @@
export * from "./frontmatter";
export * from "./db";