feat(den-api): migrate den controller to hono (#1269)

* feat(den-api): migrate den controller to hono

* fix(den-api): align worker listing with current build setup

* fix(den-api): avoid duplicate org role seeding

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-01 11:20:02 -07:00
committed by GitHub
parent af63132554
commit 0bc2f91e86
60 changed files with 6567 additions and 21 deletions

View File

@@ -0,0 +1,25 @@
PORT=8790
CORS_ORIGINS=http://localhost:3000,http://localhost:3001
DATABASE_URL=mysql://root:password@127.0.0.1:3306/den
BETTER_AUTH_SECRET=replace-with-32-plus-character-secret
BETTER_AUTH_URL=http://localhost:8790
DEN_BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000,http://localhost:3001
LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL=replace-with-loops-template-id
LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL=replace-with-loops-template-id
PROVISIONER_MODE=daytona
WORKER_URL_TEMPLATE=https://workers.local/{workerId}
WORKER_ACTIVITY_BASE_URL=http://localhost:8790
RENDER_API_KEY=
RENDER_OWNER_ID=
RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX=
VERCEL_TOKEN=
VERCEL_DNS_DOMAIN=
POLAR_FEATURE_GATE_ENABLED=false
POLAR_ACCESS_TOKEN=
POLAR_PRODUCT_ID=
POLAR_BENEFIT_ID=
POLAR_SUCCESS_URL=
POLAR_RETURN_URL=
DAYTONA_API_KEY=
DAYTONA_API_URL=https://app.daytona.io/api
OPENWORK_DEV_MODE=1

42
ee/apps/den-api/README.md Normal file
View File

@@ -0,0 +1,42 @@
# Den API
Hono-based successor to `ee/apps/den-controller`.
This package is the Hono-based successor to `ee/apps/den-controller`.
It now carries the full migrated Den API route surface in a foldered Hono structure so agents can navigate one area at a time without scanning the whole service.
## Quick start
```bash
pnpm --filter @openwork-ee/den-api dev:local
```
## Current routes
- `GET /` -> `302 https://openworklabs.com`
- `GET /health`
- Better Auth mount at `/api/auth/*`
- desktop handoff routes under `/v1/auth/*`
- current user routes under `/v1/me*`
- organization routes under `/v1/orgs*`
- admin routes under `/v1/admin*`
- worker lifecycle and billing routes under `/v1/workers*`
## Folder map
- `src/routes/auth/`: Better Auth mount + desktop handoff endpoints
- `src/routes/me/`: current user and current user's org resolution routes
- `src/routes/org/`: organization CRUD-ish surfaces, split by area
- `src/routes/admin/`: admin-only reporting endpoints
- `src/routes/workers/`: worker lifecycle, billing, runtime, and heartbeat endpoints
- `src/middleware/`: reusable Hono middleware for auth context, org context, teams, and validation
Each major folder also has its own `README.md` so future agents can inspect one area in isolation.
## Migration approach
1. Keep `den-controller` as the source of truth while the migration is still in flight.
2. Port endpoints to focused Hono route groups one surface at a time.
3. Reuse shared middleware and Zod validators instead of duplicating request/session/org plumbing.
4. Leave a short README in each route area when the structure changes so later agents can recover context fast.

View File

@@ -0,0 +1,29 @@
{
"name": "@openwork-ee/den-api",
"private": true,
"type": "module",
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 tsx watch src/server.ts",
"dev:local": "sh -lc 'OPENWORK_DEV_MODE=1 PORT=${DEN_API_PORT:-8790} tsx watch src/server.ts'",
"build": "pnpm run build:den-db && tsc -p tsconfig.json",
"build:den-db": "pnpm --filter @openwork-ee/den-db build",
"start": "node dist/server.js"
},
"dependencies": {
"@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.1.8",
"better-auth": "^1.4.18",
"dotenv": "^16.4.5",
"hono": "^4.7.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^20.11.30",
"tsx": "^4.15.7",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1 @@
export const DEN_WORKER_POLL_INTERVAL_MS = 1000

View File

@@ -0,0 +1,53 @@
import { sql } from "@openwork-ee/den-db/drizzle"
import { AdminAllowlistTable } from "@openwork-ee/den-db/schema"
import { createDenTypeId } from "@openwork-ee/utils/typeid"
import { db } from "./db.js"
const ADMIN_ALLOWLIST_SEEDS = [
{
email: "ben@openworklabs.com",
note: "Seeded internal admin",
},
{
email: "jan@openworklabs.com",
note: "Seeded internal admin",
},
{
email: "omar@openworklabs.com",
note: "Seeded internal admin",
},
{
email: "berk@openworklabs.com",
note: "Seeded internal admin",
},
] as const
let ensureAdminAllowlistSeededPromise: Promise<void> | null = null
async function seedAdminAllowlist() {
for (const entry of ADMIN_ALLOWLIST_SEEDS) {
await db
.insert(AdminAllowlistTable)
.values({
id: createDenTypeId("adminAllowlist"),
...entry,
})
.onDuplicateKeyUpdate({
set: {
note: entry.note,
updated_at: sql`CURRENT_TIMESTAMP(3)`,
},
})
}
}
export async function ensureAdminAllowlistSeeded() {
if (!ensureAdminAllowlistSeededPromise) {
ensureAdminAllowlistSeededPromise = seedAdminAllowlist().catch((error) => {
ensureAdminAllowlistSeededPromise = null
throw error
})
}
await ensureAdminAllowlistSeededPromise
}

View File

@@ -0,0 +1,66 @@
import "./load-env.js"
import { createDenTypeId } from "@openwork-ee/utils/typeid"
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 { env } from "./env.js"
import type { MemberTeamsContext, OrganizationContextVariables, UserOrganizationsContext } from "./middleware/index.js"
import { registerAdminRoutes } from "./routes/admin/index.js"
import { registerAuthRoutes } from "./routes/auth/index.js"
import { registerMeRoutes } from "./routes/me/index.js"
import { registerOrgRoutes } from "./routes/org/index.js"
import { registerWorkerRoutes } from "./routes/workers/index.js"
import type { AuthContextVariables } from "./session.js"
import { sessionMiddleware } from "./session.js"
type AppVariables = RequestIdVariables & AuthContextVariables & Partial<UserOrganizationsContext> & Partial<OrganizationContextVariables> & Partial<MemberTeamsContext>
const app = new Hono<{ Variables: AppVariables }>()
app.use("*", logger())
app.use("*", requestId({
headerName: "",
generator: () => createDenTypeId("request"),
}))
app.use("*", async (c, next) => {
await next()
c.header("X-Request-Id", c.get("requestId"))
})
if (env.corsOrigins.length > 0) {
app.use(
"*",
cors({
origin: env.corsOrigins,
credentials: true,
allowHeaders: ["Content-Type", "Authorization", "X-Request-Id"],
allowMethods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
exposeHeaders: ["Content-Length", "X-Request-Id"],
maxAge: 600,
}),
)
}
app.use("*", sessionMiddleware)
app.get("/", (c) => {
return c.redirect("https://openworklabs.com", 302)
})
app.get("/health", (c) => {
return c.json({ ok: true, service: "den-api" })
})
registerAdminRoutes(app)
registerAuthRoutes(app)
registerMeRoutes(app)
registerOrgRoutes(app)
registerWorkerRoutes(app)
app.notFound((c) => {
return c.json({ error: "not_found" }, 404)
})
export default app

205
ee/apps/den-api/src/auth.ts Normal file
View File

@@ -0,0 +1,205 @@
import { db } from "./db.js"
import { env } from "./env.js"
import { sendDenOrganizationInvitationEmail, sendDenVerificationEmail } from "./email.js"
import { syncDenSignupContact } from "./loops.js"
import { denOrganizationAccess, denOrganizationStaticRoles } from "./organization-access.js"
import { seedDefaultOrganizationRoles } from "./orgs.js"
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import * as schema from "@openwork-ee/den-db/schema"
import { APIError } from "better-call"
import { betterAuth } from "better-auth"
import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { emailOTP, organization } from "better-auth/plugins"
const socialProviders = {
...(env.github.clientId && env.github.clientSecret
? {
github: {
clientId: env.github.clientId,
clientSecret: env.github.clientSecret,
},
}
: {}),
...(env.google.clientId && env.google.clientSecret
? {
google: {
clientId: env.google.clientId,
clientSecret: env.google.clientSecret,
},
}
: {}),
}
function hasRole(roleValue: string, roleName: string) {
return roleValue
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
.includes(roleName)
}
function getInvitationOrigin() {
return env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ?? env.betterAuthUrl
}
function buildInvitationLink(invitationId: string) {
return new URL(`/join-org?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString()
}
export const auth = betterAuth({
baseURL: env.betterAuthUrl,
secret: env.betterAuthSecret,
trustedOrigins: env.betterAuthTrustedOrigins.length > 0 ? env.betterAuthTrustedOrigins : undefined,
socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined,
database: drizzleAdapter(db, {
provider: "mysql",
schema,
}),
advanced: {
ipAddress: {
ipAddressHeaders: ["x-forwarded-for", "x-real-ip", "cf-connecting-ip"],
ipv6Subnet: 64,
},
database: {
generateId: (options) => {
switch (options.model) {
case "user":
return createDenTypeId("user")
case "session":
return createDenTypeId("session")
case "account":
return createDenTypeId("account")
case "verification":
return createDenTypeId("verification")
case "rateLimit":
return createDenTypeId("rateLimit")
case "organization":
return createDenTypeId("organization")
case "member":
return createDenTypeId("member")
case "invitation":
return createDenTypeId("invitation")
case "team":
return createDenTypeId("team")
case "teamMember":
return createDenTypeId("teamMember")
case "organizationRole":
return createDenTypeId("organizationRole")
default:
return false
}
},
},
},
rateLimit: {
enabled: true,
storage: "database",
window: 60,
max: 20,
customRules: {
"/sign-in/email": {
window: 300,
max: 5,
},
"/sign-up/email": {
window: 3600,
max: 3,
},
"/email-otp/send-verification-otp": {
window: 3600,
max: 5,
},
"/email-otp/verify-email": {
window: 300,
max: 10,
},
"/request-password-reset": {
window: 3600,
max: 5,
},
},
},
emailVerification: {
sendOnSignUp: true,
sendOnSignIn: true,
afterEmailVerification: async (user) => {
await syncDenSignupContact({
email: user.email,
name: user.name,
})
},
},
emailAndPassword: {
enabled: true,
autoSignIn: false,
requireEmailVerification: true,
},
plugins: [
emailOTP({
overrideDefaultEmailVerification: true,
otpLength: 6,
expiresIn: 600,
allowedAttempts: 5,
async sendVerificationOTP({ email, otp, type }) {
if (type !== "email-verification") {
return
}
await sendDenVerificationEmail({
email,
verificationCode: otp,
})
},
}),
organization({
ac: denOrganizationAccess,
roles: denOrganizationStaticRoles,
creatorRole: "owner",
requireEmailVerificationOnInvitation: true,
dynamicAccessControl: {
enabled: true,
},
teams: {
enabled: true,
defaultTeam: {
enabled: false,
},
},
async sendInvitationEmail(data) {
await sendDenOrganizationInvitationEmail({
email: data.email,
inviteLink: buildInvitationLink(data.id),
invitedByName: data.inviter.user.name ?? data.inviter.user.email,
invitedByEmail: data.inviter.user.email,
organizationName: data.organization.name,
role: data.role,
})
},
organizationHooks: {
afterCreateOrganization: async ({ organization }) => {
await seedDefaultOrganizationRoles(normalizeDenTypeId("organization", organization.id))
},
beforeRemoveMember: async ({ member }) => {
if (hasRole(member.role, "owner")) {
throw new APIError("BAD_REQUEST", {
message: "The organization owner cannot be removed.",
})
}
},
beforeUpdateMemberRole: async ({ member, newRole }) => {
if (hasRole(member.role, "owner")) {
throw new APIError("BAD_REQUEST", {
message: "The organization owner role cannot be changed.",
})
}
if (hasRole(newRole, "owner")) {
throw new APIError("BAD_REQUEST", {
message: "Owner can only be assigned during organization creation.",
})
}
},
},
}),
],
})

View File

@@ -0,0 +1,828 @@
import { env } from "../env.js"
import { sendSubscribedToDenEvent } from "../loops.js"
type PolarCustomerState = {
granted_benefits?: Array<{
benefit_id?: string
}>
}
type PolarCheckoutSession = {
url?: string
}
type PolarCustomerSession = {
customer_portal_url?: string
}
type PolarCustomer = {
id?: string
email?: string
external_id?: string | null
}
type PolarListResource<T> = {
items?: T[]
}
type PolarSubscription = {
id?: string
status?: string
amount?: number
currency?: string
recurring_interval?: string | null
recurring_interval_count?: number | null
current_period_start?: string | null
current_period_end?: string | null
cancel_at_period_end?: boolean
canceled_at?: string | null
ended_at?: string | null
}
type PolarOrder = {
id?: string
created_at?: string
status?: string
total_amount?: number
net_amount?: number
currency?: string
invoice_number?: string
is_invoice_generated?: boolean
}
type PolarOrderInvoice = {
url?: string
}
type PolarProductPrice = {
amount_type?: string
price_currency?: string
price_amount?: number
minimum_amount?: number
preset_amount?: number | null
is_archived?: boolean
seat_tiers?: {
tiers?: Array<{
price_per_seat?: number
}>
}
}
type PolarProduct = {
recurring_interval?: string | null
recurring_interval_count?: number | null
prices?: PolarProductPrice[]
}
const CHECKOUT_TRIAL_INTERVAL = "day"
const CHECKOUT_TRIAL_INTERVAL_COUNT = 14
export type CloudWorkerAccess =
| {
allowed: true
}
| {
allowed: false
checkoutUrl: string
}
export type CloudWorkerBillingPrice = {
amount: number | null
currency: string | null
recurringInterval: string | null
recurringIntervalCount: number | null
}
export type CloudWorkerBillingSubscription = {
id: string
status: string
amount: number | null
currency: string | null
recurringInterval: string | null
recurringIntervalCount: number | null
currentPeriodStart: string | null
currentPeriodEnd: string | null
cancelAtPeriodEnd: boolean
canceledAt: string | null
endedAt: string | null
}
export type CloudWorkerBillingInvoice = {
id: string
createdAt: string | null
status: string
totalAmount: number | null
currency: string | null
invoiceNumber: string | null
invoiceUrl: string | null
}
export type CloudWorkerBillingStatus = {
featureGateEnabled: boolean
hasActivePlan: boolean
checkoutRequired: boolean
checkoutUrl: string | null
portalUrl: string | null
price: CloudWorkerBillingPrice | null
subscription: CloudWorkerBillingSubscription | null
invoices: CloudWorkerBillingInvoice[]
}
export type CloudWorkerAdminBillingStatus = {
status: "paid" | "unpaid" | "unavailable"
featureGateEnabled: boolean
subscriptionId: string | null
subscriptionStatus: string | null
currentPeriodEnd: string | null
source: "benefit" | "subscription" | "unavailable"
note: string | null
}
type CloudAccessInput = {
userId: string
email: string
name: string
}
type BillingStatusOptions = {
includeCheckoutUrl?: boolean
includePortalUrl?: boolean
includeInvoices?: boolean
}
function sanitizeApiBase(value: string) {
return value.replace(/\/+$/, "")
}
function parseJson<T>(text: string): T | null {
if (!text) {
return null
}
return JSON.parse(text) as T
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
async function polarFetch(path: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set("Authorization", `Bearer ${env.polar.accessToken}`)
headers.set("Accept", "application/json")
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
return fetch(`${sanitizeApiBase(env.polar.apiBase)}${path}`, {
...init,
headers,
})
}
async function polarFetchJson<T>(path: string, init: RequestInit = {}) {
const response = await polarFetch(path, init)
const text = await response.text()
const payload = parseJson<T>(text)
return { response, text, payload }
}
function assertPaywallConfig() {
if (!env.polar.accessToken) {
throw new Error("POLAR_ACCESS_TOKEN is required when POLAR_FEATURE_GATE_ENABLED=true")
}
if (!env.polar.productId) {
throw new Error("POLAR_PRODUCT_ID is required when POLAR_FEATURE_GATE_ENABLED=true")
}
if (!env.polar.benefitId) {
throw new Error("POLAR_BENEFIT_ID is required when POLAR_FEATURE_GATE_ENABLED=true")
}
if (!env.polar.successUrl) {
throw new Error("POLAR_SUCCESS_URL is required when POLAR_FEATURE_GATE_ENABLED=true")
}
if (!env.polar.returnUrl) {
throw new Error("POLAR_RETURN_URL is required when POLAR_FEATURE_GATE_ENABLED=true")
}
}
async function getCustomerStateByExternalId(externalCustomerId: string): Promise<PolarCustomerState | null> {
const encodedExternalId = encodeURIComponent(externalCustomerId)
const { response, payload, text } = await polarFetchJson<PolarCustomerState>(`/v1/customers/external/${encodedExternalId}/state`, {
method: "GET",
})
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`Polar customer state lookup failed (${response.status}): ${text.slice(0, 400)}`)
}
return payload
}
async function getCustomerStateById(customerId: string): Promise<PolarCustomerState | null> {
const encodedCustomerId = encodeURIComponent(customerId)
const { response, payload, text } = await polarFetchJson<PolarCustomerState>(`/v1/customers/${encodedCustomerId}/state`, {
method: "GET",
})
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`Polar customer state lookup by ID failed (${response.status}): ${text.slice(0, 400)}`)
}
return payload
}
async function getCustomerByEmail(email: string): Promise<PolarCustomer | null> {
const normalizedEmail = email.trim().toLowerCase()
if (!normalizedEmail) {
return null
}
const encodedEmail = encodeURIComponent(normalizedEmail)
const { response, payload, text } = await polarFetchJson<PolarListResource<PolarCustomer>>(`/v1/customers/?email=${encodedEmail}`, {
method: "GET",
})
if (!response.ok) {
throw new Error(`Polar customer lookup by email failed (${response.status}): ${text.slice(0, 400)}`)
}
const customers = payload?.items ?? []
const exact = customers.find((customer) => customer.email?.trim().toLowerCase() === normalizedEmail)
return exact ?? customers[0] ?? null
}
async function linkCustomerExternalId(customer: PolarCustomer, externalCustomerId: string): Promise<void> {
if (!customer.id) {
return
}
if (typeof customer.external_id === "string" && customer.external_id.length > 0) {
return
}
const encodedCustomerId = encodeURIComponent(customer.id)
await polarFetch(`/v1/customers/${encodedCustomerId}`, {
method: "PATCH",
body: JSON.stringify({
external_id: externalCustomerId,
}),
})
}
function hasRequiredBenefit(state: PolarCustomerState | null) {
if (!state?.granted_benefits || !env.polar.benefitId) {
return false
}
return state.granted_benefits.some((grant) => grant.benefit_id === env.polar.benefitId)
}
async function createCheckoutSession(input: CloudAccessInput): Promise<string> {
const payload = {
products: [env.polar.productId],
success_url: env.polar.successUrl,
return_url: env.polar.returnUrl,
allow_trial: true,
trial_interval: CHECKOUT_TRIAL_INTERVAL,
trial_interval_count: CHECKOUT_TRIAL_INTERVAL_COUNT,
external_customer_id: input.userId,
customer_email: input.email,
customer_name: input.name,
}
const { response, payload: checkout, text } = await polarFetchJson<PolarCheckoutSession>("/v1/checkouts/", {
method: "POST",
body: JSON.stringify(payload),
})
if (!response.ok) {
throw new Error(`Polar checkout creation failed (${response.status}): ${text.slice(0, 400)}`)
}
if (!checkout?.url) {
throw new Error("Polar checkout response missing URL")
}
return checkout.url
}
type CloudWorkerAccessEvaluation = {
featureGateEnabled: boolean
hasActivePlan: boolean
checkoutUrl: string | null
}
async function evaluateCloudWorkerAccess(
input: CloudAccessInput,
options: { includeCheckoutUrl?: boolean } = {},
): Promise<CloudWorkerAccessEvaluation> {
if (!env.polar.featureGateEnabled) {
return {
featureGateEnabled: false,
hasActivePlan: true,
checkoutUrl: null,
}
}
assertPaywallConfig()
const externalState = await getCustomerStateByExternalId(input.userId)
if (hasRequiredBenefit(externalState)) {
return {
featureGateEnabled: true,
hasActivePlan: true,
checkoutUrl: null,
}
}
const customer = await getCustomerByEmail(input.email)
if (customer?.id) {
const emailState = await getCustomerStateById(customer.id)
if (hasRequiredBenefit(emailState)) {
await linkCustomerExternalId(customer, input.userId).catch(() => undefined)
return {
featureGateEnabled: true,
hasActivePlan: true,
checkoutUrl: null,
}
}
}
return {
featureGateEnabled: true,
hasActivePlan: false,
checkoutUrl: options.includeCheckoutUrl ? await createCheckoutSession(input) : null,
}
}
function normalizeRecurringInterval(value: string | null | undefined): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null
}
function normalizeRecurringIntervalCount(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null
}
function isActiveSubscriptionStatus(status: string | null | undefined) {
const normalized = typeof status === "string" ? status.trim().toLowerCase() : ""
return normalized === "active" || normalized === "trialing"
}
function toBillingSubscription(subscription: PolarSubscription | null): CloudWorkerBillingSubscription | null {
if (!subscription?.id) {
return null
}
return {
id: subscription.id,
status: typeof subscription.status === "string" ? subscription.status : "unknown",
amount: typeof subscription.amount === "number" ? subscription.amount : null,
currency: typeof subscription.currency === "string" ? subscription.currency : null,
recurringInterval: normalizeRecurringInterval(subscription.recurring_interval),
recurringIntervalCount: normalizeRecurringIntervalCount(subscription.recurring_interval_count),
currentPeriodStart: typeof subscription.current_period_start === "string" ? subscription.current_period_start : null,
currentPeriodEnd: typeof subscription.current_period_end === "string" ? subscription.current_period_end : null,
cancelAtPeriodEnd: subscription.cancel_at_period_end === true,
canceledAt: typeof subscription.canceled_at === "string" ? subscription.canceled_at : null,
endedAt: typeof subscription.ended_at === "string" ? subscription.ended_at : null,
}
}
function toBillingPriceFromSubscription(subscription: CloudWorkerBillingSubscription | null): CloudWorkerBillingPrice | null {
if (!subscription) {
return null
}
return {
amount: subscription.amount,
currency: subscription.currency,
recurringInterval: subscription.recurringInterval,
recurringIntervalCount: subscription.recurringIntervalCount,
}
}
async function getSubscriptionById(subscriptionId: string): Promise<PolarSubscription | null> {
const encodedId = encodeURIComponent(subscriptionId)
const { response, payload, text } = await polarFetchJson<PolarSubscription>(`/v1/subscriptions/${encodedId}`, {
method: "GET",
})
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`Polar subscription lookup failed (${response.status}): ${text.slice(0, 400)}`)
}
return payload
}
async function listSubscriptionsByExternalCustomer(
externalCustomerId: string,
options: { activeOnly?: boolean; limit?: number } = {},
): Promise<PolarSubscription[]> {
const params = new URLSearchParams()
params.set("external_customer_id", externalCustomerId)
if (env.polar.productId) {
params.set("product_id", env.polar.productId)
}
params.set("limit", String(options.limit ?? 1))
params.set("sorting", "-started_at")
if (options.activeOnly === true) {
params.set("active", "true")
}
const lookup = await polarFetchJson<PolarListResource<PolarSubscription>>(`/v1/subscriptions/?${params.toString()}`, {
method: "GET",
})
let response = lookup.response
let payload = lookup.payload
let text = lookup.text
if (response.status === 422 && params.has("sorting")) {
params.delete("sorting")
const fallbackLookup = await polarFetchJson<PolarListResource<PolarSubscription>>(`/v1/subscriptions/?${params.toString()}`, {
method: "GET",
})
response = fallbackLookup.response
payload = fallbackLookup.payload
text = fallbackLookup.text
}
if (!response.ok) {
throw new Error(`Polar subscriptions lookup failed (${response.status}): ${text.slice(0, 400)}`)
}
return payload?.items ?? []
}
async function getPrimarySubscriptionForCustomer(externalCustomerId: string): Promise<PolarSubscription | null> {
const active = await listSubscriptionsByExternalCustomer(externalCustomerId, { activeOnly: true, limit: 1 })
if (active[0]) {
return active[0]
}
const recent = await listSubscriptionsByExternalCustomer(externalCustomerId, { activeOnly: false, limit: 1 })
return recent[0] ?? null
}
async function listRecentOrdersByExternalCustomer(externalCustomerId: string, limit = 6): Promise<PolarOrder[]> {
const params = new URLSearchParams()
params.set("external_customer_id", externalCustomerId)
if (env.polar.productId) {
params.set("product_id", env.polar.productId)
}
params.set("limit", String(limit))
params.set("sorting", "-created_at")
const { response, payload, text } = await polarFetchJson<PolarListResource<PolarOrder>>(`/v1/orders/?${params.toString()}`, {
method: "GET",
})
if (!response.ok) {
throw new Error(`Polar orders lookup failed (${response.status}): ${text.slice(0, 400)}`)
}
return payload?.items ?? []
}
async function getOrderInvoiceUrl(orderId: string): Promise<string | null> {
const encodedId = encodeURIComponent(orderId)
const { response, payload, text } = await polarFetchJson<PolarOrderInvoice>(`/v1/orders/${encodedId}/invoice`, {
method: "GET",
})
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`Polar invoice lookup failed (${response.status}): ${text.slice(0, 400)}`)
}
return typeof payload?.url === "string" ? payload.url : null
}
function toBillingInvoice(order: PolarOrder, invoiceUrl: string | null): CloudWorkerBillingInvoice | null {
if (!order.id) {
return null
}
const totalAmount =
typeof order.total_amount === "number"
? order.total_amount
: typeof order.net_amount === "number"
? order.net_amount
: null
return {
id: order.id,
createdAt: typeof order.created_at === "string" ? order.created_at : null,
status: typeof order.status === "string" ? order.status : "unknown",
totalAmount,
currency: typeof order.currency === "string" ? order.currency : null,
invoiceNumber: typeof order.invoice_number === "string" ? order.invoice_number : null,
invoiceUrl,
}
}
async function listBillingInvoices(externalCustomerId: string, limit = 6): Promise<CloudWorkerBillingInvoice[]> {
const orders = await listRecentOrdersByExternalCustomer(externalCustomerId, limit)
const invoices = await Promise.all(
orders.map(async (order) => {
const invoiceUrl = order.id && order.is_invoice_generated === true ? await getOrderInvoiceUrl(order.id).catch(() => null) : null
return toBillingInvoice(order, invoiceUrl)
}),
)
return invoices.filter((invoice): invoice is CloudWorkerBillingInvoice => invoice !== null)
}
async function createCustomerPortalUrl(externalCustomerId: string): Promise<string | null> {
const body = {
external_customer_id: externalCustomerId,
return_url: env.polar.returnUrl ?? env.polar.successUrl ?? null,
}
const { response, payload, text } = await polarFetchJson<PolarCustomerSession>("/v1/customer-sessions/", {
method: "POST",
body: JSON.stringify(body),
})
if (response.status === 404 || response.status === 422) {
return null
}
if (!response.ok) {
throw new Error(`Polar customer portal session failed (${response.status}): ${text.slice(0, 400)}`)
}
return typeof payload?.customer_portal_url === "string" ? payload.customer_portal_url : null
}
function extractAmountFromProductPrice(price: PolarProductPrice): number | null {
if (price.amount_type === "fixed" && typeof price.price_amount === "number") {
return price.price_amount
}
if (price.amount_type === "seat_based") {
const firstTier = Array.isArray(price.seat_tiers?.tiers) ? price.seat_tiers?.tiers[0] : null
if (firstTier && typeof firstTier.price_per_seat === "number") {
return firstTier.price_per_seat
}
}
if (price.amount_type === "custom") {
if (typeof price.preset_amount === "number") {
return price.preset_amount
}
if (typeof price.minimum_amount === "number") {
return price.minimum_amount
}
}
if (price.amount_type === "free") {
return 0
}
return null
}
function extractBillingPriceFromProduct(product: PolarProduct | null): CloudWorkerBillingPrice | null {
if (!product || !Array.isArray(product.prices)) {
return null
}
for (const price of product.prices) {
if (!isRecord(price) || price.is_archived === true) {
continue
}
const amount = extractAmountFromProductPrice(price as PolarProductPrice)
if (amount === null) {
continue
}
const currency = typeof price.price_currency === "string" ? price.price_currency : null
return {
amount,
currency,
recurringInterval: normalizeRecurringInterval(product.recurring_interval),
recurringIntervalCount: normalizeRecurringIntervalCount(product.recurring_interval_count),
}
}
return null
}
async function getProductBillingPrice(productId: string): Promise<CloudWorkerBillingPrice | null> {
const encodedId = encodeURIComponent(productId)
const { response, payload, text } = await polarFetchJson<PolarProduct>(`/v1/products/${encodedId}`, {
method: "GET",
})
if (response.status === 404) {
return null
}
if (!response.ok) {
throw new Error(`Polar product lookup failed (${response.status}): ${text.slice(0, 400)}`)
}
return extractBillingPriceFromProduct(payload)
}
export async function requireCloudWorkerAccess(input: CloudAccessInput): Promise<CloudWorkerAccess> {
const evaluation = await evaluateCloudWorkerAccess(input, { includeCheckoutUrl: true })
if (evaluation.hasActivePlan) {
return { allowed: true }
}
if (!evaluation.checkoutUrl) {
throw new Error("Polar checkout URL unavailable")
}
return {
allowed: false,
checkoutUrl: evaluation.checkoutUrl,
}
}
export async function getCloudWorkerBillingStatus(
input: CloudAccessInput,
options: BillingStatusOptions = {},
): Promise<CloudWorkerBillingStatus> {
const includePortalUrl = options.includePortalUrl !== false
const includeInvoices = options.includeInvoices !== false
const evaluation = await evaluateCloudWorkerAccess(input, {
includeCheckoutUrl: options.includeCheckoutUrl,
})
if (!evaluation.featureGateEnabled) {
return {
featureGateEnabled: false,
hasActivePlan: true,
checkoutRequired: false,
checkoutUrl: null,
portalUrl: null,
price: null,
subscription: null,
invoices: [],
}
}
if (evaluation.hasActivePlan) {
await sendSubscribedToDenEvent(input)
}
const [subscriptionResult, priceResult, portalResult, invoicesResult] = await Promise.all([
getPrimarySubscriptionForCustomer(input.userId).catch(() => null),
env.polar.productId ? getProductBillingPrice(env.polar.productId).catch(() => null) : Promise.resolve<CloudWorkerBillingPrice | null>(null),
includePortalUrl ? createCustomerPortalUrl(input.userId).catch(() => null) : Promise.resolve<string | null>(null),
includeInvoices ? listBillingInvoices(input.userId).catch(() => []) : Promise.resolve<CloudWorkerBillingInvoice[]>([]),
])
const subscription = toBillingSubscription(subscriptionResult)
const productPrice = priceResult
const portalUrl = portalResult
const invoices = invoicesResult
return {
featureGateEnabled: evaluation.featureGateEnabled,
hasActivePlan: evaluation.hasActivePlan,
checkoutRequired: evaluation.featureGateEnabled && !evaluation.hasActivePlan,
checkoutUrl: evaluation.checkoutUrl,
portalUrl,
price: productPrice ?? toBillingPriceFromSubscription(subscription),
subscription,
invoices,
}
}
export async function getCloudWorkerAdminBillingStatus(
input: CloudAccessInput,
): Promise<CloudWorkerAdminBillingStatus> {
if (!env.polar.accessToken) {
return {
status: "unavailable",
featureGateEnabled: env.polar.featureGateEnabled,
subscriptionId: null,
subscriptionStatus: null,
currentPeriodEnd: null,
source: "unavailable",
note: "Polar access token is not configured.",
}
}
if (!env.polar.benefitId && !env.polar.productId) {
return {
status: "unavailable",
featureGateEnabled: env.polar.featureGateEnabled,
subscriptionId: null,
subscriptionStatus: null,
currentPeriodEnd: null,
source: "unavailable",
note: "Polar product or benefit configuration is missing.",
}
}
try {
let note: string | null = null
let paidByBenefit = false
if (env.polar.benefitId) {
const externalState = await getCustomerStateByExternalId(input.userId)
if (hasRequiredBenefit(externalState)) {
paidByBenefit = true
note = "Benefit granted via external customer id."
} else {
const customer = await getCustomerByEmail(input.email)
if (customer?.id) {
const emailState = await getCustomerStateById(customer.id)
if (hasRequiredBenefit(emailState)) {
paidByBenefit = true
note = "Benefit granted via matching customer email."
await linkCustomerExternalId(customer, input.userId).catch(() => undefined)
}
}
}
}
const subscription = env.polar.productId ? await getPrimarySubscriptionForCustomer(input.userId) : null
const normalizedSubscription = toBillingSubscription(subscription)
const paidBySubscription = isActiveSubscriptionStatus(normalizedSubscription?.status)
return {
status: paidByBenefit || paidBySubscription ? "paid" : "unpaid",
featureGateEnabled: env.polar.featureGateEnabled,
subscriptionId: normalizedSubscription?.id ?? null,
subscriptionStatus: normalizedSubscription?.status ?? null,
currentPeriodEnd: normalizedSubscription?.currentPeriodEnd ?? null,
source: paidByBenefit ? "benefit" : "subscription",
note:
note ??
(normalizedSubscription
? "Subscription status resolved from Polar."
: "No active billing record was found for this user."),
}
} catch (error) {
return {
status: "unavailable",
featureGateEnabled: env.polar.featureGateEnabled,
subscriptionId: null,
subscriptionStatus: null,
currentPeriodEnd: null,
source: "unavailable",
note: error instanceof Error ? error.message : "Billing lookup failed.",
}
}
}
export async function setCloudWorkerSubscriptionCancellation(
input: CloudAccessInput,
cancelAtPeriodEnd: boolean,
): Promise<CloudWorkerBillingSubscription | null> {
if (!env.polar.featureGateEnabled) {
return null
}
assertPaywallConfig()
const activeSubscriptions = await listSubscriptionsByExternalCustomer(input.userId, {
activeOnly: true,
limit: 1,
})
const active = activeSubscriptions[0]
if (!active?.id) {
return null
}
const encodedId = encodeURIComponent(active.id)
const { response, payload, text } = await polarFetchJson<PolarSubscription>(`/v1/subscriptions/${encodedId}`, {
method: "PATCH",
body: JSON.stringify({
cancel_at_period_end: cancelAtPeriodEnd,
}),
})
if (!response.ok) {
throw new Error(`Polar subscription update failed (${response.status}): ${text.slice(0, 400)}`)
}
if (payload?.id) {
return toBillingSubscription(payload)
}
const refreshed = await getSubscriptionById(active.id)
return toBillingSubscription(refreshed)
}

View File

@@ -0,0 +1,8 @@
import { createDenDb } from "@openwork-ee/den-db"
import { env } from "./env.js"
export const { db } = createDenDb({
databaseUrl: env.databaseUrl,
mode: env.dbMode,
planetscale: env.planetscale,
})

View File

@@ -0,0 +1,134 @@
import { env } from "./env.js"
const LOOPS_TRANSACTIONAL_API_URL = "https://app.loops.so/api/v1/transactional"
export async function sendDenVerificationEmail(input: {
email: string
verificationCode: string
}) {
const email = input.email.trim()
const verificationCode = input.verificationCode.trim()
if (!email || !verificationCode) {
return
}
if (env.devMode) {
console.info(`[auth] dev verification email payload for ${email}: ${JSON.stringify({ verificationCode })}`)
return
}
if (!env.loops.apiKey || !env.loops.transactionalIdDenVerifyEmail) {
console.warn(`[auth] verification email skipped for ${email}: Loops is not configured`)
return
}
try {
const response = await fetch(LOOPS_TRANSACTIONAL_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${env.loops.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
transactionalId: env.loops.transactionalIdDenVerifyEmail,
email,
dataVariables: {
verificationCode,
},
}),
})
if (response.ok) {
return
}
let detail = `status ${response.status}`
try {
const payload = (await response.json()) as { message?: string }
if (payload.message?.trim()) {
detail = payload.message
}
} catch {
// Ignore invalid upstream payloads.
}
console.warn(`[auth] failed to send verification email for ${email}: ${detail}`)
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
console.warn(`[auth] failed to send verification email for ${email}: ${message}`)
}
}
export async function sendDenOrganizationInvitationEmail(input: {
email: string
inviteLink: string
invitedByName: string
invitedByEmail: string
organizationName: string
role: string
}) {
const email = input.email.trim()
if (!email) {
return
}
if (env.devMode) {
console.info(
`[auth] dev organization invite email payload for ${email}: ${JSON.stringify({
inviteLink: input.inviteLink,
invitedByName: input.invitedByName,
invitedByEmail: input.invitedByEmail,
organizationName: input.organizationName,
role: input.role,
})}`,
)
return
}
if (!env.loops.apiKey || !env.loops.transactionalIdDenOrgInviteEmail) {
console.warn(`[auth] organization invite email skipped for ${email}: Loops is not configured`)
return
}
try {
const response = await fetch(LOOPS_TRANSACTIONAL_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${env.loops.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
transactionalId: env.loops.transactionalIdDenOrgInviteEmail,
email,
dataVariables: {
inviteLink: input.inviteLink,
invitedByName: input.invitedByName,
invitedByEmail: input.invitedByEmail,
organizationName: input.organizationName,
role: input.role,
},
}),
})
if (response.ok) {
return
}
let detail = `status ${response.status}`
try {
const payload = (await response.json()) as { message?: string }
if (payload.message?.trim()) {
detail = payload.message
}
} catch {
// Ignore invalid upstream payloads.
}
console.warn(`[auth] failed to send organization invite email for ${email}: ${detail}`)
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
console.warn(`[auth] failed to send organization invite email for ${email}: ${message}`)
}
}

263
ee/apps/den-api/src/env.ts Normal file
View File

@@ -0,0 +1,263 @@
import { DEN_WORKER_POLL_INTERVAL_MS } from "./CONSTS.js"
import { z } from "zod"
const EnvSchema = z.object({
DATABASE_URL: z.string().min(1).optional(),
DATABASE_HOST: z.string().min(1).optional(),
DATABASE_USERNAME: z.string().min(1).optional(),
DATABASE_PASSWORD: z.string().optional(),
DB_MODE: z.enum(["mysql", "planetscale"]).optional(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().min(1),
DEN_BETTER_AUTH_TRUSTED_ORIGINS: z.string().optional(),
GITHUB_CLIENT_ID: z.string().optional(),
GITHUB_CLIENT_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
LOOPS_API_KEY: z.string().optional(),
LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL: z.string().optional(),
LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL: z.string().optional(),
OPENWORK_DEV_MODE: z.string().optional(),
PORT: z.string().optional(),
CORS_ORIGINS: z.string().optional(),
WORKER_PROXY_PORT: z.string().optional(),
PROVISIONER_MODE: z.enum(["stub", "render", "daytona"]).optional(),
WORKER_URL_TEMPLATE: z.string().optional(),
WORKER_ACTIVITY_BASE_URL: z.string().optional(),
OPENWORK_DAYTONA_ENV_PATH: z.string().optional(),
RENDER_API_BASE: z.string().optional(),
RENDER_API_KEY: z.string().optional(),
RENDER_OWNER_ID: z.string().optional(),
RENDER_WORKER_REPO: z.string().optional(),
RENDER_WORKER_BRANCH: z.string().optional(),
RENDER_WORKER_ROOT_DIR: z.string().optional(),
RENDER_WORKER_PLAN: z.string().optional(),
RENDER_WORKER_REGION: z.string().optional(),
RENDER_WORKER_OPENWORK_VERSION: z.string().optional(),
RENDER_WORKER_NAME_PREFIX: z.string().optional(),
RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX: z.string().optional(),
RENDER_CUSTOM_DOMAIN_READY_TIMEOUT_MS: z.string().optional(),
RENDER_PROVISION_TIMEOUT_MS: z.string().optional(),
RENDER_HEALTHCHECK_TIMEOUT_MS: z.string().optional(),
RENDER_POLL_INTERVAL_MS: z.string().optional(),
VERCEL_API_BASE: z.string().optional(),
VERCEL_TOKEN: z.string().optional(),
VERCEL_TEAM_ID: z.string().optional(),
VERCEL_TEAM_SLUG: z.string().optional(),
VERCEL_DNS_DOMAIN: z.string().optional(),
POLAR_FEATURE_GATE_ENABLED: z.string().optional(),
POLAR_API_BASE: z.string().optional(),
POLAR_ACCESS_TOKEN: z.string().optional(),
POLAR_PRODUCT_ID: z.string().optional(),
POLAR_BENEFIT_ID: z.string().optional(),
POLAR_SUCCESS_URL: z.string().optional(),
POLAR_RETURN_URL: z.string().optional(),
DAYTONA_API_URL: z.string().optional(),
DAYTONA_API_KEY: z.string().optional(),
DAYTONA_TARGET: z.string().optional(),
DAYTONA_SNAPSHOT: z.string().optional(),
DAYTONA_SANDBOX_IMAGE: z.string().optional(),
DAYTONA_SANDBOX_CPU: z.string().optional(),
DAYTONA_SANDBOX_MEMORY: z.string().optional(),
DAYTONA_SANDBOX_DISK: z.string().optional(),
DAYTONA_SANDBOX_PUBLIC: z.string().optional(),
DAYTONA_SANDBOX_AUTO_STOP_INTERVAL: z.string().optional(),
DAYTONA_SANDBOX_AUTO_ARCHIVE_INTERVAL: z.string().optional(),
DAYTONA_SANDBOX_AUTO_DELETE_INTERVAL: z.string().optional(),
DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS: z.string().optional(),
DAYTONA_WORKER_PROXY_BASE_URL: z.string().optional(),
DAYTONA_SANDBOX_NAME_PREFIX: z.string().optional(),
DAYTONA_VOLUME_NAME_PREFIX: z.string().optional(),
DAYTONA_WORKSPACE_MOUNT_PATH: z.string().optional(),
DAYTONA_DATA_MOUNT_PATH: z.string().optional(),
DAYTONA_RUNTIME_WORKSPACE_PATH: z.string().optional(),
DAYTONA_RUNTIME_DATA_PATH: z.string().optional(),
DAYTONA_SIDECAR_DIR: z.string().optional(),
DAYTONA_OPENWORK_PORT: z.string().optional(),
DAYTONA_OPENCODE_PORT: z.string().optional(),
DAYTONA_CREATE_TIMEOUT_SECONDS: z.string().optional(),
DAYTONA_DELETE_TIMEOUT_SECONDS: z.string().optional(),
DAYTONA_HEALTHCHECK_TIMEOUT_MS: z.string().optional(),
}).superRefine((value, ctx) => {
const inferredMode = value.DB_MODE ?? (value.DATABASE_URL ? "mysql" : "planetscale")
if (inferredMode === "mysql" && !value.DATABASE_URL) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "DATABASE_URL is required when using mysql mode",
path: ["DATABASE_URL"],
})
}
if (inferredMode === "planetscale") {
for (const key of ["DATABASE_HOST", "DATABASE_USERNAME", "DATABASE_PASSWORD"] as const) {
if (!value[key]) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `${key} is required when using planetscale mode`,
path: [key],
})
}
}
}
})
const parsed = EnvSchema.parse(process.env)
function splitCsv(value: string | undefined) {
return (value ?? "")
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
}
function optionalString(value: string | undefined) {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}
function normalizeOrigin(origin: string) {
const value = origin.trim()
if (value === "*") {
return value
}
return value.replace(/\/+$/, "")
}
const corsOrigins = splitCsv(parsed.CORS_ORIGINS).map((origin) => normalizeOrigin(origin))
const betterAuthTrustedOrigins = splitCsv(parsed.DEN_BETTER_AUTH_TRUSTED_ORIGINS)
.map((origin) => normalizeOrigin(origin))
const polarFeatureGateEnabled =
(parsed.POLAR_FEATURE_GATE_ENABLED ?? "false").toLowerCase() === "true"
const daytonaSandboxPublic =
(parsed.DAYTONA_SANDBOX_PUBLIC ?? "false").toLowerCase() === "true"
const planetscaleCredentials =
parsed.DATABASE_HOST && parsed.DATABASE_USERNAME && parsed.DATABASE_PASSWORD !== undefined
? {
host: parsed.DATABASE_HOST,
username: parsed.DATABASE_USERNAME,
password: parsed.DATABASE_PASSWORD,
}
: null
export const env = {
databaseUrl: parsed.DATABASE_URL,
dbMode: parsed.DB_MODE ?? (parsed.DATABASE_URL ? "mysql" : "planetscale"),
planetscale: planetscaleCredentials,
betterAuthSecret: parsed.BETTER_AUTH_SECRET,
betterAuthUrl: normalizeOrigin(parsed.BETTER_AUTH_URL),
betterAuthTrustedOrigins: betterAuthTrustedOrigins.length > 0 ? betterAuthTrustedOrigins : corsOrigins,
devMode: (parsed.OPENWORK_DEV_MODE ?? "0").trim() === "1",
github: {
clientId: optionalString(parsed.GITHUB_CLIENT_ID),
clientSecret: optionalString(parsed.GITHUB_CLIENT_SECRET),
},
google: {
clientId: optionalString(parsed.GOOGLE_CLIENT_ID),
clientSecret: optionalString(parsed.GOOGLE_CLIENT_SECRET),
},
loops: {
apiKey: optionalString(parsed.LOOPS_API_KEY),
transactionalIdDenVerifyEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_VERIFY_EMAIL),
transactionalIdDenOrgInviteEmail: optionalString(parsed.LOOPS_TRANSACTIONAL_ID_DEN_ORG_INVITE_EMAIL),
},
port: Number(parsed.PORT ?? "8790"),
workerProxyPort: Number(parsed.WORKER_PROXY_PORT ?? "8789"),
corsOrigins,
provisionerMode: parsed.PROVISIONER_MODE ?? "daytona",
workerUrlTemplate: parsed.WORKER_URL_TEMPLATE,
workerActivityBaseUrl:
optionalString(parsed.WORKER_ACTIVITY_BASE_URL) ??
parsed.BETTER_AUTH_URL.trim().replace(/\/+$/, ""),
render: {
apiBase: parsed.RENDER_API_BASE ?? "https://api.render.com/v1",
apiKey: parsed.RENDER_API_KEY,
ownerId: parsed.RENDER_OWNER_ID,
workerRepo:
parsed.RENDER_WORKER_REPO ?? "https://github.com/different-ai/openwork",
workerBranch: parsed.RENDER_WORKER_BRANCH ?? "dev",
workerRootDir:
parsed.RENDER_WORKER_ROOT_DIR ?? "ee/apps/den-worker-runtime",
workerPlan: parsed.RENDER_WORKER_PLAN ?? "standard",
workerRegion: parsed.RENDER_WORKER_REGION ?? "oregon",
workerOpenworkVersion: parsed.RENDER_WORKER_OPENWORK_VERSION,
workerNamePrefix: parsed.RENDER_WORKER_NAME_PREFIX ?? "den-worker",
workerPublicDomainSuffix: parsed.RENDER_WORKER_PUBLIC_DOMAIN_SUFFIX,
customDomainReadyTimeoutMs: Number(
parsed.RENDER_CUSTOM_DOMAIN_READY_TIMEOUT_MS ?? "240000",
),
provisionTimeoutMs: Number(parsed.RENDER_PROVISION_TIMEOUT_MS ?? "900000"),
healthcheckTimeoutMs: Number(
parsed.RENDER_HEALTHCHECK_TIMEOUT_MS ?? "180000",
),
pollIntervalMs: Number(parsed.RENDER_POLL_INTERVAL_MS ?? "5000"),
},
vercel: {
apiBase: parsed.VERCEL_API_BASE ?? "https://api.vercel.com",
token: parsed.VERCEL_TOKEN,
teamId: parsed.VERCEL_TEAM_ID,
teamSlug: parsed.VERCEL_TEAM_SLUG,
dnsDomain: parsed.VERCEL_DNS_DOMAIN,
},
polar: {
featureGateEnabled: polarFeatureGateEnabled,
apiBase: parsed.POLAR_API_BASE ?? "https://api.polar.sh",
accessToken: parsed.POLAR_ACCESS_TOKEN,
productId: parsed.POLAR_PRODUCT_ID,
benefitId: parsed.POLAR_BENEFIT_ID,
successUrl: parsed.POLAR_SUCCESS_URL,
returnUrl: parsed.POLAR_RETURN_URL,
},
daytona: {
envPath: optionalString(parsed.OPENWORK_DAYTONA_ENV_PATH),
apiUrl: optionalString(parsed.DAYTONA_API_URL) ?? "https://app.daytona.io/api",
apiKey: optionalString(parsed.DAYTONA_API_KEY),
target: optionalString(parsed.DAYTONA_TARGET),
snapshot: optionalString(parsed.DAYTONA_SNAPSHOT),
image: optionalString(parsed.DAYTONA_SANDBOX_IMAGE) ?? "node:20-bookworm",
resources: {
cpu: Number(parsed.DAYTONA_SANDBOX_CPU ?? "2"),
memory: Number(parsed.DAYTONA_SANDBOX_MEMORY ?? "4"),
disk: Number(parsed.DAYTONA_SANDBOX_DISK ?? "8"),
},
public: daytonaSandboxPublic,
autoStopInterval: Number(parsed.DAYTONA_SANDBOX_AUTO_STOP_INTERVAL ?? "0"),
autoArchiveInterval: Number(
parsed.DAYTONA_SANDBOX_AUTO_ARCHIVE_INTERVAL ?? "10080",
),
autoDeleteInterval: Number(
parsed.DAYTONA_SANDBOX_AUTO_DELETE_INTERVAL ?? "-1",
),
signedPreviewExpiresSeconds: Number(
parsed.DAYTONA_SIGNED_PREVIEW_EXPIRES_SECONDS ?? "86400",
),
workerProxyBaseUrl:
optionalString(parsed.DAYTONA_WORKER_PROXY_BASE_URL) ?? "https://workers.den.openworklabs",
sandboxNamePrefix:
optionalString(parsed.DAYTONA_SANDBOX_NAME_PREFIX) ?? "den-daytona-worker",
volumeNamePrefix:
optionalString(parsed.DAYTONA_VOLUME_NAME_PREFIX) ?? "den-daytona-worker",
workspaceMountPath:
optionalString(parsed.DAYTONA_WORKSPACE_MOUNT_PATH) ?? "/workspace",
dataMountPath:
optionalString(parsed.DAYTONA_DATA_MOUNT_PATH) ?? "/persist/openwork",
runtimeWorkspacePath:
optionalString(parsed.DAYTONA_RUNTIME_WORKSPACE_PATH) ??
"/tmp/openwork-workspace",
runtimeDataPath:
optionalString(parsed.DAYTONA_RUNTIME_DATA_PATH) ?? "/tmp/openwork-data",
sidecarDir:
optionalString(parsed.DAYTONA_SIDECAR_DIR) ?? "/tmp/openwork-sidecars",
openworkPort: Number(parsed.DAYTONA_OPENWORK_PORT ?? "8787"),
opencodePort: Number(parsed.DAYTONA_OPENCODE_PORT ?? "4096"),
createTimeoutSeconds: Number(parsed.DAYTONA_CREATE_TIMEOUT_SECONDS ?? "300"),
deleteTimeoutSeconds: Number(parsed.DAYTONA_DELETE_TIMEOUT_SECONDS ?? "120"),
healthcheckTimeoutMs: Number(
parsed.DAYTONA_HEALTHCHECK_TIMEOUT_MS ?? "300000",
),
pollIntervalMs: DEN_WORKER_POLL_INTERVAL_MS,
},
}

View File

@@ -0,0 +1,3 @@
import app from "./app.js"
export default app

View File

@@ -0,0 +1,15 @@
import { existsSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import dotenv from "dotenv"
const srcDir = path.dirname(fileURLToPath(import.meta.url))
const serviceDir = path.resolve(srcDir, "..")
for (const filePath of [path.join(serviceDir, ".env.local"), path.join(serviceDir, ".env")]) {
if (existsSync(filePath)) {
dotenv.config({ path: filePath, override: false })
}
}
dotenv.config({ override: false })

View File

@@ -0,0 +1,121 @@
import { env } from "./env.js"
const LOOPS_CONTACTS_UPDATE_URL = "https://app.loops.so/api/v1/contacts/update"
const LOOPS_EVENTS_SEND_URL = "https://app.loops.so/api/v1/events/send"
const DEN_SIGNUP_SOURCE = "signup"
const SUBSCRIBED_TO_DEN_EVENT = "subscribedToDen"
function splitName(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean)
return {
firstName: parts[0] ?? "",
lastName: parts.slice(1).join(" ") || undefined,
}
}
export async function syncDenSignupContact(input: {
email: string
name?: string | null
}) {
const apiKey = env.loops.apiKey
if (!apiKey) {
return
}
const email = input.email.trim()
if (!email) {
return
}
const name = input.name?.trim()
const { firstName, lastName } = name ? splitName(name) : { firstName: "", lastName: undefined }
try {
const response = await fetch(LOOPS_CONTACTS_UPDATE_URL, {
method: "PUT",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
firstName: firstName || undefined,
lastName,
source: DEN_SIGNUP_SOURCE,
}),
})
if (response.ok) {
return
}
let detail = `status ${response.status}`
try {
const payload = (await response.json()) as { message?: string }
if (payload.message?.trim()) {
detail = payload.message
}
} catch {
// Ignore non-JSON error bodies from Loops.
}
console.warn(`[auth] failed to sync Loops contact for ${email}: ${detail}`)
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
console.warn(`[auth] failed to sync Loops contact for ${email}: ${message}`)
}
}
export async function sendSubscribedToDenEvent(input: {
email: string
name?: string | null
}) {
const apiKey = env.loops.apiKey
if (!apiKey) {
return
}
const email = input.email.trim()
if (!email) {
return
}
const name = input.name?.trim()
const { firstName, lastName } = name ? splitName(name) : { firstName: "", lastName: undefined }
try {
const response = await fetch(LOOPS_EVENTS_SEND_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
eventName: SUBSCRIBED_TO_DEN_EVENT,
firstName: firstName || undefined,
lastName,
eventProperties: {
subscribedAt: new Date().toISOString(),
},
}),
})
if (!response.ok) {
let detail = `status ${response.status}`
try {
const payload = (await response.json()) as { message?: string }
if (payload.message?.trim()) {
detail = payload.message
}
} catch {
// Ignore non-JSON error bodies from Loops.
}
console.warn(`[billing] failed to send Loops event ${SUBSCRIBED_TO_DEN_EVENT} for ${email}: ${detail}`)
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"
console.warn(`[billing] failed to send Loops event ${SUBSCRIBED_TO_DEN_EVENT} for ${email}: ${message}`)
}
}

View File

@@ -0,0 +1,43 @@
# Middleware
This folder contains reusable Hono middleware that route areas can compose as needed.
## Files
- `index.ts`: public export surface for all shared middleware
- `admin.ts`: requires an authenticated allowlisted admin
- `current-user.ts`: requires an authenticated user
- `user-organizations.ts`: loads the orgs the current user belongs to
- `organization-context.ts`: loads org + current member context for `:orgSlug` routes
- `member-teams.ts`: loads the teams the current org member belongs to
- `validation.ts`: shared Hono Zod validator wrappers for JSON, query, and params
## Available context
- `c.get("user")`: current authenticated user
- `c.get("session")`: current Better Auth session
- `c.get("userOrganizations")`: orgs for the current user
- `c.get("activeOrganizationId")`
- `c.get("activeOrganizationSlug")`
- `c.get("organizationContext")`: org record, current member, members, invites, roles
- `c.get("memberTeams")`: teams for the current org member
## Usage pattern
Import from `src/middleware/index.ts`:
```ts
import {
jsonValidator,
paramValidator,
requireUserMiddleware,
resolveOrganizationContextMiddleware,
} from "../../middleware/index.js"
```
Then compose only what a route needs.
## Rule of thumb
- If a value is broadly useful across multiple route areas, put it here
- If a helper only exists for one route area, keep it in that route folder instead

View File

@@ -0,0 +1,36 @@
import { eq } from "@openwork-ee/den-db/drizzle"
import { AdminAllowlistTable } from "@openwork-ee/den-db/schema"
import type { MiddlewareHandler } from "hono"
import { ensureAdminAllowlistSeeded } from "../admin-allowlist.js"
import { db } from "../db.js"
import type { AuthContextVariables } from "../session.js"
function normalizeEmail(value: string | null | undefined) {
return value?.trim().toLowerCase() ?? ""
}
export const requireAdminMiddleware: MiddlewareHandler<{ Variables: AuthContextVariables }> = async (c, next) => {
const user = c.get("user")
if (!user?.id) {
return c.json({ error: "unauthorized" }, 401) as never
}
const email = normalizeEmail(user.email)
if (!email) {
return c.json({ error: "admin_email_required" }, 403) as never
}
await ensureAdminAllowlistSeeded()
const allowed = await db
.select({ id: AdminAllowlistTable.id })
.from(AdminAllowlistTable)
.where(eq(AdminAllowlistTable.email, email))
.limit(1)
if (allowed.length === 0) {
return c.json({ error: "forbidden" }, 403) as never
}
await next()
}

View File

@@ -0,0 +1,10 @@
import type { MiddlewareHandler } from "hono"
import type { AuthContextVariables } from "../session.js"
export const requireUserMiddleware: MiddlewareHandler<{ Variables: AuthContextVariables }> = async (c, next) => {
if (!c.get("user")?.id) {
return c.json({ error: "unauthorized" }, 401) as never
}
await next()
}

View File

@@ -0,0 +1,6 @@
export * from "./admin.js"
export * from "./current-user.js"
export * from "./user-organizations.js"
export * from "./organization-context.js"
export * from "./member-teams.js"
export * from "./validation.js"

View File

@@ -0,0 +1,25 @@
import type { MiddlewareHandler } from "hono"
import { listTeamsForMember, type MemberTeamSummary } from "../orgs.js"
import type { AuthContextVariables } from "../session.js"
import type { OrganizationContextVariables } from "./organization-context.js"
export type MemberTeamsContext = {
memberTeams: MemberTeamSummary[]
}
export const resolveMemberTeamsMiddleware: MiddlewareHandler<{
Variables: AuthContextVariables & Partial<OrganizationContextVariables> & Partial<MemberTeamsContext>
}> = async (c, next) => {
const context = c.get("organizationContext")
if (!context) {
return c.json({ error: "organization_context_required" }, 500) as never
}
const memberTeams = await listTeamsForMember({
organizationId: context.organization.id,
userId: context.currentMember.userId,
})
c.set("memberTeams", memberTeams)
await next()
}

View File

@@ -0,0 +1,42 @@
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { MiddlewareHandler } from "hono"
import { getOrganizationContextForUser, type OrganizationContext } from "../orgs.js"
import type { AuthContextVariables } from "../session.js"
export type OrganizationContextVariables = {
organizationContext: OrganizationContext
}
export const resolveOrganizationContextMiddleware: MiddlewareHandler<{
Variables: AuthContextVariables & Partial<OrganizationContextVariables>
}> = async (c, next) => {
const user = c.get("user")
if (!user?.id) {
return c.json({ error: "unauthorized" }, 401) as never
}
const params = (c.req as { valid: (target: "param") => { orgId?: string } }).valid("param")
const organizationIdRaw = params.orgId?.trim()
if (!organizationIdRaw) {
return c.json({ error: "organization_id_required" }, 400) as never
}
let organizationId
try {
organizationId = normalizeDenTypeId("organization", organizationIdRaw)
} catch {
return c.json({ error: "organization_not_found" }, 404) as never
}
const context = await getOrganizationContextForUser({
userId: normalizeDenTypeId("user", user.id),
organizationId,
})
if (!context) {
return c.json({ error: "organization_not_found" }, 404) as never
}
c.set("organizationContext", context)
await next()
}

View File

@@ -0,0 +1,30 @@
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { MiddlewareHandler } from "hono"
import { resolveUserOrganizations, type UserOrgSummary } from "../orgs.js"
import type { AuthContextVariables } from "../session.js"
export type UserOrganizationsContext = {
userOrganizations: UserOrgSummary[]
activeOrganizationId: string | null
activeOrganizationSlug: string | null
}
export const resolveUserOrganizationsMiddleware: MiddlewareHandler<{
Variables: AuthContextVariables & Partial<UserOrganizationsContext>
}> = async (c, next) => {
const user = c.get("user")
if (!user?.id) {
return c.json({ error: "unauthorized" }, 401) as never
}
const session = c.get("session")
const resolved = await resolveUserOrganizations({
activeOrganizationId: session?.activeOrganizationId ?? null,
userId: normalizeDenTypeId("user", user.id),
})
c.set("userOrganizations", resolved.orgs)
c.set("activeOrganizationId", resolved.activeOrgId)
c.set("activeOrganizationSlug", resolved.activeOrgSlug)
await next()
}

View File

@@ -0,0 +1,36 @@
import { zValidator } from "@hono/zod-validator"
import type { ZodSchema } from "zod"
function invalidRequestResponse(result: { success: false; error: { issues: unknown } }, c: { json: (body: unknown, status?: number) => Response }) {
return c.json(
{
error: "invalid_request",
details: result.error.issues,
},
400,
)
}
export function jsonValidator<T extends ZodSchema>(schema: T) {
return zValidator("json", schema, (result, c) => {
if (!result.success) {
return invalidRequestResponse(result, c)
}
})
}
export function queryValidator<T extends ZodSchema>(schema: T) {
return zValidator("query", schema, (result, c) => {
if (!result.success) {
return invalidRequestResponse(result, c)
}
})
}
export function paramValidator<T extends ZodSchema>(schema: T) {
return zValidator("param", schema, (result, c) => {
if (!result.success) {
return invalidRequestResponse(result, c)
}
})
}

View File

@@ -0,0 +1,15 @@
import { createAccessControl } from "better-auth/plugins/access"
import { defaultRoles, defaultStatements } from "better-auth/plugins/organization/access"
export const denOrganizationAccess = createAccessControl(defaultStatements)
export const denOrganizationStaticRoles = {
owner: defaultRoles.owner,
admin: defaultRoles.admin,
member: defaultRoles.member,
} as const
export const denDefaultDynamicOrganizationRoles = {
admin: defaultRoles.admin.statements,
member: defaultRoles.member.statements,
} as const

709
ee/apps/den-api/src/orgs.ts Normal file
View File

@@ -0,0 +1,709 @@
import { and, asc, eq } from "@openwork-ee/den-db/drizzle"
import {
AuthSessionTable,
AuthUserTable,
InvitationTable,
MemberTable,
OrganizationRoleTable,
OrganizationTable,
TeamMemberTable,
TeamTable,
} from "@openwork-ee/den-db/schema"
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import { db } from "./db.js"
import { denDefaultDynamicOrganizationRoles, denOrganizationStaticRoles } from "./organization-access.js"
type UserId = typeof AuthUserTable.$inferSelect.id
type SessionId = typeof AuthSessionTable.$inferSelect.id
type OrgId = typeof OrganizationTable.$inferSelect.id
type MemberRow = typeof MemberTable.$inferSelect
type InvitationRow = typeof InvitationTable.$inferSelect
export type InvitationStatus = "pending" | "accepted" | "canceled" | "expired"
export type InvitationPreview = {
invitation: {
id: string
email: string
role: string
status: InvitationStatus
expiresAt: Date
createdAt: Date
}
organization: {
id: OrgId
name: string
slug: string
}
}
export type UserOrgSummary = {
id: OrgId
name: string
slug: string
logo: string | null
metadata: string | null
role: string
orgMemberId: string
membershipId: string
createdAt: Date
updatedAt: Date
}
export type OrganizationContext = {
organization: {
id: OrgId
name: string
slug: string
logo: string | null
metadata: string | null
createdAt: Date
updatedAt: Date
}
currentMember: {
id: string
userId: UserId
role: string
createdAt: Date
isOwner: boolean
}
members: Array<{
id: string
userId: UserId
role: string
createdAt: Date
isOwner: boolean
user: {
id: UserId
email: string
name: string
image: string | null
}
}>
invitations: Array<{
id: string
email: string
role: string
status: string
expiresAt: Date
createdAt: Date
}>
roles: Array<{
id: string
role: string
permission: Record<string, string[]>
builtIn: boolean
protected: boolean
createdAt: Date | null
updatedAt: Date | null
}>
}
export type MemberTeamSummary = {
id: typeof TeamTable.$inferSelect.id
name: string
organizationId: typeof TeamTable.$inferSelect.organizationId
createdAt: Date
updatedAt: Date
}
function splitRoles(value: string) {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
}
function hasRole(roleValue: string, roleName: string) {
return splitRoles(roleValue).includes(roleName)
}
export function roleIncludesOwner(roleValue: string) {
return hasRole(roleValue, "owner")
}
function titleCase(value: string) {
return value
.split(/\s+/)
.filter(Boolean)
.map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`)
.join(" ")
}
function buildPersonalOrgName(input: {
name?: string | null
email?: string | null
}) {
const normalizedName = input.name?.trim()
if (normalizedName) {
return `${normalizedName}'s Org`
}
const localPart = input.email?.split("@")[0] ?? "Personal"
const normalized = titleCase(localPart.replace(/[._-]+/g, " ").trim()) || "Personal"
const suffix = normalized.endsWith("s") ? "' Org" : "'s Org"
return `${normalized}${suffix}`
}
export function parsePermissionRecord(value: string | null) {
if (!value) {
return {}
}
try {
const parsed = JSON.parse(value) as Record<string, unknown>
return Object.fromEntries(
Object.entries(parsed)
.filter((entry): entry is [string, unknown[]] => Array.isArray(entry[1]))
.map(([resource, actions]) => [
resource,
actions.filter((entry: unknown): entry is string => typeof entry === "string"),
]),
)
} catch {
return {}
}
}
export function serializePermissionRecord(value: Record<string, string[]>) {
return JSON.stringify(value)
}
async function listMembershipRows(userId: UserId) {
return db
.select()
.from(MemberTable)
.where(eq(MemberTable.userId, userId))
.orderBy(asc(MemberTable.createdAt))
}
function getInvitationStatus(invitation: Pick<InvitationRow, "status" | "expiresAt">): InvitationStatus {
if (invitation.status !== "pending") {
return invitation.status as Exclude<InvitationStatus, "expired">
}
return invitation.expiresAt > new Date() ? "pending" : "expired"
}
async function getInvitationById(invitationIdRaw: string) {
let invitationId
try {
invitationId = normalizeDenTypeId("invitation", invitationIdRaw)
} catch {
return null
}
const rows = await db
.select()
.from(InvitationTable)
.where(eq(InvitationTable.id, invitationId))
.limit(1)
return rows[0] ?? null
}
async function ensureDefaultDynamicRoles(orgId: OrgId) {
for (const [role, permission] of Object.entries(denDefaultDynamicOrganizationRoles)) {
await db
.insert(OrganizationRoleTable)
.values({
id: createDenTypeId("organizationRole"),
organizationId: orgId,
role,
permission: serializePermissionRecord(permission),
})
.onDuplicateKeyUpdate({
set: {
permission: serializePermissionRecord(permission),
},
})
}
}
function normalizeAssignableRole(input: string, availableRoles: Set<string>) {
const roles = splitRoles(input).filter((role) => availableRoles.has(role))
if (roles.length === 0) {
return "member"
}
return roles.join(",")
}
export async function listAssignableRoles(orgId: OrgId) {
await ensureDefaultDynamicRoles(orgId)
const rows = await db
.select({ role: OrganizationRoleTable.role })
.from(OrganizationRoleTable)
.where(eq(OrganizationRoleTable.organizationId, orgId))
return new Set(rows.map((row) => row.role))
}
async function insertMemberIfMissing(input: {
organizationId: OrgId
userId: UserId
role: string
}) {
const existing = await db
.select()
.from(MemberTable)
.where(and(eq(MemberTable.organizationId, input.organizationId), eq(MemberTable.userId, input.userId)))
.limit(1)
if (existing.length > 0) {
return existing[0]
}
await db.insert(MemberTable).values({
id: createDenTypeId("member"),
organizationId: input.organizationId,
userId: input.userId,
role: input.role,
})
const created = await db
.select()
.from(MemberTable)
.where(and(eq(MemberTable.organizationId, input.organizationId), eq(MemberTable.userId, input.userId)))
.limit(1)
if (!created[0]) {
throw new Error("failed_to_create_member")
}
return created[0]
}
async function acceptInvitation(invitation: InvitationRow, userId: UserId) {
const availableRoles = await listAssignableRoles(invitation.organizationId)
const role = normalizeAssignableRole(invitation.role, availableRoles)
const member = await insertMemberIfMissing({
organizationId: invitation.organizationId,
userId,
role,
})
if (invitation.teamId) {
const teams = await db
.select({ id: TeamTable.id })
.from(TeamTable)
.where(eq(TeamTable.id, invitation.teamId))
.limit(1)
if (teams[0]) {
const existingTeamMember = await db
.select({ id: TeamMemberTable.id })
.from(TeamMemberTable)
.where(and(eq(TeamMemberTable.teamId, invitation.teamId), eq(TeamMemberTable.userId, userId)))
.limit(1)
if (!existingTeamMember[0]) {
await db.insert(TeamMemberTable).values({
id: createDenTypeId("teamMember"),
teamId: invitation.teamId,
userId,
})
}
}
}
await db
.update(InvitationTable)
.set({ status: "accepted" })
.where(eq(InvitationTable.id, invitation.id))
return member
}
export async function acceptInvitationForUser(input: {
userId: UserId
email: string
invitationId: string | null
}) {
if (!input.invitationId) {
return null
}
const invitation = await getInvitationById(input.invitationId)
if (!invitation) {
return null
}
if (invitation.email.trim().toLowerCase() !== input.email.trim().toLowerCase()) {
return null
}
if (getInvitationStatus(invitation) !== "pending") {
return null
}
const member = await acceptInvitation(invitation, input.userId)
return {
invitation,
member,
}
}
export async function getInvitationPreview(invitationIdRaw: string): Promise<InvitationPreview | null> {
let invitationId
try {
invitationId = normalizeDenTypeId("invitation", invitationIdRaw)
} catch {
return null
}
const rows = await db
.select({
invitation: {
id: InvitationTable.id,
email: InvitationTable.email,
role: InvitationTable.role,
status: InvitationTable.status,
expiresAt: InvitationTable.expiresAt,
createdAt: InvitationTable.createdAt,
},
organization: {
id: OrganizationTable.id,
name: OrganizationTable.name,
slug: OrganizationTable.slug,
},
})
.from(InvitationTable)
.innerJoin(OrganizationTable, eq(InvitationTable.organizationId, OrganizationTable.id))
.where(eq(InvitationTable.id, invitationId))
.limit(1)
const row = rows[0]
if (!row) {
return null
}
return {
invitation: {
...row.invitation,
status: getInvitationStatus(row.invitation),
},
organization: row.organization,
}
}
async function createOrganizationRecord(input: {
userId: UserId
name: string
logo?: string | null
metadata?: string | null
}) {
const organizationId = createDenTypeId("organization")
await db.insert(OrganizationTable).values({
id: organizationId,
name: input.name,
slug: organizationId,
logo: input.logo ?? null,
metadata: input.metadata ?? null,
})
await db.insert(MemberTable).values({
id: createDenTypeId("member"),
organizationId,
userId: input.userId,
role: "owner",
})
await ensureDefaultDynamicRoles(organizationId)
return organizationId
}
export async function ensureUserOrgAccess(input: {
userId: UserId
}) {
const memberships = await listMembershipRows(input.userId)
if (memberships.length > 0) {
const organizationIds = [...new Set(memberships.map((membership) => membership.organizationId))]
await Promise.all(organizationIds.map((organizationId) => ensureDefaultDynamicRoles(organizationId)))
return memberships[0].organizationId
}
return null
}
export async function ensurePersonalOrganizationForUser(userId: UserId) {
const existingOrgId = await ensureUserOrgAccess({ userId })
if (existingOrgId) {
return existingOrgId
}
const userRows = await db
.select({
name: AuthUserTable.name,
email: AuthUserTable.email,
})
.from(AuthUserTable)
.where(eq(AuthUserTable.id, userId))
.limit(1)
const user = userRows[0]
const organizationId = await createOrganizationRecord({
userId,
name: buildPersonalOrgName({
name: user?.name,
email: user?.email,
}),
})
return organizationId
}
export async function createOrganizationForUser(input: {
userId: UserId
name: string
}) {
return createOrganizationRecord({
userId: input.userId,
name: input.name.trim(),
})
}
export async function seedDefaultOrganizationRoles(orgId: OrgId) {
await ensureDefaultDynamicRoles(orgId)
}
export async function setSessionActiveOrganization(sessionId: SessionId, organizationId: OrgId | null) {
await db
.update(AuthSessionTable)
.set({ activeOrganizationId: organizationId })
.where(eq(AuthSessionTable.id, sessionId))
}
export async function listUserOrgs(userId: UserId) {
const memberships = await db
.select({
membershipId: MemberTable.id,
role: MemberTable.role,
organization: {
id: OrganizationTable.id,
name: OrganizationTable.name,
slug: OrganizationTable.slug,
logo: OrganizationTable.logo,
metadata: OrganizationTable.metadata,
createdAt: OrganizationTable.createdAt,
updatedAt: OrganizationTable.updatedAt,
},
})
.from(MemberTable)
.innerJoin(OrganizationTable, eq(MemberTable.organizationId, OrganizationTable.id))
.where(eq(MemberTable.userId, userId))
.orderBy(asc(MemberTable.createdAt))
return memberships.map((row) => ({
id: row.organization.id,
name: row.organization.name,
slug: row.organization.slug,
logo: row.organization.logo,
metadata: row.organization.metadata,
role: row.role,
orgMemberId: row.membershipId,
membershipId: row.membershipId,
createdAt: row.organization.createdAt,
updatedAt: row.organization.updatedAt,
})) satisfies UserOrgSummary[]
}
export async function resolveUserOrganizations(input: {
activeOrganizationId?: string | null
userId: UserId
}) {
await ensurePersonalOrganizationForUser(input.userId)
const orgs = await listUserOrgs(input.userId)
const availableOrgIds = new Set(orgs.map((org) => org.id))
let activeOrgId: OrgId | null = null
if (input.activeOrganizationId) {
try {
const normalized = normalizeDenTypeId("organization", input.activeOrganizationId)
if (availableOrgIds.has(normalized)) {
activeOrgId = normalized
}
} catch {
activeOrgId = null
}
}
activeOrgId ??= orgs[0]?.id ?? null
const activeOrg = orgs.find((org) => org.id === activeOrgId) ?? null
return {
orgs,
activeOrgId,
activeOrgSlug: activeOrg?.slug ?? null,
}
}
export async function getOrganizationContextForUser(input: {
userId: UserId
organizationId: OrgId
}) {
const organizationRows = await db
.select()
.from(OrganizationTable)
.where(eq(OrganizationTable.id, input.organizationId))
.limit(1)
const organization = organizationRows[0]
if (!organization) {
return null
}
const currentMemberRows = await db
.select()
.from(MemberTable)
.where(and(eq(MemberTable.organizationId, organization.id), eq(MemberTable.userId, input.userId)))
.limit(1)
const currentMember = currentMemberRows[0]
if (!currentMember) {
return null
}
await ensureDefaultDynamicRoles(organization.id)
const members = await db
.select({
id: MemberTable.id,
userId: MemberTable.userId,
role: MemberTable.role,
createdAt: MemberTable.createdAt,
user: {
id: AuthUserTable.id,
email: AuthUserTable.email,
name: AuthUserTable.name,
image: AuthUserTable.image,
},
})
.from(MemberTable)
.innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id))
.where(eq(MemberTable.organizationId, organization.id))
.orderBy(asc(MemberTable.createdAt))
const invitations = await db
.select({
id: InvitationTable.id,
email: InvitationTable.email,
role: InvitationTable.role,
status: InvitationTable.status,
expiresAt: InvitationTable.expiresAt,
createdAt: InvitationTable.createdAt,
})
.from(InvitationTable)
.where(eq(InvitationTable.organizationId, organization.id))
.orderBy(asc(InvitationTable.createdAt))
const dynamicRoles = await db
.select()
.from(OrganizationRoleTable)
.where(eq(OrganizationRoleTable.organizationId, organization.id))
.orderBy(asc(OrganizationRoleTable.createdAt))
const builtInDynamicRoleNames = new Set(Object.keys(denDefaultDynamicOrganizationRoles))
return {
organization: {
id: organization.id,
name: organization.name,
slug: organization.slug,
logo: organization.logo,
metadata: organization.metadata,
createdAt: organization.createdAt,
updatedAt: organization.updatedAt,
},
currentMember: {
id: currentMember.id,
userId: currentMember.userId,
role: currentMember.role,
createdAt: currentMember.createdAt,
isOwner: roleIncludesOwner(currentMember.role),
},
members: members.map((member) => ({
...member,
isOwner: roleIncludesOwner(member.role),
})),
invitations,
roles: [
{
id: "builtin-owner",
role: "owner",
permission: denOrganizationStaticRoles.owner.statements,
builtIn: true,
protected: true,
createdAt: null,
updatedAt: null,
},
...dynamicRoles.map((role) => ({
id: role.id,
role: role.role,
permission: parsePermissionRecord(role.permission),
builtIn: builtInDynamicRoleNames.has(role.role),
protected: false,
createdAt: role.createdAt,
updatedAt: role.updatedAt,
})),
],
} satisfies OrganizationContext
}
export async function listTeamsForMember(input: {
organizationId: OrgId
userId: UserId
}) {
return db
.select({
id: TeamTable.id,
name: TeamTable.name,
organizationId: TeamTable.organizationId,
createdAt: TeamTable.createdAt,
updatedAt: TeamTable.updatedAt,
})
.from(TeamMemberTable)
.innerJoin(TeamTable, eq(TeamMemberTable.teamId, TeamTable.id))
.where(and(eq(TeamTable.organizationId, input.organizationId), eq(TeamMemberTable.userId, input.userId)))
.orderBy(asc(TeamTable.createdAt))
}
export async function removeOrganizationMember(input: {
organizationId: OrgId
memberId: MemberRow["id"]
}) {
const memberRows = await db
.select()
.from(MemberTable)
.where(and(eq(MemberTable.id, input.memberId), eq(MemberTable.organizationId, input.organizationId)))
.limit(1)
const member = memberRows[0] ?? null
if (!member) {
return null
}
const teams = await db
.select({ id: TeamTable.id })
.from(TeamTable)
.where(eq(TeamTable.organizationId, input.organizationId))
await db.transaction(async (tx) => {
for (const team of teams) {
await tx
.delete(TeamMemberTable)
.where(and(eq(TeamMemberTable.teamId, team.id), eq(TeamMemberTable.userId, member.userId)))
}
await tx.delete(MemberTable).where(eq(MemberTable.id, member.id))
})
return member
}

View File

@@ -0,0 +1,22 @@
# Routes
This folder groups Den API endpoints by product surface instead of keeping one large router file.
## Layout
- `auth/`: Better Auth mount and desktop handoff routes
- `me/`: current-user routes that describe the signed-in user and their org access
- `org/`: organization routes split into focused files by concern
- `admin/`: admin-only operational endpoints
- `workers/`: worker lifecycle, runtime, billing, and heartbeat routes
## Conventions
- Each route area exports a single `register...Routes()` function from its `index.ts`
- Request validation should use Hono Zod validators from `src/middleware/index.ts`
- Shared auth/org/team context should come from `src/middleware/index.ts`, not from ad hoc request parsing
- New route areas should get their own folder plus a local `README.md`
## Why this exists
Agents often need to change one endpoint family quickly. Keeping route areas isolated makes it easier to understand ownership and avoid accidental cross-surface regressions.

View File

@@ -0,0 +1,21 @@
# Admin Routes
This folder owns admin-only Den API surfaces.
## Files
- `index.ts`: currently registers the admin overview endpoint
## Current routes
- `GET /v1/admin/overview`
## Expectations
- Gate all routes with `requireAdminMiddleware`
- Keep admin reporting logic here instead of mixing it into auth or org routes
- Prefer query validators for report flags such as `includeBilling`
## Notes
This area is intentionally small for now, but it is its own folder so future admin/reporting endpoints have a clear home.

