mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix(den): validate TypeIDs and refine OpenAPI docs (#1376)
* fix(den): validate TypeIDs in API schemas Use the shared TypeID utility in den-api request and response schemas so invalid IDs are rejected consistently and Swagger documents the expected prefixes. Keep the API docs aligned with the live validation rules used by the app. * docs(den): refine OpenAPI route visibility Hide internal and sensitive Den routes from Swagger so the published docs stay focused on public workflows. Clarify route tags and permission descriptions so ownership and workspace-admin requirements are easier to understand. --------- Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,13 @@ pnpm --filter @openwork-ee/den-api dev:local
|
||||
|
||||
Each major folder also has its own `README.md` so future agents can inspect one area in isolation.
|
||||
|
||||
## TypeID validation
|
||||
|
||||
- Shared Den TypeID validation lives in `ee/packages/utils/src/typeid.ts`.
|
||||
- Use `typeId.schema("...")` or the compatibility helpers like `normalizeDenTypeId("...", value)` when an endpoint accepts or returns a Den TypeID.
|
||||
- `ee/apps/den-api/src/openapi.ts` exposes `denTypeIdSchema(...)` so path params, request bodies, and response fields all share the same validation rules and Swagger examples.
|
||||
- Swagger now documents Den IDs with their required prefix and fixed 26-character TypeID suffix, so invalid IDs fail request validation before route logic runs.
|
||||
|
||||
## Migration approach
|
||||
|
||||
1. Keep `den-api` (formerly `den-controller`) as the source of truth for Den control-plane behavior.
|
||||
|
||||
@@ -68,6 +68,7 @@ app.get(
|
||||
"/",
|
||||
describeRoute({
|
||||
tags: ["System"],
|
||||
hide: true,
|
||||
summary: "Redirect API root",
|
||||
description: "Redirects the API root to the OpenWork marketing site instead of serving API content.",
|
||||
responses: {
|
||||
@@ -110,7 +111,7 @@ registerWorkerRoutes(app)
|
||||
app.get(
|
||||
"/openapi.json",
|
||||
describeRoute({
|
||||
tags: ["Documentation"],
|
||||
tags: ["System"],
|
||||
summary: "Get OpenAPI document",
|
||||
description: "Returns the machine-readable OpenAPI 3.1 document for the Den API so humans and tools can inspect the API surface.",
|
||||
responses: {
|
||||
@@ -123,29 +124,35 @@ app.get(
|
||||
info: {
|
||||
title: "Den API",
|
||||
version: "dev",
|
||||
description: "OpenAPI spec for the Den control plane API.",
|
||||
description: [
|
||||
"OpenAPI spec for the Den control plane API.",
|
||||
"",
|
||||
"Authentication:",
|
||||
"- Use `Authorization: Bearer <session-token>` for user-authenticated routes that require a Den session.",
|
||||
"- Use `x-api-key: <den-api-key>` for API-key-authenticated routes that accept organization API keys.",
|
||||
"- Public routes like health and documentation do not require authentication.",
|
||||
"",
|
||||
"Swagger tip: use the security schemes in the Authorize dialog to set either `bearerAuth` or `denApiKey` before trying protected endpoints.",
|
||||
].join("\n"),
|
||||
},
|
||||
servers: [
|
||||
{ url: "https://api.openworklabs.com" },
|
||||
],
|
||||
tags: [
|
||||
{ name: "System", description: "Service health and operational routes." },
|
||||
{ name: "Documentation", description: "OpenAPI document and Swagger UI routes." },
|
||||
{ name: "Organizations", description: "Organization-scoped Den API routes." },
|
||||
{ name: "Organization Invitations", description: "Organization invitation creation, preview, acceptance, and cancellation routes." },
|
||||
{ name: "Organization API Keys", description: "Organization API key management routes." },
|
||||
{ name: "Organization Members", description: "Organization member management routes." },
|
||||
{ name: "Organization Roles", description: "Organization custom role management routes." },
|
||||
{ name: "Organization Teams", description: "Organization team management routes." },
|
||||
{ name: "Organization Templates", description: "Organization shared template routes." },
|
||||
{ name: "Organization LLM Providers", description: "Organization LLM provider catalog, configuration, and access routes." },
|
||||
{ name: "Organization Skills", description: "Organization skill authoring and sharing routes." },
|
||||
{ name: "Organization Skill Hubs", description: "Organization skill hub management and access routes." },
|
||||
{ name: "Organizations", description: "Top-level organization creation and context routes." },
|
||||
{ name: "Invitations", description: "Invitation preview, acceptance, creation, and cancellation routes." },
|
||||
{ name: "API Keys", description: "Organization API key management routes." },
|
||||
{ name: "Members", description: "Organization member management routes." },
|
||||
{ name: "Roles", description: "Organization custom role management routes." },
|
||||
{ name: "Teams", description: "Organization team management routes." },
|
||||
{ name: "Templates", description: "Organization shared template routes." },
|
||||
{ name: "LLM Providers", description: "Organization LLM provider catalog, configuration, and access routes." },
|
||||
{ name: "Skills", description: "Organization skill authoring and sharing routes." },
|
||||
{ name: "Skill Hubs", description: "Organization skill hub management and access routes." },
|
||||
{ name: "Workers", description: "Worker lifecycle, billing, and runtime routes." },
|
||||
{ name: "Worker Billing", description: "Worker subscription and billing status routes." },
|
||||
{ name: "Worker Runtime", description: "Worker runtime inspection and upgrade routes." },
|
||||
{ name: "Worker Activity", description: "Worker heartbeat and activity reporting routes." },
|
||||
{ name: "Authentication", description: "Authentication and desktop sign-in handoff routes." },
|
||||
{ name: "Admin", description: "Administrative reporting routes." },
|
||||
{ name: "Users", description: "Current user and membership routes." },
|
||||
],
|
||||
@@ -155,11 +162,13 @@ app.get(
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
bearerFormat: "session-token",
|
||||
description: "Session token passed as `Authorization: Bearer <session-token>` for user-authenticated Den routes.",
|
||||
},
|
||||
denApiKey: {
|
||||
type: "apiKey",
|
||||
in: "header",
|
||||
name: "x-api-key",
|
||||
description: "Organization API key passed as the `x-api-key` header for API-key-authenticated Den routes.",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -178,7 +187,7 @@ app.get(
|
||||
app.get(
|
||||
"/docs",
|
||||
describeRoute({
|
||||
tags: ["Documentation"],
|
||||
tags: ["System"],
|
||||
summary: "Serve Swagger UI",
|
||||
description: "Serves Swagger UI so developers can browse and try the Den API from a browser.",
|
||||
responses: {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { type DenTypeIdName, typeId } from "@openwork-ee/utils/typeid"
|
||||
import { resolver } from "hono-openapi"
|
||||
import { z } from "zod"
|
||||
|
||||
const TYPE_ID_EXAMPLE_SUFFIX = "01h2xcejqtf2nbrexx3vqjhp41"
|
||||
|
||||
function toPascalCase(value: string) {
|
||||
return value
|
||||
.replace(/[^a-zA-Z0-9]+/g, " ")
|
||||
@@ -34,6 +37,15 @@ export function buildOperationId(method: string, path: string) {
|
||||
.replace(/^[A-Z]/, (char) => char.toLowerCase())
|
||||
}
|
||||
|
||||
export function denTypeIdSchema<TName extends DenTypeIdName>(typeName: TName) {
|
||||
const prefix = typeId.prefix[typeName]
|
||||
return typeId.schema(typeName).describe(`Den TypeID with '${prefix}_' prefix.`).meta({
|
||||
description: `Den TypeID with '${prefix}_' prefix and a ${typeId.suffixLength}-character base32 suffix.`,
|
||||
examples: [`${prefix}_${TYPE_ID_EXAMPLE_SUFFIX}`],
|
||||
format: "typeid",
|
||||
})
|
||||
}
|
||||
|
||||
const validationIssueSchema = z.object({
|
||||
message: z.string(),
|
||||
path: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { z } from "zod"
|
||||
import { getCloudWorkerAdminBillingStatus } from "../../billing/polar.js"
|
||||
import { db } from "../../db.js"
|
||||
import { queryValidator, requireAdminMiddleware } from "../../middleware/index.js"
|
||||
import { invalidRequestSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, invalidRequestSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js"
|
||||
import type { AuthContextVariables } from "../../session.js"
|
||||
|
||||
type UserId = typeof AuthUserTable.$inferSelect.id
|
||||
@@ -17,7 +17,7 @@ const overviewQuerySchema = z.object({
|
||||
|
||||
const adminOverviewResponseSchema = z.object({
|
||||
viewer: z.object({
|
||||
id: z.string(),
|
||||
id: denTypeIdSchema("user"),
|
||||
email: z.string(),
|
||||
name: z.string().nullable(),
|
||||
}),
|
||||
|
||||
@@ -7,7 +7,7 @@ import { describeRoute } from "hono-openapi"
|
||||
import { z } from "zod"
|
||||
import { jsonValidator, requireUserMiddleware } from "../../middleware/index.js"
|
||||
import { db } from "../../db.js"
|
||||
import { invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import type { AuthContextVariables } from "../../session.js"
|
||||
|
||||
const createGrantSchema = z.object({
|
||||
@@ -28,7 +28,7 @@ const desktopHandoffGrantResponseSchema = z.object({
|
||||
const desktopHandoffExchangeResponseSchema = z.object({
|
||||
token: z.string(),
|
||||
user: z.object({
|
||||
id: z.string(),
|
||||
id: denTypeIdSchema("user"),
|
||||
email: z.string().email(),
|
||||
name: z.string().nullable(),
|
||||
}),
|
||||
@@ -115,6 +115,7 @@ export function registerDesktopAuthRoutes<T extends { Variables: AuthContextVari
|
||||
app.post(
|
||||
"/v1/auth/desktop-handoff",
|
||||
describeRoute({
|
||||
hide: true,
|
||||
tags: ["Authentication"],
|
||||
summary: "Create desktop handoff grant",
|
||||
description: "Creates a short-lived desktop handoff grant and deep link so a signed-in web user can continue the same account in the OpenWork desktop app.",
|
||||
@@ -162,6 +163,7 @@ export function registerDesktopAuthRoutes<T extends { Variables: AuthContextVari
|
||||
app.post(
|
||||
"/v1/auth/desktop-handoff/exchange",
|
||||
describeRoute({
|
||||
hide: true,
|
||||
tags: ["Authentication"],
|
||||
summary: "Exchange desktop handoff grant",
|
||||
description: "Exchanges a one-time desktop handoff grant for the user's session token and basic profile so the desktop app can sign the user in.",
|
||||
|
||||
@@ -10,6 +10,7 @@ export function registerAuthRoutes<T extends { Variables: AuthContextVariables }
|
||||
["GET", "POST"],
|
||||
"/api/auth/*",
|
||||
describeRoute({
|
||||
hide: true,
|
||||
tags: ["Authentication"],
|
||||
summary: "Handle Better Auth flow",
|
||||
description: "Proxies Better Auth sign-in, sign-out, session, and verification flows under the Den API auth namespace.",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Hono } from "hono"
|
||||
import { describeRoute } from "hono-openapi"
|
||||
import { z } from "zod"
|
||||
import { requireUserMiddleware, resolveUserOrganizationsMiddleware, type UserOrganizationsContext } from "../../middleware/index.js"
|
||||
import { jsonResponse, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, jsonResponse, unauthorizedSchema } from "../../openapi.js"
|
||||
import type { AuthContextVariables } from "../../session.js"
|
||||
|
||||
const meResponseSchema = z.object({
|
||||
@@ -12,10 +12,10 @@ const meResponseSchema = z.object({
|
||||
|
||||
const meOrganizationsResponseSchema = z.object({
|
||||
orgs: z.array(z.object({
|
||||
id: z.string(),
|
||||
id: denTypeIdSchema("organization"),
|
||||
isActive: z.boolean(),
|
||||
}).passthrough()),
|
||||
activeOrgId: z.string().nullable(),
|
||||
activeOrgId: denTypeIdSchema("organization").nullable(),
|
||||
activeOrgSlug: z.string().nullable(),
|
||||
}).meta({ ref: "CurrentUserOrganizationsResponse" })
|
||||
|
||||
@@ -43,7 +43,7 @@ export function registerMeRoutes<T extends { Variables: AuthContextVariables & P
|
||||
app.get(
|
||||
"/v1/me/orgs",
|
||||
describeRoute({
|
||||
tags: ["Users", "Organizations"],
|
||||
tags: ["Users"],
|
||||
summary: "List current user's organizations",
|
||||
description: "Lists the organizations visible to the current user and marks which organization is currently active.",
|
||||
responses: {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
listOrganizationApiKeys,
|
||||
} from "../../api-keys.js"
|
||||
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
|
||||
import { denTypeIdSchema } from "../../openapi.js"
|
||||
import { auth } from "../../auth.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
import { ensureApiKeyManager, idParamSchema, orgIdParamSchema } from "./shared.js"
|
||||
@@ -45,8 +46,8 @@ const apiKeyNotFoundSchema = z.object({
|
||||
}).meta({ ref: "OrganizationApiKeyNotFoundError" })
|
||||
|
||||
const apiKeyOwnerSchema = z.object({
|
||||
userId: z.string(),
|
||||
memberId: z.string(),
|
||||
userId: denTypeIdSchema("user"),
|
||||
memberId: denTypeIdSchema("member"),
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
image: z.string().nullable(),
|
||||
@@ -98,7 +99,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.get(
|
||||
"/v1/orgs/:orgId/api-keys",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization API Keys"],
|
||||
tags: ["API Keys"],
|
||||
summary: "List organization API keys",
|
||||
description: "Returns the API keys that belong to the selected organization.",
|
||||
security: [{ bearerAuth: [] }],
|
||||
@@ -128,7 +129,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
description: "Only workspace owners and admins can list API keys.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(forbiddenApiKeyManagerSchema),
|
||||
@@ -163,7 +164,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/api-keys",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization API Keys"],
|
||||
tags: ["API Keys"],
|
||||
summary: "Create an organization API key",
|
||||
description: "Creates a new API key for the selected organization.",
|
||||
hide: hideApiKeyGenerationRoute,
|
||||
@@ -194,7 +195,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
description: "Only workspace owners and admins can create API keys.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(forbiddenApiKeyManagerSchema),
|
||||
@@ -260,7 +261,8 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/api-keys/:apiKeyId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization API Keys"],
|
||||
tags: ["API Keys"],
|
||||
hide: true,
|
||||
summary: "Delete an organization API key",
|
||||
description: "Deletes an API key from the selected organization.",
|
||||
security: [{ bearerAuth: [] }],
|
||||
@@ -285,7 +287,7 @@ export function registerOrgApiKeyRoutes<T extends { Variables: OrgRouteVariables
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: "Forbidden",
|
||||
description: "Only workspace owners and admins can delete API keys.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(forbiddenApiKeyManagerSchema),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { requireCloudWorkerAccess } from "../../billing/polar.js"
|
||||
import { db } from "../../db.js"
|
||||
import { env } from "../../env.js"
|
||||
import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveMemberTeamsMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
|
||||
import { forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { acceptInvitationForUser, createOrganizationForUser, getInvitationPreview, setSessionActiveOrganization } from "../../orgs.js"
|
||||
import { getRequiredUserEmail } from "../../user.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
@@ -19,11 +19,11 @@ const createOrganizationSchema = z.object({
|
||||
})
|
||||
|
||||
const invitationPreviewQuerySchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
id: denTypeIdSchema("invitation"),
|
||||
})
|
||||
|
||||
const acceptInvitationSchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
id: denTypeIdSchema("invitation"),
|
||||
})
|
||||
|
||||
const organizationResponseSchema = z.object({
|
||||
@@ -44,9 +44,9 @@ const invitationPreviewResponseSchema = z.object({}).passthrough().meta({ ref: "
|
||||
|
||||
const invitationAcceptedResponseSchema = z.object({
|
||||
accepted: z.literal(true),
|
||||
organizationId: z.string(),
|
||||
organizationId: denTypeIdSchema("organization"),
|
||||
organizationSlug: z.string().nullable(),
|
||||
invitationId: z.string(),
|
||||
invitationId: denTypeIdSchema("invitation"),
|
||||
}).meta({ ref: "InvitationAcceptedResponse" })
|
||||
|
||||
const organizationContextResponseSchema = z.object({
|
||||
@@ -74,6 +74,7 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
|
||||
"/v1/orgs",
|
||||
describeRoute({
|
||||
tags: ["Organizations"],
|
||||
hide: true,
|
||||
summary: "Create organization",
|
||||
description: "Creates a new organization for the signed-in user after verifying that their account can provision OpenWork Cloud workspaces.",
|
||||
responses: {
|
||||
@@ -144,7 +145,7 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
|
||||
app.get(
|
||||
"/v1/orgs/invitations/preview",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Invitations"],
|
||||
tags: ["Invitations"],
|
||||
summary: "Preview organization invitation",
|
||||
description: "Returns invitation preview details so a user can inspect an organization invite before accepting it.",
|
||||
responses: {
|
||||
@@ -169,7 +170,7 @@ export function registerOrgCoreRoutes<T extends { Variables: OrgRouteVariables }
|
||||
app.post(
|
||||
"/v1/orgs/invitations/accept",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Invitations"],
|
||||
tags: ["Invitations"],
|
||||
summary: "Accept organization invitation",
|
||||
description: "Accepts an organization invitation for the current signed-in user and switches their active organization to the accepted workspace.",
|
||||
responses: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { z } from "zod"
|
||||
import { db } from "../../db.js"
|
||||
import { sendDenOrganizationInvitationEmail } from "../../email.js"
|
||||
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
|
||||
import { forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { getOrganizationLimitStatus } from "../../organization-limits.js"
|
||||
import { listAssignableRoles } from "../../orgs.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
@@ -19,20 +19,20 @@ const inviteMemberSchema = z.object({
|
||||
})
|
||||
|
||||
const invitationResponseSchema = z.object({
|
||||
invitationId: z.string(),
|
||||
invitationId: denTypeIdSchema("invitation"),
|
||||
email: z.string().email(),
|
||||
role: z.string(),
|
||||
expiresAt: z.string().datetime(),
|
||||
}).meta({ ref: "InvitationResponse" })
|
||||
|
||||
type InvitationId = typeof InvitationTable.$inferSelect.id
|
||||
const orgInvitationParamsSchema = orgIdParamSchema.extend(idParamSchema("invitationId").shape)
|
||||
const orgInvitationParamsSchema = orgIdParamSchema.extend(idParamSchema("invitationId", "invitation").shape)
|
||||
|
||||
export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/invitations",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Invitations"],
|
||||
tags: ["Invitations"],
|
||||
summary: "Create organization invitation",
|
||||
description: "Creates or refreshes a pending organization invitation for an email address and sends the invite email.",
|
||||
responses: {
|
||||
@@ -40,7 +40,7 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
|
||||
201: jsonResponse("Invitation created successfully.", invitationResponseSchema),
|
||||
400: jsonResponse("The invitation request body or path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to invite organization members.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to manage invitations for this organization.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners and admins can create or resend invitations.", forbiddenSchema),
|
||||
404: jsonResponse("The organization could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -141,14 +141,14 @@ export function registerOrgInvitationRoutes<T extends { Variables: OrgRouteVaria
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/invitations/:invitationId/cancel",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Invitations"],
|
||||
tags: ["Invitations"],
|
||||
summary: "Cancel organization invitation",
|
||||
description: "Cancels a pending organization invitation so the invite link can no longer be used.",
|
||||
responses: {
|
||||
200: jsonResponse("Invitation cancelled successfully.", successSchema),
|
||||
400: jsonResponse("The invitation cancellation path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to cancel invitations.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to manage invitations for this organization.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners and admins can cancel invitations.", forbiddenSchema),
|
||||
404: jsonResponse("The invitation or organization could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "../../middleware/index.js"
|
||||
import { getModelsDevProvider, listModelsDevProviders } from "../../llm/models-dev.js"
|
||||
import type { MemberTeamsContext } from "../../middleware/member-teams.js"
|
||||
import { emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
import { idParamSchema, memberHasRole, orgIdParamSchema } from "./shared.js"
|
||||
|
||||
@@ -42,7 +42,7 @@ const providerCatalogParamsSchema = orgIdParamSchema.extend({
|
||||
providerId: z.string().trim().min(1).max(255),
|
||||
})
|
||||
|
||||
const orgLlmProviderParamsSchema = orgIdParamSchema.extend(idParamSchema("llmProviderId").shape)
|
||||
const orgLlmProviderParamsSchema = orgIdParamSchema.extend(idParamSchema("llmProviderId", "llmProvider").shape)
|
||||
|
||||
const customModelSchema = z.object({
|
||||
id: z.string().trim().min(1).max(255),
|
||||
@@ -65,8 +65,8 @@ const llmProviderWriteSchema = z.object({
|
||||
modelIds: z.array(z.string().trim().min(1).max(255)).min(1).optional(),
|
||||
customConfigText: z.string().trim().min(1).optional(),
|
||||
apiKey: z.string().trim().max(65535).optional(),
|
||||
memberIds: z.array(z.string().trim().min(1).max(255)).max(500).optional().default([]),
|
||||
teamIds: z.array(z.string().trim().min(1).max(255)).max(500).optional().default([]),
|
||||
memberIds: z.array(denTypeIdSchema("member")).max(500).optional().default([]),
|
||||
teamIds: z.array(denTypeIdSchema("team")).max(500).optional().default([]),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.source === "models_dev") {
|
||||
if (!value.providerId) {
|
||||
@@ -482,7 +482,7 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
app.get(
|
||||
"/v1/orgs/:orgId/llm-provider-catalog",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization LLM Providers"],
|
||||
tags: ["LLM Providers"],
|
||||
summary: "List LLM provider catalog",
|
||||
description: "Lists the provider catalog from models.dev so an organization can choose which LLM providers to configure.",
|
||||
responses: {
|
||||
@@ -511,7 +511,7 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
app.get(
|
||||
"/v1/orgs/:orgId/llm-provider-catalog/:providerId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization LLM Providers"],
|
||||
tags: ["LLM Providers"],
|
||||
summary: "Get LLM provider catalog entry",
|
||||
description: "Returns the full models.dev catalog record for one provider, including its config template and model list.",
|
||||
responses: {
|
||||
@@ -558,7 +558,7 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
app.get(
|
||||
"/v1/orgs/:orgId/llm-providers",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization LLM Providers"],
|
||||
tags: ["LLM Providers"],
|
||||
summary: "List organization LLM providers",
|
||||
description: "Lists the LLM providers that the current organization member is allowed to see and potentially manage.",
|
||||
responses: {
|
||||
@@ -594,14 +594,14 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
app.get(
|
||||
"/v1/orgs/:orgId/llm-providers/:llmProviderId/connect",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization LLM Providers"],
|
||||
tags: ["LLM Providers"],
|
||||
summary: "Get LLM provider connect payload",
|
||||
description: "Returns one accessible organization LLM provider with the concrete model configuration needed to connect to it.",
|
||||
responses: {
|
||||
200: jsonResponse("Provider connection payload returned successfully.", llmProviderResponseSchema),
|
||||
400: jsonResponse("The provider connect path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to connect to an organization LLM provider.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller does not have access to this organization LLM provider.", forbiddenSchema),
|
||||
403: jsonResponse("Only members with access grants, the provider creator, or workspace admins can connect to this provider.", forbiddenSchema),
|
||||
404: jsonResponse("The provider could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -671,7 +671,7 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/llm-providers",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization LLM Providers"],
|
||||
tags: ["LLM Providers"],
|
||||
summary: "Create organization LLM provider",
|
||||
description: "Creates a new organization-scoped LLM provider from either a models.dev provider template or a pasted custom configuration.",
|
||||
responses: {
|
||||
@@ -783,14 +783,14 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
app.patch(
|
||||
"/v1/orgs/:orgId/llm-providers/:llmProviderId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization LLM Providers"],
|
||||
tags: ["LLM Providers"],
|
||||
summary: "Update organization LLM provider",
|
||||
description: "Updates an existing organization LLM provider, including its provider config, selected models, secret, and access grants.",
|
||||
responses: {
|
||||
200: jsonResponse("Organization LLM provider updated successfully.", llmProviderResponseSchema),
|
||||
400: jsonResponse("The provider update request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to update organization LLM providers.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to update this organization LLM provider.", forbiddenSchema),
|
||||
403: jsonResponse("Only the provider creator or a workspace admin can update providers.", forbiddenSchema),
|
||||
404: jsonResponse("The provider or a referenced resource could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -824,7 +824,7 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
if (!canManageLlmProvider(payload, provider)) {
|
||||
return c.json({
|
||||
error: "forbidden",
|
||||
message: "Only the provider creator or an org admin can update providers.",
|
||||
message: "Only the provider creator or a workspace admin can update providers.",
|
||||
}, 403)
|
||||
}
|
||||
|
||||
@@ -919,14 +919,14 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/llm-providers/:llmProviderId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization LLM Providers"],
|
||||
tags: ["LLM Providers"],
|
||||
summary: "Delete organization LLM provider",
|
||||
description: "Deletes an organization LLM provider and removes its models and access rules.",
|
||||
responses: {
|
||||
204: emptyResponse("Organization LLM provider deleted successfully."),
|
||||
400: jsonResponse("The provider deletion path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to delete organization LLM providers.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to delete this organization LLM provider.", forbiddenSchema),
|
||||
403: jsonResponse("Only the provider creator or a workspace admin can delete providers.", forbiddenSchema),
|
||||
404: jsonResponse("The provider could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -958,7 +958,7 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
if (!canManageLlmProvider(payload, provider)) {
|
||||
return c.json({
|
||||
error: "forbidden",
|
||||
message: "Only the provider creator or an org admin can delete providers.",
|
||||
message: "Only the provider creator or a workspace admin can delete providers.",
|
||||
}, 403)
|
||||
}
|
||||
|
||||
@@ -975,20 +975,20 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/llm-providers/:llmProviderId/access/:accessId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization LLM Providers"],
|
||||
tags: ["LLM Providers"],
|
||||
summary: "Remove LLM provider access grant",
|
||||
description: "Removes one explicit member or team access grant from an organization LLM provider.",
|
||||
responses: {
|
||||
204: emptyResponse("Organization LLM provider access removed successfully."),
|
||||
400: jsonResponse("The provider access deletion path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to manage provider access.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to manage access for this provider.", forbiddenSchema),
|
||||
403: jsonResponse("Only the provider creator or a workspace admin can manage provider access.", forbiddenSchema),
|
||||
404: jsonResponse("The provider or access grant could not be found.", notFoundSchema),
|
||||
409: jsonResponse("The request tried to remove a protected provider access entry.", conflictSchema),
|
||||
},
|
||||
}),
|
||||
requireUserMiddleware,
|
||||
paramValidator(orgLlmProviderParamsSchema.extend(idParamSchema("accessId").shape)),
|
||||
paramValidator(orgLlmProviderParamsSchema.extend(idParamSchema("accessId", "llmProviderAccess").shape)),
|
||||
resolveOrganizationContextMiddleware,
|
||||
async (c) => {
|
||||
const payload = c.get("organizationContext")
|
||||
@@ -1015,7 +1015,7 @@ export function registerOrgLlmProviderRoutes<T extends { Variables: OrgRouteVari
|
||||
}
|
||||
|
||||
if (!canManageLlmProvider(payload, provider)) {
|
||||
return c.json({ error: "forbidden", message: "Only the provider creator or an org admin can manage access." }, 403)
|
||||
return c.json({ error: "forbidden", message: "Only the provider creator or a workspace admin can manage access." }, 403)
|
||||
}
|
||||
|
||||
const accessRows = await db
|
||||
|
||||
@@ -16,20 +16,20 @@ const updateMemberRoleSchema = z.object({
|
||||
})
|
||||
|
||||
type MemberId = typeof MemberTable.$inferSelect.id
|
||||
const orgMemberParamsSchema = orgIdParamSchema.extend(idParamSchema("memberId").shape)
|
||||
const orgMemberParamsSchema = orgIdParamSchema.extend(idParamSchema("memberId", "member").shape)
|
||||
|
||||
export function registerOrgMemberRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/members/:memberId/role",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Members"],
|
||||
tags: ["Members"],
|
||||
summary: "Update member role",
|
||||
description: "Changes the role assigned to a specific organization member.",
|
||||
responses: {
|
||||
200: jsonResponse("Member role updated successfully.", successSchema),
|
||||
400: jsonResponse("The member role update request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to update member roles.", unauthorizedSchema),
|
||||
403: jsonResponse("Only organization owners can update member roles.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners can update member roles.", forbiddenSchema),
|
||||
404: jsonResponse("The member or organization could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -83,14 +83,14 @@ export function registerOrgMemberRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/members/:memberId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Members"],
|
||||
tags: ["Members"],
|
||||
summary: "Remove organization member",
|
||||
description: "Removes a member from an organization while protecting the owner role from deletion.",
|
||||
responses: {
|
||||
204: emptyResponse("Member removed successfully."),
|
||||
400: jsonResponse("The member removal request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to remove organization members.", unauthorizedSchema),
|
||||
403: jsonResponse("Only organization owners can remove members.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners can remove members.", forbiddenSchema),
|
||||
404: jsonResponse("The member or organization could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -24,20 +24,20 @@ const updateRoleSchema = z.object({
|
||||
})
|
||||
|
||||
type OrganizationRoleId = typeof OrganizationRoleTable.$inferSelect.id
|
||||
const orgRoleParamsSchema = orgIdParamSchema.extend(idParamSchema("roleId").shape)
|
||||
const orgRoleParamsSchema = orgIdParamSchema.extend(idParamSchema("roleId", "organizationRole").shape)
|
||||
|
||||
export function registerOrgRoleRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/roles",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Roles"],
|
||||
tags: ["Roles"],
|
||||
summary: "Create organization role",
|
||||
description: "Creates a custom organization role with a named permission map.",
|
||||
responses: {
|
||||
201: jsonResponse("Organization role created successfully.", successSchema),
|
||||
400: jsonResponse("The role creation request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to create organization roles.", unauthorizedSchema),
|
||||
403: jsonResponse("Only organization owners can create organization roles.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners can create custom roles.", forbiddenSchema),
|
||||
404: jsonResponse("The organization could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -83,14 +83,14 @@ export function registerOrgRoleRoutes<T extends { Variables: OrgRouteVariables }
|
||||
app.patch(
|
||||
"/v1/orgs/:orgId/roles/:roleId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Roles"],
|
||||
tags: ["Roles"],
|
||||
summary: "Update organization role",
|
||||
description: "Updates a custom organization role and propagates role name changes to members and pending invitations.",
|
||||
responses: {
|
||||
200: jsonResponse("Organization role updated successfully.", successSchema),
|
||||
400: jsonResponse("The role update request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to update organization roles.", unauthorizedSchema),
|
||||
403: jsonResponse("Only organization owners can update organization roles.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners can update custom roles.", forbiddenSchema),
|
||||
404: jsonResponse("The role or organization could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -190,14 +190,14 @@ export function registerOrgRoleRoutes<T extends { Variables: OrgRouteVariables }
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/roles/:roleId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Roles"],
|
||||
tags: ["Roles"],
|
||||
summary: "Delete organization role",
|
||||
description: "Deletes a custom organization role after confirming that no members or pending invitations still depend on it.",
|
||||
responses: {
|
||||
204: emptyResponse("Organization role deleted successfully."),
|
||||
400: jsonResponse("The role deletion request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to delete organization roles.", unauthorizedSchema),
|
||||
403: jsonResponse("Only organization owners can delete organization roles.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners can delete custom roles.", forbiddenSchema),
|
||||
404: jsonResponse("The role or organization could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { createDenTypeId } from "@openwork-ee/utils/typeid"
|
||||
import { createDenTypeId, type DenTypeIdName } from "@openwork-ee/utils/typeid"
|
||||
import { z } from "zod"
|
||||
import type { MemberTeamsContext, OrganizationContextVariables, UserOrganizationsContext } from "../../middleware/index.js"
|
||||
import { env } from "../../env.js"
|
||||
import { denTypeIdSchema } from "../../openapi.js"
|
||||
import type { AuthContextVariables } from "../../session.js"
|
||||
|
||||
export type OrgRouteVariables =
|
||||
@@ -11,13 +12,19 @@ export type OrgRouteVariables =
|
||||
& Partial<MemberTeamsContext>
|
||||
|
||||
export const orgIdParamSchema = z.object({
|
||||
orgId: z.string().trim().min(1).max(255),
|
||||
orgId: denTypeIdSchema("organization"),
|
||||
})
|
||||
|
||||
export function idParamSchema<K extends string>(key: K) {
|
||||
export function idParamSchema<K extends string>(key: K, typeName?: DenTypeIdName) {
|
||||
if (!typeName) {
|
||||
return z.object({
|
||||
[key]: z.string().trim().min(1).max(255),
|
||||
} as unknown as Record<K, z.ZodString>)
|
||||
}
|
||||
|
||||
return z.object({
|
||||
[key]: z.string().trim().min(1).max(255),
|
||||
} as Record<K, z.ZodString>)
|
||||
[key]: denTypeIdSchema(typeName),
|
||||
} as unknown as Record<K, z.ZodType<string, string>>)
|
||||
}
|
||||
|
||||
export function splitRoles(value: string) {
|
||||
@@ -72,7 +79,7 @@ export function ensureOwner(c: { get: (key: "organizationContext") => OrgRouteVa
|
||||
ok: false as const,
|
||||
response: {
|
||||
error: "forbidden",
|
||||
message: "Only organization owners can manage members and roles.",
|
||||
message: "Only workspace owners can manage members and roles.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -99,7 +106,7 @@ export function ensureInviteManager(c: { get: (key: "organizationContext") => Or
|
||||
ok: false as const,
|
||||
response: {
|
||||
error: "forbidden",
|
||||
message: "Only organization owners and admins can invite members.",
|
||||
message: "Only workspace owners and admins can invite members.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -123,7 +130,7 @@ export function ensureTeamManager(c: { get: (key: "organizationContext") => OrgR
|
||||
ok: false as const,
|
||||
response: {
|
||||
error: "forbidden",
|
||||
message: "Only organization owners and admins can manage teams.",
|
||||
message: "Only workspace owners and admins can manage teams.",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -147,7 +154,7 @@ export function ensureApiKeyManager(c: { get: (key: "organizationContext") => Or
|
||||
ok: false as const,
|
||||
response: {
|
||||
error: "forbidden",
|
||||
message: "Only organization owners and admins can manage API keys.",
|
||||
message: "Only workspace owners and admins can manage API keys.",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
resolveOrganizationContextMiddleware,
|
||||
} from "../../middleware/index.js"
|
||||
import type { MemberTeamsContext } from "../../middleware/member-teams.js"
|
||||
import { emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, successSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
import { idParamSchema, memberHasRole, orgIdParamSchema } from "./shared.js"
|
||||
|
||||
@@ -80,12 +80,12 @@ const updateSkillHubSchema = z.object({
|
||||
})
|
||||
|
||||
const addSkillToHubSchema = z.object({
|
||||
skillId: z.string().trim().min(1),
|
||||
skillId: denTypeIdSchema("skill"),
|
||||
})
|
||||
|
||||
const addSkillHubAccessSchema = z.object({
|
||||
orgMembershipId: z.string().trim().min(1).optional(),
|
||||
teamId: z.string().trim().min(1).optional(),
|
||||
orgMembershipId: denTypeIdSchema("member").optional(),
|
||||
teamId: denTypeIdSchema("team").optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
const count = Number(Boolean(value.orgMembershipId)) + Number(Boolean(value.teamId))
|
||||
if (count !== 1) {
|
||||
@@ -105,10 +105,10 @@ type MemberId = typeof MemberTable.$inferSelect.id
|
||||
type SkillRow = typeof SkillTable.$inferSelect
|
||||
type SkillHubRow = typeof SkillHubTable.$inferSelect
|
||||
|
||||
const orgSkillHubParamsSchema = orgIdParamSchema.extend(idParamSchema("skillHubId").shape)
|
||||
const orgSkillParamsSchema = orgIdParamSchema.extend(idParamSchema("skillId").shape)
|
||||
const orgSkillHubSkillParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("skillId").shape)
|
||||
const orgSkillHubAccessParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("accessId").shape)
|
||||
const orgSkillHubParamsSchema = orgIdParamSchema.extend(idParamSchema("skillHubId", "skillHub").shape)
|
||||
const orgSkillParamsSchema = orgIdParamSchema.extend(idParamSchema("skillId", "skill").shape)
|
||||
const orgSkillHubSkillParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("skillId", "skill").shape)
|
||||
const orgSkillHubAccessParamsSchema = orgSkillHubParamsSchema.extend(idParamSchema("accessId", "skillHubMember").shape)
|
||||
|
||||
const skillResponseSchema = z.object({
|
||||
skill: z.object({}).passthrough(),
|
||||
@@ -265,7 +265,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/skills",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skills"],
|
||||
tags: ["Skills"],
|
||||
summary: "Create skill",
|
||||
description: "Creates a new skill in the organization from markdown content and optional sharing visibility.",
|
||||
responses: {
|
||||
@@ -316,7 +316,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.get(
|
||||
"/v1/orgs/:orgId/skills",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skills"],
|
||||
tags: ["Skills"],
|
||||
summary: "List skills",
|
||||
description: "Lists the skills the current member can view, including owned skills, shared skills, and skills available through hub access.",
|
||||
responses: {
|
||||
@@ -362,14 +362,14 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/skills/:skillId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skills"],
|
||||
tags: ["Skills"],
|
||||
summary: "Delete skill",
|
||||
description: "Deletes one organization skill when the caller is allowed to manage it.",
|
||||
responses: {
|
||||
204: emptyResponse("Skill deleted successfully."),
|
||||
400: jsonResponse("The skill deletion path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to delete skills.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to delete this skill.", forbiddenSchema),
|
||||
403: jsonResponse("Only the skill creator or a workspace admin can delete skills.", forbiddenSchema),
|
||||
404: jsonResponse("The skill could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -399,7 +399,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
}
|
||||
|
||||
if (!canManageSkill(payload, skill)) {
|
||||
return c.json({ error: "forbidden", message: "Only the skill creator or an org admin can delete skills." }, 403)
|
||||
return c.json({ error: "forbidden", message: "Only the skill creator or a workspace admin can delete skills." }, 403)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
@@ -414,14 +414,14 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.patch(
|
||||
"/v1/orgs/:orgId/skills/:skillId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skills"],
|
||||
tags: ["Skills"],
|
||||
summary: "Update skill",
|
||||
description: "Updates a skill's markdown content and-or sharing visibility while keeping derived metadata in sync.",
|
||||
responses: {
|
||||
200: jsonResponse("Skill updated successfully.", skillResponseSchema),
|
||||
400: jsonResponse("The skill update request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to update skills.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to update this skill.", forbiddenSchema),
|
||||
403: jsonResponse("Only the skill creator or a workspace admin can update skills.", forbiddenSchema),
|
||||
404: jsonResponse("The skill could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -453,7 +453,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
}
|
||||
|
||||
if (!canManageSkill(payload, skill)) {
|
||||
return c.json({ error: "forbidden", message: "Only the skill creator or an org admin can update skills." }, 403)
|
||||
return c.json({ error: "forbidden", message: "Only the skill creator or a workspace admin can update skills." }, 403)
|
||||
}
|
||||
|
||||
const nextSkillText = input.skillText ?? skill.skillText
|
||||
@@ -488,7 +488,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/skill-hubs",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skill Hubs"],
|
||||
tags: ["Skill Hubs"],
|
||||
summary: "Create skill hub",
|
||||
description: "Creates a skill hub that can group skills and assign access to specific members or teams.",
|
||||
responses: {
|
||||
@@ -544,7 +544,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.get(
|
||||
"/v1/orgs/:orgId/skill-hubs",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skill Hubs"],
|
||||
tags: ["Skill Hubs"],
|
||||
summary: "List skill hubs",
|
||||
description: "Lists the skill hubs the current member can access, along with linked skills and access metadata.",
|
||||
responses: {
|
||||
@@ -700,14 +700,14 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.patch(
|
||||
"/v1/orgs/:orgId/skill-hubs/:skillHubId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skill Hubs"],
|
||||
tags: ["Skill Hubs"],
|
||||
summary: "Update skill hub",
|
||||
description: "Updates a skill hub's display name or description.",
|
||||
responses: {
|
||||
200: jsonResponse("Skill hub updated successfully.", skillHubResponseSchema),
|
||||
400: jsonResponse("The skill hub update request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to update skill hubs.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to update this skill hub.", forbiddenSchema),
|
||||
403: jsonResponse("Only the hub creator or a workspace admin can update skill hubs.", forbiddenSchema),
|
||||
404: jsonResponse("The skill hub could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -739,7 +739,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
}
|
||||
|
||||
if (!canManageHub(payload, skillHub)) {
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can update hubs." }, 403)
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or a workspace admin can update hubs." }, 403)
|
||||
}
|
||||
|
||||
const updatedAt = new Date()
|
||||
@@ -769,14 +769,14 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/skill-hubs/:skillHubId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skill Hubs"],
|
||||
tags: ["Skill Hubs"],
|
||||
summary: "Delete skill hub",
|
||||
description: "Deletes a skill hub and removes its access links and skill links.",
|
||||
responses: {
|
||||
204: emptyResponse("Skill hub deleted successfully."),
|
||||
400: jsonResponse("The skill hub deletion path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to delete skill hubs.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to delete this skill hub.", forbiddenSchema),
|
||||
403: jsonResponse("Only the hub creator or a workspace admin can delete skill hubs.", forbiddenSchema),
|
||||
404: jsonResponse("The skill hub could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -806,7 +806,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
}
|
||||
|
||||
if (!canManageHub(payload, skillHub)) {
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can delete hubs." }, 403)
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or a workspace admin can delete hubs." }, 403)
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
@@ -822,14 +822,14 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/skill-hubs/:skillHubId/skills",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skill Hubs"],
|
||||
tags: ["Skill Hubs"],
|
||||
summary: "Add skill to skill hub",
|
||||
description: "Adds an existing organization skill to a skill hub so hub members can discover and use it.",
|
||||
responses: {
|
||||
201: jsonResponse("Skill added to skill hub successfully.", successSchema),
|
||||
400: jsonResponse("The add-skill-to-hub request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to manage skill hub contents.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to add this skill to the skill hub.", forbiddenSchema),
|
||||
403: jsonResponse("Only the hub creator or a workspace admin can manage hub skills, and private skills stay creator-controlled.", forbiddenSchema),
|
||||
404: jsonResponse("The skill hub or skill could not be found.", notFoundSchema),
|
||||
409: jsonResponse("The skill is already attached to the skill hub.", conflictSchema),
|
||||
},
|
||||
@@ -864,7 +864,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
}
|
||||
|
||||
if (!canManageHub(payload, skillHub)) {
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can manage hub skills." }, 403)
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or a workspace admin can manage hub skills." }, 403)
|
||||
}
|
||||
|
||||
const skillRows = await db
|
||||
@@ -881,7 +881,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
if (!canManageSkill(payload, skill) && skill.shared === null) {
|
||||
return c.json({
|
||||
error: "forbidden",
|
||||
message: "Private skills can only be added to hubs by their creator or an org admin.",
|
||||
message: "Private skills can only be added to hubs by their creator or a workspace admin.",
|
||||
}, 403)
|
||||
}
|
||||
|
||||
@@ -910,14 +910,14 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/skill-hubs/:skillHubId/skills/:skillId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skill Hubs"],
|
||||
tags: ["Skill Hubs"],
|
||||
summary: "Remove skill from skill hub",
|
||||
description: "Removes a skill from a skill hub without deleting the underlying skill itself.",
|
||||
responses: {
|
||||
204: emptyResponse("Skill removed from skill hub successfully."),
|
||||
400: jsonResponse("The remove-skill-from-hub path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to manage skill hub contents.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to remove skills from this skill hub.", forbiddenSchema),
|
||||
403: jsonResponse("Only the hub creator or a workspace admin can remove skills from a hub.", forbiddenSchema),
|
||||
404: jsonResponse("The skill hub or hub-skill link could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -949,7 +949,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
}
|
||||
|
||||
if (!canManageHub(payload, skillHub)) {
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can manage hub skills." }, 403)
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or a workspace admin can manage hub skills." }, 403)
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
@@ -973,14 +973,14 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/skill-hubs/:skillHubId/access",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skill Hubs"],
|
||||
tags: ["Skill Hubs"],
|
||||
summary: "Grant skill hub access",
|
||||
description: "Grants a specific member or team access to a skill hub.",
|
||||
responses: {
|
||||
201: jsonResponse("Skill hub access granted successfully.", skillHubAccessResponseSchema),
|
||||
400: jsonResponse("The skill hub access request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to manage skill hub access.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to manage access for this skill hub.", forbiddenSchema),
|
||||
403: jsonResponse("Only the hub creator or a workspace admin can grant hub access.", forbiddenSchema),
|
||||
404: jsonResponse("The skill hub or access target could not be found.", notFoundSchema),
|
||||
409: jsonResponse("The requested access entry already exists.", conflictSchema),
|
||||
},
|
||||
@@ -1017,7 +1017,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
}
|
||||
|
||||
if (!canManageHub(payload, skillHub)) {
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can manage access." }, 403)
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or a workspace admin can manage access." }, 403)
|
||||
}
|
||||
|
||||
if (orgMembershipId) {
|
||||
@@ -1084,14 +1084,14 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/skill-hubs/:skillHubId/access/:accessId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Skill Hubs"],
|
||||
tags: ["Skill Hubs"],
|
||||
summary: "Revoke skill hub access",
|
||||
description: "Revokes one member or team access entry from a skill hub.",
|
||||
responses: {
|
||||
204: emptyResponse("Skill hub access removed successfully."),
|
||||
400: jsonResponse("The skill hub access deletion path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to manage skill hub access.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to manage access for this skill hub.", forbiddenSchema),
|
||||
403: jsonResponse("Only the hub creator or a workspace admin can revoke hub access.", forbiddenSchema),
|
||||
404: jsonResponse("The skill hub or access entry could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -1123,7 +1123,7 @@ export function registerOrgSkillRoutes<T extends { Variables: OrgRouteVariables
|
||||
}
|
||||
|
||||
if (!canManageHub(payload, skillHub)) {
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or an org admin can manage access." }, 403)
|
||||
return c.json({ error: "forbidden", message: "Only the hub creator or a workspace admin can manage access." }, 403)
|
||||
}
|
||||
|
||||
const accessRows = await db
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
requireUserMiddleware,
|
||||
resolveOrganizationContextMiddleware,
|
||||
} from "../../middleware/index.js"
|
||||
import { emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
import {
|
||||
ensureTeamManager,
|
||||
@@ -26,12 +26,12 @@ import {
|
||||
|
||||
const createTeamSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255),
|
||||
memberIds: z.array(z.string().trim().min(1)).optional().default([]),
|
||||
memberIds: z.array(denTypeIdSchema("member")).optional().default([]),
|
||||
})
|
||||
|
||||
const updateTeamSchema = z.object({
|
||||
name: z.string().trim().min(1).max(255).optional(),
|
||||
memberIds: z.array(z.string().trim().min(1)).optional(),
|
||||
memberIds: z.array(denTypeIdSchema("member")).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.name === undefined && value.memberIds === undefined) {
|
||||
ctx.addIssue({
|
||||
@@ -45,16 +45,16 @@ const updateTeamSchema = z.object({
|
||||
type TeamId = typeof TeamTable.$inferSelect.id
|
||||
type MemberId = typeof MemberTable.$inferSelect.id
|
||||
|
||||
const orgTeamParamsSchema = orgIdParamSchema.extend(idParamSchema("teamId").shape)
|
||||
const orgTeamParamsSchema = orgIdParamSchema.extend(idParamSchema("teamId", "team").shape)
|
||||
|
||||
const teamResponseSchema = z.object({
|
||||
team: z.object({
|
||||
id: z.string(),
|
||||
organizationId: z.string(),
|
||||
id: denTypeIdSchema("team"),
|
||||
organizationId: denTypeIdSchema("organization"),
|
||||
name: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
memberIds: z.array(z.string()),
|
||||
memberIds: z.array(denTypeIdSchema("member")),
|
||||
}),
|
||||
}).meta({ ref: "TeamResponse" })
|
||||
|
||||
@@ -87,14 +87,14 @@ export function registerOrgTeamRoutes<T extends { Variables: OrgRouteVariables }
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/teams",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Teams"],
|
||||
tags: ["Teams"],
|
||||
summary: "Create team",
|
||||
description: "Creates a team inside an organization and can optionally attach existing organization members to it.",
|
||||
responses: {
|
||||
201: jsonResponse("Team created successfully.", teamResponseSchema),
|
||||
400: jsonResponse("The team creation request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to create teams.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to manage teams for this organization.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners and admins can create teams.", forbiddenSchema),
|
||||
404: jsonResponse("The organization or a referenced member could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -176,14 +176,14 @@ export function registerOrgTeamRoutes<T extends { Variables: OrgRouteVariables }
|
||||
app.patch(
|
||||
"/v1/orgs/:orgId/teams/:teamId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Teams"],
|
||||
tags: ["Teams"],
|
||||
summary: "Update team",
|
||||
description: "Updates a team's name and-or membership list within an organization.",
|
||||
responses: {
|
||||
200: jsonResponse("Team updated successfully.", teamResponseSchema),
|
||||
400: jsonResponse("The team update request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to update teams.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to manage teams for this organization.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners and admins can update teams.", forbiddenSchema),
|
||||
404: jsonResponse("The team, organization, or a referenced member could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -280,14 +280,14 @@ export function registerOrgTeamRoutes<T extends { Variables: OrgRouteVariables }
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/teams/:teamId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Teams"],
|
||||
tags: ["Teams"],
|
||||
summary: "Delete team",
|
||||
description: "Deletes a team and removes its related hub-access and team-membership records.",
|
||||
responses: {
|
||||
204: emptyResponse("Team deleted successfully."),
|
||||
400: jsonResponse("The team deletion path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to delete teams.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to manage teams for this organization.", forbiddenSchema),
|
||||
403: jsonResponse("Only workspace owners and admins can delete teams.", forbiddenSchema),
|
||||
404: jsonResponse("The team or organization could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describeRoute } from "hono-openapi"
|
||||
import { z } from "zod"
|
||||
import { db } from "../../db.js"
|
||||
import { jsonValidator, paramValidator, requireUserMiddleware, resolveOrganizationContextMiddleware } from "../../middleware/index.js"
|
||||
import { emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
import { idParamSchema, orgIdParamSchema, parseTemplateJson } from "./shared.js"
|
||||
|
||||
@@ -16,15 +16,15 @@ const createTemplateSchema = z.object({
|
||||
})
|
||||
|
||||
const templateSchema = z.object({
|
||||
id: z.string(),
|
||||
organizationId: z.string(),
|
||||
id: denTypeIdSchema("tempTemplateSharing"),
|
||||
organizationId: denTypeIdSchema("organization"),
|
||||
name: z.string(),
|
||||
templateData: z.unknown(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
creator: z.object({
|
||||
memberId: z.string(),
|
||||
userId: z.string(),
|
||||
memberId: denTypeIdSchema("member"),
|
||||
userId: denTypeIdSchema("user"),
|
||||
role: z.string(),
|
||||
name: z.string().nullable(),
|
||||
email: z.string().email().nullable(),
|
||||
@@ -41,13 +41,13 @@ const templateListResponseSchema = z.object({
|
||||
}).meta({ ref: "TemplateListResponse" })
|
||||
|
||||
type TemplateSharingId = typeof TempTemplateSharingTable.$inferSelect.id
|
||||
const orgTemplateParamsSchema = orgIdParamSchema.extend(idParamSchema("templateId").shape)
|
||||
const orgTemplateParamsSchema = orgIdParamSchema.extend(idParamSchema("templateId", "tempTemplateSharing").shape)
|
||||
|
||||
export function registerOrgTemplateRoutes<T extends { Variables: OrgRouteVariables }>(app: Hono<T>) {
|
||||
app.post(
|
||||
"/v1/orgs/:orgId/templates",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Templates"],
|
||||
tags: ["Templates"],
|
||||
summary: "Create shared template",
|
||||
description: "Stores a reusable shared template snapshot inside an organization.",
|
||||
responses: {
|
||||
@@ -103,7 +103,7 @@ export function registerOrgTemplateRoutes<T extends { Variables: OrgRouteVariabl
|
||||
app.get(
|
||||
"/v1/orgs/:orgId/templates",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Templates"],
|
||||
tags: ["Templates"],
|
||||
summary: "List shared templates",
|
||||
description: "Lists the shared templates that belong to an organization, including creator metadata.",
|
||||
responses: {
|
||||
@@ -170,14 +170,14 @@ export function registerOrgTemplateRoutes<T extends { Variables: OrgRouteVariabl
|
||||
app.delete(
|
||||
"/v1/orgs/:orgId/templates/:templateId",
|
||||
describeRoute({
|
||||
tags: ["Organizations", "Organization Templates"],
|
||||
tags: ["Templates"],
|
||||
summary: "Delete shared template",
|
||||
description: "Deletes a shared template when the caller is the template creator or an organization owner.",
|
||||
description: "Deletes a shared template when the caller is the template creator or a workspace owner.",
|
||||
responses: {
|
||||
204: emptyResponse("Template deleted successfully."),
|
||||
400: jsonResponse("The template deletion path parameters were invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to delete templates.", unauthorizedSchema),
|
||||
403: jsonResponse("The caller is not allowed to delete this template.", forbiddenSchema),
|
||||
403: jsonResponse("Only the template creator or a workspace owner can delete templates.", forbiddenSchema),
|
||||
404: jsonResponse("The template or organization could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -211,7 +211,7 @@ export function registerOrgTemplateRoutes<T extends { Variables: OrgRouteVariabl
|
||||
if (!isOwner && !isCreator) {
|
||||
return c.json({
|
||||
error: "forbidden",
|
||||
message: "Only the template creator or organization owner can delete templates.",
|
||||
message: "Only the template creator or a workspace owner can delete templates.",
|
||||
}, 403)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ export function registerWorkerBillingRoutes<T extends { Variables: WorkerRouteVa
|
||||
app.get(
|
||||
"/v1/workers/billing",
|
||||
describeRoute({
|
||||
tags: ["Workers", "Worker Billing"],
|
||||
tags: ["Workers"],
|
||||
hide: true,
|
||||
summary: "Get worker billing status",
|
||||
description: "Returns billing and subscription status for the signed-in user's cloud worker access.",
|
||||
responses: {
|
||||
@@ -74,7 +75,8 @@ export function registerWorkerBillingRoutes<T extends { Variables: WorkerRouteVa
|
||||
app.post(
|
||||
"/v1/workers/billing/subscription",
|
||||
describeRoute({
|
||||
tags: ["Workers", "Worker Billing"],
|
||||
tags: ["Workers"],
|
||||
hide: true,
|
||||
summary: "Update worker subscription settings",
|
||||
description: "Updates whether the user's cloud worker subscription should cancel at the end of the current billing period.",
|
||||
responses: {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describeRoute } from "hono-openapi"
|
||||
import { z } from "zod"
|
||||
import { db } from "../../db.js"
|
||||
import { jsonValidator, paramValidator, queryValidator, requireUserMiddleware, resolveUserOrganizationsMiddleware } from "../../middleware/index.js"
|
||||
import { emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { denTypeIdSchema, emptyResponse, forbiddenSchema, invalidRequestSchema, jsonResponse, notFoundSchema, unauthorizedSchema } from "../../openapi.js"
|
||||
import { getOrganizationLimitStatus } from "../../organization-limits.js"
|
||||
import type { WorkerRouteVariables } from "./shared.js"
|
||||
import {
|
||||
@@ -35,9 +35,9 @@ const workerInstanceSchema = z.object({
|
||||
}).nullable().meta({ ref: "WorkerInstance" })
|
||||
|
||||
const workerSchema = z.object({
|
||||
id: z.string(),
|
||||
orgId: z.string(),
|
||||
createdByUserId: z.string().nullable(),
|
||||
id: denTypeIdSchema("worker"),
|
||||
orgId: denTypeIdSchema("organization"),
|
||||
createdByUserId: denTypeIdSchema("user").nullable(),
|
||||
isMine: z.boolean(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
@@ -339,7 +339,7 @@ export function registerWorkerCoreRoutes<T extends { Variables: WorkerRouteVaria
|
||||
200: jsonResponse("Worker updated successfully.", z.object({ worker: workerSchema }).meta({ ref: "WorkerUpdateResponse" })),
|
||||
400: jsonResponse("The worker update request was invalid.", invalidRequestSchema),
|
||||
401: jsonResponse("The caller must be signed in to update workers.", unauthorizedSchema),
|
||||
403: jsonResponse("Only the worker creator can rename this worker.", forbiddenSchema),
|
||||
403: jsonResponse("Only the worker owner can rename this worker.", forbiddenSchema),
|
||||
404: jsonResponse("The worker could not be found.", notFoundSchema),
|
||||
},
|
||||
}),
|
||||
@@ -372,7 +372,7 @@ export function registerWorkerCoreRoutes<T extends { Variables: WorkerRouteVaria
|
||||
if (worker.created_by_user_id !== user.id) {
|
||||
return c.json({
|
||||
error: "forbidden",
|
||||
message: "Only the worker owner can rename this sandbox.",
|
||||
message: "Only the worker owner can rename this worker.",
|
||||
}, 403)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { getCloudWorkerBillingStatus, requireCloudWorkerAccess, setCloudWorkerSu
|
||||
import { db } from "../../db.js"
|
||||
import { env } from "../../env.js"
|
||||
import type { UserOrganizationsContext } from "../../middleware/index.js"
|
||||
import { denTypeIdSchema } from "../../openapi.js"
|
||||
import type { AuthContextVariables } from "../../session.js"
|
||||
import { deprovisionWorker, provisionWorker } from "../../workers/provisioner.js"
|
||||
import { customDomainForWorker } from "../../workers/vanity-domain.js"
|
||||
@@ -55,7 +56,7 @@ export const activityHeartbeatSchema = z.object({
|
||||
})
|
||||
|
||||
export const workerIdParamSchema = z.object({
|
||||
id: z.string().trim().min(1).max(255),
|
||||
id: denTypeIdSchema("worker"),
|
||||
})
|
||||
|
||||
export type WorkerRouteVariables = AuthContextVariables & Partial<UserOrganizationsContext>
|
||||
|
||||
@@ -21,9 +21,13 @@
|
||||
"build": "tsup"
|
||||
},
|
||||
"dependencies": {
|
||||
"typeid-js": "^1.2.0"
|
||||
"luxon": "^3.6.1",
|
||||
"typeid-js": "^1.2.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/luxon": "^3.6.2",
|
||||
"@types/node": "^20.11.30",
|
||||
"tsup": "^8.5.0",
|
||||
"typescript": "^5.5.4"
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { fromString, getType, typeid } from "typeid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { TypeID, typeid } from "typeid-js"
|
||||
import { v7 as uuidv7 } from "uuid"
|
||||
import { z } from "zod"
|
||||
|
||||
export const denTypeIdPrefixes = {
|
||||
export const TYPE_ID_SUFFIX_LENGTH = 26
|
||||
|
||||
const BASE32_REGEX = /^[0-9a-hjkmnp-tv-z]+$/
|
||||
|
||||
export const idTypesMapNameToPrefix = {
|
||||
request: "req",
|
||||
user: "usr",
|
||||
session: "ses",
|
||||
@@ -33,40 +40,165 @@ export const denTypeIdPrefixes = {
|
||||
auditEvent: "aev",
|
||||
} as const
|
||||
|
||||
export type DenTypeIdName = keyof typeof denTypeIdPrefixes
|
||||
export type DenTypeIdPrefix<TName extends DenTypeIdName> = (typeof denTypeIdPrefixes)[TName]
|
||||
export type DenTypeId<TName extends DenTypeIdName> = `${DenTypeIdPrefix<TName>}_${string}`
|
||||
export const denTypeIdPrefixes = idTypesMapNameToPrefix
|
||||
|
||||
type IdTypesMapNameToPrefix = typeof idTypesMapNameToPrefix
|
||||
type IdTypesMapPrefixToName = {
|
||||
[K in keyof IdTypesMapNameToPrefix as IdTypesMapNameToPrefix[K]]: K
|
||||
}
|
||||
|
||||
const idTypesMapPrefixToName = Object.fromEntries(
|
||||
Object.entries(idTypesMapNameToPrefix).map(([name, prefix]) => [prefix, name]),
|
||||
) as IdTypesMapPrefixToName
|
||||
|
||||
export type IdTypePrefixNames = keyof typeof idTypesMapNameToPrefix
|
||||
export type DenTypeIdName = IdTypePrefixNames
|
||||
export type TypeId<T extends IdTypePrefixNames> = `${IdTypesMapNameToPrefix[T]}_${string}`
|
||||
export type DenTypeId<TName extends DenTypeIdName> = TypeId<TName>
|
||||
|
||||
type TypeIdSchema<T extends IdTypePrefixNames> = z.ZodType<TypeId<T>, string>
|
||||
|
||||
const schemaCache = new Map<IdTypePrefixNames, z.ZodType<string, string>>()
|
||||
|
||||
const buildTypeIdSchema = <const T extends IdTypePrefixNames>(prefix: T): TypeIdSchema<T> => {
|
||||
const expectedPrefix = idTypesMapNameToPrefix[prefix]
|
||||
const expectedLength = TYPE_ID_SUFFIX_LENGTH + expectedPrefix.length + 1
|
||||
|
||||
return z
|
||||
.string()
|
||||
.length(expectedLength, {
|
||||
message: `TypeID must be ${expectedLength} characters (${expectedPrefix}_<26 char suffix>)`,
|
||||
})
|
||||
.startsWith(`${expectedPrefix}_`, {
|
||||
message: `TypeID must start with '${expectedPrefix}_'`,
|
||||
})
|
||||
.refine(
|
||||
(input) => {
|
||||
const suffix = input.slice(expectedPrefix.length + 1)
|
||||
return BASE32_REGEX.test(suffix)
|
||||
},
|
||||
{ message: "TypeID suffix contains invalid base32 characters" },
|
||||
)
|
||||
.refine(
|
||||
(input) => {
|
||||
try {
|
||||
TypeID.fromString(input)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ message: "TypeID is structurally invalid" },
|
||||
)
|
||||
.transform((input) => TypeID.fromString(input).toString() as TypeId<T>)
|
||||
}
|
||||
|
||||
const typeIdZodSchema = <const T extends IdTypePrefixNames>(prefix: T): TypeIdSchema<T> => {
|
||||
let schema = schemaCache.get(prefix)
|
||||
if (!schema) {
|
||||
schema = buildTypeIdSchema(prefix)
|
||||
schemaCache.set(prefix, schema)
|
||||
}
|
||||
return schema as TypeIdSchema<T>
|
||||
}
|
||||
|
||||
const typeIdGenerator = <const T extends IdTypePrefixNames>(
|
||||
prefix: T,
|
||||
) => typeid(idTypesMapNameToPrefix[prefix]).toString() as TypeId<T>
|
||||
|
||||
const validateTypeId = <const T extends IdTypePrefixNames>(
|
||||
prefix: T,
|
||||
data: unknown,
|
||||
): data is TypeId<T> => typeIdZodSchema(prefix).safeParse(data).success
|
||||
|
||||
const inferTypeId = <T extends keyof IdTypesMapPrefixToName>(
|
||||
input: `${T}_${string}`,
|
||||
): IdTypesMapPrefixToName[T] => {
|
||||
const parsed = TypeID.fromString(input)
|
||||
const prefix = parsed.getType() as T
|
||||
const typeName = idTypesMapPrefixToName[prefix]
|
||||
|
||||
if (typeName === undefined) {
|
||||
throw new Error(
|
||||
`Unknown TypeID prefix '${prefix}'. Registered prefixes: ${Object.keys(idTypesMapPrefixToName).join(", ")}`,
|
||||
)
|
||||
}
|
||||
|
||||
return typeName
|
||||
}
|
||||
|
||||
const typeIdFromString = <const T extends IdTypePrefixNames>(
|
||||
typeName: T,
|
||||
input: string,
|
||||
): TypeId<T> => {
|
||||
const parsed = TypeID.fromString(input)
|
||||
const expectedPrefix = idTypesMapNameToPrefix[typeName]
|
||||
const actualPrefix = parsed.getType()
|
||||
|
||||
if (actualPrefix !== expectedPrefix) {
|
||||
throw new Error(
|
||||
`TypeID prefix mismatch: expected '${expectedPrefix}' but got '${actualPrefix}'`,
|
||||
)
|
||||
}
|
||||
|
||||
return parsed.toString() as TypeId<T>
|
||||
}
|
||||
|
||||
const typeIdWithTimestamp = <const T extends IdTypePrefixNames>(
|
||||
typeName: T,
|
||||
timestamp?: Date | number,
|
||||
): TypeId<T> => {
|
||||
let msecs: number
|
||||
|
||||
if (timestamp === undefined) {
|
||||
msecs = DateTime.now().toMillis()
|
||||
} else if (timestamp instanceof Date) {
|
||||
msecs = timestamp.getTime()
|
||||
} else {
|
||||
msecs = timestamp
|
||||
}
|
||||
|
||||
if (!Number.isFinite(msecs)) {
|
||||
throw new Error(`Invalid timestamp: expected finite number, got ${msecs}`)
|
||||
}
|
||||
if (msecs < 0) {
|
||||
throw new Error(`Invalid timestamp: expected non-negative number, got ${msecs}`)
|
||||
}
|
||||
|
||||
const uuid = uuidv7({ msecs })
|
||||
const prefix = idTypesMapNameToPrefix[typeName]
|
||||
return TypeID.fromUUID(prefix, uuid).toString() as TypeId<T>
|
||||
}
|
||||
|
||||
const getColumnLength = <const T extends IdTypePrefixNames>(typeName: T) =>
|
||||
idTypesMapNameToPrefix[typeName].length + 1 + TYPE_ID_SUFFIX_LENGTH
|
||||
|
||||
export const typeId = {
|
||||
schema: typeIdZodSchema,
|
||||
generator: typeIdGenerator,
|
||||
generatorWithTimestamp: typeIdWithTimestamp,
|
||||
validator: validateTypeId,
|
||||
infer: inferTypeId,
|
||||
fromString: typeIdFromString,
|
||||
suffixLength: TYPE_ID_SUFFIX_LENGTH,
|
||||
prefix: idTypesMapNameToPrefix,
|
||||
columnLength: getColumnLength,
|
||||
}
|
||||
|
||||
export function createDenTypeId<TName extends DenTypeIdName>(name: TName): DenTypeId<TName> {
|
||||
return typeid(denTypeIdPrefixes[name]).toString() as DenTypeId<TName>
|
||||
return typeId.generator(name)
|
||||
}
|
||||
|
||||
export function normalizeDenTypeId<TName extends DenTypeIdName>(
|
||||
name: TName,
|
||||
value: string,
|
||||
): DenTypeId<TName> {
|
||||
const parsed = fromString(value)
|
||||
const expectedPrefix = denTypeIdPrefixes[name]
|
||||
|
||||
if (getType(parsed) !== expectedPrefix) {
|
||||
throw new Error(`invalid_den_typeid_prefix:${name}:${getType(parsed)}`)
|
||||
}
|
||||
|
||||
return parsed as DenTypeId<TName>
|
||||
return typeId.fromString(name, value)
|
||||
}
|
||||
|
||||
export function isDenTypeId<TName extends DenTypeIdName>(
|
||||
name: TName,
|
||||
value: unknown,
|
||||
): value is DenTypeId<TName> {
|
||||
if (typeof value !== "string") {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
normalizeDenTypeId(name, value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return typeId.validator(name, value)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user