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
15 changes: 11 additions & 4 deletions ee/apps/den-api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,25 @@
},
"dependencies": {
"@better-auth/api-key": "^1.5.6",
"@openwork-ee/den-db": "workspace:*",
"@openwork-ee/utils": "workspace:*",
"@daytonaio/sdk": "^0.150.0",
"@hono/node-server": "^1.13.8",
"@hono/zod-validator": "^0.7.6",
"better-call": "^1.3.2",
"@hono/standard-validator": "^0.2.2",
"@hono/swagger-ui": "^0.6.1",
"@openwork-ee/den-db": "workspace:*",
"@openwork-ee/utils": "workspace:*",
"@standard-community/standard-json": "^0.3.5",
"@standard-community/standard-openapi": "^0.2.9",
"@standard-schema/spec": "^1.1.0",
"better-auth": "^1.5.6",
"better-call": "^1.3.2",
"dotenv": "^16.4.5",
"hono": "^4.7.2",
"hono-openapi": "^1.3.0",
"openapi-types": "^12.1.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/json-schema": "^7.0.15",
"@types/node": "^20.11.30",
"tsx": "^4.15.7",
"typescript": "^5.5.4"
Expand Down
143 changes: 137 additions & 6 deletions ee/apps/den-api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import "./load-env.js"
import { createDenTypeId } from "@openwork-ee/utils/typeid"
import { swaggerUI } from "@hono/swagger-ui"
import { cors } from "hono/cors"
import { Hono } from "hono"
import { logger } from "hono/logger"
import type { RequestIdVariables } from "hono/request-id"
import { requestId } from "hono/request-id"
import { describeRoute, openAPIRouteHandler, resolver } from "hono-openapi"
import { z } from "zod"
import { env } from "./env.js"
import type { MemberTeamsContext, OrganizationContextVariables, UserOrganizationsContext } from "./middleware/index.js"
import { buildOperationId, emptyResponse, htmlResponse, jsonResponse } from "./openapi.js"
import { registerAdminRoutes } from "./routes/admin/index.js"
import { registerAuthRoutes } from "./routes/auth/index.js"
import { registerMeRoutes } from "./routes/me/index.js"
Expand All @@ -17,6 +21,21 @@ import { sessionMiddleware } from "./session.js"

type AppVariables = RequestIdVariables & AuthContextVariables & Partial<UserOrganizationsContext> & Partial<OrganizationContextVariables> & Partial<MemberTeamsContext>

const healthResponseSchema = z.object({
ok: z.literal(true),
service: z.literal("den-api"),
}).meta({ ref: "DenApiHealthResponse" })

const openApiDocumentSchema = z.object({
openapi: z.string(),
info: z.object({
title: z.string(),
version: z.string(),
}).passthrough(),
paths: z.record(z.string(), z.unknown()),
components: z.object({}).passthrough().optional(),
}).passthrough().meta({ ref: "OpenApiDocument" })

const app = new Hono<{ Variables: AppVariables }>()

app.use("*", logger())
Expand Down Expand Up @@ -45,20 +64,132 @@ if (env.corsOrigins.length > 0) {

app.use("*", sessionMiddleware)

app.get("/", (c) => {
return c.redirect("https://openworklabs.com", 302)
})
app.get(
"/",
describeRoute({
tags: ["System"],
summary: "Redirect API root",
description: "Redirects the API root to the OpenWork marketing site instead of serving API content.",
responses: {
302: emptyResponse("Redirect to the OpenWork marketing site."),
},
}),
(c) => {
return c.redirect("https://openworklabs.com", 302)
},
)

app.get("/health", (c) => {
return c.json({ ok: true, service: "den-api" })
})
app.get(
"/health",
describeRoute({
tags: ["System"],
summary: "Check den-api health",
description: "Returns a lightweight health payload for den-api.",
responses: {
200: {
description: "den-api is reachable",
content: {
"application/json": {
schema: resolver(healthResponseSchema),
},
},
},
},
}),
(c) => {
return c.json({ ok: true, service: "den-api" })
},
)

registerAdminRoutes(app)
registerAuthRoutes(app)
registerMeRoutes(app)
registerOrgRoutes(app)
registerWorkerRoutes(app)

app.get(
"/openapi.json",
describeRoute({
tags: ["Documentation"],
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: {
200: jsonResponse("OpenAPI document returned successfully.", openApiDocumentSchema),
},
}),
openAPIRouteHandler(app, {
documentation: {
openapi: "3.1.0",
info: {
title: "Den API",
version: "dev",
description: "OpenAPI spec for the Den control plane API.",
},
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: "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." },
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "session-token",
},
denApiKey: {
type: "apiKey",
in: "header",
name: "x-api-key",
},
},
},
},
includeEmptyPaths: true,
exclude: ["/docs", "/openapi.json"],
excludeMethods: ["OPTIONS"],
defaultOptions: {
ALL: {
operationId: (route) => buildOperationId(route.method, route.path),
},
},
}),
)

app.get(
"/docs",
describeRoute({
tags: ["Documentation"],
summary: "Serve Swagger UI",
description: "Serves Swagger UI so developers can browse and try the Den API from a browser.",
responses: {
200: htmlResponse("Swagger UI page returned successfully."),
},
}),
swaggerUI({
url: "/openapi.json",
persistAuthorization: true,
displayOperationId: true,
defaultModelsExpandDepth: 1,
}),
)