View File

@@ -0,0 +1,293 @@
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 { z } from "zod"
import { getCloudWorkerAdminBillingStatus } from "../../billing/polar.js"
import { db } from "../../db.js"
import { queryValidator, requireAdminMiddleware } from "../../middleware/index.js"
import type { AuthContextVariables } from "../../session.js"
type UserId = typeof AuthUserTable.$inferSelect.id
const overviewQuerySchema = z.object({
includeBilling: z.string().optional(),
})
function normalizeEmail(value: string | null | undefined) {
return value?.trim().toLowerCase() ?? ""
}
function toNumber(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value
}
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function isWithinDays(value: Date | string | null, days: number) {
if (!value) {
return false
}
const date = value instanceof Date ? value : new Date(value)
if (Number.isNaN(date.getTime())) {
return false
}
const windowMs = days * 24 * 60 * 60 * 1000
return Date.now() - date.getTime() <= windowMs
}
function normalizeProvider(providerId: string) {
const normalized = providerId.trim().toLowerCase()
if (!normalized) {
return "unknown"
}
if (normalized === "credential" || normalized === "email-password") {
return "email"
}
return normalized
}
function parseBooleanQuery(value: string | undefined): boolean {
if (!value) {
return false
}
const normalized = value.trim().toLowerCase()
return normalized === "1" || normalized === "true" || normalized === "yes"
}
async function mapWithConcurrency<T, R>(items: T[], limit: number, mapper: (item: T) => Promise<R>) {
if (items.length === 0) {
return [] as R[]
}
const results = new Array<R>(items.length)
let nextIndex = 0
async function runWorker() {
while (nextIndex < items.length) {
const currentIndex = nextIndex
nextIndex += 1
results[currentIndex] = await mapper(items[currentIndex])
}
}
const workerCount = Math.max(1, Math.min(limit, items.length))
await Promise.all(Array.from({ length: workerCount }, () => runWorker()))
return results
}
export function registerAdminRoutes<T extends { Variables: AuthContextVariables }>(app: Hono<T>) {
app.get("/v1/admin/overview", requireAdminMiddleware, queryValidator(overviewQuerySchema), async (c) => {
const user = c.get("user")
const query = c.req.valid("query")
const includeBilling = parseBooleanQuery(query.includeBilling)
const [admins, users, workerStatsRows, sessionStatsRows, accountRows] = await Promise.all([
db
.select({
email: AdminAllowlistTable.email,
note: AdminAllowlistTable.note,
createdAt: AdminAllowlistTable.created_at,
})
.from(AdminAllowlistTable)
.orderBy(asc(AdminAllowlistTable.email)),
db.select().from(AuthUserTable).orderBy(desc(AuthUserTable.createdAt)),
db
.select({
userId: WorkerTable.created_by_user_id,
workerCount: sql<number>`count(*)`,
cloudWorkerCount: sql<number>`sum(case when ${WorkerTable.destination} = 'cloud' then 1 else 0 end)`,
localWorkerCount: sql<number>`sum(case when ${WorkerTable.destination} = 'local' then 1 else 0 end)`,
latestWorkerCreatedAt: sql<Date | null>`max(${WorkerTable.created_at})`,
})
.from(WorkerTable)
.where(isNotNull(WorkerTable.created_by_user_id))
.groupBy(WorkerTable.created_by_user_id),
db
.select({
userId: AuthSessionTable.userId,
sessionCount: sql<number>`count(*)`,
lastSeenAt: sql<Date | null>`max(${AuthSessionTable.updatedAt})`,
})
.from(AuthSessionTable)
.groupBy(AuthSessionTable.userId),
db
.select({
userId: AuthAccountTable.userId,
providerId: AuthAccountTable.providerId,
})
.from(AuthAccountTable),
])
const workerStatsByUser = new Map<UserId, {
workerCount: number
cloudWorkerCount: number
localWorkerCount: number
latestWorkerCreatedAt: Date | string | null
}>()
for (const row of workerStatsRows) {
if (!row.userId) {
continue
}
workerStatsByUser.set(row.userId, {
workerCount: toNumber(row.workerCount),
cloudWorkerCount: toNumber(row.cloudWorkerCount),
localWorkerCount: toNumber(row.localWorkerCount),
latestWorkerCreatedAt: row.latestWorkerCreatedAt,
})
}
const sessionStatsByUser = new Map<UserId, {
sessionCount: number
lastSeenAt: Date | string | null
}>()
for (const row of sessionStatsRows) {
sessionStatsByUser.set(row.userId, {
sessionCount: toNumber(row.sessionCount),
lastSeenAt: row.lastSeenAt,
})
}
const providersByUser = new Map<UserId, Set<string>>()
for (const row of accountRows) {
const providerId = normalizeProvider(row.providerId)
const existing = providersByUser.get(row.userId) ?? new Set<string>()
existing.add(providerId)
providersByUser.set(row.userId, existing)
}
const defaultBilling = {
status: "unavailable" as const,
featureGateEnabled: false,
subscriptionId: null,
subscriptionStatus: null,
currentPeriodEnd: null,
source: "unavailable" as const,
note: "Billing lookup unavailable.",
}
const billingRows = includeBilling
? await mapWithConcurrency(users, 4, async (entry) => ({
userId: entry.id,
billing: await getCloudWorkerAdminBillingStatus({
userId: entry.id,
email: entry.email,
name: entry.name ?? entry.email,
}),
}))
: []
const billingByUser = new Map(billingRows.map((row) => [row.userId, row.billing]))
const userRows = users.map((entry) => {
const workerStats = workerStatsByUser.get(entry.id) ?? {
workerCount: 0,
cloudWorkerCount: 0,
localWorkerCount: 0,
latestWorkerCreatedAt: null,
}
const sessionStats = sessionStatsByUser.get(entry.id) ?? {
sessionCount: 0,
lastSeenAt: null,
}
const authProviders = Array.from(providersByUser.get(entry.id) ?? []).sort()
return {
id: entry.id,
name: entry.name,
email: entry.email,
emailVerified: entry.emailVerified,
createdAt: entry.createdAt,
updatedAt: entry.updatedAt,
lastSeenAt: sessionStats.lastSeenAt,
sessionCount: sessionStats.sessionCount,
authProviders,
workerCount: workerStats.workerCount,
cloudWorkerCount: workerStats.cloudWorkerCount,
localWorkerCount: workerStats.localWorkerCount,
latestWorkerCreatedAt: workerStats.latestWorkerCreatedAt,
billing: includeBilling ? billingByUser.get(entry.id) ?? defaultBilling : null,
}
})
const summary = userRows.reduce(
(accumulator, entry) => {
accumulator.totalUsers += 1
accumulator.totalWorkers += entry.workerCount
accumulator.cloudWorkers += entry.cloudWorkerCount
accumulator.localWorkers += entry.localWorkerCount
if (entry.emailVerified) {
accumulator.verifiedUsers += 1
}
if (entry.workerCount > 0) {
accumulator.usersWithWorkers += 1
}
if (includeBilling && entry.billing) {
if (entry.billing.status === "paid") {
accumulator.paidUsers += 1
} else if (entry.billing.status === "unpaid") {
accumulator.unpaidUsers += 1
} else {
accumulator.billingUnavailableUsers += 1
}
}
if (isWithinDays(entry.createdAt, 7)) {
accumulator.recentUsers7d += 1
}
if (isWithinDays(entry.createdAt, 30)) {
accumulator.recentUsers30d += 1
}
return accumulator
},
{
totalUsers: 0,
verifiedUsers: 0,
recentUsers7d: 0,
recentUsers30d: 0,
totalWorkers: 0,
cloudWorkers: 0,
localWorkers: 0,
usersWithWorkers: 0,
paidUsers: 0,
unpaidUsers: 0,
billingUnavailableUsers: 0,
},
)
return c.json({
viewer: {
id: user.id,
email: normalizeEmail(user.email),
name: user.name,
},
admins,
summary: {
...summary,
adminCount: admins.length,
billingLoaded: includeBilling,
paidUsers: includeBilling ? summary.paidUsers : null,
unpaidUsers: includeBilling ? summary.unpaidUsers : null,
billingUnavailableUsers: includeBilling ? summary.billingUnavailableUsers : null,
usersWithoutWorkers: summary.totalUsers - summary.usersWithWorkers,
},
users: userRows,
generatedAt: new Date().toISOString(),
})
})
}

