diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 0000000..608f0d6 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,22 @@ +{ + "name": "@bmm/auth", + "version": "0.1.0", + "type": "module", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@bmm/db": "workspace:*", + "drizzle-orm": "0.36.4" + }, + "devDependencies": { + "@types/node": "22.10.2", + "typescript": "5.7.2" + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 0000000..433ec1e --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,141 @@ +import crypto from 'node:crypto'; +import { and, createDb, eq, gt, type Database, magicLinks, memberships, organizations, sessions, users } from '@bmm/db'; + +const MAGIC_LINK_TTL_MS = 15 * 60 * 1000; // 15 min +const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days + +function sha256(input: string): string { + return crypto.createHash('sha256').update(input).digest('hex'); +} + +function randomToken(bytes = 32): string { + return crypto.randomBytes(bytes).toString('base64url'); +} + +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 48) || 'org'; +} + +export interface MagicLinkIssued { + token: string; + expiresAt: Date; +} + +export async function issueMagicLink(email: string, db: Database = createDb()): Promise { + const lower = email.trim().toLowerCase(); + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(lower)) { + throw new Error('invalid_email'); + } + const token = randomToken(32); + const tokenHash = sha256(token); + const expiresAt = new Date(Date.now() + MAGIC_LINK_TTL_MS); + await db.insert(magicLinks).values({ email: lower, tokenHash, expiresAt }); + return { token, expiresAt }; +} + +export interface ConsumedSession { + sessionToken: string; + userId: string; + orgId: string; + email: string; +} + +export async function consumeMagicLink( + token: string, + meta: { ipAddress?: string; userAgent?: string } = {}, + db: Database = createDb(), +): Promise { + const tokenHash = sha256(token); + const [row] = await db + .select() + .from(magicLinks) + .where(and(eq(magicLinks.tokenHash, tokenHash), gt(magicLinks.expiresAt, new Date()))) + .limit(1); + if (!row || row.consumedAt) { + throw new Error('invalid_or_expired_token'); + } + await db.update(magicLinks).set({ consumedAt: new Date() }).where(eq(magicLinks.id, row.id)); + + // Get or create user + default org + let user = (await db.select().from(users).where(eq(users.email, row.email)).limit(1))[0]; + if (!user) { + [user] = await db + .insert(users) + .values({ email: row.email, emailVerified: true }) + .returning(); + const orgSlug = `${slugify(row.email.split('@')[0] ?? 'me')}-${randomToken(3).toLowerCase()}`; + const [org] = await db + .insert(organizations) + .values({ slug: orgSlug, name: `${row.email.split('@')[0]}'s workspace` }) + .returning(); + if (!org) throw new Error('org_create_failed'); + await db.insert(memberships).values({ orgId: org.id, userId: user!.id, role: 'owner' }); + } else if (!user.emailVerified) { + await db.update(users).set({ emailVerified: true }).where(eq(users.id, user.id)); + } + + if (!user) throw new Error('user_resolve_failed'); + + const [membership] = await db + .select() + .from(memberships) + .where(eq(memberships.userId, user.id)) + .limit(1); + if (!membership) throw new Error('no_org_membership'); + + const sessionToken = randomToken(32); + const sessionHash = sha256(sessionToken); + await db.insert(sessions).values({ + userId: user.id, + tokenHash: sessionHash, + expiresAt: new Date(Date.now() + SESSION_TTL_MS), + ipAddress: meta.ipAddress, + userAgent: meta.userAgent, + }); + + return { sessionToken, userId: user.id, orgId: membership.orgId, email: user.email }; +} + +export interface AuthedUser { + userId: string; + orgId: string; + email: string; + role: string; +} + +export async function getSession( + sessionToken: string | null | undefined, + db: Database = createDb(), +): Promise { + if (!sessionToken) return null; + const hash = sha256(sessionToken); + const [row] = await db + .select({ + userId: sessions.userId, + expiresAt: sessions.expiresAt, + email: users.email, + }) + .from(sessions) + .innerJoin(users, eq(users.id, sessions.userId)) + .where(eq(sessions.tokenHash, hash)) + .limit(1); + if (!row || row.expiresAt < new Date()) return null; + const [membership] = await db + .select({ orgId: memberships.orgId, role: memberships.role }) + .from(memberships) + .where(eq(memberships.userId, row.userId)) + .limit(1); + if (!membership) return null; + return { userId: row.userId, orgId: membership.orgId, email: row.email, role: membership.role }; +} + +export async function destroySession(sessionToken: string, db: Database = createDb()): Promise { + const hash = sha256(sessionToken); + await db.delete(sessions).where(eq(sessions.tokenHash, hash)); +} + +export const __test = { sha256, randomToken, slugify }; diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json new file mode 100644 index 0000000..4b1980d --- /dev/null +++ b/packages/auth/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["src/**/*"] +} diff --git a/packages/types/package.json b/packages/types/package.json new file mode 100644 index 0000000..8c5d75b --- /dev/null +++ b/packages/types/package.json @@ -0,0 +1,20 @@ +{ + "name": "@bmm/types", + "version": "0.1.0", + "type": "module", + "private": true, + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "3.23.8" + }, + "devDependencies": { + "typescript": "5.7.2" + } +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts new file mode 100644 index 0000000..c6de277 --- /dev/null +++ b/packages/types/src/index.ts @@ -0,0 +1,135 @@ +import { z } from 'zod'; + +export const ServerStatus = z.enum([ + 'draft', + 'queued', + 'generating', + 'building', + 'deploying', + 'live', + 'failed', + 'paused', +]); +export type ServerStatus = z.infer; + +export const BuildStatus = z.enum([ + 'queued', + 'generating', + 'building', + 'deploying', + 'success', + 'failed', + 'cancelled', +]); +export type BuildStatus = z.infer; + +export const Plan = z.enum(['hobby', 'pro', 'team', 'enterprise']); +export type Plan = z.infer; + +// ---- Generator output spec (what Claude returns) ---- + +export const ToolParam = z + .object({ + type: z.enum(['string', 'number', 'boolean', 'array', 'object']), + description: z.string().optional(), + required: z.boolean().optional(), + enum: z.array(z.string()).optional(), + }) + .passthrough(); + +export const ToolSpec = z.object({ + name: z + .string() + .min(1) + .max(64) + .regex(/^[a-z][a-z0-9_]*$/, 'snake_case identifier required'), + description: z.string().min(1).max(2000), + inputSchema: z.record(z.string(), ToolParam), + implementation: z.string().min(1).max(20_000), +}); +export type ToolSpec = z.infer; + +export const ResourceSpec = z.object({ + uri: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + mimeType: z.string().optional(), + implementation: z.string().min(1), +}); +export type ResourceSpec = z.infer; + +export const PromptSpec = z.object({ + name: z.string().min(1), + description: z.string().optional(), + arguments: z + .array(z.object({ name: z.string(), description: z.string().optional(), required: z.boolean().optional() })) + .optional(), + template: z.string().min(1), +}); +export type PromptSpec = z.infer; + +export const GeneratorSpec = z.object({ + name: z.string().min(1).max(128), + description: z.string().max(2000).optional(), + tools: z.array(ToolSpec).min(1).max(50), + resources: z.array(ResourceSpec).max(50).default([]), + prompts: z.array(PromptSpec).max(50).default([]), + requiredSecrets: z.array(z.string().regex(/^[A-Z][A-Z0-9_]*$/)).max(30).default([]), + scopes: z.array(z.string()).max(50).default([]), + dependencies: z.record(z.string(), z.string()).default({}), +}); +export type GeneratorSpec = z.infer; + +// ---- Build stream events ---- + +export const BuildEvent = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('status'), + status: BuildStatus, + at: z.string(), + }), + z.object({ + type: z.literal('log'), + level: z.enum(['info', 'warn', 'error']), + message: z.string(), + at: z.string(), + }), + z.object({ + type: z.literal('done'), + status: BuildStatus, + serverId: z.string().uuid(), + publicUrl: z.string().nullable(), + at: z.string(), + }), + z.object({ + type: z.literal('error'), + message: z.string(), + at: z.string(), + }), +]); +export type BuildEvent = z.infer; + +// ---- API request payloads ---- + +export const CreateServerInput = z.object({ + name: z.string().min(1).max(128), + slug: z + .string() + .min(1) + .max(64) + .regex(/^[a-z][a-z0-9-]*$/, 'lowercase, hyphenated'), + prompt: z.string().min(10).max(8000), + secrets: z.record(z.string(), z.string()).default({}), +}); +export type CreateServerInput = z.infer; + +export const IterateServerInput = z.object({ + prompt: z.string().min(10).max(8000), + secrets: z.record(z.string(), z.string()).default({}), +}); +export type IterateServerInput = z.infer; + +// ---- Install snippets ---- + +export const InstallTarget = z.enum(['claude-desktop', 'cursor', 'chatgpt']); +export type InstallTarget = z.infer; diff --git a/packages/types/tsconfig.json b/packages/types/tsconfig.json new file mode 100644 index 0000000..8f24167 --- /dev/null +++ b/packages/types/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +}