Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions ee/apps/den-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ pnpm --filter @openwork-ee/den-api dev:local

Each major folder also has its own `README.md` so future agents can inspect one area in isolation.

## TypeID validation

- Shared Den TypeID validation lives in `ee/packages/utils/src/typeid.ts`.
- Use `typeId.schema("...")` or the compatibility helpers like `normalizeDenTypeId("...", value)` when an endpoint accepts or returns a Den TypeID.
- `ee/apps/den-api/src/openapi.ts` exposes `denTypeIdSchema(...)` so path params, request bodies, and response fields all share the same validation rules and Swagger examples.
- Swagger now documents Den IDs with their required prefix and fixed 26-character TypeID suffix, so invalid IDs fail request validation before route logic runs.

## Migration approach

1. Keep `den-api` (formerly `den-controller`) as the source of truth for Den control-plane behavior.
Expand Down
41 changes: 25 additions & 16 deletions ee/apps/den-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ app.get(
"/",
describeRoute({
tags: ["System"],
hide: true,
summary: "Redirect API root",
description: "Redirects the API root to the OpenWork marketing site instead of serving API content.",
responses: {
Expand Down Expand Up @@ -110,7 +111,7 @@ registerWorkerRoutes(app)
app.get(
"/openapi.json",
describeRoute({
tags: ["Documentation"],
tags: ["System"],
summary: "Get OpenAPI document",
description: "Returns the machine-readable OpenAPI 3.1 document for the Den API so humans and tools can inspect the API surface.",
responses: {
Expand All @@ -123,26 +124,32 @@ app.get(
info: {
title: "Den API",
version: "dev",
description: "OpenAPI spec for the Den control plane API.",
description: [
"OpenAPI spec for the Den control plane API.",
"",
"Authentication:",
"- Use `Authorization: Bearer <session-token>` for user-authenticated routes that require a Den session.",
"- Use `x-api-key: <den-api-key>` for API-key-authenticated routes that accept organization API keys.",
"- Public routes like health and documentation do not require authentication.",
"",
"Swagger tip: use the security schemes in the Authorize dialog to set either `bearerAuth` or `denApiKey` before trying protected endpoints.",
].join("\n"),
},
tags: [
{ name: "System", description: "Service health and operational routes." },
{ name: "Documentation", description: "OpenAPI document and Swagger UI routes." },
{ name: "Organizations", description: "Organization-scoped Den API routes." },
{ name: "Organization Invitations", description: "Organization invitation creation, preview, acceptance, and cancellation routes." },
{ name: "Organization API Keys", description: "Organization API key management routes." },
{ name: "Organization Members", description: "Organization member management routes." },
{ name: "Organization Roles", description: "Organization custom role management routes." },
{ name: "Organization Teams", description: "Organization team management routes." },
{ name: "Organization Templates", description: "Organization shared template routes." },
{ name: "Organization LLM Providers", description: "Organization LLM provider catalog, configuration, and access routes." },
{ name: "Organization Skills", description: "Organization skill authoring and sharing routes." },
{ name: "Organization Skill Hubs", description: "Organization skill hub management and access routes." },
{ name: "Organizations", description: "Top-level organization creation and context routes." },
{ name: "Invitations", description: "Invitation preview, acceptance, creation, and cancellation routes." },
{ name: "API Keys", description: "Organization API key management routes." },
{ name: "Members", description: "Organization member management routes." },
{ name: "Roles", description: "Organization custom role management routes." },
{ name: "Teams", description: "Organization team management routes." },
{ name: "Templates", description: "Organization shared template routes." },
{ name: "LLM Providers", description: "Organization LLM provider catalog, configuration, and access routes." },
{ name: "Skills", description: "Organization skill authoring and sharing routes." },
{ name: "Skill Hubs", description: "Organization skill hub management and access routes." },
{ name: "Workers", description: "Worker lifecycle, billing, and runtime routes." },
{ name: "Worker Billing", description: "Worker subscription and billing status routes." },
{ name: "Worker Runtime", description: "Worker runtime inspection and upgrade routes." },
{ name: "Worker Activity", description: "Worker heartbeat and activity reporting routes." },
{ name: "Authentication", description: "Authentication and desktop sign-in handoff routes." },
{ name: "Admin", description: "Administrative reporting routes." },
{ name: "Users", description: "Current user and membership routes." },
],
Expand All @@ -152,11 +159,13 @@ app.get(
type: "http",
scheme: "bearer",
bearerFormat: "session-token",
description: "Session token passed as `Authorization: Bearer <session-token>` for user-authenticated Den routes.",
},
denApiKey: {
type: "apiKey",
in: "header",
name: "x-api-key",
description: "Organization API key passed as the `x-api-key` header for API-key-authenticated Den routes.",
},
},
},
Expand All @@ -175,7 +184,7 @@ app.get(
app.get(
"/docs",
describeRoute({
tags: ["Documentation"],
tags: ["System"],
summary: "Serve Swagger UI",
description: "Serves Swagger UI so developers can browse and try the Den API from a browser.",
responses: {
Expand Down
12 changes: 12 additions & 0 deletions ee/apps/den-api/src/openapi.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { type DenTypeIdName, typeId } from "@openwork-ee/utils/typeid"
import { resolver } from "hono-openapi"
import { z } from "zod"

const TYPE_ID_EXAMPLE_SUFFIX = "01h2xcejqtf2nbrexx3vqjhp41"

function toPascalCase(value: string) {
return value
.replace(/[^a-zA-Z0-9]+/g, " ")
Expand Down Expand Up @@ -34,6 +37,15 @@ export function buildOperationId(method: string, path: string) {
.replace(/^[A-Z]/, (char) => char.toLowerCase())
}

export function denTypeIdSchema<TName extends DenTypeIdName>(typeName: TName) {
const prefix = typeId.prefix[typeName]
return typeId.schema(typeName).describe(`Den TypeID with '${prefix}_' prefix.`).meta({
description: `Den TypeID with '${prefix}_' prefix and a ${typeId.suffixLength}-character base32 suffix.`,
examples: [`${prefix}_${TYPE_ID_EXAMPLE_SUFFIX}`],
format: "typeid",
})
}

const validationIssueSchema = z.object({
message: z.string(),
path: z.array(z.union([z.string(), z.number()])).optional(),
Expand Down
4 changes: 2 additions & 2 deletions ee/apps/den-api/src/routes/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { z } from "zod"
import { getCloudWorkerAdminBillingStatus } from "../../billing/polar.js"
import { db } from "../../db.js"
import { queryValidator, requireAdminMiddleware } from "../../middleware/index.js"
import { invalidRequestSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js"
import { denTypeIdSchema, invalidRequestSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js"
import type { AuthContextVariables } from "../../session.js"

type UserId = typeof AuthUserTable.$inferSelect.id
Expand All @@ -17,7 +17,7 @@ const overviewQuerySchema = z.object({

const adminOverviewResponseSchema = z.object({
viewer: z.object({
id: z.string(),
id: denTypeIdSchema("user"),
email: z.string(),
name: z.string().nullable(),
}),
Expand Down
6 changes: 4 additions & 2 deletions ee/apps/den-api/src/routes/auth/desktop-handoff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { describeRoute } from "hono-openapi"
import { z } from "zod"
import { jsonValidator, requireUserMiddleware } from "../../middleware/index.js"
import { db } from "../../db.js"
import { invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
import { denTypeIdSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
import type { AuthContextVariables } from "../../session.js"

const createGrantSchema = z.object({
Expand All @@ -28,7 +28,7 @@ const desktopHandoffGrantResponseSchema = z.object({
const desktopHandoffExchangeResponseSchema = z.object({
token: z.string(),
user: z.object({
id: z.string(),
id: denTypeIdSchema("user"),
email: z.string().email(),
name: z.string().nullable(),
}),
Expand Down Expand Up @@ -115,6 +115,7 @@ export function registerDesktopAuthRoutes<T extends { Variables: AuthContextVari
app.post(
"/v1/auth/desktop-handoff",
describeRoute({
hide: true,
tags: ["Authentication"],
summary: "Create desktop handoff grant",
description: "Creates a short-lived desktop handoff grant and deep link so a signed-in web user can continue the same account in the OpenWork desktop app.",
Expand Down Expand Up @@ -162,6 +163,7 @@ export function registerDesktopAuthRoutes<T extends { Variables: AuthContextVari
app.post(
"/v1/auth/desktop-handoff/exchange",
describeRoute({
hide: true,
tags: ["Authentication"],
summary: "Exchange desktop handoff grant",
description: "Exchanges a one-time desktop handoff grant for the user's session token and basic profile so the desktop app can sign the user in.",
Expand Down
1 change: 1 addition & 0 deletions ee/apps/den-api/src/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export function registerAuthRoutes<T extends { Variables: AuthContextVariables }
["GET", "POST"],
"/api/auth/*",
describeRoute({
hide: true,
tags: ["Authentication"],
summary: "Handle Better Auth flow",
description: "Proxies Better Auth sign-in, sign-out, session, and verification flows under the Den API auth namespace.",
Expand Down
8 changes: 4 additions & 4 deletions ee/apps/den-api/src/routes/me/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Hono } from "hono"
import { describeRoute } from "hono-openapi"
import { z } from "zod"
import { requireUserMiddleware, resolveUserOrganizationsMiddleware, type UserOrganizationsContext } from "../../middleware/index.js"
import { jsonResponse, unauthorizedSchema } from "../../openapi.js"
import { denTypeIdSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js"
import type { AuthContextVariables } from "../../session.js"

const meResponseSchema = z.object({
Expand All @@ -12,10 +12,10 @@ const meResponseSchema = z.object({

const meOrganizationsResponseSchema = z.object({
orgs: z.array(z.object({
id: z.string(),
id: denTypeIdSchema("organization"),
isActive: z.boolean(),
}).passthrough()),
activeOrgId: z.string().nullable(),
activeOrgId: denTypeIdSchema("organization").nullable(),
activeOrgSlug: z.string().nullable(),
}).meta({ ref: "CurrentUserOrganizationsResponse" })

Expand Down Expand Up @@ -43,7 +43,7 @@ export function registerMeRoutes<T extends { Variables: AuthContextVariables & P
app.get(
"/v1/me/orgs",
describeRoute({
tags: ["Users", "Organizations"],
tags: ["Users"],
summary: "List current user's organizations",
description: "Lists the organizations visible to the current user and marks which organization is currently active.",
responses: {
Expand Down
18 changes: 10 additions & 8 deletions ee/apps/den-api/src/routes/org/api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
listOrganizationApiKeys,
} from "../../api-keys.js"
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { denTypeIdSchema } from "../../openapi.js"
import { auth } from "../../auth.js"
import type { OrgRouteVariables } from "./shared.js"
import { ensureApiKeyManager, idParamSchema, orgIdParamSchema } from "./shared.js"
Expand Down Expand Up @@ -45,8 +46,8 @@ const apiKeyNotFoundSchema = z.object({
}).meta({ ref: "OrganizationApiKeyNotFoundError" })

const apiKeyOwnerSchema = z.object({
userId: z.string(),
memberId: z.string(),
userId: denTypeIdSchema("user"),
memberId: denTypeIdSchema("member"),
name: z.string(),
email: z.string().email(),
image: z.string().nullable(),
Expand Down Expand Up @@ -98,7 +99,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
app.get(
"/v1/orgs/:orgId/api-keys",
describeRoute({
tags: ["Organizations", "Organization API Keys"],
tags: ["API Keys"],
summary: "List organization API keys",
description: "Returns the API keys that belong to the selected organization.",
security: [{ bearerAuth: [] }],
Expand Down Expand Up @@ -128,7 +129,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
},
},
403: {
description: "Forbidden",
description: "Only workspace owners and admins can list API keys.",
content: {
"application/json": {
schema: resolver(forbiddenApiKeyManagerSchema),
Expand Down Expand Up @@ -163,7 +164,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
app.post(
"/v1/orgs/:orgId/api-keys",
describeRoute({
tags: ["Organizations", "Organization API Keys"],
tags: ["API Keys"],
summary: "Create an organization API key",
description: "Creates a new API key for the selected organization.",
hide: hideApiKeyGenerationRoute,
Expand Down Expand Up @@ -194,7 +195,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
},
},
403: {
description: "Forbidden",
description: "Only workspace owners and admins can create API keys.",
content: {
"application/json": {
schema: resolver(forbiddenApiKeyManagerSchema),
Expand Down Expand Up @@ -260,7 +261,8 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
app.delete(
"/v1/orgs/:orgId/api-keys/:apiKeyId",
describeRoute({
tags: ["Organizations", "Organization API Keys"],
tags: ["API Keys"],
hide: true,
summary: "Delete an organization API key",
description: "Deletes an API key from the selected organization.",
security: [{ bearerAuth: [] }],
Expand All @@ -285,7 +287,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
},
},
403: {
description: "Forbidden",
description: "Only workspace owners and admins can delete API keys.",
content: {
"application/json": {
schema: resolver(forbiddenApiKeyManagerSchema),
Expand Down
15 changes: 8 additions & 7 deletions ee/apps/den-api/src/routes/org/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { requireCloudWorkerAccess } from "../../billing/polar.js"
import { db } from "../../db.js"
import { env } from "../../env.js"
import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveMemberTeamsMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
import { denTypeIdSchema, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
import { acceptInvitationForUser, createOrganizationForUser, getInvitationPreview, setSessionActiveOrganization } from "../../orgs.js"
import { getRequiredUserEmail } from "../../user.js"
import type { OrgRouteVariables } from "./shared.js"
Expand All @@ -19,11 +19,11 @@ const createOrganizationSchema = z.object({
})

const invitationPreviewQuerySchema = z.object({
id: z.string().trim().min(1),
id: denTypeIdSchema("invitation"),
})

const acceptInvitationSchema = z.object({
id: z.string().trim().min(1),
id: denTypeIdSchema("invitation"),
})

const organizationResponseSchema = z.object({
Expand All @@ -44,9 +44,9 @@ const invitationPreviewResponseSchema = z.object({}).passthrough().meta({ ref: "

const invitationAcceptedResponseSchema = z.object({
accepted: z.literal(true),
organizationId: z.string(),
organizationId: denTypeIdSchema("organization"),
organizationSlug: z.string().nullable(),
invitationId: z.string(),
invitationId: denTypeIdSchema("invitation"),
}).meta({ ref: "InvitationAcceptedResponse" })

const organizationContextResponseSchema = z.object({
Expand Down Expand Up @@ -74,6 +74,7 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
"/v1/orgs",
describeRoute({
tags: ["Organizations"],
hide: true,
summary: "Create organization",
description: "Creates a new organization for the signed-in user after verifying that their account can provision OpenWork Cloud workspaces.",
responses: {
Expand Down Expand Up @@ -144,7 +145,7 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
app.get(
"/v1/orgs/invitations/preview",
describeRoute({
tags: ["Organizations", "Organization Invitations"],
tags: ["Invitations"],
summary: "Preview organization invitation",
description: "Returns invitation preview details so a user can inspect an organization invite before accepting it.",
responses: {
Expand All @@ -169,7 +170,7 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
app.post(
"/v1/orgs/invitations/accept",
describeRoute({
tags: ["Organizations", "Organization Invitations"],
tags: ["Invitations"],
summary: "Accept organization invitation",
description: "Accepts an organization invitation for the current signed-in user and switches their active organization to the accepted workspace.",
responses: {
Expand Down
Loading
Loading