View File

@@ -0,0 +1,25 @@
# Auth Routes
This folder owns authentication-related HTTP surfaces.
## Files
- `index.ts`: mounts Better Auth at `/api/auth/*` and registers auth-specific route groups
- `desktop-handoff.ts`: desktop sign-in handoff flow under `/v1/auth/desktop-handoff*`
## Current responsibilities
- forward Better Auth requests to `auth.handler(c.req.raw)`
- create short-lived desktop handoff grants
- exchange a valid handoff grant for a session token
## Expected dependencies
- Better Auth configuration from `src/auth.ts`
- shared auth/session middleware from `src/session.ts`
- request validation from `src/middleware/index.ts`
## Notes for future work
- Keep browser auth routes mounted through Better Auth unless there is a strong reason to wrap them
- Put new auth-adjacent custom endpoints in this folder, not in `me/` or `org/`

View File

@@ -0,0 +1,175 @@
import { randomBytes } from "node:crypto"
import { and, eq, gt, isNull } from "@openwork-ee/den-db/drizzle"
import { AuthSessionTable, AuthUserTable, DesktopHandoffGrantTable } from "@openwork-ee/den-db/schema"
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
import { jsonValidator, requireUserMiddleware } from "../../middleware/index.js"
import { db } from "../../db.js"
import type { AuthContextVariables } from "../../session.js"
const createGrantSchema = z.object({
next: z.string().trim().max(128).optional(),
desktopScheme: z.string().trim().max(32).optional(),
})
const exchangeGrantSchema = z.object({
grant: z.string().trim().min(12).max(128),
})
function readSingleHeader(value: string | null) {
const first = value?.split(",")[0]?.trim() ?? ""
return first || null
}
function isWebAppHost(hostname: string) {
const normalized = hostname.trim().toLowerCase()
return normalized === "app.openworklabs.com"
|| normalized === "app.openwork.software"
|| normalized.startsWith("app.")
}
function withDenProxyPath(origin: string) {
const url = new URL(origin)
const pathname = url.pathname.replace(/\/+$/, "")
if (pathname.toLowerCase().endsWith("/api/den")) {
return url.toString().replace(/\/+$/, "")
}
url.pathname = `${pathname}/api/den`.replace(/\/+/g, "/")
return url.toString().replace(/\/+$/, "")
}
function resolveDesktopDenBaseUrl(request: Request) {
const originHeader = readSingleHeader(request.headers.get("origin"))
if (originHeader) {
try {
const originUrl = new URL(originHeader)
if ((originUrl.protocol === "https:" || originUrl.protocol === "http:") && isWebAppHost(originUrl.hostname)) {
return withDenProxyPath(originUrl.origin)
}
} catch {
// Ignore invalid origins.
}
}
const forwardedProto = readSingleHeader(request.headers.get("x-forwarded-proto"))
const forwardedHost = readSingleHeader(request.headers.get("x-forwarded-host"))
const host = readSingleHeader(request.headers.get("host"))
const protocol = forwardedProto ?? new URL(request.url).protocol.replace(/:$/, "")
const targetHost = forwardedHost ?? host
if (!targetHost) {
return "https://app.openworklabs.com/api/den"
}
const origin = `${protocol}://${targetHost}`
try {
const url = new URL(origin)
if (isWebAppHost(url.hostname)) {
return withDenProxyPath(url.origin)
}
} catch {
// Ignore invalid forwarded origins.
}
return origin
}
function buildOpenworkDeepLink(input: {
scheme?: string | null
grant: string
denBaseUrl: string
}) {
const requestedScheme = input.scheme?.trim() || "openwork"
const scheme = /^[a-z][a-z0-9+.-]*$/i.test(requestedScheme)
? requestedScheme
: "openwork"
const url = new URL(`${scheme}://den-auth`)
url.searchParams.set("grant", input.grant)
url.searchParams.set("denBaseUrl", input.denBaseUrl)
return url.toString()
}
export function registerDesktopAuthRoutes<T extends { Variables: AuthContextVariables }>(app: Hono<T>) {
app.post("/v1/auth/desktop-handoff", requireUserMiddleware, jsonValidator(createGrantSchema), async (c) => {
const user = c.get("user")
const session = c.get("session")
if (!user?.id || !session?.token) {
return c.json({ error: "unauthorized" }, 401)
}
const input = c.req.valid("json")
const grant = randomBytes(24).toString("base64url")
const expiresAt = new Date(Date.now() + 5 * 60 * 1000)
await db.insert(DesktopHandoffGrantTable).values({
id: grant,
user_id: normalizeDenTypeId("user", user.id),
session_token: session.token,
expires_at: expiresAt,
consumed_at: null,
})
const denBaseUrl = resolveDesktopDenBaseUrl(c.req.raw)
return c.json({
grant,
expiresAt: expiresAt.toISOString(),
openworkUrl: buildOpenworkDeepLink({
scheme: input.desktopScheme || "openwork",
grant,
denBaseUrl,
}),
})
})
app.post("/v1/auth/desktop-handoff/exchange", jsonValidator(exchangeGrantSchema), async (c) => {
const input = c.req.valid("json")
const now = new Date()
const rows = await db
.select({
grant: DesktopHandoffGrantTable,
session: AuthSessionTable,
user: AuthUserTable,
})
.from(DesktopHandoffGrantTable)
.innerJoin(AuthSessionTable, eq(DesktopHandoffGrantTable.session_token, AuthSessionTable.token))
.innerJoin(AuthUserTable, eq(DesktopHandoffGrantTable.user_id, AuthUserTable.id))
.where(
and(
eq(DesktopHandoffGrantTable.id, input.grant),
isNull(DesktopHandoffGrantTable.consumed_at),
gt(DesktopHandoffGrantTable.expires_at, now),
gt(AuthSessionTable.expiresAt, now),
),
)
.limit(1)
const row = rows[0]
if (!row) {
return c.json({
error: "grant_not_found",
message: "This desktop sign-in link is missing, expired, or already used.",
}, 404)
}
await db
.update(DesktopHandoffGrantTable)
.set({ consumed_at: now })
.where(
and(
eq(DesktopHandoffGrantTable.id, input.grant),
isNull(DesktopHandoffGrantTable.consumed_at),
),
)
return c.json({
token: row.session.token,
user: {
id: row.user.id,
email: row.user.email,
name: row.user.name,
},
})
})
}

View File

@@ -0,0 +1,9 @@
import type { Hono } from "hono"
import { auth } from "../../auth.js"
import type { AuthContextVariables } from "../../session.js"
import { registerDesktopAuthRoutes } from "./desktop-handoff.js"
export function registerAuthRoutes<T extends { Variables: AuthContextVariables }>(app: Hono<T>) {
app.on(["GET", "POST"], "/api/auth/*", (c) => auth.handler(c.req.raw))
registerDesktopAuthRoutes(app)
}

View File

@@ -0,0 +1,23 @@
# Me Routes
This folder owns routes about the currently authenticated user.
## Files
- `index.ts`: registers `/v1/me` and `/v1/me/orgs`
## Current responsibilities
- return the current authenticated user/session payload
- resolve the orgs the current user belongs to
- expose active org selection data for the current session
## Middleware expectations
- use `requireUserMiddleware` when a route needs an authenticated user
- use `resolveUserOrganizationsMiddleware` when a route needs org membership context
## Notes for future work
- Keep this folder focused on the current actor, not arbitrary user admin operations
- If more current-user subareas appear later, split them into additional files inside this folder

View File

@@ -0,0 +1,25 @@
import type { Hono } from "hono"
import { requireUserMiddleware, resolveUserOrganizationsMiddleware, type UserOrganizationsContext } from "../../middleware/index.js"
import type { AuthContextVariables } from "../../session.js"
export function registerMeRoutes<T extends { Variables: AuthContextVariables & Partial<UserOrganizationsContext> }>(app: Hono<T>) {
app.get("/v1/me", requireUserMiddleware, (c) => {
return c.json({
user: c.get("user"),
session: c.get("session"),
})
})
app.get("/v1/me/orgs", resolveUserOrganizationsMiddleware, (c) => {
const orgs = (c.get("userOrganizations") ?? []) as NonNullable<UserOrganizationsContext["userOrganizations"]>
return c.json({
orgs: orgs.map((org) => ({
...org,
isActive: org.id === c.get("activeOrganizationId"),
})),
activeOrgId: c.get("activeOrganizationId") ?? null,
activeOrgSlug: c.get("activeOrganizationSlug") ?? null,
})
})
}

View File

@@ -0,0 +1,31 @@
# Org Routes
This folder owns organization-facing Den API routes.
## Files
- `index.ts`: registers all org route groups
- `core.ts`: org creation, invitation preview/accept, and org context
- `invitations.ts`: invitation creation and cancellation
- `members.ts`: member role updates and member removal
- `roles.ts`: dynamic role CRUD
- `templates.ts`: shared template CRUD
- `shared.ts`: shared route-local helpers, param schemas, and guard helpers
## Middleware expectations
- `requireUserMiddleware`: the route requires a signed-in user
- `resolveOrganizationContextMiddleware`: the route needs the current org and member context
- `resolveMemberTeamsMiddleware`: the route needs the teams for the current org member
Import these from `src/middleware/index.ts` so route files stay consistent.
## Validation expectations
- Query, JSON body, and params should use Hono Zod validators
- Route files should read validated input with `c.req.valid(...)`
- Avoid direct `c.req.param()`, `c.req.query()`, or manual `safeParse()` in route handlers
## Why this is split up
The org surface is the largest migrated area so far. Splitting by concern keeps edits small and lets agents change invitations, members, roles, or templates without scanning one giant router file.

