diff --git a/.github/media-os-transfer-probe.txt b/.github/media-os-transfer-probe.txt new file mode 100644 index 000000000..3f1989b87 --- /dev/null +++ b/.github/media-os-transfer-probe.txt @@ -0,0 +1 @@ +Media OS implementation transfer path verified. diff --git a/.github/push-files-schema-probe.txt b/.github/push-files-schema-probe.txt new file mode 100644 index 000000000..da0c4eb8d --- /dev/null +++ b/.github/push-files-schema-probe.txt @@ -0,0 +1 @@ +probe diff --git a/.github/scripts/bootstrap_media_os.py b/.github/scripts/bootstrap_media_os.py new file mode 100644 index 000000000..0c55debe3 --- /dev/null +++ b/.github/scripts/bootstrap_media_os.py @@ -0,0 +1,955 @@ +from __future__ import annotations + +import json +import os +import re +from pathlib import Path +from textwrap import dedent + +ROOT = Path.cwd() +APP = Path("src/app") if (ROOT / "src/app").is_dir() else Path("app") +LIB = Path("src/lib") if (ROOT / "src").is_dir() else Path("lib") +MIGRATIONS = Path("supabase/migrations") + + +def write(path: Path, content: str) -> None: + target = ROOT / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(dedent(content).lstrip(), encoding="utf-8") + + +def rel_import(source: Path, target: Path) -> str: + relative = os.path.relpath(target, source.parent).replace(os.sep, "/") + if not relative.startswith("."): + relative = "./" + relative + return re.sub(r"\.(ts|tsx)$", "", relative) + + +# Never overwrite a substantive implementation produced by the primary worktree. +existing = { + p + for base in (ROOT / APP, ROOT / LIB, ROOT / "supabase") + if base.exists() + for p in base.rglob("*") + if p.is_file() + and re.search(r"media.?os|japan-market-insights", str(p), re.IGNORECASE) +} +if len(existing) >= 8: + print(f"Existing Media OS implementation detected ({len(existing)} files); bootstrap skipped.") + raise SystemExit(0) + +CORE = LIB / "media-os" + +write( + CORE / "types.ts", + r''' + export const MEDIA_OS_CHANNELS = ["pseo", "youtube", "x", "linkedin", "commercial"] as const; + export type MediaOsChannel = (typeof MEDIA_OS_CHANNELS)[number]; + + export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; + + export interface MediaOsEvidence { + id: string; + memo_id: string; + title: string; + source_url: string; + excerpt: string; + source_type: string; + published_at: string | null; + retrieved_at: string; + metadata: Record; + created_at: string; + } + + export interface MediaOsMemo { + id: string; + slug: string; + title: string; + summary: string; + research_body: string; + declaration_evidence_ids: string[]; + status: "draft" | "review" | "approved" | "archived"; + revision: number; + approval_stage: 0 | 1 | 2; + stage1_approved_by: string | null; + stage1_approved_at: string | null; + stage2_approved_by: string | null; + stage2_approved_at: string | null; + approved_at: string | null; + created_by: string; + updated_by: string; + created_at: string; + updated_at: string; + } + + export interface MediaOsClaim { + text: string; + evidenceIds: string[]; + } + + export interface MediaOsArtifactContent { + slug: string; + title: string; + summary: string; + body: string[]; + claims: MediaOsClaim[]; + evidence: Array>; + callToAction: string; + deliveryMode: "internal-publish" | "human-controlled"; + metadata: Record; + } + + export interface MediaOsQualityIssue { + code: + | "UNKNOWN_EVIDENCE_ID" + | "UNSUPPORTED_CLAIM" + | "DECLARATION_MISMATCH" + | "MISSING_EVIDENCE" + | "INVALID_CHANNEL_CONTENT"; + severity: "error" | "warning"; + message: string; + path?: string; + } + + export interface MediaOsArtifact { + id: string; + memo_id: string; + channel: MediaOsChannel; + source_revision: number; + revision: number; + state: "draft" | "approved" | "scheduled" | "publishing" | "awaiting_human" | "published" | "stale" | "error"; + approval_stage: 0 | 1 | 2; + content: MediaOsArtifactContent; + quality_issues: MediaOsQualityIssue[]; + quality_error_count: number; + instruction_payload: Record | null; + instruction_signature: string | null; + instruction_expires_at: string | null; + scheduled_at: string | null; + external_url: string | null; + published_at: string | null; + created_at: string; + updated_at: string; + } + + export interface MediaOsLead { + id: string; + insight_slug: string; + email: string; + name: string | null; + company: string | null; + website: string | null; + message: string | null; + consent: boolean; + consented_at: string; + created_at: string; + } + + export interface MediaOsAuditEvent { + id: number; + entity_type: string; + entity_id: string; + action: string; + actor: string; + before_state: Record | null; + after_state: Record | null; + created_at: string; + } + + export interface MediaOsAdminSnapshot { + memos: MediaOsMemo[]; + selectedMemo: MediaOsMemo | null; + evidence: MediaOsEvidence[]; + artifacts: MediaOsArtifact[]; + leads: MediaOsLead[]; + audit: MediaOsAuditEvent[]; + analytics: Array<{ insight_slug: string; event_name: string; count: number }>; + serverTime: string; + } + ''', +) + +write( + CORE / "config.ts", + r''' + const read = (name: string): string => process.env[name]?.trim() ?? ""; + + export function requiredServerEnv(name: string): string { + const value = read(name); + if (!value) throw new Error(`Missing required server environment variable: ${name}`); + return value; + } + + export function supabaseServerConfig(): { url: string; serviceKey: string; anonKey: string } { + return { + url: requiredServerEnv("NEXT_PUBLIC_SUPABASE_URL").replace(/\/$/, ""), + serviceKey: requiredServerEnv("SUPABASE_SERVICE_ROLE_KEY"), + anonKey: requiredServerEnv("NEXT_PUBLIC_SUPABASE_ANON_KEY"), + }; + } + + export function mediaOsSigningSecret(): string { + return requiredServerEnv("MEDIA_OS_SIGNING_SECRET"); + } + + export function mediaOsIpHashSecret(): string { + return read("MEDIA_OS_IP_HASH_SECRET") || mediaOsSigningSecret(); + } + + export function mediaOsPublisherToken(): string { + return requiredServerEnv("MEDIA_OS_PUBLISHER_TOKEN"); + } + + export function turnstileSecret(): string | null { + return read("TURNSTILE_SECRET_KEY") || read("CLOUDFLARE_TURNSTILE_SECRET_KEY") || null; + } + + export function mediaOsAdminEmails(): Set { + const value = read("MEDIA_OS_ADMIN_EMAILS") || read("ADMIN_EMAILS") || read("ALLOWED_EMAILS"); + return new Set(value.split(",").map((email) => email.trim().toLowerCase()).filter(Boolean)); + } + ''', +) + +write( + CORE / "http.ts", + r''' + import { createHash, createHmac } from "node:crypto"; + import type { NextRequest } from "next/server"; + + export class MediaOsHttpError extends Error { + constructor(public readonly status: number, message: string, public readonly code = "MEDIA_OS_ERROR") { + super(message); + this.name = "MediaOsHttpError"; + } + } + + export async function readJsonObject(request: Request, maxBytes = 64_000): Promise> { + const declared = Number(request.headers.get("content-length") || 0); + if (Number.isFinite(declared) && declared > maxBytes) throw new MediaOsHttpError(413, "Request body is too large."); + const raw = await request.text(); + if (Buffer.byteLength(raw, "utf8") > maxBytes) throw new MediaOsHttpError(413, "Request body is too large."); + try { + const value: unknown = JSON.parse(raw || "{}"); + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("object required"); + return value as Record; + } catch { + throw new MediaOsHttpError(400, "A valid JSON object is required.", "INVALID_JSON"); + } + } + + export function nonEmptyString(value: unknown, name: string, max = 20_000): string { + if (typeof value !== "string") throw new MediaOsHttpError(400, `${name} must be a string.`, "INVALID_INPUT"); + const normalized = value.trim(); + if (!normalized || normalized.length > max) throw new MediaOsHttpError(400, `${name} is invalid.`, "INVALID_INPUT"); + return normalized; + } + + export function optionalString(value: unknown, max = 2_000): string | null { + if (value === null || value === undefined || value === "") return null; + if (typeof value !== "string") throw new MediaOsHttpError(400, "Invalid string value.", "INVALID_INPUT"); + const normalized = value.trim(); + if (normalized.length > max) throw new MediaOsHttpError(400, "String value is too long.", "INVALID_INPUT"); + return normalized || null; + } + + export function expectedRevision(value: unknown): number { + const revision = typeof value === "number" ? value : Number(value); + if (!Number.isInteger(revision) || revision < 1) throw new MediaOsHttpError(400, "A valid expected revision is required.", "INVALID_REVISION"); + return revision; + } + + export function safeSlug(input: string): string { + const slug = input.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120); + if (!slug) throw new MediaOsHttpError(400, "A URL-safe English slug is required.", "INVALID_SLUG"); + return slug; + } + + function privateIpv4(host: string): boolean { + const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host); + if (!match) return false; + const octets = match.slice(1).map(Number); + if (octets.some((part) => part > 255)) return true; + const [a, b] = octets; + return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224; + } + + export function safeExternalUrl(value: unknown, { allowHttp = false }: { allowHttp?: boolean } = {}): string { + const raw = nonEmptyString(value, "URL", 2_048); + let parsed: URL; + try { parsed = new URL(raw); } catch { throw new MediaOsHttpError(400, "A valid absolute URL is required.", "INVALID_URL"); } + if (parsed.protocol !== "https:" && !(allowHttp && parsed.protocol === "http:")) throw new MediaOsHttpError(400, "Only secure public URLs are accepted.", "INVALID_URL"); + const host = parsed.hostname.toLowerCase().replace(/\.$/, ""); + if (!host || host === "localhost" || host.endsWith(".local") || host === "::1" || host.startsWith("fe80:") || privateIpv4(host)) { + throw new MediaOsHttpError(400, "Private or local URLs are not accepted.", "INVALID_URL"); + } + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); + } + + export function assertSameOrigin(request: NextRequest): void { + if (request.headers.get("authorization")?.startsWith("Bearer ")) return; + const origin = request.headers.get("origin"); + if (!origin) throw new MediaOsHttpError(403, "Origin header is required.", "CSRF_BLOCKED"); + const forwardedHost = request.headers.get("x-forwarded-host") || request.headers.get("host"); + const forwardedProto = request.headers.get("x-forwarded-proto") || request.nextUrl.protocol.replace(":", ""); + if (!forwardedHost || origin !== `${forwardedProto}://${forwardedHost}`) throw new MediaOsHttpError(403, "Cross-origin request blocked.", "CSRF_BLOCKED"); + } + + export function clientIp(request: Request): string { + const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); + return forwarded || request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || "unknown"; + } + + export function privacyHash(value: string, secret: string): string { + return createHmac("sha256", secret).update(value).digest("hex"); + } + + export function sessionHash(value: string): string { + return createHash("sha256").update(value).digest("hex"); + } + ''', +) + +write( + CORE / "auth.ts", + r''' + import type { NextRequest } from "next/server"; + import { mediaOsAdminEmails, supabaseServerConfig } from "./config"; + import { MediaOsHttpError } from "./http"; + + export interface MediaOsAdminUser { id: string; email: string; role: string; } + + function decodeCookieValue(raw: string): unknown { + let value = raw; + try { value = decodeURIComponent(value); } catch { /* cookie may already be decoded */ } + if (value.startsWith("base64-")) { + const encoded = value.slice(7).replace(/-/g, "+").replace(/_/g, "/"); + try { value = Buffer.from(encoded, "base64").toString("utf8"); } catch { return null; } + } + try { return JSON.parse(value); } catch { return value; } + } + + function findAccessToken(value: unknown): string | null { + if (!value) return null; + if (typeof value === "object") { + const record = value as Record; + if (typeof record.access_token === "string") return record.access_token; + if (Array.isArray(value)) { + for (const item of value) { const token = findAccessToken(item); if (token) return token; } + } else { + for (const item of Object.values(record)) { const token = findAccessToken(item); if (token) return token; } + } + } + return null; + } + + function tokenFromCookies(request: NextRequest): string | null { + const candidates = request.cookies.getAll().filter((cookie) => /auth-token(?:\.\d+)?$/.test(cookie.name)); + const groups = new Map>(); + for (const cookie of candidates) { + const base = cookie.name.replace(/\.\d+$/, ""); + groups.set(base, [...(groups.get(base) || []), cookie]); + } + for (const values of groups.values()) { + values.sort((a, b) => { + const ai = Number(a.name.match(/\.(\d+)$/)?.[1] ?? -1); + const bi = Number(b.name.match(/\.(\d+)$/)?.[1] ?? -1); + return ai - bi; + }); + const token = findAccessToken(decodeCookieValue(values.map((entry) => entry.value).join(""))); + if (token) return token; + } + return null; + } + + export async function requireMediaOsAdmin(request: NextRequest): Promise { + const bearer = request.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim(); + const accessToken = bearer || tokenFromCookies(request); + if (!accessToken) throw new MediaOsHttpError(401, "Authentication required.", "UNAUTHENTICATED"); + const { url, anonKey } = supabaseServerConfig(); + const response = await fetch(`${url}/auth/v1/user`, { + headers: { apikey: anonKey, authorization: `Bearer ${accessToken}` }, + cache: "no-store", + }); + if (!response.ok) throw new MediaOsHttpError(401, "Session is invalid or expired.", "UNAUTHENTICATED"); + const user = (await response.json()) as { + id?: string; email?: string; app_metadata?: Record; user_metadata?: Record; + }; + const email = user.email?.toLowerCase() || ""; + const roleValue = user.app_metadata?.role ?? user.user_metadata?.role ?? ""; + const role = typeof roleValue === "string" ? roleValue.toLowerCase() : ""; + const allowedRoles = new Set(["admin", "owner", "staff", "editor", "internal"]); + const emails = mediaOsAdminEmails(); + if (!user.id || !email || (!allowedRoles.has(role) && !emails.has(email))) { + throw new MediaOsHttpError(403, "Media OS administrator access is required.", "FORBIDDEN"); + } + return { id: user.id, email, role: role || "allowlisted" }; + } + ''', +) + +write( + CORE / "signatures.ts", + r''' + import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; + + export interface SignedInstructionPayload { + version: 1; + action: "human_publish" | "human_outreach"; + artifactId: string; + channel: string; + revision: number; + expiresAt: string; + nonce: string; + deliveryMode: "human-controlled"; + } + + function canonical(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`; + const record = value as Record; + return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonical(record[key])}`).join(",")}}`; + } + + export function signInstruction( + input: Omit, + secret: string, + ): { payload: SignedInstructionPayload; signature: string } { + const payload: SignedInstructionPayload = { ...input, version: 1, nonce: randomUUID(), deliveryMode: "human-controlled" }; + const signature = createHmac("sha256", secret).update(canonical(payload)).digest("base64url"); + return { payload, signature }; + } + + export function verifyInstruction(payload: SignedInstructionPayload, signature: string, secret: string, now = Date.now()): boolean { + if (payload.version !== 1 || payload.deliveryMode !== "human-controlled" || Date.parse(payload.expiresAt) <= now) return false; + const expected = createHmac("sha256", secret).update(canonical(payload)).digest(); + let received: Buffer; + try { received = Buffer.from(signature, "base64url"); } catch { return false; } + return received.length === expected.length && timingSafeEqual(received, expected); + } + + export function timingSafeTokenEqual(received: string, expectedValue: string): boolean { + const left = Buffer.from(received); + const right = Buffer.from(expectedValue); + return left.length === right.length && timingSafeEqual(left, right); + } + ''', +) + +write( + CORE / "quality.ts", + r''' + import type { MediaOsArtifactContent, MediaOsChannel, MediaOsEvidence, MediaOsQualityIssue } from "./types"; + + const EVIDENCE_ID = /^EV-[A-Z0-9][A-Z0-9-]{2,63}$/; + const MARKER = /\[(EV-[A-Z0-9][A-Z0-9-]{2,63})\]/g; + const FACTUAL_SIGNAL = /(?:\b\d+(?:[.,]\d+)?%?\b|[$€£¥]\s?\d|\b(?:million|billion|trillion|increase|decrease|grew|growth|market size|ranking)\b)/i; + + export function extractEvidenceMarkers(text: string): string[] { + return [...text.matchAll(MARKER)].map((match) => match[1]); + } + + export function validEvidenceId(value: string): boolean { return EVIDENCE_ID.test(value); } + + export function validateArtifactQuality(input: { + channel: MediaOsChannel; + content: MediaOsArtifactContent; + evidence: MediaOsEvidence[]; + declarationEvidenceIds: string[]; + }): MediaOsQualityIssue[] { + const { content, evidence, declarationEvidenceIds, channel } = input; + const issues: MediaOsQualityIssue[] = []; + const known = new Set(evidence.map((item) => item.id)); + const used = new Set(); + for (const [index, claim] of content.claims.entries()) { + if (!claim.text.trim()) issues.push({ code: "INVALID_CHANNEL_CONTENT", severity: "error", message: "Claim text is required.", path: `claims.${index}` }); + if (claim.evidenceIds.length === 0 && FACTUAL_SIGNAL.test(claim.text)) { + issues.push({ code: "UNSUPPORTED_CLAIM", severity: "error", message: "A factual or numeric claim has no evidence ID.", path: `claims.${index}` }); + } + for (const id of claim.evidenceIds) { + used.add(id); + if (!known.has(id)) issues.push({ code: "UNKNOWN_EVIDENCE_ID", severity: "error", message: `Unknown evidence ID: ${id}`, path: `claims.${index}` }); + } + } + for (const paragraph of content.body) for (const id of extractEvidenceMarkers(paragraph)) used.add(id); + for (const id of used) if (!known.has(id)) issues.push({ code: "UNKNOWN_EVIDENCE_ID", severity: "error", message: `Unknown evidence marker: ${id}` }); + for (const id of declarationEvidenceIds) { + if (!known.has(id)) issues.push({ code: "DECLARATION_MISMATCH", severity: "error", message: `Declaration references evidence not attached to the memo: ${id}` }); + } + if (declarationEvidenceIds.length > 0 && !declarationEvidenceIds.some((id) => used.has(id))) { + issues.push({ code: "DECLARATION_MISMATCH", severity: "error", message: "Generated content does not use any declared evidence ID." }); + } + if (known.size === 0) issues.push({ code: "MISSING_EVIDENCE", severity: "error", message: "At least one evidence record is required." }); + if (!content.title.trim() || !content.summary.trim() || content.body.length === 0) { + issues.push({ code: "INVALID_CHANNEL_CONTENT", severity: "error", message: `${channel} content is incomplete.` }); + } + if (channel === "pseo" && content.deliveryMode !== "internal-publish") { + issues.push({ code: "INVALID_CHANNEL_CONTENT", severity: "error", message: "pSEO must use internal publishing." }); + } + if (channel !== "pseo" && content.deliveryMode !== "human-controlled") { + issues.push({ code: "INVALID_CHANNEL_CONTENT", severity: "error", message: "External channels must remain human-controlled." }); + } + return issues; + } + ''', +) + +write( + CORE / "generator.ts", + r''' + import type { MediaOsArtifactContent, MediaOsChannel, MediaOsEvidence, MediaOsMemo } from "./types"; + + function claim(text: string, evidence: MediaOsEvidence[]) { + return { text, evidenceIds: evidence.slice(0, 3).map((item) => item.id) }; + } + + function marker(evidence: MediaOsEvidence[]): string { + return evidence.slice(0, 3).map((item) => `[${item.id}]`).join(" "); + } + + function base(memo: MediaOsMemo, evidence: MediaOsEvidence[], channel: MediaOsChannel): MediaOsArtifactContent { + const citations = marker(evidence); + const factual = memo.summary || memo.research_body.split(/\n+/)[0] || memo.title; + const common = { + slug: memo.slug, + evidence: evidence.map(({ id, title, source_url, excerpt }) => ({ id, title, source_url, excerpt })), + claims: [claim(factual, evidence)], + callToAction: "Discuss a controlled Japan market-entry validation with Paradigm.", + }; + if (channel === "pseo") return { + ...common, + title: memo.title, + summary: memo.summary, + body: [memo.summary, `${factual} ${citations}`.trim(), memo.research_body, "Evidence references are listed below and should be reviewed before a commercial decision."], + deliveryMode: "internal-publish", + metadata: { channel, sourceRevision: memo.revision, locale: "en", evidenceMarkers: citations }, + }; + if (channel === "youtube") return { + ...common, + title: `${memo.title} — YouTube production package`, + summary: memo.summary, + body: ["00:00 — Context", `00:30 — Core finding ${citations}`, "02:00 — Japan-entry implications", "04:30 — Evidence and limitations", "06:00 — Human-reviewed next step"], + deliveryMode: "human-controlled", + metadata: { channel, sourceRevision: memo.revision, timecodes: ["00:00", "00:30", "02:00", "04:30", "06:00"] }, + }; + if (channel === "x") return { + ...common, + title: `${memo.title} — X thread`, + summary: memo.summary, + body: [`1/ ${memo.title}`, `2/ ${factual} ${citations}`.trim(), "3/ Market-entry decisions require localized validation, not automated outreach.", "4/ Sources and limitations are attached for human review."], + deliveryMode: "human-controlled", + metadata: { channel, sourceRevision: memo.revision, format: "thread" }, + }; + if (channel === "linkedin") return { + ...common, + title: `${memo.title} — LinkedIn draft`, + summary: memo.summary, + body: [memo.summary, `${factual} ${citations}`.trim(), "The operational takeaway is to validate positioning, compliance, distribution, and demand before scaling.", "This draft requires a human editor and manual publication."], + deliveryMode: "human-controlled", + metadata: { channel, sourceRevision: memo.revision, format: "post" }, + }; + return { + ...common, + title: `${memo.title} — commercial brief`, + summary: memo.summary, + body: ["Opportunity summary", `${factual} ${citations}`.trim(), "Qualification questions", "Risks and evidence limitations", "Human-controlled consultation handoff; no automated messaging or engagement."], + deliveryMode: "human-controlled", + metadata: { channel, sourceRevision: memo.revision, automatedOutreach: false }, + }; + } + + export function generateAllArtifacts(memo: MediaOsMemo, evidence: MediaOsEvidence[]): Array<{ channel: MediaOsChannel; content: MediaOsArtifactContent }> { + return (["pseo", "youtube", "x", "linkedin", "commercial"] as const).map((channel) => ({ channel, content: base(memo, evidence, channel) })); + } + ''', +) + +write( + CORE / "store.ts", + r''' + import { randomUUID } from "node:crypto"; + import { mediaOsSigningSecret, supabaseServerConfig } from "./config"; + import { generateAllArtifacts } from "./generator"; + import { MediaOsHttpError, safeExternalUrl, safeSlug } from "./http"; + import { validateArtifactQuality } from "./quality"; + import { signInstruction } from "./signatures"; + import type { JsonValue, MediaOsAdminSnapshot, MediaOsArtifact, MediaOsEvidence, MediaOsMemo } from "./types"; + + type Row = Record; + + async function serviceRequest(path: string, init: RequestInit = {}): Promise { + const { url, serviceKey } = supabaseServerConfig(); + const response = await fetch(`${url}/rest/v1/${path}`, { + ...init, + cache: "no-store", + headers: { + apikey: serviceKey, + authorization: `Bearer ${serviceKey}`, + "content-type": "application/json", + ...(init.headers || {}), + }, + }); + if (!response.ok) { + const detail = await response.text(); + const conflict = response.status === 409 || /stale_revision|revision_conflict/i.test(detail); + throw new MediaOsHttpError(conflict ? 409 : response.status, conflict ? "The record changed. Reload the latest revision before retrying." : `Media OS storage error: ${detail.slice(0, 500)}`, conflict ? "REVISION_CONFLICT" : "STORE_ERROR"); + } + if (response.status === 204) return undefined as T; + const text = await response.text(); + return (text ? JSON.parse(text) : null) as T; + } + + async function rpc(name: string, args: Record): Promise { + return serviceRequest(`rpc/${name}`, { method: "POST", body: JSON.stringify(args) }); + } + + async function audit(entityType: string, entityId: string, action: string, actor: string, before: unknown, after: unknown): Promise { + await serviceRequest("media_os_audit_log", { + method: "POST", + headers: { prefer: "return=minimal" }, + body: JSON.stringify({ entity_type: entityType, entity_id: entityId, action, actor, before_state: before, after_state: after }), + }); + } + + export async function adminSnapshot(selectedMemoId?: string | null): Promise { + const memos = await serviceRequest("media_os_memos?select=*&order=updated_at.desc&limit=100"); + const selectedMemo = (selectedMemoId ? memos.find((memo) => memo.id === selectedMemoId) : memos[0]) || null; + const memoFilter = selectedMemo ? `&memo_id=eq.${encodeURIComponent(selectedMemo.id)}` : "&memo_id=is.null"; + const [evidence, artifacts, leads, auditEvents, analytics] = await Promise.all([ + serviceRequest(`media_os_evidence?select=*${memoFilter}&order=created_at.asc`), + serviceRequest(`media_os_artifacts?select=*${memoFilter}&order=channel.asc,created_at.desc`), + serviceRequest("media_os_leads?select=id,insight_slug,email,name,company,website,message,consent,consented_at,created_at&order=created_at.desc&limit=100"), + serviceRequest(selectedMemo ? `media_os_audit_log?select=*&entity_id=eq.${encodeURIComponent(selectedMemo.id)}&order=created_at.desc&limit=200` : "media_os_audit_log?select=*&order=created_at.desc&limit=100"), + rpc("media_os_analytics_summary", {}), + ]); + return { memos, selectedMemo, evidence, artifacts, leads, audit: auditEvents, analytics, serverTime: new Date().toISOString() }; + } + + export async function createMemo(input: Row, actor: string): Promise { + const title = String(input.title || "").trim(); + const summary = String(input.summary || "").trim(); + if (!title || !summary) throw new MediaOsHttpError(400, "Title and summary are required."); + const slug = safeSlug(String(input.slug || title)); + const rows = await serviceRequest("media_os_memos", { + method: "POST", headers: { prefer: "return=representation" }, + body: JSON.stringify({ slug, title: title.slice(0, 240), summary: summary.slice(0, 2_000), research_body: String(input.researchBody || "").slice(0, 80_000), declaration_evidence_ids: [], created_by: actor, updated_by: actor }), + }); + const memo = rows[0]; + await audit("memo", memo.id, "created", actor, null, memo); + return memo; + } + + export async function updateMemo(input: Row, actor: string): Promise { + const id = String(input.id || ""); + const revision = Number(input.expectedRevision); + if (!id || !Number.isInteger(revision)) throw new MediaOsHttpError(400, "Memo ID and expected revision are required."); + const beforeRows = await serviceRequest(`media_os_memos?select=*&id=eq.${encodeURIComponent(id)}&limit=1`); + const body: Row = { updated_by: actor }; + if (typeof input.title === "string") body.title = input.title.trim().slice(0, 240); + if (typeof input.summary === "string") body.summary = input.summary.trim().slice(0, 2_000); + if (typeof input.researchBody === "string") body.research_body = input.researchBody.slice(0, 80_000); + if (typeof input.slug === "string") body.slug = safeSlug(input.slug); + if (Array.isArray(input.declarationEvidenceIds)) body.declaration_evidence_ids = input.declarationEvidenceIds.filter((value): value is string => typeof value === "string"); + const rows = await serviceRequest(`media_os_memos?id=eq.${encodeURIComponent(id)}&revision=eq.${revision}`, { method: "PATCH", headers: { prefer: "return=representation" }, body: JSON.stringify(body) }); + if (!rows[0]) throw new MediaOsHttpError(409, "The memo changed. Reload before saving.", "REVISION_CONFLICT"); + await audit("memo", id, "updated", actor, beforeRows[0] || null, rows[0]); + return rows[0]; + } + + export async function addEvidence(input: Row, actor: string): Promise { + const memoId = String(input.memoId || ""); + const title = String(input.title || "").trim(); + const excerpt = String(input.excerpt || "").trim(); + const sourceUrl = safeExternalUrl(input.sourceUrl, { allowHttp: true }); + if (!memoId || !title || !excerpt) throw new MediaOsHttpError(400, "Memo, evidence title, URL, and excerpt are required."); + const id = `EV-${randomUUID().replace(/-/g, "").slice(0, 12).toUpperCase()}`; + const rows = await serviceRequest("media_os_evidence", { + method: "POST", headers: { prefer: "return=representation" }, + body: JSON.stringify({ id, memo_id: memoId, title: title.slice(0, 300), source_url: sourceUrl, excerpt: excerpt.slice(0, 8_000), source_type: String(input.sourceType || "web").slice(0, 60), published_at: input.publishedAt || null, retrieved_at: new Date().toISOString(), metadata: {}, created_by: actor }), + }); + await audit("memo", memoId, "evidence_added", actor, null, { evidenceId: id, sourceUrl }); + return rows[0]; + } + + export async function removeEvidence(input: Row, actor: string): Promise { + const id = String(input.evidenceId || ""); + const memoId = String(input.memoId || ""); + if (!id || !memoId) throw new MediaOsHttpError(400, "Evidence and memo IDs are required."); + await serviceRequest(`media_os_evidence?id=eq.${encodeURIComponent(id)}&memo_id=eq.${encodeURIComponent(memoId)}`, { method: "DELETE", headers: { prefer: "return=minimal" } }); + await audit("memo", memoId, "evidence_removed", actor, { evidenceId: id }, null); + } + + export async function approveMemo(input: Row, actor: string): Promise { + return rpc("media_os_approve_memo", { p_memo_id: String(input.memoId || ""), p_expected_revision: Number(input.expectedRevision), p_stage: Number(input.stage), p_actor: actor }); + } + + export async function generateArtifacts(input: Row, actor: string): Promise { + const memoId = String(input.memoId || ""); + const expected = Number(input.expectedRevision); + const snapshot = await rpc<{ memo: MediaOsMemo; evidence: MediaOsEvidence[] }>("media_os_generation_snapshot", { p_memo_id: memoId, p_expected_revision: expected }); + const generated = generateAllArtifacts(snapshot.memo, snapshot.evidence).map(({ channel, content }) => { + const quality = validateArtifactQuality({ channel, content, evidence: snapshot.evidence, declarationEvidenceIds: snapshot.memo.declaration_evidence_ids }); + const errors = quality.filter((issue) => issue.severity === "error").length; + const external = channel !== "pseo"; + const signed = external ? signInstruction({ action: channel === "commercial" ? "human_outreach" : "human_publish", artifactId: `${memoId}:${channel}:${expected}`, channel, revision: expected, expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString() }, mediaOsSigningSecret()) : null; + return { channel, content, quality_issues: quality, quality_error_count: errors, instruction_payload: signed?.payload || null, instruction_signature: signed?.signature || null, instruction_expires_at: signed?.payload.expiresAt || null }; + }); + const blocking = generated.flatMap((artifact) => artifact.quality_issues).filter((issue) => issue.severity === "error"); + if (blocking.length) throw new MediaOsHttpError(422, `Quality gate blocked generation: ${blocking.map((issue) => issue.message).join("; ")}`, "QUALITY_GATE_FAILED"); + return rpc("media_os_store_artifacts", { p_memo_id: memoId, p_expected_revision: expected, p_actor: actor, p_artifacts: generated }); + } + + export async function approveArtifact(input: Row, actor: string): Promise { + return rpc("media_os_approve_artifact", { p_artifact_id: String(input.artifactId || ""), p_expected_revision: Number(input.expectedRevision), p_stage: Number(input.stage), p_actor: actor }); + } + + export async function scheduleArtifact(input: Row, actor: string): Promise { + const scheduledAt = new Date(String(input.scheduledAt || "")); + if (Number.isNaN(scheduledAt.valueOf())) throw new MediaOsHttpError(400, "A valid schedule time is required."); + return rpc("media_os_schedule_artifact", { p_artifact_id: String(input.artifactId || ""), p_expected_revision: Number(input.expectedRevision), p_scheduled_at: scheduledAt.toISOString(), p_actor: actor }); + } + + export async function retryArtifact(input: Row, actor: string): Promise { + return rpc("media_os_retry_artifact", { p_artifact_id: String(input.artifactId || ""), p_expected_revision: Number(input.expectedRevision), p_actor: actor }); + } + + export async function confirmExternalPublication(input: Row, actor: string): Promise { + return rpc("media_os_confirm_external_publication", { p_artifact_id: String(input.artifactId || ""), p_expected_revision: Number(input.expectedRevision), p_external_url: safeExternalUrl(input.externalUrl), p_actor: actor }); + } + + export async function publicInsights(): Promise> { + return serviceRequest("media_os_artifacts?select=public_slug:content->>slug,public_title:content->>title,public_summary:content->>summary,published_at&channel=eq.pseo&state=eq.published&order=published_at.desc&limit=100").then((rows: unknown) => (rows as Array>).map((row) => ({ slug: row.public_slug, title: row.public_title, summary: row.public_summary, published_at: row.published_at }))); + } + + export async function publicInsight(slug: string): Promise { + const safe = safeSlug(slug); + const rows = await serviceRequest(`media_os_artifacts?select=*&channel=eq.pseo&state=eq.published&content->>slug=eq.${encodeURIComponent(safe)}&limit=1`); + return rows[0] || null; + } + + export async function acceptLead(input: Row): Promise<{ id: string }> { + return rpc("media_os_accept_lead", input); + } + + export async function recordAnalytics(input: Row): Promise { + await rpc("media_os_record_analytics", input); + } + + export async function publisherHealth(): Promise> { + return rpc("media_os_health", {}); + } + + export async function claimPublishJob(workerId: string): Promise { + return rpc("media_os_claim_publish_job", { p_worker_id: workerId }); + } + + export async function completePublishJob(jobId: string, claimToken: string, actor: string): Promise { + return rpc("media_os_complete_publish_job", { p_job_id: jobId, p_claim_token: claimToken, p_actor: actor }); + } + + export async function failPublishJob(jobId: string, claimToken: string, error: string): Promise { + await rpc("media_os_fail_publish_job", { p_job_id: jobId, p_claim_token: claimToken, p_error: error.slice(0, 2_000) }); + } + ''', +) + +# Route imports are computed so the bootstrap works with either /app or /src/app. +admin_route = APP / "api/media-os/admin/route.ts" +store_import = rel_import(admin_route, CORE / "store.ts") +auth_import = rel_import(admin_route, CORE / "auth.ts") +http_import = rel_import(admin_route, CORE / "http.ts") +write( + admin_route, + f''' + import {{ NextRequest, NextResponse }} from "next/server"; + import {{ requireMediaOsAdmin }} from "{auth_import}"; + import {{ assertSameOrigin, MediaOsHttpError, readJsonObject }} from "{http_import}"; + import {{ adminSnapshot, addEvidence, approveArtifact, approveMemo, confirmExternalPublication, createMemo, generateArtifacts, removeEvidence, retryArtifact, scheduleArtifact, updateMemo }} from "{store_import}"; + + export const runtime = "nodejs"; + export const dynamic = "force-dynamic"; + + function failure(error: unknown): NextResponse {{ + if (error instanceof MediaOsHttpError) return NextResponse.json({{ error: error.message, code: error.code }}, {{ status: error.status }}); + console.error("Media OS admin API error", error); + return NextResponse.json({{ error: "Media OS request failed.", code: "INTERNAL_ERROR" }}, {{ status: 500 }}); + }} + + export async function GET(request: NextRequest): Promise {{ + try {{ + await requireMediaOsAdmin(request); + return NextResponse.json(await adminSnapshot(request.nextUrl.searchParams.get("memo")), {{ headers: {{ "cache-control": "no-store" }} }}); + }} catch (error) {{ return failure(error); }} + }} + + export async function POST(request: NextRequest): Promise {{ + try {{ + assertSameOrigin(request); + const user = await requireMediaOsAdmin(request); + const input = await readJsonObject(request); + const action = String(input.action || ""); + const actor = `${{user.id}}:${{user.email}}`; + let data: unknown; + switch (action) {{ + case "createMemo": data = await createMemo(input, actor); break; + case "updateMemo": data = await updateMemo(input, actor); break; + case "addEvidence": data = await addEvidence(input, actor); break; + case "removeEvidence": data = await removeEvidence(input, actor); break; + case "approveMemo": data = await approveMemo(input, actor); break; + case "generateArtifacts": data = await generateArtifacts(input, actor); break; + case "approveArtifact": data = await approveArtifact(input, actor); break; + case "scheduleArtifact": data = await scheduleArtifact(input, actor); break; + case "retryArtifact": data = await retryArtifact(input, actor); break; + case "confirmExternalPublication": data = await confirmExternalPublication(input, actor); break; + default: throw new MediaOsHttpError(400, "Unknown Media OS action.", "UNKNOWN_ACTION"); + }} + return NextResponse.json({{ ok: true, data }}); + }} catch (error) {{ return failure(error); }} + }} + ''', +) + +lead_route = APP / "api/media-os/leads/route.ts" +lead_store_import = rel_import(lead_route, CORE / "store.ts") +lead_config_import = rel_import(lead_route, CORE / "config.ts") +lead_http_import = rel_import(lead_route, CORE / "http.ts") +write( + lead_route, + f''' + import {{ NextRequest, NextResponse }} from "next/server"; + import {{ mediaOsIpHashSecret, turnstileSecret }} from "{lead_config_import}"; + import {{ clientIp, MediaOsHttpError, optionalString, privacyHash, readJsonObject, safeExternalUrl, safeSlug }} from "{lead_http_import}"; + import {{ acceptLead }} from "{lead_store_import}"; + + export const runtime = "nodejs"; + export const dynamic = "force-dynamic"; + + async function verifyTurnstile(token: string, ip: string): Promise {{ + const secret = turnstileSecret(); + if (!secret) return true; + if (!token) return false; + const body = new URLSearchParams({{ secret, response: token, remoteip: ip }}); + const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {{ method: "POST", body, cache: "no-store" }}); + if (!response.ok) return false; + return Boolean(((await response.json()) as {{ success?: boolean }}).success); + }} + + export async function POST(request: NextRequest): Promise {{ + try {{ + const input = await readJsonObject(request, 24_000); + if (typeof input.website === "string" && input.website.trim()) return NextResponse.json({{ ok: true }}, {{ status: 202 }}); + if (input.consent !== true) throw new MediaOsHttpError(400, "Explicit consent is required.", "CONSENT_REQUIRED"); + const email = String(input.email || "").trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) throw new MediaOsHttpError(400, "A valid email address is required.", "INVALID_EMAIL"); + const ip = clientIp(request); + if (!(await verifyTurnstile(String(input.turnstileToken || ""), ip))) throw new MediaOsHttpError(400, "Human verification failed.", "TURNSTILE_FAILED"); + const companyWebsite = input.companyWebsite ? safeExternalUrl(input.companyWebsite) : null; + const result = await acceptLead({{ + p_insight_slug: safeSlug(String(input.insightSlug || "general")), p_email: email, + p_name: optionalString(input.name, 160), p_company: optionalString(input.company, 240), p_website: companyWebsite, + p_message: optionalString(input.message, 4_000), p_consent: true, + p_ip_hash: privacyHash(ip, mediaOsIpHashSecret()), p_user_agent_hash: privacyHash(request.headers.get("user-agent") || "unknown", mediaOsIpHashSecret()), + }}); + return NextResponse.json({{ ok: true, id: result.id }}, {{ status: 201 }}); + }} catch (error) {{ + if (error instanceof MediaOsHttpError) return NextResponse.json({{ error: error.message, code: error.code }}, {{ status: error.status }}); + const message = error instanceof Error ? error.message : ""; + if (/rate_limited/i.test(message)) return NextResponse.json({{ error: "Too many requests." }}, {{ status: 429 }}); + console.error("Media OS lead error", error); + return NextResponse.json({{ error: "Unable to submit the request." }}, {{ status: 500 }}); + }} + }} + ''', +) + +analytics_route = APP / "api/media-os/analytics/route.ts" +analytics_store_import = rel_import(analytics_route, CORE / "store.ts") +analytics_config_import = rel_import(analytics_route, CORE / "config.ts") +analytics_http_import = rel_import(analytics_route, CORE / "http.ts") +write( + analytics_route, + f''' + import {{ NextRequest, NextResponse }} from "next/server"; + import {{ mediaOsIpHashSecret }} from "{analytics_config_import}"; + import {{ clientIp, privacyHash, readJsonObject, safeSlug }} from "{analytics_http_import}"; + import {{ recordAnalytics }} from "{analytics_store_import}"; + + export const runtime = "nodejs"; + export async function POST(request: NextRequest): Promise {{ + try {{ + const input = await readJsonObject(request, 8_000); + const eventName = String(input.eventName || "page_view"); + if (!new Set(["page_view", "lead_open", "lead_submit"]).has(eventName)) return NextResponse.json({{ error: "Invalid event." }}, {{ status: 400 }}); + await recordAnalytics({{ p_insight_slug: safeSlug(String(input.insightSlug || "general")), p_event_name: eventName, p_session_hash: privacyHash(String(input.sessionId || "anonymous"), mediaOsIpHashSecret()), p_ip_hash: privacyHash(clientIp(request), mediaOsIpHashSecret()) }}); + return new NextResponse(null, {{ status: 204 }}); + }} catch {{ return new NextResponse(null, {{ status: 204 }}); }} + }} + ''', +) + +health_route = APP / "api/media-os/publisher/health/route.ts" +health_store_import = rel_import(health_route, CORE / "store.ts") +health_config_import = rel_import(health_route, CORE / "config.ts") +health_sig_import = rel_import(health_route, CORE / "signatures.ts") +write( + health_route, + f''' + import {{ NextRequest, NextResponse }} from "next/server"; + import {{ mediaOsPublisherToken }} from "{health_config_import}"; + import {{ timingSafeTokenEqual }} from "{health_sig_import}"; + import {{ publisherHealth }} from "{health_store_import}"; + + export const runtime = "nodejs"; + export const dynamic = "force-dynamic"; + export async function GET(request: NextRequest): Promise {{ + const received = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") || ""; + let expected = ""; + try {{ expected = mediaOsPublisherToken(); }} catch {{ return NextResponse.json({{ ok: false, configured: false }}, {{ status: 503 }}); }} + if (!timingSafeTokenEqual(received, expected)) return NextResponse.json({{ error: "Unauthorized" }}, {{ status: 401 }}); + try {{ return NextResponse.json({{ ok: true, configured: true, database: await publisherHealth(), time: new Date().toISOString() }}); }} + catch {{ return NextResponse.json({{ ok: false, configured: true }}, {{ status: 503 }}); }} + }} + ''', +) + +publisher_route = APP / "api/media-os/publisher/run/route.ts" +pub_store_import = rel_import(publisher_route, CORE / "store.ts") +pub_config_import = rel_import(publisher_route, CORE / "config.ts") +pub_sig_import = rel_import(publisher_route, CORE / "signatures.ts") +write( + publisher_route, + f''' + import {{ randomUUID }} from "node:crypto"; + import {{ NextRequest, NextResponse }} from "next/server"; + import {{ mediaOsPublisherToken }} from "{pub_config_import}"; + import {{ timingSafeTokenEqual }} from "{pub_sig_import}"; + import {{ claimPublishJob, completePublishJob, failPublishJob }} from "{pub_store_import}"; + + export const runtime = "nodejs"; + export const dynamic = "force-dynamic"; + export async function POST(request: NextRequest): Promise {{ + const received = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "") || ""; + let expected = ""; + try {{ expected = mediaOsPublisherToken(); }} catch {{ return NextResponse.json({{ error: "Publisher is not configured." }}, {{ status: 503 }}); }} + if (!timingSafeTokenEqual(received, expected)) return NextResponse.json({{ error: "Unauthorized" }}, {{ status: 401 }}); + const worker = `publisher:${{randomUUID()}}`; + const job = await claimPublishJob(worker); + if (!job) return new NextResponse(null, {{ status: 204 }}); + const id = String(job.id || ""); + const token = String(job.claim_token || ""); + try {{ + const result = await completePublishJob(id, token, worker); + return NextResponse.json({{ ok: true, result, externalActionsAutomated: false }}); + }} catch (error) {{ + await failPublishJob(id, token, error instanceof Error ? error.message : "Unknown publisher error"); + return NextResponse.json({{ error: "Publish job failed." }}, {{ status: 500 }}); + }} + }} + ''', +) + +write(CORE / "version.ts", 'export const MEDIA_OS_SCHEMA_VERSION = 1 as const;\n') + +# UI and migrations are added in the second half below. diff --git a/.github/scripts/bootstrap_media_os_part2_db.py b/.github/scripts/bootstrap_media_os_part2_db.py new file mode 100644 index 000000000..b457053a4 --- /dev/null +++ b/.github/scripts/bootstrap_media_os_part2_db.py @@ -0,0 +1,741 @@ +from __future__ import annotations + +import json +import os +from pathlib import Path +from textwrap import dedent + +ROOT = Path.cwd() +LIB = Path("src/lib") if (ROOT / "src").is_dir() else Path("lib") +CORE = LIB / "media-os" +MIGRATIONS = Path("supabase/migrations") + + +def write_if_missing(path: Path, content: str) -> None: + target = ROOT / path + if target.exists(): + print(f"Preserving existing file: {path}") + return + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(dedent(content).lstrip(), encoding="utf-8") + print(f"Created: {path}") + + +package = {} +package_path = ROOT / "package.json" +if package_path.exists(): + package = json.loads(package_path.read_text(encoding="utf-8")) +deps = {**package.get("dependencies", {}), **package.get("devDependencies", {})} +if "vitest" in deps: + test_import = 'import { describe, expect, it } from "vitest";' + equal = "expect(actual).toEqual(expected);" + truthy = "expect(actual).toBe(true);" + falsy = "expect(actual).toBe(false);" +elif "jest" in deps or "@jest/globals" in deps: + test_import = 'import { describe, expect, it } from "@jest/globals";' + equal = "expect(actual).toEqual(expected);" + truthy = "expect(actual).toBe(true);" + falsy = "expect(actual).toBe(false);" +else: + test_import = 'import { strict as assert } from "node:assert";\nimport { describe, it } from "node:test";' + equal = "assert.deepEqual(actual, expected);" + truthy = "assert.equal(actual, true);" + falsy = "assert.equal(actual, false);" + +write_if_missing( + CORE / "quality.test.ts", + f''' + {test_import} + import {{ extractEvidenceMarkers, validateArtifactQuality }} from "./quality"; + import type {{ MediaOsArtifactContent, MediaOsEvidence }} from "./types"; + + const evidence: MediaOsEvidence[] = [{{ + id: "EV-OFFICIAL-2026", memo_id: "memo-1", title: "Official source", source_url: "https://example.com/report", + excerpt: "The source reports a measurable market result.", source_type: "official", published_at: "2026-07-01T00:00:00.000Z", + retrieved_at: "2026-07-31T00:00:00.000Z", metadata: {{}}, created_at: "2026-07-31T00:00:00.000Z", + }}]; + + function content(claimIds: string[]): MediaOsArtifactContent {{ + return {{ slug: "validated-topic", title: "Validated topic", summary: "Evidence-linked summary", body: ["The market grew 12% [EV-OFFICIAL-2026]."], + claims: [{{ text: "The market grew 12%.", evidenceIds: claimIds }}], evidence: evidence.map((item) => ({{ id: item.id, title: item.title, source_url: item.source_url, excerpt: item.excerpt }})), + callToAction: "Review the evidence", deliveryMode: "internal-publish", metadata: {{}}, }}; + }} + + describe("Media OS quality gates", () => {{ + it("extracts stable evidence markers", () => {{ const actual = extractEvidenceMarkers("A [EV-OFFICIAL-2026] B [EV-SECOND-2]"); const expected = ["EV-OFFICIAL-2026", "EV-SECOND-2"]; {equal} }}); + it("accepts supported numeric claims", () => {{ const actual = validateArtifactQuality({{ channel: "pseo", content: content([evidence[0].id]), evidence, declarationEvidenceIds: [evidence[0].id] }}).length === 0; {truthy} }}); + it("blocks unsupported numeric claims", () => {{ const actual = validateArtifactQuality({{ channel: "pseo", content: content([]), evidence, declarationEvidenceIds: [evidence[0].id] }}).some((issue) => issue.code === "UNSUPPORTED_CLAIM"); {truthy} }}); + it("blocks unknown evidence IDs", () => {{ const actual = validateArtifactQuality({{ channel: "pseo", content: content(["EV-UNKNOWN-1"]), evidence, declarationEvidenceIds: [evidence[0].id] }}).some((issue) => issue.code === "UNKNOWN_EVIDENCE_ID"); {truthy} }}); + }}); + ''', +) + +write_if_missing( + CORE / "signatures.test.ts", + f''' + {test_import} + import {{ signInstruction, verifyInstruction }} from "./signatures"; + + describe("Media OS signed human instructions", () => {{ + it("accepts an unmodified, unexpired instruction", () => {{ + const signed = signInstruction({{ action: "human_publish", artifactId: "artifact-1", channel: "linkedin", revision: 2, expiresAt: new Date(Date.now() + 60_000).toISOString() }}, "test-secret"); + const actual = verifyInstruction(signed.payload, signed.signature, "test-secret"); {truthy} + }}); + it("rejects tampering", () => {{ + const signed = signInstruction({{ action: "human_outreach", artifactId: "artifact-2", channel: "commercial", revision: 1, expiresAt: new Date(Date.now() + 60_000).toISOString() }}, "test-secret"); + const actual = verifyInstruction({{ ...signed.payload, revision: 99 }}, signed.signature, "test-secret"); {falsy} + }}); + it("rejects expired instructions", () => {{ + const signed = signInstruction({{ action: "human_publish", artifactId: "artifact-3", channel: "x", revision: 1, expiresAt: new Date(Date.now() - 1_000).toISOString() }}, "test-secret"); + const actual = verifyInstruction(signed.payload, signed.signature, "test-secret"); {falsy} + }}); + }}); + ''', +) + +write_if_missing( + MIGRATIONS / "202607310001_media_os_core.sql", + r''' + begin; + + create extension if not exists pgcrypto; + + create table if not exists public.media_os_memos ( + id uuid primary key default gen_random_uuid(), + slug text not null unique check (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'), + title text not null check (char_length(title) between 1 and 240), + summary text not null check (char_length(summary) between 1 and 2000), + research_body text not null default '' check (char_length(research_body) <= 80000), + declaration_evidence_ids text[] not null default '{}', + status text not null default 'draft' check (status in ('draft','review','approved','archived')), + revision integer not null default 1 check (revision > 0), + approval_stage smallint not null default 0 check (approval_stage between 0 and 2), + stage1_approved_by text, + stage1_approved_at timestamptz, + stage2_approved_by text, + stage2_approved_at timestamptz, + approved_at timestamptz, + created_by text not null, + updated_by text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ); + + create table if not exists public.media_os_evidence ( + id text primary key check (id ~ '^EV-[A-Z0-9][A-Z0-9-]{2,63}$'), + memo_id uuid not null references public.media_os_memos(id) on delete cascade, + title text not null check (char_length(title) between 1 and 300), + source_url text not null check (source_url ~ '^https?://'), + excerpt text not null check (char_length(excerpt) between 1 and 8000), + source_type text not null default 'web' check (char_length(source_type) between 1 and 60), + published_at timestamptz, + retrieved_at timestamptz not null default now(), + metadata jsonb not null default '{}'::jsonb, + created_by text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ); + create index if not exists media_os_evidence_memo_idx on public.media_os_evidence(memo_id, created_at); + + create table if not exists public.media_os_artifacts ( + id uuid primary key default gen_random_uuid(), + memo_id uuid not null references public.media_os_memos(id) on delete cascade, + channel text not null check (channel in ('pseo','youtube','x','linkedin','commercial')), + source_revision integer not null check (source_revision > 0), + revision integer not null default 1 check (revision > 0), + state text not null default 'draft' check (state in ('draft','approved','scheduled','publishing','awaiting_human','published','stale','error')), + approval_stage smallint not null default 0 check (approval_stage between 0 and 2), + stage1_approved_by text, + stage1_approved_at timestamptz, + stage2_approved_by text, + stage2_approved_at timestamptz, + content jsonb not null, + quality_issues jsonb not null default '[]'::jsonb, + quality_error_count integer not null default 0 check (quality_error_count >= 0), + instruction_payload jsonb, + instruction_signature text, + instruction_expires_at timestamptz, + scheduled_at timestamptz, + external_url text, + published_at timestamptz, + last_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (memo_id, channel) + ); + create index if not exists media_os_artifacts_publish_idx on public.media_os_artifacts(channel, state, scheduled_at); + + create table if not exists public.media_os_approval_events ( + id bigint generated always as identity primary key, + entity_type text not null check (entity_type in ('memo','artifact')), + entity_id uuid not null, + stage smallint not null check (stage in (1,2)), + revision integer not null, + actor text not null, + created_at timestamptz not null default now() + ); + + create table if not exists public.media_os_audit_log ( + id bigint generated always as identity primary key, + entity_type text not null, + entity_id text not null, + action text not null, + actor text not null, + before_state jsonb, + after_state jsonb, + created_at timestamptz not null default now() + ); + create index if not exists media_os_audit_entity_idx on public.media_os_audit_log(entity_type, entity_id, created_at desc); + + create table if not exists public.media_os_publish_jobs ( + id uuid primary key default gen_random_uuid(), + artifact_id uuid not null unique references public.media_os_artifacts(id) on delete cascade, + state text not null default 'pending' check (state in ('pending','claimed','completed','failed','cancelled')), + due_at timestamptz not null, + claimed_by text, + claim_token uuid, + claimed_at timestamptz, + completed_at timestamptz, + attempt_count integer not null default 0 check (attempt_count >= 0), + last_error text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() + ); + create index if not exists media_os_publish_jobs_claim_idx on public.media_os_publish_jobs(state, due_at); + + create or replace function public.media_os_set_updated_at() + returns trigger + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + begin + new.updated_at := now(); + return new; + end; + $$; + + create or replace function public.media_os_memo_revision_guard() + returns trigger + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + begin + if row(new.slug, new.title, new.summary, new.research_body, new.declaration_evidence_ids) + is distinct from row(old.slug, old.title, old.summary, old.research_body, old.declaration_evidence_ids) then + new.revision := old.revision + 1; + new.status := 'draft'; + new.approval_stage := 0; + new.stage1_approved_by := null; + new.stage1_approved_at := null; + new.stage2_approved_by := null; + new.stage2_approved_at := null; + new.approved_at := null; + elsif new.revision < old.revision then + raise exception 'revision_regression' using errcode = 'P0001'; + end if; + new.updated_at := now(); + return new; + end; + $$; + + create or replace function public.media_os_invalidate_artifacts_after_memo_change() + returns trigger + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + begin + if new.revision <> old.revision then + update public.media_os_artifacts + set state = 'stale', approval_stage = 0, stage1_approved_by = null, stage1_approved_at = null, + stage2_approved_by = null, stage2_approved_at = null, scheduled_at = null, + last_error = 'Source memo changed; regenerate from the approved revision.', updated_at = now() + where memo_id = new.id and state <> 'stale'; + update public.media_os_publish_jobs j + set state = 'cancelled', updated_at = now(), last_error = 'Source memo revision changed.' + from public.media_os_artifacts a + where j.artifact_id = a.id and a.memo_id = new.id and j.state in ('pending','claimed','failed'); + end if; + return new; + end; + $$; + + create or replace function public.media_os_touch_memo_from_evidence() + returns trigger + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare + v_memo_id uuid; + v_actor text; + begin + if tg_op = 'UPDATE' and new.memo_id <> old.memo_id then + raise exception 'evidence_memo_is_immutable' using errcode = 'P0001'; + end if; + v_memo_id := case when tg_op = 'DELETE' then old.memo_id else new.memo_id end; + v_actor := case when tg_op = 'DELETE' then old.created_by else new.created_by end; + update public.media_os_memos + set revision = revision + 1, status = 'draft', approval_stage = 0, + stage1_approved_by = null, stage1_approved_at = null, stage2_approved_by = null, + stage2_approved_at = null, approved_at = null, updated_by = coalesce(v_actor, 'system'), updated_at = now() + where id = v_memo_id; + return case when tg_op = 'DELETE' then old else new end; + end; + $$; + + create or replace function public.media_os_artifact_revision_guard() + returns trigger + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + begin + if row(new.source_revision, new.content, new.quality_issues, new.quality_error_count, new.instruction_payload, new.instruction_signature, new.instruction_expires_at) + is distinct from row(old.source_revision, old.content, old.quality_issues, old.quality_error_count, old.instruction_payload, old.instruction_signature, old.instruction_expires_at) then + new.revision := old.revision + 1; + new.state := 'draft'; + new.approval_stage := 0; + new.stage1_approved_by := null; + new.stage1_approved_at := null; + new.stage2_approved_by := null; + new.stage2_approved_at := null; + new.scheduled_at := null; + new.external_url := null; + new.published_at := null; + new.last_error := null; + elsif new.revision < old.revision then + raise exception 'revision_regression' using errcode = 'P0001'; + end if; + new.updated_at := now(); + return new; + end; + $$; + + create or replace function public.media_os_prevent_audit_mutation() + returns trigger + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + begin + raise exception 'audit_log_is_immutable' using errcode = 'P0001'; + end; + $$; + + drop trigger if exists media_os_memo_revision_guard on public.media_os_memos; + create trigger media_os_memo_revision_guard before update on public.media_os_memos for each row execute function public.media_os_memo_revision_guard(); + drop trigger if exists media_os_memo_invalidate_artifacts on public.media_os_memos; + create trigger media_os_memo_invalidate_artifacts after update on public.media_os_memos for each row execute function public.media_os_invalidate_artifacts_after_memo_change(); + drop trigger if exists media_os_evidence_updated_at on public.media_os_evidence; + create trigger media_os_evidence_updated_at before update on public.media_os_evidence for each row execute function public.media_os_set_updated_at(); + drop trigger if exists media_os_evidence_touch_memo on public.media_os_evidence; + create trigger media_os_evidence_touch_memo after insert or update or delete on public.media_os_evidence for each row execute function public.media_os_touch_memo_from_evidence(); + drop trigger if exists media_os_artifact_revision_guard on public.media_os_artifacts; + create trigger media_os_artifact_revision_guard before update on public.media_os_artifacts for each row execute function public.media_os_artifact_revision_guard(); + drop trigger if exists media_os_publish_jobs_updated_at on public.media_os_publish_jobs; + create trigger media_os_publish_jobs_updated_at before update on public.media_os_publish_jobs for each row execute function public.media_os_set_updated_at(); + drop trigger if exists media_os_audit_immutable on public.media_os_audit_log; + create trigger media_os_audit_immutable before update or delete on public.media_os_audit_log for each row execute function public.media_os_prevent_audit_mutation(); + + create or replace function public.media_os_approve_memo(p_memo_id uuid, p_expected_revision integer, p_stage integer, p_actor text) + returns public.media_os_memos + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_memo public.media_os_memos; + begin + select * into v_memo from public.media_os_memos where id = p_memo_id for update; + if not found then raise exception 'memo_not_found' using errcode = 'P0001'; end if; + if v_memo.revision <> p_expected_revision then raise exception 'stale_revision' using errcode = 'P0001'; end if; + if p_stage = 1 then + if v_memo.approval_stage >= 1 then + if v_memo.stage1_approved_by = p_actor then return v_memo; end if; + raise exception 'approval_stage_already_completed' using errcode = 'P0001'; + end if; + update public.media_os_memos set status = 'review', approval_stage = 1, stage1_approved_by = p_actor, stage1_approved_at = now(), updated_by = p_actor where id = p_memo_id returning * into v_memo; + elsif p_stage = 2 then + if v_memo.approval_stage < 1 then raise exception 'stage_one_approval_required' using errcode = 'P0001'; end if; + if v_memo.stage1_approved_by = p_actor then raise exception 'two_distinct_approvers_required' using errcode = 'P0001'; end if; + if v_memo.approval_stage = 2 then return v_memo; end if; + update public.media_os_memos set status = 'approved', approval_stage = 2, stage2_approved_by = p_actor, stage2_approved_at = now(), approved_at = now(), updated_by = p_actor where id = p_memo_id returning * into v_memo; + else raise exception 'invalid_approval_stage' using errcode = 'P0001'; + end if; + insert into public.media_os_approval_events(entity_type, entity_id, stage, revision, actor) values ('memo', p_memo_id, p_stage, v_memo.revision, p_actor); + insert into public.media_os_audit_log(entity_type, entity_id, action, actor, after_state) values ('memo', p_memo_id::text, 'approval_stage_' || p_stage::text, p_actor, to_jsonb(v_memo)); + return v_memo; + end; + $$; + + create or replace function public.media_os_generation_snapshot(p_memo_id uuid, p_expected_revision integer) + returns jsonb + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_memo public.media_os_memos; v_evidence jsonb; + begin + select * into v_memo from public.media_os_memos where id = p_memo_id for update; + if not found then raise exception 'memo_not_found' using errcode = 'P0001'; end if; + if v_memo.revision <> p_expected_revision then raise exception 'stale_revision' using errcode = 'P0001'; end if; + if v_memo.status <> 'approved' or v_memo.approval_stage <> 2 or v_memo.approved_at is null then raise exception 'memo_not_fully_approved' using errcode = 'P0001'; end if; + select coalesce(jsonb_agg(to_jsonb(e) order by e.created_at), '[]'::jsonb) into v_evidence from public.media_os_evidence e where e.memo_id = p_memo_id; + if jsonb_array_length(v_evidence) = 0 then raise exception 'evidence_required' using errcode = 'P0001'; end if; + if exists (select 1 from unnest(v_memo.declaration_evidence_ids) declared where not exists (select 1 from public.media_os_evidence e where e.memo_id = p_memo_id and e.id = declared)) then raise exception 'declaration_evidence_mismatch' using errcode = 'P0001'; end if; + return jsonb_build_object('memo', to_jsonb(v_memo), 'evidence', v_evidence); + end; + $$; + + create or replace function public.media_os_store_artifacts(p_memo_id uuid, p_expected_revision integer, p_actor text, p_artifacts jsonb) + returns jsonb + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_memo public.media_os_memos; v_item jsonb; v_channel text; v_result jsonb; v_count integer; + begin + select * into v_memo from public.media_os_memos where id = p_memo_id for update; + if not found then raise exception 'memo_not_found' using errcode = 'P0001'; end if; + if v_memo.revision <> p_expected_revision then raise exception 'stale_revision' using errcode = 'P0001'; end if; + if v_memo.status <> 'approved' or v_memo.approval_stage <> 2 then raise exception 'memo_not_fully_approved' using errcode = 'P0001'; end if; + if jsonb_typeof(p_artifacts) <> 'array' or jsonb_array_length(p_artifacts) <> 5 then raise exception 'five_channel_artifacts_required' using errcode = 'P0001'; end if; + select count(distinct item->>'channel') into v_count from jsonb_array_elements(p_artifacts) item where item->>'channel' in ('pseo','youtube','x','linkedin','commercial'); + if v_count <> 5 then raise exception 'invalid_channel_set' using errcode = 'P0001'; end if; + for v_item in select value from jsonb_array_elements(p_artifacts) loop + v_channel := v_item->>'channel'; + if coalesce((v_item->>'quality_error_count')::integer, 0) <> 0 then raise exception 'quality_gate_failed' using errcode = 'P0001'; end if; + insert into public.media_os_artifacts(memo_id, channel, source_revision, content, quality_issues, quality_error_count, instruction_payload, instruction_signature, instruction_expires_at) + values (p_memo_id, v_channel, p_expected_revision, v_item->'content', coalesce(v_item->'quality_issues','[]'::jsonb), coalesce((v_item->>'quality_error_count')::integer,0), nullif(v_item->'instruction_payload','null'::jsonb), nullif(v_item->>'instruction_signature',''), nullif(v_item->>'instruction_expires_at','')::timestamptz) + on conflict (memo_id, channel) do update set source_revision = excluded.source_revision, content = excluded.content, quality_issues = excluded.quality_issues, quality_error_count = excluded.quality_error_count, instruction_payload = excluded.instruction_payload, instruction_signature = excluded.instruction_signature, instruction_expires_at = excluded.instruction_expires_at, updated_at = now(); + end loop; + select coalesce(jsonb_agg(to_jsonb(a) order by a.channel), '[]'::jsonb) into v_result from public.media_os_artifacts a where a.memo_id = p_memo_id; + insert into public.media_os_audit_log(entity_type, entity_id, action, actor, after_state) values ('memo', p_memo_id::text, 'artifacts_generated', p_actor, jsonb_build_object('source_revision',p_expected_revision,'channels',5)); + return v_result; + end; + $$; + + create or replace function public.media_os_approve_artifact(p_artifact_id uuid, p_expected_revision integer, p_stage integer, p_actor text) + returns public.media_os_artifacts + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_artifact public.media_os_artifacts; v_memo public.media_os_memos; + begin + select * into v_artifact from public.media_os_artifacts where id = p_artifact_id for update; + if not found then raise exception 'artifact_not_found' using errcode = 'P0001'; end if; + if v_artifact.revision <> p_expected_revision then raise exception 'stale_revision' using errcode = 'P0001'; end if; + select * into v_memo from public.media_os_memos where id = v_artifact.memo_id for update; + if v_memo.status <> 'approved' or v_memo.approval_stage <> 2 or v_artifact.source_revision <> v_memo.revision then raise exception 'artifact_source_is_stale' using errcode = 'P0001'; end if; + if v_artifact.quality_error_count <> 0 then raise exception 'quality_gate_failed' using errcode = 'P0001'; end if; + if p_stage = 1 then + if v_artifact.approval_stage >= 1 then + if v_artifact.stage1_approved_by = p_actor then return v_artifact; end if; + raise exception 'approval_stage_already_completed' using errcode = 'P0001'; + end if; + update public.media_os_artifacts set approval_stage = 1, stage1_approved_by = p_actor, stage1_approved_at = now() where id = p_artifact_id returning * into v_artifact; + elsif p_stage = 2 then + if v_artifact.approval_stage < 1 then raise exception 'stage_one_approval_required' using errcode = 'P0001'; end if; + if v_artifact.stage1_approved_by = p_actor then raise exception 'two_distinct_approvers_required' using errcode = 'P0001'; end if; + if v_artifact.approval_stage = 2 then return v_artifact; end if; + update public.media_os_artifacts set state = 'approved', approval_stage = 2, stage2_approved_by = p_actor, stage2_approved_at = now() where id = p_artifact_id returning * into v_artifact; + else raise exception 'invalid_approval_stage' using errcode = 'P0001'; + end if; + insert into public.media_os_approval_events(entity_type, entity_id, stage, revision, actor) values ('artifact', p_artifact_id, p_stage, v_artifact.revision, p_actor); + insert into public.media_os_audit_log(entity_type, entity_id, action, actor, after_state) values ('artifact', p_artifact_id::text, 'approval_stage_' || p_stage::text, p_actor, to_jsonb(v_artifact)); + return v_artifact; + end; + $$; + + create or replace function public.media_os_schedule_artifact(p_artifact_id uuid, p_expected_revision integer, p_scheduled_at timestamptz, p_actor text) + returns public.media_os_artifacts + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_artifact public.media_os_artifacts; v_memo public.media_os_memos; + begin + select * into v_artifact from public.media_os_artifacts where id = p_artifact_id for update; + if not found then raise exception 'artifact_not_found' using errcode = 'P0001'; end if; + if v_artifact.revision <> p_expected_revision then raise exception 'stale_revision' using errcode = 'P0001'; end if; + select * into v_memo from public.media_os_memos where id = v_artifact.memo_id for update; + if v_artifact.approval_stage <> 2 or v_artifact.quality_error_count <> 0 or v_memo.status <> 'approved' or v_artifact.source_revision <> v_memo.revision then raise exception 'artifact_not_publishable' using errcode = 'P0001'; end if; + update public.media_os_artifacts set state = 'scheduled', scheduled_at = p_scheduled_at, last_error = null where id = p_artifact_id returning * into v_artifact; + if v_artifact.channel = 'pseo' then + insert into public.media_os_publish_jobs(artifact_id, state, due_at) values (p_artifact_id, 'pending', p_scheduled_at) + on conflict (artifact_id) do update set state = 'pending', due_at = excluded.due_at, claimed_by = null, claim_token = null, claimed_at = null, completed_at = null, last_error = null, updated_at = now(); + end if; + insert into public.media_os_audit_log(entity_type, entity_id, action, actor, after_state) values ('artifact', p_artifact_id::text, 'scheduled', p_actor, jsonb_build_object('scheduled_at',p_scheduled_at,'human_controlled',v_artifact.channel <> 'pseo')); + return v_artifact; + end; + $$; + + create or replace function public.media_os_retry_artifact(p_artifact_id uuid, p_expected_revision integer, p_actor text) + returns public.media_os_artifacts + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_artifact public.media_os_artifacts; + begin + select * into v_artifact from public.media_os_artifacts where id = p_artifact_id for update; + if not found then raise exception 'artifact_not_found' using errcode = 'P0001'; end if; + if v_artifact.revision <> p_expected_revision then raise exception 'stale_revision' using errcode = 'P0001'; end if; + if v_artifact.state <> 'error' or v_artifact.approval_stage <> 2 then raise exception 'artifact_not_retryable' using errcode = 'P0001'; end if; + update public.media_os_artifacts set state = case when channel = 'pseo' then 'scheduled' else 'awaiting_human' end, scheduled_at = coalesce(scheduled_at, now()), last_error = null where id = p_artifact_id returning * into v_artifact; + if v_artifact.channel = 'pseo' then + insert into public.media_os_publish_jobs(artifact_id, state, due_at) values (p_artifact_id, 'pending', coalesce(v_artifact.scheduled_at,now())) + on conflict (artifact_id) do update set state = 'pending', due_at = excluded.due_at, claimed_by = null, claim_token = null, claimed_at = null, last_error = null, updated_at = now(); + end if; + insert into public.media_os_audit_log(entity_type, entity_id, action, actor, after_state) values ('artifact', p_artifact_id::text, 'retry_requested', p_actor, to_jsonb(v_artifact)); + return v_artifact; + end; + $$; + + create or replace function public.media_os_confirm_external_publication(p_artifact_id uuid, p_expected_revision integer, p_external_url text, p_actor text) + returns public.media_os_artifacts + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_artifact public.media_os_artifacts; + begin + select * into v_artifact from public.media_os_artifacts where id = p_artifact_id for update; + if not found then raise exception 'artifact_not_found' using errcode = 'P0001'; end if; + if v_artifact.revision <> p_expected_revision then raise exception 'stale_revision' using errcode = 'P0001'; end if; + if v_artifact.channel = 'pseo' then raise exception 'pseo_uses_internal_publisher' using errcode = 'P0001'; end if; + if v_artifact.approval_stage <> 2 or v_artifact.state not in ('approved','scheduled','awaiting_human') then raise exception 'artifact_not_publishable' using errcode = 'P0001'; end if; + update public.media_os_artifacts set state = 'published', external_url = p_external_url, published_at = now(), last_error = null where id = p_artifact_id returning * into v_artifact; + insert into public.media_os_audit_log(entity_type, entity_id, action, actor, after_state) values ('artifact', p_artifact_id::text, 'external_publication_confirmed', p_actor, jsonb_build_object('external_url',p_external_url,'human_controlled',true)); + return v_artifact; + end; + $$; + + create or replace function public.media_os_claim_publish_job(p_worker_id text) + returns jsonb + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_job public.media_os_publish_jobs; v_artifact public.media_os_artifacts; v_token uuid; + begin + select j.* into v_job + from public.media_os_publish_jobs j + join public.media_os_artifacts a on a.id = j.artifact_id + join public.media_os_memos m on m.id = a.memo_id + where j.state = 'pending' and j.due_at <= now() and a.channel = 'pseo' and a.state = 'scheduled' + and a.approval_stage = 2 and a.quality_error_count = 0 and a.source_revision = m.revision and m.status = 'approved' + order by j.due_at, j.created_at for update of j skip locked limit 1; + if not found then return null; end if; + v_token := gen_random_uuid(); + update public.media_os_publish_jobs set state = 'claimed', claimed_by = p_worker_id, claim_token = v_token, claimed_at = now(), attempt_count = attempt_count + 1, updated_at = now() where id = v_job.id returning * into v_job; + update public.media_os_artifacts set state = 'publishing', last_error = null where id = v_job.artifact_id returning * into v_artifact; + return jsonb_build_object('id',v_job.id,'claim_token',v_token,'artifact',to_jsonb(v_artifact)); + end; + $$; + + create or replace function public.media_os_complete_publish_job(p_job_id uuid, p_claim_token text, p_actor text) + returns jsonb + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_job public.media_os_publish_jobs; v_artifact public.media_os_artifacts; + begin + select * into v_job from public.media_os_publish_jobs where id = p_job_id for update; + if not found or v_job.state <> 'claimed' or v_job.claim_token::text <> p_claim_token then raise exception 'invalid_publish_claim' using errcode = 'P0001'; end if; + update public.media_os_artifacts set state = 'published', published_at = now(), last_error = null where id = v_job.artifact_id and channel = 'pseo' returning * into v_artifact; + if not found then raise exception 'pseo_artifact_not_found' using errcode = 'P0001'; end if; + update public.media_os_publish_jobs set state = 'completed', completed_at = now(), claim_token = null, updated_at = now() where id = p_job_id; + insert into public.media_os_audit_log(entity_type, entity_id, action, actor, after_state) values ('artifact', v_artifact.id::text, 'pseo_published', p_actor, to_jsonb(v_artifact)); + return to_jsonb(v_artifact); + end; + $$; + + create or replace function public.media_os_fail_publish_job(p_job_id uuid, p_claim_token text, p_error text) + returns void + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_job public.media_os_publish_jobs; + begin + select * into v_job from public.media_os_publish_jobs where id = p_job_id for update; + if not found or v_job.state <> 'claimed' or v_job.claim_token::text <> p_claim_token then raise exception 'invalid_publish_claim' using errcode = 'P0001'; end if; + update public.media_os_publish_jobs set state = 'failed', last_error = left(p_error,2000), claim_token = null, updated_at = now() where id = p_job_id; + update public.media_os_artifacts set state = 'error', last_error = left(p_error,2000) where id = v_job.artifact_id; + end; + $$; + + create or replace function public.media_os_public_insights() + returns table(slug text, title text, summary text, published_at timestamptz) + language sql + stable + security definer + set search_path = public, pg_temp + as $$ + select a.content->>'slug', a.content->>'title', a.content->>'summary', a.published_at + from public.media_os_artifacts a + where a.channel = 'pseo' and a.state = 'published' and a.published_at is not null + order by a.published_at desc limit 100; + $$; + + create or replace function public.media_os_public_insight(p_slug text) + returns jsonb + language sql + stable + security definer + set search_path = public, pg_temp + as $$ + select to_jsonb(a) from public.media_os_artifacts a + where a.channel = 'pseo' and a.state = 'published' and a.content->>'slug' = p_slug + order by a.published_at desc limit 1; + $$; + + create or replace function public.media_os_health() + returns jsonb + language sql + stable + security definer + set search_path = public, pg_temp + as $$ + select jsonb_build_object('schema_version',1,'memos',(select count(*) from public.media_os_memos),'artifacts',(select count(*) from public.media_os_artifacts),'pending_jobs',(select count(*) from public.media_os_publish_jobs where state='pending'),'checked_at',now()); + $$; + + alter table public.media_os_memos enable row level security; + alter table public.media_os_evidence enable row level security; + alter table public.media_os_artifacts enable row level security; + alter table public.media_os_approval_events enable row level security; + alter table public.media_os_audit_log enable row level security; + alter table public.media_os_publish_jobs enable row level security; + + revoke all on table public.media_os_memos, public.media_os_evidence, public.media_os_artifacts, public.media_os_approval_events, public.media_os_audit_log, public.media_os_publish_jobs from public, anon, authenticated; + grant all on table public.media_os_memos, public.media_os_evidence, public.media_os_artifacts, public.media_os_approval_events, public.media_os_audit_log, public.media_os_publish_jobs to service_role; + grant usage, select on all sequences in schema public to service_role; + + revoke all on function public.media_os_approve_memo(uuid,integer,integer,text), public.media_os_generation_snapshot(uuid,integer), public.media_os_store_artifacts(uuid,integer,text,jsonb), public.media_os_approve_artifact(uuid,integer,integer,text), public.media_os_schedule_artifact(uuid,integer,timestamptz,text), public.media_os_retry_artifact(uuid,integer,text), public.media_os_confirm_external_publication(uuid,integer,text,text), public.media_os_claim_publish_job(text), public.media_os_complete_publish_job(uuid,text,text), public.media_os_fail_publish_job(uuid,text,text), public.media_os_public_insights(), public.media_os_public_insight(text), public.media_os_health() from public, anon, authenticated; + grant execute on function public.media_os_approve_memo(uuid,integer,integer,text), public.media_os_generation_snapshot(uuid,integer), public.media_os_store_artifacts(uuid,integer,text,jsonb), public.media_os_approve_artifact(uuid,integer,integer,text), public.media_os_schedule_artifact(uuid,integer,timestamptz,text), public.media_os_retry_artifact(uuid,integer,text), public.media_os_confirm_external_publication(uuid,integer,text,text), public.media_os_claim_publish_job(text), public.media_os_complete_publish_job(uuid,text,text), public.media_os_fail_publish_job(uuid,text,text), public.media_os_public_insights(), public.media_os_public_insight(text), public.media_os_health() to service_role; + + commit; + ''', +) + +write_if_missing( + MIGRATIONS / "202607310002_media_os_leads_analytics.sql", + r''' + begin; + + create table if not exists public.media_os_leads ( + id uuid primary key default gen_random_uuid(), + insight_slug text not null check (insight_slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'), + email text not null check (char_length(email) between 3 and 254), + name text, + company text, + website text, + message text, + consent boolean not null check (consent is true), + consented_at timestamptz not null default now(), + ip_hash text not null check (char_length(ip_hash) = 64), + user_agent_hash text not null check (char_length(user_agent_hash) = 64), + created_at timestamptz not null default now() + ); + create index if not exists media_os_leads_rate_idx on public.media_os_leads(ip_hash, created_at desc); + create index if not exists media_os_leads_created_idx on public.media_os_leads(created_at desc); + + create table if not exists public.media_os_analytics_events ( + id bigint generated always as identity primary key, + insight_slug text not null, + event_name text not null check (event_name in ('page_view','lead_open','lead_submit')), + session_hash text not null check (char_length(session_hash) = 64), + ip_hash text not null check (char_length(ip_hash) = 64), + created_at timestamptz not null default now() + ); + create index if not exists media_os_analytics_summary_idx on public.media_os_analytics_events(insight_slug, event_name, created_at desc); + create index if not exists media_os_analytics_rate_idx on public.media_os_analytics_events(session_hash, created_at desc); + + create or replace function public.media_os_accept_lead(p_insight_slug text, p_email text, p_name text, p_company text, p_website text, p_message text, p_consent boolean, p_ip_hash text, p_user_agent_hash text) + returns jsonb + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_id uuid; v_recent integer; + begin + if p_consent is distinct from true then raise exception 'consent_required' using errcode = 'P0001'; end if; + if p_email !~* '^[^[:space:]@]+@[^[:space:]@]+\.[^[:space:]@]+$' then raise exception 'invalid_email' using errcode = 'P0001'; end if; + perform pg_advisory_xact_lock(hashtextextended(p_ip_hash, 73031)); + select count(*) into v_recent from public.media_os_leads where ip_hash = p_ip_hash and created_at > now() - interval '1 hour'; + if v_recent >= 5 then raise exception 'rate_limited' using errcode = 'P0001'; end if; + insert into public.media_os_leads(insight_slug,email,name,company,website,message,consent,consented_at,ip_hash,user_agent_hash) + values (p_insight_slug,lower(p_email),nullif(p_name,''),nullif(p_company,''),nullif(p_website,''),nullif(p_message,''),true,now(),p_ip_hash,p_user_agent_hash) + returning id into v_id; + insert into public.media_os_analytics_events(insight_slug,event_name,session_hash,ip_hash) values (p_insight_slug,'lead_submit',p_user_agent_hash,p_ip_hash); + return jsonb_build_object('id',v_id); + end; + $$; + + create or replace function public.media_os_record_analytics(p_insight_slug text, p_event_name text, p_session_hash text, p_ip_hash text) + returns void + language plpgsql + security definer + set search_path = public, pg_temp + as $$ + declare v_recent integer; + begin + if p_event_name not in ('page_view','lead_open','lead_submit') then raise exception 'invalid_analytics_event' using errcode = 'P0001'; end if; + perform pg_advisory_xact_lock(hashtextextended(p_session_hash, 73032)); + select count(*) into v_recent from public.media_os_analytics_events where session_hash = p_session_hash and created_at > now() - interval '1 hour'; + if v_recent < 120 then insert into public.media_os_analytics_events(insight_slug,event_name,session_hash,ip_hash) values (p_insight_slug,p_event_name,p_session_hash,p_ip_hash); end if; + end; + $$; + + create or replace function public.media_os_analytics_summary() + returns table(insight_slug text, event_name text, count bigint) + language sql + stable + security definer + set search_path = public, pg_temp + as $$ + select e.insight_slug, e.event_name, count(*) from public.media_os_analytics_events e where e.created_at > now() - interval '90 days' group by e.insight_slug, e.event_name order by e.insight_slug, e.event_name; + $$; + + alter table public.media_os_leads enable row level security; + alter table public.media_os_analytics_events enable row level security; + revoke all on table public.media_os_leads, public.media_os_analytics_events from public, anon, authenticated; + grant all on table public.media_os_leads, public.media_os_analytics_events to service_role; + grant usage, select on all sequences in schema public to service_role; + revoke all on function public.media_os_accept_lead(text,text,text,text,text,text,boolean,text,text), public.media_os_record_analytics(text,text,text,text), public.media_os_analytics_summary() from public, anon, authenticated; + grant execute on function public.media_os_accept_lead(text,text,text,text,text,text,boolean,text,text), public.media_os_record_analytics(text,text,text,text), public.media_os_analytics_summary() to service_role; + + commit; + ''', +) + +write_if_missing( + Path("docs/media-os.md"), + r''' + # Japan Market Entry Media OS + + The Media OS turns one evidence-linked research memo into five controlled outputs: pSEO, YouTube, X, LinkedIn, and a commercial brief. The pSEO artifact can be published by the internal worker only after two-stage approval. External channels produce expiring HMAC-signed instructions and always require a human to publish or contact a lead. + + ## Required server environment + + - `NEXT_PUBLIC_SUPABASE_URL` + - `NEXT_PUBLIC_SUPABASE_ANON_KEY` + - `SUPABASE_SERVICE_ROLE_KEY` + - `MEDIA_OS_SIGNING_SECRET` + - `MEDIA_OS_IP_HASH_SECRET` + - `MEDIA_OS_PUBLISHER_TOKEN` + - `MEDIA_OS_ADMIN_EMAILS` or an authenticated user metadata role of `admin`, `owner`, `staff`, `editor`, or `internal` + - Optional Turnstile pair: `NEXT_PUBLIC_TURNSTILE_SITE_KEY` and `TURNSTILE_SECRET_KEY` + + Apply both `202607310001_media_os_core.sql` and `202607310002_media_os_leads_analytics.sql` before enabling the publisher. Run the publisher endpoint with an authenticated scheduler and the publisher bearer token. Never expose the service role, signing secret, hash secret, or publisher token to browser code. + + ## Safety invariants + + Memo and evidence edits advance the memo revision and invalidate every approval, generated artifact, and queued publication. Generation locks the approved memo revision, stores all five artifacts atomically, and rejects unsupported claims or unknown evidence IDs. Artifact changes also invalidate approvals. Two distinct actors are required for the two approval stages. Publish jobs use `FOR UPDATE SKIP LOCKED`; lead limits use transaction advisory locks. Audit rows are append-only. Public forms require consent, include a honeypot, optionally verify Turnstile, validate public URLs, and store only keyed hashes of network identifiers. + ''', +) diff --git a/.github/scripts/bootstrap_media_os_part2_ui.py b/.github/scripts/bootstrap_media_os_part2_ui.py new file mode 100644 index 000000000..d8ae6981b --- /dev/null +++ b/.github/scripts/bootstrap_media_os_part2_ui.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +import os +import re +from pathlib import Path +from textwrap import dedent + +ROOT = Path.cwd() +APP = Path("src/app") if (ROOT / "src/app").is_dir() else Path("app") +LIB = Path("src/lib") if (ROOT / "src").is_dir() else Path("lib") +CORE = LIB / "media-os" + + +def write_if_missing(path: Path, content: str) -> None: + target = ROOT / path + if target.exists(): + print(f"Preserving existing file: {path}") + return + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(dedent(content).lstrip(), encoding="utf-8") + print(f"Created: {path}") + + +def rel_import(source: Path, target: Path) -> str: + relative = os.path.relpath(target, source.parent).replace(os.sep, "/") + if not relative.startswith("."): + relative = "./" + relative + return re.sub(r"\.(ts|tsx)$", "", relative) + + +# The guarded part-one bootstrap used a direct PostgREST JSON expression for public +# insights. Replace only that exact generated implementation with stable RPCs. +store_path = ROOT / CORE / "store.ts" +if store_path.exists(): + store_text = store_path.read_text(encoding="utf-8") + start = store_text.find("export async function publicInsights()") + end = store_text.find("export async function acceptLead", start) + if start >= 0 and end > start and "public_slug:content->>slug" in store_text[start:end]: + replacement = dedent( + ''' + export async function publicInsights(): Promise> { + return rpc("media_os_public_insights", {}); + } + + export async function publicInsight(slug: string): Promise { + return rpc("media_os_public_insight", { p_slug: safeSlug(slug) }); + } + + ''' + ) + store_path.write_text(store_text[:start] + replacement + store_text[end:], encoding="utf-8") + print("Patched generated public insight reads to use RPCs.") + +console_path = APP / "work/media-os/media-os-console.tsx" +types_import = rel_import(console_path, CORE / "types.ts") +write_if_missing( + console_path, + f''' + "use client"; + + import type {{ CSSProperties, FormEvent }} from "react"; + import {{ useCallback, useEffect, useMemo, useRef, useState }} from "react"; + import type {{ MediaOsAdminSnapshot, MediaOsArtifact, MediaOsMemo }} from "{types_import}"; + + type MemoDraft = {{ id: string; slug: string; title: string; summary: string; researchBody: string; declarationEvidenceIds: string }}; + type ApiError = Error & {{ status?: number; code?: string }}; + + const emptyDraft: MemoDraft = {{ id: "", slug: "", title: "", summary: "", researchBody: "", declarationEvidenceIds: "" }}; + const card: CSSProperties = {{ border: "1px solid #d9dde7", borderRadius: 14, padding: 18, background: "#fff", boxShadow: "0 8px 28px rgba(15,23,42,.05)" }}; + const input: CSSProperties = {{ width: "100%", border: "1px solid #c7cedb", borderRadius: 9, padding: "10px 12px", font: "inherit", boxSizing: "border-box" }}; + const button: CSSProperties = {{ border: 0, borderRadius: 9, padding: "10px 14px", fontWeight: 700, cursor: "pointer", background: "#111827", color: "white" }}; + const secondary: CSSProperties = {{ ...button, background: "#e8edf5", color: "#111827" }}; + const danger: CSSProperties = {{ ...button, background: "#991b1b" }}; + const grid: CSSProperties = {{ display: "grid", gap: 14 }}; + + function draftFrom(memo: MediaOsMemo | null): MemoDraft {{ + if (!memo) return emptyDraft; + return {{ id: memo.id, slug: memo.slug, title: memo.title, summary: memo.summary, researchBody: memo.research_body, declarationEvidenceIds: memo.declaration_evidence_ids.join(", ") }}; + }} + + async function api(action: string, payload: Record = {{}}): Promise {{ + const response = await fetch("/api/media-os/admin", {{ + method: "POST", + credentials: "same-origin", + headers: {{ "content-type": "application/json" }}, + body: JSON.stringify({{ action, ...payload }}), + }}); + const body = (await response.json().catch(() => ({{}}))) as {{ error?: string; code?: string; data?: unknown }}; + if (!response.ok) {{ + const error = new Error(body.error || `Request failed (${{response.status}})`) as ApiError; + error.status = response.status; + error.code = body.code; + throw error; + }} + return body.data; + }} + + function Stage({{ value }}: {{ value: number }}): JSX.Element {{ + return 承認 ${{value}} / 2; + }} + + function ArtifactCard({{ artifact, busy, onRun }}: {{ artifact: MediaOsArtifact; busy: boolean; onRun: (action: string, payload: Record) => Promise }}): JSX.Element {{ + const [scheduledAt, setScheduledAt] = useState(artifact.scheduled_at ? artifact.scheduled_at.slice(0, 16) : ""); + const [externalUrl, setExternalUrl] = useState(artifact.external_url || ""); + const external = artifact.channel !== "pseo"; + const errors = artifact.quality_issues.filter((issue) => issue.severity === "error"); + return ( +
+
+
{{artifact.channel}} rev.{{artifact.revision}} / source {{artifact.source_revision}}
+
{{artifact.state}}
+
+

