chore: convert submodules to standard directories for true monorepo structure

This commit is contained in:
2026-05-13 14:54:23 -07:00
parent 4339da259c
commit abf9bf89c2
761 changed files with 133928 additions and 2 deletions

View File

@@ -0,0 +1,189 @@
/**
* GitHub OAuth integration helpers.
*
* Storage layout — fs_users.data.integrations.github:
* {
* login: "octocat",
* accessToken: <encryptSecret(token)>, // encrypted at rest
* scope: "repo,read:user",
* connectedAt: ISO,
* }
*
* Tokens never leave the server. All API calls happen server-side; the
* UI only ever sees the GitHub `login` and the list of repos.
*
* Scopes requested: `repo` (private+public read+write so we can mirror)
* `read:user` (so we can show the connected username)
*/
import { encryptSecret, decryptSecret } from "@/lib/auth/secret-box";
import { query } from "@/lib/db-postgres";
const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? "";
const SCOPES = "repo,read:user";
export function isGithubOauthConfigured(): boolean {
return CLIENT_ID.length > 0 && CLIENT_SECRET.length > 0;
}
/** Build the GitHub authorize URL. `state` MUST be unguessable per request. */
export function buildAuthorizeUrl(state: string, callbackUrl: string): string {
const u = new URL("https://github.com/login/oauth/authorize");
u.searchParams.set("client_id", CLIENT_ID);
u.searchParams.set("redirect_uri", callbackUrl);
u.searchParams.set("scope", SCOPES);
u.searchParams.set("state", state);
u.searchParams.set("allow_signup", "true");
return u.toString();
}
interface TokenExchangeResult {
accessToken: string;
scope: string;
tokenType: string;
}
/** POST /login/oauth/access_token — exchange auth code for an access token. */
export async function exchangeCodeForToken(
code: string, callbackUrl: string,
): Promise<TokenExchangeResult> {
const res = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: callbackUrl,
}),
});
const body = await res.json() as {
access_token?: string; scope?: string; token_type?: string;
error?: string; error_description?: string;
};
if (!res.ok || !body.access_token) {
throw new Error(body.error_description || body.error || "GitHub token exchange failed");
}
return {
accessToken: body.access_token,
scope: body.scope ?? "",
tokenType: body.token_type ?? "bearer",
};
}
/** GET /user — fetch the authenticated user's login + name. */
export async function getAuthenticatedUser(token: string) {
const res = await fetch("https://api.github.com/user", {
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!res.ok) {
throw new Error(`GitHub /user failed: ${res.status} ${res.statusText}`);
}
return res.json() as Promise<{ login: string; name: string | null; avatar_url: string }>;
}
interface GithubRepo {
id: number;
name: string;
full_name: string;
private: boolean;
description: string | null;
default_branch: string;
html_url: string;
pushed_at: string | null;
language: string | null;
fork: boolean;
}
/**
* GET /user/repos — list repos the authenticated user can access.
* Returns at most `perPage` repos; we cap at 100 (GitHub max) and don't
* paginate further. The picker UI does client-side filter on top.
*/
export async function listUserRepos(token: string, perPage = 100): Promise<GithubRepo[]> {
const u = new URL("https://api.github.com/user/repos");
u.searchParams.set("per_page", String(perPage));
u.searchParams.set("sort", "pushed");
u.searchParams.set("affiliation", "owner,collaborator,organization_member");
const res = await fetch(u.toString(), {
headers: {
"Authorization": `Bearer ${token}`,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
});
if (!res.ok) {
throw new Error(`GitHub /user/repos failed: ${res.status} ${res.statusText}`);
}
return res.json() as Promise<GithubRepo[]>;
}
// ─────────────────────────────────────────────────────────────────────────────
// fs_users persistence
// ─────────────────────────────────────────────────────────────────────────────
export interface StoredGithubIntegration {
login: string;
accessToken: string; // encrypted
scope: string;
connectedAt: string;
}
/** Write the integration to fs_users.data.integrations.github. */
export async function persistGithubIntegration(
email: string, login: string, plainToken: string, scope: string,
): Promise<void> {
const blob: StoredGithubIntegration = {
login,
accessToken: encryptSecret(plainToken),
scope,
connectedAt: new Date().toISOString(),
};
await query(
`UPDATE fs_users
SET data = jsonb_set(
jsonb_set(data, '{integrations}', COALESCE(data->'integrations','{}'::jsonb), true),
'{integrations,github}', $2::jsonb, true
),
updated_at = NOW()
WHERE data->>'email' = $1`,
[email, JSON.stringify(blob)],
);
}
/** Read + decrypt the GitHub access token for a user, if any. */
export async function loadGithubIntegration(
email: string,
): Promise<{ login: string; token: string; scope: string } | null> {
const rows = await query<{ blob: StoredGithubIntegration | null }>(
`SELECT data->'integrations'->'github' AS blob
FROM fs_users WHERE data->>'email' = $1 LIMIT 1`,
[email],
);
const blob = rows[0]?.blob;
if (!blob || !blob.accessToken) return null;
try {
return { login: blob.login, token: decryptSecret(blob.accessToken), scope: blob.scope };
} catch {
return null;
}
}
/** Drop the stored integration. */
export async function disconnectGithubIntegration(email: string): Promise<void> {
await query(
`UPDATE fs_users
SET data = data #- '{integrations,github}',
updated_at = NOW()
WHERE data->>'email' = $1`,
[email],
);
}

View File

@@ -0,0 +1,519 @@
/**
* Sentry-as-product integration.
*
* Provisions a Sentry project per Vibn project under the shared
* `vibnai` Sentry org, then makes its DSN + auth token available
* to any Coolify app deployed for that project. Real-user errors
* from the user's deployed app land in Sentry; AI tools (Stage 3)
* read the issue feed back into chat (Stage 4).
*
* Design choices:
* - **Idempotent.** Looks up existing Sentry projects by slug
* before creating, so reruns are safe.
* - **Shared org token.** The same `SENTRY_AUTH_TOKEN` we use to
* upload source maps for vibn-frontend is reused across every
* user project — it has org-write scope. This means we DON'T
* need per-project auth tokens, just per-project DSNs.
* - **Slug convention.** `vibn-{workspace}-{projectSlug}`. Avoids
* collisions across workspaces and stays under Sentry's
* 50-char project slug limit. Clamped if longer.
* - **Soft failure.** If Sentry provisioning fails (rate limit,
* network, token revoked) we log and continue. The Vibn
* project still works without Sentry; we'll lazily retry on
* the next deploy.
*
* See SENTRY_AS_PRODUCT.md for the full proposal context.
*/
import { query } from '@/lib/db-postgres';
import { upsertApplicationEnv } from '@/lib/coolify';
const SENTRY_API_BASE = 'https://de.sentry.io/api/0';
const SENTRY_ORG_SLUG = 'vibnai';
const SENTRY_TEAM_SLUG = 'vibnai'; // Default team name matches org slug for personal/single-team setups.
export interface SentryProjectInfo {
/** Sentry project slug, e.g. `vibn-mark-account-checkout-app`. */
slug: string;
/** Public DSN for runtime error capture. NEXT_PUBLIC_SENTRY_DSN. */
dsn: string;
/** When this row was created/updated. */
provisionedAt: string;
}
export interface ProvisionInput {
projectId: string;
/** Vibn workspace slug, e.g. "mark-account". */
workspaceSlug: string;
/** Vibn project slug, e.g. "checkout-app". */
projectSlug: string;
/** Display name shown in the Sentry UI. */
projectName: string;
}
/**
* Returns the Sentry project for a given Vibn project, creating
* it if missing. Persists the result to fs_projects.data.sentry
* so subsequent calls hit the cache instead of Sentry's API.
*
* Returns null if Sentry provisioning fails — caller should log
* and continue.
*/
export async function ensureSentryProject(
input: ProvisionInput,
): Promise<SentryProjectInfo | null> {
const authToken = process.env.SENTRY_AUTH_TOKEN;
if (!authToken) {
console.warn('[sentry] SENTRY_AUTH_TOKEN missing — skipping provisioning');
return null;
}
// 1. Fast path: already provisioned for this Vibn project.
const cached = await loadSentryFromProject(input.projectId);
if (cached) return cached;
// 2. Build the deterministic slug. Sentry caps at 50 chars and
// only allows lowercase az, 09, dashes.
const slug = buildSentrySlug(input.workspaceSlug, input.projectSlug);
try {
// 3. Try to look up existing Sentry project (handles the case
// where fs_projects was wiped but Sentry still has the project).
const existing = await fetchSentryProject(slug, authToken);
if (existing) {
const info = await materialize(input.projectId, existing, authToken);
if (info) return info;
}
// 4. Create fresh.
const created = await createSentryProject({
slug,
name: input.projectName.slice(0, 50),
authToken,
});
if (!created) return null;
return await materialize(input.projectId, created, authToken);
} catch (err) {
console.error('[sentry] ensureSentryProject failed', err);
return null;
}
}
/**
* Sets the standard Sentry env vars on a Coolify application
* so that its next build inlines the DSN and uploads source maps.
* Idempotent — safe to call on every apps.create. Soft-fails on
* any per-key error so a single failed upsert doesn't block deploy.
*
* Env vars set:
* - NEXT_PUBLIC_SENTRY_DSN — public DSN for runtime capture
* - SENTRY_AUTH_TOKEN — shared org token for source map upload
*
* Both are marked Coolify-default (is_buildtime=true is_runtime=true);
* the public DSN must be present at build time so Next.js inlines
* it into the client bundle.
*/
export async function applySentryEnvToCoolifyApp(
coolifyAppUuid: string,
projectId: string,
): Promise<void> {
const sentry = await loadSentryFromProject(projectId);
if (!sentry) return; // Project not yet provisioned in Sentry — Stage 1 will retry.
const authToken = process.env.SENTRY_AUTH_TOKEN;
if (!authToken) return;
const envs: Array<[string, string]> = [
['NEXT_PUBLIC_SENTRY_DSN', sentry.dsn],
['SENTRY_AUTH_TOKEN', authToken],
];
for (const [key, value] of envs) {
try {
await upsertApplicationEnv(coolifyAppUuid, { key, value });
} catch (err) {
console.warn(`[sentry] upsert ${key} on ${coolifyAppUuid} failed`, err);
}
}
}
/**
* Returns the Sentry project info for a Vibn project if already
* provisioned, else null. Read-only — does NOT call Sentry's API.
*/
export async function loadSentryFromProject(
projectId: string,
): Promise<SentryProjectInfo | null> {
const rows = await query<{ data: any }>(
`SELECT data FROM fs_projects WHERE id = $1 LIMIT 1`,
[projectId],
);
const sentry = rows[0]?.data?.sentry;
if (sentry?.slug && sentry?.dsn && sentry?.provisionedAt) {
return sentry as SentryProjectInfo;
}
return null;
}
// ──────────────────────────────────────────────────────────────────
// Issue feed — Stage 3 MCP tools read these
// ──────────────────────────────────────────────────────────────────
export interface SentryIssueSummary {
id: string;
title: string;
/** "error" | "fatal" | "warning" | "info" | "debug" */
level: string;
/** Total event count (lifetime). */
count: number;
/** ISO of most recent occurrence. */
lastSeen: string;
/** ISO of first occurrence. */
firstSeen: string;
/** "unresolved" | "resolved" | "ignored" — we filter to unresolved. */
status: string;
/** Best-effort culprit string from Sentry, e.g. "GET /api/checkout". */
culprit: string | null;
/** Direct URL to the issue in Sentry's UI. */
permalink: string | null;
}
export interface SentryEventDetail {
/** Sentry event ID (the canonical "fingerprint" of this occurrence). */
eventId: string;
/** Most recent event's render of the stack trace (top frames first). */
stackFrames: Array<{
function: string | null;
filename: string | null;
lineno: number | null;
colno: number | null;
/** Source code surrounding the line if Sentry has source maps. */
contextLine: string | null;
}>;
/** User-context tag (email/id/username/ip), if Sentry captured one. */
user: { email?: string; id?: string; username?: string; ipAddress?: string } | null;
/** Request method + URL if this was an HTTP-facing error. */
request: { method?: string; url?: string } | null;
/** Truncated breadcrumbs (last 20) — clicks, fetches, navigations. */
breadcrumbs: Array<{
type: string | null;
category: string | null;
message: string | null;
timestamp: string | null;
}>;
/** Direct URL to a Session Replay if the user had one recorded. */
replayUrl: string | null;
}
/**
* List recent unresolved issues for a Vibn project's Sentry project.
* Returns [] if Sentry isn't provisioned yet — caller should treat
* that as "no errors", which is functionally correct (no Sentry =
* no error capture path).
*/
export async function listRecentSentryIssues(
projectId: string,
options?: { limit?: number; sinceHours?: number },
): Promise<SentryIssueSummary[]> {
const sentry = await loadSentryFromProject(projectId);
if (!sentry) return [];
const authToken = process.env.SENTRY_AUTH_TOKEN;
if (!authToken) return [];
const limit = clampLimit(options?.limit ?? 10);
const sinceHours = options?.sinceHours ?? 24;
const statsPeriod = `${sinceHours}h`;
const url = new URL(
`${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${sentry.slug}/issues/`,
);
url.searchParams.set('limit', String(limit));
url.searchParams.set('query', 'is:unresolved');
url.searchParams.set('statsPeriod', statsPeriod);
url.searchParams.set('sort', 'date');
const res = await fetch(url, {
headers: { Authorization: `Bearer ${authToken}` },
});
if (!res.ok) {
console.warn(
`[sentry] listRecentSentryIssues ${res.status}: ${await res.text()}`,
);
return [];
}
const raw = (await res.json()) as Array<Record<string, any>>;
return raw.map((i) => ({
id: String(i.id),
title: String(i.title ?? '(untitled)'),
level: String(i.level ?? 'error'),
count: Number(i.count ?? 0),
lastSeen: String(i.lastSeen ?? ''),
firstSeen: String(i.firstSeen ?? ''),
status: String(i.status ?? 'unresolved'),
culprit: i.culprit ? String(i.culprit) : null,
permalink: i.permalink ? String(i.permalink) : null,
}));
}
/**
* Fetch the latest event for an issue, with stack trace,
* breadcrumbs, user, request, and Session Replay link if any.
* Returns null if the issue isn't found or Sentry isn't ready.
*/
export async function getSentryIssueDetail(
projectId: string,
issueId: string,
): Promise<SentryEventDetail | null> {
const sentry = await loadSentryFromProject(projectId);
if (!sentry) return null;
const authToken = process.env.SENTRY_AUTH_TOKEN;
if (!authToken) return null;
const res = await fetch(
`${SENTRY_API_BASE}/issues/${encodeURIComponent(issueId)}/events/latest/`,
{
headers: { Authorization: `Bearer ${authToken}` },
},
);
if (!res.ok) {
console.warn(
`[sentry] getSentryIssueDetail ${res.status}: ${await res.text()}`,
);
return null;
}
const e = (await res.json()) as Record<string, any>;
// Sentry stores frames in a few different places depending on
// platform. The exception entry has the most reliable shape.
const exceptionEntry = (e.entries || []).find(
(entry: any) => entry?.type === 'exception',
);
const firstException = exceptionEntry?.data?.values?.[0];
const frames =
(firstException?.stacktrace?.frames as Array<Record<string, any>> | undefined) ?? [];
// Sentry returns frames in oldest-first order; reverse so top of
// stack (the line that actually threw) is first — which is what
// every developer scans first.
const stackFrames = [...frames].reverse().slice(0, 12).map((f) => ({
function: f.function ?? null,
filename: f.filename ?? null,
lineno: typeof f.lineno === 'number' ? f.lineno : null,
colno: typeof f.colno === 'number' ? f.colno : null,
contextLine: f.contextLine ?? null,
}));
const breadcrumbsEntry = (e.entries || []).find(
(entry: any) => entry?.type === 'breadcrumbs',
);
const breadcrumbs =
((breadcrumbsEntry?.data?.values as Array<Record<string, any>> | undefined) ?? [])
.slice(-20)
.map((b) => ({
type: b.type ?? null,
category: b.category ?? null,
message: b.message ?? null,
timestamp: b.timestamp ?? null,
}));
const requestEntry = (e.entries || []).find(
(entry: any) => entry?.type === 'request',
);
const request = requestEntry?.data
? {
method: requestEntry.data.method ?? undefined,
url: requestEntry.data.url ?? undefined,
}
: null;
// Session Replay: Sentry attaches a `replayId` tag on events that
// have one. Build the canonical UI URL.
const replayId = (e.tags as Array<Record<string, any>> | undefined)?.find(
(t) => t.key === 'replayId',
)?.value;
const replayUrl = replayId
? `https://${SENTRY_ORG_SLUG}.sentry.io/replays/${replayId}/`
: null;
return {
eventId: String(e.eventID ?? e.id ?? ''),
stackFrames,
user: e.user ? sanitizeUser(e.user) : null,
request,
breadcrumbs,
replayUrl,
};
}
/**
* Mark an issue resolved. Used by the AI after it ships a fix and
* verifies the bug no longer fires (e.g. via tests, log watch, or
* explicit user confirmation).
*/
export async function resolveSentryIssue(
projectId: string,
issueId: string,
): Promise<boolean> {
const sentry = await loadSentryFromProject(projectId);
if (!sentry) return false;
const authToken = process.env.SENTRY_AUTH_TOKEN;
if (!authToken) return false;
const res = await fetch(
`${SENTRY_API_BASE}/issues/${encodeURIComponent(issueId)}/`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: 'resolved' }),
},
);
return res.ok;
}
function clampLimit(n: number): number {
if (!Number.isFinite(n)) return 10;
return Math.max(1, Math.min(50, Math.floor(n)));
}
function sanitizeUser(u: Record<string, any>): SentryEventDetail['user'] {
return {
email: u.email ?? undefined,
id: u.id ?? undefined,
username: u.username ?? undefined,
ipAddress: u.ip_address ?? u.ipAddress ?? undefined,
};
}
// ──────────────────────────────────────────────────────────────────
// Internal helpers
// ──────────────────────────────────────────────────────────────────
function buildSentrySlug(workspaceSlug: string, projectSlug: string): string {
const raw = `vibn-${workspaceSlug}-${projectSlug}`
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
// Sentry: 50 char max. Truncate from the end (preserves the
// `vibn-{workspace}` prefix which keeps slugs distinguishable
// across workspaces).
return raw.slice(0, 50);
}
interface SentryApiProject {
slug: string;
name: string;
id: string;
}
async function fetchSentryProject(
slug: string,
authToken: string,
): Promise<SentryApiProject | null> {
const res = await fetch(
`${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${slug}/`,
{
headers: { Authorization: `Bearer ${authToken}` },
},
);
if (res.status === 404) return null;
if (!res.ok) {
throw new Error(
`Sentry GET project failed: ${res.status} ${await res.text()}`,
);
}
return (await res.json()) as SentryApiProject;
}
async function createSentryProject(input: {
slug: string;
name: string;
authToken: string;
}): Promise<SentryApiProject | null> {
const res = await fetch(
`${SENTRY_API_BASE}/teams/${SENTRY_ORG_SLUG}/${SENTRY_TEAM_SLUG}/projects/`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${input.authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: input.name,
slug: input.slug,
platform: 'javascript-nextjs', // Most common case; doesn't gate which SDK actually sends events.
}),
},
);
if (!res.ok) {
const text = await res.text();
// 409 = slug already exists in another team; we tried the GET
// path first, so this is genuinely a race or a slug conflict.
if (res.status === 409) {
console.warn(`[sentry] slug ${input.slug} taken — retry GET`);
return await fetchSentryProject(input.slug, input.authToken);
}
throw new Error(`Sentry POST project failed: ${res.status} ${text}`);
}
return (await res.json()) as SentryApiProject;
}
interface SentryClientKey {
dsn: { public: string };
isActive: boolean;
}
async function fetchProjectDsn(
slug: string,
authToken: string,
): Promise<string | null> {
const res = await fetch(
`${SENTRY_API_BASE}/projects/${SENTRY_ORG_SLUG}/${slug}/keys/`,
{
headers: { Authorization: `Bearer ${authToken}` },
},
);
if (!res.ok) {
throw new Error(
`Sentry GET keys failed: ${res.status} ${await res.text()}`,
);
}
const keys = (await res.json()) as SentryClientKey[];
// Sentry auto-creates a "Default" key on project creation; pick
// the first active one. Multiple keys exist in pro setups but
// we always want the live one.
const active = keys.find((k) => k.isActive) ?? keys[0];
return active?.dsn?.public ?? null;
}
async function materialize(
projectId: string,
apiProj: SentryApiProject,
authToken: string,
): Promise<SentryProjectInfo | null> {
const dsn = await fetchProjectDsn(apiProj.slug, authToken);
if (!dsn) {
console.error(`[sentry] no DSN returned for ${apiProj.slug}`);
return null;
}
const info: SentryProjectInfo = {
slug: apiProj.slug,
dsn,
provisionedAt: new Date().toISOString(),
};
await query(
`UPDATE fs_projects
SET data = jsonb_set(COALESCE(data, '{}'::jsonb), '{sentry}', $2::jsonb, true),
updated_at = NOW()
WHERE id = $1`,
[projectId, JSON.stringify(info)],
);
return info;
}