View File

@@ -0,0 +1,111 @@
import { eq } from "@openwork-ee/den-db/drizzle"
import { OrganizationTable } from "@openwork-ee/den-db/schema"
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
import { db } from "../../db.js"
import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveMemberTeamsMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { acceptInvitationForUser, createOrganizationForUser, getInvitationPreview, setSessionActiveOrganization } from "../../orgs.js"
import { getRequiredUserEmail } from "../../user.js"
import type { OrgRouteVariables } from "./shared.js"
import { orgIdParamSchema } from "./shared.js"
const createOrganizationSchema = z.object({
name: z.string().trim().min(2).max(120),
})
const invitationPreviewQuerySchema = z.object({
id: z.string().trim().min(1),
})
const acceptInvitationSchema = z.object({
id: z.string().trim().min(1),
})
export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
app.post("/v1/orgs", requireUserMiddleware, jsonValidator(createOrganizationSchema), async (c) => {
const user = c.get("user")
const session = c.get("session")
const input = c.req.valid("json")
const organizationId = await createOrganizationForUser({
userId: normalizeDenTypeId("user", user.id),
name: input.name,
})
if (session?.id) {
await setSessionActiveOrganization(normalizeDenTypeId("session", session.id), organizationId)
}
const organization = await db
.select()
.from(OrganizationTable)
.where(eq(OrganizationTable.id, organizationId))
.limit(1)
return c.json({ organization: organization[0] ?? null }, 201)
})
app.get("/v1/orgs/invitations/preview", queryValidator(invitationPreviewQuerySchema), async (c) => {
const query = c.req.valid("query")
const invitation = await getInvitationPreview(query.id)
if (!invitation) {
return c.json({ error: "invitation_not_found" }, 404)
}
return c.json(invitation)
})
app.post("/v1/orgs/invitations/accept", requireUserMiddleware, jsonValidator(acceptInvitationSchema), async (c) => {
const user = c.get("user")
const session = c.get("session")
const input = c.req.valid("json")
const email = getRequiredUserEmail(user)
if (!email) {
return c.json({ error: "user_email_required" }, 400)
}
const accepted = await acceptInvitationForUser({
userId: normalizeDenTypeId("user", user.id),
email,
invitationId: input.id,
})
if (!accepted) {
return c.json({ error: "invitation_not_found" }, 404)
}
if (session?.id) {
await setSessionActiveOrganization(normalizeDenTypeId("session", session.id), accepted.member.organizationId)
}
const orgRows = await db
.select({ slug: OrganizationTable.slug })
.from(OrganizationTable)
.where(eq(OrganizationTable.id, accepted.member.organizationId))
.limit(1)
return c.json({
accepted: true,
organizationId: accepted.member.organizationId,
organizationSlug: orgRows[0]?.slug ?? null,
invitationId: accepted.invitation.id,
})
})
app.get(
"/v1/orgs/:orgId/context",
requireUserMiddleware,
paramValidator(orgIdParamSchema),
resolveOrganizationContextMiddleware,
resolveMemberTeamsMiddleware,
(c) => {
return c.json({
...c.get("organizationContext"),
currentMemberTeams: c.get("memberTeams") ?? [],
})
},
)
}

View File

@@ -0,0 +1,15 @@
import type { Hono } from "hono"
import type { OrgRouteVariables } from "./shared.js"
import { registerOrgCoreRoutes } from "./core.js"
import { registerOrgInvitationRoutes } from "./invitations.js"
import { registerOrgMemberRoutes } from "./members.js"
import { registerOrgRoleRoutes } from "./roles.js"
import { registerOrgTemplateRoutes } from "./templates.js"
export function registerOrgRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
registerOrgCoreRoutes(app)
registerOrgInvitationRoutes(app)
registerOrgMemberRoutes(app)
registerOrgRoleRoutes(app)
registerOrgTemplateRoutes(app)
}

View File

@@ -0,0 +1,126 @@
import { and, eq, gt } from "@openwork-ee/den-db/drizzle"
import { AuthUserTable, InvitationTable, MemberTable } from "@openwork-ee/den-db/schema"
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
import { db } from "../../db.js"
import { sendDenOrganizationInvitationEmail } from "../../email.js"
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { listAssignableRoles } from "../../orgs.js"
import type { OrgRouteVariables } from "./shared.js"
import { buildInvitationLink, createInvitationId, ensureInviteManager, idParamSchema, normalizeRoleName, orgIdParamSchema } from "./shared.js"
const inviteMemberSchema = z.object({
email: z.string().email(),
role: z.string().trim().min(1).max(64),
})
type InvitationId = typeof InvitationTable.$inferSelect.id
const orgInvitationParamsSchema = orgIdParamSchema.extend(idParamSchema("invitationId").shape)
export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
app.post("/v1/orgs/:orgId/invitations", requireUserMiddleware, paramValidator(orgIdParamSchema), resolveOrganizationContextMiddleware, jsonValidator(inviteMemberSchema), async (c) => {
const permission = ensureInviteManager(c)
if (!permission.ok) {
return c.json(permission.response, permission.response.error === "forbidden" ? 403 : 404)
}
const payload = c.get("organizationContext")
const user = c.get("user")
const input = c.req.valid("json")
const email = input.email.trim().toLowerCase()
const availableRoles = await listAssignableRoles(payload.organization.id)
const role = normalizeRoleName(input.role)
if (!availableRoles.has(role)) {
return c.json({ error: "invalid_role", message: "Choose one of the existing organization roles." }, 400)
}
const existingMembers = await db
.select({ id: MemberTable.id })
.from(MemberTable)
.innerJoin(AuthUserTable, eq(MemberTable.userId, AuthUserTable.id))
.where(and(eq(MemberTable.organizationId, payload.organization.id), eq(AuthUserTable.email, email)))
.limit(1)
if (existingMembers[0]) {
return c.json({
error: "member_exists",
message: "That email address is already a member of this organization.",
}, 409)
}
const existingInvitation = await db
.select()
.from(InvitationTable)
.where(
and(
eq(InvitationTable.organizationId, payload.organization.id),
eq(InvitationTable.email, email),
eq(InvitationTable.status, "pending"),
gt(InvitationTable.expiresAt, new Date()),
),
)
.limit(1)
const expiresAt = new Date(Date.now() + 1000 * 60 * 60 * 24 * 7)
const invitationId = existingInvitation[0]?.id ?? createInvitationId()
if (existingInvitation[0]) {
await db
.update(InvitationTable)
.set({ role, inviterId: normalizeDenTypeId("user", user.id), expiresAt })
.where(eq(InvitationTable.id, existingInvitation[0].id))
} else {
await db.insert(InvitationTable).values({
id: invitationId,
organizationId: payload.organization.id,
email,
role,
status: "pending",
inviterId: normalizeDenTypeId("user", user.id),
expiresAt,
})
}
await sendDenOrganizationInvitationEmail({
email,
inviteLink: buildInvitationLink(invitationId),
invitedByName: user.name ?? user.email ?? "OpenWork",
invitedByEmail: user.email ?? "",
organizationName: payload.organization.name,
role,
})
return c.json({ invitationId, email, role, expiresAt }, existingInvitation[0] ? 200 : 201)
})
app.post("/v1/orgs/:orgId/invitations/:invitationId/cancel", requireUserMiddleware, paramValidator(orgInvitationParamsSchema), resolveOrganizationContextMiddleware, async (c) => {
const permission = ensureInviteManager(c)
if (!permission.ok) {
return c.json(permission.response, permission.response.error === "forbidden" ? 403 : 404)
}
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let invitationId: InvitationId
try {
invitationId = normalizeDenTypeId("invitation", params.invitationId)
} catch {
return c.json({ error: "invitation_not_found" }, 404)
}
const invitationRows = await db
.select({ id: InvitationTable.id })
.from(InvitationTable)
.where(and(eq(InvitationTable.id, invitationId), eq(InvitationTable.organizationId, payload.organization.id)))
.limit(1)
if (!invitationRows[0]) {
return c.json({ error: "invitation_not_found" }, 404)
}
await db.update(InvitationTable).set({ status: "canceled" }).where(eq(InvitationTable.id, invitationId))
return c.json({ success: true })
})
}

View File

@@ -0,0 +1,98 @@
import { and, eq } from "@openwork-ee/den-db/drizzle"
import { MemberTable } from "@openwork-ee/den-db/schema"
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
import { db } from "../../db.js"
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { listAssignableRoles, removeOrganizationMember, roleIncludesOwner } from "../../orgs.js"
import type { OrgRouteVariables } from "./shared.js"
import { ensureOwner, idParamSchema, normalizeRoleName, orgIdParamSchema } from "./shared.js"
const updateMemberRoleSchema = z.object({
role: z.string().trim().min(1).max(64),
})
type MemberId = typeof MemberTable.$inferSelect.id
const orgMemberParamsSchema = orgIdParamSchema.extend(idParamSchema("memberId").shape)
export function registerOrgMemberRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
app.post("/v1/orgs/:orgId/members/:memberId/role", requireUserMiddleware, paramValidator(orgMemberParamsSchema), resolveOrganizationContextMiddleware, jsonValidator(updateMemberRoleSchema), async (c) => {
const permission = ensureOwner(c)
if (!permission.ok) {
return c.json(permission.response, 403)
}
const payload = c.get("organizationContext")
const input = c.req.valid("json")
const params = c.req.valid("param")
let memberId: MemberId
try {
memberId = normalizeDenTypeId("member", params.memberId)
} catch {
return c.json({ error: "member_not_found" }, 404)
}
const memberRows = await db
.select()
.from(MemberTable)
.where(and(eq(MemberTable.id, memberId), eq(MemberTable.organizationId, payload.organization.id)))
.limit(1)
const member = memberRows[0]
if (!member) {
return c.json({ error: "member_not_found" }, 404)
}
if (roleIncludesOwner(member.role)) {
return c.json({ error: "owner_role_locked", message: "The organization owner role cannot be changed." }, 400)
}
const role = normalizeRoleName(input.role)
const availableRoles = await listAssignableRoles(payload.organization.id)
if (!availableRoles.has(role)) {
return c.json({ error: "invalid_role", message: "Choose one of the existing organization roles." }, 400)
}
await db.update(MemberTable).set({ role }).where(eq(MemberTable.id, member.id))
return c.json({ success: true })
})
app.delete("/v1/orgs/:orgId/members/:memberId", requireUserMiddleware, paramValidator(orgMemberParamsSchema), resolveOrganizationContextMiddleware, async (c) => {
const permission = ensureOwner(c)
if (!permission.ok) {
return c.json(permission.response, 403)
}
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let memberId: MemberId
try {
memberId = normalizeDenTypeId("member", params.memberId)
} catch {
return c.json({ error: "member_not_found" }, 404)
}
const memberRows = await db
.select()
.from(MemberTable)
.where(and(eq(MemberTable.id, memberId), eq(MemberTable.organizationId, payload.organization.id)))
.limit(1)
const member = memberRows[0]
if (!member) {
return c.json({ error: "member_not_found" }, 404)
}
if (roleIncludesOwner(member.role)) {
return c.json({ error: "owner_role_locked", message: "The organization owner cannot be removed." }, 400)
}
await removeOrganizationMember({
organizationId: payload.organization.id,
memberId: member.id,
})
return c.body(null, 204)
})
}

View File

@@ -0,0 +1,200 @@
import { and, eq } from "@openwork-ee/den-db/drizzle"
import { InvitationTable, MemberTable, OrganizationRoleTable } from "@openwork-ee/den-db/schema"
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
import { db } from "../../db.js"
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import { serializePermissionRecord } from "../../orgs.js"
import type { OrgRouteVariables } from "./shared.js"
import { createRoleId, ensureOwner, idParamSchema, normalizeRoleName, orgIdParamSchema, replaceRoleValue, splitRoles } from "./shared.js"
const permissionSchema = z.record(z.string(), z.array(z.string()))
const createRoleSchema = z.object({
roleName: z.string().trim().min(2).max(64),
permission: permissionSchema,
})
const updateRoleSchema = z.object({
roleName: z.string().trim().min(2).max(64).optional(),
permission: permissionSchema.optional(),
})
type OrganizationRoleId = typeof OrganizationRoleTable.$inferSelect.id
const orgRoleParamsSchema = orgIdParamSchema.extend(idParamSchema("roleId").shape)
export function registerOrgRoleRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
app.post("/v1/orgs/:orgId/roles", requireUserMiddleware, paramValidator(orgIdParamSchema), resolveOrganizationContextMiddleware, jsonValidator(createRoleSchema), async (c) => {
const permission = ensureOwner(c)
if (!permission.ok) {
return c.json(permission.response, 403)
}
const payload = c.get("organizationContext")
const input = c.req.valid("json")
const roleName = normalizeRoleName(input.roleName)
if (roleName === "owner") {
return c.json({ error: "invalid_role", message: "Owner is managed by the system." }, 400)
}
const existingByName = await db
.select({ id: OrganizationRoleTable.id })
.from(OrganizationRoleTable)
.where(and(eq(OrganizationRoleTable.organizationId, payload.organization.id), eq(OrganizationRoleTable.role, roleName)))
.limit(1)
if (existingByName[0]) {
return c.json({ error: "role_exists", message: "That role already exists in this organization." }, 409)
}
await db.insert(OrganizationRoleTable).values({
id: createRoleId(),
organizationId: payload.organization.id,
role: roleName,
permission: serializePermissionRecord(input.permission),
})
return c.json({ success: true }, 201)
})
app.patch("/v1/orgs/:orgId/roles/:roleId", requireUserMiddleware, paramValidator(orgRoleParamsSchema), resolveOrganizationContextMiddleware, jsonValidator(updateRoleSchema), async (c) => {
const permission = ensureOwner(c)
if (!permission.ok) {
return c.json(permission.response, 403)
}
const payload = c.get("organizationContext")
const input = c.req.valid("json")
const params = c.req.valid("param")
let roleId: OrganizationRoleId
try {
roleId = normalizeDenTypeId("organizationRole", params.roleId)
} catch {
return c.json({ error: "role_not_found" }, 404)
}
const roleRows = await db
.select()
.from(OrganizationRoleTable)
.where(and(eq(OrganizationRoleTable.id, roleId), eq(OrganizationRoleTable.organizationId, payload.organization.id)))
.limit(1)
const roleRow = roleRows[0]
if (!roleRow) {
return c.json({ error: "role_not_found" }, 404)
}
const nextRoleName = input.roleName ? normalizeRoleName(input.roleName) : roleRow.role
if (nextRoleName === "owner") {
return c.json({ error: "invalid_role", message: "Owner is managed by the system." }, 400)
}
if (nextRoleName !== roleRow.role) {
const duplicate = await db
.select({ id: OrganizationRoleTable.id })
.from(OrganizationRoleTable)
.where(and(eq(OrganizationRoleTable.organizationId, payload.organization.id), eq(OrganizationRoleTable.role, nextRoleName)))
.limit(1)
if (duplicate[0]) {
return c.json({ error: "role_exists", message: "That role name is already in use." }, 409)
}
}
const nextPermission = input.permission ? serializePermissionRecord(input.permission) : roleRow.permission
await db
.update(OrganizationRoleTable)
.set({ role: nextRoleName, permission: nextPermission })
.where(eq(OrganizationRoleTable.id, roleRow.id))
if (nextRoleName !== roleRow.role) {
const members = await db
.select()
.from(MemberTable)
.where(eq(MemberTable.organizationId, payload.organization.id))
for (const member of members) {
if (!splitRoles(member.role).includes(roleRow.role)) {
continue
}
await db
.update(MemberTable)
.set({ role: replaceRoleValue(member.role, roleRow.role, nextRoleName) })
.where(eq(MemberTable.id, member.id))
}
const invitations = await db
.select()
.from(InvitationTable)
.where(eq(InvitationTable.organizationId, payload.organization.id))
for (const invitation of invitations) {
if (!splitRoles(invitation.role).includes(roleRow.role)) {
continue
}
await db
.update(InvitationTable)
.set({ role: replaceRoleValue(invitation.role, roleRow.role, nextRoleName) })
.where(eq(InvitationTable.id, invitation.id))
}
}
return c.json({ success: true })
})
app.delete("/v1/orgs/:orgId/roles/:roleId", requireUserMiddleware, paramValidator(orgRoleParamsSchema), resolveOrganizationContextMiddleware, async (c) => {
const permission = ensureOwner(c)
if (!permission.ok) {
return c.json(permission.response, 403)
}
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let roleId: OrganizationRoleId
try {
roleId = normalizeDenTypeId("organizationRole", params.roleId)
} catch {
return c.json({ error: "role_not_found" }, 404)
}
const roleRows = await db
.select()
.from(OrganizationRoleTable)
.where(and(eq(OrganizationRoleTable.id, roleId), eq(OrganizationRoleTable.organizationId, payload.organization.id)))
.limit(1)
const roleRow = roleRows[0]
if (!roleRow) {
return c.json({ error: "role_not_found" }, 404)
}
const membersUsingRole = await db
.select({ role: MemberTable.role })
.from(MemberTable)
.where(eq(MemberTable.organizationId, payload.organization.id))
if (membersUsingRole.some((member) => splitRoles(member.role).includes(roleRow.role))) {
return c.json({ error: "role_in_use", message: "Update members using this role before deleting it." }, 400)
}
const invitationsUsingRole = await db
.select({ role: InvitationTable.role })
.from(InvitationTable)
.where(eq(InvitationTable.organizationId, payload.organization.id))
if (invitationsUsingRole.some((invitation) => splitRoles(invitation.role).includes(roleRow.role))) {
return c.json({
error: "role_in_use",
message: "Cancel or update pending invitations using this role before deleting it.",
}, 400)
}
await db.delete(OrganizationRoleTable).where(eq(OrganizationRoleTable.id, roleRow.id))
return c.body(null, 204)
})
}

View File

@@ -0,0 +1,113 @@
import { createDenTypeId } from "@openwork-ee/utils/typeid"
import { z } from "zod"
import type { MemberTeamsContext, OrganizationContextVariables, UserOrganizationsContext } from "../../middleware/index.js"
import { env } from "../../env.js"
import type { AuthContextVariables } from "../../session.js"
export type OrgRouteVariables =
& AuthContextVariables
& Partial<UserOrganizationsContext>
& Partial<OrganizationContextVariables>
& Partial<MemberTeamsContext>
export const orgIdParamSchema = z.object({
orgId: z.string().trim().min(1).max(255),
})
export function idParamSchema<K extends string>(key: K) {
return z.object({
[key]: z.string().trim().min(1).max(255),
} as Record<K, z.ZodString>)
}
export function splitRoles(value: string) {
return value
.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
}
export function memberHasRole(value: string, role: string) {
return splitRoles(value).includes(role)
}
export function normalizeRoleName(value: string) {
return value
.trim()
.toLowerCase()
.replace(/\s+/g, "-")
}
export function replaceRoleValue(value: string, previousRole: string, nextRole: string | null) {
const existing = splitRoles(value)
const remaining = existing.filter((role) => role !== previousRole)
if (nextRole && !remaining.includes(nextRole)) {
remaining.push(nextRole)
}
return remaining[0] ? remaining.join(",") : "member"
}
export function getInvitationOrigin() {
return env.betterAuthTrustedOrigins.find((origin) => origin !== "*") ?? env.betterAuthUrl
}
export function buildInvitationLink(invitationId: string) {
return new URL(`/join-org?invite=${encodeURIComponent(invitationId)}`, getInvitationOrigin()).toString()
}
export function parseTemplateJson(value: string) {
try {
return JSON.parse(value)
} catch {
return null
}
}
export function ensureOwner(c: { get: (key: "organizationContext") => OrgRouteVariables["organizationContext"] }) {
const payload = c.get("organizationContext")
if (!payload?.currentMember.isOwner) {
return {
ok: false as const,
response: {
error: "forbidden",
message: "Only organization owners can manage members and roles.",
},
}
}
return { ok: true as const }
}
export function ensureInviteManager(c: { get: (key: "organizationContext") => OrgRouteVariables["organizationContext"] }) {
const payload = c.get("organizationContext")
if (!payload) {
return {
ok: false as const,
response: {
error: "organization_not_found",
},
}
}
if (payload.currentMember.isOwner || memberHasRole(payload.currentMember.role, "admin")) {
return { ok: true as const }
}
return {
ok: false as const,
response: {
error: "forbidden",
message: "Only organization owners and admins can invite members.",
},
}
}
export function createInvitationId() {
return createDenTypeId("invitation")
}
export function createRoleId() {
return createDenTypeId("organizationRole")
}

View File

@@ -0,0 +1,142 @@
import { and, desc, eq } from "@openwork-ee/den-db/drizzle"
import { AuthUserTable, MemberTable, TempTemplateSharingTable } from "@openwork-ee/den-db/schema"
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { z } from "zod"
import { db } from "../../db.js"
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
import type { OrgRouteVariables } from "./shared.js"
import { idParamSchema, orgIdParamSchema, parseTemplateJson } from "./shared.js"
const createTemplateSchema = z.object({
name: z.string().trim().min(1).max(255),
templateData: z.unknown(),
})
type TemplateSharingId = typeof TempTemplateSharingTable.$inferSelect.id
const orgTemplateParamsSchema = orgIdParamSchema.extend(idParamSchema("templateId").shape)
export function registerOrgTemplateRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
app.post("/v1/orgs/:orgId/templates", requireUserMiddleware, paramValidator(orgIdParamSchema), resolveOrganizationContextMiddleware, jsonValidator(createTemplateSchema), async (c) => {
const payload = c.get("organizationContext")
const user = c.get("user")
const input = c.req.valid("json")
const templateId = createDenTypeId("tempTemplateSharing")
const now = new Date()
await db.insert(TempTemplateSharingTable).values({
id: templateId,
organizationId: payload.organization.id,
creatorMemberId: payload.currentMember.id,
creatorUserId: normalizeDenTypeId("user", user.id),
name: input.name,
templateJson: JSON.stringify(input.templateData),
createdAt: now,
updatedAt: now,
})
return c.json({
template: {
id: templateId,
name: input.name,
templateData: input.templateData,
createdAt: now,
updatedAt: now,
organizationId: payload.organization.id,
creator: {
memberId: payload.currentMember.id,
userId: user.id,
role: payload.currentMember.role,
name: user.name,
email: user.email,
},
},
}, 201)
})
app.get("/v1/orgs/:orgId/templates", requireUserMiddleware, paramValidator(orgIdParamSchema), resolveOrganizationContextMiddleware, async (c) => {
const payload = c.get("organizationContext")
const templates = await db
.select({
template: {
id: TempTemplateSharingTable.id,
organizationId: TempTemplateSharingTable.organizationId,
name: TempTemplateSharingTable.name,
templateJson: TempTemplateSharingTable.templateJson,
createdAt: TempTemplateSharingTable.createdAt,
updatedAt: TempTemplateSharingTable.updatedAt,
},
creatorMember: {
id: MemberTable.id,
role: MemberTable.role,
},
creatorUser: {
id: AuthUserTable.id,
name: AuthUserTable.name,
email: AuthUserTable.email,
image: AuthUserTable.image,
},
})
.from(TempTemplateSharingTable)
.innerJoin(MemberTable, eq(TempTemplateSharingTable.creatorMemberId, MemberTable.id))
.innerJoin(AuthUserTable, eq(TempTemplateSharingTable.creatorUserId, AuthUserTable.id))
.where(eq(TempTemplateSharingTable.organizationId, payload.organization.id))
.orderBy(desc(TempTemplateSharingTable.createdAt))
return c.json({
templates: templates.map((row) => ({
id: row.template.id,
organizationId: row.template.organizationId,
name: row.template.name,
templateData: parseTemplateJson(row.template.templateJson),
createdAt: row.template.createdAt,
updatedAt: row.template.updatedAt,
creator: {
memberId: row.creatorMember.id,
role: row.creatorMember.role,
userId: row.creatorUser.id,
name: row.creatorUser.name,
email: row.creatorUser.email,
image: row.creatorUser.image,
},
})),
})
})
app.delete("/v1/orgs/:orgId/templates/:templateId", requireUserMiddleware, paramValidator(orgTemplateParamsSchema), resolveOrganizationContextMiddleware, async (c) => {
const payload = c.get("organizationContext")
const params = c.req.valid("param")
let templateId: TemplateSharingId
try {
templateId = normalizeDenTypeId("tempTemplateSharing", params.templateId)
} catch {
return c.json({ error: "template_not_found" }, 404)
}
const templateRows = await db
.select()
.from(TempTemplateSharingTable)
.where(and(eq(TempTemplateSharingTable.id, templateId), eq(TempTemplateSharingTable.organizationId, payload.organization.id)))
.limit(1)
const template = templateRows[0]
if (!template) {
return c.json({ error: "template_not_found" }, 404)
}
const isOwner = payload.currentMember.isOwner
const isCreator = template.creatorMemberId === payload.currentMember.id
if (!isOwner && !isCreator) {
return c.json({
error: "forbidden",
message: "Only the template creator or organization owner can delete templates.",
}, 403)
}
await db.delete(TempTemplateSharingTable).where(eq(TempTemplateSharingTable.id, template.id))
return c.body(null, 204)
})
}