app.notFound((c) => {
return c.json({ error: "not_found" }, 404)
})
Expand Down
6 changes: 3 additions & 3 deletions ee/apps/den-api/src/middleware/validation.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { zValidator } from "@hono/zod-validator"
import { validator as zValidator } from "hono-openapi"
import type { ZodSchema } from "zod"

function invalidRequestResponse(result: { success: false; error: { issues: unknown } }, c: { json: (body: unknown, status?: number) => Response }) {
function invalidRequestResponse(result: { success: false; error: unknown }, c: { json: (body: unknown, status?: number) => Response }) {
return c.json(
{
error: "invalid_request",
details: result.error.issues,
details: result.error,
},
400,
)
Expand Down
102 changes: 102 additions & 0 deletions ee/apps/den-api/src/openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { resolver } from "hono-openapi"
import { z } from "zod"

function toPascalCase(value: string) {
return value
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("")
}

export function buildOperationId(method: string, path: string) {
const parts = path
.split("/")
.filter(Boolean)
.filter((part) => part !== "v1")
.map((part) => {
if (part.startsWith(":")) {
return `by-${part.slice(1)}`
}

if (part === "*") {
return "wildcard"
}

return part
})

return [method.toLowerCase(), ...parts]
.map(toPascalCase)
.join("")
.replace(/^[A-Z]/, (char) => char.toLowerCase())
}

const validationIssueSchema = z.object({
message: z.string(),
path: z.array(z.union([z.string(), z.number()])).optional(),
}).passthrough()

export const invalidRequestSchema = z.object({
error: z.literal("invalid_request"),
details: z.array(validationIssueSchema),
}).meta({ ref: "InvalidRequestError" })

export const unauthorizedSchema = z.object({
error: z.literal("unauthorized"),
}).meta({ ref: "UnauthorizedError" })

export const forbiddenSchema = z.object({
error: z.literal("forbidden"),
message: z.string().optional(),
}).meta({ ref: "ForbiddenError" })

export const notFoundSchema = z.object({
error: z.string(),
message: z.string().optional(),
}).meta({ ref: "NotFoundError" })

export const successSchema = z.object({
success: z.literal(true),
}).meta({ ref: "SuccessResponse" })

export const emptyObjectSchema = z.object({}).passthrough().meta({ ref: "OpaqueObject" })

export function jsonResponse(description: string, schema: z.ZodTypeAny) {
return {
description,
content: {
"application/json": {
schema: resolver(schema),
},
},
}
}

export function htmlResponse(description: string) {
return {
description,
content: {
"text/html": {
schema: resolver(z.string()),
},
},
}
}

export function textResponse(description: string) {
return {
description,
content: {
"text/plain": {
schema: resolver(z.string()),
},
},
}
}

export function emptyResponse(description: string) {
return { description }
}
33 changes: 31 additions & 2 deletions ee/apps/den-api/src/routes/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { asc, desc, eq, isNotNull, sql } from "@openwork-ee/den-db/drizzle"
import { AuthAccountTable, AuthSessionTable, AuthUserTable, WorkerTable, AdminAllowlistTable } from "@openwork-ee/den-db/schema"
import type { Hono } from "hono"
import { describeRoute } from "hono-openapi"
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 type { AuthContextVariables } from "../../session.js"

type UserId = typeof AuthUserTable.$inferSelect.id
Expand All @@ -13,6 +15,18 @@ const overviewQuerySchema = z.object({
includeBilling: z.string().optional(),
})

const adminOverviewResponseSchema = z.object({
viewer: z.object({
id: z.string(),
email: z.string(),
name: z.string().nullable(),
}),
admins: z.array(z.object({}).passthrough()),
summary: z.object({}).passthrough(),
users: z.array(z.object({}).passthrough()),
generatedAt: z.string().datetime(),
}).meta({ ref: "AdminOverviewResponse" })

function normalizeEmail(value: string | null | undefined) {
return value?.trim().toLowerCase() ?? ""
}
Expand Down Expand Up @@ -84,7 +98,21 @@ async function mapWithConcurrency<T, R>(items: T[], limit: number, mapper: (item
}

export function registerAdminRoutes<T extends { Variables: AuthContextVariables }>(app: Hono<T>) {
app.get("/v1/admin/overview", requireAdminMiddleware, queryValidator(overviewQuerySchema), async (c) => {
app.get(
"/v1/admin/overview",
describeRoute({
tags: ["Admin"],
summary: "Get admin overview",
description: "Returns a high-level administrative overview of users, sessions, workers, admins, and optional billing data for Den operations.",
responses: {
200: jsonResponse("Administrative overview returned successfully.", adminOverviewResponseSchema),
400: jsonResponse("The admin overview query parameters were invalid.", invalidRequestSchema),
401: jsonResponse("The caller must be an authenticated admin.", unauthorizedSchema),
},
}),
requireAdminMiddleware,
queryValidator(overviewQuerySchema),
async (c) => {
const user = c.get("user")
const query = c.req.valid("query")
const includeBilling = parseBooleanQuery(query.includeBilling)
Expand Down Expand Up @@ -289,5 +317,6 @@ export function registerAdminRoutes<T extends { Variables: AuthContextVariables
users: userRows,
generatedAt: new Date().toISOString(),
})
})
},
)
}
Loading
Loading