mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
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:
25
ee/apps/den-api/.env.example
Normal file
25
ee/apps/den-api/.env.example
Normal 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
42
ee/apps/den-api/README.md
Normal 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.
|
||||
29
ee/apps/den-api/package.json
Normal file
29
ee/apps/den-api/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
ee/apps/den-api/src/CONSTS.ts
Normal file
1
ee/apps/den-api/src/CONSTS.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const DEN_WORKER_POLL_INTERVAL_MS = 1000
|
||||
53
ee/apps/den-api/src/admin-allowlist.ts
Normal file
53
ee/apps/den-api/src/admin-allowlist.ts
Normal 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
|
||||
}
|
||||
66
ee/apps/den-api/src/app.ts
Normal file
66
ee/apps/den-api/src/app.ts
Normal 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
205
ee/apps/den-api/src/auth.ts
Normal 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.",
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
828
ee/apps/den-api/src/billing/polar.ts
Normal file
828
ee/apps/den-api/src/billing/polar.ts
Normal 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)
|
||||
}
|
||||
8
ee/apps/den-api/src/db.ts
Normal file
8
ee/apps/den-api/src/db.ts
Normal 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,
|
||||
})
|
||||
134
ee/apps/den-api/src/email.ts
Normal file
134
ee/apps/den-api/src/email.ts
Normal 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
263
ee/apps/den-api/src/env.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
3
ee/apps/den-api/src/index.ts
Normal file
3
ee/apps/den-api/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import app from "./app.js"
|
||||
|
||||
export default app
|
||||
15
ee/apps/den-api/src/load-env.ts
Normal file
15
ee/apps/den-api/src/load-env.ts
Normal 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 })
|
||||
121
ee/apps/den-api/src/loops.ts
Normal file
121
ee/apps/den-api/src/loops.ts
Normal 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}`)
|
||||
}
|
||||
}
|
||||
43
ee/apps/den-api/src/middleware/README.md
Normal file
43
ee/apps/den-api/src/middleware/README.md
Normal 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
|
||||
36
ee/apps/den-api/src/middleware/admin.ts
Normal file
36
ee/apps/den-api/src/middleware/admin.ts
Normal 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()
|
||||
}
|
||||
10
ee/apps/den-api/src/middleware/current-user.ts
Normal file
10
ee/apps/den-api/src/middleware/current-user.ts
Normal 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()
|
||||
}
|
||||
6
ee/apps/den-api/src/middleware/index.ts
Normal file
6
ee/apps/den-api/src/middleware/index.ts
Normal 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"
|
||||
25
ee/apps/den-api/src/middleware/member-teams.ts
Normal file
25
ee/apps/den-api/src/middleware/member-teams.ts
Normal 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()
|
||||
}
|
||||
42
ee/apps/den-api/src/middleware/organization-context.ts
Normal file
42
ee/apps/den-api/src/middleware/organization-context.ts
Normal 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()
|
||||
}
|
||||
30
ee/apps/den-api/src/middleware/user-organizations.ts
Normal file
30
ee/apps/den-api/src/middleware/user-organizations.ts
Normal 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()
|
||||
}
|
||||
36
ee/apps/den-api/src/middleware/validation.ts
Normal file
36
ee/apps/den-api/src/middleware/validation.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
15
ee/apps/den-api/src/organization-access.ts
Normal file
15
ee/apps/den-api/src/organization-access.ts
Normal 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
709
ee/apps/den-api/src/orgs.ts
Normal 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
|
||||
}
|
||||
22
ee/apps/den-api/src/routes/README.md
Normal file
22
ee/apps/den-api/src/routes/README.md
Normal 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.
|
||||
21
ee/apps/den-api/src/routes/admin/README.md
Normal file
21
ee/apps/den-api/src/routes/admin/README.md
Normal 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.
|
||||
293
ee/apps/den-api/src/routes/admin/index.ts
Normal file
293
ee/apps/den-api/src/routes/admin/index.ts
Normal 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(),
|
||||
})
|
||||
})
|
||||
}
|
||||
25
ee/apps/den-api/src/routes/auth/README.md
Normal file
25
ee/apps/den-api/src/routes/auth/README.md
Normal 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/`
|
||||
175
ee/apps/den-api/src/routes/auth/desktop-handoff.ts
Normal file
175
ee/apps/den-api/src/routes/auth/desktop-handoff.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
9
ee/apps/den-api/src/routes/auth/index.ts
Normal file
9
ee/apps/den-api/src/routes/auth/index.ts
Normal 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)
|
||||
}
|
||||
23
ee/apps/den-api/src/routes/me/README.md
Normal file
23
ee/apps/den-api/src/routes/me/README.md
Normal 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
|
||||
25
ee/apps/den-api/src/routes/me/index.ts
Normal file
25
ee/apps/den-api/src/routes/me/index.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
}
|
||||
31
ee/apps/den-api/src/routes/org/README.md
Normal file
31
ee/apps/den-api/src/routes/org/README.md
Normal 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.
|
||||
111
ee/apps/den-api/src/routes/org/core.ts
Normal file
111
ee/apps/den-api/src/routes/org/core.ts
Normal 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") ?? [],
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
15
ee/apps/den-api/src/routes/org/index.ts
Normal file
15
ee/apps/den-api/src/routes/org/index.ts
Normal 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)
|
||||
}
|
||||
126
ee/apps/den-api/src/routes/org/invitations.ts
Normal file
126
ee/apps/den-api/src/routes/org/invitations.ts
Normal 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 })
|
||||
})
|
||||
}
|
||||
98
ee/apps/den-api/src/routes/org/members.ts
Normal file
98
ee/apps/den-api/src/routes/org/members.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
200
ee/apps/den-api/src/routes/org/roles.ts
Normal file
200
ee/apps/den-api/src/routes/org/roles.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
113
ee/apps/den-api/src/routes/org/shared.ts
Normal file
113
ee/apps/den-api/src/routes/org/shared.ts
Normal 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")
|
||||
}
|
||||
142
ee/apps/den-api/src/routes/org/templates.ts
Normal file
142
ee/apps/den-api/src/routes/org/templates.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
24
ee/apps/den-api/src/routes/workers/README.md
Normal file
24
ee/apps/den-api/src/routes/workers/README.md
Normal 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
|
||||
90
ee/apps/den-api/src/routes/workers/activity.ts
Normal file
90
ee/apps/den-api/src/routes/workers/activity.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
}
|
||||
71
ee/apps/den-api/src/routes/workers/billing.ts
Normal file
71
ee/apps/den-api/src/routes/workers/billing.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
303
ee/apps/den-api/src/routes/workers/core.ts
Normal file
303
ee/apps/den-api/src/routes/workers/core.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
13
ee/apps/den-api/src/routes/workers/index.ts
Normal file
13
ee/apps/den-api/src/routes/workers/index.ts
Normal 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)
|
||||
}
|
||||
76
ee/apps/den-api/src/routes/workers/runtime.ts
Normal file
76
ee/apps/den-api/src/routes/workers/runtime.ts
Normal 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",
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
494
ee/apps/den-api/src/routes/workers/shared.ts
Normal file
494
ee/apps/den-api/src/routes/workers/shared.ts
Normal 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
|
||||
}
|
||||
7
ee/apps/den-api/src/server.ts
Normal file
7
ee/apps/den-api/src/server.ts
Normal 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}`)
|
||||
})
|
||||
100
ee/apps/den-api/src/session.ts
Normal file
100
ee/apps/den-api/src/session.ts
Normal 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()
|
||||
}
|
||||
8
ee/apps/den-api/src/user.ts
Normal file
8
ee/apps/den-api/src/user.ts
Normal 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
|
||||
}
|
||||
499
ee/apps/den-api/src/workers/daytona.ts
Normal file
499
ee/apps/den-api/src/workers/daytona.ts
Normal 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}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
406
ee/apps/den-api/src/workers/provisioner.ts
Normal file
406
ee/apps/den-api/src/workers/provisioner.ts
Normal 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}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
183
ee/apps/den-api/src/workers/vanity-domain.ts
Normal file
183
ee/apps/den-api/src/workers/vanity-domain.ts
Normal 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
|
||||
}
|
||||
14
ee/apps/den-api/tsconfig.json
Normal file
14
ee/apps/den-api/tsconfig.json
Normal 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"]
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
54
pnpm-lock.yaml
generated
@@ -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': {}
|
||||
|
||||
Reference in New Issue
Block a user