View File

@@ -0,0 +1,24 @@
# Worker Routes
This folder owns worker lifecycle, runtime, billing, and heartbeat routes.
## Files
- `index.ts`: registers all worker route groups
- `activity.ts`: unauthenticated worker heartbeat endpoint authenticated by worker activity token
- `billing.ts`: user-facing cloud worker billing endpoints
- `core.ts`: list/create/get/update/delete worker routes and token lookup
- `runtime.ts`: worker runtime inspection and upgrade passthrough endpoints
- `shared.ts`: worker schemas, helper functions, response mapping, and shared DB/runtime utilities
## Middleware expectations
- Most worker routes use `requireUserMiddleware`
- Org-scoped worker routes should use `resolveUserOrganizationsMiddleware` to determine the current active org
- Request payloads, params, and query flags should use Hono Zod validators from `src/middleware/index.ts`
## Notes
- Activity heartbeat is the exception: it uses worker tokens instead of user auth
- Runtime endpoints proxy to the worker runtime using stored host tokens and instance URLs
- Provisioning logic lives in `src/workers/`, not in the route handlers themselves

View File

@@ -0,0 +1,90 @@
import { and, eq, isNull } from "@openwork-ee/den-db/drizzle"
import { WorkerTable, WorkerTokenTable } from "@openwork-ee/den-db/schema"
import type { Hono } from "hono"
import { db } from "../../db.js"
import { jsonValidator, paramValidator } from "../../middleware/index.js"
import {
activityHeartbeatSchema,
newerDate,
parseHeartbeatTimestamp,
parseWorkerIdParam,
readBearerToken,
workerIdParamSchema,
type WorkerRouteVariables,
} from "./shared.js"
export function registerWorkerActivityRoutes<T extends { Variables: WorkerRouteVariables }>(app: Hono<T>) {
app.post("/v1/workers/:id/activity-heartbeat", paramValidator(workerIdParamSchema), jsonValidator(activityHeartbeatSchema), async (c) => {
const params = c.req.valid("param")
const body = c.req.valid("json")
let workerId
try {
workerId = parseWorkerIdParam(params.id)
} catch {
return c.json({ error: "worker_not_found" }, 404)
}
const authorization =
readBearerToken(c.req.header("authorization") ?? undefined) ??
(c.req.header("x-den-worker-heartbeat-token")?.trim() || null)
if (!authorization) {
return c.json({ error: "unauthorized" }, 401)
}
const tokenRows = await db
.select({ id: WorkerTokenTable.id })
.from(WorkerTokenTable)
.where(
and(
eq(WorkerTokenTable.worker_id, workerId),
eq(WorkerTokenTable.scope, "activity"),
eq(WorkerTokenTable.token, authorization),
isNull(WorkerTokenTable.revoked_at),
),
)
.limit(1)
if (tokenRows.length === 0) {
return c.json({ error: "unauthorized" }, 401)
}
const workerRows = await db
.select()
.from(WorkerTable)
.where(eq(WorkerTable.id, workerId))
.limit(1)
const worker = workerRows[0]
if (!worker) {
return c.json({ error: "worker_not_found" }, 404)
}
const heartbeatAt = parseHeartbeatTimestamp(body.sentAt) ?? new Date()
const requestedActivityAt = parseHeartbeatTimestamp(body.lastActivityAt ?? null)
const activityAt = body.isActiveRecently ? (requestedActivityAt ?? heartbeatAt) : null
const nextHeartbeatAt = newerDate(worker.last_heartbeat_at, heartbeatAt)
const nextActiveAt = body.isActiveRecently
? newerDate(worker.last_active_at, activityAt)
: worker.last_active_at
await db
.update(WorkerTable)
.set({
last_heartbeat_at: nextHeartbeatAt,
last_active_at: nextActiveAt,
})
.where(eq(WorkerTable.id, workerId))
return c.json({
ok: true,
workerId,
isActiveRecently: body.isActiveRecently,
openSessionCount: body.openSessionCount ?? null,
lastHeartbeatAt: nextHeartbeatAt,
lastActiveAt: nextActiveAt,
})
})
}

View File

@@ -0,0 +1,71 @@
import type { Hono } from "hono"
import { env } from "../../env.js"
import { jsonValidator, queryValidator, requireUserMiddleware } from "../../middleware/index.js"
import { getRequiredUserEmail } from "../../user.js"
import type { WorkerRouteVariables } from "./shared.js"
import { billingQuerySchema, billingSubscriptionSchema, getWorkerBilling, setWorkerBillingSubscription, queryIncludesFlag } from "./shared.js"
export function registerWorkerBillingRoutes<T extends { Variables: WorkerRouteVariables }>(app: Hono<T>) {
app.get("/v1/workers/billing", requireUserMiddleware, queryValidator(billingQuerySchema), async (c) => {
const user = c.get("user")
const query = c.req.valid("query")
const email = getRequiredUserEmail(user)
if (!email) {
return c.json({ error: "user_email_required" }, 400)
}
const billing = await getWorkerBilling({
userId: user.id,
email,
name: user.name ?? user.email ?? "OpenWork User",
includeCheckoutUrl: queryIncludesFlag(query.includeCheckout),
includePortalUrl: !queryIncludesFlag(query.excludePortal),
includeInvoices: !queryIncludesFlag(query.excludeInvoices),
})
return c.json({
billing: {
...billing,
productId: env.polar.productId,
benefitId: env.polar.benefitId,
},
})
})
app.post("/v1/workers/billing/subscription", requireUserMiddleware, jsonValidator(billingSubscriptionSchema), async (c) => {
const user = c.get("user")
const input = c.req.valid("json")
const email = getRequiredUserEmail(user)
if (!email) {
return c.json({ error: "user_email_required" }, 400)
}
const billingInput = {
userId: user.id,
email,
name: user.name ?? user.email ?? "OpenWork User",
}
const subscription = await setWorkerBillingSubscription({
...billingInput,
cancelAtPeriodEnd: input.cancelAtPeriodEnd,
})
const billing = await getWorkerBilling({
...billingInput,
includeCheckoutUrl: false,
includePortalUrl: true,
includeInvoices: true,
})
return c.json({
subscription,
billing: {
...billing,
productId: env.polar.productId,
benefitId: env.polar.benefitId,
},
})
})
}

View File

@@ -0,0 +1,303 @@
import { desc, eq } from "@openwork-ee/den-db/drizzle"
import { WorkerTable, WorkerTokenTable } from "@openwork-ee/den-db/schema"
import { createDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { db } from "../../db.js"
import { env } from "../../env.js"
import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveUserOrganizationsMiddleware } from "../../middleware/index.js"
import { getRequiredUserEmail } from "../../user.js"
import type { WorkerRouteVariables } from "./shared.js"
import {
continueCloudProvisioning,
countUserCloudWorkers,
createWorkerSchema,
deleteWorkerCascade,
getLatestWorkerInstance,
getWorkerByIdForOrg,
getWorkerTokensAndConnect,
listWorkersQuerySchema,
parseWorkerIdParam,
requireCloudAccessOrPayment,
toInstanceResponse,
toWorkerResponse,
token,
updateWorkerSchema,
workerIdParamSchema,
} from "./shared.js"
export function registerWorkerCoreRoutes<T extends { Variables: WorkerRouteVariables }>(app: Hono<T>) {
app.get("/v1/workers", requireUserMiddleware, resolveUserOrganizationsMiddleware, queryValidator(listWorkersQuerySchema), async (c) => {
const user = c.get("user")
const orgId = c.get("activeOrganizationId")
const query = c.req.valid("query")
if (!orgId) {
return c.json({ workers: [] })
}
const rows = await db
.select()
.from(WorkerTable)
.where(eq(WorkerTable.org_id, orgId))
.orderBy(desc(WorkerTable.created_at))
.limit(query.limit)
const workers = await Promise.all(
rows.map(async (row) => {
const instance = await getLatestWorkerInstance(row.id)
return {
...toWorkerResponse(row, user.id),
instance: toInstanceResponse(instance),
}
}),
)
return c.json({ workers })
})
app.post("/v1/workers", requireUserMiddleware, resolveUserOrganizationsMiddleware, jsonValidator(createWorkerSchema), async (c) => {
const user = c.get("user")
const orgId = c.get("activeOrganizationId")
const input = c.req.valid("json")
if (!orgId) {
return c.json({ error: "organization_unavailable" }, 400)
}
if (input.destination === "local" && !input.workspacePath) {
return c.json({ error: "workspace_path_required" }, 400)
}
if (input.destination === "cloud" && !env.devMode && (await countUserCloudWorkers(user.id)) > 0) {
const email = getRequiredUserEmail(user)
if (!email) {
return c.json({ error: "user_email_required" }, 400)
}
const access = await requireCloudAccessOrPayment({
userId: user.id,
email,
name: user.name ?? user.email ?? "OpenWork User",
})
if (!access.allowed) {
return c.json({
error: "payment_required",
message: "Additional cloud workers require an active Den Cloud plan.",
polar: {
checkoutUrl: access.checkoutUrl,
productId: env.polar.productId,
benefitId: env.polar.benefitId,
},
}, 402)
}
}
const workerId = createDenTypeId("worker")
const workerStatus = input.destination === "cloud" ? "provisioning" : "healthy"
await db.insert(WorkerTable).values({
id: workerId,
org_id: orgId,
created_by_user_id: user.id,
name: input.name,
description: input.description,
destination: input.destination,
status: workerStatus,
image_version: input.imageVersion,
workspace_path: input.workspacePath,
sandbox_backend: input.sandboxBackend,
})
const hostToken = token()
const clientToken = token()
const activityToken = token()
await db.insert(WorkerTokenTable).values([
{
id: createDenTypeId("workerToken"),
worker_id: workerId,
scope: "host",
token: hostToken,
},
{
id: createDenTypeId("workerToken"),
worker_id: workerId,
scope: "client",
token: clientToken,
},
{
id: createDenTypeId("workerToken"),
worker_id: workerId,
scope: "activity",
token: activityToken,
},
])
if (input.destination === "cloud") {
void continueCloudProvisioning({
workerId,
name: input.name,
hostToken,
clientToken,
activityToken,
})
}
return c.json({
worker: toWorkerResponse(
{
id: workerId,
org_id: orgId,
created_by_user_id: user.id,
name: input.name,
description: input.description ?? null,
destination: input.destination,
status: workerStatus,
image_version: input.imageVersion ?? null,
workspace_path: input.workspacePath ?? null,
sandbox_backend: input.sandboxBackend ?? null,
last_heartbeat_at: null,
last_active_at: null,
created_at: new Date(),
updated_at: new Date(),
},
user.id,
),
tokens: {
owner: hostToken,
host: hostToken,
client: clientToken,
},
instance: null,
launch: input.destination === "cloud" ? { mode: "async", pollAfterMs: 5000 } : { mode: "instant", pollAfterMs: 0 },
}, input.destination === "cloud" ? 202 : 201)
})
app.get("/v1/workers/:id", requireUserMiddleware, resolveUserOrganizationsMiddleware, paramValidator(workerIdParamSchema), async (c) => {
const user = c.get("user")
const orgId = c.get("activeOrganizationId")
const params = c.req.valid("param")
if (!orgId) {
return c.json({ error: "worker_not_found" }, 404)
}
let workerId
try {
workerId = parseWorkerIdParam(params.id)
} catch {
return c.json({ error: "worker_not_found" }, 404)
}
const worker = await getWorkerByIdForOrg(workerId, orgId)
if (!worker) {
return c.json({ error: "worker_not_found" }, 404)
}
const instance = await getLatestWorkerInstance(worker.id)
return c.json({
worker: toWorkerResponse(worker, user.id),
instance: toInstanceResponse(instance),
})
})
app.patch("/v1/workers/:id", requireUserMiddleware, resolveUserOrganizationsMiddleware, paramValidator(workerIdParamSchema), jsonValidator(updateWorkerSchema), async (c) => {
const user = c.get("user")
const orgId = c.get("activeOrganizationId")
const params = c.req.valid("param")
const input = c.req.valid("json")
if (!orgId) {
return c.json({ error: "worker_not_found" }, 404)
}
let workerId
try {
workerId = parseWorkerIdParam(params.id)
} catch {
return c.json({ error: "worker_not_found" }, 404)
}
const worker = await getWorkerByIdForOrg(workerId, orgId)
if (!worker) {
return c.json({ error: "worker_not_found" }, 404)
}
if (worker.created_by_user_id !== user.id) {
return c.json({
error: "forbidden",
message: "Only the worker owner can rename this sandbox.",
}, 403)
}
await db.update(WorkerTable).set({ name: input.name }).where(eq(WorkerTable.id, workerId))
return c.json({
worker: toWorkerResponse(
{
...worker,
name: input.name,
updated_at: new Date(),
},
user.id,
),
})
})
app.post("/v1/workers/:id/tokens", requireUserMiddleware, resolveUserOrganizationsMiddleware, paramValidator(workerIdParamSchema), async (c) => {
const orgId = c.get("activeOrganizationId")
const params = c.req.valid("param")
if (!orgId) {
return c.json({ error: "worker_not_found" }, 404)
}
let workerId
try {
workerId = parseWorkerIdParam(params.id)
} catch {
return c.json({ error: "worker_not_found" }, 404)
}
const worker = await getWorkerByIdForOrg(workerId, orgId)
if (!worker) {
return c.json({ error: "worker_not_found" }, 404)
}
const resolved = await getWorkerTokensAndConnect(worker)
if ("error" in resolved && resolved.error) {
return new Response(JSON.stringify(resolved.error.body), {
status: resolved.error.status,
headers: {
"Content-Type": "application/json",
},
})
}
return c.json(resolved)
})
app.delete("/v1/workers/:id", requireUserMiddleware, resolveUserOrganizationsMiddleware, paramValidator(workerIdParamSchema), async (c) => {
const orgId = c.get("activeOrganizationId")
const params = c.req.valid("param")
if (!orgId) {
return c.json({ error: "worker_not_found" }, 404)
}
let workerId
try {
workerId = parseWorkerIdParam(params.id)
} catch {
return c.json({ error: "worker_not_found" }, 404)
}
const worker = await getWorkerByIdForOrg(workerId, orgId)
if (!worker) {
return c.json({ error: "worker_not_found" }, 404)
}
await deleteWorkerCascade(worker)
return c.body(null, 204)
})
}

View File

@@ -0,0 +1,13 @@
import type { Hono } from "hono"
import type { WorkerRouteVariables } from "./shared.js"
import { registerWorkerActivityRoutes } from "./activity.js"
import { registerWorkerBillingRoutes } from "./billing.js"
import { registerWorkerCoreRoutes } from "./core.js"
import { registerWorkerRuntimeRoutes } from "./runtime.js"
export function registerWorkerRoutes<T extends { Variables: WorkerRouteVariables }>(app: Hono<T>) {
registerWorkerActivityRoutes(app)
registerWorkerBillingRoutes(app)
registerWorkerCoreRoutes(app)
registerWorkerRuntimeRoutes(app)
}

View File

@@ -0,0 +1,76 @@
import type { Hono } from "hono"
import { z } from "zod"
import { jsonValidator, paramValidator, requireUserMiddleware, resolveUserOrganizationsMiddleware } from "../../middleware/index.js"
import type { WorkerRouteVariables } from "./shared.js"
import { fetchWorkerRuntimeJson, getWorkerByIdForOrg, parseWorkerIdParam, workerIdParamSchema } from "./shared.js"
export function registerWorkerRuntimeRoutes<T extends { Variables: WorkerRouteVariables }>(app: Hono<T>) {
app.get("/v1/workers/:id/runtime", requireUserMiddleware, resolveUserOrganizationsMiddleware, paramValidator(workerIdParamSchema), async (c) => {
const orgId = c.get("activeOrganizationId")
const params = c.req.valid("param")
if (!orgId) {
return c.json({ error: "worker_not_found" }, 404)
}
let workerId
try {
workerId = parseWorkerIdParam(params.id)
} catch {
return c.json({ error: "worker_not_found" }, 404)
}
const worker = await getWorkerByIdForOrg(workerId, orgId)
if (!worker) {
return c.json({ error: "worker_not_found" }, 404)
}
const runtime = await fetchWorkerRuntimeJson({
workerId: worker.id,
path: "/runtime/versions",
})
return new Response(JSON.stringify(runtime.payload), {
status: runtime.status,
headers: {
"Content-Type": "application/json",
},
})
})
app.post("/v1/workers/:id/runtime/upgrade", requireUserMiddleware, resolveUserOrganizationsMiddleware, paramValidator(workerIdParamSchema), jsonValidator(z.object({}).passthrough()), async (c) => {
const orgId = c.get("activeOrganizationId")
const params = c.req.valid("param")
const body = c.req.valid("json")
if (!orgId) {
return c.json({ error: "worker_not_found" }, 404)
}
let workerId
try {
workerId = parseWorkerIdParam(params.id)
} catch {
return c.json({ error: "worker_not_found" }, 404)
}
const worker = await getWorkerByIdForOrg(workerId, orgId)
if (!worker) {
return c.json({ error: "worker_not_found" }, 404)
}
const runtime = await fetchWorkerRuntimeJson({
workerId: worker.id,
path: "/runtime/upgrade",
method: "POST",
body,
})
return new Response(JSON.stringify(runtime.payload), {
status: runtime.status,
headers: {
"Content-Type": "application/json",
},
})
})
}

View File

@@ -0,0 +1,494 @@
import { randomBytes } from "node:crypto"
import { and, asc, desc, eq, isNull } from "@openwork-ee/den-db/drizzle"
import {
AuditEventTable,
AuthUserTable,
DaytonaSandboxTable,
OrgMembershipTable,
WorkerBundleTable,
WorkerInstanceTable,
WorkerTable,
WorkerTokenTable,
} from "@openwork-ee/den-db/schema"
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import { z } from "zod"
import { getCloudWorkerBillingStatus, requireCloudWorkerAccess, setCloudWorkerSubscriptionCancellation } from "../../billing/polar.js"
import { db } from "../../db.js"
import { env } from "../../env.js"
import type { UserOrganizationsContext } from "../../middleware/index.js"
import type { AuthContextVariables } from "../../session.js"
import { deprovisionWorker, provisionWorker } from "../../workers/provisioner.js"
import { customDomainForWorker } from "../../workers/vanity-domain.js"
export const createWorkerSchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
destination: z.enum(["local", "cloud"]),
workspacePath: z.string().optional(),
sandboxBackend: z.string().optional(),
imageVersion: z.string().optional(),
})
export const updateWorkerSchema = z.object({
name: z.string().trim().min(1).max(255),
})
export const listWorkersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(50).default(20),
})
export const billingQuerySchema = z.object({
includeCheckout: z.string().optional(),
excludePortal: z.string().optional(),
excludeInvoices: z.string().optional(),
})
export const billingSubscriptionSchema = z.object({
cancelAtPeriodEnd: z.boolean().default(true),
})
export const activityHeartbeatSchema = z.object({
sentAt: z.string().datetime().optional(),
isActiveRecently: z.boolean(),
lastActivityAt: z.string().datetime().optional().nullable(),
openSessionCount: z.number().int().min(0).optional(),
})
export const workerIdParamSchema = z.object({
id: z.string().trim().min(1).max(255),
})
export type WorkerRouteVariables = AuthContextVariables & Partial<UserOrganizationsContext>
type WorkerRow = typeof WorkerTable.$inferSelect
type WorkerInstanceRow = typeof WorkerInstanceTable.$inferSelect
export type WorkerId = WorkerRow["id"]
type OrgId = typeof OrgMembershipTable.$inferSelect.organizationId
type UserId = typeof AuthUserTable.$inferSelect.id
export const token = () => randomBytes(32).toString("hex")
export function parseWorkerIdParam(value: string): WorkerId {
return normalizeDenTypeId("worker", value)
}
export function parseUserId(value: string): UserId {
return normalizeDenTypeId("user", value)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}
function normalizeUrl(value: string): string {
return value.trim().replace(/\/+$/, "")
}
function parseWorkspaceSelection(payload: unknown): { workspaceId: string; openworkUrl: string } | null {
if (!isRecord(payload) || !Array.isArray(payload.items)) {
return null
}
const activeId = typeof payload.activeId === "string" ? payload.activeId : null
let workspaceId = activeId
if (!workspaceId) {
for (const item of payload.items) {
if (isRecord(item) && typeof item.id === "string" && item.id.trim()) {
workspaceId = item.id
break
}
}
}
const baseUrl = typeof payload.baseUrl === "string" ? normalizeUrl(payload.baseUrl) : ""
if (!workspaceId || !baseUrl) {
return null
}
return {
workspaceId,
openworkUrl: `${baseUrl}/w/${encodeURIComponent(workspaceId)}`,
}
}
async function resolveConnectUrlFromWorker(instanceUrl: string, clientToken: string) {
const baseUrl = normalizeUrl(instanceUrl)
if (!baseUrl || !clientToken.trim()) {
return null
}
try {
const response = await fetch(`${baseUrl}/workspaces`, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${clientToken.trim()}`,
},
})
if (!response.ok) {
return null
}
const payload = (await response.json()) as unknown
const selected = parseWorkspaceSelection({
...(isRecord(payload) ? payload : {}),
baseUrl,
})
return selected
} catch {
return null
}
}
function getConnectUrlCandidates(workerId: WorkerId, instanceUrl: string | null) {
const candidates: string[] = []
const vanityHostname = customDomainForWorker(workerId, env.render.workerPublicDomainSuffix)
if (vanityHostname) {
candidates.push(`https://${vanityHostname}`)
}
if (instanceUrl) {
const normalized = normalizeUrl(instanceUrl)
if (normalized && !candidates.includes(normalized)) {
candidates.push(normalized)
}
}
return candidates
}
export function queryIncludesFlag(value: string | undefined): boolean {
if (typeof value !== "string") {
return false
}
const normalized = value.trim().toLowerCase()
return normalized === "1" || normalized === "true" || normalized === "yes"
}
export function readBearerToken(value: string | undefined) {
const trimmed = value?.trim() ?? ""
if (!trimmed.toLowerCase().startsWith("bearer ")) {
return null
}
const tokenValue = trimmed.slice(7).trim()
return tokenValue ? tokenValue : null
}
export function parseHeartbeatTimestamp(value: string | null | undefined) {
if (!value) {
return null
}
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) {
return null
}
return parsed
}
export function newerDate(current: Date | null | undefined, candidate: Date | null | undefined) {
if (!candidate) {
return current ?? null
}
if (!current) {
return candidate
}
return candidate.getTime() > current.getTime() ? candidate : current
}
async function resolveConnectUrlFromCandidates(workerId: WorkerId, instanceUrl: string | null, clientToken: string) {
const candidates = getConnectUrlCandidates(workerId, instanceUrl)
for (const candidate of candidates) {
const resolved = await resolveConnectUrlFromWorker(candidate, clientToken)
if (resolved) {
return resolved
}
}
return null
}
async function getWorkerRuntimeAccess(workerId: WorkerId) {
const instance = await getLatestWorkerInstance(workerId)
const tokenRows = await db
.select()
.from(WorkerTokenTable)
.where(and(eq(WorkerTokenTable.worker_id, workerId), isNull(WorkerTokenTable.revoked_at)))
.orderBy(asc(WorkerTokenTable.created_at))
const hostToken = tokenRows.find((entry) => entry.scope === "host")?.token ?? null
if (!instance?.url || !hostToken) {
return null
}
return {
instance,
hostToken,
candidates: getConnectUrlCandidates(workerId, instance.url),
}
}
export async function fetchWorkerRuntimeJson(input: {
workerId: WorkerId
path: string
method?: "GET" | "POST"
body?: unknown
}) {
const access = await getWorkerRuntimeAccess(input.workerId)
if (!access) {
return {
ok: false as const,
status: 409,
payload: {
error: "worker_runtime_unavailable",
message: "Worker runtime access is not ready yet. Wait for provisioning to finish and try again.",
},
}
}
let lastPayload: unknown = null
let lastStatus = 502
for (const candidate of access.candidates) {
try {
const response = await fetch(`${normalizeUrl(candidate)}${input.path}`, {
method: input.method ?? "GET",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"X-OpenWork-Host-Token": access.hostToken,
},
body: input.body === undefined ? undefined : JSON.stringify(input.body),
})
const text = await response.text()
lastStatus = response.status
try {
lastPayload = text ? JSON.parse(text) : null
} catch {
lastPayload = text ? { message: text } : null
}
if (response.ok) {
return { ok: true as const, status: response.status, payload: lastPayload }
}
} catch (error) {
lastPayload = { message: error instanceof Error ? error.message : "worker_request_failed" }
}
}
return { ok: false as const, status: lastStatus, payload: lastPayload }
}
export async function countUserCloudWorkers(userId: UserId) {
const rows = await db
.select({ id: WorkerTable.id })
.from(WorkerTable)
.where(and(eq(WorkerTable.created_by_user_id, userId), eq(WorkerTable.destination, "cloud")))
.limit(2)
return rows.length
}
export async function getLatestWorkerInstance(workerId: WorkerId) {
const rows = await db
.select()
.from(WorkerInstanceTable)
.where(eq(WorkerInstanceTable.worker_id, workerId))
.orderBy(desc(WorkerInstanceTable.created_at))
.limit(1)
return rows[0] ?? null
}
export function toInstanceResponse(instance: WorkerInstanceRow | null) {
if (!instance) {
return null
}
return {
provider: instance.provider,
region: instance.region,
url: instance.url,
status: instance.status,
createdAt: instance.created_at,
updatedAt: instance.updated_at,
}
}
export function toWorkerResponse(row: WorkerRow, userId: string) {
return {
id: row.id,
orgId: row.org_id,
createdByUserId: row.created_by_user_id,
isMine: row.created_by_user_id === userId,
name: row.name,
description: row.description,
destination: row.destination,
status: row.status,
imageVersion: row.image_version,
workspacePath: row.workspace_path,
sandboxBackend: row.sandbox_backend,
lastHeartbeatAt: row.last_heartbeat_at,
lastActiveAt: row.last_active_at,
createdAt: row.created_at,
updatedAt: row.updated_at,
}
}
export async function continueCloudProvisioning(input: {
workerId: WorkerId
name: string
hostToken: string
clientToken: string
activityToken: string
}) {
try {
const provisioned = await provisionWorker({
workerId: input.workerId,
name: input.name,
hostToken: input.hostToken,
clientToken: input.clientToken,
activityToken: input.activityToken,
})
await db
.update(WorkerTable)
.set({ status: provisioned.status })
.where(eq(WorkerTable.id, input.workerId))
await db.insert(WorkerInstanceTable).values({
id: createDenTypeId("workerInstance"),
worker_id: input.workerId,
provider: provisioned.provider,
region: provisioned.region,
url: provisioned.url,
status: provisioned.status,
})
} catch (error) {
await db
.update(WorkerTable)
.set({ status: "failed" })
.where(eq(WorkerTable.id, input.workerId))
const message = error instanceof Error ? error.message : "provisioning_failed"
console.error(`[workers] provisioning failed for ${input.workerId}: ${message}`)
}
}
export async function requireCloudAccessOrPayment(input: {
userId: UserId
email: string
name: string
}) {
return requireCloudWorkerAccess(input)
}
export async function getWorkerBilling(input: {
userId: UserId
email: string
name: string
includeCheckoutUrl: boolean
includePortalUrl: boolean
includeInvoices: boolean
}) {
return getCloudWorkerBillingStatus(
{
userId: input.userId,
email: input.email,
name: input.name,
},
{
includeCheckoutUrl: input.includeCheckoutUrl,
includePortalUrl: input.includePortalUrl,
includeInvoices: input.includeInvoices,
},
)
}
export async function setWorkerBillingSubscription(input: {
userId: UserId
email: string
name: string
cancelAtPeriodEnd: boolean
}) {
return setCloudWorkerSubscriptionCancellation(
{
userId: input.userId,
email: input.email,
name: input.name,
},
input.cancelAtPeriodEnd,
)
}
export async function getWorkerTokensAndConnect(worker: WorkerRow) {
const tokenRows = await db
.select()
.from(WorkerTokenTable)
.where(and(eq(WorkerTokenTable.worker_id, worker.id), isNull(WorkerTokenTable.revoked_at)))
.orderBy(asc(WorkerTokenTable.created_at))
const hostToken = tokenRows.find((entry) => entry.scope === "host")?.token ?? null
const clientToken = tokenRows.find((entry) => entry.scope === "client")?.token ?? null
if (!hostToken || !clientToken) {
return {
error: {
status: 409,
body: {
error: "worker_tokens_unavailable",
message: "Worker tokens are missing for this worker. Launch a new worker and try again.",
},
},
}
}
const instance = await getLatestWorkerInstance(worker.id)
const connect = await resolveConnectUrlFromCandidates(worker.id, instance?.url ?? null, clientToken)
return {
tokens: {
owner: hostToken,
host: hostToken,
client: clientToken,
},
connect: connect ?? (instance?.url ? { openworkUrl: instance.url, workspaceId: null } : null),
}
}
export async function deleteWorkerCascade(worker: WorkerRow) {
const instance = await getLatestWorkerInstance(worker.id)
if (worker.destination === "cloud") {
try {
await deprovisionWorker({
workerId: worker.id,
instanceUrl: instance?.url ?? null,
})
} catch (error) {
const message = error instanceof Error ? error.message : "deprovision_failed"
console.warn(`[workers] deprovision warning for ${worker.id}: ${message}`)
}
}
await db.transaction(async (tx) => {
await tx.delete(WorkerTokenTable).where(eq(WorkerTokenTable.worker_id, worker.id))
await tx.delete(DaytonaSandboxTable).where(eq(DaytonaSandboxTable.worker_id, worker.id))
await tx.delete(WorkerInstanceTable).where(eq(WorkerInstanceTable.worker_id, worker.id))
await tx.delete(WorkerBundleTable).where(eq(WorkerBundleTable.worker_id, worker.id))
await tx.delete(AuditEventTable).where(eq(AuditEventTable.worker_id, worker.id))
await tx.delete(WorkerTable).where(eq(WorkerTable.id, worker.id))
})
}
export async function getWorkerByIdForOrg(workerId: WorkerId, orgId: OrgId) {
const rows = await db
.select()
.from(WorkerTable)
.where(and(eq(WorkerTable.id, workerId), eq(WorkerTable.org_id, orgId)))
.limit(1)
return rows[0] ?? null
}

