diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index 3dec10293..ca210aa13 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -13,6 +13,7 @@ import { DbService } from "../services/db"; import { TelemetryLive } from "../services/telemetry"; import { OrgHttpApi } from "../org/compose"; import { OrgHandlers } from "../org/handlers"; +import { WorkspacesHandlers } from "../workspaces/handlers"; import { CoreSharedServices } from "./core-shared-services"; import { ProtectedCloudApi, RouterConfig } from "./protected-layers"; @@ -66,7 +67,7 @@ export const makeOrgApiLive = ( rsLive: Layer.Layer, ) => HttpApiBuilder.layer(OrgHttpApi).pipe( - Layer.provide(OrgHandlers), + Layer.provide(Layer.mergeAll(OrgHandlers, WorkspacesHandlers)), Layer.provide(requestScopedMiddleware(rsLive).layer), Layer.provideMerge(OrgAuthLive), ); diff --git a/apps/cloud/src/org/compose.ts b/apps/cloud/src/org/compose.ts index 25979da51..fe14e66aa 100644 --- a/apps/cloud/src/org/compose.ts +++ b/apps/cloud/src/org/compose.ts @@ -1,6 +1,10 @@ import { HttpApi } from "effect/unstable/httpapi"; import { OrgAuth } from "../auth/middleware"; +import { WorkspacesApi } from "../workspaces/api"; import { OrgApi } from "./api"; /** Org API with org-level auth — requires authenticated session with an org. */ -export const OrgHttpApi = HttpApi.make("org").add(OrgApi).middleware(OrgAuth); +export const OrgHttpApi = HttpApi.make("org") + .add(OrgApi) + .add(WorkspacesApi) + .middleware(OrgAuth); diff --git a/apps/cloud/src/services/workspace-store.node.test.ts b/apps/cloud/src/services/workspace-store.node.test.ts new file mode 100644 index 000000000..2c9de03fb --- /dev/null +++ b/apps/cloud/src/services/workspace-store.node.test.ts @@ -0,0 +1,121 @@ +// Workspace store + handle-derivation tests. + +import { describe, expect, it } from "@effect/vitest"; +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import { combinedSchema } from "./db"; +import { organizations } from "./schema"; +import { makeWorkspaceStore } from "./workspace-store"; +import { makeUserStore } from "./user-store"; + +const url = + process.env.DATABASE_URL ?? + "postgresql://postgres:postgres@127.0.0.1:5434/postgres"; + +const withDb = async (fn: (db: ReturnType) => Promise) => { + const sql = postgres(url, { max: 1, idle_timeout: 0, max_lifetime: 30 }); + try { + return await fn(drizzle(sql, { schema: combinedSchema })); + } finally { + await sql.end({ timeout: 0 }).catch(() => undefined); + } +}; + +const seedOrg = async (db: ReturnType) => { + const id = `org_${crypto.randomUUID()}`; + await makeUserStore(db).upsertOrganization({ id, name: `Test ${id}` }); + return id; +}; + +describe("workspace-store", () => { + it("creates a workspace with slugified id and slug from name", async () => { + await withDb(async (db) => { + const orgId = await seedOrg(db); + const ws = await makeWorkspaceStore(db).create({ + organizationId: orgId, + name: "Billing API", + }); + expect(ws.organizationId).toBe(orgId); + expect(ws.slug).toBe("billing-api"); + expect(ws.id.startsWith("workspace_")).toBe(true); + expect(ws.name).toBe("Billing API"); + }); + }); + + it("disambiguates slug collisions within an org with -2, -3, …", async () => { + await withDb(async (db) => { + const orgId = await seedOrg(db); + const store = makeWorkspaceStore(db); + const a = await store.create({ organizationId: orgId, name: "Edge Cases" }); + const b = await store.create({ organizationId: orgId, name: "Edge cases" }); + const c = await store.create({ organizationId: orgId, name: "EDGE-cases" }); + expect(a.slug).toBe("edge-cases"); + expect(b.slug).toBe("edge-cases-2"); + expect(c.slug).toBe("edge-cases-3"); + }); + }); + + it("allows the same slug across different orgs", async () => { + await withDb(async (db) => { + const o1 = await seedOrg(db); + const o2 = await seedOrg(db); + const a = await makeWorkspaceStore(db).create({ + organizationId: o1, + name: "Common", + }); + const b = await makeWorkspaceStore(db).create({ + organizationId: o2, + name: "Common", + }); + expect(a.slug).toBe("common"); + expect(b.slug).toBe("common"); + }); + }); + + it("respects an explicit slug override", async () => { + await withDb(async (db) => { + const orgId = await seedOrg(db); + const ws = await makeWorkspaceStore(db).create({ + organizationId: orgId, + name: "Anything", + slug: "custom-handle", + }); + expect(ws.slug).toBe("custom-handle"); + }); + }); + + it("list returns workspaces in creation order", async () => { + await withDb(async (db) => { + const orgId = await seedOrg(db); + const store = makeWorkspaceStore(db); + await store.create({ organizationId: orgId, name: "First" }); + await store.create({ organizationId: orgId, name: "Second" }); + const rows = await store.list(orgId); + expect(rows.map((r) => r.slug)).toEqual(["first", "second"]); + }); + }); + + it("getBySlug returns null for an unknown slug", async () => { + await withDb(async (db) => { + const orgId = await seedOrg(db); + const missing = await makeWorkspaceStore(db).getBySlug(orgId, "nope"); + expect(missing).toBeNull(); + }); + }); + + it("getBySlug scopes to the org", async () => { + await withDb(async (db) => { + const o1 = await seedOrg(db); + const o2 = await seedOrg(db); + const a = await makeWorkspaceStore(db).create({ + organizationId: o1, + name: "Shared", + }); + const fromO2 = await makeWorkspaceStore(db).getBySlug(o2, a.slug); + expect(fromO2).toBeNull(); + const fromO1 = await makeWorkspaceStore(db).getBySlug(o1, a.slug); + expect(fromO1?.id).toBe(a.id); + }); + }); +}); diff --git a/apps/cloud/src/services/workspace-store.ts b/apps/cloud/src/services/workspace-store.ts new file mode 100644 index 000000000..d3bf7af12 --- /dev/null +++ b/apps/cloud/src/services/workspace-store.ts @@ -0,0 +1,113 @@ +// --------------------------------------------------------------------------- +// Workspace storage — local Drizzle-backed CRUD +// --------------------------------------------------------------------------- +// +// Workspaces are an org-local entity. Org membership grants access to every +// workspace in the org (no per-workspace ACLs in v1), and workspace deletion +// is intentionally out of scope. The store exposes only the surface the API +// + UI need today: create, list, get-by-slug, get-by-id. + +import { and, asc, desc, eq, like } from "drizzle-orm"; + +import { newId, slugifyHandle, withHandleSuffix } from "./ids"; +import { workspaces } from "./schema"; +import type { DrizzleDb } from "./db"; + +export type Workspace = typeof workspaces.$inferSelect; + +const SLUG_MAX_ATTEMPTS = 100; + +const pickFreeSlug = async ( + db: DrizzleDb, + organizationId: string, + base: string, +): Promise => { + const existing = await db + .select({ slug: workspaces.slug }) + .from(workspaces) + .where( + and( + eq(workspaces.organizationId, organizationId), + like(workspaces.slug, `${base}%`), + ), + ); + const taken = new Set(existing.map((r) => r.slug)); + if (!taken.has(base)) return base; + for (let n = 2; n < SLUG_MAX_ATTEMPTS; n++) { + const candidate = withHandleSuffix(base, n); + if (!taken.has(candidate)) return candidate; + } + throw new Error( + `could not allocate workspace slug for org ${organizationId} (base "${base}")`, + ); +}; + +export const makeWorkspaceStore = (db: DrizzleDb) => ({ + /** + * Create a workspace inside an org. Slug is auto-generated from `name` + * with collision suffixes; caller can override by passing `slug` explicitly. + */ + create: async (input: { + organizationId: string; + name: string; + slug?: string; + }): Promise => { + const base = input.slug ?? slugifyHandle(input.name); + const slug = await pickFreeSlug(db, input.organizationId, base); + const [row] = await db + .insert(workspaces) + .values({ + id: newId("workspace"), + organizationId: input.organizationId, + slug, + name: input.name, + }) + .returning(); + return row!; + }, + + list: async (organizationId: string): Promise => + db + .select() + .from(workspaces) + .where(eq(workspaces.organizationId, organizationId)) + .orderBy(asc(workspaces.createdAt)), + + getBySlug: async ( + organizationId: string, + slug: string, + ): Promise => { + const rows = await db + .select() + .from(workspaces) + .where( + and( + eq(workspaces.organizationId, organizationId), + eq(workspaces.slug, slug), + ), + ); + return rows[0] ?? null; + }, + + getById: async (id: string): Promise => { + const rows = await db + .select() + .from(workspaces) + .where(eq(workspaces.id, id)); + return rows[0] ?? null; + }, + + /** Most-recently-created first. Used by the switcher to suggest defaults. */ + listMostRecent: async ( + organizationId: string, + limit = 50, + ): Promise => + db + .select() + .from(workspaces) + .where(eq(workspaces.organizationId, organizationId)) + .orderBy(desc(workspaces.createdAt)) + .limit(limit), +}); + +export type WorkspaceStore = ReturnType; diff --git a/apps/cloud/src/workspaces/api.ts b/apps/cloud/src/workspaces/api.ts new file mode 100644 index 000000000..976daa079 --- /dev/null +++ b/apps/cloud/src/workspaces/api.ts @@ -0,0 +1,66 @@ +// --------------------------------------------------------------------------- +// Workspaces HTTP API — schemas + endpoint definitions +// --------------------------------------------------------------------------- +// +// Workspace context is org-scoped; the existing OrgAuth middleware (org +// membership check on the active session) covers authorization. v1 surface: +// create, list, get-by-slug. Slug is auto-generated from `name`; the client +// can override by passing `slug` explicitly. + +import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; +import { Schema } from "effect"; + +const Workspace = Schema.Struct({ + id: Schema.String, + organizationId: Schema.String, + slug: Schema.String, + name: Schema.String, + createdAt: Schema.Date, + updatedAt: Schema.Date, +}); + +export class WorkspaceNotFound extends Schema.TaggedErrorClass()( + "WorkspaceNotFound", + {}, + { httpApiStatus: 404 }, +) {} + +export class InvalidWorkspaceName extends Schema.TaggedErrorClass()( + "InvalidWorkspaceName", + { reason: Schema.String }, + { httpApiStatus: 400 }, +) {} + +const CreateWorkspaceBody = Schema.Struct({ + name: Schema.String, + slug: Schema.optional(Schema.String), +}); + +const SlugParam = { slug: Schema.String }; + +const ListResponse = Schema.Struct({ + workspaces: Schema.Array(Workspace), +}); + +export class WorkspacesApi extends HttpApiGroup.make("workspaces") + .add( + HttpApiEndpoint.get("listWorkspaces", "/workspaces", { + success: ListResponse, + }), + ) + .add( + HttpApiEndpoint.post("createWorkspace", "/workspaces", { + payload: CreateWorkspaceBody, + success: Workspace, + error: InvalidWorkspaceName, + }), + ) + .add( + HttpApiEndpoint.get("getWorkspace", "/workspaces/:slug", { + params: SlugParam, + success: Workspace, + error: WorkspaceNotFound, + }), + ) {} + +export { Workspace }; diff --git a/apps/cloud/src/workspaces/handlers.ts b/apps/cloud/src/workspaces/handlers.ts new file mode 100644 index 000000000..b59856aaf --- /dev/null +++ b/apps/cloud/src/workspaces/handlers.ts @@ -0,0 +1,83 @@ +// --------------------------------------------------------------------------- +// Workspaces handlers — wired into OrgHttpApi (OrgAuth-gated) +// --------------------------------------------------------------------------- + +import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { Effect } from "effect"; + +import { AuthContext } from "../auth/middleware"; +import { DbService } from "../services/db"; +import { slugifyHandle } from "../services/ids"; +import { makeWorkspaceStore, type Workspace } from "../services/workspace-store"; +import { OrgHttpApi } from "../org/compose"; +import { InvalidWorkspaceName, WorkspaceNotFound } from "./api"; + +const NAME_MAX = 96; +const SLUG_MAX = 48; + +const toResponse = (row: Workspace) => ({ + id: row.id, + organizationId: row.organizationId, + slug: row.slug, + name: row.name, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +export const WorkspacesHandlers = HttpApiBuilder.group( + OrgHttpApi, + "workspaces", + (handlers) => + handlers + .handle("listWorkspaces", () => + Effect.gen(function* () { + const auth = yield* AuthContext; + const { db } = yield* DbService; + const rows = yield* Effect.promise(() => + makeWorkspaceStore(db).list(auth.organizationId), + ); + return { workspaces: rows.map(toResponse) }; + }), + ) + .handle("createWorkspace", ({ payload }) => + Effect.gen(function* () { + const trimmed = payload.name.trim(); + if (trimmed.length === 0 || trimmed.length > NAME_MAX) { + return yield* new InvalidWorkspaceName({ + reason: "name must be 1–96 characters after trimming", + }); + } + if (slugifyHandle(trimmed) === "org" && !/[a-z0-9]/i.test(trimmed)) { + return yield* new InvalidWorkspaceName({ + reason: "name must contain a letter or digit", + }); + } + if (payload.slug && payload.slug.length > SLUG_MAX) { + return yield* new InvalidWorkspaceName({ + reason: "slug must be at most 48 characters", + }); + } + const auth = yield* AuthContext; + const { db } = yield* DbService; + const row = yield* Effect.promise(() => + makeWorkspaceStore(db).create({ + organizationId: auth.organizationId, + name: trimmed, + slug: payload.slug, + }), + ); + return toResponse(row); + }), + ) + .handle("getWorkspace", ({ params }) => + Effect.gen(function* () { + const auth = yield* AuthContext; + const { db } = yield* DbService; + const row = yield* Effect.promise(() => + makeWorkspaceStore(db).getBySlug(auth.organizationId, params.slug), + ); + if (!row) return yield* new WorkspaceNotFound(); + return toResponse(row); + }), + ), +);