diff --git a/apps/worker/migrations/0005_tenant_foundation.sql b/apps/worker/migrations/0005_tenant_foundation.sql new file mode 100644 index 0000000..6a68179 --- /dev/null +++ b/apps/worker/migrations/0005_tenant_foundation.sql @@ -0,0 +1,227 @@ +-- 0005 — managed tenant foundation. +-- Existing self-hosted data is assigned to a stable legacy workspace while +-- engine-domain primary keys and indexes become workspace-scoped. + +CREATE TABLE users ( + id TEXT PRIMARY KEY, + github_user_id INTEGER NOT NULL UNIQUE, + github_login TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE workspaces ( + id TEXT PRIMARY KEY, + github_account_id INTEGER, + github_account_type TEXT, + github_account_login TEXT, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (github_account_id, github_account_type) +); + +CREATE TABLE sessions ( + id_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +CREATE INDEX idx_sessions_user ON sessions (user_id); + +CREATE TABLE workspace_members ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, user_id) +); + +CREATE TABLE github_installations ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + installation_id INTEGER NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('active', 'suspended', 'deleted')), + suspended_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, installation_id) +); + +CREATE TABLE github_repositories ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + repository_id INTEGER NOT NULL, + installation_id INTEGER NOT NULL, + owner TEXT NOT NULL, + name TEXT NOT NULL, + full_name TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'removed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, repository_id), + UNIQUE (workspace_id, full_name) +); +CREATE INDEX idx_github_repositories_installation + ON github_repositories (workspace_id, installation_id, status, enabled); + +CREATE TABLE workspace_config ( + workspace_id TEXT PRIMARY KEY REFERENCES workspaces (id) ON DELETE CASCADE, + config_json TEXT NOT NULL, + revision INTEGER NOT NULL DEFAULT 1, + updated_by TEXT, + updated_at TEXT NOT NULL +); + +CREATE TABLE audit_actions ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + id TEXT NOT NULL, + repository_id INTEGER, + action TEXT NOT NULL, + outcome TEXT NOT NULL, + actor_json TEXT NOT NULL DEFAULT '{}', + detail_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, id) +); +CREATE INDEX idx_audit_actions_created + ON audit_actions (workspace_id, created_at DESC); + +INSERT INTO workspaces (id, name, created_at, updated_at) +VALUES ('legacy', 'Legacy self-hosted workspace', datetime('now'), datetime('now')); + +ALTER TABLE identities RENAME TO identities_legacy; +CREATE TABLE identities ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + id TEXT NOT NULL, + handles TEXT NOT NULL DEFAULT '{}', + email TEXT, + display_name TEXT, + PRIMARY KEY (workspace_id, id) +); +INSERT INTO identities SELECT 'legacy', id, handles, email, display_name FROM identities_legacy; +DROP TABLE identities_legacy; +CREATE INDEX idx_identities_email ON identities (workspace_id, email); + +ALTER TABLE threads RENAME TO threads_legacy; +CREATE TABLE threads ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + id TEXT NOT NULL, + platform TEXT NOT NULL, + native_id TEXT NOT NULL, + type TEXT NOT NULL, + title TEXT, + body TEXT, + state TEXT NOT NULL, + participants TEXT NOT NULL DEFAULT '[]', + owner TEXT, + meta TEXT NOT NULL DEFAULT '{}', + timeline TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, id), + UNIQUE (workspace_id, platform, native_id) +); +INSERT INTO threads + SELECT 'legacy', id, platform, native_id, type, title, body, state, + participants, owner, meta, timeline, updated_at + FROM threads_legacy; +DROP TABLE threads_legacy; + +ALTER TABLE links RENAME TO links_legacy; +CREATE TABLE links ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + kind TEXT NOT NULL, + PRIMARY KEY (workspace_id, from_id, to_id, kind) +); +INSERT INTO links SELECT 'legacy', from_id, to_id, kind FROM links_legacy; +DROP TABLE links_legacy; +CREATE INDEX idx_links_from ON links (workspace_id, from_id); +CREATE INDEX idx_links_to ON links (workspace_id, to_id); + +ALTER TABLE clusters RENAME TO clusters_legacy; +CREATE TABLE clusters ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + id TEXT NOT NULL, + PRIMARY KEY (workspace_id, id) +); +INSERT INTO clusters SELECT 'legacy', id FROM clusters_legacy; +DROP TABLE clusters_legacy; + +ALTER TABLE thread_cluster RENAME TO thread_cluster_legacy; +CREATE TABLE thread_cluster ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + thread_id TEXT NOT NULL, + cluster_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, thread_id) +); +INSERT INTO thread_cluster SELECT 'legacy', thread_id, cluster_id FROM thread_cluster_legacy; +DROP TABLE thread_cluster_legacy; +CREATE INDEX idx_thread_cluster_cluster + ON thread_cluster (workspace_id, cluster_id); + +ALTER TABLE signals RENAME TO signals_legacy; +CREATE TABLE signals ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + id TEXT NOT NULL, + thread_id TEXT NOT NULL, + kind TEXT NOT NULL, + owed_by TEXT, + detected_at TEXT NOT NULL, + cleared_at TEXT, + PRIMARY KEY (workspace_id, id) +); +INSERT INTO signals + SELECT 'legacy', id, thread_id, kind, owed_by, detected_at, cleared_at FROM signals_legacy; +DROP TABLE signals_legacy; +CREATE INDEX idx_signals_thread_open + ON signals (workspace_id, thread_id) WHERE cleared_at IS NULL; + +ALTER TABLE nudges RENAME TO nudges_legacy; +CREATE TABLE nudges ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + dedupe_key TEXT NOT NULL, + person TEXT NOT NULL, + signal_id TEXT NOT NULL, + channel TEXT NOT NULL, + sent_at TEXT, + state TEXT NOT NULL, + escalations INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (workspace_id, dedupe_key) +); +INSERT INTO nudges + SELECT 'legacy', dedupe_key, person, signal_id, channel, sent_at, state, escalations + FROM nudges_legacy; +DROP TABLE nudges_legacy; +CREATE INDEX idx_nudges_person ON nudges (workspace_id, person); + +ALTER TABLE preferences RENAME TO preferences_legacy; +CREATE TABLE preferences ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + person TEXT NOT NULL, + rule TEXT NOT NULL, + selector TEXT NOT NULL DEFAULT '{}', + until TEXT, + UNIQUE (workspace_id, person, rule, selector) +); +INSERT INTO preferences (workspace_id, person, rule, selector, until) + SELECT 'legacy', person, rule, selector, until FROM preferences_legacy; +DROP TABLE preferences_legacy; +CREATE INDEX idx_preferences_person ON preferences (workspace_id, person); + +ALTER TABLE working_notes RENAME TO working_notes_legacy; +CREATE TABLE working_notes ( + workspace_id TEXT NOT NULL REFERENCES workspaces (id) ON DELETE CASCADE, + scope TEXT NOT NULL, + target_id TEXT NOT NULL, + content TEXT NOT NULL, + content_hash TEXT NOT NULL, + provenance TEXT NOT NULL, + external_ref TEXT, + PRIMARY KEY (workspace_id, scope, target_id) +); +INSERT INTO working_notes + SELECT 'legacy', scope, target_id, content, content_hash, provenance, external_ref + FROM working_notes_legacy; +DROP TABLE working_notes_legacy; diff --git a/apps/worker/migrations/0006_public_safety.sql b/apps/worker/migrations/0006_public_safety.sql new file mode 100644 index 0000000..2ee3db3 --- /dev/null +++ b/apps/worker/migrations/0006_public_safety.sql @@ -0,0 +1,49 @@ +-- 0006 — public-free safeguards. +-- Managed workspaces are shadow-first and audit records are append-only for the +-- lifetime of a workspace. Cascading removal during workspace offboarding is +-- intentionally allowed. + +CREATE TRIGGER workspace_shadow_config_after_insert +AFTER INSERT ON workspaces +WHEN NEW.id <> 'legacy' + AND NOT EXISTS ( + SELECT 1 FROM workspace_config WHERE workspace_id = NEW.id + ) +BEGIN + INSERT INTO workspace_config ( + workspace_id, + config_json, + revision, + updated_at + ) VALUES ( + NEW.id, + '{"shadow":{"global":true,"capabilities":{}}}', + 1, + datetime('now') + ); +END; + +INSERT INTO workspace_config (workspace_id, config_json, revision, updated_at) +SELECT + id, + '{"shadow":{"global":true,"capabilities":{}}}', + 1, + datetime('now') +FROM workspaces +WHERE id <> 'legacy' +ON CONFLICT(workspace_id) DO NOTHING; + +CREATE TRIGGER audit_actions_no_update +BEFORE UPDATE ON audit_actions +BEGIN + SELECT RAISE(ABORT, 'audit_actions are immutable'); +END; + +CREATE TRIGGER audit_actions_no_delete +BEFORE DELETE ON audit_actions +WHEN EXISTS ( + SELECT 1 FROM workspaces WHERE id = OLD.workspace_id +) +BEGIN + SELECT RAISE(ABORT, 'audit_actions are immutable'); +END; diff --git a/apps/worker/src/auth/session.ts b/apps/worker/src/auth/session.ts new file mode 100644 index 0000000..f33bb17 --- /dev/null +++ b/apps/worker/src/auth/session.ts @@ -0,0 +1,157 @@ +import { Err, Ok, Result } from "@aipm/core"; +import { + createSession, + deleteSession, + getSession, + upsertUserFromGithub, + type UserRow, +} from "@aipm/db"; +import type { DbEnv } from "../tenancy/guards.js"; + +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; +const OAUTH_STATE_TTL_MS = 10 * 60 * 1000; + +export const SESSION_COOKIE = "aipm_session"; +export const OAUTH_STATE_COOKIE = "aipm_oauth_state"; + +export const hashToken = async (token: string): Promise => { + const bytes = new TextEncoder().encode(token); + const digest = await crypto.subtle.digest("SHA-256", bytes); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +}; + +const mintOpaqueToken = (): string => + crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""); + +export const createUserSession = async ( + env: DbEnv, + user: { githubUserId: number; githubLogin: string; userId?: string }, +): Promise> => { + const userId = user.userId ?? crypto.randomUUID(); + const upserted = await upsertUserFromGithub(env.DB, user.githubUserId, user.githubLogin, userId); + if (!upserted.ok) return upserted; + const token = mintOpaqueToken(); + const idHash = await hashToken(token); + const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString(); + const created = await createSession(env.DB, idHash, upserted.data.id, expiresAt); + if (!created.ok) return created; + return Ok({ token, expiresAt, user: upserted.data }); +}; + +export const resolveSessionToken = async ( + env: DbEnv, + token: string | undefined, +): Promise> => { + if (!token) return Ok(undefined); + const idHash = await hashToken(token); + const session = await getSession(env.DB, idHash); + if (!session.ok) return session; + if (!session.data) return Ok(undefined); + if (Date.parse(session.data.expiresAt) <= Date.now()) { + const cleared = await deleteSession(env.DB, idHash); + if (!cleared.ok) return cleared; + return Ok(undefined); + } + return Ok({ + userId: session.data.userId, + githubLogin: session.data.githubLogin, + }); +}; + +export const revokeSessionToken = async ( + env: DbEnv, + token: string | undefined, +): Promise> => { + if (!token) return Ok(undefined); + const idHash = await hashToken(token); + return deleteSession(env.DB, idHash); +}; + +/** Payload stored server-side for an OAuth login; the client only sees an opaque token. */ +export interface OAuthState { + readonly returnTo?: string; + readonly issuedAt: number; +} + +const oauthStateKey = (token: string) => `oauth:state:${token}`; + +/** + * Mint an opaque OAuth `state` token and persist `{ returnTo, issuedAt }` in KV + * with a short TTL. The token itself carries no client-readable payload. + */ +export const mintOAuthState = async ( + kv: KVNamespace, + returnTo?: string, +): Promise> => { + const token = mintOpaqueToken(); + const state: OAuthState = { + issuedAt: Date.now(), + ...(returnTo === undefined ? {} : { returnTo }), + }; + const body = Result.fromSync(() => JSON.stringify(state)); + if (!body.ok) return body; + const put = await Result.from(() => + kv.put(oauthStateKey(token), body.data, { + expirationTtl: Math.floor(OAUTH_STATE_TTL_MS / 1000), + }), + ); + if (!put.ok) return put; + return Ok({ token }); +}; + +/** + * Verify the cookie/query double-submit, then atomically consume the KV record + * so the state cannot be replayed. + */ +export const consumeOAuthState = async ( + kv: KVNamespace, + cookieToken: string | undefined, + queryToken: string | undefined, +): Promise> => { + if (!cookieToken || !queryToken || cookieToken !== queryToken) { + return Err(new Error("OAUTH_STATE_MISMATCH")); + } + const key = oauthStateKey(queryToken); + const raw = await Result.from(() => kv.get(key)); + if (!raw.ok) return raw; + if (!raw.data) return Err(new Error("OAUTH_STATE_MISSING")); + const deleted = await Result.from(() => kv.delete(key)); + if (!deleted.ok) return deleted; + const parsed = Result.fromSync(() => JSON.parse(raw.data) as unknown); + if (!parsed.ok) return Err(new Error("OAUTH_STATE_INVALID")); + if (!isOAuthState(parsed.data)) return Err(new Error("OAUTH_STATE_INVALID")); + if (Date.now() - parsed.data.issuedAt > OAUTH_STATE_TTL_MS) { + return Err(new Error("OAUTH_STATE_EXPIRED")); + } + return Ok(parsed.data); +}; + +export const sessionCookieHeader = (token: string, expiresAt: string): string => { + const maxAge = Math.max(0, Math.floor((Date.parse(expiresAt) - Date.now()) / 1000)); + return `${SESSION_COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${String(maxAge)}`; +}; + +export const clearSessionCookieHeader = (): string => + `${SESSION_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`; + +export const oauthStateCookieHeader = (token: string): string => + `${OAUTH_STATE_COOKIE}=${token}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=${String(Math.floor(OAUTH_STATE_TTL_MS / 1000))}`; + +export const clearOAuthStateCookieHeader = (): string => + `${OAUTH_STATE_COOKIE}=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0`; + +export const readCookie = (cookieHeader: string | null, name: string): string | undefined => { + if (!cookieHeader) return undefined; + const parts = cookieHeader.split(";").map((part) => part.trim()); + const match = parts.find((part) => part.startsWith(`${name}=`)); + if (!match) return undefined; + return match.slice(name.length + 1); +}; + +const isOAuthState = (value: unknown): value is OAuthState => { + if (typeof value !== "object" || value === null) return false; + const row = value as Record; + if (typeof row.issuedAt !== "number") return false; + if (row.returnTo !== undefined && typeof row.returnTo !== "string") return false; + return true; +}; diff --git a/apps/worker/src/context.ts b/apps/worker/src/context.ts index f55610f..fb2b311 100644 --- a/apps/worker/src/context.ts +++ b/apps/worker/src/context.ts @@ -1,59 +1,51 @@ import { GitHubAdapter, installationTokenProvider } from "@aipm/adapter-github"; import { SlackAdapter } from "@aipm/adapter-slack"; -import { BudgetedLlmAdapter, EchoLlmAdapter, WorkersAiLlmAdapter } from "@aipm/adapter-llm"; -import { buildConfig } from "@aipm/config"; +import { EchoLlmAdapter, WorkersAiLlmAdapter } from "@aipm/adapter-llm"; import { configIdentitySource, Err, Ok, Result, systemClock, + type EngineConfig, type EngineContext, type LlmAdapter, type Platform, type PlatformId, type RawEvent, } from "@aipm/core"; -import { D1Store } from "@aipm/db"; +import { createWorkspaceStore, LEGACY_WORKSPACE_ID, type WorkspaceId } from "@aipm/db"; import type { Env } from "./env.js"; +import { workspaceAudit } from "./tenancy/audit.js"; +import { createWorkspaceBudgetedLlm } from "./tenancy/budgets.js"; +import { buildConfigFromEnv, loadStoredWorkspaceConfig } from "./tenancy/config.js"; +import { workspaceInstallTokenKv } from "./tenancy/kv.js"; const DEFAULT_MODEL = "@cf/openai/gpt-oss-120b"; const DEFAULT_LLM_TIMEOUT_MS = 30_000; /** - * Assemble an EngineContext for one event inside the thread DO. The GitHub - * adapter is built per-event because the installation (and thus token) varies; - * this keeps token scoping correct (DESIGN §6). + * Assemble an EngineContext for one event inside the thread DO. Loads + * workspace_config when present; otherwise falls back to deployment env vars + * (legacy self-host path). */ -export function buildEngineContext(env: Env, event: RawEvent): Result { - // Fail safe: shadow stays ON unless explicitly disabled with "false", both - // globally and per capability — so a capability goes live only when its var is - // exactly "false" (DESIGN §8/§10 staged rollout). - const cap = (v: string | undefined) => (v === undefined ? undefined : v !== "false"); - const configResult = buildConfig({ - llmJudge: env.LLM_JUDGE === "true", - notesPrompt: promptVar(env.NOTES_PROMPT), - clusterPrompt: promptVar(env.CLUSTER_PROMPT), - shadow: { - global: env.SHADOW_GLOBAL !== "false", - capabilities: { - workingNotes: cap(env.SHADOW_WORKING_NOTES), - nudges: cap(env.SHADOW_NUDGES), - digest: cap(env.SHADOW_DIGEST), - proposals: cap(env.SHADOW_PROPOSALS), - orgRollup: cap(env.SHADOW_ORG_ROLLUP), - }, - }, - }); +export async function buildEngineContext( + env: Env, + event: RawEvent, + workspaceId: WorkspaceId = LEGACY_WORKSPACE_ID, +): Promise> { + const configResult = await loadWorkspaceEngineConfig(env, workspaceId); if (!configResult.ok) return configResult; const config = configResult.data; const identitiesResult = configIdentitySource(env.IDENTITY_ROSTER ?? "[]"); if (!identitiesResult.ok) return identitiesResult; - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, workspaceId); const platforms = new Map(); - platforms.set("github", buildGitHubAdapter(env, event, config.botAccounts)); - if (env.SLACK_BOT_TOKEN) { + platforms.set("github", buildGitHubAdapter(env, event, workspaceId, config.botAccounts)); + // A deployment-global Slack token is legacy self-host configuration. Managed + // workspaces stay GitHub-only until workspace-scoped Slack OAuth is shipped. + if (workspaceId === LEGACY_WORKSPACE_ID && env.SLACK_BOT_TOKEN) { platforms.set("slack", new SlackAdapter({ botToken: env.SLACK_BOT_TOKEN })); } @@ -69,17 +61,8 @@ export function buildEngineContext(env: Env, event: RawEvent): Result env.DELIVERY_DEDUPE.get(k), - put: (k, v, o) => env.DELIVERY_DEDUPE.put(k, v, o), - }, - perMinute: intVar(env.LLM_PER_MINUTE_BUDGET, 60), - perDay: intVar(env.LLM_DAILY_BUDGET, 1000), - }); + const llm: LlmAdapter = createWorkspaceBudgetedLlm(baseLlm, env, workspaceId); + const audit = workspaceAudit(env, workspaceId); return Ok({ store, @@ -88,9 +71,29 @@ export function buildEngineContext(env: Env, event: RawEvent): Result + audit.append({ + action: entry.action, + outcome: entry.outcome, + actor: { source: "worker", kind: "service" }, + ...(entry.repositoryId === undefined ? {} : { repositoryId: entry.repositoryId }), + detail: entry.detail, + }), + }, }); } +async function loadWorkspaceEngineConfig( + env: Env, + workspaceId: WorkspaceId, +): Promise> { + const stored = await loadStoredWorkspaceConfig(env, workspaceId); + if (!stored.ok) return stored; + if (stored.data) return Ok(stored.data); + return buildConfigFromEnv(env); +} + /** * Parse an integer Worker var, falling back to the default on a missing, blank, * or non-integer value — so a blank var can't silently disable a budget window @@ -101,22 +104,16 @@ function intVar(raw: string | undefined, fallback: number): number { return Number(raw.trim()); } -/** - * Normalize a prompt-override var: undefined when unset or blank (so the schema - * default takes over), otherwise the trimmed instruction text. - */ -function promptVar(v: string | undefined): string | undefined { - if (v === undefined) return undefined; - const trimmed = v.trim(); - if (!trimmed) return undefined; - return trimmed; -} - -function buildGitHubAdapter(env: Env, event: RawEvent, botAccounts: Array): GitHubAdapter { +function buildGitHubAdapter( + env: Env, + event: RawEvent, + workspaceId: WorkspaceId, + botAccounts: Array, +): GitHubAdapter { const token = env.GITHUB_APP_PRIVATE_KEY && env.GITHUB_APP_CLIENT_ID && event.installationId != null ? installationTokenProvider({ - kv: env.INSTALL_TOKENS, + kv: workspaceInstallTokenKv(env.INSTALL_TOKENS, workspaceId), privateKeyPem: env.GITHUB_APP_PRIVATE_KEY, clientId: env.GITHUB_APP_CLIENT_ID, installationId: event.installationId, diff --git a/apps/worker/src/coordinator.ts b/apps/worker/src/coordinator.ts index d4238f7..8e000da 100644 --- a/apps/worker/src/coordinator.ts +++ b/apps/worker/src/coordinator.ts @@ -4,7 +4,7 @@ import { installationTokenProvider, mintAppJwt, parseNativeId, - resolveRepoInstallationId, + resolveRepoInstallationId as lookupRepoInstallationId, } from "@aipm/adapter-github"; import { asyncForEach, @@ -23,10 +23,17 @@ import { type Thread, type ThreadType, } from "@aipm/core"; +import type { WorkspaceId } from "@aipm/db"; import { buildEngineContext } from "./context.js"; import type { Env } from "./env.js"; +import { getClusterCoordinator, getMergeRegistry } from "./tenancy/durable.js"; +import { + getCachedRepoInstallation, + putCachedRepoInstallation, + workspaceInstallTokens, + workspaceLog, +} from "./tenancy/index.js"; -const MERGE_REGISTRY_KEY = "global"; const MAX_FORWARD_HOPS = 8; const WORK_PREFIX = "work:"; const MAX_ATTEMPTS = 3; @@ -38,6 +45,7 @@ type ClusterWorkArgs = { threadNativeId: string; clusterId: string; hop: number; + workspaceId: WorkspaceId; }; interface ClusterWorkItem { @@ -117,14 +125,21 @@ export class ClusterCoordinator extends DurableObject { const deleted = await this.deleteIfCurrent(key, item.id); if (!deleted.ok) return deleted; } else if (item.attempts + 1 >= MAX_ATTEMPTS) { - console.error( + workspaceLog( + item.args.workspaceId, + "error", `cluster work failed permanently for ${item.args.threadNativeId}:`, processed.error, ); const deleted = await this.deleteIfCurrent(key, item.id); if (!deleted.ok) return deleted; } else { - console.error(`cluster work failed for ${item.args.threadNativeId}:`, processed.error); + workspaceLog( + item.args.workspaceId, + "error", + `cluster work failed for ${item.args.threadNativeId}:`, + processed.error, + ); const scheduled = await this.scheduleDrain(); if (!scheduled.ok) return scheduled; return Ok(undefined); @@ -181,7 +196,7 @@ export class ClusterCoordinator extends DurableObject { } private async processOne(args: ClusterWorkArgs): Promise> { - const ctxResult = buildEngineContext(this.env, args.event); + const ctxResult = await buildEngineContext(this.env, args.event, args.workspaceId); if (!ctxResult.ok) return ctxResult; const ctx = ctxResult.data; const store = ctx.store; @@ -210,10 +225,10 @@ export class ClusterCoordinator extends DurableObject { const counterpartCluster = counterpartClusterResult.data; const crossesClusters = ownCluster !== counterpartCluster; if (!crossesClusters) return Ok(undefined); - const registryId = this.env.MERGE_REGISTRY.idFromName(MERGE_REGISTRY_KEY); - const registry = this.env.MERGE_REGISTRY.get(registryId); + const registry = getMergeRegistry(this.env, args.workspaceId); const unioned = await Result.from(() => registry.union({ + workspaceId: args.workspaceId, threadA: thread.nativeId, threadB: counterpart, }), @@ -233,7 +248,9 @@ export class ClusterCoordinator extends DurableObject { if (mergedAway) return this.forward(args, ownerAfter); const hydratedGitHubThreads = - thread.platform === "slack" ? await this.hydrateLinkedGitHubThreads(ctx, thread, links) : []; + thread.platform === "slack" + ? await this.hydrateLinkedGitHubThreads(ctx, thread, links, args.workspaceId) + : []; const membersResult = await store.listClusterThreads(args.clusterId); if (!membersResult.ok) return membersResult; const members = membersResult.data; @@ -242,13 +259,20 @@ export class ClusterCoordinator extends DurableObject { if (cluster) { const synthesized = await synthesizeCluster(ctx, cluster); if (!synthesized.ok) { - console.error(`cluster synth failed for ${args.clusterId}:`, synthesized.error); + workspaceLog( + args.workspaceId, + "error", + `cluster synth failed for ${args.clusterId}:`, + synthesized.error, + ); } } await asyncForEach(hydratedGitHubThreads, async (hydrated) => { const synthesizedThread = await synthesize(hydrated.ctx, hydrated.thread, cluster); if (!synthesizedThread.ok) { - console.error( + workspaceLog( + args.workspaceId, + "error", `synthesize failed for ${hydrated.thread.nativeId}:`, synthesizedThread.error, ); @@ -257,16 +281,31 @@ export class ClusterCoordinator extends DurableObject { if (thread.platform === "github") { const synthesizedThread = await synthesize(ctx, thread, cluster); if (!synthesizedThread.ok) { - console.error(`synthesize failed for ${thread.nativeId}:`, synthesizedThread.error); + workspaceLog( + args.workspaceId, + "error", + `synthesize failed for ${thread.nativeId}:`, + synthesizedThread.error, + ); } } const signals = await evaluate(ctx, thread); if (!signals.ok) { - console.error(`evaluate failed for ${thread.nativeId}:`, signals.error); + workspaceLog( + args.workspaceId, + "error", + `evaluate failed for ${thread.nativeId}:`, + signals.error, + ); } else { const routed = await route(ctx, thread, signals.data); if (!routed.ok) { - console.error(`route failed for ${thread.nativeId}:`, routed.error); + workspaceLog( + args.workspaceId, + "error", + `route failed for ${thread.nativeId}:`, + routed.error, + ); } } return Ok(undefined); @@ -275,15 +314,19 @@ export class ClusterCoordinator extends DurableObject { async forward(args: ClusterWorkArgs, target: string): Promise> { const overLimit = args.hop >= MAX_FORWARD_HOPS; if (overLimit) { - console.error(`cluster forward hop limit for ${args.threadNativeId} -> ${target}`); + workspaceLog( + args.workspaceId, + "error", + `cluster forward hop limit for ${args.threadNativeId} -> ${target}`, + ); return Ok(undefined); } - const id = this.env.CLUSTER_COORDINATOR.idFromName(target); - const forwarded = await this.env.CLUSTER_COORDINATOR.get(id).process({ + const forwarded = await getClusterCoordinator(this.env, args.workspaceId, target).process({ event: args.event, threadNativeId: args.threadNativeId, clusterId: target, hop: args.hop + 1, + workspaceId: args.workspaceId, }); if (!forwarded.ok) return forwarded; return Ok(undefined); @@ -293,6 +336,7 @@ export class ClusterCoordinator extends DurableObject { ctx: EngineContext, sourceThread: Thread, links: Array, + workspaceId: WorkspaceId, ) { const typeHints = githubTypeHints(sourceThread); const candidateIds = links.flatMap((link) => { @@ -303,9 +347,14 @@ export class ClusterCoordinator extends DurableObject { const nativeIds = new Set(candidateIds); const hydratedResults = await asyncMap([...nativeIds], async (nativeId) => { - const linked = await this.hydrateLinkedGitHubThread(ctx, nativeId, typeHints); + const linked = await this.hydrateLinkedGitHubThread(ctx, nativeId, typeHints, workspaceId); if (linked.ok) return linked.data; - console.error(`linked GitHub hydration failed for ${nativeId}:`, linked.error); + workspaceLog( + workspaceId, + "error", + `linked GitHub hydration failed for ${nativeId}:`, + linked.error, + ); return undefined; }); return hydratedResults.flatMap((it) => (it ? [it] : [])); @@ -315,10 +364,12 @@ export class ClusterCoordinator extends DurableObject { ctx: EngineContext, nativeId: string, typeHints: Map, + workspaceId: WorkspaceId, ): Promise> { const parsed = parseNativeId(nativeId); if (!parsed.ok) return Ok(undefined); const installationId = await this.resolveRepoInstallationId( + workspaceId, parsed.data.owner, parsed.data.repo, ); @@ -329,7 +380,7 @@ export class ClusterCoordinator extends DurableObject { const github = new GitHubAdapter({ token: installationTokenProvider({ - kv: this.env.INSTALL_TOKENS, + kv: workspaceInstallTokens(this.env, workspaceId), privateKeyPem: privateKey, clientId: this.env.GITHUB_APP_CLIENT_ID, installationId: installationId.data, @@ -348,24 +399,27 @@ export class ClusterCoordinator extends DurableObject { } private async resolveRepoInstallationId( + workspaceId: WorkspaceId, owner: string, repo: string, ): Promise> { const privateKey = this.env.GITHUB_APP_PRIVATE_KEY; const clientId = this.env.GITHUB_APP_CLIENT_ID; if (!privateKey || !clientId) return Ok(undefined); - const key = `repo-inst:${owner}/${repo}`; - const cachedResult = await Result.from(() => this.env.INSTALL_TOKENS.get(key)); + const fullName = `${owner}/${repo}`; + const cachedResult = await Result.from(() => + getCachedRepoInstallation(this.env, workspaceId, fullName), + ); if (!cachedResult.ok) return cachedResult; const cached = cachedResult.data; if (cached && /^\d+$/.test(cached)) return Ok(Number(cached)); const jwt = await mintAppJwt(privateKey, clientId); if (!jwt.ok) return jwt; - const id = await resolveRepoInstallationId(jwt.data, owner, repo); + const id = await lookupRepoInstallationId(jwt.data, owner, repo); if (!id.ok) return id; const cachedInstallation = await Result.from(() => - this.env.INSTALL_TOKENS.put(key, String(id.data), { expirationTtl: 86_400 }), + putCachedRepoInstallation(this.env, workspaceId, fullName, id.data), ); if (!cachedInstallation.ok) return cachedInstallation; return Ok(id.data); diff --git a/apps/worker/src/env.ts b/apps/worker/src/env.ts index ef81539..769e122 100644 --- a/apps/worker/src/env.ts +++ b/apps/worker/src/env.ts @@ -1,6 +1,6 @@ -import type { RawEvent } from "@aipm/core"; import type { ClusterCoordinator } from "./coordinator.js"; import type { MergeRegistry } from "./merge-registry.js"; +import type { WorkspaceIngestMessage } from "./messages.js"; /** Bindings declared in wrangler.jsonc, plus secrets (set via `wrangler secret`). */ export interface Env { @@ -9,7 +9,7 @@ export interface Env { /** Webhook delivery-id dedupe + other short-lived flags incl. LLM budget counters. */ DELIVERY_DEDUPE: KVNamespace; INSTALL_TOKENS: KVNamespace; - INGEST_QUEUE: Queue; + INGEST_QUEUE: Queue; CLUSTER_COORDINATOR: DurableObjectNamespace; MERGE_REGISTRY: DurableObjectNamespace; /** Optional: absent in environments (e.g. local/test) without the AI binding configured. */ @@ -23,6 +23,12 @@ export interface Env { LLM_PER_MINUTE_BUDGET?: string; /** Max LLM calls per UTC day before the budget cap trips. Default 1000. */ LLM_DAILY_BUDGET?: string; + /** Deployment-wide LLM hard ceilings. These counters contain no tenant data. */ + GLOBAL_LLM_PER_MINUTE_HARD_CEILING?: string; + GLOBAL_LLM_DAILY_HARD_CEILING?: string; + /** Unpublished tenant ingress and deployment-wide hard ceilings. */ + TENANT_RATE_PER_MINUTE_CEILING?: string; + GLOBAL_RATE_PER_MINUTE_HARD_CEILING?: string; /** Hard wall-clock bound (ms) on one LLM completion under the cluster lock. Default 30000. */ LLM_REQUEST_TIMEOUT_MS?: string; /** Per-capability shadow overrides ("false" = go live for that capability). */ @@ -44,13 +50,23 @@ export interface Env { CLUSTER_PROMPT?: string; /** GitHub App client id (or numeric App id) — the JWT `iss`. */ GITHUB_APP_CLIENT_ID: string; + /** Public Worker origin used for OAuth callback URLs, e.g. https://api.thepm.dev. */ + PUBLIC_BASE_URL?: string; + /** Site origin for post-login redirects and CORS, e.g. https://thepm.dev. */ + SITE_ORIGIN?: string; + /** GitHub App slug for install deep-links. */ + GITHUB_APP_SLUG?: string; // secrets (DESIGN §9) /** PKCS#8 PEM ("BEGIN PRIVATE KEY"); convert GitHub's PKCS#1 download first. */ GITHUB_APP_PRIVATE_KEY?: string; GITHUB_WEBHOOK_SECRET?: string; + /** GitHub App OAuth client secret (user-to-server login). */ + GITHUB_OAUTH_CLIENT_SECRET?: string; SLACK_BOT_TOKEN?: string; SLACK_SIGNING_SECRET?: string; + /** Shared managed deployments disable legacy single-token Slack ingress. */ + MANAGED_MODE?: string; /** Identity roster JSON array (DESIGN §5); see @aipm/core configIdentitySource. */ IDENTITY_ROSTER?: string; /** Cron sweep targets: JSON `[{owner,repo,installationId}]` (DESIGN §10.1). */ diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index f3b44be..11bcd07 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -15,15 +15,51 @@ import { Result, type RawEvent, } from "@aipm/core"; -import { D1Store } from "@aipm/db"; +import { createWorkspaceStore, LEGACY_WORKSPACE_ID, type SweepRepository } from "@aipm/db"; import { Hono } from "hono"; import { buildEngineContext } from "./context.js"; import type { Env } from "./env.js"; +import { + createWorkspaceIngestMessage, + parseWorkspaceIngestMessage, + type WorkspaceIngestMessage, +} from "./messages.js"; +import { cors } from "hono/cors"; +import { apiRoutes } from "./routes/api.js"; +import { authRoutes } from "./routes/auth.js"; import { githubRoutes } from "./routes/github.js"; import { slackRoutes } from "./routes/slack.js"; +import { getClusterCoordinator } from "./tenancy/durable.js"; +import { + scheduledWorkspaceContext, + verifyInstallationBelongsToWorkspace, +} from "./tenancy/guards.js"; +import { workspaceInstallTokens, workspaceLog } from "./tenancy/index.js"; +import { listSweepRepositories } from "./tenancy/sweeps.js"; const app = new Hono<{ Bindings: Env }>(); +const siteCors = (origin: string) => + cors({ + origin, + credentials: true, + allowHeaders: ["Content-Type", "X-CSRF-Token"], + allowMethods: ["GET", "POST", "PUT", "OPTIONS"], + }); + +app.use("/auth/*", async (c, next) => { + const origin = c.env.SITE_ORIGIN; + if (!origin) return next(); + return siteCors(origin)(c as never, next); +}); +app.use("/api/*", async (c, next) => { + const origin = c.env.SITE_ORIGIN; + if (!origin) return next(); + return siteCors(origin)(c as never, next); +}); + +app.route("/auth", authRoutes); +app.route("/api", apiRoutes); app.route("/webhooks/github", githubRoutes); app.route("/webhooks/slack", slackRoutes); @@ -47,31 +83,33 @@ const DIGEST_CRON = "0 14 * * *"; /** Retry signal for a preference-capture infra failure (reason:"error") at the queue boundary. */ const PREFERENCE_CAPTURE_FAILED = "PREFERENCE_CAPTURE_FAILED"; -interface SweepRepo { - owner: string; - repo: string; - installationId: number; -} - export default { fetch: app.fetch, /** Ingest queue consumer (DESIGN §6): route each event to its cluster DO. */ - async queue(batch: MessageBatch, env: Env): Promise { + async queue(batch: MessageBatch, env: Env): Promise { await asyncForEach([...batch.messages], async (msg) => { + let logWorkspaceId = LEGACY_WORKSPACE_ID; const handled = await (async (): Promise> => { - if (msg.body.platform === "slack" && msg.body.event === "preference") { + const parsed = await parseWorkspaceIngestMessage(msg.body, (installationId, workspaceId) => + verifyInstallationBelongsToWorkspace(env, installationId, workspaceId), + ); + if (!parsed.ok) return parsed; + const { workspaceId, event } = parsed.data; + logWorkspaceId = workspaceId; + + if (event.platform === "slack" && event.event === "preference") { // Preference capture isn't thread-scoped; handle it directly (DESIGN §8). - const { slackUserId, text } = msg.body.payload as { slackUserId: string; text: string }; - const ctxResult = buildEngineContext(env, msg.body); + const { slackUserId, text } = event.payload as { slackUserId: string; text: string }; + const ctxResult = await buildEngineContext(env, event, workspaceId); if (!ctxResult.ok) return ctxResult; const captured = await capturePreference(ctxResult.data, slackUserId, text); if (captured.reason === "error") return Err(new Error(PREFERENCE_CAPTURE_FAILED)); return Ok(undefined); } - const nativeId = deriveNativeId(msg.body); + const nativeId = deriveNativeId(event); if (!nativeId) return Ok(undefined); - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, workspaceId); const existing = await store.findCluster(nativeId); if (!existing.ok) return existing; const cluster = await (async () => { @@ -79,49 +117,57 @@ export default { return store.getOrCreateCluster(nativeId); })(); if (!cluster.ok) return cluster; - const coordinatorId = env.CLUSTER_COORDINATOR.idFromName(cluster.data); - const processed = await env.CLUSTER_COORDINATOR.get(coordinatorId).process({ - event: msg.body, + const processed = await getClusterCoordinator(env, workspaceId, cluster.data).process({ + event, threadNativeId: nativeId, clusterId: cluster.data, hop: 0, + workspaceId, }); if (!processed.ok) return processed; return Ok(undefined); })(); if (handled.ok) msg.ack(); - else msg.retry(); + else { + workspaceLog(logWorkspaceId, "error", "queue message failed:", handled.error); + msg.retry(); + } }); }, /** Cron: the daily trigger builds per-person digests; others sweep (DESIGN §7/§8). */ async scheduled(event: ScheduledController, env: Env): Promise { if (event.cron === DIGEST_CRON) { - // No installation needed; pass a synthetic event. Digest + org rollup. - const ctxResult = buildEngineContext(env, { platform: "slack", payload: {} }); + // Digest remains on the legacy workspace until Slack is tenant-scoped. + const ctxResult = await buildEngineContext( + env, + { platform: "slack", payload: {} }, + LEGACY_WORKSPACE_ID, + ); if (!ctxResult.ok) throw ctxResult.error; const ctx = ctxResult.data; const aggregated = await aggregate(ctx); if (!aggregated.ok) { - console.error("digest cron failed:", aggregated.error); + workspaceLog(LEGACY_WORKSPACE_ID, "error", "digest cron failed:", aggregated.error); throw aggregated.error; } const rolled = await aggregateOrg(ctx, { channelId: env.ORG_ROLLUP_CHANNEL_ID }); if (!rolled.ok) { - console.error("digest cron failed:", rolled.error); + workspaceLog(LEGACY_WORKSPACE_ID, "error", "digest cron failed:", rolled.error); throw rolled.error; } return; } - const repos = parseSweepRepos(env.SWEEP_REPOS); - if (!repos.length || !env.GITHUB_APP_PRIVATE_KEY || !env.GITHUB_APP_CLIENT_ID) return; + const repos = await listSweepRepositories(env); + if (!repos.ok) throw repos.error; + if (!repos.data.length || !env.GITHUB_APP_PRIVATE_KEY || !env.GITHUB_APP_CLIENT_ID) return; const privateKeyPem = env.GITHUB_APP_PRIVATE_KEY; const clientId = env.GITHUB_APP_CLIENT_ID; - const sweepResults = await asyncMap(repos, async (sweepRepo) => { + const sweepResults = await asyncMap(repos.data, async (sweepRepo: SweepRepository) => { const token = installationTokenProvider({ - kv: env.INSTALL_TOKENS, + kv: workspaceInstallTokens(env, sweepRepo.workspaceId), privateKeyPem, clientId, installationId: sweepRepo.installationId, @@ -129,17 +175,18 @@ export default { const adapter = new GitHubAdapter({ token }); const threadsResult = await adapter.listThreads({ owner: sweepRepo.owner, - repo: sweepRepo.repo, + repo: sweepRepo.name, }); if (!threadsResult.ok) return threadsResult; const threads = threadsResult.data; + const context = scheduledWorkspaceContext(sweepRepo.workspaceId); const messages = threads.map((t) => ({ - body: { + body: createWorkspaceIngestMessage(context, { platform: "github" as const, event: "sweep", installationId: sweepRepo.installationId, payload: { nativeId: t.nativeId, type: t.type }, - }, + }), })); const batches = chunk(messages, 100); const sendResults = await asyncMap(batches, (batch) => @@ -157,14 +204,7 @@ export default { throw firstSweepError.error; } }, -} satisfies ExportedHandler; - -function parseSweepRepos(raw: string | undefined): Array { - if (!raw) return []; - const parsed = Result.fromSync(() => JSON.parse(raw) as unknown); - if (!parsed.ok) return []; - return Array.isArray(parsed.data) ? (parsed.data as Array) : []; -} +} satisfies ExportedHandler; export { ClusterCoordinator } from "./coordinator.js"; export { MergeRegistry } from "./merge-registry.js"; diff --git a/apps/worker/src/merge-registry.ts b/apps/worker/src/merge-registry.ts index 792e338..fe15d0c 100644 --- a/apps/worker/src/merge-registry.ts +++ b/apps/worker/src/merge-registry.ts @@ -1,17 +1,18 @@ import { DurableObject } from "cloudflare:workers"; import { Ok } from "@aipm/core"; -import { D1Store } from "@aipm/db"; +import { createWorkspaceStore, type WorkspaceId } from "@aipm/db"; import type { Env } from "./env.js"; /** - * Global singleton (addressed by idFromName("global")). Serializes every cluster - * merge under blockConcurrencyWhile so transitive merges can't interleave into a - * split. Re-resolves both threads to their live cluster ids inside the lock. + * Per-workspace merge registry (addressed via mergeRegistryName(workspaceId)). + * Serializes every cluster merge under blockConcurrencyWhile so transitive + * merges can't interleave into a split. Re-resolves both threads to their live + * cluster ids inside the lock. */ export class MergeRegistry extends DurableObject { - async union(args: { threadA: string; threadB: string }) { + async union(args: { workspaceId: WorkspaceId; threadA: string; threadB: string }) { const mergeResult = await this.ctx.blockConcurrencyWhile(async () => { - const store = new D1Store(this.env.DB); + const store = createWorkspaceStore(this.env.DB, args.workspaceId); const clusterAResult = await store.getOrCreateCluster(args.threadA); if (!clusterAResult.ok) return clusterAResult; const clusterA = clusterAResult.data; diff --git a/apps/worker/src/messages.ts b/apps/worker/src/messages.ts new file mode 100644 index 0000000..f3645a8 --- /dev/null +++ b/apps/worker/src/messages.ts @@ -0,0 +1,64 @@ +import { Err, Ok, type RawEvent, type Result } from "@aipm/core"; +import { workspaceIdFromTrustedSource, type WorkspaceContext, type WorkspaceId } from "@aipm/db"; + +const WORKSPACE_INGEST_MESSAGE_VERSION = 1 as const; + +export interface WorkspaceIngestMessage { + readonly version: typeof WORKSPACE_INGEST_MESSAGE_VERSION; + readonly workspaceId: WorkspaceId; + readonly installationId?: number; + readonly event: RawEvent; +} + +export const createWorkspaceIngestMessage = ( + context: WorkspaceContext, + event: RawEvent, +): WorkspaceIngestMessage => ({ + version: WORKSPACE_INGEST_MESSAGE_VERSION, + workspaceId: context.workspaceId, + installationId: + context.provenance.kind === "github-installation" + ? context.provenance.installationId + : event.installationId, + event, +}); + +export type VerifyInstallationWorkspace = ( + installationId: number, + workspaceId: WorkspaceId, +) => Promise; + +export const parseWorkspaceIngestMessage = async ( + value: unknown, + verifyInstallationWorkspace: VerifyInstallationWorkspace, +): Promise> => { + if (!isRecord(value) || value.version !== WORKSPACE_INGEST_MESSAGE_VERSION) { + return Err(new Error("UNSUPPORTED_WORKSPACE_MESSAGE")); + } + if (typeof value.workspaceId !== "string" || !isRawEvent(value.event)) { + return Err(new Error("INVALID_WORKSPACE_MESSAGE")); + } + const workspaceId = workspaceIdFromTrustedSource(value.workspaceId); + const installationId = value.installationId; + if (installationId !== undefined && typeof installationId !== "number") { + return Err(new Error("INVALID_WORKSPACE_MESSAGE")); + } + if ( + installationId !== undefined && + !(await verifyInstallationWorkspace(installationId, workspaceId)) + ) { + return Err(new Error("INSTALLATION_WORKSPACE_MISMATCH")); + } + return Ok({ + version: WORKSPACE_INGEST_MESSAGE_VERSION, + workspaceId, + installationId, + event: value.event, + }); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isRawEvent = (value: unknown): value is RawEvent => + isRecord(value) && (value.platform === "github" || value.platform === "slack"); diff --git a/apps/worker/src/routes/api.ts b/apps/worker/src/routes/api.ts new file mode 100644 index 0000000..adae95f --- /dev/null +++ b/apps/worker/src/routes/api.ts @@ -0,0 +1,416 @@ +import { buildConfig, type EngineConfigInput } from "@aipm/config"; +import { installationTokenProvider, mintAppJwt } from "@aipm/adapter-github"; +import { Err, Ok, Result } from "@aipm/core"; +import { + createWorkspaceForGithubAccount, + enableWorkspaceCapability, + ensureWorkspaceMember, + findWorkspaceByGithubAccount, + getWorkspaceConfig, + listAuditActions, + listGithubRepositories, + listInstallationsForWorkspace, + listWorkspacesForUser, + setRepositoriesEnabled, + upsertGithubInstallation, + upsertGithubRepositories, + upsertWorkspaceConfig, + type GithubAccountRef, + type GithubRepositoryRef, + type ManagedCapability, + type WorkspaceId, +} from "@aipm/db"; +import { Hono } from "hono"; +import type { Env } from "../env.js"; +import { workspaceAudit } from "../tenancy/audit.js"; +import { requireWorkspaceMemberByParam } from "../tenancy/guards.js"; +import { + CSRF_COOKIE, + csrfCookieHeader, + mintCsrfToken, + readCookie, + requireAuthedUser, + requireCsrf, +} from "./auth.js"; + +export const apiRoutes = new Hono<{ Bindings: Env }>(); + +apiRoutes.get("/me", async (c) => { + const user = await requireAuthedUser(c); + if (!user.ok) return c.json({ error: "unauthenticated" }, 401); + const workspaces = await listWorkspacesForUser(c.env.DB, user.data.userId); + if (!workspaces.ok) return c.json({ error: "me_failed" }, 500); + + const existingCsrf = readCookie(c.req.header("cookie") ?? null, CSRF_COOKIE); + const csrfToken = existingCsrf ?? mintCsrfToken(); + const payload = { + user: { id: user.data.userId, githubLogin: user.data.githubLogin }, + workspaces: workspaces.data, + slack: { available: false, status: "coming_next" as const }, + csrfToken, + }; + const res = c.json(payload); + if (!existingCsrf) res.headers.append("Set-Cookie", csrfCookieHeader(csrfToken)); + return res; +}); + +/** GitHub App setup URL landing: reconnect/create workspace from installation id. */ +apiRoutes.get("/setup/github/callback", async (c) => { + const user = await requireAuthedUser(c); + if (!user.ok) { + const publicBase = c.env.PUBLIC_BASE_URL ?? ""; + const site = c.env.SITE_ORIGIN ?? publicBase; + const query = c.req.url.includes("?") ? c.req.url.slice(c.req.url.indexOf("?")) : ""; + const returnTo = encodeURIComponent( + `${trimSlash(publicBase)}/api/setup/github/callback${query}`, + ); + return c.redirect(`${trimSlash(site)}/login?returnTo=${returnTo}`, 302); + } + + const installationIdRaw = c.req.query("installation_id"); + const installationId = installationIdRaw ? Number(installationIdRaw) : NaN; + if (!Number.isFinite(installationId)) return c.json({ error: "installation_id_required" }, 400); + + const account = await fetchInstallationAccount(c.env, installationId); + if (!account.ok) return c.json({ error: "installation_lookup_failed" }, 502); + + const existing = await findWorkspaceByGithubAccount(c.env.DB, account.data); + if (!existing.ok) return c.json({ error: "workspace_lookup_failed" }, 500); + + const workspaceId: Result = existing.data + ? Ok(existing.data) + : await createWorkspaceForGithubAccount(c.env.DB, account.data); + if (!workspaceId.ok) return c.json({ error: "workspace_create_failed" }, 500); + + const member = await ensureWorkspaceMember(c.env.DB, workspaceId.data, user.data.userId, "owner"); + if (!member.ok) return c.json({ error: "membership_failed" }, 500); + + const install = await upsertGithubInstallation( + c.env.DB, + workspaceId.data, + installationId, + "active", + ); + if (!install.ok) return c.json({ error: "installation_record_failed" }, 500); + + // Best-effort repo sync; lifecycle webhooks remain source of truth (sibling-owned). + const repos = await listInstallationRepositories(c.env, installationId); + if (repos.ok && repos.data.length) { + const written = await upsertGithubRepositories( + c.env.DB, + workspaceId.data, + installationId, + repos.data, + ); + if (!written.ok) return c.json({ error: "repository_sync_failed" }, 500); + } + + const site = c.env.SITE_ORIGIN ?? c.env.PUBLIC_BASE_URL; + if (!site) return c.json({ workspaceId: workspaceId.data }); + return c.redirect(`${trimSlash(site)}/setup/repositories?workspace=${workspaceId.data}`, 302); +}); + +apiRoutes.get("/workspaces/:workspaceId/repositories", async (c) => { + const gated = await authorizeWorkspace(c); + if (!gated.ok) return gated.response; + const repos = await listGithubRepositories(c.env.DB, gated.data.workspaceId); + if (!repos.ok) return c.json({ error: "repositories_failed" }, 500); + const installations = await listInstallationsForWorkspace(c.env.DB, gated.data.workspaceId); + if (!installations.ok) return c.json({ error: "installations_failed" }, 500); + return c.json({ + repositories: repos.data, + installations: installations.data, + shadowDefault: true, + }); +}); + +apiRoutes.put("/workspaces/:workspaceId/repositories", async (c) => { + const csrf = requireCsrf(c); + if (!csrf.ok) return c.json({ error: "csrf_required" }, 403); + const gated = await authorizeWorkspace(c); + if (!gated.ok) return gated.response; + const body = await Result.from(() => c.req.json()); + if (!body.ok) return c.json({ error: "invalid_json" }, 400); + const enabledIds = parseNumberArray( + isRecord(body.data) ? body.data.enabledRepositoryIds : undefined, + ); + if (!enabledIds.ok) return c.json({ error: "enabledRepositoryIds_required" }, 400); + const updated = await setRepositoriesEnabled(c.env.DB, gated.data.workspaceId, enabledIds.data); + if (!updated.ok) return c.json({ error: "repositories_update_failed" }, 500); + const audited = await workspaceAudit(c.env, gated.data.workspaceId).append({ + action: "repositories.enabled_set", + outcome: "revised", + actor: { + source: "control-plane", + id: gated.data.userId, + login: gated.data.githubLogin, + kind: "user", + }, + detail: { enabledRepositoryIds: enabledIds.data }, + }); + if (!audited.ok) return c.json({ error: "audit_failed" }, 500); + const repos = await listGithubRepositories(c.env.DB, gated.data.workspaceId); + if (!repos.ok) return c.json({ error: "repositories_failed" }, 500); + return c.json({ repositories: repos.data }); +}); + +apiRoutes.get("/workspaces/:workspaceId/config", async (c) => { + const gated = await authorizeWorkspace(c); + if (!gated.ok) return gated.response; + const stored = await getWorkspaceConfig(c.env.DB, gated.data.workspaceId); + if (!stored.ok) return c.json({ error: "config_failed" }, 500); + const configRow = stored.data; + const partial = configRow + ? Result.fromSync(() => JSON.parse(configRow.configJson) as Partial) + : Ok({ shadow: { global: true, capabilities: {} } }); + if (!partial.ok) return c.json({ error: "config_invalid" }, 500); + const built = buildConfig(partial.data); + if (!built.ok) return c.json({ error: "config_invalid" }, 500); + return c.json({ + config: built.data, + revision: configRow?.revision ?? 0, + updatedAt: configRow?.updatedAt ?? null, + updatedBy: configRow?.updatedBy ?? null, + slack: { available: false, status: "coming_next" }, + }); +}); + +apiRoutes.put("/workspaces/:workspaceId/config", async (c) => { + const csrf = requireCsrf(c); + if (!csrf.ok) return c.json({ error: "csrf_required" }, 403); + const gated = await authorizeWorkspace(c); + if (!gated.ok) return gated.response; + const body = await Result.from(() => c.req.json()); + if (!body.ok || !isRecord(body.data)) return c.json({ error: "invalid_json" }, 400); + const configInput = + body.data.config !== undefined && isRecord(body.data.config) ? body.data.config : body.data; + const built = buildConfig(configInput); + if (!built.ok) return c.json({ error: "config_invalid", detail: String(built.error) }, 400); + const shadowSafe = { + ...built.data, + shadow: { + ...built.data.shadow, + global: built.data.shadow.global, + }, + }; + const configJson = Result.fromSync(() => JSON.stringify(shadowSafe)); + if (!configJson.ok) return c.json({ error: "config_serialize_failed" }, 500); + const saved = await upsertWorkspaceConfig( + c.env.DB, + gated.data.workspaceId, + configJson.data, + gated.data.userId, + ); + if (!saved.ok) return c.json({ error: "config_save_failed" }, 500); + const audited = await workspaceAudit(c.env, gated.data.workspaceId).append({ + action: "config.updated", + outcome: "revised", + actor: { + source: "control-plane", + id: gated.data.userId, + login: gated.data.githubLogin, + kind: "user", + }, + detail: { revision: saved.data.revision }, + }); + if (!audited.ok) return c.json({ error: "audit_failed" }, 500); + return c.json({ + config: shadowSafe, + revision: saved.data.revision, + updatedAt: saved.data.updatedAt, + updatedBy: saved.data.updatedBy, + }); +}); + +apiRoutes.post("/workspaces/:workspaceId/capabilities", async (c) => { + const csrf = requireCsrf(c); + if (!csrf.ok) return c.json({ error: "csrf_required" }, 403); + const gated = await authorizeWorkspace(c); + if (!gated.ok) return gated.response; + const body = await Result.from(() => c.req.json()); + if (!body.ok || !isRecord(body.data)) return c.json({ error: "invalid_json" }, 400); + const capability = body.data.capability; + const shadow = body.data.shadow; + if (typeof capability !== "string" || !isManagedCapability(capability)) { + return c.json({ error: "capability_required" }, 400); + } + if (shadow !== false) { + return c.json({ error: "only_go_live_supported" }, 400); + } + const revised = await enableWorkspaceCapability(c.env.DB, gated.data.workspaceId, capability, { + source: "control-plane", + id: gated.data.userId, + login: gated.data.githubLogin, + kind: "user", + }); + if (!revised.ok) return c.json({ error: "capability_transition_failed" }, 500); + return c.json({ capability, shadow: false, revision: revised.data }); +}); + +apiRoutes.get("/workspaces/:workspaceId/activity", async (c) => { + const gated = await authorizeWorkspace(c); + if (!gated.ok) return gated.response; + const limitRaw = c.req.query("limit"); + const limit = limitRaw ? Number(limitRaw) : 50; + const items = await listAuditActions( + c.env.DB, + gated.data.workspaceId, + Number.isFinite(limit) ? limit : 50, + ); + if (!items.ok) return c.json({ error: "activity_failed" }, 500); + const repositoryIdRaw = c.req.query("repositoryId"); + const repositoryId = repositoryIdRaw ? Number(repositoryIdRaw) : undefined; + const filtered = + repositoryId !== undefined && Number.isFinite(repositoryId) + ? items.data.filter((item) => item.repositoryId === repositoryId) + : items.data; + return c.json({ items: filtered }); +}); + +apiRoutes.get("/workspaces/:workspaceId/activity/:actionId", async (c) => { + const gated = await authorizeWorkspace(c); + if (!gated.ok) return gated.response; + const items = await listAuditActions(c.env.DB, gated.data.workspaceId, 250); + if (!items.ok) return c.json({ error: "activity_failed" }, 500); + const actionId = c.req.param("actionId"); + const item = items.data.find((entry) => entry.id === actionId); + if (!item) return c.json({ error: "not_found" }, 404); + return c.json({ item }); +}); + +type AuthedWorkspace = { + workspaceId: WorkspaceId; + userId: string; + githubLogin: string; + role: "owner" | "admin" | "member"; +}; + +const authorizeWorkspace = async (c: { + env: Env; + req: { + param: (name: string) => string; + header: (name: string) => string | undefined; + }; +}): Promise<{ ok: true; data: AuthedWorkspace } | { ok: false; response: Response }> => { + const user = await requireAuthedUser(c); + if (!user.ok) return { ok: false, response: jsonError({ error: "unauthenticated" }, 401) }; + const member = await requireWorkspaceMemberByParam( + c.env, + c.req.param("workspaceId"), + user.data.userId, + ); + if (!member.ok) return { ok: false, response: jsonError({ error: "forbidden" }, 403) }; + return { + ok: true, + data: { + workspaceId: member.data.context.workspaceId, + userId: user.data.userId, + githubLogin: user.data.githubLogin, + role: member.data.role, + }, + }; +}; + +const jsonError = (body: unknown, status: number) => + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); + +const fetchInstallationAccount = async ( + env: Env, + installationId: number, +): Promise> => { + if (!env.GITHUB_APP_PRIVATE_KEY || !env.GITHUB_APP_CLIENT_ID) { + return Err(new Error("GITHUB_APP_NOT_CONFIGURED")); + } + // TODO(lifecycle): prefer installation account fields persisted by webhook handlers when available. + const jwt = await mintAppJwt(env.GITHUB_APP_PRIVATE_KEY, env.GITHUB_APP_CLIENT_ID); + if (!jwt.ok) return jwt; + const response = await Result.from(() => + fetch(`https://api.github.com/app/installations/${String(installationId)}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${jwt.data}`, + "User-Agent": "aipm-managed", + "X-GitHub-Api-Version": "2022-11-28", + }, + }), + ); + if (!response.ok) return response; + if (!response.data.ok) return Err(new Error("INSTALLATION_HTTP")); + const body = await Result.from(() => response.data.json()); + if (!body.ok) return body; + if (!isRecord(body.data) || !isRecord(body.data.account)) { + return Err(new Error("INSTALLATION_ACCOUNT_MISSING")); + } + const account = body.data.account; + if ( + typeof account.id !== "number" || + typeof account.login !== "string" || + (account.type !== "Organization" && account.type !== "User") + ) { + return Err(new Error("INSTALLATION_ACCOUNT_INVALID")); + } + return Ok({ + id: account.id, + login: account.login, + type: account.type, + }); +}; + +const listInstallationRepositories = async ( + env: Env, + installationId: number, +): Promise, Error>> => { + if (!env.GITHUB_APP_PRIVATE_KEY || !env.GITHUB_APP_CLIENT_ID) return Ok([]); + const token = await installationTokenProvider({ + kv: env.INSTALL_TOKENS, + privateKeyPem: env.GITHUB_APP_PRIVATE_KEY, + clientId: env.GITHUB_APP_CLIENT_ID, + installationId, + })(); + if (!token.ok) return Ok([]); + const response = await Result.from(() => + fetch("https://api.github.com/installation/repositories?per_page=100", { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${token.data}`, + "User-Agent": "aipm-managed", + "X-GitHub-Api-Version": "2022-11-28", + }, + }), + ); + if (!response.ok || !response.data.ok) return Ok([]); + const body = await Result.from(() => response.data.json()); + if (!body.ok || !isRecord(body.data) || !Array.isArray(body.data.repositories)) return Ok([]); + return Ok( + body.data.repositories.flatMap((repo) => { + if (!isRecord(repo)) return []; + if (typeof repo.id !== "number" || typeof repo.name !== "string") return []; + const fullName = typeof repo.full_name === "string" ? repo.full_name : `unknown/${repo.name}`; + const owner = fullName.split("/")[0]; + if (!owner) return []; + return [{ id: repo.id, owner, name: repo.name, fullName }]; + }), + ); +}; + +const parseNumberArray = (value: unknown): Result, Error> => { + if (!Array.isArray(value) || value.some((item) => typeof item !== "number")) { + return Err(new Error("INVALID_NUMBER_ARRAY")); + } + return Ok(value); +}; + +const isManagedCapability = (value: string): value is ManagedCapability => + value === "workingNotes" || + value === "nudges" || + value === "digest" || + value === "proposals" || + value === "orgRollup"; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const trimSlash = (value: string) => value.replace(/\/+$/, ""); diff --git a/apps/worker/src/routes/auth.ts b/apps/worker/src/routes/auth.ts new file mode 100644 index 0000000..a82b7df --- /dev/null +++ b/apps/worker/src/routes/auth.ts @@ -0,0 +1,203 @@ +import { Err, Ok, Result } from "@aipm/core"; +import { + clearOAuthStateCookieHeader, + clearSessionCookieHeader, + consumeOAuthState, + createUserSession, + mintOAuthState, + OAUTH_STATE_COOKIE, + oauthStateCookieHeader, + readCookie, + resolveSessionToken, + revokeSessionToken, + SESSION_COOKIE, + sessionCookieHeader, +} from "../auth/session.js"; +import type { Env } from "../env.js"; +import { Hono } from "hono"; + +export { readCookie }; + +export const authRoutes = new Hono<{ Bindings: Env }>(); + +authRoutes.get("/github", async (c) => { + const clientId = c.env.GITHUB_APP_CLIENT_ID; + const publicBase = c.env.PUBLIC_BASE_URL; + if (!clientId || !publicBase) { + return c.json({ error: "oauth_not_configured" }, 503); + } + const returnTo = c.req.query("returnTo") ?? undefined; + const minted = await mintOAuthState(c.env.DELIVERY_DEDUPE, returnTo); + if (!minted.ok) return c.json({ error: "oauth_state_failed" }, 500); + const redirectUri = `${trimSlash(publicBase)}/auth/github/callback`; + const url = new URL("https://github.com/login/oauth/authorize"); + url.searchParams.set("client_id", clientId); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("state", minted.data.token); + url.searchParams.set("scope", "read:user"); + const res = c.redirect(url.toString(), 302); + res.headers.append("Set-Cookie", oauthStateCookieHeader(minted.data.token)); + return res; +}); + +authRoutes.get("/github/callback", async (c) => { + const code = c.req.query("code"); + const state = c.req.query("state"); + const cookieState = readCookie(c.req.header("cookie") ?? null, OAUTH_STATE_COOKIE); + const verified = await consumeOAuthState(c.env.DELIVERY_DEDUPE, cookieState, state); + if (!verified.ok) return c.json({ error: "invalid_oauth_state" }, 400); + if (!code) return c.json({ error: "missing_code" }, 400); + + const clientId = c.env.GITHUB_APP_CLIENT_ID; + const clientSecret = c.env.GITHUB_OAUTH_CLIENT_SECRET; + const publicBase = c.env.PUBLIC_BASE_URL; + const siteOrigin = c.env.SITE_ORIGIN ?? publicBase; + if (!clientId || !clientSecret || !publicBase || !siteOrigin) { + return c.json({ error: "oauth_not_configured" }, 503); + } + + const tokenResult = await exchangeCodeForToken({ + clientId, + clientSecret, + code, + redirectUri: `${trimSlash(publicBase)}/auth/github/callback`, + }); + if (!tokenResult.ok) return c.json({ error: "oauth_exchange_failed" }, 502); + + const profile = await fetchGithubUser(tokenResult.data); + if (!profile.ok) return c.json({ error: "oauth_profile_failed" }, 502); + + const session = await createUserSession(c.env, { + githubUserId: profile.data.id, + githubLogin: profile.data.login, + }); + if (!session.ok) return c.json({ error: "session_create_failed" }, 500); + + const returnTo = + safeReturnTo(verified.data.returnTo, siteOrigin) ?? `${trimSlash(siteOrigin)}/setup/github`; + const res = c.redirect(returnTo, 302); + res.headers.append("Set-Cookie", sessionCookieHeader(session.data.token, session.data.expiresAt)); + res.headers.append("Set-Cookie", clearOAuthStateCookieHeader()); + return res; +}); + +authRoutes.post("/logout", async (c) => { + const csrf = requireCsrf(c); + if (!csrf.ok) return c.json({ error: "csrf_required" }, 403); + const token = readCookie(c.req.header("cookie") ?? null, SESSION_COOKIE); + const revoked = await revokeSessionToken(c.env, token); + if (!revoked.ok) return c.json({ error: "logout_failed" }, 500); + const res = c.json({ ok: true }); + res.headers.append("Set-Cookie", clearSessionCookieHeader()); + res.headers.append("Set-Cookie", clearCsrfCookieHeader()); + return res; +}); + +const exchangeCodeForToken = async (input: { + clientId: string; + clientSecret: string; + code: string; + redirectUri: string; +}): Promise> => { + const response = await Result.from(() => + fetch("https://github.com/login/oauth/access_token", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: input.clientId, + client_secret: input.clientSecret, + code: input.code, + redirect_uri: input.redirectUri, + }), + }), + ); + if (!response.ok) return response; + if (!response.data.ok) return Err(new Error("OAUTH_TOKEN_HTTP")); + const body = await Result.from(() => response.data.json()); + if (!body.ok) return body; + if (!isRecord(body.data) || typeof body.data.access_token !== "string") { + return Err(new Error("OAUTH_TOKEN_MISSING")); + } + return Ok(body.data.access_token); +}; + +const fetchGithubUser = async ( + accessToken: string, +): Promise> => { + const response = await Result.from(() => + fetch("https://api.github.com/user", { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${accessToken}`, + "User-Agent": "aipm-managed", + "X-GitHub-Api-Version": "2022-11-28", + }, + }), + ); + if (!response.ok) return response; + if (!response.data.ok) return Err(new Error("OAUTH_USER_HTTP")); + const body = await Result.from(() => response.data.json()); + if (!body.ok) return body; + if ( + !isRecord(body.data) || + typeof body.data.id !== "number" || + typeof body.data.login !== "string" + ) { + return Err(new Error("OAUTH_USER_INVALID")); + } + return Ok({ id: body.data.id, login: body.data.login }); +}; + +const trimSlash = (value: string) => value.replace(/\/+$/, ""); + +const safeReturnTo = (candidate: string | undefined, siteOrigin: string): string | undefined => { + if (!candidate) return undefined; + if (candidate.startsWith("/") && !candidate.startsWith("//")) { + return `${trimSlash(siteOrigin)}${candidate}`; + } + const parsed = Result.fromSync(() => new URL(candidate)); + if (!parsed.ok) return undefined; + const origin = Result.fromSync(() => new URL(siteOrigin)); + if (!origin.ok) return undefined; + if (parsed.data.origin !== origin.data.origin) return undefined; + return parsed.data.toString(); +}; + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +export const CSRF_COOKIE = "aipm_csrf"; +export const CSRF_HEADER = "x-csrf-token"; + +export const mintCsrfToken = (): string => crypto.randomUUID().replaceAll("-", ""); + +export const csrfCookieHeader = (token: string): string => + `${CSRF_COOKIE}=${token}; Path=/; Secure; SameSite=Lax; Max-Age=${String(60 * 60 * 24 * 30)}`; + +const clearCsrfCookieHeader = (): string => + `${CSRF_COOKIE}=; Path=/; Secure; SameSite=Lax; Max-Age=0`; + +export const requireCsrf = (c: { + req: { header: (name: string) => string | undefined }; +}): Result => { + const cookieToken = readCookie(c.req.header("cookie") ?? null, CSRF_COOKIE); + const headerToken = c.req.header(CSRF_HEADER); + if (!cookieToken || !headerToken || cookieToken !== headerToken) { + return Err(new Error("CSRF_REQUIRED")); + } + return Ok(undefined); +}; + +export const requireAuthedUser = async (c: { + env: Env; + req: { header: (name: string) => string | undefined }; +}): Promise> => { + const token = readCookie(c.req.header("cookie") ?? null, SESSION_COOKIE); + const session = await resolveSessionToken(c.env, token); + if (!session.ok) return session; + if (!session.data) return Err(new Error("UNAUTHENTICATED")); + return Ok(session.data); +}; diff --git a/apps/worker/src/routes/github.ts b/apps/worker/src/routes/github.ts index 13c0ce3..589bb0d 100644 --- a/apps/worker/src/routes/github.ts +++ b/apps/worker/src/routes/github.ts @@ -1,18 +1,31 @@ import { verifyWebhook } from "@aipm/adapter-github"; -import { NOTES_MARKER, Ok, Result } from "@aipm/core"; +import { NOTES_MARKER, Ok, Result, type RawEvent } from "@aipm/core"; +import { LEGACY_WORKSPACE_ID } from "@aipm/db"; import { Hono } from "hono"; import { markDelivered } from "../dedupe.js"; import type { Env } from "../env.js"; -import { memberGate } from "../members.js"; +import { createWorkspaceIngestMessage } from "../messages.js"; +import { workspaceActorGate } from "../tenancy/actors.js"; +import { workspaceAudit } from "../tenancy/audit.js"; +import { consumeWorkspaceRateBudget } from "../tenancy/budgets.js"; +import { + installationWorkspaceId, + requireEnabledRepositoryUnlessLegacy, + resolveWorkspaceInstallationOrLegacy, +} from "../tenancy/guards.js"; +import { workspaceDeliveryDedupe, workspaceLog } from "../tenancy/index.js"; +import { deliveryKey } from "../tenancy/keys.js"; +import { + handleGithubInstallationLifecycle, + isInstallationLifecycleEvent, + type InstallationLifecyclePayload, +} from "../tenancy/lifecycle.js"; export const githubRoutes = new Hono<{ Bindings: Env }>(); -interface GithubWebhookBody { - action?: string; - installation?: { id?: number }; +interface GithubWebhookBody extends InstallationLifecyclePayload { comment?: { body?: string }; - /** The user whose action fired this webhook — drives the member-trigger gate. */ - sender?: { login?: string }; + repository?: { id?: number; full_name?: string; name?: string }; } githubRoutes.post("/", async (c) => { @@ -26,72 +39,153 @@ githubRoutes.post("/", async (c) => { if (!verified.ok) throw verified.error; if (!verified.data) return c.json({ error: "bad signature" }, 401); - // Delivery-id dedupe in KV (DESIGN §6/§9). + const parsed = Result.fromSync(() => JSON.parse(raw.data) as unknown); + if (!parsed.ok) return c.json({ error: "invalid json" }, 400); + if (!isGithubWebhookBody(parsed.data)) return c.json({ error: "invalid payload" }, 400); + const body = parsed.data; + const eventName = c.req.header("x-github-event") ?? undefined; const delivery = c.req.header("x-github-delivery") ?? undefined; + + if (eventName !== undefined && isInstallationLifecycleEvent(eventName)) { + const handled = await handleGithubInstallationLifecycle(c.env, eventName, body); + if (!handled.ok) { + console.error("installation lifecycle failed:", handled.error); + throw handled.error; + } + const dedupe = workspaceDeliveryDedupe(c.env, handled.data.workspaceId); + const delivered = await markDelivered( + c.env.DELIVERY_DEDUPE, + delivery ? dedupe.key(githubDeliverySuffix(delivery)) : null, + ); + if (!delivered.ok) throw delivered.error; + return c.json({ ok: true, handled: handled.data.handled }); + } + + const workspace = await resolveWorkspaceInstallationOrLegacy(c.env, body.installation?.id); + if (!workspace.ok) { + await markRejectedDelivery(c.env, body.installation?.id, delivery); + return c.json({ ok: true, ignored: workspace.error.message }); + } + + const rate = await consumeWorkspaceRateBudget(c.env, workspace.data.workspaceId); + if (!rate.ok) throw rate.error; + if (!rate.data.allowed) { + const key = delivery + ? deliveryKey(workspace.data.workspaceId, githubDeliverySuffix(delivery)) + : null; + const delivered = await markDelivered(c.env.DELIVERY_DEDUPE, key); + if (!delivered.ok) throw delivered.error; + const audited = await workspaceAudit(c.env, workspace.data.workspaceId).append({ + action: "github.ingress", + outcome: "skipped", + actor: { + source: "github", + kind: "installation", + ...(body.installation?.id === undefined ? {} : { id: String(body.installation.id) }), + }, + detail: { reason: "safety_ceiling", exhausted: rate.data.exhausted }, + }); + if (!audited.ok) throw audited.error; + workspaceLog( + workspace.data.workspaceId, + "info", + `github event skipped: ${rate.data.exhausted ?? "unknown"} rate ceiling`, + ); + return c.json({ ok: true, ignored: "safety-ceiling" }); + } + + const dedupeKv = workspaceDeliveryDedupe(c.env, workspace.data.workspaceId); + const deliverySuffix = delivery ? githubDeliverySuffix(delivery) : null; const dedupe = await (async () => { - if (!delivery) return Ok(null); - return Result.from(() => c.env.DELIVERY_DEDUPE.get(`gh:${delivery}`)); + if (!deliverySuffix) return Ok(null); + return Result.from(() => dedupeKv.get(deliverySuffix)); })(); if (!dedupe.ok) throw dedupe.error; if (dedupe.data) { return c.json({ ok: true, deduped: true }); } - // Carry the discriminators the engine needs: event name (header — the only - // reliable classifier), action, delivery id, and installation id (for token). - const parsed = Result.fromSync(() => JSON.parse(raw.data) as unknown); - if (!parsed.ok) return c.json({ error: "invalid json" }, 400); - if (!isGithubWebhookBody(parsed.data)) return c.json({ error: "invalid payload" }, 400); - const body = parsed.data; - // Ignore the bot's own sticky-note comment edits — otherwise editing the note // fires issue_comment events that re-ingest and re-edit it in a loop. - if ( - c.req.header("x-github-event") === "issue_comment" && - body.comment?.body?.includes(NOTES_MARKER) - ) { + if (eventName === "issue_comment" && body.comment?.body?.includes(NOTES_MARKER)) { const delivered = await markDelivered( c.env.DELIVERY_DEDUPE, - delivery ? `gh:${delivery}` : null, + deliverySuffix ? dedupeKv.key(deliverySuffix) : null, ); if (!delivered.ok) throw delivered.error; return c.json({ ok: true, ignored: "own-comment" }); } + const enabledRepo = await requireEnabledRepositoryUnlessLegacy( + c.env, + workspace.data, + body.repository?.id, + ); + if (!enabledRepo.ok) { + const delivered = await markDelivered( + c.env.DELIVERY_DEDUPE, + deliverySuffix ? dedupeKv.key(deliverySuffix) : null, + ); + if (!delivered.ok) throw delivered.error; + return c.json({ ok: true, ignored: "repository-disabled" }); + } + // Member-trigger gate: drop events fired by non-members before any spend - // (queue/DO/LLM), so a public repo's strangers — or a comment loop — can't run - // up the bill. Default on; bypass with REQUIRE_MEMBER_TRIGGER="false". - const gate = memberGate(c.env); + // (queue/DO/LLM). Managed workspaces use membership; legacy uses IDENTITY_ROSTER. + const gate = workspaceActorGate(c.env, workspace.data.workspaceId); if (!gate.ok) throw gate.error; const allowed = await gate.data.allows("github", body.sender?.login); if (!allowed) { const delivered = await markDelivered( c.env.DELIVERY_DEDUPE, - delivery ? `gh:${delivery}` : null, + deliverySuffix ? dedupeKv.key(deliverySuffix) : null, ); if (!delivered.ok) throw delivered.error; return c.json({ ok: true, ignored: "non-member" }); } - const queued = await Result.from(() => - c.env.INGEST_QUEUE.send({ - platform: "github", - event: c.req.header("x-github-event") ?? undefined, - action: body.action, - deliveryId: delivery, - installationId: body.installation?.id, - payload: body, - }), - ); + const event: RawEvent = { + platform: "github", + event: eventName, + action: body.action, + deliveryId: delivery, + installationId: body.installation?.id, + payload: body, + }; + const message = createWorkspaceIngestMessage(workspace.data, event); + const queued = await Result.from(() => c.env.INGEST_QUEUE.send(message)); if (!queued.ok) throw queued.error; // Mark delivered only after a successful enqueue, so an enqueue failure (which // returns 5xx and is retried by GitHub) can't permanently drop the event. - const delivered = await markDelivered(c.env.DELIVERY_DEDUPE, delivery ? `gh:${delivery}` : null); + const delivered = await markDelivered( + c.env.DELIVERY_DEDUPE, + deliverySuffix ? dedupeKv.key(deliverySuffix) : null, + ); if (!delivered.ok) throw delivered.error; + workspaceLog(workspace.data.workspaceId, "info", `enqueued github ${eventName ?? "event"}`); return c.json({ ok: true }); }); +const githubDeliverySuffix = (delivery: string): string => `gh:${delivery}`; + +const markRejectedDelivery = async ( + env: Env, + installationId: number | undefined, + delivery: string | undefined, +): Promise => { + if (!delivery) return; + const workspaceId = await (async () => { + if (installationId === undefined) return LEGACY_WORKSPACE_ID; + const record = await installationWorkspaceId(env, installationId); + if (!record.ok) throw record.error; + return record.data ?? LEGACY_WORKSPACE_ID; + })(); + const key = deliveryKey(workspaceId, githubDeliverySuffix(delivery)); + const delivered = await markDelivered(env.DELIVERY_DEDUPE, key); + if (!delivered.ok) throw delivered.error; +}; + const isRecord = (value: unknown): value is Record => { const isObject = typeof value === "object"; return isObject && value !== null; @@ -114,13 +208,29 @@ const isOptionalComment = (value: unknown) => { const isOptionalInstallation = (value: unknown) => { if (value === undefined) return true; if (!isRecord(value)) return false; - return isOptionalNumber(value.id); + const account = value.account; + if (account === undefined) return isOptionalNumber(value.id); + if (!isRecord(account)) return false; + return ( + isOptionalNumber(value.id) && + isOptionalNumber(account.id) && + isOptionalString(account.login) && + isOptionalString(account.type) + ); }; const isOptionalSender = (value: unknown) => { if (value === undefined) return true; if (!isRecord(value)) return false; - return isOptionalString(value.login); + return isOptionalString(value.login) && isOptionalNumber(value.id); +}; + +const isOptionalRepository = (value: unknown) => { + if (value === undefined) return true; + if (!isRecord(value)) return false; + return ( + isOptionalNumber(value.id) && isOptionalString(value.full_name) && isOptionalString(value.name) + ); }; const isGithubWebhookBody = (value: unknown): value is GithubWebhookBody => { @@ -129,5 +239,6 @@ const isGithubWebhookBody = (value: unknown): value is GithubWebhookBody => { const validComment = isOptionalComment(value.comment); const validInstallation = isOptionalInstallation(value.installation); const validSender = isOptionalSender(value.sender); - return validAction && validComment && validInstallation && validSender; + const validRepository = isOptionalRepository(value.repository); + return validAction && validComment && validInstallation && validSender && validRepository; }; diff --git a/apps/worker/src/routes/slack.ts b/apps/worker/src/routes/slack.ts index 6a68037..67b29d0 100644 --- a/apps/worker/src/routes/slack.ts +++ b/apps/worker/src/routes/slack.ts @@ -4,10 +4,18 @@ import { Hono } from "hono"; import { markDelivered } from "../dedupe.js"; import type { Env } from "../env.js"; import { memberGate } from "../members.js"; +import { createWorkspaceIngestMessage } from "../messages.js"; +import { legacyWorkspaceContext } from "../tenancy/guards.js"; export const slackRoutes = new Hono<{ Bindings: Env }>(); slackRoutes.post("/", async (c) => { + // The legacy route uses one deployment-global signing secret/token and cannot + // safely resolve a managed tenant. Keep it available only for self-host mode. + if (c.env.MANAGED_MODE === "true") { + return c.json({ error: "managed Slack OAuth is not available" }, 410); + } + const raw = await Result.from(() => c.req.text()); if (!raw.ok) return c.json({ error: "bad request" }, 400); @@ -54,26 +62,24 @@ slackRoutes.post("/", async (c) => { if (!allowed) { console.info("slack event ignored", slackEventLog(body, "not_roster_member", subject)); } else if (subject.channelType === "im" && subject.text) { - const queued = await Result.from(() => - c.env.INGEST_QUEUE.send({ - platform: "slack", - event: "preference", - deliveryId: body.event_id, - payload: { slackUserId: subject.user, text: subject.text }, - }), - ); + const message = createWorkspaceIngestMessage(legacyWorkspaceContext(), { + platform: "slack", + event: "preference", + deliveryId: body.event_id, + payload: { slackUserId: subject.user, text: subject.text }, + }); + const queued = await Result.from(() => c.env.INGEST_QUEUE.send(message)); if (!queued.ok) throw queued.error; enqueued = true; console.info("slack event enqueued", slackEventLog(body, "preference", subject)); } else if (subject.channelType === "channel" || subject.channelType === "group") { - const queued = await Result.from(() => - c.env.INGEST_QUEUE.send({ - platform: "slack", - event: "thread_message", - deliveryId: body.event_id, - payload: { channel: subject.channel, threadTs: subject.threadTs }, - }), - ); + const message = createWorkspaceIngestMessage(legacyWorkspaceContext(), { + platform: "slack", + event: "thread_message", + deliveryId: body.event_id, + payload: { channel: subject.channel, threadTs: subject.threadTs }, + }); + const queued = await Result.from(() => c.env.INGEST_QUEUE.send(message)); if (!queued.ok) throw queued.error; enqueued = true; console.info("slack event enqueued", slackEventLog(body, "thread_message", subject)); diff --git a/apps/worker/src/tenancy/actors.ts b/apps/worker/src/tenancy/actors.ts new file mode 100644 index 0000000..4b7ca59 --- /dev/null +++ b/apps/worker/src/tenancy/actors.ts @@ -0,0 +1,36 @@ +import { Ok, Result } from "@aipm/core"; +import { LEGACY_WORKSPACE_ID, type WorkspaceId } from "@aipm/db"; +import type { Env } from "../env.js"; +import type { MemberGate } from "../members.js"; +import { memberGate } from "../members.js"; +import { workspaceAllowsGithubActor } from "./guards.js"; + +/** + * Workspace-scoped actor authorization. Managed workspaces authorize against + * workspace membership (fail closed). The legacy self-host workspace keeps the + * IDENTITY_ROSTER gate for backwards compatibility. + */ +export const workspaceActorGate = ( + env: Env, + workspaceId: WorkspaceId, +): Result => { + if (workspaceId === LEGACY_WORKSPACE_ID) { + return memberGate(env); + } + + const required = env.REQUIRE_MEMBER_TRIGGER !== "false"; + const isMember: MemberGate["isMember"] = async (platform, handle) => { + if (platform !== "github") return false; + const allowed = await workspaceAllowsGithubActor(env, workspaceId, handle); + return allowed.ok && allowed.data; + }; + + return Ok({ + required, + isMember, + async allows(platform, handle) { + if (!required) return true; + return isMember(platform, handle); + }, + }); +}; diff --git a/apps/worker/src/tenancy/audit.ts b/apps/worker/src/tenancy/audit.ts new file mode 100644 index 0000000..deefcf8 --- /dev/null +++ b/apps/worker/src/tenancy/audit.ts @@ -0,0 +1,7 @@ +import { appendAuditAction, type AppendAuditAction, type WorkspaceId } from "@aipm/db"; +import type { DbEnv } from "./guards.js"; + +/** Permanently binds audit writes to one verified workspace. */ +export const workspaceAudit = (env: DbEnv, workspaceId: WorkspaceId) => ({ + append: (entry: AppendAuditAction) => appendAuditAction(env.DB, workspaceId, entry), +}); diff --git a/apps/worker/src/tenancy/budgets.ts b/apps/worker/src/tenancy/budgets.ts new file mode 100644 index 0000000..f4d3225 --- /dev/null +++ b/apps/worker/src/tenancy/budgets.ts @@ -0,0 +1,109 @@ +import { BudgetedLlmAdapter, type CounterStore } from "@aipm/adapter-llm"; +import { Ok, Result, type LlmAdapter } from "@aipm/core"; +import type { WorkspaceId } from "@aipm/db"; +import type { Env } from "../env.js"; +import { budgetKey } from "./keys.js"; + +const DEFAULT_TENANT_LLM_MINUTE = 60; +const DEFAULT_TENANT_LLM_DAY = 1_000; +const DEFAULT_GLOBAL_LLM_MINUTE = 600; +const DEFAULT_GLOBAL_LLM_DAY = 10_000; +const DEFAULT_TENANT_RATE_MINUTE = 300; +const DEFAULT_GLOBAL_RATE_MINUTE = 3_000; + +/** + * Applies an unpublished tenant ceiling first and a deployment hard ceiling + * second. Tenant counters are workspace-namespaced; global counters contain no + * customer content. + */ +export function createWorkspaceBudgetedLlm( + inner: LlmAdapter, + env: Env, + workspaceId: WorkspaceId, +): LlmAdapter { + const kv = counterStore(env.DELIVERY_DEDUPE); + const global = new BudgetedLlmAdapter(inner, { + store: prefixedStore(kv, "global-budget:llm:"), + perMinute: intVar(env.GLOBAL_LLM_PER_MINUTE_HARD_CEILING, DEFAULT_GLOBAL_LLM_MINUTE), + perDay: intVar(env.GLOBAL_LLM_DAILY_HARD_CEILING, DEFAULT_GLOBAL_LLM_DAY), + }); + return new BudgetedLlmAdapter(global, { + store: tenantBudgetStore(kv, workspaceId), + perMinute: intVar(env.LLM_PER_MINUTE_BUDGET, DEFAULT_TENANT_LLM_MINUTE), + perDay: intVar(env.LLM_DAILY_BUDGET, DEFAULT_TENANT_LLM_DAY), + }); +} + +export interface RateBudgetDecision { + readonly allowed: boolean; + readonly exhausted?: "tenant" | "global"; +} + +/** Reserves one ingress slot, degrading to a safe skip when a ceiling is full. */ +export async function consumeWorkspaceRateBudget( + env: Env, + workspaceId: WorkspaceId, + now = new Date(), +): Promise> { + const tenantLimit = intVar(env.TENANT_RATE_PER_MINUTE_CEILING, DEFAULT_TENANT_RATE_MINUTE); + const globalLimit = intVar(env.GLOBAL_RATE_PER_MINUTE_HARD_CEILING, DEFAULT_GLOBAL_RATE_MINUTE); + const bucket = minuteBucket(now); + const tenantKey = budgetKey(workspaceId, "minute", `rate:${bucket}`); + const globalKey = `global-budget:rate:${bucket}`; + const store = counterStore(env.DELIVERY_DEDUPE); + + const tenant = await reserve(store, tenantKey, tenantLimit); + if (!tenant.ok) return tenant; + if (!tenant.data) return Ok({ allowed: false, exhausted: "tenant" }); + const global = await reserve(store, globalKey, globalLimit); + if (!global.ok) return global; + if (!global.data) return Ok({ allowed: false, exhausted: "global" }); + return Ok({ allowed: true }); +} + +const counterStore = (kv: KVNamespace): CounterStore => ({ + get: (key) => kv.get(key), + put: (key, value, options) => kv.put(key, value, options), +}); + +const prefixedStore = (store: CounterStore, prefix: string): CounterStore => ({ + get: (key) => store.get(`${prefix}${key}`), + put: (key, value, options) => store.put(`${prefix}${key}`, value, options), +}); + +const tenantBudgetStore = (store: CounterStore, workspaceId: WorkspaceId): CounterStore => ({ + get: (key) => store.get(adapterBudgetKey(workspaceId, key)), + put: (key, value, options) => store.put(adapterBudgetKey(workspaceId, key), value, options), +}); + +const adapterBudgetKey = (workspaceId: WorkspaceId, key: string): string => { + const bucket = key.replace(/^llm:budget:/, ""); + return budgetKey(workspaceId, bucket.includes("T") ? "minute" : "day", bucket); +}; + +async function reserve( + store: CounterStore, + key: string, + limit: number, +): Promise> { + if (limit <= 0) return Ok(true); + const loaded = await Result.from(() => store.get(key)); + if (!loaded.ok) return loaded; + const parsed = Number(loaded.data); + const count = Number.isFinite(parsed) && parsed > 0 ? parsed : 0; + if (count >= limit) return Ok(false); + const stored = await Result.from(() => store.put(key, String(count + 1), { expirationTtl: 120 })); + if (!stored.ok) return stored; + return Ok(true); +} + +const intVar = (raw: string | undefined, fallback: number): number => { + if (raw === undefined || !/^-?\d+$/.test(raw.trim())) return fallback; + return Number(raw.trim()); +}; + +const pad = (value: number): string => String(value).padStart(2, "0"); +const minuteBucket = (date: Date): string => + `${String(date.getUTCFullYear())}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}T${pad( + date.getUTCHours(), + )}:${pad(date.getUTCMinutes())}`; diff --git a/apps/worker/src/tenancy/config.ts b/apps/worker/src/tenancy/config.ts new file mode 100644 index 0000000..8a398a1 --- /dev/null +++ b/apps/worker/src/tenancy/config.ts @@ -0,0 +1,44 @@ +import { buildConfig, type EngineConfigInput } from "@aipm/config"; +import { Ok, Result, type EngineConfig } from "@aipm/core"; +import { getWorkspaceConfig, type WorkspaceId } from "@aipm/db"; +import type { Env } from "../env.js"; +import type { DbEnv } from "./guards.js"; + +export const loadStoredWorkspaceConfig = async ( + env: DbEnv, + workspaceId: WorkspaceId, +): Promise> => { + const stored = await getWorkspaceConfig(env.DB, workspaceId); + if (!stored.ok) return stored; + if (!stored.data) return Ok(undefined); + const row = stored.data; + const parsed = Result.fromSync(() => JSON.parse(row.configJson) as Partial); + if (!parsed.ok) return parsed; + return buildConfig(parsed.data); +}; + +export const buildConfigFromEnv = (env: Env): Result => { + const cap = (v: string | undefined) => (v === undefined ? undefined : v !== "false"); + return buildConfig({ + llmJudge: env.LLM_JUDGE === "true", + notesPrompt: promptVar(env.NOTES_PROMPT), + clusterPrompt: promptVar(env.CLUSTER_PROMPT), + shadow: { + global: env.SHADOW_GLOBAL !== "false", + capabilities: { + workingNotes: cap(env.SHADOW_WORKING_NOTES), + nudges: cap(env.SHADOW_NUDGES), + digest: cap(env.SHADOW_DIGEST), + proposals: cap(env.SHADOW_PROPOSALS), + orgRollup: cap(env.SHADOW_ORG_ROLLUP), + }, + }, + }); +}; + +const promptVar = (v: string | undefined): string | undefined => { + if (v === undefined) return undefined; + const trimmed = v.trim(); + if (!trimmed) return undefined; + return trimmed; +}; diff --git a/apps/worker/src/tenancy/durable.ts b/apps/worker/src/tenancy/durable.ts new file mode 100644 index 0000000..434fede --- /dev/null +++ b/apps/worker/src/tenancy/durable.ts @@ -0,0 +1,17 @@ +import type { WorkspaceId } from "@aipm/db"; +import type { Env } from "../env.js"; +import { coordinatorName, mergeRegistryName } from "./keys.js"; + +export const getClusterCoordinator = ( + env: Pick, + workspaceId: WorkspaceId, + clusterId: string, +) => { + const id = env.CLUSTER_COORDINATOR.idFromName(coordinatorName(workspaceId, clusterId)); + return env.CLUSTER_COORDINATOR.get(id); +}; + +export const getMergeRegistry = (env: Pick, workspaceId: WorkspaceId) => { + const id = env.MERGE_REGISTRY.idFromName(mergeRegistryName(workspaceId)); + return env.MERGE_REGISTRY.get(id); +}; diff --git a/apps/worker/src/tenancy/guards.ts b/apps/worker/src/tenancy/guards.ts new file mode 100644 index 0000000..f761a75 --- /dev/null +++ b/apps/worker/src/tenancy/guards.ts @@ -0,0 +1,204 @@ +import { Err, Ok, Result } from "@aipm/core"; +import { + getInstallationRecord, + isWorkspaceGithubMember, + LEGACY_WORKSPACE_ID, + workspaceIdFromTrustedSource, + type WorkspaceContext, + type WorkspaceId, +} from "@aipm/db"; + +export interface DbEnv { + readonly DB: D1Database; +} + +export interface WorkspaceMembership { + readonly context: WorkspaceContext; + readonly userId: string; + readonly role: "owner" | "admin" | "member"; +} + +export const requireWorkspaceMember = async ( + env: DbEnv, + workspaceId: WorkspaceId, + userId: string, + sessionId?: string, +): Promise> => { + const loaded = await Result.from(() => + env.DB.prepare(`SELECT role FROM workspace_members WHERE workspace_id = ? AND user_id = ?`) + .bind(workspaceId, userId) + .first<{ role: WorkspaceMembership["role"] }>(), + ); + if (!loaded.ok) return loaded; + if (!loaded.data) return Err(new Error("WORKSPACE_MEMBERSHIP_REQUIRED")); + return Ok({ + context: { + workspaceId, + provenance: { kind: "session", sessionId: sessionId ?? userId }, + }, + userId, + role: loaded.data.role, + }); +}; + +/** Control-plane entry: trust a route param only after membership is confirmed. */ +export const requireWorkspaceMemberByParam = async ( + env: DbEnv, + workspaceIdRaw: string, + userId: string, + sessionId?: string, +): Promise> => { + if (!workspaceIdRaw.trim()) return Err(new Error("WORKSPACE_ID_REQUIRED")); + return requireWorkspaceMember( + env, + workspaceIdFromTrustedSource(workspaceIdRaw), + userId, + sessionId, + ); +}; + +export const resolveWorkspaceInstallation = async ( + env: DbEnv, + installationId: number, +): Promise> => { + const loaded = await getInstallationRecord(env.DB, installationId); + if (!loaded.ok) return loaded; + if (!loaded.data) return Err(new Error("ACTIVE_INSTALLATION_REQUIRED")); + if (loaded.data.status !== "active") { + return Err(new Error(`INSTALLATION_${loaded.data.status.toUpperCase()}`)); + } + return Ok({ + workspaceId: loaded.data.workspaceId, + provenance: { kind: "github-installation", installationId }, + }); +}; + +/** + * Resolve an installation to a workspace, falling back to the legacy self-host + * workspace when no installation row exists yet (migration path). Suspended or + * deleted installations are always rejected. + */ +export const resolveWorkspaceInstallationOrLegacy = async ( + env: DbEnv, + installationId: number | undefined, +): Promise> => { + if (installationId === undefined) { + return Ok({ + workspaceId: LEGACY_WORKSPACE_ID, + provenance: { kind: "legacy-bootstrap" }, + }); + } + const loaded = await getInstallationRecord(env.DB, installationId); + if (!loaded.ok) return loaded; + if (!loaded.data) { + return Ok({ + workspaceId: LEGACY_WORKSPACE_ID, + provenance: { kind: "legacy-bootstrap" }, + }); + } + if (loaded.data.status !== "active") { + return Err(new Error(`INSTALLATION_${loaded.data.status.toUpperCase()}`)); + } + return Ok({ + workspaceId: loaded.data.workspaceId, + provenance: { kind: "github-installation", installationId }, + }); +}; + +export interface EnabledRepository { + readonly workspaceId: WorkspaceId; + readonly repositoryId: number; + readonly installationId: number; + readonly fullName: string; +} + +export const requireEnabledRepository = async ( + env: DbEnv, + context: WorkspaceContext, + repositoryId: number, +): Promise> => { + const loaded = await Result.from(() => + env.DB.prepare( + `SELECT repository_id, installation_id, full_name + FROM github_repositories + WHERE workspace_id = ? AND repository_id = ? + AND enabled = 1 AND status = 'active'`, + ) + .bind(context.workspaceId, repositoryId) + .first<{ + repository_id: number; + installation_id: number; + full_name: string; + }>(), + ); + if (!loaded.ok) return loaded; + if (!loaded.data) return Err(new Error("ENABLED_REPOSITORY_REQUIRED")); + return Ok({ + workspaceId: context.workspaceId, + repositoryId: loaded.data.repository_id, + installationId: loaded.data.installation_id, + fullName: loaded.data.full_name, + }); +}; + +/** + * Managed workspaces require an enabled repository. The legacy self-host + * workspace skips the registry check so existing SWEEP_REPOS deployments keep working. + */ +export const requireEnabledRepositoryUnlessLegacy = async ( + env: DbEnv, + context: WorkspaceContext, + repositoryId: number | undefined, +): Promise> => { + if (context.workspaceId === LEGACY_WORKSPACE_ID) return Ok(undefined); + if (repositoryId === undefined) return Err(new Error("REPOSITORY_REQUIRED")); + const enabled = await requireEnabledRepository(env, context, repositoryId); + if (!enabled.ok) return enabled; + return Ok(enabled.data); +}; + +export const verifyInstallationBelongsToWorkspace = async ( + env: DbEnv, + installationId: number, + workspaceId: WorkspaceId, +): Promise => { + const loaded = await getInstallationRecord(env.DB, installationId); + if (!loaded.ok) return false; + if (!loaded.data) { + // Self-host path: no installation row yet, only the legacy workspace may proceed. + return workspaceId === LEGACY_WORKSPACE_ID; + } + return loaded.data.status === "active" && loaded.data.workspaceId === workspaceId; +}; + +export const workspaceAllowsGithubActor = async ( + env: DbEnv, + workspaceId: WorkspaceId, + githubLogin: string | undefined, +): Promise> => { + if (!githubLogin) return Ok(false); + if (workspaceId === LEGACY_WORKSPACE_ID) { + return Ok(false); + } + return isWorkspaceGithubMember(env.DB, workspaceId, githubLogin); +}; + +export const installationWorkspaceId = async ( + env: DbEnv, + installationId: number, +): Promise> => { + const loaded = await getInstallationRecord(env.DB, installationId); + if (!loaded.ok) return loaded; + if (!loaded.data) return Ok(undefined); + return Ok(loaded.data.workspaceId); +}; + +export const scheduledWorkspaceContext = (workspaceId: WorkspaceId): WorkspaceContext => ({ + workspaceId, + provenance: { kind: "scheduled" }, +}); + +export const legacyWorkspaceContext = (): WorkspaceContext => ({ + workspaceId: workspaceIdFromTrustedSource(LEGACY_WORKSPACE_ID), + provenance: { kind: "legacy-bootstrap" }, +}); diff --git a/apps/worker/src/tenancy/index.ts b/apps/worker/src/tenancy/index.ts new file mode 100644 index 0000000..642d98f --- /dev/null +++ b/apps/worker/src/tenancy/index.ts @@ -0,0 +1,57 @@ +import type { KVLike } from "@aipm/adapter-github"; +import type { WorkspaceId } from "@aipm/db"; +import type { Env } from "../env.js"; +import { deliveryKey, repositoryInstallationKey } from "./keys.js"; +import { workspaceInstallTokenKv } from "./kv.js"; + +export const workspaceDeliveryDedupe = ( + env: Pick, + workspaceId: WorkspaceId, +) => ({ + get: (deliveryId: string) => env.DELIVERY_DEDUPE.get(deliveryKey(workspaceId, deliveryId)), + put: (deliveryId: string, value: string, options?: { expirationTtl?: number }) => + env.DELIVERY_DEDUPE.put(deliveryKey(workspaceId, deliveryId), value, options), + key: (deliveryId: string) => deliveryKey(workspaceId, deliveryId), +}); + +export const workspaceInstallTokens = ( + env: Pick, + workspaceId: WorkspaceId, +): KVLike => workspaceInstallTokenKv(env.INSTALL_TOKENS, workspaceId); + +export const getCachedRepoInstallation = async ( + env: Pick, + workspaceId: WorkspaceId, + fullName: string, +): Promise => + env.INSTALL_TOKENS.get(repositoryInstallationKey(workspaceId, fullName)); + +export const putCachedRepoInstallation = async ( + env: Pick, + workspaceId: WorkspaceId, + fullName: string, + installationId: number, + expirationTtl = 86_400, +): Promise => { + await env.INSTALL_TOKENS.put( + repositoryInstallationKey(workspaceId, fullName), + String(installationId), + { expirationTtl }, + ); +}; + +export const workspaceLog = ( + workspaceId: WorkspaceId, + level: "error" | "info", + message: string, + detail?: unknown, +): void => { + const line = `[workspace=${workspaceId}] ${message}`; + if (level === "error") { + if (detail === undefined) console.error(line); + else console.error(line, detail); + return; + } + if (detail === undefined) console.info(line); + else console.info(line, detail); +}; diff --git a/apps/worker/src/tenancy/keys.ts b/apps/worker/src/tenancy/keys.ts new file mode 100644 index 0000000..b2625c3 --- /dev/null +++ b/apps/worker/src/tenancy/keys.ts @@ -0,0 +1,29 @@ +import type { WorkspaceId } from "@aipm/db"; + +const segment = (value: string | number): string => encodeURIComponent(String(value)); +const scoped = (prefix: string, workspaceId: WorkspaceId, suffix: string): string => + `${prefix}:${segment(workspaceId)}:${suffix}`; + +export const deliveryKey = (workspaceId: WorkspaceId, deliveryId: string): string => + scoped("delivery", workspaceId, segment(deliveryId)); + +export const installationTokenKey = (workspaceId: WorkspaceId, installationId: number): string => + scoped("installation-token", workspaceId, segment(installationId)); + +export const repositoryInstallationKey = (workspaceId: WorkspaceId, fullName: string): string => + scoped("repository-installation", workspaceId, segment(fullName)); + +export const budgetKey = ( + workspaceId: WorkspaceId, + window: "minute" | "day", + bucket: string, +): string => scoped("llm-budget", workspaceId, `${window}:${segment(bucket)}`); + +export const llmCacheKey = (workspaceId: WorkspaceId, digest: string): string => + scoped("llm-cache", workspaceId, segment(digest)); + +export const coordinatorName = (workspaceId: WorkspaceId, clusterId: string): string => + scoped("coordinator", workspaceId, segment(clusterId)); + +export const mergeRegistryName = (workspaceId: WorkspaceId): string => + scoped("merge-registry", workspaceId, "registry"); diff --git a/apps/worker/src/tenancy/kv.ts b/apps/worker/src/tenancy/kv.ts new file mode 100644 index 0000000..b2d871b --- /dev/null +++ b/apps/worker/src/tenancy/kv.ts @@ -0,0 +1,27 @@ +import type { KVLike } from "@aipm/adapter-github"; +import type { WorkspaceId } from "@aipm/db"; +import { installationTokenKey, repositoryInstallationKey } from "./keys.js"; + +/** Remap adapter-owned cache keys onto workspace-scoped KV keys. */ +export const workspaceInstallTokenKv = (kv: KVNamespace, workspaceId: WorkspaceId): KVLike => ({ + get: async (key) => { + const remapped = remapInstallKey(workspaceId, key); + return kv.get(remapped); + }, + put: async (key, value, options) => { + const remapped = remapInstallKey(workspaceId, key); + await kv.put(remapped, value, options); + }, +}); + +const remapInstallKey = (workspaceId: WorkspaceId, key: string): string => { + const inst = /^inst:(\d+)$/.exec(key); + const installationId = inst?.[1]; + if (installationId !== undefined) { + return installationTokenKey(workspaceId, Number(installationId)); + } + const repo = /^repo-inst:(.+)$/.exec(key); + const fullName = repo?.[1]; + if (fullName !== undefined) return repositoryInstallationKey(workspaceId, fullName); + return installationTokenKey(workspaceId, 0) + ":" + encodeURIComponent(key); +}; diff --git a/apps/worker/src/tenancy/lifecycle.ts b/apps/worker/src/tenancy/lifecycle.ts new file mode 100644 index 0000000..664edbb --- /dev/null +++ b/apps/worker/src/tenancy/lifecycle.ts @@ -0,0 +1,222 @@ +import { Err, Ok, Result } from "@aipm/core"; +import { + createWorkspaceForGithubAccount, + findWorkspaceByGithubAccount, + getInstallationRecord, + markInstallationRepositoriesRemoved, + markRepositoriesRemoved, + setInstallationStatus, + upsertGithubInstallation, + upsertGithubRepositories, + workspaceIdFromTrustedSource, + type GithubAccountRef, + type GithubRepositoryRef, + type WorkspaceId, +} from "@aipm/db"; +import type { DbEnv } from "./guards.js"; + +export interface InstallationLifecyclePayload { + readonly action?: string; + readonly installation?: { + readonly id?: number; + readonly account?: { + readonly id?: number; + readonly login?: string; + readonly type?: string; + }; + }; + readonly repositories?: ReadonlyArray<{ + readonly id?: number; + readonly name?: string; + readonly full_name?: string; + }>; + readonly repositories_added?: ReadonlyArray<{ + readonly id?: number; + readonly name?: string; + readonly full_name?: string; + }>; + readonly repositories_removed?: ReadonlyArray<{ + readonly id?: number; + readonly name?: string; + readonly full_name?: string; + }>; + readonly sender?: { + readonly id?: number; + readonly login?: string; + }; +} + +export const isInstallationLifecycleEvent = (eventName: string | undefined): boolean => + eventName === "installation" || eventName === "installation_repositories"; + +export const handleGithubInstallationLifecycle = async ( + env: DbEnv, + eventName: string, + body: InstallationLifecyclePayload, +): Promise> => { + const installationId = body.installation?.id; + if (installationId === undefined) return Err(new Error("INSTALLATION_ID_REQUIRED")); + + if (eventName === "installation") { + return handleInstallationEvent(env, installationId, body); + } + if (eventName === "installation_repositories") { + return handleInstallationRepositoriesEvent(env, installationId, body); + } + return Err(new Error("UNSUPPORTED_INSTALLATION_EVENT")); +}; + +const handleInstallationEvent = async ( + env: DbEnv, + installationId: number, + body: InstallationLifecyclePayload, +): Promise> => { + const action = body.action; + if (action === "created") { + const account = parseAccount(body); + if (!account.ok) return account; + const workspaceId = await resolveOrCreateWorkspace(env, account.data); + if (!workspaceId.ok) return workspaceId; + const upserted = await upsertGithubInstallation( + env.DB, + workspaceId.data, + installationId, + "active", + ); + if (!upserted.ok) return upserted; + const repos = parseRepositories(body.repositories ?? []); + if (repos.length) { + const written = await upsertGithubRepositories( + env.DB, + workspaceId.data, + installationId, + repos, + ); + if (!written.ok) return written; + } + return Ok({ workspaceId: workspaceId.data, handled: "installation.created" }); + } + + if (action === "deleted") { + const workspaceId = await workspaceIdForInstallation(env, installationId); + if (!workspaceId.ok) return workspaceId; + const status = await setInstallationStatus(env.DB, installationId, "deleted"); + if (!status.ok) return status; + const removed = await markInstallationRepositoriesRemoved(env.DB, installationId); + if (!removed.ok) return removed; + return Ok({ workspaceId: workspaceId.data, handled: "installation.deleted" }); + } + + if (action === "suspend" || action === "suspended") { + const workspaceId = await workspaceIdForInstallation(env, installationId); + if (!workspaceId.ok) return workspaceId; + const status = await setInstallationStatus(env.DB, installationId, "suspended"); + if (!status.ok) return status; + return Ok({ workspaceId: workspaceId.data, handled: "installation.suspended" }); + } + + if (action === "unsuspend" || action === "unsuspended") { + const workspaceId = await workspaceIdForInstallation(env, installationId); + if (!workspaceId.ok) return workspaceId; + const status = await setInstallationStatus(env.DB, installationId, "active"); + if (!status.ok) return status; + return Ok({ workspaceId: workspaceId.data, handled: "installation.unsuspended" }); + } + + return Err(new Error(`UNSUPPORTED_INSTALLATION_ACTION:${action ?? "none"}`)); +}; + +const handleInstallationRepositoriesEvent = async ( + env: DbEnv, + installationId: number, + body: InstallationLifecyclePayload, +): Promise> => { + const account = parseAccount(body); + const workspaceId = account.ok + ? await resolveOrCreateWorkspace(env, account.data) + : await workspaceIdForInstallation(env, installationId); + if (!workspaceId.ok) return workspaceId; + + const ensureInstall = await upsertGithubInstallation( + env.DB, + workspaceId.data, + installationId, + "active", + ); + if (!ensureInstall.ok) return ensureInstall; + + const action = body.action; + if (action === "added") { + const repos = parseRepositories(body.repositories_added ?? body.repositories ?? []); + if (repos.length) { + const written = await upsertGithubRepositories( + env.DB, + workspaceId.data, + installationId, + repos, + ); + if (!written.ok) return written; + } + return Ok({ workspaceId: workspaceId.data, handled: "installation_repositories.added" }); + } + + if (action === "removed") { + const removedIds = (body.repositories_removed ?? []) + .map((repo) => repo.id) + .filter((id): id is number => typeof id === "number"); + const marked = await markRepositoriesRemoved(env.DB, workspaceId.data, removedIds); + if (!marked.ok) return marked; + return Ok({ workspaceId: workspaceId.data, handled: "installation_repositories.removed" }); + } + + return Err(new Error(`UNSUPPORTED_INSTALLATION_REPOSITORIES_ACTION:${action ?? "none"}`)); +}; + +const resolveOrCreateWorkspace = async ( + env: DbEnv, + account: GithubAccountRef, +): Promise> => { + const existing = await findWorkspaceByGithubAccount(env.DB, account); + if (!existing.ok) return existing; + if (existing.data) return Ok(existing.data); + const workspaceId = workspaceIdFromTrustedSource(crypto.randomUUID()); + return createWorkspaceForGithubAccount(env.DB, account, workspaceId); +}; + +const workspaceIdForInstallation = async ( + env: DbEnv, + installationId: number, +): Promise> => { + const loaded = await getInstallationRecord(env.DB, installationId); + if (!loaded.ok) return loaded; + if (!loaded.data) return Err(new Error("INSTALLATION_NOT_FOUND")); + return Ok(loaded.data.workspaceId); +}; + +const parseAccount = (body: InstallationLifecyclePayload): Result => { + const account = body.installation?.account; + if ( + !account || + typeof account.id !== "number" || + typeof account.login !== "string" || + (account.type !== "Organization" && account.type !== "User") + ) { + return Err(new Error("INSTALLATION_ACCOUNT_REQUIRED")); + } + return Ok({ + id: account.id, + login: account.login, + type: account.type, + }); +}; + +const parseRepositories = ( + repos: ReadonlyArray<{ id?: number; name?: string; full_name?: string }>, +): Array => + repos.flatMap((repo) => { + if (typeof repo.id !== "number" || typeof repo.name !== "string") return []; + const fullName = typeof repo.full_name === "string" ? repo.full_name : `unknown/${repo.name}`; + const owner = fullName.split("/")[0]; + if (!owner) return []; + return [{ id: repo.id, owner, name: repo.name, fullName }]; + }); diff --git a/apps/worker/src/tenancy/sweeps.ts b/apps/worker/src/tenancy/sweeps.ts new file mode 100644 index 0000000..bd9e28c --- /dev/null +++ b/apps/worker/src/tenancy/sweeps.ts @@ -0,0 +1,55 @@ +import { Ok, Result } from "@aipm/core"; +import { LEGACY_WORKSPACE_ID, listActiveSweepRepositories, type SweepRepository } from "@aipm/db"; +import type { Env } from "../env.js"; +import type { DbEnv } from "./guards.js"; + +/** + * Active repositories for cron sweeps. Prefers D1-backed enabled repos from + * installation lifecycle; falls back to SWEEP_REPOS for legacy self-host. + */ +export const listSweepRepositories = async ( + env: DbEnv & Pick, +): Promise, Error>> => { + const fromDb = await listActiveSweepRepositories(env.DB); + if (!fromDb.ok) return fromDb; + if (fromDb.data.length) return fromDb; + + const legacy = parseSweepRepos(env.SWEEP_REPOS); + return Ok( + legacy.map((repo) => ({ + workspaceId: LEGACY_WORKSPACE_ID, + repositoryId: 0, + installationId: repo.installationId, + owner: repo.owner, + name: repo.repo, + fullName: `${repo.owner}/${repo.repo}`, + })), + ); +}; + +function parseSweepRepos( + raw: string | undefined, +): Array<{ owner: string; repo: string; installationId: number }> { + if (!raw) return []; + const parsed = Result.fromSync(() => JSON.parse(raw) as unknown); + if (!parsed.ok) return []; + if (!Array.isArray(parsed.data)) return []; + return parsed.data.flatMap((entry) => { + if (!entry || typeof entry !== "object") return []; + const row = entry as Record; + if ( + typeof row.owner !== "string" || + typeof row.repo !== "string" || + typeof row.installationId !== "number" + ) { + return []; + } + return [ + { + owner: row.owner, + repo: row.repo, + installationId: row.installationId, + }, + ]; + }); +} diff --git a/apps/worker/test/cluster.test.ts b/apps/worker/test/cluster.test.ts index 4012f6b..16fc2b7 100644 --- a/apps/worker/test/cluster.test.ts +++ b/apps/worker/test/cluster.test.ts @@ -1,13 +1,12 @@ -import { D1Store } from "@aipm/db"; +import { createWorkspaceStore, LEGACY_WORKSPACE_ID } from "@aipm/db"; import { env } from "cloudflare:test"; import { describe, expect, it } from "vitest"; import type { Nudge } from "@aipm/core"; - -const MERGE_REGISTRY_KEY = "global"; +import { getMergeRegistry } from "../src/tenancy/durable.js"; describe("D1Store cluster membership (issue #8)", () => { it("getOrCreateCluster mints once and is idempotent for the same thread", async () => { - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); const first = await store.getOrCreateCluster("o/r#1"); expect(first.ok).toBe(true); if (!first.ok) throw first.error; @@ -22,7 +21,7 @@ describe("D1Store cluster membership (issue #8)", () => { }); it("findCluster returns undefined for an unknown thread", async () => { - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); const found = await store.findCluster("o/r#never"); expect(found.ok).toBe(true); if (!found.ok) throw found.error; @@ -30,7 +29,7 @@ describe("D1Store cluster membership (issue #8)", () => { }); it("two distinct threads get two distinct minted cluster ids", async () => { - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); const a = await store.getOrCreateCluster("o/r#a"); expect(a.ok).toBe(true); if (!a.ok) throw a.error; @@ -41,7 +40,7 @@ describe("D1Store cluster membership (issue #8)", () => { }); it("listClusterThreads returns the cluster's members ordered by thread id", async () => { - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); const clusterId = await store.getOrCreateCluster("o/r#10"); expect(clusterId.ok).toBe(true); if (!clusterId.ok) throw clusterId.error; @@ -70,7 +69,7 @@ describe("D1Store cluster membership (issue #8)", () => { }); it("repointCluster moves every member and deleteCluster drops the row + cluster note", async () => { - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); const loser = await store.getOrCreateCluster("o/r#loser1"); expect(loser.ok).toBe(true); if (!loser.ok) throw loser.error; @@ -138,7 +137,7 @@ describe("D1Store.tryClaimNudge atomic claim (issue #8)", () => { }); it("first claimant wins; a second claim on a live row loses and does not overwrite", async () => { - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); const key = "u-1:o/r#claim:review_requested"; const wonFirst = await store.tryClaimNudge(nudge({ dedupeKey: key, escalations: 1 })); expect(wonFirst.ok).toBe(true); @@ -155,7 +154,7 @@ describe("D1Store.tryClaimNudge atomic claim (issue #8)", () => { }); it("upgrades an existing shadow row to a real send (the go-live path)", async () => { - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); const key = "u-1:o/r#shadow:review_requested"; const seeded = await store.upsertNudge(nudge({ dedupeKey: key, state: "shadow" })); expect(seeded.ok).toBe(true); @@ -177,7 +176,7 @@ describe("D1Store.tryClaimNudge atomic claim (issue #8)", () => { describe("D1Store.replaceLinksFrom", () => { it("replaces only links owned by the source thread", async () => { - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); const seeded = await store.upsertLinks([ { from: "links/source", to: "links/stale", kind: "refs" }, { from: "links/inbound", to: "links/source", kind: "refs" }, @@ -203,9 +202,8 @@ describe("D1Store.replaceLinksFrom", () => { describe("MergeRegistry.union (issue #8)", () => { it("merges two clusters to the lexicographically smaller winner and converges", async () => { - const store = new D1Store(env.DB); - const registryId = env.MERGE_REGISTRY.idFromName(MERGE_REGISTRY_KEY); - const registry = env.MERGE_REGISTRY.get(registryId); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); + const registry = getMergeRegistry(env, LEGACY_WORKSPACE_ID); const clusterA = await store.getOrCreateCluster("m/r#1"); expect(clusterA.ok).toBe(true); @@ -216,7 +214,11 @@ describe("MergeRegistry.union (issue #8)", () => { const expectedWinner = clusterA.data < clusterB.data ? clusterA.data : clusterB.data; const expectedLoser = clusterA.data < clusterB.data ? clusterB.data : clusterA.data; - const winner = await registry.union({ threadA: "m/r#1", threadB: "m/r#2" }); + const winner = await registry.union({ + workspaceId: LEGACY_WORKSPACE_ID, + threadA: "m/r#1", + threadB: "m/r#2", + }); expect(winner).toBe(expectedWinner); const foundA = await store.findCluster("m/r#1"); expect(foundA.ok).toBe(true); @@ -231,13 +233,17 @@ describe("MergeRegistry.union (issue #8)", () => { if (!loserMembers.ok) throw loserMembers.error; expect(loserMembers.data).toEqual([]); - const repeat = await registry.union({ threadA: "m/r#1", threadB: "m/r#2" }); + const repeat = await registry.union({ + workspaceId: LEGACY_WORKSPACE_ID, + threadA: "m/r#1", + threadB: "m/r#2", + }); expect(repeat).toBe(expectedWinner); }); it("is a no-op when both threads already share a cluster", async () => { - const store = new D1Store(env.DB); - const registry = env.MERGE_REGISTRY.get(env.MERGE_REGISTRY.idFromName(MERGE_REGISTRY_KEY)); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); + const registry = getMergeRegistry(env, LEGACY_WORKSPACE_ID); const shared = await store.getOrCreateCluster("m/r#same1"); expect(shared.ok).toBe(true); if (!shared.ok) throw shared.error; @@ -250,7 +256,11 @@ describe("MergeRegistry.union (issue #8)", () => { }); expect(repointed.ok).toBe(true); if (!repointed.ok) throw repointed.error; - const result = await registry.union({ threadA: "m/r#same1", threadB: "m/r#same2" }); + const result = await registry.union({ + workspaceId: LEGACY_WORKSPACE_ID, + threadA: "m/r#same1", + threadB: "m/r#same2", + }); expect(result).toBe(shared.data); const foundSame2 = await store.findCluster("m/r#same2"); expect(foundSame2.ok).toBe(true); diff --git a/apps/worker/test/control-plane.test.ts b/apps/worker/test/control-plane.test.ts new file mode 100644 index 0000000..5706b58 --- /dev/null +++ b/apps/worker/test/control-plane.test.ts @@ -0,0 +1,222 @@ +import { + createSession, + createWorkspaceForGithubAccount, + ensureWorkspaceMember, + upsertGithubInstallation, + upsertGithubRepositories, + upsertUserFromGithub, + workspaceIdFromTrustedSource, +} from "@aipm/db"; +import { env, SELF } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { createUserSession, hashToken } from "../src/auth/session.js"; +import { CSRF_COOKIE, CSRF_HEADER, mintCsrfToken } from "../src/routes/auth.js"; + +const workspaceA = workspaceIdFromTrustedSource("cp-a"); +const workspaceB = workspaceIdFromTrustedSource("cp-b"); +const now = "2026-01-01T00:00:00.000Z"; + +beforeAll(async () => { + await env.DB.batch([ + env.DB.prepare( + `INSERT OR IGNORE INTO workspaces + (id, github_account_id, github_account_type, github_account_login, name, created_at, updated_at) + VALUES (?, 11, 'Organization', 'org-a', 'Org A', ?, ?)`, + ).bind(workspaceA, now, now), + env.DB.prepare( + `INSERT OR IGNORE INTO workspaces + (id, github_account_id, github_account_type, github_account_login, name, created_at, updated_at) + VALUES (?, 22, 'Organization', 'org-b', 'Org B', ?, ?)`, + ).bind(workspaceB, now, now), + ]); + + const userA = await upsertUserFromGithub(env.DB, 501, "alice", "user-alice"); + const userB = await upsertUserFromGithub(env.DB, 502, "bob", "user-bob"); + expect(userA.ok).toBe(true); + expect(userB.ok).toBe(true); + + expect((await ensureWorkspaceMember(env.DB, workspaceA, "user-alice", "owner")).ok).toBe(true); + expect((await ensureWorkspaceMember(env.DB, workspaceB, "user-bob", "owner")).ok).toBe(true); + expect((await upsertGithubInstallation(env.DB, workspaceA, 8001, "active")).ok).toBe(true); + expect( + ( + await upsertGithubRepositories(env.DB, workspaceA, 8001, [ + { id: 9001, owner: "org-a", name: "repo", fullName: "org-a/repo" }, + ]) + ).ok, + ).toBe(true); +}); + +const sessionCookie = async (userId: string, githubUserId: number, login: string) => { + const session = await createUserSession(env, { + userId, + githubUserId, + githubLogin: login, + }); + expect(session.ok).toBe(true); + if (!session.ok) throw session.error; + return `aipm_session=${session.data.token}`; +}; + +describe("auth sessions", () => { + it("stores opaque sessions hashed and resolves the user", async () => { + const token = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", ""); + const idHash = await hashToken(token); + const created = await createSession( + env.DB, + idHash, + "user-alice", + new Date(Date.now() + 60_000).toISOString(), + ); + expect(created.ok).toBe(true); + + const rows = await env.DB.prepare(`SELECT id_hash FROM sessions WHERE user_id = ?`) + .bind("user-alice") + .all<{ id_hash: string }>(); + expect(rows.results?.some((row) => row.id_hash === idHash)).toBe(true); + expect(rows.results?.some((row) => row.id_hash === token)).toBe(false); + }); + + it("rejects /api/me without a session", async () => { + const res = await SELF.fetch("https://example.com/api/me"); + expect(res.status).toBe(401); + }); + + it("returns the authed user and workspaces from /api/me", async () => { + const cookie = await sessionCookie("user-alice", 501, "alice"); + const res = await SELF.fetch("https://example.com/api/me", { + headers: { cookie }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + user: { githubLogin: string }; + workspaces: Array<{ id: string }>; + slack: { status: string }; + csrfToken: string; + }; + expect(body.user.githubLogin).toBe("alice"); + expect(body.workspaces.map((w) => w.id)).toContain(workspaceA); + expect(body.workspaces.map((w) => w.id)).not.toContain(workspaceB); + expect(body.slack.status).toBe("coming_next"); + expect(body.csrfToken.length).toBeGreaterThan(8); + }); + + it("requires CSRF for logout", async () => { + const cookie = await sessionCookie("user-alice", 501, "alice"); + const denied = await SELF.fetch("https://example.com/auth/logout", { + method: "POST", + headers: { cookie }, + }); + expect(denied.status).toBe(403); + + const csrf = mintCsrfToken(); + const allowed = await SELF.fetch("https://example.com/auth/logout", { + method: "POST", + headers: { + cookie: `${cookie}; ${CSRF_COOKIE}=${csrf}`, + [CSRF_HEADER]: csrf, + }, + }); + expect(allowed.status).toBe(200); + }); +}); + +describe("control-plane authorization", () => { + it("forbids reading another workspace repositories", async () => { + const cookie = await sessionCookie("user-alice", 501, "alice"); + const res = await SELF.fetch(`https://example.com/api/workspaces/${workspaceB}/repositories`, { + headers: { cookie }, + }); + expect(res.status).toBe(403); + }); + + it("allows a member to list their own repositories", async () => { + const cookie = await sessionCookie("user-alice", 501, "alice"); + const res = await SELF.fetch(`https://example.com/api/workspaces/${workspaceA}/repositories`, { + headers: { cookie }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + repositories: Array<{ fullName: string; enabled: boolean }>; + shadowDefault: boolean; + }; + expect(body.shadowDefault).toBe(true); + expect(body.repositories[0]?.fullName).toBe("org-a/repo"); + expect(body.repositories[0]?.enabled).toBe(false); + }); + + it("requires CSRF to mutate repositories and keeps cross-tenant isolation", async () => { + const cookie = await sessionCookie("user-alice", 501, "alice"); + const csrf = mintCsrfToken(); + const denied = await SELF.fetch( + `https://example.com/api/workspaces/${workspaceA}/repositories`, + { + method: "PUT", + headers: { + cookie, + "content-type": "application/json", + }, + body: JSON.stringify({ enabledRepositoryIds: [9001] }), + }, + ); + expect(denied.status).toBe(403); + + const cross = await SELF.fetch( + `https://example.com/api/workspaces/${workspaceB}/repositories`, + { + method: "PUT", + headers: { + cookie: `${cookie}; ${CSRF_COOKIE}=${csrf}`, + [CSRF_HEADER]: csrf, + "content-type": "application/json", + }, + body: JSON.stringify({ enabledRepositoryIds: [9001] }), + }, + ); + expect(cross.status).toBe(403); + + const ok = await SELF.fetch(`https://example.com/api/workspaces/${workspaceA}/repositories`, { + method: "PUT", + headers: { + cookie: `${cookie}; ${CSRF_COOKIE}=${csrf}`, + [CSRF_HEADER]: csrf, + "content-type": "application/json", + }, + body: JSON.stringify({ enabledRepositoryIds: [9001] }), + }); + expect(ok.status).toBe(200); + const body = (await ok.json()) as { repositories: Array<{ enabled: boolean }> }; + expect(body.repositories[0]?.enabled).toBe(true); + }); + + it("returns shadow-first config and blocks foreign workspace config reads", async () => { + const cookie = await sessionCookie("user-alice", 501, "alice"); + const forbidden = await SELF.fetch(`https://example.com/api/workspaces/${workspaceB}/config`, { + headers: { cookie }, + }); + expect(forbidden.status).toBe(403); + + // Trigger inserts default shadow config for non-legacy workspaces (migration 0006). + const created = await createWorkspaceForGithubAccount(env.DB, { + id: 33, + login: "org-c", + type: "Organization", + }); + expect(created.ok).toBe(true); + if (!created.ok) throw created.error; + expect((await ensureWorkspaceMember(env.DB, created.data, "user-alice", "owner")).ok).toBe( + true, + ); + + const res = await SELF.fetch(`https://example.com/api/workspaces/${created.data}/config`, { + headers: { cookie }, + }); + expect(res.status).toBe(200); + const body = (await res.json()) as { + config: { shadow: { global: boolean } }; + slack: { status: string }; + }; + expect(body.config.shadow.global).toBe(true); + expect(body.slack.status).toBe("coming_next"); + }); +}); diff --git a/apps/worker/test/lifecycle.test.ts b/apps/worker/test/lifecycle.test.ts new file mode 100644 index 0000000..27bee82 --- /dev/null +++ b/apps/worker/test/lifecycle.test.ts @@ -0,0 +1,270 @@ +import { listActiveSweepRepositories, workspaceIdFromTrustedSource } from "@aipm/db"; +import { env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + consumeOAuthState, + createUserSession, + hashToken, + mintOAuthState, + resolveSessionToken, + revokeSessionToken, +} from "../src/auth/session.js"; +import { resolveWorkspaceInstallation } from "../src/tenancy/guards.js"; +import { handleGithubInstallationLifecycle } from "../src/tenancy/lifecycle.js"; +import { listSweepRepositories } from "../src/tenancy/sweeps.js"; +import { mergeRegistryName, coordinatorName, deliveryKey } from "../src/tenancy/keys.js"; + +describe("github installation lifecycle", () => { + it("creates a workspace and persists repositories on installation.created", async () => { + const handled = await handleGithubInstallationLifecycle(env, "installation", { + action: "created", + installation: { + id: 8101, + account: { id: 55001, login: "acme", type: "Organization" }, + }, + repositories: [ + { id: 9101, name: "web", full_name: "acme/web" }, + { id: 9102, name: "api", full_name: "acme/api" }, + ], + }); + expect(handled.ok).toBe(true); + if (!handled.ok) throw handled.error; + + const installation = await resolveWorkspaceInstallation(env, 8101); + expect(installation.ok).toBe(true); + if (!installation.ok) throw installation.error; + expect(installation.data.workspaceId).toBe(handled.data.workspaceId); + + const repos = await env.DB.prepare( + `SELECT full_name, enabled, status FROM github_repositories + WHERE workspace_id = ? ORDER BY full_name`, + ) + .bind(handled.data.workspaceId) + .all<{ full_name: string; enabled: number; status: string }>(); + expect(repos.results).toEqual([ + { full_name: "acme/api", enabled: 0, status: "active" }, + { full_name: "acme/web", enabled: 0, status: "active" }, + ]); + }); + + it("reconnects the same workspace across reinstalls via github account id", async () => { + const first = await handleGithubInstallationLifecycle(env, "installation", { + action: "created", + installation: { + id: 8201, + account: { id: 55002, login: "reconnect-co", type: "Organization" }, + }, + repositories: [{ id: 9201, name: "app", full_name: "reconnect-co/app" }], + }); + expect(first.ok).toBe(true); + if (!first.ok) throw first.error; + + const deleted = await handleGithubInstallationLifecycle(env, "installation", { + action: "deleted", + installation: { + id: 8201, + account: { id: 55002, login: "reconnect-co", type: "Organization" }, + }, + }); + expect(deleted.ok).toBe(true); + + const second = await handleGithubInstallationLifecycle(env, "installation", { + action: "created", + installation: { + id: 8202, + account: { id: 55002, login: "reconnect-co", type: "Organization" }, + }, + repositories: [{ id: 9201, name: "app", full_name: "reconnect-co/app" }], + }); + expect(second.ok).toBe(true); + if (!second.ok) throw second.error; + expect(second.data.workspaceId).toBe(first.data.workspaceId); + }); + + it("rejects suspended installations and restores them on unsuspend", async () => { + const created = await handleGithubInstallationLifecycle(env, "installation", { + action: "created", + installation: { + id: 8301, + account: { id: 55003, login: "suspend-co", type: "Organization" }, + }, + }); + expect(created.ok).toBe(true); + + const suspended = await handleGithubInstallationLifecycle(env, "installation", { + action: "suspend", + installation: { id: 8301 }, + }); + expect(suspended.ok).toBe(true); + expect((await resolveWorkspaceInstallation(env, 8301)).ok).toBe(false); + + const unsuspended = await handleGithubInstallationLifecycle(env, "installation", { + action: "unsuspend", + installation: { id: 8301 }, + }); + expect(unsuspended.ok).toBe(true); + expect((await resolveWorkspaceInstallation(env, 8301)).ok).toBe(true); + }); + + it("adds and removes repositories from an installation", async () => { + const created = await handleGithubInstallationLifecycle(env, "installation", { + action: "created", + installation: { + id: 8401, + account: { id: 55004, login: "repos-co", type: "Organization" }, + }, + repositories: [{ id: 9401, name: "one", full_name: "repos-co/one" }], + }); + expect(created.ok).toBe(true); + if (!created.ok) throw created.error; + + const added = await handleGithubInstallationLifecycle(env, "installation_repositories", { + action: "added", + installation: { + id: 8401, + account: { id: 55004, login: "repos-co", type: "Organization" }, + }, + repositories_added: [{ id: 9402, name: "two", full_name: "repos-co/two" }], + }); + expect(added.ok).toBe(true); + + const removed = await handleGithubInstallationLifecycle(env, "installation_repositories", { + action: "removed", + installation: { + id: 8401, + account: { id: 55004, login: "repos-co", type: "Organization" }, + }, + repositories_removed: [{ id: 9401, name: "one", full_name: "repos-co/one" }], + }); + expect(removed.ok).toBe(true); + + const rows = await env.DB.prepare( + `SELECT repository_id, status, enabled FROM github_repositories + WHERE workspace_id = ? ORDER BY repository_id`, + ) + .bind(created.data.workspaceId) + .all<{ repository_id: number; status: string; enabled: number }>(); + expect(rows.results).toEqual([ + { repository_id: 9401, status: "removed", enabled: 0 }, + { repository_id: 9402, status: "active", enabled: 0 }, + ]); + }); +}); + +describe("tenant-scoped sweeps", () => { + beforeAll(async () => { + const workspaceId = workspaceIdFromTrustedSource("sweep-ws"); + await env.DB.batch([ + env.DB.prepare( + `INSERT OR IGNORE INTO workspaces (id, name, created_at, updated_at) + VALUES (?, 'Sweep', ?, ?)`, + ).bind(workspaceId, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), + env.DB.prepare( + `INSERT OR IGNORE INTO github_installations + (workspace_id, installation_id, status, created_at, updated_at) + VALUES (?, 8501, 'active', ?, ?)`, + ).bind(workspaceId, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), + env.DB.prepare( + `INSERT OR IGNORE INTO github_repositories + (workspace_id, repository_id, installation_id, owner, name, full_name, + enabled, status, created_at, updated_at) + VALUES (?, 9501, 8501, 'sweep', 'live', 'sweep/live', 1, 'active', ?, ?)`, + ).bind(workspaceId, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), + env.DB.prepare( + `INSERT OR IGNORE INTO github_repositories + (workspace_id, repository_id, installation_id, owner, name, full_name, + enabled, status, created_at, updated_at) + VALUES (?, 9502, 8501, 'sweep', 'shadow', 'sweep/shadow', 0, 'active', ?, ?)`, + ).bind(workspaceId, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), + ]); + }); + + it("lists only enabled active repositories from D1", async () => { + const listed = await listActiveSweepRepositories(env.DB); + expect(listed.ok).toBe(true); + if (!listed.ok) throw listed.error; + const mine = listed.data.filter((row) => row.installationId === 8501); + expect(mine).toEqual([ + { + workspaceId: "sweep-ws", + repositoryId: 9501, + installationId: 8501, + owner: "sweep", + name: "live", + fullName: "sweep/live", + }, + ]); + + const viaHelper = await listSweepRepositories({ ...env, SWEEP_REPOS: undefined }); + expect(viaHelper.ok).toBe(true); + if (!viaHelper.ok) throw viaHelper.error; + expect(viaHelper.data.some((row) => row.name === "live")).toBe(true); + expect(viaHelper.data.some((row) => row.name === "shadow")).toBe(false); + }); +}); + +describe("session auth primitives", () => { + it("creates, resolves, and revokes opaque hashed sessions", async () => { + const created = await createUserSession(env, { + githubUserId: 42, + githubLogin: "octocat", + }); + expect(created.ok).toBe(true); + if (!created.ok) throw created.error; + + const resolved = await resolveSessionToken(env, created.data.token); + expect(resolved.ok && resolved.data?.githubLogin).toBe("octocat"); + + const idHash = await hashToken(created.data.token); + const sessionRow = await env.DB.prepare(`SELECT user_id FROM sessions WHERE id_hash = ?`) + .bind(idHash) + .first<{ user_id: string }>(); + expect(sessionRow?.user_id).toBe(created.data.user.id); + + const revoked = await revokeSessionToken(env, created.data.token); + expect(revoked.ok).toBe(true); + const after = await resolveSessionToken(env, created.data.token); + expect(after.ok && after.data).toBeUndefined(); + }); + + it("stores oauth state opaquely and consumes it once", async () => { + const minted = await mintOAuthState(env.DELIVERY_DEDUPE, "/setup/github"); + expect(minted.ok).toBe(true); + if (!minted.ok) return; + + const mismatch = await consumeOAuthState(env.DELIVERY_DEDUPE, minted.data.token, "tampered"); + expect(mismatch.ok).toBe(false); + + const missingCookie = await consumeOAuthState( + env.DELIVERY_DEDUPE, + undefined, + minted.data.token, + ); + expect(missingCookie.ok).toBe(false); + + const first = await consumeOAuthState( + env.DELIVERY_DEDUPE, + minted.data.token, + minted.data.token, + ); + expect(first.ok).toBe(true); + if (first.ok) expect(first.data.returnTo).toBe("/setup/github"); + + const replay = await consumeOAuthState( + env.DELIVERY_DEDUPE, + minted.data.token, + minted.data.token, + ); + expect(replay.ok).toBe(false); + }); +}); + +describe("durable object key namespacing", () => { + it("keeps coordinator and merge registry names workspace-local", () => { + const a = workspaceIdFromTrustedSource("do-a"); + const b = workspaceIdFromTrustedSource("do-b"); + expect(coordinatorName(a, "cluster-1")).not.toBe(coordinatorName(b, "cluster-1")); + expect(mergeRegistryName(a)).not.toBe(mergeRegistryName(b)); + expect(deliveryKey(a, "gh:1")).not.toBe(deliveryKey(b, "gh:1")); + }); +}); diff --git a/apps/worker/test/public-safety.test.ts b/apps/worker/test/public-safety.test.ts new file mode 100644 index 0000000..5efbdc7 --- /dev/null +++ b/apps/worker/test/public-safety.test.ts @@ -0,0 +1,224 @@ +import { EchoLlmAdapter, LlmBudgetExceededError } from "@aipm/adapter-llm"; +import { + appendAuditAction, + enableWorkspaceCapability, + listAuditActions, + offboardWorkspace, + workspaceIdFromTrustedSource, +} from "@aipm/db"; +import { env } from "cloudflare:test"; +import { describe, expect, it } from "vitest"; +import type { Env } from "../src/env.js"; +import { consumeWorkspaceRateBudget, createWorkspaceBudgetedLlm } from "../src/tenancy/budgets.js"; + +const now = "2026-07-21T12:00:00.000Z"; + +describe("public-free audit safeguards", () => { + it("defaults managed workspaces to shadow and records immutable outcomes", async () => { + const workspace = workspaceIdFromTrustedSource(`audit-${crypto.randomUUID()}`); + await insertWorkspace(workspace); + + const config = await env.DB.prepare( + `SELECT config_json, revision FROM workspace_config WHERE workspace_id = ?`, + ) + .bind(workspace) + .first<{ config_json: string; revision: number }>(); + expect(JSON.parse(config!.config_json)).toMatchObject({ shadow: { global: true } }); + + for (const outcome of [ + "preview", + "attempted", + "sent", + "skipped", + "suppressed", + "failed", + ] as const) { + const recorded = await appendAuditAction(env.DB, workspace, { + action: "working_note", + outcome, + actor: { source: "worker", kind: "service" }, + detail: { outcome }, + createdAt: now, + }); + expect(recorded.ok).toBe(true); + } + + const actions = await listAuditActions(env.DB, workspace); + expect(actions.ok && actions.data.map((action) => action.outcome).sort()).toEqual( + ["attempted", "failed", "preview", "sent", "skipped", "suppressed"].sort(), + ); + await expect( + env.DB.prepare(`UPDATE audit_actions SET outcome = 'sent' WHERE workspace_id = ?`) + .bind(workspace) + .run(), + ).rejects.toThrow(/immutable/); + await expect( + env.DB.prepare(`DELETE FROM audit_actions WHERE workspace_id = ?`).bind(workspace).run(), + ).rejects.toThrow(/immutable/); + }); + + it("records a capability-level go-live revision", async () => { + const workspace = workspaceIdFromTrustedSource(`live-${crypto.randomUUID()}`); + await insertWorkspace(workspace); + + const enabled = await enableWorkspaceCapability(env.DB, workspace, "workingNotes", { + source: "control-plane", + kind: "user", + id: "user-1", + login: "maintainer", + }); + expect(enabled.ok).toBe(true); + if (!enabled.ok) throw enabled.error; + expect(enabled.data).toBe(2); + + const config = await env.DB.prepare( + `SELECT config_json, revision FROM workspace_config WHERE workspace_id = ?`, + ) + .bind(workspace) + .first<{ config_json: string; revision: number }>(); + expect(config?.revision).toBe(2); + expect(JSON.parse(config!.config_json)).toMatchObject({ + shadow: { global: true, capabilities: { workingNotes: false } }, + }); + const actions = await listAuditActions(env.DB, workspace); + expect(actions.ok && actions.data[0]).toMatchObject({ + action: "capability.go_live", + outcome: "revised", + detail: { capability: "workingNotes", previousRevision: 1, revision: 2 }, + }); + }); +}); + +describe("workspace offboarding", () => { + it("disables access, removes credentials, then deletes tenant-owned rows", async () => { + const workspace = workspaceIdFromTrustedSource(`offboard-${crypto.randomUUID()}`); + await insertWorkspace(workspace); + await env.DB.prepare( + `INSERT INTO github_installations ( + workspace_id, installation_id, status, created_at, updated_at + ) VALUES (?, ?, 'active', ?, ?)`, + ) + .bind(workspace, 81001, now, now) + .run(); + await env.DB.prepare( + `INSERT INTO github_repositories ( + workspace_id, repository_id, installation_id, owner, name, full_name, + enabled, status, created_at, updated_at + ) VALUES (?, ?, ?, 'same', 'repo', 'same/repo', 1, 'active', ?, ?)`, + ) + .bind(workspace, 91001, 81001, now, now) + .run(); + await appendAuditAction(env.DB, workspace, { + action: "workspace.offboard", + outcome: "attempted", + actor: { source: "control-plane", kind: "user", id: "owner" }, + }); + + const removed: number[] = []; + const result = await offboardWorkspace(env.DB, workspace, { + deleteInstallationCredential: async (installationId) => { + const status = await env.DB.prepare( + `SELECT status FROM github_installations + WHERE workspace_id = ? AND installation_id = ?`, + ) + .bind(workspace, installationId) + .first<{ status: string }>(); + expect(status?.status).toBe("deleted"); + removed.push(installationId); + }, + }); + expect(result.ok).toBe(true); + expect(removed).toEqual([81001]); + expect( + await env.DB.prepare(`SELECT id FROM workspaces WHERE id = ?`).bind(workspace).first(), + ).toBeNull(); + expect( + await env.DB.prepare(`SELECT id FROM audit_actions WHERE workspace_id = ?`) + .bind(workspace) + .first(), + ).toBeNull(); + }); +}); + +describe("workspace safety ceiling isolation", () => { + it("exhausting one tenant's rate and AI budget does not block another", async () => { + const workspaceA = workspaceIdFromTrustedSource("budget-a"); + const workspaceB = workspaceIdFromTrustedSource("budget-b"); + const map = new Map(); + const fakeEnv = { + DELIVERY_DEDUPE: { + get: async (key: string) => map.get(key) ?? null, + put: async (key: string, value: string) => { + map.set(key, value); + }, + }, + LLM_PER_MINUTE_BUDGET: "1", + LLM_DAILY_BUDGET: "10", + GLOBAL_LLM_PER_MINUTE_HARD_CEILING: "10", + GLOBAL_LLM_DAILY_HARD_CEILING: "100", + TENANT_RATE_PER_MINUTE_CEILING: "1", + GLOBAL_RATE_PER_MINUTE_HARD_CEILING: "10", + } as unknown as Env; + + const a = createWorkspaceBudgetedLlm(new EchoLlmAdapter(), fakeEnv, workspaceA); + const b = createWorkspaceBudgetedLlm(new EchoLlmAdapter(), fakeEnv, workspaceB); + expect((await a.complete("a")).ok).toBe(true); + const exhausted = await a.complete("again"); + expect(exhausted.ok).toBe(false); + if (!exhausted.ok) expect(exhausted.error).toBeInstanceOf(LlmBudgetExceededError); + expect((await b.complete("b")).ok).toBe(true); + + const at = new Date(now); + expect((await consumeWorkspaceRateBudget(fakeEnv, workspaceA, at)).data).toEqual({ + allowed: true, + }); + expect((await consumeWorkspaceRateBudget(fakeEnv, workspaceA, at)).data).toEqual({ + allowed: false, + exhausted: "tenant", + }); + expect((await consumeWorkspaceRateBudget(fakeEnv, workspaceB, at)).data).toEqual({ + allowed: true, + }); + expect([...map.keys()].some((key) => key.includes("budget-a"))).toBe(true); + expect([...map.keys()].some((key) => key.includes("budget-b"))).toBe(true); + }); + + it("enforces the deployment-wide hard ceiling across tenants", async () => { + const map = new Map(); + const fakeEnv = { + DELIVERY_DEDUPE: { + get: async (key: string) => map.get(key) ?? null, + put: async (key: string, value: string) => { + map.set(key, value); + }, + }, + LLM_PER_MINUTE_BUDGET: "10", + LLM_DAILY_BUDGET: "100", + GLOBAL_LLM_PER_MINUTE_HARD_CEILING: "1", + GLOBAL_LLM_DAILY_HARD_CEILING: "100", + } as unknown as Env; + const workspaceA = workspaceIdFromTrustedSource("global-a"); + const workspaceB = workspaceIdFromTrustedSource("global-b"); + + expect( + (await createWorkspaceBudgetedLlm(new EchoLlmAdapter(), fakeEnv, workspaceA).complete("a")) + .ok, + ).toBe(true); + const blocked = await createWorkspaceBudgetedLlm( + new EchoLlmAdapter(), + fakeEnv, + workspaceB, + ).complete("b"); + expect(blocked.ok).toBe(false); + if (!blocked.ok) expect(blocked.error).toBeInstanceOf(LlmBudgetExceededError); + expect([...map.keys()].some((key) => key.startsWith("global-budget:llm:"))).toBe(true); + }); +}); + +async function insertWorkspace(workspaceId: string): Promise { + await env.DB.prepare( + `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)`, + ) + .bind(workspaceId, workspaceId, now, now) + .run(); +} diff --git a/apps/worker/test/slack-route.test.ts b/apps/worker/test/slack-route.test.ts index 839b890..4143b83 100644 --- a/apps/worker/test/slack-route.test.ts +++ b/apps/worker/test/slack-route.test.ts @@ -86,6 +86,16 @@ describe("slackMessageSubject", () => { }); describe("slackRoutes", () => { + it("disables legacy single-token ingress in managed mode", async () => { + const response = await slackRoutes.fetch( + new Request("https://example.com/", { method: "POST", body: "{}" }), + { MANAGED_MODE: "true" } as Env, + ); + + expect(response.status).toBe(410); + expect(await response.json()).toEqual({ error: "managed Slack OAuth is not available" }); + }); + it("does not dedupe ignored events, but dedupes enqueued events", async () => { const kv = new Map(); const queued: unknown[] = []; diff --git a/apps/worker/test/tenant-isolation.test.ts b/apps/worker/test/tenant-isolation.test.ts new file mode 100644 index 0000000..e63e651 --- /dev/null +++ b/apps/worker/test/tenant-isolation.test.ts @@ -0,0 +1,243 @@ +import { createWorkspaceStore, workspaceIdFromTrustedSource, type WorkspaceId } from "@aipm/db"; +import { env } from "cloudflare:test"; +import { beforeAll, describe, expect, it } from "vitest"; +import { + budgetKey, + coordinatorName, + deliveryKey, + installationTokenKey, + llmCacheKey, + mergeRegistryName, +} from "../src/tenancy/keys.js"; +import { + requireEnabledRepository, + requireWorkspaceMember, + resolveWorkspaceInstallation, +} from "../src/tenancy/guards.js"; +import { createWorkspaceIngestMessage, parseWorkspaceIngestMessage } from "../src/messages.js"; + +const workspaceA = workspaceIdFromTrustedSource("isolation-a"); +const workspaceB = workspaceIdFromTrustedSource("isolation-b"); + +beforeAll(async () => { + await env.DB.prepare( + `INSERT OR IGNORE INTO workspaces (id, name, created_at, updated_at) + VALUES (?, ?, ?, ?), (?, ?, ?, ?)`, + ) + .bind( + workspaceA, + "Isolation A", + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + workspaceB, + "Isolation B", + "2026-01-01T00:00:00Z", + "2026-01-01T00:00:00Z", + ) + .run(); + await env.DB.batch([ + env.DB.prepare( + `INSERT OR IGNORE INTO users + (id, github_user_id, github_login, created_at, updated_at) + VALUES ('user-a', 1001, 'user-a', ?, ?)`, + ).bind("2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), + env.DB.prepare( + `INSERT OR IGNORE INTO workspace_members + (workspace_id, user_id, role, created_at) VALUES (?, 'user-a', 'owner', ?)`, + ).bind(workspaceA, "2026-01-01T00:00:00Z"), + env.DB.prepare( + `INSERT OR IGNORE INTO github_installations + (workspace_id, installation_id, status, created_at, updated_at) + VALUES (?, 7001, 'active', ?, ?)`, + ).bind(workspaceA, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), + env.DB.prepare( + `INSERT OR IGNORE INTO github_installations + (workspace_id, installation_id, status, created_at, updated_at) + VALUES (?, 7002, 'suspended', ?, ?)`, + ).bind(workspaceB, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), + env.DB.prepare( + `INSERT OR IGNORE INTO github_repositories + (workspace_id, repository_id, installation_id, owner, name, full_name, + enabled, status, created_at, updated_at) + VALUES (?, 9001, 7001, 'same', 'repo', 'same/repo', 1, 'active', ?, ?)`, + ).bind(workspaceA, "2026-01-01T00:00:00Z", "2026-01-01T00:00:00Z"), + ]); +}); + +describe("workspace-scoped D1 store", () => { + it("creates the control plane and legacy migration workspace", async () => { + const legacy = await env.DB.prepare(`SELECT name FROM workspaces WHERE id = 'legacy'`).first<{ + name: string; + }>(); + const columns = await env.DB.prepare(`PRAGMA table_info(threads)`).all<{ name: string }>(); + + expect(legacy?.name).toBe("Legacy self-hosted workspace"); + expect(columns.results.map((column) => column.name)).toContain("workspace_id"); + }); + + it("isolates identical engine identifiers across two workspaces", async () => { + const a = createWorkspaceStore(env.DB, workspaceA); + const b = createWorkspaceStore(env.DB, workspaceB); + const identity = { id: "same-user", handles: { github: "same-handle" } }; + const thread = { + platform: "github", + nativeId: "same/repo#1", + type: "issue" as const, + state: "open", + participants: ["same-user"], + meta: {}, + timeline: [], + }; + + expect((await a.upsertIdentity({ ...identity, displayName: "Workspace A" })).ok).toBe(true); + expect((await b.upsertIdentity({ ...identity, displayName: "Workspace B" })).ok).toBe(true); + expect((await a.upsertThread({ ...thread, title: "Thread A" })).ok).toBe(true); + expect((await b.upsertThread({ ...thread, title: "Thread B" })).ok).toBe(true); + expect( + ( + await a.upsertSignal({ + id: "same-signal", + threadId: thread.nativeId, + kind: "mentioned_no_response", + detectedAt: "2026-01-01T00:00:00Z", + }) + ).ok, + ).toBe(true); + expect( + ( + await b.upsertSignal({ + id: "same-signal", + threadId: thread.nativeId, + kind: "mentioned_no_response", + detectedAt: "2026-01-02T00:00:00Z", + }) + ).ok, + ).toBe(true); + expect( + ( + await a.upsertWorkingNotes({ + scope: "thread", + targetId: thread.nativeId, + content: "A", + contentHash: "same-hash", + provenance: "test", + }) + ).ok, + ).toBe(true); + expect( + ( + await b.upsertWorkingNotes({ + scope: "thread", + targetId: thread.nativeId, + content: "B", + contentHash: "same-hash", + provenance: "test", + }) + ).ok, + ).toBe(true); + + expect((await a.getIdentity("same-user")).data?.displayName).toBe("Workspace A"); + expect((await b.getIdentity("same-user")).data?.displayName).toBe("Workspace B"); + expect((await a.getThread("github", thread.nativeId)).data?.title).toBe("Thread A"); + expect((await b.getThread("github", thread.nativeId)).data?.title).toBe("Thread B"); + expect((await a.listOpenSignals()).data?.[0]?.detectedAt).toBe("2026-01-01T00:00:00Z"); + expect((await b.listOpenSignals()).data?.[0]?.detectedAt).toBe("2026-01-02T00:00:00Z"); + expect((await a.getWorkingNotes("thread", thread.nativeId)).data?.content).toBe("A"); + expect((await b.getWorkingNotes("thread", thread.nativeId)).data?.content).toBe("B"); + + expect((await a.deleteIdentity("same-user")).ok).toBe(true); + expect((await a.getIdentity("same-user")).data).toBeUndefined(); + expect((await b.getIdentity("same-user")).data?.displayName).toBe("Workspace B"); + }); + + it("isolates cluster membership with identical thread ids", async () => { + const a = createWorkspaceStore(env.DB, workspaceA); + const b = createWorkspaceStore(env.DB, workspaceB); + const clusterA = await a.getOrCreateCluster("same/repo#cluster"); + const clusterB = await b.getOrCreateCluster("same/repo#cluster"); + + expect(clusterA.ok && clusterB.ok).toBe(true); + expect(clusterA.data).not.toBe(clusterB.data); + expect((await a.listClusterThreads(clusterA.data!)).data).toEqual(["same/repo#cluster"]); + expect((await b.listClusterThreads(clusterA.data!)).data).toEqual([]); + }); +}); + +describe("tenant key helpers", () => { + const assertSeparated = (helper: (workspaceId: WorkspaceId) => string) => { + expect(helper(workspaceA)).not.toBe(helper(workspaceB)); + expect(helper(workspaceA)).toContain(encodeURIComponent(workspaceA)); + }; + + it("namespaces every external primitive by workspace", () => { + assertSeparated((id) => deliveryKey(id, "same-delivery")); + assertSeparated((id) => installationTokenKey(id, 42)); + assertSeparated((id) => budgetKey(id, "minute", "same-window")); + assertSeparated((id) => llmCacheKey(id, "same-digest")); + assertSeparated((id) => coordinatorName(id, "same-cluster")); + assertSeparated(mergeRegistryName); + }); +}); + +describe("workspace guards", () => { + it("rejects cross-workspace membership and repository access", async () => { + const membershipA = await requireWorkspaceMember(env, workspaceA, "user-a"); + const membershipB = await requireWorkspaceMember(env, workspaceB, "user-a"); + expect(membershipA.ok).toBe(true); + expect(membershipB.ok).toBe(false); + + const installation = await resolveWorkspaceInstallation(env, 7001); + expect(installation.ok && installation.data.workspaceId).toBe(workspaceA); + expect( + ( + await requireEnabledRepository( + env, + { workspaceId: workspaceB, provenance: { kind: "scheduled" } }, + 9001, + ) + ).ok, + ).toBe(false); + }); + + it("rejects suspended installations", async () => { + const suspended = await resolveWorkspaceInstallation(env, 7002); + expect(suspended.ok).toBe(false); + if (!suspended.ok) expect(suspended.error.message).toBe("INSTALLATION_SUSPENDED"); + }); +}); + +describe("workspace message envelope", () => { + it("revalidates installation ownership while parsing", async () => { + const message = createWorkspaceIngestMessage( + { + workspaceId: workspaceA, + provenance: { kind: "github-installation", installationId: 7001 }, + }, + { platform: "github", installationId: 7001, payload: {} }, + ); + const accepted = await parseWorkspaceIngestMessage( + message, + async (installationId, workspaceId) => installationId === 7001 && workspaceId === workspaceA, + ); + const rejected = await parseWorkspaceIngestMessage(message, async () => false); + + expect(accepted.ok).toBe(true); + expect(rejected.ok).toBe(false); + }); + + it("rejects a crafted cross-workspace envelope", async () => { + const crafted = { + version: 1, + workspaceId: workspaceB, + installationId: 7001, + event: { platform: "github", installationId: 7001, payload: {} }, + }; + const parsed = await parseWorkspaceIngestMessage( + crafted, + async (installationId, workspaceId) => installationId === 7001 && workspaceId === workspaceA, + ); + + expect(parsed.ok).toBe(false); + if (!parsed.ok) expect(parsed.error.message).toBe("INSTALLATION_WORKSPACE_MISMATCH"); + }); +}); diff --git a/apps/worker/test/worker.test.ts b/apps/worker/test/worker.test.ts index 103411e..a2aac83 100644 --- a/apps/worker/test/worker.test.ts +++ b/apps/worker/test/worker.test.ts @@ -1,4 +1,4 @@ -import { D1Store } from "@aipm/db"; +import { createWorkspaceStore, LEGACY_WORKSPACE_ID } from "@aipm/db"; import { env, SELF } from "cloudflare:test"; import { describe, expect, it } from "vitest"; import { memberGate } from "../src/members.js"; @@ -18,7 +18,7 @@ describe("worker", () => { }); it("D1Store round-trips an identity against the migrated schema", async () => { - const store = new D1Store(env.DB); + const store = createWorkspaceStore(env.DB, LEGACY_WORKSPACE_ID); const upserted = await store.upsertIdentity({ id: "u1", handles: { github: "octocat" }, diff --git a/docs/managed-slack-follow-up.md b/docs/managed-slack-follow-up.md new file mode 100644 index 0000000..661606f --- /dev/null +++ b/docs/managed-slack-follow-up.md @@ -0,0 +1,22 @@ +# Managed Slack follow-up contract + +Slack is deferred from the managed GitHub-first MVP. GitHub onboarding, shadow +previews, and working notes must operate with no Slack configuration. + +Before managed Slack can be enabled, it must provide: + +- workspace OAuth with a persisted mapping from an ai/pm workspace to a Slack + team ID; +- encrypted, per-workspace bot credentials (never a deployment-global token); +- uninstall/revocation handling that immediately disables delivery and removes + stored credentials; +- team-qualified user, channel, thread, event-dedupe, and cache identifiers; +- installation/team resolution before an event is accepted or enqueued; and +- workspace-scoped audit records for OAuth, ingress, attempted delivery, and + sent/skipped/suppressed/failed outcomes. + +The existing `/webhooks/slack` route and `SLACK_BOT_TOKEN` / +`SLACK_SIGNING_SECRET` settings are legacy self-host configuration only. +`MANAGED_MODE=true` disables that ingress with HTTP 410, and managed engine +contexts do not attach the global Slack token. Do not expose these settings in +the managed control plane. diff --git a/eslint-rules/index.mjs b/eslint-rules/index.mjs index d00840a..f6e2cb3 100644 --- a/eslint-rules/index.mjs +++ b/eslint-rules/index.mjs @@ -1,5 +1,10 @@ import noTryCatch from "./rules/no-try-catch.mjs"; import noRawLoops from "./rules/no-raw-loops.mjs"; +import noDirectD1Access from "./rules/no-direct-d1-access.mjs"; +import noUnscopedTenantPrimitive from "./rules/no-unscoped-tenant-primitive.mjs"; +import noAdHocTenantKey from "./rules/no-ad-hoc-tenant-key.mjs"; +import noUnverifiedWorkspaceId from "./rules/no-unverified-workspace-id.mjs"; +import requireWorkspaceGuard from "./rules/require-workspace-guard.mjs"; const plugin = { meta: { @@ -9,6 +14,11 @@ const plugin = { rules: { "no-try-catch": noTryCatch, "no-raw-loops": noRawLoops, + "no-direct-d1-access": noDirectD1Access, + "no-unscoped-tenant-primitive": noUnscopedTenantPrimitive, + "no-ad-hoc-tenant-key": noAdHocTenantKey, + "no-unverified-workspace-id": noUnverifiedWorkspaceId, + "require-workspace-guard": requireWorkspaceGuard, }, }; diff --git a/eslint-rules/rules/no-ad-hoc-tenant-key.mjs b/eslint-rules/rules/no-ad-hoc-tenant-key.mjs new file mode 100644 index 0000000..bd20bbf --- /dev/null +++ b/eslint-rules/rules/no-ad-hoc-tenant-key.mjs @@ -0,0 +1,47 @@ +const PREFIX = + /^(gh|delivery|installation-token|repo-inst|repository-installation|llm:budget|llm-budget|llm-cache|coordinator|merge-registry|work):/; + +const allowed = (context) => { + const filename = (context.filename ?? context.getFilename?.() ?? "").replaceAll("\\", "/"); + return (context.options[0]?.allowPaths ?? []).some((path) => filename.endsWith(path)); +}; + +const text = (node) => { + if (node.type === "Literal" && typeof node.value === "string") return node.value; + if (node.type === "TemplateLiteral") return node.quasis[0]?.value.raw ?? ""; + return undefined; +}; + +export default { + meta: { + type: "problem", + schema: [ + { + type: "object", + properties: { allowPaths: { type: "array", items: { type: "string" } } }, + additionalProperties: false, + }, + ], + messages: { + key: "Construct recognized tenant keys through apps/worker/src/tenancy/keys.ts.", + }, + }, + create(context) { + if (allowed(context)) return {}; + return { + Literal(node) { + if (typeof node.value === "string" && PREFIX.test(node.value)) { + context.report({ node, messageId: "key" }); + } + }, + TemplateLiteral(node) { + if (PREFIX.test(text(node) ?? "")) context.report({ node, messageId: "key" }); + }, + BinaryExpression(node) { + if (node.operator === "+" && PREFIX.test(text(node.left) ?? "")) { + context.report({ node, messageId: "key" }); + } + }, + }; + }, +}; diff --git a/eslint-rules/rules/no-direct-d1-access.mjs b/eslint-rules/rules/no-direct-d1-access.mjs new file mode 100644 index 0000000..3d73819 --- /dev/null +++ b/eslint-rules/rules/no-direct-d1-access.mjs @@ -0,0 +1,65 @@ +const allowed = (context) => { + const filename = (context.filename ?? context.getFilename?.() ?? "").replaceAll("\\", "/"); + return (context.options[0]?.allowPaths ?? []).some((path) => filename.endsWith(path)); +}; + +export default { + meta: { + type: "problem", + schema: [ + { + type: "object", + properties: { allowPaths: { type: "array", items: { type: "string" } } }, + additionalProperties: false, + }, + ], + messages: { + direct: "Use createWorkspaceStore() or an approved workspace persistence boundary.", + }, + }, + create(context) { + if (allowed(context)) return {}; + const storeNames = new Set(["D1Store"]); + const dbNames = new Set(["db", "DB"]); + return { + ImportSpecifier(node) { + if (node.imported.name === "D1Store") storeNames.add(node.local.name); + }, + VariableDeclarator(node) { + if (node.id.type !== "ObjectPattern") return; + const sourceIsEnv = node.init?.type === "Identifier" && node.init.name === "env"; + if (!sourceIsEnv) return; + node.id.properties.forEach((property) => { + if (property.type !== "Property") return; + if (property.key.type === "Identifier" && property.key.name === "DB") { + if (property.value.type === "Identifier") dbNames.add(property.value.name); + context.report({ node: property, messageId: "direct" }); + } + }); + }, + MemberExpression(node) { + const workspaceFactoryArgument = + node.parent.type === "CallExpression" && + node.parent.callee.type === "Identifier" && + node.parent.callee.name === "createWorkspaceStore"; + const envDb = + node.object.type === "Identifier" && + node.object.name === "env" && + node.property.type === "Identifier" && + node.property.name === "DB" && + !workspaceFactoryArgument; + const directPrepare = + node.property.type === "Identifier" && + node.property.name === "prepare" && + node.object.type === "Identifier" && + dbNames.has(node.object.name); + if (envDb || directPrepare) context.report({ node, messageId: "direct" }); + }, + NewExpression(node) { + if (node.callee.type === "Identifier" && storeNames.has(node.callee.name)) { + context.report({ node, messageId: "direct" }); + } + }, + }; + }, +}; diff --git a/eslint-rules/rules/no-unscoped-tenant-primitive.mjs b/eslint-rules/rules/no-unscoped-tenant-primitive.mjs new file mode 100644 index 0000000..4d9850b --- /dev/null +++ b/eslint-rules/rules/no-unscoped-tenant-primitive.mjs @@ -0,0 +1,63 @@ +const TENANT_BINDINGS = new Set([ + "DELIVERY_DEDUPE", + "INSTALL_TOKENS", + "CLUSTER_COORDINATOR", + "MERGE_REGISTRY", +]); + +const allowed = (context) => { + const filename = (context.filename ?? context.getFilename?.() ?? "").replaceAll("\\", "/"); + return (context.options[0]?.allowPaths ?? []).some((path) => filename.endsWith(path)); +}; + +export default { + meta: { + type: "problem", + schema: [ + { + type: "object", + properties: { allowPaths: { type: "array", items: { type: "string" } } }, + additionalProperties: false, + }, + ], + messages: { + unscoped: "Use a workspace-scoped primitive wrapper from tenancy helpers.", + }, + }, + create(context) { + if (allowed(context)) return {}; + const tenantAliases = new Set(); + return { + VariableDeclarator(node) { + if (node.id.type !== "ObjectPattern") return; + node.id.properties.forEach((property) => { + if ( + property.type === "Property" && + property.key.type === "Identifier" && + TENANT_BINDINGS.has(property.key.name) && + property.value.type === "Identifier" + ) { + tenantAliases.add(property.value.name); + } + }); + }, + CallExpression(node) { + if (node.callee.type !== "MemberExpression") return; + const method = + node.callee.property.type === "Identifier" ? node.callee.property.name : undefined; + if (method === "idFromName") { + context.report({ node, messageId: "unscoped" }); + return; + } + if (method !== "get" && method !== "put" && method !== "delete") return; + const object = node.callee.object; + const directBinding = + object.type === "MemberExpression" && + object.property.type === "Identifier" && + TENANT_BINDINGS.has(object.property.name); + const alias = object.type === "Identifier" && tenantAliases.has(object.name); + if (directBinding || alias) context.report({ node, messageId: "unscoped" }); + }, + }; + }, +}; diff --git a/eslint-rules/rules/no-unverified-workspace-id.mjs b/eslint-rules/rules/no-unverified-workspace-id.mjs new file mode 100644 index 0000000..ef07b5f --- /dev/null +++ b/eslint-rules/rules/no-unverified-workspace-id.mjs @@ -0,0 +1,52 @@ +const allowed = (context) => { + const filename = (context.filename ?? context.getFilename?.() ?? "").replaceAll("\\", "/"); + return (context.options[0]?.allowPaths ?? []).some((path) => filename.endsWith(path)); +}; + +const workspaceTypeName = (typeAnnotation) => + typeAnnotation?.type === "TSTypeReference" && + typeAnnotation.typeName.type === "Identifier" && + typeAnnotation.typeName.name === "WorkspaceId"; + +export default { + meta: { + type: "problem", + schema: [ + { + type: "object", + properties: { allowPaths: { type: "array", items: { type: "string" } } }, + additionalProperties: false, + }, + ], + messages: { + workspace: + "WorkspaceId may only be created by an allowlisted verified resolver or bootstrap module.", + }, + }, + create(context) { + if (allowed(context)) return {}; + const constructors = new Set(["workspaceIdFromTrustedSource"]); + return { + ImportSpecifier(node) { + if (node.imported.name === "workspaceIdFromTrustedSource") { + constructors.add(node.local.name); + } + }, + TSAsExpression(node) { + if (workspaceTypeName(node.typeAnnotation)) { + context.report({ node, messageId: "workspace" }); + } + }, + TSTypeAssertion(node) { + if (workspaceTypeName(node.typeAnnotation)) { + context.report({ node, messageId: "workspace" }); + } + }, + CallExpression(node) { + if (node.callee.type === "Identifier" && constructors.has(node.callee.name)) { + context.report({ node, messageId: "workspace" }); + } + }, + }; + }, +}; diff --git a/eslint-rules/rules/require-workspace-guard.mjs b/eslint-rules/rules/require-workspace-guard.mjs new file mode 100644 index 0000000..6439af2 --- /dev/null +++ b/eslint-rules/rules/require-workspace-guard.mjs @@ -0,0 +1,65 @@ +const GUARDS = new Set([ + "requireWorkspaceMember", + "resolveWorkspaceInstallation", + "resolveWorkspaceInstallationOrLegacy", + "requireEnabledRepository", + "requireEnabledRepositoryUnlessLegacy", + "verifyInstallationBelongsToWorkspace", + "handleGithubInstallationLifecycle", +]); + +const allowed = (context) => { + const filename = (context.filename ?? context.getFilename?.() ?? "").replaceAll("\\", "/"); + return (context.options[0]?.allowPaths ?? []).some((path) => filename.endsWith(path)); +}; + +const accessesTenantState = (source) => + /(?:\.DB\b|createWorkspaceStore\s*\(|\.idFromName\s*\()/.test(source); + +const isEntryPointFile = (context) => { + const filename = (context.filename ?? "").replaceAll("\\", "/"); + return ( + filename.includes("/apps/worker/src/routes/") || + filename.endsWith("/apps/worker/src/index.ts") || + filename.endsWith("/apps/worker/src/coordinator.ts") + ); +}; + +export default { + meta: { + type: "problem", + schema: [ + { + type: "object", + properties: { allowPaths: { type: "array", items: { type: "string" } } }, + additionalProperties: false, + }, + ], + messages: { + guard: "Tenant-state entry points must obtain WorkspaceContext through an approved guard.", + }, + }, + create(context) { + if (allowed(context) || !isEntryPointFile(context)) return {}; + const guardNames = new Set(GUARDS); + const sourceCode = context.sourceCode; + const check = (node) => { + const source = sourceCode.getText(node); + if (!accessesTenantState(source)) return; + const guarded = [...guardNames].some((name) => new RegExp(`\\b${name}\\s*\\(`).test(source)); + if (!guarded) context.report({ node, messageId: "guard" }); + }; + return { + ImportSpecifier(node) { + if (GUARDS.has(node.imported.name)) guardNames.add(node.local.name); + }, + ExportNamedDeclaration(node) { + if (node.declaration?.type === "FunctionDeclaration") check(node.declaration); + if (node.declaration?.type === "VariableDeclaration") check(node.declaration); + }, + ExportDefaultDeclaration(node) { + check(node.declaration); + }, + }; + }, +}; diff --git a/eslint-rules/rules/tenant-isolation.test.mjs b/eslint-rules/rules/tenant-isolation.test.mjs new file mode 100644 index 0000000..9706235 --- /dev/null +++ b/eslint-rules/rules/tenant-isolation.test.mjs @@ -0,0 +1,136 @@ +import { RuleTester } from "eslint"; +import { describe, it } from "node:test"; +import tseslint from "typescript-eslint"; +import noDirectD1Access from "./no-direct-d1-access.mjs"; +import noUnscopedTenantPrimitive from "./no-unscoped-tenant-primitive.mjs"; +import noAdHocTenantKey from "./no-ad-hoc-tenant-key.mjs"; +import noUnverifiedWorkspaceId from "./no-unverified-workspace-id.mjs"; +import requireWorkspaceGuard from "./require-workspace-guard.mjs"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const tester = new RuleTester({ + languageOptions: { + parser: tseslint.parser, + ecmaVersion: 2024, + sourceType: "module", + }, +}); + +tester.run("no-direct-d1-access", noDirectD1Access, { + valid: [ + { + code: `createWorkspaceStore(database, workspaceId);`, + filename: "/repo/apps/worker/src/service.ts", + }, + { + code: `const { DB: database } = env; database.prepare("SELECT 1");`, + filename: "/repo/packages/db/src/index.ts", + options: [{ allowPaths: ["packages/db/src/index.ts"] }], + }, + ], + invalid: [ + { + code: `import { D1Store as Store } from "@aipm/db"; new Store(env.DB);`, + filename: "/repo/apps/worker/src/bad.ts", + errors: [{ messageId: "direct" }, { messageId: "direct" }], + }, + { + code: `const { DB: database } = env; database.prepare("SELECT 1");`, + filename: "/repo/apps/worker/src/bad.ts", + errors: [{ messageId: "direct" }, { messageId: "direct" }], + }, + ], +}); + +tester.run("no-unscoped-tenant-primitive", noUnscopedTenantPrimitive, { + valid: [ + { + code: `tenantKv(workspaceId).put(key, value);`, + filename: "/repo/apps/worker/src/service.ts", + }, + ], + invalid: [ + { + code: `env.CLUSTER_COORDINATOR.idFromName(clusterId);`, + filename: "/repo/apps/worker/src/bad.ts", + errors: [{ messageId: "unscoped" }], + }, + { + code: `const { DELIVERY_DEDUPE: cache } = env; cache.put(key, "1");`, + filename: "/repo/apps/worker/src/bad.ts", + errors: [{ messageId: "unscoped" }], + }, + ], +}); + +tester.run("no-ad-hoc-tenant-key", noAdHocTenantKey, { + valid: [ + { + code: `deliveryKey(workspaceId, deliveryId);`, + filename: "/repo/apps/worker/src/service.ts", + }, + { + code: "const key = `delivery:${workspaceId}:${deliveryId}`;", + filename: "/repo/apps/worker/src/tenancy/keys.ts", + options: [{ allowPaths: ["apps/worker/src/tenancy/keys.ts"] }], + }, + ], + invalid: [ + { + code: "const key = `delivery:${deliveryId}`;", + filename: "/repo/apps/worker/src/bad.ts", + errors: [{ messageId: "key" }], + }, + { + code: `const key = "repo-inst:" + fullName;`, + filename: "/repo/apps/worker/src/bad.ts", + errors: [{ messageId: "key" }, { messageId: "key" }], + }, + ], +}); + +tester.run("no-unverified-workspace-id", noUnverifiedWorkspaceId, { + valid: [ + { + code: `workspaceIdFromTrustedSource(value);`, + filename: "/repo/apps/worker/src/tenancy/guards.ts", + options: [{ allowPaths: ["apps/worker/src/tenancy/guards.ts"] }], + }, + ], + invalid: [ + { + code: `const workspaceId = value as WorkspaceId;`, + filename: "/repo/apps/worker/src/bad.ts", + errors: [{ messageId: "workspace" }], + }, + { + code: `import { workspaceIdFromTrustedSource as brand } from "@aipm/db"; brand(value);`, + filename: "/repo/apps/worker/src/bad.ts", + errors: [{ messageId: "workspace" }], + }, + ], +}); + +tester.run("require-workspace-guard", requireWorkspaceGuard, { + valid: [ + { + code: `import { requireWorkspaceMember as guard } from "./guards.js"; + export async function handler(env, id, user) { + const membership = await guard(env.DB, id, user); + return membership; + }`, + filename: "/repo/apps/worker/src/routes/control.ts", + }, + ], + invalid: [ + { + code: `export async function handler(env, workspaceId) { + return createWorkspaceStore(env.DB, workspaceId); + }`, + filename: "/repo/apps/worker/src/routes/control.ts", + errors: [{ messageId: "guard" }], + }, + ], +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 7c1868c..b3c1434 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -51,6 +51,77 @@ export default defineConfig( rules: { "local/no-try-catch": "error", "local/no-raw-loops": "error", + "local/no-direct-d1-access": [ + "error", + { + allowPaths: [ + "packages/db/src/d1-store.ts", + "packages/db/src/index.ts", + "packages/db/src/control-plane.ts", + "packages/db/src/public-safety.ts", + "apps/worker/src/tenancy/guards.ts", + "apps/worker/src/tenancy/audit.ts", + "apps/worker/src/tenancy/lifecycle.ts", + "apps/worker/src/tenancy/config.ts", + "apps/worker/src/tenancy/sweeps.ts", + "apps/worker/src/tenancy/offboarding.ts", + "apps/worker/src/auth/session.ts", + "apps/worker/src/routes/api.ts", + "apps/worker/test/apply-migrations.ts", + ], + }, + ], + "local/no-unscoped-tenant-primitive": [ + "error", + { + allowPaths: [ + "apps/worker/src/tenancy/keys.ts", + "apps/worker/src/tenancy/kv.ts", + "apps/worker/src/tenancy/durable.ts", + "apps/worker/src/tenancy/index.ts", + "apps/worker/src/tenancy/budgets.ts", + "apps/worker/src/tenancy/offboarding.ts", + "apps/worker/src/auth/session.ts", + "apps/worker/src/routes/slack.ts", + ], + }, + ], + "local/no-ad-hoc-tenant-key": [ + "error", + { + allowPaths: [ + "apps/worker/src/tenancy/keys.ts", + "apps/worker/src/tenancy/kv.ts", + "apps/worker/src/tenancy/budgets.ts", + "apps/worker/src/coordinator.ts", + "apps/worker/src/routes/github.ts", + "apps/worker/src/routes/slack.ts", + "packages/adapter-llm/src/index.ts", + ], + }, + ], + "local/no-unverified-workspace-id": [ + "error", + { + allowPaths: [ + "packages/db/src/workspace.ts", + "packages/db/src/control-plane.ts", + "apps/worker/src/messages.ts", + "apps/worker/src/tenancy/guards.ts", + "apps/worker/src/tenancy/lifecycle.ts", + ], + }, + ], + "local/require-workspace-guard": [ + "error", + { + allowPaths: [ + "apps/worker/src/context.ts", + "apps/worker/src/coordinator.ts", + "apps/worker/src/merge-registry.ts", + ], + }, + ], }, }, ); diff --git a/package.json b/package.json index ff25427..b80ad9b 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "dev": "turbo run dev", "lint": "eslint . --fix", "lint:check": "eslint .", + "test:eslint-rules": "node --test eslint-rules/rules/*.test.mjs", "typecheck": "turbo run typecheck", "test": "turbo run test", "knip": "knip", diff --git a/packages/core/src/pipeline.ts b/packages/core/src/pipeline.ts index 2ab5fec..f401142 100644 --- a/packages/core/src/pipeline.ts +++ b/packages/core/src/pipeline.ts @@ -30,6 +30,18 @@ export interface EngineContext { llm: LlmAdapter; config: EngineConfig; clock: Clock; + audit?: EngineAuditSink; +} + +export interface EngineAuditEntry { + action: string; + outcome: "preview" | "attempted" | "sent" | "skipped" | "suppressed" | "failed"; + repositoryId?: number; + detail?: Readonly>; +} + +export interface EngineAuditSink { + record(entry: EngineAuditEntry): Promise>; } // --- Ingest ------------------------------------------------------------------- @@ -366,7 +378,13 @@ export async function synthesize( const shadow = isShadowed(ctx.config, "workingNotes"); // Up to date: in shadow, "computed" is enough; live also needs it actually posted. - if (stored?.contentHash === contentHash && (shadow || stored.externalRef)) return Ok(undefined); + if (stored?.contentHash === contentHash && (shadow || stored.externalRef)) { + return auditAction(ctx, { + action: "working_note", + outcome: "skipped", + detail: { reason: "unchanged", threadId: thread.nativeId }, + }); + } // Only call the LLM when something changed (bounds cost incl. in shadow). const completed = await ctx.llm.complete(buildNotesInput(thread), { @@ -374,10 +392,24 @@ export async function synthesize( cacheKey: `notes:${thread.nativeId}:${contentHash}`, temperature: 0, }); - if (!completed.ok) return completed; + if (!completed.ok) { + const audited = await auditAction(ctx, { + action: "working_note", + outcome: "failed", + detail: { reason: "llm", threadId: thread.nativeId, error: completed.error.message }, + }); + if (!audited.ok) return audited; + return completed; + } const summaryMarkdown = completed.data; // A transient empty LLM result must not overwrite a good note; retry next event. - if (!summaryMarkdown.trim()) return Ok(undefined); + if (!summaryMarkdown.trim()) { + return auditAction(ctx, { + action: "working_note", + outcome: "skipped", + detail: { reason: "empty_completion", threadId: thread.nativeId }, + }); + } const content = renderWorkingNotes({ ...parts, summaryMarkdown, related }, contentHash); if (shadow) { @@ -392,9 +424,20 @@ export async function synthesize( provenance: `${thread.platform}:shadow`, }); if (!upserted.ok) return upserted; - return Ok(undefined); + return auditAction(ctx, { + action: "working_note", + outcome: "preview", + detail: { threadId: thread.nativeId, contentHash }, + }); } + const attempted = await auditAction(ctx, { + action: "working_note", + outcome: "attempted", + detail: { threadId: thread.nativeId, contentHash }, + }); + if (!attempted.ok) return attempted; + // Edit-or-create exactly one sticky comment. Recover the id from the marker if // it wasn't persisted (retry after a partial post) or D1 lost it. let externalRef = stored?.externalRef; @@ -406,12 +449,28 @@ export async function synthesize( if (externalRef) { const ref = externalRef; const edited = await platform.editMessage(ref, content); - if (!edited.ok && !isNotFound(edited.error)) return edited; + if (!edited.ok && !isNotFound(edited.error)) { + const audited = await auditAction(ctx, { + action: "working_note", + outcome: "failed", + detail: { reason: "edit", threadId: thread.nativeId, error: edited.error.message }, + }); + if (!audited.ok) return audited; + return edited; + } if (!edited.ok) externalRef = undefined; } if (!externalRef) { const posted = await platform.postMessage({ threadNativeId: thread.nativeId }, content); - if (!posted.ok) return posted; + if (!posted.ok) { + const audited = await auditAction(ctx, { + action: "working_note", + outcome: "failed", + detail: { reason: "post", threadId: thread.nativeId, error: posted.error.message }, + }); + if (!audited.ok) return audited; + return posted; + } externalRef = posted.data.id; } @@ -424,9 +483,23 @@ export async function synthesize( externalRef, }); if (!upserted.ok) return upserted; - return Ok(undefined); + return auditAction(ctx, { + action: "working_note", + outcome: "sent", + detail: { threadId: thread.nativeId, contentHash, externalRef }, + }); } +const auditAction = async ( + ctx: EngineContext, + entry: EngineAuditEntry, +): Promise> => { + if (!ctx.audit) return Ok(undefined); + const recorded = await ctx.audit.record(entry); + if (!recorded.ok) return recorded; + return Ok(undefined); +}; + /** Strip the cluster note's hidden marker + hash footer for embedding in an issue note. */ function clusterSummaryFor(content: string): string { return content diff --git a/packages/db/src/control-plane.ts b/packages/db/src/control-plane.ts new file mode 100644 index 0000000..acad32b --- /dev/null +++ b/packages/db/src/control-plane.ts @@ -0,0 +1,709 @@ +import { Err, Ok, Result } from "@aipm/core"; +import { workspaceIdFromTrustedSource, type WorkspaceId } from "./workspace.js"; + +export type WorkspaceRole = "owner" | "admin" | "member"; +export type GithubAccountType = "Organization" | "User"; +export type InstallationStatus = "active" | "suspended" | "deleted"; +export type RepositoryStatus = "active" | "removed"; + +export interface GithubAccountRef { + readonly id: number; + readonly login: string; + readonly type: GithubAccountType; +} + +export interface GithubRepositoryRef { + readonly id: number; + readonly owner: string; + readonly name: string; + readonly fullName: string; +} + +export interface UserRow { + readonly id: string; + readonly githubUserId: number; + readonly githubLogin: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface SessionRow { + readonly idHash: string; + readonly userId: string; + readonly githubLogin: string; + readonly expiresAt: string; + readonly createdAt: string; +} + +export interface WorkspaceConfigRow { + readonly workspaceId: WorkspaceId; + readonly configJson: string; + readonly revision: number; + readonly updatedBy: string | null; + readonly updatedAt: string; +} + +export interface InstallationRecord { + readonly workspaceId: WorkspaceId; + readonly installationId: number; + readonly status: InstallationStatus; + readonly suspendedAt: string | null; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface SweepRepository { + readonly workspaceId: WorkspaceId; + readonly repositoryId: number; + readonly installationId: number; + readonly owner: string; + readonly name: string; + readonly fullName: string; +} + +export interface GithubRepositoryRow { + readonly workspaceId: WorkspaceId; + readonly repositoryId: number; + readonly installationId: number; + readonly owner: string; + readonly name: string; + readonly fullName: string; + readonly enabled: boolean; + readonly status: RepositoryStatus; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface WorkspaceSummary { + readonly id: WorkspaceId; + readonly name: string; + readonly githubAccountId: number | null; + readonly githubAccountType: string | null; + readonly githubAccountLogin: string | null; + readonly role: WorkspaceRole; + readonly createdAt: string; + readonly updatedAt: string; +} + +const nowIso = () => new Date().toISOString(); + +export const upsertUserFromGithub = async ( + db: D1Database, + githubUserId: number, + githubLogin: string, + userId: string, +): Promise> => { + const now = nowIso(); + const written = await Result.from(() => + db + .prepare( + `INSERT INTO users (id, github_user_id, github_login, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(github_user_id) DO UPDATE SET + github_login = excluded.github_login, + updated_at = excluded.updated_at`, + ) + .bind(userId, githubUserId, githubLogin, now, now) + .run(), + ); + if (!written.ok) return written; + return findUserByGithubId(db, githubUserId).then((found) => { + if (!found.ok) return found; + if (!found.data) return Err(new Error("USER_UPSERT_FAILED")); + return Ok(found.data); + }); +}; + +export const findUserByGithubId = async ( + db: D1Database, + githubUserId: number, +): Promise> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT id, github_user_id, github_login, created_at, updated_at + FROM users WHERE github_user_id = ?`, + ) + .bind(githubUserId) + .first<{ + id: string; + github_user_id: number; + github_login: string; + created_at: string; + updated_at: string; + }>(), + ); + if (!loaded.ok) return loaded; + if (!loaded.data) return Ok(null); + return Ok({ + id: loaded.data.id, + githubUserId: loaded.data.github_user_id, + githubLogin: loaded.data.github_login, + createdAt: loaded.data.created_at, + updatedAt: loaded.data.updated_at, + }); +}; + +export const findUserById = async ( + db: D1Database, + userId: string, +): Promise> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT id, github_user_id, github_login, created_at, updated_at + FROM users WHERE id = ?`, + ) + .bind(userId) + .first<{ + id: string; + github_user_id: number; + github_login: string; + created_at: string; + updated_at: string; + }>(), + ); + if (!loaded.ok) return loaded; + if (!loaded.data) return Ok(null); + return Ok({ + id: loaded.data.id, + githubUserId: loaded.data.github_user_id, + githubLogin: loaded.data.github_login, + createdAt: loaded.data.created_at, + updatedAt: loaded.data.updated_at, + }); +}; + +export const createSession = async ( + db: D1Database, + idHash: string, + userId: string, + expiresAt: string, +): Promise> => + Result.from(async () => { + await db + .prepare( + `INSERT INTO sessions (id_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, + ) + .bind(idHash, userId, expiresAt, nowIso()) + .run(); + }); + +export const getSession = async ( + db: D1Database, + idHash: string, +): Promise> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT s.id_hash, s.user_id, s.expires_at, s.created_at, u.github_login + FROM sessions s + JOIN users u ON u.id = s.user_id + WHERE s.id_hash = ?`, + ) + .bind(idHash) + .first<{ + id_hash: string; + user_id: string; + expires_at: string; + created_at: string; + github_login: string; + }>(), + ); + if (!loaded.ok) return loaded; + if (!loaded.data) return Ok(null); + return Ok({ + idHash: loaded.data.id_hash, + userId: loaded.data.user_id, + githubLogin: loaded.data.github_login, + expiresAt: loaded.data.expires_at, + createdAt: loaded.data.created_at, + }); +}; + +export const deleteSession = async (db: D1Database, idHash: string): Promise> => + Result.from(async () => { + await db.prepare(`DELETE FROM sessions WHERE id_hash = ?`).bind(idHash).run(); + }); + +export const deleteExpiredSessions = async ( + db: D1Database, + now: string = nowIso(), +): Promise> => { + const deleted = await Result.from(() => + db.prepare(`DELETE FROM sessions WHERE expires_at <= ?`).bind(now).run(), + ); + if (!deleted.ok) return deleted; + return Ok(deleted.data.meta.changes); +}; + +export const findWorkspaceByGithubAccount = async ( + db: D1Database, + account: GithubAccountRef, +): Promise> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT id FROM workspaces + WHERE github_account_id = ? AND github_account_type = ?`, + ) + .bind(account.id, account.type) + .first<{ id: string }>(), + ); + if (!loaded.ok) return loaded; + if (!loaded.data) return Ok(null); + return Ok(workspaceIdFromTrustedSource(loaded.data.id)); +}; + +export const createWorkspaceForGithubAccount = async ( + db: D1Database, + account: GithubAccountRef, + workspaceId: WorkspaceId = workspaceIdFromTrustedSource(crypto.randomUUID()), +): Promise> => { + const now = nowIso(); + const written = await Result.from(() => + db + .prepare( + `INSERT INTO workspaces + (id, github_account_id, github_account_type, github_account_login, name, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + ) + .bind(workspaceId, account.id, account.type, account.login, account.login, now, now) + .run(), + ); + if (!written.ok) return written; + return Ok(workspaceId); +}; + +export const ensureWorkspaceMember = async ( + db: D1Database, + workspaceId: WorkspaceId, + userId: string, + role: WorkspaceRole = "owner", +): Promise> => + Result.from(async () => { + await db + .prepare( + `INSERT INTO workspace_members (workspace_id, user_id, role, created_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(workspace_id, user_id) DO UPDATE SET role = excluded.role`, + ) + .bind(workspaceId, userId, role, nowIso()) + .run(); + }); + +export const listWorkspacesForUser = async ( + db: D1Database, + userId: string, +): Promise, Error>> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT w.id, w.name, w.github_account_id, w.github_account_type, w.github_account_login, + w.created_at, w.updated_at, m.role + FROM workspace_members m + JOIN workspaces w ON w.id = m.workspace_id + WHERE m.user_id = ? + ORDER BY w.name ASC`, + ) + .bind(userId) + .all<{ + id: string; + name: string; + github_account_id: number | null; + github_account_type: string | null; + github_account_login: string | null; + created_at: string; + updated_at: string; + role: WorkspaceRole; + }>(), + ); + if (!loaded.ok) return loaded; + return Ok( + loaded.data.results.map((row) => ({ + id: workspaceIdFromTrustedSource(row.id), + name: row.name, + githubAccountId: row.github_account_id, + githubAccountType: row.github_account_type, + githubAccountLogin: row.github_account_login, + role: row.role, + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + ); +}; + +export const isWorkspaceGithubMember = async ( + db: D1Database, + workspaceId: WorkspaceId, + githubLogin: string, +): Promise> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT 1 AS ok + FROM workspace_members m + JOIN users u ON u.id = m.user_id + WHERE m.workspace_id = ? AND lower(u.github_login) = lower(?)`, + ) + .bind(workspaceId, githubLogin) + .first<{ ok: number }>(), + ); + if (!loaded.ok) return loaded; + return Ok(Boolean(loaded.data)); +}; + +export const getInstallationRecord = async ( + db: D1Database, + installationId: number, +): Promise> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT workspace_id, installation_id, status, suspended_at, created_at, updated_at + FROM github_installations WHERE installation_id = ?`, + ) + .bind(installationId) + .first<{ + workspace_id: string; + installation_id: number; + status: InstallationStatus; + suspended_at: string | null; + created_at: string; + updated_at: string; + }>(), + ); + if (!loaded.ok) return loaded; + if (!loaded.data) return Ok(null); + return Ok({ + workspaceId: workspaceIdFromTrustedSource(loaded.data.workspace_id), + installationId: loaded.data.installation_id, + status: loaded.data.status, + suspendedAt: loaded.data.suspended_at, + createdAt: loaded.data.created_at, + updatedAt: loaded.data.updated_at, + }); +}; + +export const upsertGithubInstallation = async ( + db: D1Database, + workspaceId: WorkspaceId, + installationId: number, + status: InstallationStatus, +): Promise> => { + const now = nowIso(); + const suspendedAt = status === "suspended" ? now : null; + return Result.from(async () => { + await db + .prepare( + `INSERT INTO github_installations + (workspace_id, installation_id, status, suspended_at, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(installation_id) DO UPDATE SET + workspace_id = excluded.workspace_id, + status = excluded.status, + suspended_at = excluded.suspended_at, + updated_at = excluded.updated_at`, + ) + .bind(workspaceId, installationId, status, suspendedAt, now, now) + .run(); + }); +}; + +export const setInstallationStatus = async ( + db: D1Database, + installationId: number, + status: InstallationStatus, +): Promise> => { + const now = nowIso(); + const suspendedAt = status === "suspended" ? now : null; + return Result.from(async () => { + await db + .prepare( + `UPDATE github_installations + SET status = ?, suspended_at = ?, updated_at = ? + WHERE installation_id = ?`, + ) + .bind(status, suspendedAt, now, installationId) + .run(); + }); +}; + +export const upsertGithubRepositories = async ( + db: D1Database, + workspaceId: WorkspaceId, + installationId: number, + repos: ReadonlyArray, +): Promise> => { + if (!repos.length) return Ok(undefined); + const now = nowIso(); + return Result.from(async () => { + await db.batch( + repos.map((repo) => + db + .prepare( + `INSERT INTO github_repositories + (workspace_id, repository_id, installation_id, owner, name, full_name, + enabled, status, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, 0, 'active', ?, ?) + ON CONFLICT(workspace_id, repository_id) DO UPDATE SET + installation_id = excluded.installation_id, + owner = excluded.owner, + name = excluded.name, + full_name = excluded.full_name, + status = 'active', + updated_at = excluded.updated_at`, + ) + .bind( + workspaceId, + repo.id, + installationId, + repo.owner, + repo.name, + repo.fullName, + now, + now, + ), + ), + ); + }); +}; + +export const markRepositoriesRemoved = async ( + db: D1Database, + workspaceId: WorkspaceId, + repositoryIds: ReadonlyArray, +): Promise> => { + if (!repositoryIds.length) return Ok(undefined); + const now = nowIso(); + return Result.from(async () => { + await db.batch( + repositoryIds.map((repositoryId) => + db + .prepare( + `UPDATE github_repositories + SET enabled = 0, status = 'removed', updated_at = ? + WHERE workspace_id = ? AND repository_id = ?`, + ) + .bind(now, workspaceId, repositoryId), + ), + ); + }); +}; + +export const markInstallationRepositoriesRemoved = async ( + db: D1Database, + installationId: number, +): Promise> => + Result.from(async () => { + await db + .prepare( + `UPDATE github_repositories + SET enabled = 0, status = 'removed', updated_at = ? + WHERE installation_id = ?`, + ) + .bind(nowIso(), installationId) + .run(); + }); + +export const listGithubRepositories = async ( + db: D1Database, + workspaceId: WorkspaceId, +): Promise, Error>> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT workspace_id, repository_id, installation_id, owner, name, full_name, + enabled, status, created_at, updated_at + FROM github_repositories + WHERE workspace_id = ? + ORDER BY full_name ASC`, + ) + .bind(workspaceId) + .all<{ + workspace_id: string; + repository_id: number; + installation_id: number; + owner: string; + name: string; + full_name: string; + enabled: number; + status: RepositoryStatus; + created_at: string; + updated_at: string; + }>(), + ); + if (!loaded.ok) return loaded; + return Ok( + loaded.data.results.map((row) => ({ + workspaceId: workspaceIdFromTrustedSource(row.workspace_id), + repositoryId: row.repository_id, + installationId: row.installation_id, + owner: row.owner, + name: row.name, + fullName: row.full_name, + enabled: row.enabled === 1, + status: row.status, + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + ); +}; + +export const setRepositoriesEnabled = async ( + db: D1Database, + workspaceId: WorkspaceId, + repositoryIds: ReadonlyArray, +): Promise> => { + const now = nowIso(); + const cleared = await Result.from(() => + db + .prepare( + `UPDATE github_repositories SET enabled = 0, updated_at = ? + WHERE workspace_id = ? AND status = 'active'`, + ) + .bind(now, workspaceId) + .run(), + ); + if (!cleared.ok) return cleared; + if (!repositoryIds.length) return Ok(undefined); + return Result.from(async () => { + await db.batch( + repositoryIds.map((repositoryId) => + db + .prepare( + `UPDATE github_repositories SET enabled = 1, updated_at = ? + WHERE workspace_id = ? AND repository_id = ? AND status = 'active'`, + ) + .bind(now, workspaceId, repositoryId), + ), + ); + }); +}; + +export const listActiveSweepRepositories = async ( + db: D1Database, +): Promise, Error>> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT r.workspace_id, r.repository_id, r.installation_id, r.owner, r.name, r.full_name + FROM github_repositories r + JOIN github_installations i + ON i.workspace_id = r.workspace_id AND i.installation_id = r.installation_id + WHERE r.enabled = 1 AND r.status = 'active' AND i.status = 'active' + ORDER BY r.workspace_id, r.full_name`, + ) + .all<{ + workspace_id: string; + repository_id: number; + installation_id: number; + owner: string; + name: string; + full_name: string; + }>(), + ); + if (!loaded.ok) return loaded; + return Ok( + loaded.data.results.map((row) => ({ + workspaceId: workspaceIdFromTrustedSource(row.workspace_id), + repositoryId: row.repository_id, + installationId: row.installation_id, + owner: row.owner, + name: row.name, + fullName: row.full_name, + })), + ); +}; + +export const getWorkspaceConfig = async ( + db: D1Database, + workspaceId: WorkspaceId, +): Promise> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT workspace_id, config_json, revision, updated_by, updated_at + FROM workspace_config WHERE workspace_id = ?`, + ) + .bind(workspaceId) + .first<{ + workspace_id: string; + config_json: string; + revision: number; + updated_by: string | null; + updated_at: string; + }>(), + ); + if (!loaded.ok) return loaded; + if (!loaded.data) return Ok(null); + return Ok({ + workspaceId: workspaceIdFromTrustedSource(loaded.data.workspace_id), + configJson: loaded.data.config_json, + revision: loaded.data.revision, + updatedBy: loaded.data.updated_by, + updatedAt: loaded.data.updated_at, + }); +}; + +export const upsertWorkspaceConfig = async ( + db: D1Database, + workspaceId: WorkspaceId, + configJson: string, + updatedBy: string, +): Promise> => { + const now = nowIso(); + const written = await Result.from(() => + db + .prepare( + `INSERT INTO workspace_config (workspace_id, config_json, revision, updated_by, updated_at) + VALUES (?, ?, 1, ?, ?) + ON CONFLICT(workspace_id) DO UPDATE SET + config_json = excluded.config_json, + revision = workspace_config.revision + 1, + updated_by = excluded.updated_by, + updated_at = excluded.updated_at`, + ) + .bind(workspaceId, configJson, updatedBy, now) + .run(), + ); + if (!written.ok) return written; + const loaded = await getWorkspaceConfig(db, workspaceId); + if (!loaded.ok) return loaded; + if (!loaded.data) return Err(new Error("CONFIG_PUT_FAILED")); + return Ok(loaded.data); +}; + +export const listInstallationsForWorkspace = async ( + db: D1Database, + workspaceId: WorkspaceId, +): Promise, Error>> => { + const loaded = await Result.from(() => + db + .prepare( + `SELECT workspace_id, installation_id, status, suspended_at, created_at, updated_at + FROM github_installations WHERE workspace_id = ? + ORDER BY installation_id ASC`, + ) + .bind(workspaceId) + .all<{ + workspace_id: string; + installation_id: number; + status: InstallationStatus; + suspended_at: string | null; + created_at: string; + updated_at: string; + }>(), + ); + if (!loaded.ok) return loaded; + return Ok( + loaded.data.results.map((row) => ({ + workspaceId: workspaceIdFromTrustedSource(row.workspace_id), + installationId: row.installation_id, + status: row.status, + suspendedAt: row.suspended_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + })), + ); +}; diff --git a/packages/db/src/d1-store.ts b/packages/db/src/d1-store.ts index 79067ef..c857d4b 100644 --- a/packages/db/src/d1-store.ts +++ b/packages/db/src/d1-store.ts @@ -9,6 +9,7 @@ import type { WorkingNotes, } from "@aipm/core"; import { Err, findMap, Ok, Result } from "@aipm/core"; +import type { WorkspaceId } from "./workspace.js"; const threadKey = (platform: string, nativeId: string) => `${platform}:${nativeId}`; @@ -94,7 +95,10 @@ function rowToWorkingNotes(r: Record): WorkingNotes { /** D1-backed implementation of the core Store port. */ export class D1Store implements Store { - constructor(private readonly db: D1Database) {} + constructor( + private readonly db: D1Database, + private readonly workspaceId: WorkspaceId, + ) {} // --- identities --- async upsertIdentity(i: Identity): Promise> { @@ -103,10 +107,10 @@ export class D1Store implements Store { const written = await Result.from(() => this.db .prepare( - `INSERT INTO identities (id, handles, email, display_name) VALUES (?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET handles=excluded.handles, email=excluded.email, display_name=excluded.display_name`, + `INSERT INTO identities (workspace_id, id, handles, email, display_name) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(workspace_id, id) DO UPDATE SET handles=excluded.handles, email=excluded.email, display_name=excluded.display_name`, ) - .bind(i.id, handles.data, i.email ?? null, i.displayName ?? null) + .bind(this.workspaceId, i.id, handles.data, i.email ?? null, i.displayName ?? null) .run(), ); if (!written.ok) return written; @@ -115,7 +119,10 @@ export class D1Store implements Store { async getIdentity(id: string): Promise> { const r = await Result.from(() => - this.db.prepare(`SELECT * FROM identities WHERE id = ?`).bind(id).first(), + this.db + .prepare(`SELECT * FROM identities WHERE workspace_id = ? AND id = ?`) + .bind(this.workspaceId, id) + .first(), ); if (!r.ok) return r; const row = r.data; @@ -130,7 +137,12 @@ export class D1Store implements Store { email?: string; }): Promise> { if (!q.email && !q.handle) return Ok(undefined); - const queried = await Result.from(() => this.db.prepare(`SELECT * FROM identities`).all()); + const queried = await Result.from(() => + this.db + .prepare(`SELECT * FROM identities WHERE workspace_id = ?`) + .bind(this.workspaceId) + .all(), + ); if (!queried.ok) return queried; const match = findMap(queried.data.results, (it) => { const mapped = this.rowToIdentity(it); @@ -145,7 +157,10 @@ export class D1Store implements Store { async deleteIdentity(id: string): Promise> { const written = await Result.from(() => - this.db.prepare(`DELETE FROM identities WHERE id = ?`).bind(id).run(), + this.db + .prepare(`DELETE FROM identities WHERE workspace_id = ? AND id = ?`) + .bind(this.workspaceId, id) + .run(), ); if (!written.ok) return written; return Ok(undefined); @@ -159,8 +174,10 @@ export class D1Store implements Store { // json_set on the JSON column avoids a read-modify-write race across DOs. const written = await Result.from(() => this.db - .prepare(`UPDATE identities SET handles = json_set(handles, '$.' || ?, ?) WHERE id = ?`) - .bind(platform, handle, id) + .prepare( + `UPDATE identities SET handles = json_set(handles, '$.' || ?, ?) WHERE workspace_id = ? AND id = ?`, + ) + .bind(platform, handle, this.workspaceId, id) .run(), ); if (!written.ok) return written; @@ -189,13 +206,14 @@ export class D1Store implements Store { const written = await Result.from(() => this.db .prepare( - `INSERT INTO threads (id, platform, native_id, type, title, body, state, participants, owner, meta, timeline, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET type=excluded.type, title=excluded.title, body=excluded.body, + `INSERT INTO threads (workspace_id, id, platform, native_id, type, title, body, state, participants, owner, meta, timeline, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id, id) DO UPDATE SET type=excluded.type, title=excluded.title, body=excluded.body, state=excluded.state, participants=excluded.participants, owner=excluded.owner, meta=excluded.meta, timeline=excluded.timeline, updated_at=excluded.updated_at`, ) .bind( + this.workspaceId, threadKey(t.platform, t.nativeId), t.platform, t.nativeId, @@ -218,8 +236,8 @@ export class D1Store implements Store { async getThread(platform: string, nativeId: string): Promise> { const r = await Result.from(() => this.db - .prepare(`SELECT * FROM threads WHERE platform = ? AND native_id = ?`) - .bind(platform, nativeId) + .prepare(`SELECT * FROM threads WHERE workspace_id = ? AND platform = ? AND native_id = ?`) + .bind(this.workspaceId, platform, nativeId) .first(), ); if (!r.ok) return r; @@ -236,8 +254,10 @@ export class D1Store implements Store { this.db.batch( links.map((l) => this.db - .prepare(`INSERT OR IGNORE INTO links (from_id, to_id, kind) VALUES (?, ?, ?)`) - .bind(l.from, l.to, l.kind), + .prepare( + `INSERT OR IGNORE INTO links (workspace_id, from_id, to_id, kind) VALUES (?, ?, ?, ?)`, + ) + .bind(this.workspaceId, l.from, l.to, l.kind), ), ), ); @@ -248,13 +268,17 @@ export class D1Store implements Store { async replaceLinksFrom(fromId: string, links: Array): Promise> { const written = await Result.from(() => this.db.batch([ - this.db.prepare(`DELETE FROM links WHERE from_id = ?`).bind(fromId), + this.db + .prepare(`DELETE FROM links WHERE workspace_id = ? AND from_id = ?`) + .bind(this.workspaceId, fromId), ...links .filter((l) => l.from === fromId) .map((l) => this.db - .prepare(`INSERT OR IGNORE INTO links (from_id, to_id, kind) VALUES (?, ?, ?)`) - .bind(l.from, l.to, l.kind), + .prepare( + `INSERT OR IGNORE INTO links (workspace_id, from_id, to_id, kind) VALUES (?, ?, ?, ?)`, + ) + .bind(this.workspaceId, l.from, l.to, l.kind), ), ]), ); @@ -265,8 +289,11 @@ export class D1Store implements Store { async getLinks(threadId: string): Promise, Error>> { const queried = await Result.from(() => this.db - .prepare(`SELECT from_id, to_id, kind FROM links WHERE from_id = ? OR to_id = ?`) - .bind(threadId, threadId) + .prepare( + `SELECT from_id, to_id, kind FROM links + WHERE workspace_id = ? AND (from_id = ? OR to_id = ?)`, + ) + .bind(this.workspaceId, threadId, threadId) .all<{ from_id: string; to_id: string; kind: string }>(), ); if (!queried.ok) return queried; @@ -282,8 +309,8 @@ export class D1Store implements Store { async findCluster(threadNativeId: string): Promise> { const row = await Result.from(() => this.db - .prepare(`SELECT cluster_id FROM thread_cluster WHERE thread_id = ?`) - .bind(threadNativeId) + .prepare(`SELECT cluster_id FROM thread_cluster WHERE workspace_id = ? AND thread_id = ?`) + .bind(this.workspaceId, threadNativeId) .first<{ cluster_id: string }>(), ); if (!row.ok) return row; @@ -294,22 +321,27 @@ export class D1Store implements Store { const freshId = crypto.randomUUID(); const inserted = await Result.from(() => this.db - .prepare(`INSERT OR IGNORE INTO thread_cluster (thread_id, cluster_id) VALUES (?, ?)`) - .bind(threadNativeId, freshId) + .prepare( + `INSERT OR IGNORE INTO thread_cluster (workspace_id, thread_id, cluster_id) VALUES (?, ?, ?)`, + ) + .bind(this.workspaceId, threadNativeId, freshId) .run(), ); if (!inserted.ok) return inserted; const row = await Result.from(() => this.db - .prepare(`SELECT cluster_id FROM thread_cluster WHERE thread_id = ?`) - .bind(threadNativeId) + .prepare(`SELECT cluster_id FROM thread_cluster WHERE workspace_id = ? AND thread_id = ?`) + .bind(this.workspaceId, threadNativeId) .first<{ cluster_id: string }>(), ); if (!row.ok) return row; if (!row.data) return Err(new Error("CLUSTER_MEMBERSHIP_LOST")); const clusterId = row.data.cluster_id; const ensured = await Result.from(() => - this.db.prepare(`INSERT OR IGNORE INTO clusters (id) VALUES (?)`).bind(clusterId).run(), + this.db + .prepare(`INSERT OR IGNORE INTO clusters (workspace_id, id) VALUES (?, ?)`) + .bind(this.workspaceId, clusterId) + .run(), ); if (!ensured.ok) return ensured; return Ok(clusterId); @@ -318,8 +350,10 @@ export class D1Store implements Store { async listClusterThreads(clusterId: string): Promise, Error>> { const queried = await Result.from(() => this.db - .prepare(`SELECT thread_id FROM thread_cluster WHERE cluster_id = ? ORDER BY thread_id`) - .bind(clusterId) + .prepare( + `SELECT thread_id FROM thread_cluster WHERE workspace_id = ? AND cluster_id = ? ORDER BY thread_id`, + ) + .bind(this.workspaceId, clusterId) .all<{ thread_id: string }>(), ); if (!queried.ok) return queried; @@ -332,8 +366,10 @@ export class D1Store implements Store { }): Promise> { const written = await Result.from(() => this.db - .prepare(`UPDATE thread_cluster SET cluster_id = ? WHERE cluster_id = ?`) - .bind(args.toClusterId, args.fromClusterId) + .prepare( + `UPDATE thread_cluster SET cluster_id = ? WHERE workspace_id = ? AND cluster_id = ?`, + ) + .bind(args.toClusterId, this.workspaceId, args.fromClusterId) .run(), ); if (!written.ok) return written; @@ -344,9 +380,13 @@ export class D1Store implements Store { const written = await Result.from(() => this.db.batch([ this.db - .prepare(`DELETE FROM working_notes WHERE scope = 'cluster' AND target_id = ?`) - .bind(clusterId), - this.db.prepare(`DELETE FROM clusters WHERE id = ?`).bind(clusterId), + .prepare( + `DELETE FROM working_notes WHERE workspace_id = ? AND scope = 'cluster' AND target_id = ?`, + ) + .bind(this.workspaceId, clusterId), + this.db + .prepare(`DELETE FROM clusters WHERE workspace_id = ? AND id = ?`) + .bind(this.workspaceId, clusterId), ]), ); if (!written.ok) return written; @@ -358,10 +398,18 @@ export class D1Store implements Store { const written = await Result.from(() => this.db .prepare( - `INSERT INTO signals (id, thread_id, kind, owed_by, detected_at, cleared_at) VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(id) DO UPDATE SET owed_by=excluded.owed_by, cleared_at=excluded.cleared_at`, + `INSERT INTO signals (workspace_id, id, thread_id, kind, owed_by, detected_at, cleared_at) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id, id) DO UPDATE SET owed_by=excluded.owed_by, cleared_at=excluded.cleared_at`, + ) + .bind( + this.workspaceId, + s.id, + s.threadId, + s.kind, + s.owedBy ?? null, + s.detectedAt, + s.clearedAt ?? null, ) - .bind(s.id, s.threadId, s.kind, s.owedBy ?? null, s.detectedAt, s.clearedAt ?? null) .run(), ); if (!written.ok) return written; @@ -371,8 +419,10 @@ export class D1Store implements Store { async getOpenSignals(threadId: string): Promise, Error>> { const queried = await Result.from(() => this.db - .prepare(`SELECT * FROM signals WHERE thread_id = ? AND cleared_at IS NULL`) - .bind(threadId) + .prepare( + `SELECT * FROM signals WHERE workspace_id = ? AND thread_id = ? AND cleared_at IS NULL`, + ) + .bind(this.workspaceId, threadId) .all(), ); if (!queried.ok) return queried; @@ -382,7 +432,12 @@ export class D1Store implements Store { async listOpenSignals(): Promise, Error>> { const queried = await Result.from(() => - this.db.prepare(`SELECT * FROM signals WHERE cleared_at IS NULL ORDER BY detected_at`).all(), + this.db + .prepare( + `SELECT * FROM signals WHERE workspace_id = ? AND cleared_at IS NULL ORDER BY detected_at`, + ) + .bind(this.workspaceId) + .all(), ); if (!queried.ok) return queried; const signals = queried.data.results.map(rowToSignal); @@ -391,7 +446,10 @@ export class D1Store implements Store { async getSignal(id: string): Promise> { const r = await Result.from(() => - this.db.prepare(`SELECT * FROM signals WHERE id = ?`).bind(id).first(), + this.db + .prepare(`SELECT * FROM signals WHERE workspace_id = ? AND id = ?`) + .bind(this.workspaceId, id) + .first(), ); if (!r.ok) return r; if (!r.data) return Ok(undefined); @@ -400,7 +458,10 @@ export class D1Store implements Store { async clearSignal(id: string, clearedAt: string): Promise> { const written = await Result.from(() => - this.db.prepare(`UPDATE signals SET cleared_at = ? WHERE id = ?`).bind(clearedAt, id).run(), + this.db + .prepare(`UPDATE signals SET cleared_at = ? WHERE workspace_id = ? AND id = ?`) + .bind(clearedAt, this.workspaceId, id) + .run(), ); if (!written.ok) return written; return Ok(undefined); @@ -411,12 +472,13 @@ export class D1Store implements Store { const written = await Result.from(() => this.db .prepare( - `INSERT INTO nudges (dedupe_key, person, signal_id, channel, sent_at, state, escalations) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(dedupe_key) DO UPDATE SET signal_id=excluded.signal_id, channel=excluded.channel, + `INSERT INTO nudges (workspace_id, dedupe_key, person, signal_id, channel, sent_at, state, escalations) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id, dedupe_key) DO UPDATE SET signal_id=excluded.signal_id, channel=excluded.channel, sent_at=excluded.sent_at, state=excluded.state, escalations=excluded.escalations`, ) .bind( + this.workspaceId, n.dedupeKey, n.person, n.signalId, @@ -435,14 +497,15 @@ export class D1Store implements Store { const res = await Result.from(() => this.db .prepare( - `INSERT INTO nudges (dedupe_key, person, signal_id, channel, sent_at, state, escalations) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(dedupe_key) DO UPDATE SET person=excluded.person, signal_id=excluded.signal_id, + `INSERT INTO nudges (workspace_id, dedupe_key, person, signal_id, channel, sent_at, state, escalations) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id, dedupe_key) DO UPDATE SET person=excluded.person, signal_id=excluded.signal_id, channel=excluded.channel, sent_at=excluded.sent_at, state=excluded.state, escalations=excluded.escalations WHERE nudges.state = 'shadow'`, ) .bind( + this.workspaceId, n.dedupeKey, n.person, n.signalId, @@ -459,7 +522,10 @@ export class D1Store implements Store { async getNudgeByDedupeKey(dedupeKey: string): Promise> { const r = await Result.from(() => - this.db.prepare(`SELECT * FROM nudges WHERE dedupe_key = ?`).bind(dedupeKey).first(), + this.db + .prepare(`SELECT * FROM nudges WHERE workspace_id = ? AND dedupe_key = ?`) + .bind(this.workspaceId, dedupeKey) + .first(), ); if (!r.ok) return r; return Ok(r.data ? rowToNudge(r.data) : undefined); @@ -467,7 +533,12 @@ export class D1Store implements Store { async listPendingDigestNudges(): Promise, Error>> { const queried = await Result.from(() => - this.db.prepare(`SELECT * FROM nudges WHERE channel = 'digest' AND state = 'pending'`).all(), + this.db + .prepare( + `SELECT * FROM nudges WHERE workspace_id = ? AND channel = 'digest' AND state = 'pending'`, + ) + .bind(this.workspaceId) + .all(), ); if (!queried.ok) return queried; return Ok(queried.data.results.map(rowToNudge)); @@ -476,7 +547,10 @@ export class D1Store implements Store { // --- preferences --- async getPreferences(person: string): Promise, Error>> { const queried = await Result.from(() => - this.db.prepare(`SELECT * FROM preferences WHERE person = ?`).bind(person).all(), + this.db + .prepare(`SELECT * FROM preferences WHERE workspace_id = ? AND person = ?`) + .bind(this.workspaceId, person) + .all(), ); if (!queried.ok) return queried; const mappedRows = queried.data.results.map((r) => { @@ -504,10 +578,10 @@ export class D1Store implements Store { const written = await Result.from(() => this.db .prepare( - `INSERT INTO preferences (person, rule, selector, until) VALUES (?, ?, ?, ?) - ON CONFLICT(person, rule, selector) DO UPDATE SET until=excluded.until`, + `INSERT INTO preferences (workspace_id, person, rule, selector, until) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(workspace_id, person, rule, selector) DO UPDATE SET until=excluded.until`, ) - .bind(p.person, p.rule, selector.data, p.until ?? null) + .bind(this.workspaceId, p.person, p.rule, selector.data, p.until ?? null) .run(), ); if (!written.ok) return written; @@ -521,8 +595,10 @@ export class D1Store implements Store { ): Promise> { const r = await Result.from(() => this.db - .prepare(`SELECT * FROM working_notes WHERE scope = ? AND target_id = ?`) - .bind(scope, targetId) + .prepare( + `SELECT * FROM working_notes WHERE workspace_id = ? AND scope = ? AND target_id = ?`, + ) + .bind(this.workspaceId, scope, targetId) .first(), ); if (!r.ok) return r; @@ -533,7 +609,10 @@ export class D1Store implements Store { scope: WorkingNotes["scope"], ): Promise, Error>> { const queried = await Result.from(() => - this.db.prepare(`SELECT * FROM working_notes WHERE scope = ?`).bind(scope).all(), + this.db + .prepare(`SELECT * FROM working_notes WHERE workspace_id = ? AND scope = ?`) + .bind(this.workspaceId, scope) + .all(), ); if (!queried.ok) return queried; return Ok(queried.data.results.map(rowToWorkingNotes)); @@ -543,13 +622,21 @@ export class D1Store implements Store { const written = await Result.from(() => this.db .prepare( - `INSERT INTO working_notes (scope, target_id, content, content_hash, provenance, external_ref) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(scope, target_id) DO UPDATE SET content=excluded.content, + `INSERT INTO working_notes (workspace_id, scope, target_id, content, content_hash, provenance, external_ref) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(workspace_id, scope, target_id) DO UPDATE SET content=excluded.content, content_hash=excluded.content_hash, provenance=excluded.provenance, external_ref=excluded.external_ref`, ) - .bind(n.scope, n.targetId, n.content, n.contentHash, n.provenance, n.externalRef ?? null) + .bind( + this.workspaceId, + n.scope, + n.targetId, + n.content, + n.contentHash, + n.provenance, + n.externalRef ?? null, + ) .run(), ); if (!written.ok) return written; diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 802242c..728445b 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1 +1,68 @@ -export { D1Store } from "./d1-store.js"; +import { D1Store } from "./d1-store.js"; +import type { WorkspaceId } from "./workspace.js"; + +export const createWorkspaceStore = (db: D1Database, workspaceId: WorkspaceId): D1Store => + new D1Store(db, workspaceId); + +export { + LEGACY_WORKSPACE_ID, + type WorkspaceContext, + type WorkspaceId, + type WorkspaceProvenance, + workspaceIdFromTrustedSource, +} from "./workspace.js"; + +export { + createSession, + createWorkspaceForGithubAccount, + deleteExpiredSessions, + deleteSession, + ensureWorkspaceMember, + findUserByGithubId, + findUserById, + findWorkspaceByGithubAccount, + getInstallationRecord, + getSession, + getWorkspaceConfig, + isWorkspaceGithubMember, + listActiveSweepRepositories, + listGithubRepositories, + listInstallationsForWorkspace, + listWorkspacesForUser, + markInstallationRepositoriesRemoved, + markRepositoriesRemoved, + setInstallationStatus, + setRepositoriesEnabled, + type GithubAccountRef, + type GithubAccountType, + type GithubRepositoryRef, + type GithubRepositoryRow, + type InstallationRecord, + type InstallationStatus, + type RepositoryStatus, + type SessionRow, + type SweepRepository, + type UserRow, + type WorkspaceConfigRow, + type WorkspaceRole, + type WorkspaceSummary, + upsertGithubInstallation, + upsertGithubRepositories, + upsertUserFromGithub, + upsertWorkspaceConfig, +} from "./control-plane.js"; + +export { + appendAuditAction, + auditOutcomes, + enableWorkspaceCapability, + listAuditActions, + offboardWorkspace, + type AppendAuditAction, + type AuditAction, + type AuditActor, + type AuditOutcome, + type AuditSource, + type ManagedCapability, + type WorkspaceCredentialRemover, +} from "./public-safety.js"; diff --git a/packages/db/src/public-safety.ts b/packages/db/src/public-safety.ts new file mode 100644 index 0000000..cd45322 --- /dev/null +++ b/packages/db/src/public-safety.ts @@ -0,0 +1,315 @@ +import { Err, Ok, Result, findMap } from "@aipm/core"; +import type { WorkspaceId } from "./workspace.js"; + +export const auditOutcomes = [ + "preview", + "attempted", + "sent", + "skipped", + "suppressed", + "failed", + "revised", +] as const; + +export type AuditOutcome = (typeof auditOutcomes)[number]; +export type AuditSource = "github" | "control-plane" | "worker" | "scheduler" | "system"; + +export interface AuditActor { + readonly source: AuditSource; + readonly id?: string; + readonly login?: string; + readonly kind?: "user" | "installation" | "service"; +} + +export interface AuditAction { + readonly id: string; + readonly workspaceId: WorkspaceId; + readonly repositoryId?: number; + readonly action: string; + readonly outcome: AuditOutcome; + readonly actor: AuditActor; + readonly detail: Readonly>; + readonly createdAt: string; +} + +export interface AppendAuditAction { + readonly id?: string; + readonly repositoryId?: number; + readonly action: string; + readonly outcome: AuditOutcome; + readonly actor: AuditActor; + readonly detail?: Readonly>; + readonly createdAt?: string; +} + +/** Append-only audit boundary. No update/delete API is exposed. */ +export async function appendAuditAction( + db: D1Database, + workspaceId: WorkspaceId, + entry: AppendAuditAction, +): Promise> { + const action: AuditAction = { + id: entry.id ?? crypto.randomUUID(), + workspaceId, + ...(entry.repositoryId === undefined ? {} : { repositoryId: entry.repositoryId }), + action: entry.action, + outcome: entry.outcome, + actor: entry.actor, + detail: entry.detail ?? {}, + createdAt: entry.createdAt ?? new Date().toISOString(), + }; + const serialized = serializeAudit(action); + if (!serialized.ok) return serialized; + const written = await Result.from(() => + db + .prepare( + `INSERT INTO audit_actions ( + workspace_id, id, repository_id, action, outcome, + actor_json, detail_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + workspaceId, + action.id, + action.repositoryId ?? null, + action.action, + action.outcome, + serialized.data.actor, + serialized.data.detail, + action.createdAt, + ) + .run(), + ); + if (!written.ok) return written; + return Ok(action); +} + +export async function listAuditActions( + db: D1Database, + workspaceId: WorkspaceId, + limit = 100, +): Promise, Error>> { + const safeLimit = Math.max(1, Math.min(Math.trunc(limit), 250)); + const loaded = await Result.from(() => + db + .prepare( + `SELECT id, repository_id, action, outcome, actor_json, detail_json, created_at + FROM audit_actions + WHERE workspace_id = ? + ORDER BY created_at DESC, id DESC + LIMIT ?`, + ) + .bind(workspaceId, safeLimit) + .all(), + ); + if (!loaded.ok) return loaded; + const parsed = loaded.data.results.map((row) => parseAuditRow(workspaceId, row)); + const firstError = findMap(parsed, (result) => + result.ok ? { kind: "CONTINUE" as const } : { kind: "FOUND" as const, data: result }, + ); + if (firstError) return firstError; + return Ok(parsed.flatMap((result) => (result.ok ? [result.data] : []))); +} + +export type ManagedCapability = "workingNotes" | "nudges" | "digest" | "proposals" | "orgRollup"; + +/** + * Explicitly transitions one capability live and records the config revision + * in the same D1 batch. Unknown config fields are preserved. + */ +export async function enableWorkspaceCapability( + db: D1Database, + workspaceId: WorkspaceId, + capability: ManagedCapability, + actor: AuditActor, +): Promise> { + const loaded = await Result.from(() => + db + .prepare(`SELECT config_json, revision FROM workspace_config WHERE workspace_id = ?`) + .bind(workspaceId) + .first<{ config_json: string; revision: number }>(), + ); + if (!loaded.ok) return loaded; + if (!loaded.data) return Err(new Error("WORKSPACE_CONFIG_REQUIRED")); + const parsed = parseConfig(loaded.data.config_json); + if (!parsed.ok) return parsed; + + const previousRevision = loaded.data.revision; + const revision = previousRevision + 1; + const config = { + ...parsed.data, + shadow: { + ...asRecord(parsed.data.shadow), + global: asRecord(parsed.data.shadow).global ?? true, + capabilities: { + ...asRecord(asRecord(parsed.data.shadow).capabilities), + [capability]: false, + }, + }, + }; + const audit: AuditAction = { + id: crypto.randomUUID(), + workspaceId, + action: "capability.go_live", + outcome: "revised", + actor, + detail: { capability, previousRevision, revision }, + createdAt: new Date().toISOString(), + }; + const serialized = serializeAudit(audit); + if (!serialized.ok) return serialized; + const configJson = Result.fromSync(() => JSON.stringify(config)); + if (!configJson.ok) return configJson; + + const written = await Result.from(() => + db.batch([ + db + .prepare( + `UPDATE workspace_config + SET config_json = ?, revision = ?, updated_by = ?, updated_at = ? + WHERE workspace_id = ? AND revision = ?`, + ) + .bind( + configJson.data, + revision, + actor.id ?? actor.source, + audit.createdAt, + workspaceId, + previousRevision, + ), + db + .prepare( + `INSERT INTO audit_actions ( + workspace_id, id, repository_id, action, outcome, + actor_json, detail_json, created_at + ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?)`, + ) + .bind( + workspaceId, + audit.id, + audit.action, + audit.outcome, + serialized.data.actor, + serialized.data.detail, + audit.createdAt, + ), + ]), + ); + if (!written.ok) return written; + if (written.data[0]?.meta.changes !== 1) return Err(new Error("CONFIG_REVISION_CONFLICT")); + return Ok(revision); +} + +export interface WorkspaceCredentialRemover { + deleteInstallationCredential(installationId: number): Promise; + deleteWorkspaceCredentials?(workspaceId: WorkspaceId): Promise; +} + +/** + * Offboards in fail-safe order: installations/repositories are disabled first, + * credentials are removed second, and workspace-owned D1 rows are removed last. + * A credential failure leaves the workspace disabled and safe to retry. + */ +export async function offboardWorkspace( + db: D1Database, + workspaceId: WorkspaceId, + credentials: WorkspaceCredentialRemover, +): Promise> { + const installations = await Result.from(() => + db + .prepare(`SELECT installation_id FROM github_installations WHERE workspace_id = ?`) + .bind(workspaceId) + .all<{ installation_id: number }>(), + ); + if (!installations.ok) return installations; + + const disabled = await Result.from(() => + db.batch([ + db + .prepare( + `UPDATE github_installations + SET status = 'deleted', updated_at = ? + WHERE workspace_id = ?`, + ) + .bind(new Date().toISOString(), workspaceId), + db + .prepare( + `UPDATE github_repositories + SET enabled = 0, status = 'removed', updated_at = ? + WHERE workspace_id = ?`, + ) + .bind(new Date().toISOString(), workspaceId), + ]), + ); + if (!disabled.ok) return disabled; + + const removedCredentials = await Result.from(async () => { + await Promise.all( + installations.data.results.map(({ installation_id }) => + credentials.deleteInstallationCredential(installation_id), + ), + ); + await credentials.deleteWorkspaceCredentials?.(workspaceId); + }); + if (!removedCredentials.ok) return removedCredentials; + + const removedData = await Result.from(() => + db.prepare(`DELETE FROM workspaces WHERE id = ?`).bind(workspaceId).run(), + ); + if (!removedData.ok) return removedData; + return Ok(undefined); +} + +interface AuditRow { + id: string; + repository_id: number | null; + action: string; + outcome: string; + actor_json: string; + detail_json: string; + created_at: string; +} + +const asRecord = (value: unknown): Record => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; + +const parseConfig = (raw: string): Result, Error> => { + const parsed = Result.fromSync(() => JSON.parse(raw) as unknown); + if (!parsed.ok) return parsed; + if (Object.keys(asRecord(parsed.data)).length === 0 && parsed.data !== null) { + return Err(new Error("INVALID_WORKSPACE_CONFIG")); + } + return Ok(asRecord(parsed.data)); +}; + +const serializeAudit = (action: AuditAction): Result<{ actor: string; detail: string }, Error> => { + const actor = Result.fromSync(() => JSON.stringify(action.actor)); + if (!actor.ok) return actor; + const detail = Result.fromSync(() => JSON.stringify(action.detail)); + if (!detail.ok) return detail; + return Ok({ actor: actor.data, detail: detail.data }); +}; + +const parseAuditRow = (workspaceId: WorkspaceId, row: AuditRow): Result => { + const actor = Result.fromSync(() => JSON.parse(row.actor_json) as AuditActor); + if (!actor.ok) return actor; + const detail = Result.fromSync( + () => JSON.parse(row.detail_json) as Readonly>, + ); + if (!detail.ok) return detail; + if (!auditOutcomes.includes(row.outcome as AuditOutcome)) { + return Err(new Error(`INVALID_AUDIT_OUTCOME:${row.outcome}`)); + } + return Ok({ + id: row.id, + workspaceId, + ...(row.repository_id === null ? {} : { repositoryId: row.repository_id }), + action: row.action, + outcome: row.outcome as AuditOutcome, + actor: actor.data, + detail: detail.data, + createdAt: row.created_at, + }); +}; diff --git a/packages/db/src/workspace.ts b/packages/db/src/workspace.ts new file mode 100644 index 0000000..459e6d8 --- /dev/null +++ b/packages/db/src/workspace.ts @@ -0,0 +1,22 @@ +declare const workspaceIdBrand: unique symbol; + +export type WorkspaceId = string & { readonly [workspaceIdBrand]: "WorkspaceId" }; + +export const LEGACY_WORKSPACE_ID = "legacy" as WorkspaceId; + +export type WorkspaceProvenance = + | { readonly kind: "legacy-bootstrap" } + | { readonly kind: "github-installation"; readonly installationId: number } + | { readonly kind: "session"; readonly sessionId: string } + | { readonly kind: "scheduled" }; + +export interface WorkspaceContext { + readonly workspaceId: WorkspaceId; + readonly provenance: WorkspaceProvenance; +} + +/** Resolver/bootstrap boundary for converting a validated persisted id. */ +export const workspaceIdFromTrustedSource = (value: string): WorkspaceId => { + if (!value.trim()) throw new Error("WORKSPACE_ID_REQUIRED"); + return value as WorkspaceId; +}; diff --git a/site/app/app/[[...slug]]/page-client.tsx b/site/app/app/[[...slug]]/page-client.tsx new file mode 100644 index 0000000..4bfc9dd --- /dev/null +++ b/site/app/app/[[...slug]]/page-client.tsx @@ -0,0 +1,289 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { AppShell, Muted, NavLink, Panel } from "../../../components/app-shell"; +import { apiGet, apiMutate, fetchMe } from "../../../lib/api"; + +type Section = "overview" | "repositories" | "configuration" | "activity" | "settings"; + +const parsePath = (): { workspaceId: string; section: Section } => { + if (typeof window === "undefined") return { workspaceId: "", section: "overview" }; + const parts = window.location.pathname.split("/").filter(Boolean); + const workspaceId = parts[1] ?? ""; + const section = (parts[2] as Section | undefined) ?? "overview"; + const allowed: Array
= [ + "overview", + "repositories", + "configuration", + "activity", + "settings", + ]; + return { + workspaceId, + section: allowed.includes(section) ? section : "overview", + }; +}; + +const AppDashboardPage = () => { + const initial = useMemo(() => parsePath(), []); + const [workspaceId] = useState(initial.workspaceId); + const [section] = useState
(initial.section); + const [csrf, setCsrf] = useState(""); + + useEffect(() => { + fetchMe() + .then((me) => { + if (me) setCsrf(me.csrfToken); + }) + .catch(() => undefined); + }, []); + + if (!workspaceId) { + return ( + + + Missing workspace id in the path. Start from setup. +
+ go to setup +
+
+
+ ); + } + + return ( + + + {section === "overview" ? : null} + {section === "repositories" ? : null} + {section === "configuration" ? : null} + {section === "activity" ? : null} + {section === "settings" ? : null} + + ); +}; + +const Overview = (props: { workspaceId: string }) => { + const [summary, setSummary] = useState("loading…"); + useEffect(() => { + Promise.all([ + apiGet<{ + installations: Array<{ status: string }>; + repositories: Array<{ enabled: boolean }>; + }>(`/api/workspaces/${props.workspaceId}/repositories`), + apiGet<{ + config: { shadow: { global: boolean } }; + }>(`/api/workspaces/${props.workspaceId}/config`), + ]) + .then(([repos, config]) => { + const enabled = repos.repositories.filter((repo) => repo.enabled).length; + const activeInstalls = repos.installations.filter( + (item) => item.status === "active", + ).length; + setSummary( + `${activeInstalls} active install(s) · ${enabled} enabled repo(s) · shadow global ${config.config.shadow.global ? "on" : "off"}`, + ); + }) + .catch((err: Error) => setSummary(err.message)); + }, [props.workspaceId]); + + return ( + + {summary} +

+ Slack is unavailable / coming next. GitHub operation does not depend on Slack. +

+
+ ); +}; + +const Repositories = (props: { workspaceId: string; csrf: string }) => { + const [repos, setRepos] = useState< + Array<{ repositoryId: number; fullName: string; enabled: boolean }> + >([]); + const [selected, setSelected] = useState>(new Set()); + const [message, setMessage] = useState(null); + + useEffect(() => { + apiGet<{ + repositories: Array<{ + repositoryId: number; + fullName: string; + enabled: boolean; + status: string; + }>; + }>(`/api/workspaces/${props.workspaceId}/repositories`) + .then((data) => { + const active = data.repositories.filter((repo) => repo.status === "active"); + setRepos(active); + setSelected( + new Set(active.filter((repo) => repo.enabled).map((repo) => repo.repositoryId)), + ); + }) + .catch((err: Error) => setMessage(err.message)); + }, [props.workspaceId]); + + return ( + + Enabled repositories are eligible for shadow processing. +
    + {repos.map((repo) => ( +
  • + { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(repo.repositoryId)) next.delete(repo.repositoryId); + else next.add(repo.repositoryId); + return next; + }); + }} + /> + {repo.fullName} +
  • + ))} +
+ + {message ?

{message}

: null} +
+ ); +}; + +const Configuration = (props: { workspaceId: string; csrf: string }) => { + const [workingNotesLive, setWorkingNotesLive] = useState(false); + const [revision, setRevision] = useState(0); + const [message, setMessage] = useState(null); + + useEffect(() => { + apiGet<{ + config: { shadow: { capabilities: Record } }; + revision: number; + }>(`/api/workspaces/${props.workspaceId}/config`) + .then((data) => { + setRevision(data.revision); + setWorkingNotesLive(data.config.shadow.capabilities.workingNotes === false); + }) + .catch((err: Error) => setMessage(err.message)); + }, [props.workspaceId]); + + return ( + + + Shadow-first defaults. Enabling working notes is an explicit capability transition recorded + in the activity feed. + +

config revision {revision}

+

+ working notes:{" "} + + {workingNotesLive ? "live" : "shadow"} + +

+ {!workingNotesLive ? ( + + ) : null} + {message ?

{message}

: null} +
+ ); +}; + +const Activity = (props: { workspaceId: string }) => { + const [items, setItems] = useState< + Array<{ id: string; action: string; outcome: string; createdAt: string }> + >([]); + const [error, setError] = useState(null); + + useEffect(() => { + apiGet<{ items: Array<{ id: string; action: string; outcome: string; createdAt: string }> }>( + `/api/workspaces/${props.workspaceId}/activity?limit=50`, + ) + .then((data) => setItems(data.items)) + .catch((err: Error) => setError(err.message)); + }, [props.workspaceId]); + + return ( + + Append-only activity and preview feed for this workspace. + {error ?

{error}

: null} +
    + {items.map((item) => ( +
  • + {item.createdAt} · {item.action} · {item.outcome} +
  • + ))} + {!items.length && !error ? ( +
  • no activity yet
  • + ) : null} +
+
+ ); +}; + +const Settings = () => ( + + + Workspace settings. Slack OAuth is intentionally unavailable until a tenant-safe follow-up + ships. Do not paste manual Slack tokens into the managed service. + +
+ slack + coming next +
+
+ github oauth + connected via session +
+
+); + +export default AppDashboardPage; diff --git a/site/app/app/[[...slug]]/page.tsx b/site/app/app/[[...slug]]/page.tsx new file mode 100644 index 0000000..1e8998e --- /dev/null +++ b/site/app/app/[[...slug]]/page.tsx @@ -0,0 +1,9 @@ +import AppDashboardPage from "./page-client"; + +export function generateStaticParams() { + return [{ slug: [] as Array }]; +} + +const Page = () => ; + +export default Page; diff --git a/site/app/login/page.tsx b/site/app/login/page.tsx new file mode 100644 index 0000000..105605e --- /dev/null +++ b/site/app/login/page.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AppShell, Muted, Panel } from "../../components/app-shell"; +import { authGithubUrl } from "../../lib/api"; + +const LoginPage = () => { + const [returnTo, setReturnTo] = useState("/setup/github"); + + useEffect(() => { + const fromQuery = new URLSearchParams(window.location.search).get("returnTo"); + if (fromQuery) setReturnTo(fromQuery); + }, []); + + return ( + + + + Managed ai/pm uses GitHub OAuth. New repositories start in shadow mode — previews only — + until you explicitly enable working notes. + + + continue with github + + + + ); +}; + +export default LoginPage; diff --git a/site/app/page.tsx b/site/app/page.tsx index 20583da..fe65680 100644 --- a/site/app/page.tsx +++ b/site/app/page.tsx @@ -300,6 +300,9 @@ const Page = () => { get started + + sign in + }; + }; + revision: number; + slack: { status: string }; +}; + +const SetupConfigurePage = () => { + const workspaceId = useMemo(() => { + if (typeof window === "undefined") return ""; + return new URLSearchParams(window.location.search).get("workspace") ?? ""; + }, []); + const [csrf, setCsrf] = useState(""); + const [notesPrompt, setNotesPrompt] = useState(""); + const [clusterPrompt, setClusterPrompt] = useState(""); + const [shadowGlobal, setShadowGlobal] = useState(true); + const [slackStatus, setSlackStatus] = useState("coming_next"); + const [error, setError] = useState(null); + const [saved, setSaved] = useState(false); + + useEffect(() => { + if (!workspaceId) return; + Promise.all([fetchMe(), apiGet(`/api/workspaces/${workspaceId}/config`)]) + .then(([me, data]) => { + if (me) setCsrf(me.csrfToken); + setNotesPrompt(data.config.notesPrompt); + setClusterPrompt(data.config.clusterPrompt); + setShadowGlobal(data.config.shadow.global); + setSlackStatus(data.slack.status); + }) + .catch((err: Error) => setError(err.message)); + }, [workspaceId]); + + const save = async () => { + setError(null); + try { + await apiMutate(`/api/workspaces/${workspaceId}/config`, "PUT", csrf, { + config: { + notesPrompt, + clusterPrompt, + shadow: { global: shadowGlobal, capabilities: {} }, + }, + }); + setSaved(true); + } catch (err) { + setError(err instanceof Error ? err.message : "save_failed"); + } + }; + + return ( + + + + Review default prompts. Shadow mode stays on for new workspaces — nothing is posted to + GitHub until you go live on a capability from the dashboard. + + {error ?

{error}

: null} +