View File

@@ -0,0 +1,7 @@
import { serve } from "@hono/node-server"
import app from "./app.js"
import { env } from "./env.js"
serve({ fetch: app.fetch, port: env.port }, (info) => {
console.log(`den-api listening on ${info.port}`)
})

View File

@@ -0,0 +1,100 @@
import { and, eq, gt } from "@openwork-ee/den-db/drizzle"
import { AuthSessionTable, AuthUserTable } from "@openwork-ee/den-db/schema"
import { normalizeDenTypeId } from "@openwork-ee/utils/typeid"
import type { MiddlewareHandler } from "hono"
import { auth } from "./auth.js"
import { db } from "./db.js"
type AuthSessionLike = Awaited<ReturnType<typeof auth.api.getSession>>
type AuthSessionValue = NonNullable<AuthSessionLike>
export type AuthContextVariables = {
user: AuthSessionValue["user"] | null
session: AuthSessionValue["session"] | null
}
function readBearerToken(headers: Headers): string | null {
const header = headers.get("authorization")?.trim() ?? ""
if (!header) {
return null
}
const match = header.match(/^Bearer\s+(.+)$/i)
if (!match) {
return null
}
const token = match[1]?.trim() ?? ""
return token || null
}
async function getSessionFromBearerToken(token: string): Promise<AuthSessionLike> {
const rows = await db
.select({
session: {
id: AuthSessionTable.id,
token: AuthSessionTable.token,
userId: AuthSessionTable.userId,
activeOrganizationId: AuthSessionTable.activeOrganizationId,
activeTeamId: AuthSessionTable.activeTeamId,
expiresAt: AuthSessionTable.expiresAt,
createdAt: AuthSessionTable.createdAt,
updatedAt: AuthSessionTable.updatedAt,
ipAddress: AuthSessionTable.ipAddress,
userAgent: AuthSessionTable.userAgent,
},
user: {
id: AuthUserTable.id,
name: AuthUserTable.name,
email: AuthUserTable.email,
emailVerified: AuthUserTable.emailVerified,
image: AuthUserTable.image,
createdAt: AuthUserTable.createdAt,
updatedAt: AuthUserTable.updatedAt,
},
})
.from(AuthSessionTable)
.innerJoin(AuthUserTable, eq(AuthSessionTable.userId, AuthUserTable.id))
.where(and(eq(AuthSessionTable.token, token), gt(AuthSessionTable.expiresAt, new Date())))
.limit(1)
const row = rows[0]
if (!row) {
return null
}
return {
session: row.session,
user: {
...row.user,
id: normalizeDenTypeId("user", row.user.id),
},
}
}
export async function getRequestSession(headers: Headers): Promise<AuthSessionLike> {
const cookieSession = await auth.api.getSession({ headers })
if (cookieSession?.user?.id) {
return {
...cookieSession,
user: {
...cookieSession.user,
id: normalizeDenTypeId("user", cookieSession.user.id),
},
}
}
const bearerToken = readBearerToken(headers)
if (!bearerToken) {
return null
}
return getSessionFromBearerToken(bearerToken)
}
export const sessionMiddleware: MiddlewareHandler<{ Variables: AuthContextVariables }> = async (c, next) => {
const resolved = await getRequestSession(c.req.raw.headers)
c.set("user", resolved?.user ?? null)
c.set("session", resolved?.session ?? null)
await next()
}

View File

@@ -0,0 +1,8 @@
export function getRequiredUserEmail(user: { id: string; email?: string | null }) {
const email = user.email?.trim()
if (!email) {
return null
}
return email
}

View File

@@ -0,0 +1,499 @@
import { Daytona, type Sandbox } from "@daytonaio/sdk"
import { eq } from "@openwork-ee/den-db/drizzle"
import { DaytonaSandboxTable } from "@openwork-ee/den-db/schema"
import { createDenTypeId } from "@openwork-ee/utils/typeid"
import { db } from "../db.js"
import { env } from "../env.js"
type WorkerId = typeof DaytonaSandboxTable.$inferSelect.worker_id
type ProvisionInput = {
workerId: WorkerId
name: string
hostToken: string
clientToken: string
activityToken: string
}
type ProvisionedInstance = {
provider: string
url: string
status: "provisioning" | "healthy"
region?: string
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const maxSignedPreviewExpirySeconds = 60 * 60 * 24
const signedPreviewRefreshLeadMs = 5 * 60 * 1000
const slug = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
function shellQuote(value: string) {
return `'${value.replace(/'/g, `'"'"'`)}'`
}
function createDaytonaClient() {
return new Daytona({
apiKey: env.daytona.apiKey,
apiUrl: env.daytona.apiUrl,
...(env.daytona.target ? { target: env.daytona.target } : {}),
})
}
function normalizedSignedPreviewExpirySeconds() {
return Math.max(
1,
Math.min(env.daytona.signedPreviewExpiresSeconds, maxSignedPreviewExpirySeconds),
)
}
function signedPreviewRefreshAt(expiresInSeconds: number) {
return new Date(
Date.now() + Math.max(0, expiresInSeconds * 1000 - signedPreviewRefreshLeadMs),
)
}
function workerProxyUrl(workerId: WorkerId) {
return `${env.daytona.workerProxyBaseUrl.replace(/\/+$/, "")}/${encodeURIComponent(workerId)}`
}
function workerActivityHeartbeatUrl(workerId: WorkerId) {
const base = env.workerActivityBaseUrl.replace(/\/+$/, "")
return `${base}/v1/workers/${encodeURIComponent(workerId)}/activity-heartbeat`
}
function assertDaytonaConfig() {
if (!env.daytona.apiKey) {
throw new Error("DAYTONA_API_KEY is required for daytona provisioner")
}
}
function workerHint(workerId: WorkerId) {
return workerId.replace(/-/g, "").slice(0, 12)
}
function sandboxLabels(workerId: WorkerId) {
return {
"openwork.den.provider": "daytona",
"openwork.den.worker-id": workerId,
}
}
function sandboxName(input: ProvisionInput) {
return slug(
`${env.daytona.sandboxNamePrefix}-${input.name}-${workerHint(input.workerId)}`,
).slice(0, 63)
}
function workspaceVolumeName(workerId: WorkerId) {
return slug(`${env.daytona.volumeNamePrefix}-${workerHint(workerId)}-workspace`).slice(0, 63)
}
function dataVolumeName(workerId: WorkerId) {
return slug(`${env.daytona.volumeNamePrefix}-${workerHint(workerId)}-data`).slice(0, 63)
}
function buildOpenWorkStartCommand(input: ProvisionInput) {
const verifyRuntimeStep = [
"if ! command -v openwork >/dev/null 2>&1; then echo 'openwork binary missing from Daytona runtime image; rebuild and republish the Daytona snapshot' >&2; exit 1; fi",
"if ! command -v opencode >/dev/null 2>&1; then echo 'opencode binary missing from Daytona runtime image; rebuild and republish the Daytona snapshot' >&2; exit 1; fi",
].join("; ")
const openworkServe = [
"OPENWORK_DATA_DIR=",
shellQuote(env.daytona.runtimeDataPath),
" OPENWORK_SIDECAR_DIR=",
shellQuote(env.daytona.sidecarDir),
" OPENWORK_TOKEN=",
shellQuote(input.clientToken),
" OPENWORK_HOST_TOKEN=",
shellQuote(input.hostToken),
" DEN_RUNTIME_PROVIDER=",
shellQuote("daytona"),
" DEN_WORKER_ID=",
shellQuote(input.workerId),
" DEN_ACTIVITY_HEARTBEAT_ENABLED=",
shellQuote("1"),
" DEN_ACTIVITY_HEARTBEAT_URL=",
shellQuote(workerActivityHeartbeatUrl(input.workerId)),
" DEN_ACTIVITY_HEARTBEAT_TOKEN=",
shellQuote(input.activityToken),
" openwork serve",
` --workspace ${shellQuote(env.daytona.runtimeWorkspacePath)}`,
` --remote-access`,
` --openwork-port ${env.daytona.openworkPort}`,
` --opencode-host 127.0.0.1`,
` --opencode-port ${env.daytona.opencodePort}`,
` --connect-host 127.0.0.1`,
` --cors '*'`,
` --approval manual`,
` --allow-external`,
` --opencode-source external`,
` --opencode-bin $(command -v opencode)`,
` --no-opencode-router`,
` --verbose`,
].join("")
const script = `
set -u
mkdir -p ${shellQuote(env.daytona.workspaceMountPath)} ${shellQuote(env.daytona.dataMountPath)} ${shellQuote(env.daytona.runtimeWorkspacePath)} ${shellQuote(env.daytona.runtimeDataPath)} ${shellQuote(env.daytona.sidecarDir)} ${shellQuote(`${env.daytona.runtimeWorkspacePath}/volumes`)}
ln -sfn ${shellQuote(env.daytona.workspaceMountPath)} ${shellQuote(`${env.daytona.runtimeWorkspacePath}/volumes/workspace`) }
ln -sfn ${shellQuote(env.daytona.dataMountPath)} ${shellQuote(`${env.daytona.runtimeWorkspacePath}/volumes/data`) }
${verifyRuntimeStep}
attempt=0
while [ "$attempt" -lt 3 ]; do
attempt=$((attempt + 1))
if ${openworkServe}; then
exit 0
fi
status=$?
echo "openwork serve failed (attempt $attempt, exit $status); retrying in 3s"
sleep 3
done
exit 1
`.trim()
return `sh -lc ${shellQuote(script)}`
}
async function waitForVolumeReady(daytona: Daytona, name: string, timeoutMs: number) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
const volume = await daytona.volume.get(name)
if (volume.state === "ready") {
return volume
}
await sleep(env.daytona.pollIntervalMs)
}
throw new Error(`Timed out waiting for Daytona volume ${name} to become ready`)
}
async function waitForHealth(url: string, timeoutMs: number, sandbox: Sandbox, sessionId: string, commandId: string) {
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(`${url.replace(/\/$/, "")}/health`, { method: "GET" })
if (response.ok) {
return
}
} catch {
// ignore transient startup failures
}
try {
const command = await sandbox.process.getSessionCommand(sessionId, commandId)
if (typeof command.exitCode === "number" && command.exitCode !== 0) {
const logs = await sandbox.process.getSessionCommandLogs(sessionId, commandId)
throw new Error(
[
`openwork session exited with ${command.exitCode}`,
logs.stdout?.trim() ? `stdout:\n${logs.stdout.trim().slice(-4000)}` : "",
logs.stderr?.trim() ? `stderr:\n${logs.stderr.trim().slice(-4000)}` : "",
]
.filter(Boolean)
.join("\n\n"),
)
}
} catch (error) {
if (error instanceof Error && error.message.startsWith("openwork session exited")) {
throw error
}
}
await sleep(env.daytona.pollIntervalMs)
}
const logs = await sandbox.process.getSessionCommandLogs(sessionId, commandId).catch(
() => null,
)
throw new Error(
[
`Timed out waiting for Daytona worker health at ${url.replace(/\/$/, "")}/health`,
logs?.stdout?.trim() ? `stdout:\n${logs.stdout.trim().slice(-4000)}` : "",
logs?.stderr?.trim() ? `stderr:\n${logs.stderr.trim().slice(-4000)}` : "",
]
.filter(Boolean)
.join("\n\n"),
)
}
async function upsertDaytonaSandbox(input: {
workerId: WorkerId
sandboxId: string
workspaceVolumeId: string
dataVolumeId: string
signedPreviewUrl: string
signedPreviewUrlExpiresAt: Date
region: string | null
}) {
const existing = await db
.select({ id: DaytonaSandboxTable.id })
.from(DaytonaSandboxTable)
.where(eq(DaytonaSandboxTable.worker_id, input.workerId))
.limit(1)
if (existing.length > 0) {
await db
.update(DaytonaSandboxTable)
.set({
sandbox_id: input.sandboxId,
workspace_volume_id: input.workspaceVolumeId,
data_volume_id: input.dataVolumeId,
signed_preview_url: input.signedPreviewUrl,
signed_preview_url_expires_at: input.signedPreviewUrlExpiresAt,
region: input.region,
})
.where(eq(DaytonaSandboxTable.worker_id, input.workerId))
return
}
await db.insert(DaytonaSandboxTable).values({
id: createDenTypeId("daytonaSandbox"),
worker_id: input.workerId,
sandbox_id: input.sandboxId,
workspace_volume_id: input.workspaceVolumeId,
data_volume_id: input.dataVolumeId,
signed_preview_url: input.signedPreviewUrl,
signed_preview_url_expires_at: input.signedPreviewUrlExpiresAt,
region: input.region,
})
}
export async function getDaytonaSandboxRecord(workerId: WorkerId) {
const rows = await db
.select()
.from(DaytonaSandboxTable)
.where(eq(DaytonaSandboxTable.worker_id, workerId))
.limit(1)
return rows[0] ?? null
}
export async function refreshDaytonaSignedPreview(workerId: WorkerId) {
assertDaytonaConfig()
const record = await getDaytonaSandboxRecord(workerId)
if (!record) {
return null
}
const daytona = createDaytonaClient()
const sandbox = await daytona.get(record.sandbox_id)
await sandbox.refreshData()
const expiresInSeconds = normalizedSignedPreviewExpirySeconds()
const preview = await sandbox.getSignedPreviewUrl(env.daytona.openworkPort, expiresInSeconds)
const expiresAt = signedPreviewRefreshAt(expiresInSeconds)
await db
.update(DaytonaSandboxTable)
.set({
signed_preview_url: preview.url,
signed_preview_url_expires_at: expiresAt,
region: sandbox.target,
})
.where(eq(DaytonaSandboxTable.worker_id, workerId))
return {
...record,
signed_preview_url: preview.url,
signed_preview_url_expires_at: expiresAt,
region: sandbox.target,
}
}
export async function getDaytonaSignedPreviewForProxy(workerId: WorkerId) {
const record = await getDaytonaSandboxRecord(workerId)
if (!record) {
return null
}
if (record.signed_preview_url_expires_at.getTime() > Date.now()) {
return record.signed_preview_url
}
const refreshed = await refreshDaytonaSignedPreview(workerId)
return refreshed?.signed_preview_url ?? null
}
export async function provisionWorkerOnDaytona(
input: ProvisionInput,
): Promise<ProvisionedInstance> {
assertDaytonaConfig()
const daytona = createDaytonaClient()
const labels = sandboxLabels(input.workerId)
const workspaceVolumeNameValue = workspaceVolumeName(input.workerId)
const dataVolumeNameValue = dataVolumeName(input.workerId)
await daytona.volume.get(workspaceVolumeNameValue, true)
await daytona.volume.get(dataVolumeNameValue, true)
const workspaceVolume = await waitForVolumeReady(
daytona,
workspaceVolumeNameValue,
env.daytona.createTimeoutSeconds * 1000,
)
const dataVolume = await waitForVolumeReady(
daytona,
dataVolumeNameValue,
env.daytona.createTimeoutSeconds * 1000,
)
let sandbox: Awaited<ReturnType<typeof daytona.create>> | null = null
try {
sandbox = env.daytona.snapshot
? await daytona.create(
{
name: sandboxName(input),
snapshot: env.daytona.snapshot,
autoStopInterval: env.daytona.autoStopInterval,
autoArchiveInterval: env.daytona.autoArchiveInterval,
autoDeleteInterval: env.daytona.autoDeleteInterval,
public: env.daytona.public,
labels,
envVars: {
DEN_WORKER_ID: input.workerId,
DEN_RUNTIME_PROVIDER: "daytona",
},
volumes: [
{
volumeId: workspaceVolume.id,
mountPath: env.daytona.workspaceMountPath,
},
{
volumeId: dataVolume.id,
mountPath: env.daytona.dataMountPath,
},
],
},
{ timeout: env.daytona.createTimeoutSeconds },
)
: await daytona.create(
{
name: sandboxName(input),
image: env.daytona.image,
autoStopInterval: env.daytona.autoStopInterval,
autoArchiveInterval: env.daytona.autoArchiveInterval,
autoDeleteInterval: env.daytona.autoDeleteInterval,
public: env.daytona.public,
labels,
envVars: {
DEN_WORKER_ID: input.workerId,
DEN_RUNTIME_PROVIDER: "daytona",
},
resources: {
cpu: env.daytona.resources.cpu,
memory: env.daytona.resources.memory,
disk: env.daytona.resources.disk,
},
volumes: [
{
volumeId: workspaceVolume.id,
mountPath: env.daytona.workspaceMountPath,
},
{
volumeId: dataVolume.id,
mountPath: env.daytona.dataMountPath,
},
],
},
{ timeout: env.daytona.createTimeoutSeconds },
)
const sessionId = `openwork-${workerHint(input.workerId)}`
await sandbox.process.createSession(sessionId)
const command = await sandbox.process.executeSessionCommand(
sessionId,
{
command: buildOpenWorkStartCommand(input),
runAsync: true,
},
0,
)
const expiresInSeconds = normalizedSignedPreviewExpirySeconds()
const preview = await sandbox.getSignedPreviewUrl(env.daytona.openworkPort, expiresInSeconds)
await waitForHealth(preview.url, env.daytona.healthcheckTimeoutMs, sandbox, sessionId, command.cmdId)
await upsertDaytonaSandbox({
workerId: input.workerId,
sandboxId: sandbox.id,
workspaceVolumeId: workspaceVolume.id,
dataVolumeId: dataVolume.id,
signedPreviewUrl: preview.url,
signedPreviewUrlExpiresAt: signedPreviewRefreshAt(expiresInSeconds),
region: sandbox.target ?? null,
})
return {
provider: "daytona",
url: workerProxyUrl(input.workerId),
status: "healthy",
region: sandbox.target,
}
} catch (error) {
if (sandbox) {
await sandbox.delete(env.daytona.deleteTimeoutSeconds).catch(() => {})
}
await daytona.volume.delete(workspaceVolume).catch(() => {})
await daytona.volume.delete(dataVolume).catch(() => {})
throw error
}
}
export async function deprovisionWorkerOnDaytona(workerId: WorkerId) {
assertDaytonaConfig()
const daytona = createDaytonaClient()
const record = await getDaytonaSandboxRecord(workerId)
if (record) {
try {
const sandbox = await daytona.get(record.sandbox_id)
await sandbox.delete(env.daytona.deleteTimeoutSeconds)
} catch (error) {
const message = error instanceof Error ? error.message : "unknown_error"
console.warn(`[provisioner] failed to delete Daytona sandbox ${record.sandbox_id}: ${message}`)
}
const volumes = await daytona.volume.list().catch(() => [])
for (const volumeId of [record.workspace_volume_id, record.data_volume_id]) {
const volume = volumes.find((entry) => entry.id === volumeId)
if (!volume) {
continue
}
await daytona.volume.delete(volume).catch((error) => {
const message = error instanceof Error ? error.message : "unknown_error"
console.warn(`[provisioner] failed to delete Daytona volume ${volumeId}: ${message}`)
})
}
return
}
const sandboxes = await daytona.list(sandboxLabels(workerId), 1, 20)
for (const sandbox of sandboxes.items) {
await sandbox.delete(env.daytona.deleteTimeoutSeconds).catch((error) => {
const message = error instanceof Error ? error.message : "unknown_error"
console.warn(`[provisioner] failed to delete Daytona sandbox ${sandbox.id}: ${message}`)
})
}
const volumes = await daytona.volume.list()
for (const name of [workspaceVolumeName(workerId), dataVolumeName(workerId)]) {
const volume = volumes.find((entry) => entry.name === name)
if (!volume) {
continue
}
await daytona.volume.delete(volume).catch((error) => {
const message = error instanceof Error ? error.message : "unknown_error"
console.warn(`[provisioner] failed to delete Daytona volume ${name}: ${message}`)
})
}
}