{{artifact.content.title}}

+

{{artifact.content.summary}}

+ {{errors.length > 0 &&
{{errors.map((item) => item.message).join(" / ")}}
}} +
生成本文・根拠を確認
{{artifact.content.body.map((paragraph, index) =>

{{paragraph}}

)}}
{{JSON.stringify(artifact.content.evidence, null, 2)}}
+
+ + + +
+
+ setScheduledAt(event.target.value)}} /> + +
+ {{external &&
+ setExternalUrl(event.target.value)}} /> + +
}} + {{artifact.instruction_payload &&
署名付き・人手実行指示書
{{JSON.stringify({{ payload: artifact.instruction_payload, signature: artifact.instruction_signature }}, null, 2)}}
}} +
+ ); + }} + + export default function MediaOsConsole(): JSX.Element {{ + const [snapshot, setSnapshot] = useState(null); + const [selectedId, setSelectedId] = useState(""); + const [draft, setDraft] = useState(emptyDraft); + const [dirty, setDirty] = useState(false); + const [remoteChanged, setRemoteChanged] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [evidence, setEvidence] = useState({{ title: "", sourceUrl: "", excerpt: "", sourceType: "web", publishedAt: "" }}); + const dirtyRef = useRef(false); + const selectedRef = useRef(""); + const revisionRef = useRef(0); + useEffect(() => {{ dirtyRef.current = dirty; }}, [dirty]); + useEffect(() => {{ selectedRef.current = selectedId; }}, [selectedId]); + + const load = useCallback(async (force = false, requestedId?: string): Promise => {{ + const id = requestedId ?? selectedRef.current; + const response = await fetch(`/api/media-os/admin${{id ? `?memo=${{encodeURIComponent(id)}}` : ""}}`, {{ credentials: "same-origin", cache: "no-store" }}); + if (response.status === 401) {{ window.location.assign(`/login?next=${{encodeURIComponent("/work/media-os")}}`); return; }} + if (!response.ok) throw new Error(`管理データを取得できませんでした (${{response.status}})`); + const next = (await response.json()) as MediaOsAdminSnapshot; + setSnapshot(next); + const memo = next.selectedMemo; + if (memo && !selectedRef.current) {{ selectedRef.current = memo.id; setSelectedId(memo.id); }} + if (force || !dirtyRef.current) {{ + const nextDraft = draftFrom(memo); + setDraft(nextDraft); + setDirty(false); + dirtyRef.current = false; + revisionRef.current = memo?.revision || 0; + setRemoteChanged(false); + }} else if (memo && memo.id === selectedRef.current && memo.revision !== revisionRef.current) {{ + setRemoteChanged(true); + }} + }}, []); + + useEffect(() => {{ + void load(true).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "取得に失敗しました。")); + const timer = window.setInterval(() => void load(false).catch(() => undefined), 15_000); + return () => window.clearInterval(timer); + }}, [load]); + + const selectedMemo = snapshot?.memos.find((memo) => memo.id === selectedId) || snapshot?.selectedMemo || null; + const artifacts = snapshot?.artifacts || []; + const updateDraft = (patch: Partial): void => {{ setDraft((value) => ({{ ...value, ...patch }})); setDirty(true); dirtyRef.current = true; }}; + + const run = async (action: string, payload: Record): Promise => {{ + setBusy(true); setError(""); setNotice(""); + try {{ + const result = await api(action, payload) as {{ id?: string }} | undefined; + const nextId = result?.id || selectedRef.current; + if (nextId) {{ selectedRef.current = nextId; setSelectedId(nextId); }} + setNotice("処理が完了しました。"); + await load(true, nextId); + }} catch (reason) {{ + const apiError = reason as ApiError; + if (apiError.status === 409) setRemoteChanged(true); + setError(apiError.message || "処理に失敗しました。"); + }} finally {{ setBusy(false); }} + }}; + + const saveMemo = async (event: FormEvent): Promise => {{ + event.preventDefault(); + const declarationEvidenceIds = draft.declarationEvidenceIds.split(",").map((value) => value.trim()).filter(Boolean); + if (draft.id && selectedMemo) await run("updateMemo", {{ id: draft.id, expectedRevision: selectedMemo.revision, slug: draft.slug, title: draft.title, summary: draft.summary, researchBody: draft.researchBody, declarationEvidenceIds }}); + else await run("createMemo", {{ slug: draft.slug, title: draft.title, summary: draft.summary, researchBody: draft.researchBody }}); + }}; + + const selectMemo = async (id: string): Promise => {{ + selectedRef.current = id; setSelectedId(id); setDirty(false); dirtyRef.current = false; setRemoteChanged(false); + await load(true, id).catch((reason: unknown) => setError(reason instanceof Error ? reason.message : "取得に失敗しました。")); + }}; + + const addEvidence = async (event: FormEvent): Promise => {{ + event.preventDefault(); + if (!selectedMemo) return; + await run("addEvidence", {{ memoId: selectedMemo.id, ...evidence }}); + setEvidence({{ title: "", sourceUrl: "", excerpt: "", sourceType: "web", publishedAt: "" }}); + }}; + + const analytics = useMemo(() => snapshot?.analytics.reduce((sum, row) => sum + Number(row.count || 0), 0) || 0, [snapshot]); + + return ( +
+
+

PARADIGM INTERNAL

Japan Market Entry Media OS

一つの承認済みリサーチメモから、根拠付き5媒体成果物を安全に管理します。

+
+
+ {{error &&
{{error}}
}} + {{notice &&
{{notice}}
}} + {{remoteChanged &&
別の更新を検知しました。 未保存入力は保持しています。古いリビジョンの送信は409で停止します。
}} +
+ +
+
+

リサーチメモ

{{selectedMemo &&
rev.{{selectedMemo.revision}}
}}
+ + +