View File

@@ -0,0 +1,406 @@
import { WorkerTable } from "@openwork-ee/den-db/schema"
import { env } from "../env.js"
import {
deprovisionWorkerOnDaytona,
provisionWorkerOnDaytona,
} from "./daytona.js"
import {
customDomainForWorker,
ensureVercelDnsRecord,
} from "./vanity-domain.js"
type WorkerId = typeof WorkerTable.$inferSelect.id
export type ProvisionInput = {
workerId: WorkerId
name: string
hostToken: string
clientToken: string
activityToken: string
}
export type ProvisionedInstance = {
provider: string
url: string
status: "provisioning" | "healthy"
region?: string
}
type RenderService = {
id: string
name?: string
slug?: string
serviceDetails?: {
url?: string
region?: string
}
}
type RenderServiceListRow = {
cursor?: string
service?: RenderService
}
type RenderDeploy = {
id: string
status: string
}
const terminalDeployStates = new Set([
"live",
"update_failed",
"build_failed",
"canceled",
])
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
const slug = (value: string) =>
value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
const hostFromUrl = (value: string | null | undefined) => {
if (!value) {
return ""
}
try {
return new URL(value).host.toLowerCase()
} catch {
return ""
}
}
async function renderRequest<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers)
headers.set("Authorization", `Bearer ${env.render.apiKey}`)
headers.set("Accept", "application/json")
if (init.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
const response = await fetch(`${env.render.apiBase}${path}`, {
...init,
headers,
})
const text = await response.text()
if (!response.ok) {
throw new Error(
`Render API ${path} failed (${response.status}): ${text.slice(0, 400)}`,
)
}
if (!text) {
return null as T
}
return JSON.parse(text) as T
}
async function waitForDeployLive(serviceId: string) {
const startedAt = Date.now()
let latest: RenderDeploy | null = null
while (Date.now() - startedAt < env.render.provisionTimeoutMs) {
const rows = await renderRequest<Array<{ deploy: RenderDeploy }>>(
`/services/${serviceId}/deploys?limit=1`,
)
latest = rows[0]?.deploy ?? null
if (latest && terminalDeployStates.has(latest.status)) {
if (latest.status !== "live") {
throw new Error(
`Render deploy ${latest.id} ended with ${latest.status}`,
)
}
return latest
}
await sleep(env.render.pollIntervalMs)
}
throw new Error(
`Timed out waiting for Render deploy for service ${serviceId}`,
)
}
async function waitForHealth(
url: string,
timeoutMs = env.render.healthcheckTimeoutMs,
) {
const healthUrl = `${url.replace(/\/$/, "")}/health`
const startedAt = Date.now()
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(healthUrl, { method: "GET" })
if (response.ok) {
return
}
} catch {
// ignore transient network failures while the instance boots
}
await sleep(env.render.pollIntervalMs)
}
throw new Error(`Timed out waiting for worker health endpoint ${healthUrl}`)
}
async function listRenderServices(limit = 200) {
const rows: RenderService[] = []
let cursor: string | undefined
while (rows.length < limit) {
const query = new URLSearchParams({ limit: "100" })
if (cursor) {
query.set("cursor", cursor)
}
const page = await renderRequest<RenderServiceListRow[]>(
`/services?${query.toString()}`,
)
if (page.length === 0) {
break
}
rows.push(
...page
.map((entry) => entry.service)
.filter((entry): entry is RenderService => Boolean(entry?.id)),
)
const nextCursor = page[page.length - 1]?.cursor
if (!nextCursor || nextCursor === cursor) {
break
}
cursor = nextCursor
}
return rows.slice(0, limit)
}
async function attachRenderCustomDomain(
serviceId: string,
workerId: string,
renderUrl: string,
) {
const hostname = customDomainForWorker(
workerId,
env.render.workerPublicDomainSuffix,
)
if (!hostname) {
return null
}
try {
await renderRequest(`/services/${serviceId}/custom-domains`, {
method: "POST",
body: JSON.stringify({
name: hostname,
}),
})
const dnsReady = await ensureVercelDnsRecord({
hostname,
targetUrl: renderUrl,
domain: env.vercel.dnsDomain ?? env.render.workerPublicDomainSuffix,
apiBase: env.vercel.apiBase,
token: env.vercel.token,
teamId: env.vercel.teamId,
teamSlug: env.vercel.teamSlug,
})
if (!dnsReady) {
console.warn(
`[provisioner] vanity dns upsert skipped or failed for ${hostname}; using Render URL fallback`,
)
return null
}
return `https://${hostname}`
} catch (error) {
const message = error instanceof Error ? error.message : "unknown_error"
console.warn(
`[provisioner] custom domain attach failed for ${serviceId}: ${message}`,
)
return null
}
}
function assertRenderConfig() {
if (!env.render.apiKey) {
throw new Error("RENDER_API_KEY is required for render provisioner")
}
if (!env.render.ownerId) {
throw new Error("RENDER_OWNER_ID is required for render provisioner")
}
}
async function provisionWorkerOnRender(
input: ProvisionInput,
): Promise<ProvisionedInstance> {
assertRenderConfig()
const serviceName = slug(
`${env.render.workerNamePrefix}-${input.name}-${input.workerId.slice(0, 8)}`,
).slice(0, 62)
const orchestratorPackage = env.render.workerOpenworkVersion?.trim()
? `openwork-orchestrator@${env.render.workerOpenworkVersion.trim()}`
: "openwork-orchestrator"
const buildCommand = [
`npm install -g ${orchestratorPackage}`,
"node ./scripts/install-opencode.mjs",
].join(" && ")
const startCommand = [
"mkdir -p /tmp/workspace",
"attempt=0; while [ $attempt -lt 3 ]; do attempt=$((attempt + 1)); openwork serve --workspace /tmp/workspace --remote-access --openwork-port ${PORT:-10000} --opencode-host 127.0.0.1 --opencode-port 4096 --connect-host 127.0.0.1 --cors '*' --approval manual --allow-external --opencode-source external --opencode-bin ./bin/opencode --no-opencode-router --verbose && exit 0; echo \"openwork serve failed (attempt $attempt); retrying in 3s\"; sleep 3; done; exit 1",
].join(" && ")
const payload = {
type: "web_service",
name: serviceName,
ownerId: env.render.ownerId,
repo: env.render.workerRepo,
branch: env.render.workerBranch,
autoDeploy: "no",
rootDir: env.render.workerRootDir,
envVars: [
{ key: "OPENWORK_TOKEN", value: input.clientToken },
{ key: "OPENWORK_HOST_TOKEN", value: input.hostToken },
{ key: "DEN_WORKER_ID", value: input.workerId },
],
serviceDetails: {
runtime: "node",
plan: env.render.workerPlan,
region: env.render.workerRegion,
healthCheckPath: "/health",
envSpecificDetails: {
buildCommand,
startCommand,
},
},
}
const created = await renderRequest<{ service: RenderService }>("/services", {
method: "POST",
body: JSON.stringify(payload),
})
const serviceId = created.service.id
await waitForDeployLive(serviceId)
const service = await renderRequest<RenderService>(`/services/${serviceId}`)
const renderUrl = service.serviceDetails?.url
if (!renderUrl) {
throw new Error(`Render service ${serviceId} has no public URL`)
}
await waitForHealth(renderUrl)
const customUrl = await attachRenderCustomDomain(
serviceId,
input.workerId,
renderUrl,
)
let url = renderUrl
if (customUrl) {
try {
await waitForHealth(customUrl, env.render.customDomainReadyTimeoutMs)
url = customUrl
} catch {
console.warn(
`[provisioner] vanity domain not ready yet for ${input.workerId}; returning Render URL fallback`,
)
}
}
return {
provider: "render",
url,
status: "healthy",
region: service.serviceDetails?.region ?? env.render.workerRegion,
}
}
export async function provisionWorker(
input: ProvisionInput,
): Promise<ProvisionedInstance> {
if (env.provisionerMode === "render") {
return provisionWorkerOnRender(input)
}
if (env.provisionerMode === "daytona") {
return provisionWorkerOnDaytona(input)
}
const template = env.workerUrlTemplate ?? "https://workers.local/{workerId}"
const url = template.replace("{workerId}", input.workerId)
return {
provider: "stub",
url,
status: "provisioning",
}
}
export async function deprovisionWorker(input: {
workerId: WorkerId
instanceUrl: string | null
}) {
if (env.provisionerMode === "daytona") {
await deprovisionWorkerOnDaytona(input.workerId)
return
}
if (env.provisionerMode !== "render") {
return
}
assertRenderConfig()
const targetHost = hostFromUrl(input.instanceUrl)
const workerHint = input.workerId.slice(0, 8).toLowerCase()
const services = await listRenderServices()
const target =
services.find((service) => {
if (service.name?.toLowerCase().includes(workerHint)) {
return true
}
if (
targetHost &&
hostFromUrl(service.serviceDetails?.url) === targetHost
) {
return true
}
return false
}) ?? null
if (!target) {
return
}
try {
await renderRequest(`/services/${target.id}/suspend`, {
method: "POST",
body: JSON.stringify({}),
})
} catch (error) {
const message = error instanceof Error ? error.message : "unknown_error"
console.warn(
`[provisioner] failed to suspend Render service ${target.id}: ${message}`,
)
}
}

View File

@@ -0,0 +1,183 @@
function normalizeUrl(value: string): string {
return value.trim().replace(/\/+$/, "")
}
function slug(value: string) {
return value
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
}
function splitHostname(hostname: string, domain: string): string | null {
const normalizedHost = hostname.trim().toLowerCase()
const normalizedDomain = domain.trim().toLowerCase()
if (!normalizedHost || !normalizedDomain) {
return null
}
if (normalizedHost === normalizedDomain) {
return ""
}
if (!normalizedHost.endsWith(`.${normalizedDomain}`)) {
return null
}
return normalizedHost.slice(0, -(normalizedDomain.length + 1))
}
function hostFromUrl(value: string): string | null {
try {
return new URL(normalizeUrl(value)).host.toLowerCase()
} catch {
return null
}
}
function withVercelScope(url: URL, teamId?: string, teamSlug?: string) {
if (teamId?.trim()) {
url.searchParams.set("teamId", teamId.trim())
} else if (teamSlug?.trim()) {
url.searchParams.set("slug", teamSlug.trim())
}
return url
}
type VercelDnsRecord = {
id: string
type?: string
name?: string
value?: string
}
async function vercelRequest<T>(input: {
apiBase: string
token: string
path: string
teamId?: string
teamSlug?: string
method?: "GET" | "POST" | "PATCH"
body?: unknown
}): Promise<T> {
const base = normalizeUrl(input.apiBase || "https://api.vercel.com")
const url = withVercelScope(new URL(`${base}${input.path}`), input.teamId, input.teamSlug)
const headers = new Headers({
Authorization: `Bearer ${input.token}`,
Accept: "application/json",
})
const init: RequestInit = {
method: input.method ?? "GET",
headers,
}
if (typeof input.body !== "undefined") {
headers.set("Content-Type", "application/json")
init.body = JSON.stringify(input.body)
}
const response = await fetch(url, init)
const text = await response.text()
if (!response.ok) {
throw new Error(`Vercel API ${input.path} failed (${response.status}): ${text.slice(0, 300)}`)
}
if (!text) {
return null as T
}
return JSON.parse(text) as T
}
export function customDomainForWorker(workerId: string, suffix: string | null | undefined): string | null {
const normalizedSuffix = suffix?.trim().toLowerCase()
if (!normalizedSuffix) {
return null
}
const label = slug(workerId).slice(0, 32)
if (!label) {
return null
}
return `${label}.${normalizedSuffix}`
}
export async function ensureVercelDnsRecord(input: {
hostname: string
targetUrl: string
domain: string | null | undefined
apiBase?: string
token?: string
teamId?: string
teamSlug?: string
}): Promise<boolean> {
const domain = input.domain?.trim().toLowerCase()
const token = input.token?.trim()
if (!domain || !token) {
return false
}
const name = splitHostname(input.hostname, domain)
const targetHost = hostFromUrl(input.targetUrl)
if (name === null || !targetHost) {
return false
}
const list = await vercelRequest<{ records?: VercelDnsRecord[] }>({
apiBase: input.apiBase ?? "https://api.vercel.com",
token,
teamId: input.teamId,
teamSlug: input.teamSlug,
path: `/v4/domains/${encodeURIComponent(domain)}/records`,
})
const records = Array.isArray(list.records) ? list.records : []
const current = records.find((record) => {
if (!record?.id) {
return false
}
if ((record.type ?? "").toUpperCase() !== "CNAME") {
return false
}
return (record.name ?? "") === name
})
if (current && (current.value ?? "").toLowerCase() === targetHost.toLowerCase()) {
return true
}
const payload = {
name,
type: "CNAME",
value: targetHost,
}
if (current?.id) {
await vercelRequest({
apiBase: input.apiBase ?? "https://api.vercel.com",
token,
teamId: input.teamId,
teamSlug: input.teamSlug,
method: "PATCH",
path: `/v4/domains/${encodeURIComponent(domain)}/records/${encodeURIComponent(current.id)}`,
body: payload,
})
return true
}
await vercelRequest({
apiBase: input.apiBase ?? "https://api.vercel.com",
token,
teamId: input.teamId,
teamSlug: input.teamSlug,
method: "POST",
path: `/v4/domains/${encodeURIComponent(domain)}/records`,
body: payload,
})
return true
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}

View File

@@ -5,6 +5,7 @@ export type DenOrgSummary = {
logo: string | null;
metadata: string | null;
role: string;
orgMemberId: string;
membershipId: string;
createdAt: string | null;
updatedAt: string | null;
@@ -203,8 +204,9 @@ export function parseOrgListPayload(payload: unknown): {
const name = asString(entry.name);
const slug = asString(entry.slug);
const role = asString(entry.role);
const orgMemberId = asString(entry.orgMemberId);
const membershipId = asString(entry.membershipId);
if (!id || !name || !slug || !role || !membershipId) {
if (!id || !name || !slug || !role || !orgMemberId || !membershipId) {
return null;
}
@@ -215,6 +217,7 @@ export function parseOrgListPayload(payload: unknown): {
logo: asString(entry.logo),
metadata: asString(entry.metadata),
role,
orgMemberId,
membershipId,
createdAt: asIsoString(entry.createdAt),
updatedAt: asIsoString(entry.updatedAt),

View File

@@ -86,7 +86,7 @@ function asTemplateCard(value: unknown): TemplateCard | null {
};
}
export function useOrgTemplates(orgSlug: string) {
export function useOrgTemplates(orgId: string | null) {
const [templates, setTemplates] = useState<TemplateCard[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -95,8 +95,14 @@ export function useOrgTemplates(orgSlug: string) {
setBusy(true);
setError(null);
try {
if (!orgId) {
setTemplates([]);
setError("Organization not found.");
return;
}
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/templates`,
`/v1/orgs/${encodeURIComponent(orgId)}/templates`,
{ method: "GET" },
12000,
);
@@ -120,7 +126,7 @@ export function useOrgTemplates(orgSlug: string) {
useEffect(() => {
void loadTemplates();
}, [orgSlug]);
}, [orgId]);
return {
templates,

View File

@@ -41,9 +41,9 @@ function getTemplateAccent(seed: string) {
}
export function SharedSetupsScreen() {
const { orgSlug, activeOrg, orgContext } = useOrgDashboard();
const { orgSlug, orgId, activeOrg, orgContext } = useOrgDashboard();
const { user } = useDenFlow();
const { templates, busy, error, reloadTemplates } = useOrgTemplates(orgSlug);
const { templates, busy, error, reloadTemplates } = useOrgTemplates(orgId);
const [deleteError, setDeleteError] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [query, setQuery] = useState("");
@@ -92,8 +92,12 @@ export function SharedSetupsScreen() {
setDeletingId(templateId);
setDeleteError(null);
try {
if (!orgId) {
throw new Error("Organization not found.");
}
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/templates/${encodeURIComponent(templateId)}`,
`/v1/orgs/${encodeURIComponent(orgId)}/templates/${encodeURIComponent(templateId)}`,
{ method: "DELETE" },
12000,
);

View File

@@ -20,6 +20,7 @@ import {
type OrgDashboardContextValue = {
orgSlug: string;
orgId: string | null;
orgDirectory: DenOrgSummary[];
activeOrg: DenOrgSummary | null;
orgContext: DenOrgContext | null;
@@ -60,6 +61,16 @@ export function OrgDashboardProvider({
[orgDirectory, orgSlug],
);
const activeOrgId = activeOrg?.id ?? orgContext?.organization.id ?? null;
function getRequiredActiveOrgId() {
if (!activeOrgId) {
throw new Error("Organization not found.");
}
return activeOrgId;
}
async function loadOrgDirectory() {
const { response, payload } = await requestJson("/v1/me/orgs", { method: "GET" }, 12000);
if (!response.ok) {
@@ -69,8 +80,8 @@ export function OrgDashboardProvider({
return parseOrgListPayload(payload).orgs;
}
async function loadOrgContext(targetOrgSlug: string) {
const { response, payload } = await requestJson(`/v1/orgs/${encodeURIComponent(targetOrgSlug)}/context`, { method: "GET" }, 12000);
async function loadOrgContext(targetOrgId: string) {
const { response, payload } = await requestJson(`/v1/orgs/${encodeURIComponent(targetOrgId)}/context`, { method: "GET" }, 12000);
if (!response.ok) {
throw new Error(getErrorMessage(payload, `Failed to load organization (${response.status}).`));
}
@@ -95,12 +106,16 @@ export function OrgDashboardProvider({
setOrgError(null);
try {
const [directory, context] = await Promise.all([
loadOrgDirectory(),
loadOrgContext(orgSlug),
]);
const directory = await loadOrgDirectory();
const targetOrg = directory.find((entry) => entry.slug === orgSlug) ?? null;
setOrgDirectory(directory.map((entry) => ({ ...entry, isActive: entry.slug === context.organization.slug })));
if (!targetOrg) {
throw new Error("Organization not found.");
}
const context = await loadOrgContext(targetOrg.id);
setOrgDirectory(directory.map((entry) => ({ ...entry, isActive: entry.id === context.organization.id })));
setOrgContext(context);
await refreshWorkers({ keepSelection: false, quiet: workersLoadedOnce });
} catch (error) {
@@ -166,7 +181,7 @@ export function OrgDashboardProvider({
async function inviteMember(input: { email: string; role: string }) {
await runMutation("invite-member", async () => {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/invitations`,
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/invitations`,
{
method: "POST",
body: JSON.stringify(input),
@@ -183,7 +198,7 @@ export function OrgDashboardProvider({
async function cancelInvitation(invitationId: string) {
await runMutation("cancel-invitation", async () => {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/invitations/${encodeURIComponent(invitationId)}/cancel`,
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/invitations/${encodeURIComponent(invitationId)}/cancel`,
{ method: "POST", body: JSON.stringify({}) },
12000,
);
@@ -197,7 +212,7 @@ export function OrgDashboardProvider({
async function updateMemberRole(memberId: string, role: string) {
await runMutation("update-member-role", async () => {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/members/${encodeURIComponent(memberId)}/role`,
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/members/${encodeURIComponent(memberId)}/role`,
{
method: "POST",
body: JSON.stringify({ role }),
@@ -214,7 +229,7 @@ export function OrgDashboardProvider({
async function removeMember(memberId: string) {
await runMutation("remove-member", async () => {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/members/${encodeURIComponent(memberId)}`,
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/members/${encodeURIComponent(memberId)}`,
{ method: "DELETE" },
12000,
);
@@ -228,7 +243,7 @@ export function OrgDashboardProvider({
async function createRole(input: { roleName: string; permission: Record<string, string[]> }) {
await runMutation("create-role", async () => {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/roles`,
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/roles`,
{
method: "POST",
body: JSON.stringify(input),
@@ -245,7 +260,7 @@ export function OrgDashboardProvider({
async function updateRole(roleId: string, input: { roleName?: string; permission?: Record<string, string[]> }) {
await runMutation("update-role", async () => {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/roles/${encodeURIComponent(roleId)}`,
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/roles/${encodeURIComponent(roleId)}`,
{
method: "PATCH",
body: JSON.stringify(input),
@@ -262,7 +277,7 @@ export function OrgDashboardProvider({
async function deleteRole(roleId: string) {
await runMutation("delete-role", async () => {
const { response, payload } = await requestJson(
`/v1/orgs/${encodeURIComponent(orgSlug)}/roles/${encodeURIComponent(roleId)}`,
`/v1/orgs/${encodeURIComponent(getRequiredActiveOrgId())}/roles/${encodeURIComponent(roleId)}`,
{ method: "DELETE" },
12000,
);
@@ -289,6 +304,7 @@ export function OrgDashboardProvider({
const value: OrgDashboardContextValue = {
orgSlug,
orgId: activeOrgId,
orgDirectory,
activeOrg,
orgContext,

View File

@@ -1,6 +1,7 @@
import { fromString, getType, typeid } from "typeid-js"
export const denTypeIdPrefixes = {
request: "req",
user: "usr",
session: "ses",
account: "acc",

54
pnpm-lock.yaml generated
View File

@@ -353,6 +353,49 @@ importers:
specifier: ^2.11.0
version: 2.11.10(solid-js@1.9.9)(vite@6.4.1(@types/node@25.4.0)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
ee/apps/den-api:
dependencies:
'@daytonaio/sdk':
specifier: ^0.150.0
version: 0.150.0(ws@8.19.0)
'@hono/node-server':
specifier: ^1.13.8
version: 1.19.11(hono@4.12.8)
'@hono/zod-validator':
specifier: ^0.7.6
version: 0.7.6(hono@4.12.8)(zod@4.3.6)
'@openwork-ee/den-db':
specifier: workspace:*
version: link:../../packages/den-db
'@openwork-ee/utils':
specifier: workspace:*
version: link:../../packages/utils
better-auth:
specifier: ^1.4.18
version: 1.4.18(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@opentelemetry/api@1.9.0)(@planetscale/database@1.19.0)(bun-types@1.3.6)(kysely@0.28.11)(mysql2@3.17.4))(mysql2@3.17.4)(next@16.2.1(@opentelemetry/api@1.9.0)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.10)
better-call:
specifier: ^1.1.8
version: 1.1.8(zod@4.3.6)
dotenv:
specifier: ^16.4.5
version: 16.6.1
hono:
specifier: ^4.7.2
version: 4.12.8
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@types/node':
specifier: ^20.11.30
version: 20.12.12
tsx:
specifier: ^4.15.7
version: 4.21.0
typescript:
specifier: ^5.5.4
version: 5.9.3
ee/apps/den-controller:
dependencies:
'@daytonaio/sdk':
@@ -1430,6 +1473,12 @@ packages:
peerDependencies:
hono: ^4
'@hono/zod-validator@0.7.6':
resolution: {integrity: sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw==}
peerDependencies:
hono: '>=3.9.0'
zod: ^3.25.0 || ^4.0.0
'@iarna/toml@2.2.5':
resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==}
@@ -6078,6 +6127,11 @@ snapshots:
dependencies:
hono: 4.12.8
'@hono/zod-validator@0.7.6(hono@4.12.8)(zod@4.3.6)':
dependencies:
hono: 4.12.8
zod: 4.3.6
'@iarna/toml@2.2.5': {}
'@img/colour@1.1.0': {}