kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
Porównaj commity
No commits in common. "@agentic/llamaindex@7.3.6" and "main" have entirely different histories.
@agentic/l
...
main
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"$schema": "https://unpkg.com/@changesets/config@3.0.2/schema.json",
|
||||
"changelog": "@changesets/cli/changelog",
|
||||
"commit": false,
|
||||
"fixed": [],
|
||||
"linked": [],
|
||||
"access": "restricted",
|
||||
"baseBranch": "main",
|
||||
"privatePackages": false,
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": []
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
---
|
||||
description: General TypeScript coding guidelines
|
||||
globs:
|
||||
---
|
||||
|
||||
## General
|
||||
|
||||
- Write elegant, concise, and readable code
|
||||
- Prefer `const` over `let` (never use `var`)
|
||||
- Use kebab-case for file and directory names
|
||||
- Use clear, descriptive names for variables, functions, and components
|
||||
|
||||
## Modules
|
||||
|
||||
### Imports & Exports
|
||||
|
||||
- Always use ESM `import` and `export` (never use CJS `require`)
|
||||
- File imports should never use an extension (NOT `.js`, `.ts` or `.tsx`).
|
||||
- GOOD examples:
|
||||
- `import { Foo } from './foo'`
|
||||
- `import { type Route } from './types/root'`
|
||||
- `import zod from 'zod'`
|
||||
- `import { logger } from '~/types'`
|
||||
- BAD examples:
|
||||
- `import { Foo } from './foo.js'`
|
||||
- `import { type Route } from './types/root.js'`
|
||||
- `import { Foo } from './foo.ts'`
|
||||
- Always prefer named exports over default exports
|
||||
|
||||
### Packages
|
||||
|
||||
All packages must follow these `package.json` rules:
|
||||
|
||||
- `type` must be set to `module`
|
||||
|
||||
## TypeScript
|
||||
|
||||
- Avoid semicolons at the end of lines
|
||||
- Use TypeScript's utility types (e.g., `Partial`, `Pick`, `Omit`) to manipulate existing types
|
||||
- Create custom types for complex data structures used throughout the application
|
||||
- If possible, avoid using `any`/`unknown` or casting values like `(value as any)` in TypeScript outside of test files e.g. `*.test.ts` or test fixtures e.g. `**/test-data.ts`.
|
||||
- Don't rely on `typeof`, `ReturnType<>`, `Awaited<>`, etc for complex type inference (it's ok for simple types)
|
||||
- You can use `as const` as needed for better type inference
|
||||
- Functions should accept an object parameter instead of multiple parameters
|
||||
- Good examples:
|
||||
```ts
|
||||
function myFunction({ foo, bar }: { foo: boolean; bar: string }) {}
|
||||
function VideoPlayer({ sid }: { sid: string }) {}
|
||||
```
|
||||
- Bad examples:
|
||||
```ts
|
||||
function myFunction(foo: boolean, bar: string, baz: number) {}
|
||||
```
|
||||
- Arguments should generally be destructured in the function definition, not the function body.
|
||||
- Good example:
|
||||
```ts
|
||||
function myFunction({ foo, bar }: { foo: boolean; bar: string }) {}
|
||||
```
|
||||
- Bad example:
|
||||
```ts
|
||||
function myFunction(args: { foo: boolean; bar: string }) {
|
||||
const { foo, bar } = args
|
||||
}
|
||||
```
|
||||
- Zod should be used to parse untrusted data, but not for data that is trusted like function arguments
|
||||
- Prefer Zod unions over Zod enums
|
||||
- For example, this union `z.union([ z.literal('youtube'), z.literal('spotify') ])` is better than this enum `z.enum([ 'youtube', 'spotify' ])`
|
||||
- Promises (and `async` functions which implicitly create Promises) must always be properly handled, either via:
|
||||
- Using `await` to wait for the Promise to resolve successfully
|
||||
- Using `.then` or `.catch` to handle Promise resolution
|
||||
- Returning a Promise to a calling function which itself has to handle the Promise.
|
||||
|
||||
## Node.js
|
||||
|
||||
- Utilize the `node:` protocol when importing Node.js modules (e.g., `import fs from 'node:fs/promises'`)
|
||||
- Prefer promise-based APIs over Node's legacy callback APIs
|
||||
- Use environment variables for secrets (avoid hardcoding sensitive information)
|
||||
|
||||
### Web Standard APIs
|
||||
|
||||
Always prefer using standard web APIs like `fetch`, `WebSocket`, and `ReadableStream` when possible. Avoid redundant libraries (like `node-fetch`).
|
||||
|
||||
- Prefer the `fetch` API for making HTTP requests instead of Node.js modules like `http` or `https`
|
||||
- Use the native `fetch` API instead of `node-fetch` or polyfilled `cross-fetch`
|
||||
- Use the `ky` library for HTTP requests instead of `axios` or `superagent`
|
||||
- Use the WHATWG `URL` and `URLSearchParams` classes instead of the Node.js `url` module
|
||||
- Use `Request` and `Response` objects from the Fetch API instead of Node.js-specific request and response objects
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Prefer `async`/`await` over `.then()` and `.catch()`
|
||||
- Always handle errors correctly (eg: `try`/`catch` or `.catch()`)
|
||||
- Avoid swallowing errors silently; always log or handle caught errors appropriately
|
||||
|
||||
## Comments
|
||||
|
||||
Comments should be used to document and explain code. They should complement the use of descriptive variable and function names and type declarations.
|
||||
|
||||
- Add comments to explain complex sections of code
|
||||
- Add comments that will improve the autocompletion preview in IDEs (eg: functions and types)
|
||||
- Don't add comments that just reword symbol names or repeat type declarations
|
||||
- Use **JSDoc** formatting for comments (not TSDoc or inline comments)
|
||||
|
||||
## Logging
|
||||
|
||||
- Just use `console` for logging.
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Testing
|
||||
|
||||
- **All unit tests should use Vitest**
|
||||
- DO NOT attempt to install or use other testing libraries like Jest
|
||||
- Test files should be named `[target].test.ts` and placed in the same directory as the code they are testing (NOT a separate directory)
|
||||
- Good example: `src/my-file.ts` and `src/my-file.test.ts`
|
||||
- Bad example: `src/my-file.ts` and `src/test/my-file.test.ts` or `test/my-file.test.ts` or `src/__tests__/my-file.test.ts`
|
||||
- Tests should be run with `pnpm test:unit`
|
||||
- It's acceptable to use `any`/`unknown` in test files (such as `*.test.ts`) or test fixtures (like `**/test-data.ts`) to facilitate mocking or stubbing external modules or partial function arguments, referencing the usage guidelines in the TypeScript section.
|
||||
- Frontend react code does not need unit tests
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- Test critical business logic and edge cases
|
||||
- Don't add tests for trivial code or just to increase test coverage
|
||||
- Don't make tests too brittle or flaky by relying on implementation details
|
||||
|
||||
## Git
|
||||
|
||||
- When possible, combine the `git add` and `git commit` commands into a single `git commit -am` command, to speed things up
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"root": true,
|
||||
"extends": ["@fisch0920/eslint-config/node"],
|
||||
"ignorePatterns": ["out"]
|
||||
}
|
|
@ -11,9 +11,12 @@ jobs:
|
|||
fail-fast: true
|
||||
matrix:
|
||||
node-version:
|
||||
- 18
|
||||
- 22
|
||||
- 23
|
||||
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
@ -24,5 +27,14 @@ jobs:
|
|||
cache: 'pnpm'
|
||||
|
||||
- run: pnpm install --frozen-lockfile --strict-peer-dependencies
|
||||
|
||||
- name: Cache turbo build setup
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .turbo
|
||||
key: ${{ runner.os }}-node-${{ matrix.node-version }}-turbo-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.node-version }}-turbo-
|
||||
|
||||
- run: pnpm build
|
||||
- run: pnpm test
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: pnpm
|
||||
- run: pnpm dlx changelogithub
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
@ -25,9 +25,19 @@ yarn-debug.log*
|
|||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
# dotenv files
|
||||
.env
|
||||
.env.production
|
||||
.env.staging
|
||||
.env.test
|
||||
.env*.local
|
||||
|
||||
# cloudflare env vars
|
||||
.dev.vars
|
||||
.dev.vars.production
|
||||
.dev.vars.staging
|
||||
.dev.vars.test
|
||||
|
||||
# turbo
|
||||
.turbo
|
||||
|
||||
|
@ -38,7 +48,13 @@ yarn-error.log*
|
|||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.env
|
||||
|
||||
old/
|
||||
out/
|
||||
|
||||
.wrangler
|
||||
.sentryclirc
|
||||
.eslintcache
|
||||
.nitro
|
||||
.tanstack
|
||||
|
||||
.xmcp
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
npm run precommit
|
|
@ -1 +1,5 @@
|
|||
out
|
||||
# autogenerated files
|
||||
packages/types/src/openapi.d.ts
|
||||
apps/web/src/routeTree.gen.ts
|
||||
legacy/packages/openapi-to-ts/fixtures/generated
|
||||
examples/mcp-servers/xmcp/xmcp-env.d.ts
|
||||
|
|
11
.prettierrc
11
.prettierrc
|
@ -1,11 +0,0 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"jsxSingleQuote": true,
|
||||
"semi": false,
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"trailingComma": "none"
|
||||
}
|
|
@ -0,0 +1,76 @@
|
|||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug API",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
|
||||
// Debug server in VSCode
|
||||
"cwd": "${workspaceFolder}/apps/api",
|
||||
"program": "src/server.ts",
|
||||
// "program": "${file}",
|
||||
|
||||
/*
|
||||
* Path to tsx binary
|
||||
* Assuming locally installed
|
||||
*/
|
||||
"runtimeExecutable": "tsx",
|
||||
|
||||
/*
|
||||
* Open terminal when debugging starts (Optional)
|
||||
* Useful to see console.logs
|
||||
*/
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
|
||||
// Files to exclude from debugger (e.g. call stack)
|
||||
"skipFiles": [
|
||||
// Node.js internal core modules
|
||||
"<node_internals>/**"
|
||||
|
||||
// Ignore all dependencies (optional)
|
||||
// "${workspaceFolder}/node_modules/**"
|
||||
]
|
||||
}
|
||||
// Wrangler's vscode support seems to be extremely buggy. It sometimes works
|
||||
// 1/10th of the time, but nothing I tried could improve that consistency.
|
||||
// Will use browser debugger instead for now.
|
||||
// {
|
||||
// "name": "gateway",
|
||||
// "type": "node",
|
||||
// "request": "attach",
|
||||
// "port": 9229,
|
||||
// "cwd": "${workspaceFolder}/apps/gateway",
|
||||
// // "cwd": "${workspaceFolder}",
|
||||
// // "cwd": "/",
|
||||
// "attachExistingChildren": false,
|
||||
// "autoAttachChildProcesses": false,
|
||||
// "sourceMaps": true,
|
||||
// "outFiles": ["${workspaceFolder}/apps/gateway/.wrangler/tmp/**/*"],
|
||||
// "resolveSourceMapLocations": null,
|
||||
// // "resolveSourceMapLocations": ["**", "!**/node_modules/**"],
|
||||
// "skipFiles": ["<node_internals>/**"],
|
||||
// "internalConsoleOptions": "neverOpen",
|
||||
// "restart": true
|
||||
// },
|
||||
// {
|
||||
// "name": "Wrangler",
|
||||
// "type": "node",
|
||||
// "request": "attach",
|
||||
// "port": 9229,
|
||||
// "cwd": "/",
|
||||
// "resolveSourceMapLocations": null,
|
||||
// "attachExistingChildren": false,
|
||||
// "autoAttachChildProcesses": false,
|
||||
// "sourceMaps": true // works with or without this line (supposedly)
|
||||
// }
|
||||
]
|
||||
// "compounds": [
|
||||
// {
|
||||
// "name": "Debug Workers",
|
||||
// "configurations": ["gateway"],
|
||||
// "stopAll": true
|
||||
// }
|
||||
// ]
|
||||
}
|
|
@ -0,0 +1,219 @@
|
|||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a monorepo for Agentic - a platform that provides API gateway services for MCP (Model Context Protocol) and OpenAPI integrations.
|
||||
|
||||
### Core Architecture
|
||||
|
||||
The platform consists of:
|
||||
|
||||
- **API Service** (`apps/api/`) - Platform backend API with authentication, billing, and resource management
|
||||
- **Gateway Service** (`apps/gateway/`) - Cloudflare Worker that proxies requests to origin MCP/OpenAPI services
|
||||
- **Website** (`apps/web/`) - Next.js site for both the marketing site and authenticated webapp
|
||||
- **E2E Tests** (`apps/e2e/`) - End-to-end test suite for HTTP and MCP gateway requests
|
||||
- **Shared Packages** (`packages/`) - Common utilities, types, validators, and config
|
||||
- **StdLib Packages** (`stdlib/`) - TS AI SDK adapters
|
||||
|
||||
The gateway accepts HTTP requests at `https://gateway.agentic.so/deploymentIdentifier/tool-name` or `https://gateway.agentic.so/deploymentIdentifier/mcp` for MCP.
|
||||
|
||||
### Development Commands
|
||||
|
||||
**Main development workflow:**
|
||||
|
||||
- `pnpm dev` - Start all services in development mode
|
||||
- `pnpm build` - Build all packages and apps (except for the website)
|
||||
- `pnpm test` - Run all tests (format, lint, typecheck, unit, but not e2e tests)
|
||||
- `pnpm clean` - Clean all build artifacts
|
||||
|
||||
**Individual test commands:**
|
||||
|
||||
- `pnpm test:format` - Check code formatting with Prettier
|
||||
- `pnpm test:lint` - Run ESLint across all packages
|
||||
- `pnpm test:typecheck` - Run TypeScript type checking
|
||||
- `pnpm test:unit` - Run unit tests with Vitest
|
||||
|
||||
**Code quality:**
|
||||
|
||||
- `pnpm fix` - Auto-fix formatting and linting issues
|
||||
- `pnpm knip` - Check for unused dependencies
|
||||
|
||||
**E2E testing:**
|
||||
|
||||
- (from the `apps/e2e` directory)
|
||||
- `pnpm e2e` - Run all E2E tests
|
||||
- `pnpm e2e-http` - Run HTTP edge E2E tests
|
||||
- `pnpm e2e-mcp` - Run MCP edge E2E tests
|
||||
|
||||
### Key Database Models
|
||||
|
||||
The system uses Drizzle ORM with PostgreSQL. Core entities:
|
||||
|
||||
- **User** - Platform users
|
||||
- **Team** - Organizations with members and billing
|
||||
- **Project** - Namespace API products comprised of immutable Deployments
|
||||
- **Deployment** - Immutable instances of MCP/OpenAPI services, including gateway and pricing config
|
||||
- **Consumer** - Customer subscription tracking usage and billing
|
||||
|
||||
### Agentic Configuration
|
||||
|
||||
Agentic projects use `agentic.config.{ts,js,json}` files to define:
|
||||
|
||||
- Project name and metadata
|
||||
- Origin adapter (MCP server or OpenAPI spec)
|
||||
- Tool configurations and permissions
|
||||
- Pricing plans and rate limits
|
||||
- Authentication requirements
|
||||
|
||||
The platform supports both MCP servers and OpenAPI specifications as origin adapters.
|
||||
|
||||
### Gateway Request Flow
|
||||
|
||||
1. Request hits gateway with deployment identifier
|
||||
2. Gateway validates consumer authentication/rate limits/caching
|
||||
3. Request is transformed and forwarded to origin service
|
||||
4. Response is processed and returned with appropriate headers
|
||||
5. Usage is tracked for billing and analytics
|
||||
|
||||
### Environment Setup
|
||||
|
||||
All apps require environment variables for:
|
||||
|
||||
- Database connections (`DATABASE_URL`)
|
||||
- External services (Stripe, GitHub, Resend, Sentry)
|
||||
- Internal services (API, gateway, etc)
|
||||
- Authentication secrets
|
||||
- Stripe secrets
|
||||
- Admin API keys
|
||||
- Sentry DSN
|
||||
- etc
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
### General
|
||||
|
||||
- Write elegant, concise, and readable code
|
||||
- Prefer `const` over `let` (never use `var`)
|
||||
- Use kebab-case for file and directory names
|
||||
- Use clear, descriptive names for variables, functions, and components
|
||||
|
||||
### Modules
|
||||
|
||||
- Always use ESM `import` and `export` (never use CJS `require`)
|
||||
- File imports should never use an extension (NOT `.js`, `.ts` or `.tsx`).
|
||||
- GOOD examples:
|
||||
- `import { Foo } from './foo'`
|
||||
- `import { type Route } from './types/root'`
|
||||
- `import zod from 'zod'`
|
||||
- `import { logger } from '~/types'`
|
||||
- BAD examples:
|
||||
- `import { Foo } from './foo.js'`
|
||||
- `import { type Route } from './types/root.js'`
|
||||
- `import { Foo } from './foo.ts'`
|
||||
- Always prefer named exports over default exports except for when default exports are required (like in Next.js `page.tsx` components)
|
||||
|
||||
### Packages
|
||||
|
||||
All packages must follow these `package.json` rules:
|
||||
|
||||
- `type` must be set to `module`
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Avoid semicolons at the end of lines
|
||||
- Use TypeScript's utility types (e.g., `Partial`, `Pick`, `Omit`) to manipulate existing types
|
||||
- Create custom types for complex data structures used throughout the application
|
||||
- If possible, avoid using `any`/`unknown` or casting values like `(value as any)` in TypeScript outside of test files e.g. `*.test.ts` or test fixtures e.g. `**/test-data.ts`.
|
||||
- Try not to rely on `typeof`, `ReturnType<>`, `Awaited<>`, etc for complex type inference (it's ok for simple types)
|
||||
- You can use `as const` as needed for better type inference
|
||||
- Functions should accept an object parameter instead of multiple parameters
|
||||
- GOOD examples:
|
||||
```ts
|
||||
function myFunction({ foo, bar }: { foo: boolean; bar: string }) {}
|
||||
function VideoPlayer({ sid }: { sid: string }) {}
|
||||
```
|
||||
- BAD examples:
|
||||
```ts
|
||||
function myFunction(foo: boolean, bar: string, baz: number) {}
|
||||
```
|
||||
- Arguments should generally be destructured in the function definition, not the function body.
|
||||
- GOOD example:
|
||||
```ts
|
||||
function myFunction({ foo, bar }: { foo: boolean; bar: string }) {}
|
||||
function exampleWithOptionalParams({
|
||||
foo = 'example'
|
||||
}: { foo?: string } = {}) {}
|
||||
```
|
||||
- BAD example:
|
||||
```ts
|
||||
function myFunction(opts: { foo: boolean; bar: string }) {
|
||||
const { foo, bar } = opts
|
||||
}
|
||||
```
|
||||
- Zod should be used to parse untrusted data, but not for data that is trusted like function arguments
|
||||
- Prefer Zod unions over Zod enums
|
||||
- For example, this union `z.union([ z.literal('youtube'), z.literal('spotify') ])` is better than this enum `z.enum([ 'youtube', 'spotify' ])`
|
||||
- Promises (and `async` functions which implicitly create Promises) must always be properly handled, either via:
|
||||
- Using `await` to wait for the Promise to resolve successfully
|
||||
- Using `.then` or `.catch` to handle Promise resolution
|
||||
- Returning a Promise to a calling function which itself has to handle the Promise.
|
||||
|
||||
## Node.js
|
||||
|
||||
- Utilize the `node:` protocol when importing Node.js modules (e.g., `import fs from 'node:fs/promises'`)
|
||||
- Prefer promise-based APIs over Node's legacy callback APIs
|
||||
- Use environment variables for secrets (avoid hardcoding sensitive information)
|
||||
|
||||
### Web Standard APIs
|
||||
|
||||
Always prefer using standard web APIs like `fetch`, `WebSocket`, and `ReadableStream` when possible. Avoid redundant libraries (like `node-fetch`).
|
||||
|
||||
- Prefer the `fetch` API for making HTTP requests instead of Node.js modules like `http` or `https`
|
||||
- Prefer using the `ky` `fetch` wrapper for HTTP requests instead of `axios`, `superagent`, `node-fetch` or any other HTTP request library
|
||||
- Never use `node-fetch`; prefer `ky` or native `fetch` directly
|
||||
- Use the WHATWG `URL` and `URLSearchParams` classes instead of the Node.js `url` module
|
||||
- Use `Request` and `Response` objects from the Fetch API instead of Node.js-specific request and response objects
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Prefer `async`/`await` over `.then()` and `.catch()`
|
||||
- Always handle errors correctly (eg: `try`/`catch` or `.catch()`)
|
||||
- Avoid swallowing errors silently; always log or handle caught errors appropriately
|
||||
|
||||
### Comments
|
||||
|
||||
Comments should be used to document and explain code. They should complement the use of descriptive variable and function names and type declarations.
|
||||
|
||||
- Add comments to explain complex sections of code
|
||||
- Add comments that will improve the autocompletion preview in IDEs (eg: functions and types)
|
||||
- Don't add comments that just reword symbol names or repeat type declarations
|
||||
- Use **JSDoc** formatting for comments (not TSDoc or inline comments)
|
||||
|
||||
### Logging
|
||||
|
||||
- Just use `console` for logging.
|
||||
|
||||
### Testing
|
||||
|
||||
#### Unit Testing
|
||||
|
||||
- **All unit tests should use Vitest**
|
||||
- DO NOT attempt to install or use other testing libraries like Jest
|
||||
- Test files should be named `[target].test.ts` and placed in the same directory as the code they are testing (NOT a separate directory)
|
||||
- GOOD example: `src/my-file.ts` and `src/my-file.test.ts`
|
||||
- BAD example: `src/my-file.ts` and `src/test/my-file.test.ts` or `test/my-file.test.ts` or `src/__tests__/my-file.test.ts`
|
||||
- Tests should be run with `pnpm test:unit`
|
||||
- You may use `any`/`unknown` in test files (such as `*.test.ts`) or test fixtures (like `**/test-data.ts`) to facilitate mocking or stubbing external modules or partial function arguments, referencing the usage guidelines in the TypeScript section.
|
||||
- Frontend react code does not need unit tests
|
||||
|
||||
#### Test Coverage
|
||||
|
||||
- Test critical business logic and edge cases
|
||||
- Don't add tests for trivial code or just to increase test coverage
|
||||
- Don't make tests too brittle or flaky by relying on implementation details
|
||||
|
||||
### Git
|
||||
|
||||
- When possible, combine the `git add` and `git commit` commands into a single `git commit -am` command, to speed things up
|
|
@ -0,0 +1,60 @@
|
|||
# 🌊 run `tilt up` to start
|
||||
# then open http://localhost:10350/r/(all)/overview
|
||||
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
# Find docs on Tilt at https://docs.tilt.dev/api.html#api.local_resource
|
||||
# Find icons at https://fonts.google.com/icons
|
||||
|
||||
local_resource(
|
||||
'🍍 API',
|
||||
serve_dir='apps/api',
|
||||
serve_cmd='pnpm dev:server',
|
||||
links=[ link('http://localhost:3001/v1/health', 'API'), ],
|
||||
labels=['Agentic']
|
||||
)
|
||||
|
||||
local_resource(
|
||||
'🌶️ Web',
|
||||
serve_dir='apps/web',
|
||||
serve_cmd='pnpm dev',
|
||||
links=[ link('http://localhost:3000', 'Web'), ],
|
||||
labels=['Agentic']
|
||||
)
|
||||
|
||||
local_resource(
|
||||
'🍉 Gateway',
|
||||
serve_dir='apps/gateway',
|
||||
serve_cmd='pnpm dev',
|
||||
labels=['Agentic']
|
||||
)
|
||||
|
||||
local_resource(
|
||||
'🧪 E2E Tests',
|
||||
cmd='echo 0',
|
||||
labels=['Testing'],
|
||||
auto_init=False
|
||||
)
|
||||
|
||||
local_resource(
|
||||
'🔍 Drizzle Studio',
|
||||
serve_dir='apps/api',
|
||||
serve_cmd='pnpm drizzle-kit studio',
|
||||
links=[ link('https://local.drizzle.studio', 'Drizzle Studio'), ],
|
||||
labels=['Services'],
|
||||
)
|
||||
|
||||
local_resource(
|
||||
'💸 Stripe Webhooks',
|
||||
serve_dir='apps/api',
|
||||
serve_cmd='pnpm dev:stripe',
|
||||
# links=[ link('http://localhost:4983', 'Stripe Webhooks'), ],
|
||||
labels=['Services'],
|
||||
)
|
||||
|
||||
cmd_button(
|
||||
'Seed Database',
|
||||
argv=['sh', '-c', 'cd apps/e2e && pnpm run seed-db'],
|
||||
location=location.NAV,
|
||||
icon_name='nature',
|
||||
text='Seed Database',
|
||||
)
|
|
@ -0,0 +1,40 @@
|
|||
# ------------------------------------------------------------------------------
|
||||
# This is an example .env file.
|
||||
#
|
||||
# All of these environment vars must be defined either in your environment or in
|
||||
# a local .env file in order to run this project.
|
||||
# ------------------------------------------------------------------------------
|
||||
|
||||
DATABASE_URL=
|
||||
|
||||
AGENTIC_WEB_BASE_URL=
|
||||
AGENTIC_GATEWAY_BASE_URL=
|
||||
AGENTIC_STORAGE_BASE_URL='https://storage.agentic.so'
|
||||
|
||||
JWT_SECRET=
|
||||
|
||||
# SENTRY_DSN is optional (not set by default in development environment)
|
||||
SENTRY_DSN=
|
||||
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
RESEND_API_KEY=
|
||||
|
||||
# Used to make admin API calls from the API gateway
|
||||
AGENTIC_ADMIN_API_KEY=
|
||||
|
||||
# Used to simplify recreating the demo `@agentic/search` project during
|
||||
# development while we're frequently resetting the database
|
||||
AGENTIC_SEARCH_PROXY_SECRET=
|
||||
|
||||
# s3 connection settings (compatible with cloudflare r2)
|
||||
S3_BUCKET='agentic'
|
||||
S3_REGION='auto'
|
||||
# example: "https://<id>.r2.cloudflarestorage.com"
|
||||
S3_ENDPOINT=
|
||||
S3_ACCESS_KEY_ID=
|
||||
S3_ACCESS_KEY_SECRET=
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'drizzle-kit'
|
||||
|
||||
export default defineConfig({
|
||||
out: './drizzle',
|
||||
schema: './src/db/schema/index.ts',
|
||||
dialect: 'postgresql',
|
||||
dbCredentials: {
|
||||
// eslint-disable-next-line no-process-env
|
||||
url: process.env.DATABASE_URL!
|
||||
}
|
||||
})
|
|
@ -0,0 +1,68 @@
|
|||
{
|
||||
"name": "api",
|
||||
"private": true,
|
||||
"version": "8.4.4",
|
||||
"description": "Internal Agentic platform API service.",
|
||||
"author": "Travis Fischer <travis@transitivebullsh.it>",
|
||||
"license": "AGPL-3.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/transitive-bullshit/agentic.git",
|
||||
"directory": "apps/api"
|
||||
},
|
||||
"type": "module",
|
||||
"source": "./src/server.ts",
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "run-p dev:*",
|
||||
"dev:server": "dotenvx run -- tsx src/server.ts",
|
||||
"dev:stripe": "dotenvx run -- stripe listen --forward-to http://localhost:3001/v1/webhooks/stripe",
|
||||
"prod": "run-p prod:*",
|
||||
"prod:server": "dotenvx run -o -f .env.production -- tsx src/server.ts",
|
||||
"prod:stripe": "dotenvx run -o -f .env.production -- stripe listen --forward-to http://localhost:3001/v1/webhooks/stripe",
|
||||
"start": "tsx src/server.ts",
|
||||
"drizzle-kit": "dotenvx run -- drizzle-kit",
|
||||
"drizzle-kit:prod": "dotenvx run -o -f .env.production -- drizzle-kit",
|
||||
"clean": "del dist",
|
||||
"test": "run-s test:*",
|
||||
"test:typecheck": "tsc --noEmit",
|
||||
"test:unit": "[ -n \"$CI\" ] || dotenvx run -- vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentic/platform": "workspace:*",
|
||||
"@agentic/platform-core": "workspace:*",
|
||||
"@agentic/platform-emails": "workspace:*",
|
||||
"@agentic/platform-hono": "workspace:*",
|
||||
"@agentic/platform-types": "workspace:*",
|
||||
"@agentic/platform-validators": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.726.1",
|
||||
"@aws-sdk/s3-request-presigner": "^3.726.1",
|
||||
"@dicebear/collection": "catalog:",
|
||||
"@dicebear/core": "catalog:",
|
||||
"@fisch0920/drizzle-orm": "catalog:",
|
||||
"@fisch0920/drizzle-zod": "catalog:",
|
||||
"@hono/node-server": "catalog:",
|
||||
"@hono/zod-openapi": "catalog:",
|
||||
"@paralleldrive/cuid2": "catalog:",
|
||||
"@sentry/node": "catalog:",
|
||||
"bcryptjs": "catalog:",
|
||||
"exit-hook": "catalog:",
|
||||
"file-type": "^21.0.0",
|
||||
"hono": "catalog:",
|
||||
"ky": "catalog:",
|
||||
"mrmime": "^2.0.1",
|
||||
"octokit": "catalog:",
|
||||
"p-all": "catalog:",
|
||||
"postgres": "catalog:",
|
||||
"restore-cursor": "catalog:",
|
||||
"semver": "catalog:",
|
||||
"stripe": "catalog:",
|
||||
"type-fest": "catalog:",
|
||||
"zod-validation-error": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/semver": "catalog:",
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
<p align="center">
|
||||
<a href="https://agentic.so">
|
||||
<img alt="Agentic" src="https://raw.githubusercontent.com/transitive-bullshit/agentic/main/apps/web/public/agentic-social-image-light.jpg" width="640">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/transitive-bullshit/agentic/actions/workflows/main.yml"><img alt="Build Status" src="https://github.com/transitive-bullshit/agentic/actions/workflows/main.yml/badge.svg" /></a>
|
||||
<a href="https://prettier.io"><img alt="Prettier Code Formatting" src="https://img.shields.io/badge/code_style-prettier-brightgreen.svg" /></a>
|
||||
</p>
|
||||
|
||||
# Agentic API <!-- omit from toc -->
|
||||
|
||||
> Backend API for the Agentic platform.
|
||||
|
||||
- [Website](https://agentic.so)
|
||||
- [Docs](https://docs.agentic.so)
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Postgres**
|
||||
- `DATABASE_URL` - Postgres connection string
|
||||
- [On macOS](https://wiki.postgresql.org/wiki/Homebrew): `brew install postgresql && brew services start postgresql`
|
||||
- You'll need to run `pnpm drizzle-kit push` to set up your database schema
|
||||
- **S3** - Required to use file attachments
|
||||
- Any S3-compatible provider is supported, such as [Cloudflare R2](https://developers.cloudflare.com/r2/)
|
||||
- Alterantively, you can use a local S3 server like [MinIO](https://github.com/minio/minio#homebrew-recommended) or [LocalStack](https://github.com/localstack/localstack)
|
||||
- To run LocalStack on macOS: `brew install localstack/tap/localstack-cli && localstack start -d`
|
||||
- To run MinIO macOS: `brew install minio/stable/minio && minio server /data`
|
||||
- I recommend using Cloudflare R2, though – it's amazing and should be free for most use cases!
|
||||
- `S3_BUCKET` - Required
|
||||
- `S3_REGION` - Optional; defaults to `auto`
|
||||
- `S3_ENDPOINT` - Required; example: `https://<id>.r2.cloudflarestorage.com`
|
||||
- `ACCESS_KEY_ID` - Required ([cloudflare R2 docs](https://developers.cloudflare.com/r2/api/s3/tokens/))
|
||||
- `SECRET_ACCESS_KEY` - Required ([cloudflare R2 docs](https://developers.cloudflare.com/r2/api/s3/tokens/))
|
||||
|
||||
## License
|
||||
|
||||
[GNU AGPL 3.0](https://choosealicense.com/licenses/agpl-3.0/)
|
|
@ -0,0 +1,32 @@
|
|||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import type { OpenAPIHono } from '@hono/zod-openapi'
|
||||
import { assert } from '@agentic/platform-core'
|
||||
|
||||
import { authStorage } from './utils'
|
||||
|
||||
export function registerV1GitHubOAuthCallback(
|
||||
app: OpenAPIHono<DefaultHonoEnv>
|
||||
) {
|
||||
return app.get('auth/github/callback', async (c) => {
|
||||
const logger = c.get('logger')
|
||||
const query = c.req.query()
|
||||
|
||||
assert(query.state, 400, 'State is required')
|
||||
|
||||
const entry = await authStorage.get(['github', query.state, 'redirectUri'])
|
||||
assert(entry, 400, 'Redirect URI not found')
|
||||
const redirectUri = entry.redirectUri
|
||||
assert(entry.redirectUri, 400, 'Redirect URI not found')
|
||||
|
||||
const url = new URL(redirectUri)
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
url.searchParams.set(key, value)
|
||||
}
|
||||
|
||||
logger.info('GitHub auth callback', query, '=>', url.toString(), {
|
||||
rawUrl: redirectUri,
|
||||
query
|
||||
})
|
||||
return c.redirect(url.toString())
|
||||
})
|
||||
}
|
|
@ -0,0 +1,114 @@
|
|||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import { createAuthToken } from '@/lib/auth/create-auth-token'
|
||||
import { upsertOrLinkUserAccount } from '@/lib/auth/upsert-or-link-user-account'
|
||||
import {
|
||||
exchangeGitHubOAuthCodeForAccessToken,
|
||||
getGitHubClient
|
||||
} from '@/lib/external/github'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { authSessionResponseSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Exchanges a GitHub OAuth code for an Agentic auth session.',
|
||||
tags: ['auth'],
|
||||
operationId: 'exchangeOAuthCodeWithGitHub',
|
||||
method: 'post',
|
||||
path: 'auth/github/exchange',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z
|
||||
.object({
|
||||
code: z.string()
|
||||
})
|
||||
.passthrough()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'An auth session',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: authSessionResponseSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GitHubOAuthExchange(
|
||||
app: OpenAPIHono<DefaultHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const logger = c.get('logger')
|
||||
const body = c.req.valid('json')
|
||||
|
||||
const result = await exchangeGitHubOAuthCodeForAccessToken(body)
|
||||
logger.info('github oauth', result)
|
||||
|
||||
const client = getGitHubClient({ accessToken: result.access_token! })
|
||||
const { data: ghUser } = await client.rest.users.getAuthenticated()
|
||||
|
||||
logger.info('github user', ghUser)
|
||||
|
||||
if (!ghUser.email) {
|
||||
const { data: emails } = await client.request('GET /user/emails')
|
||||
const primary = emails.find((e) => e.primary)
|
||||
const verified = emails.find((e) => e.verified)
|
||||
const fallback = emails.find((e) => e.email)
|
||||
const email = primary?.email || verified?.email || fallback?.email
|
||||
ghUser.email = email!
|
||||
}
|
||||
|
||||
assert(
|
||||
ghUser.email,
|
||||
'Error authenticating with GitHub: user email is required.'
|
||||
)
|
||||
|
||||
const now = Date.now()
|
||||
const user = await upsertOrLinkUserAccount({
|
||||
partialAccount: {
|
||||
provider: 'github',
|
||||
accountId: `${ghUser.id}`,
|
||||
accountUsername: ghUser.login.toLowerCase(),
|
||||
accessToken: result.access_token,
|
||||
refreshToken: result.refresh_token,
|
||||
// `expires_in` and `refresh_token_expires_in` are given in seconds
|
||||
accessTokenExpiresAt: result.expires_in
|
||||
? new Date(now + result.expires_in * 1000)
|
||||
: undefined,
|
||||
refreshTokenExpiresAt: result.refresh_token_expires_in
|
||||
? new Date(now + result.refresh_token_expires_in * 1000)
|
||||
: undefined,
|
||||
scope: result.scope || undefined
|
||||
},
|
||||
partialUser: {
|
||||
email: ghUser.email,
|
||||
isEmailVerified: true,
|
||||
name: ghUser.name || undefined,
|
||||
username: ghUser.login.toLowerCase(),
|
||||
image: ghUser.avatar_url
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('github user result', user)
|
||||
|
||||
const token = await createAuthToken(user)
|
||||
return c.json(parseZodSchema(authSessionResponseSchema, { token, user }))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { authStorage } from './utils'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Starts a GitHub OAuth flow.',
|
||||
tags: ['auth'],
|
||||
operationId: 'initGitHubOAuthFlow',
|
||||
method: 'get',
|
||||
path: 'auth/github/init',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: z
|
||||
.object({
|
||||
redirect_uri: z.string(),
|
||||
client_id: z.string().optional(),
|
||||
scope: z.string().optional()
|
||||
})
|
||||
.passthrough()
|
||||
},
|
||||
responses: {
|
||||
302: {
|
||||
description: 'Redirected to GitHub'
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GitHubOAuthInitFlow(
|
||||
app: OpenAPIHono<DefaultHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const logger = c.get('logger')
|
||||
const {
|
||||
client_id: clientId = env.GITHUB_CLIENT_ID,
|
||||
scope = 'user:email',
|
||||
redirect_uri: redirectUri
|
||||
} = c.req.query()
|
||||
|
||||
const state = crypto.randomUUID()
|
||||
|
||||
// TODO: unique identifier!
|
||||
// TODO: THIS IS IMPORTANT!! if multiple users are authenticating with github concurrently, this will currently really mess things up...
|
||||
await authStorage.set(['github', state, 'redirectUri'], { redirectUri })
|
||||
|
||||
const publicRedirectUri = `${env.apiBaseUrl}/v1/auth/github/callback`
|
||||
|
||||
const url = new URL('https://github.com/login/oauth/authorize')
|
||||
url.searchParams.append('client_id', clientId)
|
||||
url.searchParams.append('scope', scope)
|
||||
url.searchParams.append('state', state)
|
||||
url.searchParams.append('redirect_uri', publicRedirectUri)
|
||||
|
||||
logger.info('Redirecting to GitHub', {
|
||||
url: url.toString(),
|
||||
clientId,
|
||||
scope,
|
||||
publicRedirectUri
|
||||
})
|
||||
|
||||
return c.redirect(url.toString())
|
||||
})
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import { schema } from '@/db'
|
||||
|
||||
export const authSessionResponseSchema = z
|
||||
.object({
|
||||
token: z.string().nonempty(),
|
||||
user: schema.userSelectSchema
|
||||
})
|
||||
.openapi('AuthSession')
|
|
@ -0,0 +1,94 @@
|
|||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import type { Context } from 'hono'
|
||||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { isValidPassword } from '@agentic/platform-validators'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { compare } from 'bcryptjs'
|
||||
|
||||
import { and, db, eq, schema } from '@/db'
|
||||
import { createAuthToken } from '@/lib/auth/create-auth-token'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { authSessionResponseSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Signs in with email and password.',
|
||||
tags: ['auth'],
|
||||
operationId: 'signInWithPassword',
|
||||
method: 'post',
|
||||
path: 'auth/password/signin',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().refine((password) => isValidPassword(password))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'An auth session',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: authSessionResponseSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1SignInWithPassword(app: OpenAPIHono<DefaultHonoEnv>) {
|
||||
return app.openapi(route, trySignIn)
|
||||
}
|
||||
|
||||
export async function trySignIn(
|
||||
c: Context<
|
||||
DefaultHonoEnv,
|
||||
'auth/password/signin',
|
||||
{
|
||||
in: {
|
||||
json: {
|
||||
password: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
out: {
|
||||
json: {
|
||||
password: string
|
||||
email: string
|
||||
}
|
||||
}
|
||||
}
|
||||
>
|
||||
) {
|
||||
const { email, password } = c.req.valid('json')
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.email, email)
|
||||
})
|
||||
assert(user, 404, `User not found "${email}"`)
|
||||
|
||||
const account = await db.query.accounts.findFirst({
|
||||
where: and(
|
||||
eq(schema.accounts.userId, user.id),
|
||||
eq(schema.accounts.provider, 'password')
|
||||
)
|
||||
})
|
||||
assert(account?.password, 404, `User "${email}" does not have a password set`)
|
||||
assert(compare(password, account.password), 403, 'Authentication error')
|
||||
|
||||
const token = await createAuthToken(user)
|
||||
return c.json(parseZodSchema(authSessionResponseSchema, { token, user }))
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { isValidPassword } from '@agentic/platform-validators'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
import { genSalt, hash } from 'bcryptjs'
|
||||
|
||||
import { usernameSchema } from '@/db'
|
||||
import { createAuthToken } from '@/lib/auth/create-auth-token'
|
||||
import { upsertOrLinkUserAccount } from '@/lib/auth/upsert-or-link-user-account'
|
||||
import { ensureUniqueNamespace } from '@/lib/ensure-unique-namespace'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { authSessionResponseSchema } from './schemas'
|
||||
import { trySignIn } from './sign-in-with-password'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Signs up for a new account with email and password.',
|
||||
tags: ['auth'],
|
||||
operationId: 'signUpWithPassword',
|
||||
method: 'post',
|
||||
path: 'auth/password/signup',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
username: usernameSchema,
|
||||
email: z.string().email(),
|
||||
password: z.string().refine((password) => isValidPassword(password))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'An auth session',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: authSessionResponseSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1SignUpWithPassword(app: OpenAPIHono<DefaultHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
try {
|
||||
// try signing in to see if the user already exists
|
||||
return await trySignIn(c)
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
const { username, email, password } = c.req.valid('json')
|
||||
await ensureUniqueNamespace(username, { label: 'username' })
|
||||
|
||||
const salt = await genSalt()
|
||||
const hashedPassword = await hash(password, salt)
|
||||
|
||||
// TODO: fail if username is taken
|
||||
const user = await upsertOrLinkUserAccount({
|
||||
partialAccount: {
|
||||
provider: 'password',
|
||||
accountId: email,
|
||||
password: hashedPassword
|
||||
},
|
||||
partialUser: {
|
||||
username,
|
||||
email
|
||||
}
|
||||
})
|
||||
|
||||
const token = await createAuthToken(user)
|
||||
return c.json(parseZodSchema(authSessionResponseSchema, { token, user }))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import { DrizzleAuthStorage } from '@/lib/auth/drizzle-auth-storage'
|
||||
|
||||
export const authStorage = DrizzleAuthStorage()
|
|
@ -0,0 +1,62 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { aclAdmin } from '@/lib/acl-admin'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponse409,
|
||||
openapiErrorResponse410,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { consumerIdParamsSchema } from './schemas'
|
||||
import { setAdminCacheControlForConsumer } from './utils'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
"Activates a consumer signifying that at least one API call has been made using the consumer's API token. This method is idempotent and admin-only.",
|
||||
tags: ['admin', 'consumers'],
|
||||
operationId: 'adminActivateConsumer',
|
||||
method: 'put',
|
||||
path: 'admin/consumers/{consumerId}/activate',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: consumerIdParamsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'An admin consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerAdminSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404,
|
||||
...openapiErrorResponse409,
|
||||
...openapiErrorResponse410
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1AdminActivateConsumer(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { consumerId } = c.req.valid('param')
|
||||
await aclAdmin(c)
|
||||
|
||||
const [consumer] = await db
|
||||
.update(schema.consumers)
|
||||
.set({ activated: true })
|
||||
.where(eq(schema.consumers.id, consumerId))
|
||||
.returning()
|
||||
assert(consumer, 404, `Consumer not found "${consumerId}"`)
|
||||
|
||||
setAdminCacheControlForConsumer(c, consumer)
|
||||
return c.json(parseZodSchema(schema.consumerAdminSelectSchema, consumer))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { aclAdmin } from '@/lib/acl-admin'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { consumerApiKeyParamsSchema, populateConsumerSchema } from './schemas'
|
||||
import { setAdminCacheControlForConsumer } from './utils'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Gets a consumer by API key. This route is admin-only.',
|
||||
tags: ['admin', 'consumers'],
|
||||
operationId: 'adminGetConsumerByApiKey',
|
||||
method: 'get',
|
||||
// TODO: is it wise to use a path param for the API key? especially wehn it'll
|
||||
// be cached in cloudflare's shared cache?
|
||||
path: 'admin/consumers/api-keys/{apiKey}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: consumerApiKeyParamsSchema,
|
||||
query: populateConsumerSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'An admin consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerAdminSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1AdminGetConsumerByApiKey(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { apiKey } = c.req.valid('param')
|
||||
const { populate = [] } = c.req.valid('query')
|
||||
await aclAdmin(c)
|
||||
|
||||
const consumer = await db.query.consumers.findFirst({
|
||||
where: eq(schema.consumers.token, apiKey),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(consumer, 404, `API key not found "${apiKey}"`)
|
||||
|
||||
setAdminCacheControlForConsumer(c, consumer)
|
||||
return c.json(parseZodSchema(schema.consumerAdminSelectSchema, consumer))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer'
|
||||
import { env } from '@/lib/env'
|
||||
import { stripe } from '@/lib/external/stripe'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponse409,
|
||||
openapiErrorResponse410,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Creates a Stripe billing portal session for the authenticated user.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'createBillingPortalSession',
|
||||
method: 'post',
|
||||
path: 'consumers/billing-portal',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A billing portal session URL',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
url: z.string().url()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404,
|
||||
...openapiErrorResponse409,
|
||||
...openapiErrorResponse410
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1CreateBillingPortalSession(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { stripeCustomer } = await upsertStripeCustomer(c)
|
||||
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: stripeCustomer.id,
|
||||
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers`
|
||||
})
|
||||
|
||||
return c.json({ url: portalSession.url })
|
||||
})
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { env } from '@/lib/env'
|
||||
import { stripe } from '@/lib/external/stripe'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponse409,
|
||||
openapiErrorResponse410,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { consumerIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Creates a Stripe billing portal session for a customer.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'createConsumerBillingPortalSession',
|
||||
method: 'post',
|
||||
path: 'consumers/{consumerId}/billing-portal',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: consumerIdParamsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A billing portal session URL',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
url: z.string().url()
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404,
|
||||
...openapiErrorResponse409,
|
||||
...openapiErrorResponse410
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1CreateConsumerBillingPortalSession(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { consumerId } = c.req.valid('param')
|
||||
const consumer = await db.query.consumers.findFirst({
|
||||
where: eq(schema.consumers.id, consumerId)
|
||||
})
|
||||
assert(consumer, 404, `Consumer not found "${consumerId}"`)
|
||||
await acl(c, consumer, { label: 'Consumer' })
|
||||
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: consumer._stripeCustomerId,
|
||||
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumerId}`
|
||||
})
|
||||
|
||||
return c.json({ url: portalSession.url })
|
||||
})
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import { parseZodSchema, pick } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { schema } from '@/db'
|
||||
import { upsertConsumerStripeCheckout } from '@/lib/consumers/upsert-consumer-stripe-checkout'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponse409,
|
||||
openapiErrorResponse410,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Creates a Stripe checkout session for a consumer to modify their subscription to a project.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'createConsumerCheckoutSession',
|
||||
method: 'post',
|
||||
path: 'consumers/checkout',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerInsertSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
checkoutSession: z.object({
|
||||
id: z.string(),
|
||||
url: z.string().url()
|
||||
}),
|
||||
consumer: schema.consumerSelectSchema
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404,
|
||||
...openapiErrorResponse409,
|
||||
...openapiErrorResponse410
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1CreateConsumerCheckoutSession(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
const { checkoutSession, consumer } = await upsertConsumerStripeCheckout(
|
||||
c,
|
||||
body
|
||||
)
|
||||
|
||||
return c.json({
|
||||
checkoutSession: pick(checkoutSession, 'id', 'url'),
|
||||
consumer: parseZodSchema(schema.consumerSelectSchema, consumer)
|
||||
})
|
||||
})
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { schema } from '@/db'
|
||||
import { upsertConsumer } from '@/lib/consumers/upsert-consumer'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponse409,
|
||||
openapiErrorResponse410,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
"Upserts a consumer by modifying a customer's subscription to a project.",
|
||||
tags: ['consumers'],
|
||||
operationId: 'createConsumer',
|
||||
method: 'post',
|
||||
path: 'consumers',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerInsertSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404,
|
||||
...openapiErrorResponse409,
|
||||
...openapiErrorResponse410
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1CreateConsumer(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
const consumer = await upsertConsumer(c, body)
|
||||
|
||||
return c.json(parseZodSchema(schema.consumerSelectSchema, consumer))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { and, db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { aclPublicProject } from '@/lib/acl-public-project'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { projectIdentifierAndPopulateConsumerSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Gets a consumer for the authenticated user and the given project identifier.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'getConsumerByProjectIdentifier',
|
||||
method: 'get',
|
||||
path: 'consumers/by-project-identifier',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: projectIdentifierAndPopulateConsumerSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetConsumerByProjectIdentifier(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { projectIdentifier, populate = [] } = c.req.valid('query')
|
||||
const userId = c.get('userId')
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.identifier, projectIdentifier)
|
||||
})
|
||||
assert(project, 404, `Project not found "${projectIdentifier}"`)
|
||||
aclPublicProject(project)
|
||||
|
||||
const consumer = await db.query.consumers.findFirst({
|
||||
where: and(
|
||||
eq(schema.consumers.userId, userId),
|
||||
eq(schema.consumers.projectId, project.id)
|
||||
),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(
|
||||
consumer,
|
||||
404,
|
||||
`Consumer not found for user "${userId}" and project "${projectIdentifier}"`
|
||||
)
|
||||
await acl(c, consumer, { label: 'Consumer' })
|
||||
|
||||
return c.json(parseZodSchema(schema.consumerSelectSchema, consumer))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { consumerIdParamsSchema, populateConsumerSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Gets a consumer by ID.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'getConsumer',
|
||||
method: 'get',
|
||||
path: 'consumers/{consumerId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: consumerIdParamsSchema,
|
||||
query: populateConsumerSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetConsumer(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { consumerId } = c.req.valid('param')
|
||||
const { populate = [] } = c.req.valid('query')
|
||||
|
||||
const consumer = await db.query.consumers.findFirst({
|
||||
where: eq(schema.consumers.id, consumerId),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(consumer, 404, `Consumer not found "${consumerId}"`)
|
||||
await acl(c, consumer, { label: 'Consumer' })
|
||||
|
||||
return c.json(parseZodSchema(schema.consumerSelectSchema, consumer))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { paginationAndPopulateConsumerSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Lists all of the customer subscriptions for the current user.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'listConsumers',
|
||||
method: 'get',
|
||||
path: 'consumers',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: paginationAndPopulateConsumerSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A list of consumers',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.array(schema.consumerSelectSchema)
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1ListConsumers(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
sort = 'desc',
|
||||
sortBy = 'createdAt',
|
||||
populate = []
|
||||
} = c.req.valid('query')
|
||||
|
||||
const user = await ensureAuthUser(c)
|
||||
|
||||
const consumers = await db.query.consumers.findMany({
|
||||
where: eq(schema.consumers.userId, user.id),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
},
|
||||
orderBy: (consumers, { asc, desc }) => [
|
||||
sort === 'desc' ? desc(consumers[sortBy]) : asc(consumers[sortBy])
|
||||
],
|
||||
offset,
|
||||
limit
|
||||
})
|
||||
|
||||
return c.json(
|
||||
parseZodSchema(z.array(schema.consumerSelectSchema), consumers)
|
||||
)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,78 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { projectIdParamsSchema } from '../projects/schemas'
|
||||
import { paginationAndPopulateConsumerSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Lists all of the customers for a project.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'listConsumersForProject',
|
||||
method: 'get',
|
||||
path: 'projects/{projectId}/consumers',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: projectIdParamsSchema,
|
||||
query: paginationAndPopulateConsumerSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A list of consumers subscribed to the given project',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.array(schema.consumerSelectSchema)
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1ListConsumersForProject(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
sort = 'desc',
|
||||
sortBy = 'createdAt',
|
||||
populate = []
|
||||
} = c.req.valid('query')
|
||||
|
||||
const { projectId } = c.req.valid('param')
|
||||
assert(projectId, 400, 'Project ID is required')
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, projectId)
|
||||
})
|
||||
assert(project, 404, `Project not found "${projectId}"`)
|
||||
await acl(c, project, { label: 'Project' })
|
||||
|
||||
const consumers = await db.query.consumers.findMany({
|
||||
where: eq(schema.consumers.projectId, projectId),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
},
|
||||
orderBy: (consumers, { asc, desc }) => [
|
||||
sort === 'desc' ? desc(consumers[sortBy]) : asc(consumers[sortBy])
|
||||
],
|
||||
offset,
|
||||
limit
|
||||
})
|
||||
|
||||
return c.json(
|
||||
parseZodSchema(z.array(schema.consumerSelectSchema), consumers)
|
||||
)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,64 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { createConsumerApiKey } from '@/lib/create-consumer-api-key'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { consumerIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: "Refreshes a consumer's API key.",
|
||||
tags: ['consumers'],
|
||||
operationId: 'refreshConsumerApiKey',
|
||||
method: 'post',
|
||||
path: 'consumers/{consumerId}/refresh-api-key',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: consumerIdParamsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1RefreshConsumerApiKey(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { consumerId } = c.req.valid('param')
|
||||
|
||||
let consumer = await db.query.consumers.findFirst({
|
||||
where: eq(schema.consumers.id, consumerId)
|
||||
})
|
||||
assert(consumer, 404, 'Consumer not found')
|
||||
await acl(c, consumer, { label: 'Consumer' })
|
||||
|
||||
// Update the consumer's API token
|
||||
;[consumer] = await db
|
||||
.update(schema.consumers)
|
||||
.set({
|
||||
token: await createConsumerApiKey()
|
||||
})
|
||||
.where(eq(schema.consumers.id, consumer.id))
|
||||
.returning()
|
||||
assert(consumer, 500, 'Error updating consumer')
|
||||
|
||||
return c.json(parseZodSchema(schema.consumerSelectSchema, consumer))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import {
|
||||
consumerIdSchema,
|
||||
consumerRelationsSchema,
|
||||
paginationSchema,
|
||||
projectIdentifierSchema
|
||||
} from '@/db'
|
||||
|
||||
export const consumerIdParamsSchema = z.object({
|
||||
consumerId: consumerIdSchema.openapi({
|
||||
param: {
|
||||
description: 'Consumer ID',
|
||||
name: 'consumerId',
|
||||
in: 'path'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const consumerApiKeyParamsSchema = z.object({
|
||||
apiKey: z
|
||||
.string()
|
||||
.nonempty()
|
||||
.openapi({
|
||||
param: {
|
||||
description: 'Consumer API key',
|
||||
name: 'apiKey',
|
||||
in: 'path'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const projectIdentifierQuerySchema = z.object({
|
||||
projectIdentifier: projectIdentifierSchema
|
||||
})
|
||||
|
||||
export const populateConsumerSchema = z.object({
|
||||
populate: z
|
||||
.union([consumerRelationsSchema, z.array(consumerRelationsSchema)])
|
||||
.default([])
|
||||
.transform((p) => (Array.isArray(p) ? p : [p]))
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const paginationAndPopulateConsumerSchema = z.object({
|
||||
...paginationSchema.shape,
|
||||
...populateConsumerSchema.shape
|
||||
})
|
||||
|
||||
export const projectIdentifierAndPopulateConsumerSchema = z.object({
|
||||
...projectIdentifierQuerySchema.shape,
|
||||
...populateConsumerSchema.shape
|
||||
})
|
|
@ -0,0 +1,66 @@
|
|||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { schema } from '@/db'
|
||||
import { upsertConsumer } from '@/lib/consumers/upsert-consumer'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponse409,
|
||||
openapiErrorResponse410,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { consumerIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
"Updates a consumer's subscription to a different deployment or pricing plan. Set `plan` to undefined to cancel the subscription.",
|
||||
tags: ['consumers'],
|
||||
operationId: 'updateConsumer',
|
||||
method: 'post',
|
||||
path: 'consumers/{consumerId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: consumerIdParamsSchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerUpdateSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404,
|
||||
...openapiErrorResponse409,
|
||||
...openapiErrorResponse410
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1UpdateConsumer(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { consumerId } = c.req.valid('param')
|
||||
const body = c.req.valid('json')
|
||||
|
||||
const consumer = await upsertConsumer(c, {
|
||||
...body,
|
||||
consumerId
|
||||
})
|
||||
|
||||
return c.json(parseZodSchema(schema.consumerSelectSchema, consumer))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import type { RawConsumer } from '@/db'
|
||||
import type { AuthenticatedHonoContext } from '@/lib/types'
|
||||
import { setPublicCacheControl } from '@/lib/cache-control'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
export function setAdminCacheControlForConsumer(
|
||||
c: AuthenticatedHonoContext,
|
||||
consumer: RawConsumer
|
||||
) {
|
||||
if (
|
||||
consumer.plan === 'free' ||
|
||||
!consumer.activated ||
|
||||
!consumer.isStripeSubscriptionActive
|
||||
) {
|
||||
// TODO: should we cache free-tier consumers for longer on prod?
|
||||
// We really don't want free tier customers to cause our backend API so
|
||||
// much traffic, but we'd also like for customers upgrading to a paid tier
|
||||
// to have a snappy, smooth experience – without having to wait for their
|
||||
// free tier subscription to expire from the cache.
|
||||
setPublicCacheControl(c.res, env.isProd ? '30s' : '10s')
|
||||
} else {
|
||||
// We don't want the gateway hitting our API too often, so cache active
|
||||
// customer subscriptions for longer in production
|
||||
setPublicCacheControl(c.res, env.isProd ? '30m' : '1m')
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { aclAdmin } from '@/lib/acl-admin'
|
||||
import { setPublicCacheControl } from '@/lib/cache-control'
|
||||
import { tryGetDeploymentByIdentifier } from '@/lib/deployments/try-get-deployment-by-identifier'
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { deploymentIdentifierAndPopulateSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Gets a deployment by its public identifier. This route is admin-only.',
|
||||
tags: ['admin', 'deployments'],
|
||||
operationId: 'adminGetDeploymentByIdentifier',
|
||||
method: 'get',
|
||||
path: 'admin/deployments/by-identifier',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: deploymentIdentifierAndPopulateSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'An admin deployment object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentAdminSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1AdminGetDeploymentByIdentifier(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { deploymentIdentifier, populate = [] } = c.req.valid('query')
|
||||
await aclAdmin(c)
|
||||
|
||||
const { project, ...deployment } = await tryGetDeploymentByIdentifier(c, {
|
||||
deploymentIdentifier,
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true])),
|
||||
project: true
|
||||
}
|
||||
})
|
||||
assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`)
|
||||
assert(
|
||||
project,
|
||||
404,
|
||||
`Project not found for deployment "${deploymentIdentifier}"`
|
||||
)
|
||||
await acl(c, deployment, { label: 'Deployment' })
|
||||
|
||||
// TODO: ensure that the deployment's project is either public OR the
|
||||
// consumer has access to it?
|
||||
|
||||
const hasPopulateProject = populate.includes('project')
|
||||
|
||||
if (env.isProd) {
|
||||
// Published deployments are immutable, so cache them for longer in production
|
||||
setPublicCacheControl(c.res, deployment.published ? '1h' : '5m')
|
||||
} else {
|
||||
setPublicCacheControl(c.res, '10s')
|
||||
}
|
||||
|
||||
return c.json(
|
||||
parseZodSchema(schema.deploymentAdminSelectSchema, {
|
||||
...deployment,
|
||||
...(hasPopulateProject ? { project } : {}),
|
||||
_secret: project._secret
|
||||
})
|
||||
)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,201 @@
|
|||
import { resolveAgenticProjectConfig } from '@agentic/platform'
|
||||
import { assert, parseZodSchema, sha256, slugify } from '@agentic/platform-core'
|
||||
import {
|
||||
isValidDeploymentIdentifier,
|
||||
parseProjectIdentifier
|
||||
} from '@agentic/platform-validators'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { normalizeDeploymentVersion } from '@/lib/deployments/normalize-deployment-version'
|
||||
import { publishDeployment } from '@/lib/deployments/publish-deployment'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponse409,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
import { uploadFileUrlToStorage } from '@/lib/storage'
|
||||
|
||||
import { createDeploymentQuerySchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Creates a new deployment within a project.',
|
||||
tags: ['deployments'],
|
||||
operationId: 'createDeployment',
|
||||
method: 'post',
|
||||
path: 'deployments',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: createDeploymentQuerySchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentInsertSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A deployment object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404,
|
||||
...openapiErrorResponse409
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1CreateDeployment(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const user = await ensureAuthUser(c)
|
||||
const { publish } = c.req.valid('query')
|
||||
const body = c.req.valid('json')
|
||||
const teamMember = c.get('teamMember')
|
||||
const logger = c.get('logger')
|
||||
|
||||
const inputNamespace = teamMember ? teamMember.teamSlug : user.username
|
||||
const slug = body.slug ?? slugify(body.name) // TODO: don't duplicate this logic here
|
||||
const inputProjectIdentifier = `@${inputNamespace}/${slug}`
|
||||
const { projectIdentifier, projectNamespace, projectSlug } =
|
||||
parseProjectIdentifier(inputProjectIdentifier)
|
||||
|
||||
let project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.identifier, projectIdentifier),
|
||||
with: {
|
||||
lastPublishedDeployment: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!project) {
|
||||
// Used for testing e2e fixtures in the development marketplace
|
||||
const isPrivate = !(
|
||||
(user.username === 'dev' && env.isDev) ||
|
||||
user.username === 'agentic'
|
||||
)
|
||||
|
||||
// Used to simplify recreating the demo `@agentic/search` project during
|
||||
// development while we're frequently resetting the database
|
||||
const secret =
|
||||
projectIdentifier === '@dev/search' ||
|
||||
projectIdentifier === '@agentic/search'
|
||||
? env.AGENTIC_SEARCH_PROXY_SECRET
|
||||
: await sha256()
|
||||
|
||||
// Upsert the project if it doesn't already exist
|
||||
// The typecast is necessary here because we're not populating the
|
||||
// lastPublishedDeployment, but that's fine because it's a new project
|
||||
// so it will be empty anyway.
|
||||
project = (
|
||||
await db
|
||||
.insert(schema.projects)
|
||||
.values({
|
||||
name: body.name,
|
||||
identifier: projectIdentifier,
|
||||
namespace: projectNamespace,
|
||||
slug: projectSlug,
|
||||
userId: user.id,
|
||||
teamId: teamMember?.teamId,
|
||||
private: isPrivate,
|
||||
_secret: secret
|
||||
})
|
||||
.returning()
|
||||
)[0] as typeof project
|
||||
}
|
||||
|
||||
assert(project, 404, `Project not found "${projectIdentifier}"`)
|
||||
await acl(c, project, { label: 'Project' })
|
||||
const projectId = project.id
|
||||
|
||||
// TODO: investigate better short hash generation
|
||||
const hash = (await sha256()).slice(0, 8)
|
||||
const deploymentIdentifier = `${project.identifier}@${hash}`
|
||||
assert(
|
||||
isValidDeploymentIdentifier(deploymentIdentifier),
|
||||
400,
|
||||
`Invalid deployment identifier "${deploymentIdentifier}"`
|
||||
)
|
||||
|
||||
let { version } = body
|
||||
if (publish) {
|
||||
assert(
|
||||
version,
|
||||
400,
|
||||
`Deployment "version" field is required to publish deployment "${deploymentIdentifier}"`
|
||||
)
|
||||
}
|
||||
|
||||
if (version) {
|
||||
version = normalizeDeploymentVersion({
|
||||
deploymentIdentifier,
|
||||
project,
|
||||
version
|
||||
})
|
||||
}
|
||||
|
||||
// Validate project config, including:
|
||||
// - pricing plans
|
||||
// - origin adapter config
|
||||
// - origin API base URL
|
||||
// - origin adapter OpenAPI or MCP specs
|
||||
// - tool definitions
|
||||
const agenticProjectConfig = await resolveAgenticProjectConfig(body, {
|
||||
label: `deployment "${deploymentIdentifier}"`,
|
||||
logger,
|
||||
uploadFileUrlToStorage: async (source) => {
|
||||
return uploadFileUrlToStorage(source, {
|
||||
prefix: projectIdentifier
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Create the deployment
|
||||
let [deployment] = await db
|
||||
.insert(schema.deployments)
|
||||
.values({
|
||||
iconUrl: user.image,
|
||||
...agenticProjectConfig,
|
||||
identifier: deploymentIdentifier,
|
||||
hash,
|
||||
userId: user.id,
|
||||
teamId: teamMember?.teamId,
|
||||
projectId,
|
||||
version
|
||||
})
|
||||
.returning()
|
||||
assert(
|
||||
deployment,
|
||||
500,
|
||||
`Failed to create deployment "${deploymentIdentifier}"`
|
||||
)
|
||||
|
||||
// Update the project
|
||||
await db
|
||||
.update(schema.projects)
|
||||
.set({
|
||||
lastDeploymentId: deployment.id
|
||||
})
|
||||
.where(eq(schema.projects.id, projectId))
|
||||
|
||||
if (publish) {
|
||||
deployment = await publishDeployment(c, {
|
||||
deployment,
|
||||
version: deployment.version!
|
||||
})
|
||||
}
|
||||
|
||||
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { tryGetDeploymentByIdentifier } from '@/lib/deployments/try-get-deployment-by-identifier'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { deploymentIdentifierAndPopulateSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Gets a deployment by its identifier (eg, "@username/project-slug@latest").',
|
||||
tags: ['deployments'],
|
||||
operationId: 'getDeploymentByIdentifier',
|
||||
method: 'get',
|
||||
path: 'deployments/by-identifier',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: deploymentIdentifierAndPopulateSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A deployment object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetDeploymentByIdentifier(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { deploymentIdentifier, populate = [] } = c.req.valid('query')
|
||||
|
||||
const deployment = await tryGetDeploymentByIdentifier(c, {
|
||||
deploymentIdentifier,
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`)
|
||||
await acl(c, deployment, { label: 'Deployment' })
|
||||
|
||||
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { getDeploymentById } from '@/lib/deployments/get-deployment-by-id'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { deploymentIdParamsSchema, populateDeploymentSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Gets a deployment by its ID',
|
||||
tags: ['deployments'],
|
||||
operationId: 'getDeployment',
|
||||
method: 'get',
|
||||
path: 'deployments/{deploymentId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: deploymentIdParamsSchema,
|
||||
query: populateDeploymentSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A deployment object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetDeployment(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { deploymentId } = c.req.valid('param')
|
||||
const { populate = [] } = c.req.valid('query')
|
||||
|
||||
const deployment = await getDeploymentById({
|
||||
deploymentId,
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(deployment, 404, `Deployment not found "${deploymentId}"`)
|
||||
await acl(c, deployment, { label: 'Deployment' })
|
||||
|
||||
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import { schema } from '@/db'
|
||||
import { aclPublicProject } from '@/lib/acl-public-project'
|
||||
import { setPublicCacheControl } from '@/lib/cache-control'
|
||||
import { tryGetDeploymentByIdentifier } from '@/lib/deployments/try-get-deployment-by-identifier'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { deploymentIdentifierAndPopulateSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Gets a public deployment by its identifier (eg, "@username/project-slug@latest").',
|
||||
tags: ['deployments'],
|
||||
operationId: 'getPublicDeploymentByIdentifier',
|
||||
method: 'get',
|
||||
path: 'deployments/public/by-identifier',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: deploymentIdentifierAndPopulateSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A deployment object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetPublicDeploymentByIdentifier(
|
||||
app: OpenAPIHono<DefaultHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { deploymentIdentifier, populate = [] } = c.req.valid('query')
|
||||
|
||||
const deployment = await tryGetDeploymentByIdentifier(c, {
|
||||
deploymentIdentifier,
|
||||
with: {
|
||||
project: true,
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(deployment, 404, `Deployment not found "${deploymentIdentifier}"`)
|
||||
assert(
|
||||
deployment.project,
|
||||
404,
|
||||
`Project not found for deployment "${deploymentIdentifier}"`
|
||||
)
|
||||
aclPublicProject(deployment.project!)
|
||||
|
||||
if (deployment.published) {
|
||||
// Note that published deployments should be immutable
|
||||
setPublicCacheControl(c.res, '1m')
|
||||
} else {
|
||||
setPublicCacheControl(c.res, '10s')
|
||||
}
|
||||
|
||||
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { and, db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
import { tryGetProjectByIdentifier } from '@/lib/projects/try-get-project-by-identifier'
|
||||
|
||||
import { paginationAndPopulateAndFilterDeploymentSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Lists deployments the user or team has access to, optionally filtering by project.',
|
||||
tags: ['deployments'],
|
||||
operationId: 'listDeployments',
|
||||
method: 'get',
|
||||
path: 'deployments',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: paginationAndPopulateAndFilterDeploymentSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A list of deployments',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.array(schema.deploymentSelectSchema)
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1ListDeployments(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
sort = 'desc',
|
||||
sortBy = 'createdAt',
|
||||
populate = [],
|
||||
projectIdentifier,
|
||||
deploymentIdentifier
|
||||
} = c.req.valid('query')
|
||||
|
||||
const userId = c.get('userId')
|
||||
const teamMember = c.get('teamMember')
|
||||
const user = await ensureAuthUser(c)
|
||||
const isAdmin = user.role === 'admin'
|
||||
|
||||
let projectId: string | undefined
|
||||
|
||||
if (projectIdentifier) {
|
||||
const project = await tryGetProjectByIdentifier(c, {
|
||||
projectIdentifier
|
||||
})
|
||||
await acl(c, project, { label: 'Project' })
|
||||
projectId = project.id
|
||||
}
|
||||
|
||||
const deployments = await db.query.deployments.findMany({
|
||||
where: and(
|
||||
isAdmin
|
||||
? undefined
|
||||
: teamMember
|
||||
? eq(schema.deployments.teamId, teamMember.teamId)
|
||||
: eq(schema.deployments.userId, userId),
|
||||
projectId ? eq(schema.deployments.projectId, projectId) : undefined,
|
||||
deploymentIdentifier
|
||||
? eq(schema.deployments.identifier, deploymentIdentifier)
|
||||
: undefined
|
||||
),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
},
|
||||
orderBy: (deployments, { asc, desc }) => [
|
||||
sort === 'desc' ? desc(deployments[sortBy]) : asc(deployments[sortBy])
|
||||
],
|
||||
offset,
|
||||
limit
|
||||
})
|
||||
|
||||
return c.json(
|
||||
parseZodSchema(z.array(schema.deploymentSelectSchema), deployments)
|
||||
)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { getDeploymentById } from '@/lib/deployments/get-deployment-by-id'
|
||||
import { publishDeployment } from '@/lib/deployments/publish-deployment'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { deploymentIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Publishes a deployment.',
|
||||
tags: ['deployments'],
|
||||
operationId: 'publishDeployment',
|
||||
method: 'post',
|
||||
path: 'deployments/{deploymentId}/publish',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: deploymentIdParamsSchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentPublishSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A deployment object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1PublishDeployment(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { deploymentId } = c.req.valid('param')
|
||||
const { version } = c.req.valid('json')
|
||||
|
||||
// First ensure the deployment exists and the user has access to it
|
||||
const deployment = await getDeploymentById({ deploymentId })
|
||||
assert(deployment, 404, `Deployment not found "${deploymentId}"`)
|
||||
await acl(c, deployment, { label: 'Deployment' })
|
||||
|
||||
const publishedDeployment = await publishDeployment(c, {
|
||||
deployment,
|
||||
version
|
||||
})
|
||||
|
||||
return c.json(
|
||||
parseZodSchema(schema.deploymentSelectSchema, publishedDeployment)
|
||||
)
|
||||
})
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import {
|
||||
deploymentIdentifierSchema,
|
||||
deploymentIdSchema,
|
||||
deploymentRelationsSchema,
|
||||
paginationSchema,
|
||||
projectIdentifierSchema
|
||||
} from '@/db'
|
||||
|
||||
export const deploymentIdParamsSchema = z.object({
|
||||
deploymentId: deploymentIdSchema.openapi({
|
||||
param: {
|
||||
description: 'deployment ID',
|
||||
name: 'deploymentId',
|
||||
in: 'path'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
export const createDeploymentQuerySchema = z.object({
|
||||
publish: z
|
||||
.union([z.literal('true'), z.literal('false')])
|
||||
.default('false')
|
||||
.transform((p) => p === 'true')
|
||||
})
|
||||
|
||||
export const filterDeploymentSchema = z.object({
|
||||
projectIdentifier: projectIdentifierSchema.optional(),
|
||||
deploymentIdentifier: deploymentIdentifierSchema.optional()
|
||||
})
|
||||
|
||||
export const populateDeploymentSchema = z.object({
|
||||
populate: z
|
||||
.union([deploymentRelationsSchema, z.array(deploymentRelationsSchema)])
|
||||
.default([])
|
||||
.transform((p) => (Array.isArray(p) ? p : [p]))
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const deploymentIdentifierQuerySchema = z.object({
|
||||
deploymentIdentifier: deploymentIdentifierSchema
|
||||
})
|
||||
|
||||
export const deploymentIdentifierAndPopulateSchema = z.object({
|
||||
...populateDeploymentSchema.shape,
|
||||
...deploymentIdentifierQuerySchema.shape
|
||||
})
|
||||
|
||||
export const paginationAndPopulateAndFilterDeploymentSchema = z.object({
|
||||
...paginationSchema.shape,
|
||||
...populateDeploymentSchema.shape,
|
||||
...filterDeploymentSchema.shape
|
||||
})
|
|
@ -0,0 +1,70 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { getDeploymentById } from '@/lib/deployments/get-deployment-by-id'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { deploymentIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Updates a deployment.',
|
||||
tags: ['deployments'],
|
||||
operationId: 'updateDeployment',
|
||||
method: 'post',
|
||||
path: 'deployments/{deploymentId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: deploymentIdParamsSchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentUpdateSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A deployment object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.deploymentSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1UpdateDeployment(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { deploymentId } = c.req.valid('param')
|
||||
const body = c.req.valid('json')
|
||||
|
||||
// First ensure the deployment exists and the user has access to it
|
||||
let deployment = await getDeploymentById({ deploymentId })
|
||||
assert(deployment, 404, `Deployment not found "${deploymentId}"`)
|
||||
await acl(c, deployment, { label: 'Deployment' })
|
||||
|
||||
// Update the deployment
|
||||
;[deployment] = await db
|
||||
.update(schema.deployments)
|
||||
.set(body)
|
||||
.where(eq(schema.deployments.id, deploymentId))
|
||||
.returning()
|
||||
assert(deployment, 500, `Failed to update deployment "${deploymentId}"`)
|
||||
|
||||
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
import { createRoute, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { HonoApp } from '@/lib/types'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Health check endpoint.',
|
||||
operationId: 'healthCheck',
|
||||
method: 'get',
|
||||
path: 'health',
|
||||
responses: {
|
||||
200: {
|
||||
description: 'OK',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
status: z.string()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export function registerHealthCheck(app: HonoApp) {
|
||||
return app.openapi(route, async (c) => {
|
||||
return c.json({ status: 'ok' })
|
||||
})
|
||||
}
|
|
@ -0,0 +1,154 @@
|
|||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import { OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import * as middleware from '@/lib/middleware'
|
||||
import { defaultHook, registerOpenAPIErrorResponses } from '@/lib/openapi-utils'
|
||||
|
||||
import { registerV1GitHubOAuthCallback } from './auth/github-callback'
|
||||
import { registerV1GitHubOAuthExchange } from './auth/github-exchange'
|
||||
import { registerV1GitHubOAuthInitFlow } from './auth/github-init'
|
||||
import { registerV1SignInWithPassword } from './auth/sign-in-with-password'
|
||||
import { registerV1SignUpWithPassword } from './auth/sign-up-with-password'
|
||||
import { registerV1AdminActivateConsumer } from './consumers/admin-activate-consumer'
|
||||
import { registerV1AdminGetConsumerByApiKey } from './consumers/admin-get-consumer-by-api-key'
|
||||
import { registerV1CreateBillingPortalSession } from './consumers/create-billing-portal-session'
|
||||
import { registerV1CreateConsumer } from './consumers/create-consumer'
|
||||
import { registerV1CreateConsumerBillingPortalSession } from './consumers/create-consumer-billing-portal-session'
|
||||
import { registerV1CreateConsumerCheckoutSession } from './consumers/create-consumer-checkout-session'
|
||||
import { registerV1GetConsumer } from './consumers/get-consumer'
|
||||
import { registerV1GetConsumerByProjectIdentifier } from './consumers/get-consumer-by-project-identifier'
|
||||
import { registerV1ListConsumers } from './consumers/list-consumers'
|
||||
import { registerV1ListConsumersForProject } from './consumers/list-project-consumers'
|
||||
import { registerV1RefreshConsumerApiKey } from './consumers/refresh-consumer-api-key'
|
||||
import { registerV1UpdateConsumer } from './consumers/update-consumer'
|
||||
import { registerV1AdminGetDeploymentByIdentifier } from './deployments/admin-get-deployment-by-identifier'
|
||||
import { registerV1CreateDeployment } from './deployments/create-deployment'
|
||||
import { registerV1GetDeployment } from './deployments/get-deployment'
|
||||
import { registerV1GetDeploymentByIdentifier } from './deployments/get-deployment-by-identifier'
|
||||
import { registerV1GetPublicDeploymentByIdentifier } from './deployments/get-public-deployment-by-identifier'
|
||||
import { registerV1ListDeployments } from './deployments/list-deployments'
|
||||
import { registerV1PublishDeployment } from './deployments/publish-deployment'
|
||||
import { registerV1UpdateDeployment } from './deployments/update-deployment'
|
||||
import { registerHealthCheck } from './health-check'
|
||||
import { registerV1CreateProject } from './projects/create-project'
|
||||
import { registerV1GetProject } from './projects/get-project'
|
||||
import { registerV1GetProjectByIdentifier } from './projects/get-project-by-identifier'
|
||||
import { registerV1GetPublicProject } from './projects/get-public-project'
|
||||
import { registerV1GetPublicProjectByIdentifier } from './projects/get-public-project-by-identifier'
|
||||
import { registerV1ListProjects } from './projects/list-projects'
|
||||
import { registerV1ListPublicProjects } from './projects/list-public-projects'
|
||||
import { registerV1UpdateProject } from './projects/update-project'
|
||||
import { registerV1GetSignedStorageUploadUrl } from './storage/get-signed-storage-upload-url'
|
||||
import { registerV1CreateTeam } from './teams/create-team'
|
||||
import { registerV1DeleteTeam } from './teams/delete-team'
|
||||
import { registerV1GetTeam } from './teams/get-team'
|
||||
import { registerV1ListTeams } from './teams/list-teams'
|
||||
import { registerV1CreateTeamMember } from './teams/members/create-team-member'
|
||||
import { registerV1DeleteTeamMember } from './teams/members/delete-team-member'
|
||||
import { registerV1UpdateTeamMember } from './teams/members/update-team-member'
|
||||
import { registerV1UpdateTeam } from './teams/update-team'
|
||||
import { registerV1GetUser } from './users/get-user'
|
||||
import { registerV1UpdateUser } from './users/update-user'
|
||||
import { registerV1StripeWebhook } from './webhooks/stripe-webhook'
|
||||
|
||||
// Note that the order of some of these routes is important because of
|
||||
// wildcards, so be careful when updating them or adding new routes.
|
||||
|
||||
export const apiV1 = new OpenAPIHono<DefaultHonoEnv>({ defaultHook })
|
||||
|
||||
apiV1.openAPIRegistry.registerComponent('securitySchemes', 'Bearer', {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT'
|
||||
})
|
||||
|
||||
registerOpenAPIErrorResponses(apiV1)
|
||||
|
||||
// Public routes
|
||||
const publicRouter = new OpenAPIHono<DefaultHonoEnv>({ defaultHook })
|
||||
|
||||
// Private, authenticated routes
|
||||
const privateRouter = new OpenAPIHono<AuthenticatedHonoEnv>({ defaultHook })
|
||||
|
||||
registerHealthCheck(publicRouter)
|
||||
|
||||
// Auth
|
||||
registerV1SignInWithPassword(publicRouter)
|
||||
registerV1SignUpWithPassword(publicRouter)
|
||||
registerV1GitHubOAuthExchange(publicRouter)
|
||||
registerV1GitHubOAuthInitFlow(publicRouter)
|
||||
registerV1GitHubOAuthCallback(publicRouter)
|
||||
|
||||
// Users
|
||||
registerV1GetUser(privateRouter)
|
||||
registerV1UpdateUser(privateRouter)
|
||||
|
||||
// Teams
|
||||
registerV1CreateTeam(privateRouter)
|
||||
registerV1ListTeams(privateRouter)
|
||||
registerV1GetTeam(privateRouter)
|
||||
registerV1DeleteTeam(privateRouter)
|
||||
registerV1UpdateTeam(privateRouter)
|
||||
|
||||
// Team members
|
||||
registerV1CreateTeamMember(privateRouter)
|
||||
registerV1UpdateTeamMember(privateRouter)
|
||||
registerV1DeleteTeamMember(privateRouter)
|
||||
|
||||
// Storage
|
||||
registerV1GetSignedStorageUploadUrl(privateRouter)
|
||||
|
||||
// Public projects
|
||||
registerV1ListPublicProjects(publicRouter)
|
||||
registerV1GetPublicProjectByIdentifier(publicRouter) // must be before `registerV1GetPublicProject`
|
||||
registerV1GetPublicProject(publicRouter)
|
||||
|
||||
// Private projects
|
||||
registerV1CreateProject(privateRouter)
|
||||
registerV1ListProjects(privateRouter)
|
||||
registerV1GetProjectByIdentifier(privateRouter) // must be before `registerV1GetProject`
|
||||
registerV1GetProject(privateRouter)
|
||||
registerV1UpdateProject(privateRouter)
|
||||
|
||||
// Consumers
|
||||
registerV1GetConsumerByProjectIdentifier(privateRouter) // must be before `registerV1GetConsumer`
|
||||
registerV1CreateBillingPortalSession(privateRouter)
|
||||
registerV1GetConsumer(privateRouter)
|
||||
registerV1CreateConsumer(privateRouter)
|
||||
registerV1CreateConsumerCheckoutSession(privateRouter)
|
||||
registerV1CreateConsumerBillingPortalSession(privateRouter)
|
||||
registerV1UpdateConsumer(privateRouter)
|
||||
registerV1RefreshConsumerApiKey(privateRouter)
|
||||
registerV1ListConsumers(privateRouter)
|
||||
registerV1ListConsumersForProject(privateRouter)
|
||||
|
||||
// Deployments
|
||||
registerV1GetPublicDeploymentByIdentifier(publicRouter)
|
||||
registerV1GetDeploymentByIdentifier(privateRouter) // must be before `registerV1GetDeployment`
|
||||
registerV1GetDeployment(privateRouter)
|
||||
registerV1CreateDeployment(privateRouter)
|
||||
registerV1UpdateDeployment(privateRouter)
|
||||
registerV1ListDeployments(privateRouter)
|
||||
registerV1PublishDeployment(privateRouter)
|
||||
|
||||
// Internal admin routes
|
||||
registerV1AdminGetConsumerByApiKey(privateRouter)
|
||||
registerV1AdminActivateConsumer(privateRouter)
|
||||
registerV1AdminGetDeploymentByIdentifier(privateRouter)
|
||||
|
||||
// Webhook event handlers
|
||||
registerV1StripeWebhook(publicRouter)
|
||||
|
||||
// Setup routes and middleware
|
||||
apiV1.route('/', publicRouter)
|
||||
apiV1.use(middleware.authenticate)
|
||||
apiV1.use(middleware.team)
|
||||
apiV1.use(middleware.me)
|
||||
apiV1.route('/', privateRouter)
|
||||
|
||||
// API route types to be used by Hono's RPC client.
|
||||
// Should include all routes except for internal and admin routes.
|
||||
// NOTE: Removing for now because Hono's RPC client / types are clunky and slow.
|
||||
// export type ApiRoutes =
|
||||
// | ReturnType<typeof registerHealthCheck>
|
|
@ -0,0 +1,92 @@
|
|||
import { assert, parseZodSchema, sha256 } from '@agentic/platform-core'
|
||||
import { parseProjectIdentifier } from '@agentic/platform-validators'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, schema } from '@/db'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Creates a new project.',
|
||||
tags: ['projects'],
|
||||
operationId: 'createProject',
|
||||
method: 'post',
|
||||
path: 'projects',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.projectInsertSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The created project',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.projectSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1CreateProject(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const body = c.req.valid('json')
|
||||
const user = await ensureAuthUser(c)
|
||||
|
||||
// if (body.teamId) {
|
||||
// await aclTeamMember(c, { teamId: body.teamId })
|
||||
// }
|
||||
|
||||
const teamMember = c.get('teamMember')
|
||||
const namespace = teamMember ? teamMember.teamSlug : user.username
|
||||
const identifier = `@${namespace}/${body.slug}`
|
||||
const { projectIdentifier, projectNamespace, projectSlug } =
|
||||
parseProjectIdentifier(identifier)
|
||||
|
||||
// Used for testing e2e fixtures in the development marketplace
|
||||
const isPrivate = !(
|
||||
(user.username === 'dev' && env.isDev) ||
|
||||
user.username === 'agentic'
|
||||
)
|
||||
|
||||
// Used to simplify recreating the demo `@agentic/search` project during
|
||||
// development while we're frequently resetting the database
|
||||
const secret =
|
||||
projectIdentifier === '@dev/search' ||
|
||||
projectIdentifier === '@agentic/search'
|
||||
? env.AGENTIC_SEARCH_PROXY_SECRET
|
||||
: await sha256()
|
||||
|
||||
const [project] = await db
|
||||
.insert(schema.projects)
|
||||
.values({
|
||||
...body,
|
||||
identifier: projectIdentifier,
|
||||
namespace: projectNamespace,
|
||||
slug: projectSlug,
|
||||
teamId: teamMember?.teamId,
|
||||
userId: user.id,
|
||||
private: isPrivate,
|
||||
_secret: secret
|
||||
})
|
||||
.returning()
|
||||
assert(project, 500, `Failed to create project "${body.name}"`)
|
||||
|
||||
return c.json(parseZodSchema(schema.projectSelectSchema, project))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { projectIdentifierAndPopulateSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Gets a project by its public identifier (eg, "@username/project-slug").',
|
||||
tags: ['projects'],
|
||||
operationId: 'getProjectByIdentifier',
|
||||
method: 'get',
|
||||
path: 'projects/by-identifier',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: projectIdentifierAndPopulateSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A project',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.projectSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetProjectByIdentifier(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { projectIdentifier, populate = [] } = c.req.valid('query')
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.identifier, projectIdentifier),
|
||||
with: {
|
||||
lastPublishedDeployment: true,
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(project, 404, `Project not found "${projectIdentifier}"`)
|
||||
await acl(c, project, { label: 'Project' })
|
||||
|
||||
return c.json(parseZodSchema(schema.projectSelectSchema, project))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { populateProjectSchema, projectIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Gets a project by ID.',
|
||||
tags: ['projects'],
|
||||
operationId: 'getProject',
|
||||
method: 'get',
|
||||
path: 'projects/{projectId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: projectIdParamsSchema,
|
||||
query: populateProjectSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A project',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.projectSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetProject(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { projectId } = c.req.valid('param')
|
||||
const { populate = [] } = c.req.valid('query')
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, projectId),
|
||||
with: {
|
||||
lastPublishedDeployment: true,
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(project, 404, `Project not found "${projectId}"`)
|
||||
await acl(c, project, { label: 'Project' })
|
||||
|
||||
return c.json(parseZodSchema(schema.projectSelectSchema, project))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { aclPublicProject } from '@/lib/acl-public-project'
|
||||
import { setPublicCacheControl } from '@/lib/cache-control'
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { projectIdentifierAndPopulateSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Gets a public project by its public identifier (eg, "@username/project-slug").',
|
||||
tags: ['projects'],
|
||||
operationId: 'getPublicProjectByIdentifier',
|
||||
method: 'get',
|
||||
path: 'projects/public/by-identifier',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: projectIdentifierAndPopulateSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A project',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.projectSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetPublicProjectByIdentifier(
|
||||
app: OpenAPIHono<DefaultHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { projectIdentifier, populate = [] } = c.req.valid('query')
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.identifier, projectIdentifier),
|
||||
with: {
|
||||
lastPublishedDeployment: true,
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
aclPublicProject(project, projectIdentifier)
|
||||
setPublicCacheControl(c.res, env.isProd ? '1m' : '10s')
|
||||
|
||||
return c.json(parseZodSchema(schema.projectSelectSchema, project))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { aclPublicProject } from '@/lib/acl-public-project'
|
||||
import { setPublicCacheControl } from '@/lib/cache-control'
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { populateProjectSchema, projectIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Gets a public project by ID.',
|
||||
tags: ['projects'],
|
||||
operationId: 'getPublicProject',
|
||||
method: 'get',
|
||||
path: 'projects/public/{projectId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: projectIdParamsSchema,
|
||||
query: populateProjectSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A project',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.projectSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetPublicProject(app: OpenAPIHono<DefaultHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { projectId } = c.req.valid('param')
|
||||
const { populate = [] } = c.req.valid('query')
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, projectId),
|
||||
with: {
|
||||
lastPublishedDeployment: true,
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
aclPublicProject(project, projectId)
|
||||
setPublicCacheControl(c.res, env.isProd ? '1m' : '10s')
|
||||
|
||||
return c.json(parseZodSchema(schema.projectSelectSchema, project))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { paginationAndPopulateProjectSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Lists projects owned by the authenticated user or team.',
|
||||
tags: ['projects'],
|
||||
operationId: 'listProjects',
|
||||
method: 'get',
|
||||
path: 'projects',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: paginationAndPopulateProjectSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A list of projects',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.array(schema.projectSelectSchema)
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1ListProjects(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
sort = 'desc',
|
||||
sortBy = 'createdAt',
|
||||
populate = []
|
||||
} = c.req.valid('query')
|
||||
|
||||
const user = await ensureAuthUser(c)
|
||||
const teamMember = c.get('teamMember')
|
||||
const isAdmin = user.role === 'admin'
|
||||
|
||||
const projects = await db.query.projects.findMany({
|
||||
where: isAdmin
|
||||
? undefined
|
||||
: teamMember
|
||||
? eq(schema.projects.teamId, teamMember.teamId)
|
||||
: eq(schema.projects.userId, user.id),
|
||||
with: {
|
||||
lastPublishedDeployment: true,
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
},
|
||||
orderBy: (projects, { asc, desc }) => [
|
||||
sort === 'desc' ? desc(projects[sortBy]) : asc(projects[sortBy])
|
||||
],
|
||||
offset,
|
||||
limit
|
||||
})
|
||||
|
||||
return c.json(parseZodSchema(z.array(schema.projectSelectSchema), projects))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
import { env } from 'node:process'
|
||||
|
||||
import type { DefaultHonoEnv } from '@agentic/platform-hono'
|
||||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import {
|
||||
and,
|
||||
arrayContains,
|
||||
db,
|
||||
eq,
|
||||
isNotNull,
|
||||
isNull,
|
||||
not,
|
||||
or,
|
||||
schema
|
||||
} from '@/db'
|
||||
import { setPublicCacheControl } from '@/lib/cache-control'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { listPublicProjectsQuerySchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Lists projects that have been published publicly to the marketplace.',
|
||||
tags: ['projects'],
|
||||
operationId: 'listPublicProjects',
|
||||
method: 'get',
|
||||
path: 'projects/public',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: listPublicProjectsQuerySchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A list of projects',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.array(schema.projectSelectSchema)
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1ListPublicProjects(app: OpenAPIHono<DefaultHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
sort = 'desc',
|
||||
sortBy = 'createdAt',
|
||||
populate = [],
|
||||
tag,
|
||||
notTag
|
||||
} = c.req.valid('query')
|
||||
|
||||
const projects = await db.query.projects.findMany({
|
||||
// List projects that are not private and have at least one published deployment
|
||||
// And optionally match a given tag
|
||||
where: and(
|
||||
eq(schema.projects.private, false),
|
||||
isNotNull(schema.projects.lastPublishedDeploymentId),
|
||||
tag ? arrayContains(schema.projects.tags, [tag]) : undefined,
|
||||
notTag
|
||||
? or(
|
||||
not(arrayContains(schema.projects.tags, [notTag])),
|
||||
isNull(schema.projects.tags)
|
||||
)
|
||||
: undefined
|
||||
),
|
||||
with: {
|
||||
lastPublishedDeployment: true,
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
},
|
||||
orderBy: (projects, { asc, desc }) => [
|
||||
sort === 'desc' ? desc(projects[sortBy]) : asc(projects[sortBy])
|
||||
],
|
||||
offset,
|
||||
limit
|
||||
})
|
||||
setPublicCacheControl(c.res, env.isProd ? '1m' : '10s')
|
||||
|
||||
return c.json(parseZodSchema(z.array(schema.projectSelectSchema), projects))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
// import { isValidNamespace } from '@agentic/platform-validators'
|
||||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import {
|
||||
paginationSchema,
|
||||
projectIdentifierSchema,
|
||||
projectIdSchema,
|
||||
projectRelationsSchema
|
||||
} from '@/db'
|
||||
|
||||
export const projectIdParamsSchema = z.object({
|
||||
projectId: projectIdSchema.openapi({
|
||||
param: {
|
||||
description: 'Project ID',
|
||||
name: 'projectId',
|
||||
in: 'path'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// export const namespaceParamsSchema = z.object({
|
||||
// namespace: z
|
||||
// .string()
|
||||
// .refine((namespace) => isValidNamespace(namespace), {
|
||||
// message: 'Invalid namespace'
|
||||
// })
|
||||
// .openapi({
|
||||
// param: {
|
||||
// description: 'Namespace',
|
||||
// name: 'namespace',
|
||||
// in: 'path'
|
||||
// }
|
||||
// })
|
||||
// })
|
||||
|
||||
export const projectIdentifierQuerySchema = z.object({
|
||||
projectIdentifier: projectIdentifierSchema
|
||||
})
|
||||
|
||||
export const filterPublicProjectSchema = z.object({
|
||||
tag: z.string().optional(),
|
||||
notTag: z.string().optional()
|
||||
})
|
||||
|
||||
export const populateProjectSchema = z.object({
|
||||
populate: z
|
||||
.union([projectRelationsSchema, z.array(projectRelationsSchema)])
|
||||
.default([])
|
||||
.transform((p) => (Array.isArray(p) ? p : [p]))
|
||||
.optional()
|
||||
})
|
||||
|
||||
export const projectIdentifierAndPopulateSchema = z.object({
|
||||
...populateProjectSchema.shape,
|
||||
...projectIdentifierQuerySchema.shape
|
||||
})
|
||||
|
||||
export const paginationAndPopulateProjectSchema = z.object({
|
||||
...paginationSchema.shape,
|
||||
...populateProjectSchema.shape
|
||||
})
|
||||
|
||||
export const listPublicProjectsQuerySchema = z.object({
|
||||
...paginationSchema.shape,
|
||||
...populateProjectSchema.shape,
|
||||
...filterPublicProjectSchema.shape
|
||||
})
|
|
@ -0,0 +1,71 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { projectIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Updates a project.',
|
||||
tags: ['projects'],
|
||||
operationId: 'updateProject',
|
||||
method: 'post',
|
||||
path: 'projects/{projectId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: projectIdParamsSchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.projectUpdateSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The updated project',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.projectSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1UpdateProject(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { projectId } = c.req.valid('param')
|
||||
const body = c.req.valid('json')
|
||||
|
||||
// First ensure the project exists and the user has access to it
|
||||
let project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.id, projectId)
|
||||
})
|
||||
assert(project, 404, `Project not found "${projectId}"`)
|
||||
await acl(c, project, { label: 'Project' })
|
||||
|
||||
// Update the project
|
||||
;[project] = await db
|
||||
.update(schema.projects)
|
||||
.set(body)
|
||||
.where(eq(schema.projects.id, projectId))
|
||||
.returning()
|
||||
assert(project, 500, `Failed to update project "${projectId}"`)
|
||||
|
||||
return c.json(parseZodSchema(schema.projectSelectSchema, project))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, projectIdentifierSchema, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
import {
|
||||
getStorageObjectPublicUrl,
|
||||
getStorageSignedUploadUrl
|
||||
} from '@/lib/storage'
|
||||
|
||||
export const getSignedUploadUrlQuerySchema = z.object({
|
||||
projectIdentifier: projectIdentifierSchema,
|
||||
|
||||
/**
|
||||
* Should be a hash of the contents of the file to upload with the correct
|
||||
* file extension.
|
||||
*
|
||||
* @example `9f86d081884c7d659a2feaa0c55ad015a.png`
|
||||
*/
|
||||
key: z
|
||||
.string()
|
||||
.nonempty()
|
||||
.describe(
|
||||
'Should be a hash of the contents of the file to upload with the correct file extension (eg, "9f86d081884c7d659a2feaa0c55ad015a.png").'
|
||||
)
|
||||
})
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
"Gets a signed URL for uploading a file to Agentic's blob storage. Files are namespaced to a given project and are identified by a key that should be a hash of the file's contents, with the correct file extension.",
|
||||
tags: ['storage'],
|
||||
operationId: 'getSignedStorageUploadUrl',
|
||||
method: 'get',
|
||||
path: 'storage/signed-upload-url',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: getSignedUploadUrlQuerySchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A signed upload URL',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.object({
|
||||
signedUploadUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe('The signed upload URL.'),
|
||||
publicObjectUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe('The public URL the object will have once uploaded.')
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetSignedStorageUploadUrl(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { projectIdentifier, key } = c.req.valid('query')
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.identifier, projectIdentifier)
|
||||
})
|
||||
assert(project, 404, `Project not found "${projectIdentifier}"`)
|
||||
await acl(c, project, { label: 'Project' })
|
||||
|
||||
const compoundKey = `${project.identifier}/${key}`
|
||||
const signedUploadUrl = await getStorageSignedUploadUrl(compoundKey)
|
||||
const publicObjectUrl = getStorageObjectPublicUrl(compoundKey)
|
||||
|
||||
return c.json({ signedUploadUrl, publicObjectUrl })
|
||||
})
|
||||
}
|
|
@ -0,0 +1,79 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, schema } from '@/db'
|
||||
import { ensureAuthUser } from '@/lib/ensure-auth-user'
|
||||
import { ensureUniqueNamespace } from '@/lib/ensure-unique-namespace'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Creates a new team.',
|
||||
tags: ['teams'],
|
||||
operationId: 'createTeam',
|
||||
method: 'post',
|
||||
path: 'teams',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamInsertSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The created team',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1CreateTeam(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const user = await ensureAuthUser(c)
|
||||
const body = c.req.valid('json')
|
||||
|
||||
await ensureUniqueNamespace(body.slug, { label: 'Team slug' })
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const [team] = await tx
|
||||
.insert(schema.teams)
|
||||
.values({
|
||||
...body,
|
||||
ownerId: user.id
|
||||
})
|
||||
.returning()
|
||||
assert(team, 500, `Failed to create team "${body.slug}"`)
|
||||
|
||||
const [teamMember] = await tx
|
||||
.insert(schema.teamMembers)
|
||||
.values({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
teamSlug: team.slug,
|
||||
role: 'admin',
|
||||
confirmed: true
|
||||
})
|
||||
.returning()
|
||||
assert(
|
||||
teamMember,
|
||||
500,
|
||||
`Failed to create team member owner for team "${body.slug}"`
|
||||
)
|
||||
|
||||
return c.json(parseZodSchema(schema.teamSelectSchema, team))
|
||||
})
|
||||
})
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { aclTeamAdmin } from '@/lib/acl-team-admin'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { teamIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Deletes a team by ID.',
|
||||
tags: ['teams'],
|
||||
operationId: 'deleteTeam',
|
||||
method: 'delete',
|
||||
path: 'teams/{teamId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: teamIdParamsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The team that was deleted',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1DeleteTeam(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { teamId } = c.req.valid('param')
|
||||
await aclTeamAdmin(c, { teamId })
|
||||
|
||||
const [team] = await db
|
||||
.delete(schema.teams)
|
||||
.where(eq(schema.teams.id, teamId))
|
||||
.returning()
|
||||
assert(team, 404, `Team not found "${teamId}"`)
|
||||
|
||||
return c.json(parseZodSchema(schema.teamSelectSchema, team))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,51 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { aclTeamMember } from '@/lib/acl-team-member'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { teamIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Gets a team by ID.',
|
||||
tags: ['teams'],
|
||||
operationId: 'getTeam',
|
||||
method: 'get',
|
||||
path: 'teams/{teamId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: teamIdParamsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A team object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetTeam(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { teamId } = c.req.valid('param')
|
||||
await aclTeamMember(c, { teamId })
|
||||
|
||||
const team = await db.query.teams.findFirst({
|
||||
where: eq(schema.teams.id, teamId)
|
||||
})
|
||||
assert(team, 404, `Team not found "${teamId}"`)
|
||||
|
||||
return c.json(parseZodSchema(schema.teamSelectSchema, team))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,60 @@
|
|||
import { parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, paginationSchema, schema } from '@/db'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Lists all teams the authenticated user belongs to.',
|
||||
tags: ['teams'],
|
||||
operationId: 'listTeams',
|
||||
method: 'get',
|
||||
path: 'teams',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: paginationSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A list of teams',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: z.array(schema.teamSelectSchema)
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1ListTeams(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 10,
|
||||
sort = 'desc',
|
||||
sortBy = 'createdAt'
|
||||
} = c.req.valid('query')
|
||||
const userId = c.get('userId')
|
||||
|
||||
// schema.teamMembers._.columns
|
||||
|
||||
const teamMembers = await db.query.teamMembers.findMany({
|
||||
where: eq(schema.teamMembers.userId, userId),
|
||||
with: {
|
||||
team: true
|
||||
},
|
||||
orderBy: (teamMembers, { asc, desc }) => [
|
||||
sort === 'desc' ? desc(teamMembers[sortBy]) : asc(teamMembers[sortBy])
|
||||
],
|
||||
offset,
|
||||
limit
|
||||
})
|
||||
|
||||
return c.json(parseZodSchema(z.array(schema.teamSelectSchema), teamMembers))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { and, db, eq, schema } from '@/db'
|
||||
import { aclTeamAdmin } from '@/lib/acl-team-admin'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponse409,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { teamIdParamsSchema } from '../schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Creates a new team member.',
|
||||
tags: ['teams'],
|
||||
operationId: 'createTeamMember',
|
||||
method: 'post',
|
||||
path: 'teams/{teamId}/members',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: teamIdParamsSchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamMemberInsertSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The created team member',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamMemberSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404,
|
||||
...openapiErrorResponse409
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1CreateTeamMember(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { teamId } = c.req.valid('param')
|
||||
const body = c.req.valid('json')
|
||||
await aclTeamAdmin(c, { teamId })
|
||||
|
||||
const team = await db.query.teams.findFirst({
|
||||
where: eq(schema.teams.id, teamId)
|
||||
})
|
||||
assert(team, 404, `Team not found "${teamId}"`)
|
||||
|
||||
const existingTeamMember = await db.query.teamMembers.findFirst({
|
||||
where: and(
|
||||
eq(schema.teamMembers.teamId, teamId),
|
||||
eq(schema.teamMembers.userId, body.userId)
|
||||
)
|
||||
})
|
||||
assert(
|
||||
existingTeamMember,
|
||||
409,
|
||||
`User "${body.userId}" is already a member of team "${teamId}"`
|
||||
)
|
||||
|
||||
const [teamMember] = await db
|
||||
.insert(schema.teamMembers)
|
||||
.values({
|
||||
...body,
|
||||
teamId,
|
||||
teamSlug: team.slug
|
||||
})
|
||||
.returning()
|
||||
assert(
|
||||
teamMember,
|
||||
500,
|
||||
`Failed to create team member "${body.userId}"for team "${teamId}"`
|
||||
)
|
||||
|
||||
// TODO: send team invite email
|
||||
|
||||
return c.json(parseZodSchema(schema.teamMemberSelectSchema, teamMember))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { and, db, eq, schema } from '@/db'
|
||||
import { aclTeamAdmin } from '@/lib/acl-team-admin'
|
||||
import { aclTeamMember } from '@/lib/acl-team-member'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { teamIdTeamMemberUserIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Deletes a team member.',
|
||||
tags: ['teams'],
|
||||
operationId: 'deleteTeamMember',
|
||||
method: 'delete',
|
||||
path: 'teams/{teamId}/members/{userId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: teamIdTeamMemberUserIdParamsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The deleted team member',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamMemberSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1DeleteTeamMember(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { teamId, userId } = c.req.valid('param')
|
||||
|
||||
await aclTeamAdmin(c, { teamId })
|
||||
await aclTeamMember(c, { teamId, userId })
|
||||
|
||||
const [teamMember] = await db
|
||||
.delete(schema.teamMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.teamMembers.teamId, teamId),
|
||||
eq(schema.teamMembers.userId, userId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
assert(
|
||||
teamMember,
|
||||
400,
|
||||
`Failed to update team member "${userId}" for team "${teamId}"`
|
||||
)
|
||||
|
||||
return c.json(parseZodSchema(schema.teamMemberSelectSchema, teamMember))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import { userIdSchema } from '@/db'
|
||||
|
||||
import { teamIdParamsSchema } from '../schemas'
|
||||
|
||||
export const teamIdTeamMemberUserIdParamsSchema = z.object({
|
||||
...teamIdParamsSchema.shape,
|
||||
|
||||
userId: userIdSchema.openapi({
|
||||
param: {
|
||||
description: 'Team member user ID',
|
||||
name: 'userId',
|
||||
in: 'path'
|
||||
}
|
||||
})
|
||||
})
|
|
@ -0,0 +1,76 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { and, db, eq, schema } from '@/db'
|
||||
import { aclTeamAdmin } from '@/lib/acl-team-admin'
|
||||
import { aclTeamMember } from '@/lib/acl-team-member'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { teamIdTeamMemberUserIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Updates a team member.',
|
||||
tags: ['teams'],
|
||||
operationId: 'updateTeamMember',
|
||||
method: 'post',
|
||||
path: 'teams/{teamId}/members/{userId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: teamIdTeamMemberUserIdParamsSchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamMemberUpdateSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The updated team member',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamMemberSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1UpdateTeamMember(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { teamId, userId } = c.req.valid('param')
|
||||
const body = c.req.valid('json')
|
||||
|
||||
await aclTeamAdmin(c, { teamId })
|
||||
await aclTeamMember(c, { teamId, userId })
|
||||
|
||||
const [teamMember] = await db
|
||||
.update(schema.teamMembers)
|
||||
.set(body)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.teamMembers.teamId, teamId),
|
||||
eq(schema.teamMembers.userId, userId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
assert(
|
||||
teamMember,
|
||||
400,
|
||||
`Failed to update team member "${userId}" for team "${teamId}"`
|
||||
)
|
||||
|
||||
return c.json(parseZodSchema(schema.teamMemberSelectSchema, teamMember))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import { teamIdSchema } from '@/db'
|
||||
|
||||
export const teamIdParamsSchema = z.object({
|
||||
teamId: teamIdSchema.openapi({
|
||||
param: {
|
||||
description: 'Team ID',
|
||||
name: 'teamId',
|
||||
in: 'path'
|
||||
}
|
||||
})
|
||||
})
|
|
@ -0,0 +1,62 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { aclTeamAdmin } from '@/lib/acl-team-admin'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { teamIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Updates a team.',
|
||||
tags: ['teams'],
|
||||
operationId: 'updateTeam',
|
||||
method: 'post',
|
||||
path: 'teams/{teamId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: teamIdParamsSchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamUpdateSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'The updated team',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.teamSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1UpdateTeam(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { teamId } = c.req.valid('param')
|
||||
const body = c.req.valid('json')
|
||||
await aclTeamAdmin(c, { teamId })
|
||||
|
||||
const [team] = await db
|
||||
.update(schema.teams)
|
||||
.set(body)
|
||||
.where(eq(schema.teams.id, teamId))
|
||||
.returning()
|
||||
assert(team, 404, `Team not found "${teamId}"`)
|
||||
|
||||
return c.json(parseZodSchema(schema.teamSelectSchema, team))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { setPublicCacheControl } from '@/lib/cache-control'
|
||||
import { env } from '@/lib/env'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { userIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Gets a user by ID.',
|
||||
tags: ['users'],
|
||||
operationId: 'getUser',
|
||||
method: 'get',
|
||||
path: 'users/{userId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: userIdParamsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A user object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.userSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetUser(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { userId } = c.req.valid('param')
|
||||
await acl(c, { userId }, { label: 'User' })
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.id, userId)
|
||||
})
|
||||
assert(user, 404, `User not found "${userId}"`)
|
||||
setPublicCacheControl(c.res, env.isProd ? '30s' : '10s')
|
||||
|
||||
return c.json(parseZodSchema(schema.userSelectSchema, user))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import { userIdSchema } from '@/db'
|
||||
|
||||
export const userIdParamsSchema = z.object({
|
||||
userId: userIdSchema.openapi({
|
||||
param: {
|
||||
description: 'User ID',
|
||||
name: 'userId',
|
||||
in: 'path'
|
||||
}
|
||||
})
|
||||
})
|
|
@ -0,0 +1,62 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { userIdParamsSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description: 'Updates a user by ID.',
|
||||
tags: ['users'],
|
||||
operationId: 'updateUser',
|
||||
method: 'post',
|
||||
path: 'users/{userId}',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
params: userIdParamsSchema,
|
||||
body: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.userUpdateSchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A user object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.userSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1UpdateUser(app: OpenAPIHono<AuthenticatedHonoEnv>) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { userId } = c.req.valid('param')
|
||||
await acl(c, { userId }, { label: 'User' })
|
||||
const body = c.req.valid('json')
|
||||
|
||||
const [user] = await db
|
||||
.update(schema.users)
|
||||
.set(body)
|
||||
.where(eq(schema.users.id, userId))
|
||||
.returning()
|
||||
assert(user, 404, `User not found "${userId}"`)
|
||||
|
||||
return c.json(parseZodSchema(schema.userSelectSchema, user))
|
||||
})
|
||||
}
|
|
@ -0,0 +1,351 @@
|
|||
import type Stripe from 'stripe'
|
||||
import { assert, HttpError } from '@agentic/platform-core'
|
||||
|
||||
import type { HonoApp } from '@/lib/types'
|
||||
import {
|
||||
and,
|
||||
db,
|
||||
eq,
|
||||
getStripePriceIdForPricingPlanLineItem,
|
||||
type RawConsumer,
|
||||
type RawDeployment,
|
||||
type RawProject,
|
||||
schema
|
||||
} from '@/db'
|
||||
import { setConsumerStripeSubscriptionStatus } from '@/lib/consumers/utils'
|
||||
import { env } from '@/lib/env'
|
||||
import { stripe } from '@/lib/external/stripe'
|
||||
|
||||
const relevantStripeEvents = new Set<Stripe.Event.Type>([
|
||||
// Stripe Checkout Sessions
|
||||
'checkout.session.completed',
|
||||
|
||||
// TODO: Handle these events
|
||||
// 'checkout.session.expired',
|
||||
// 'checkout.session.async_payment_failed',
|
||||
// 'checkout.session.async_payment_succeeded',
|
||||
|
||||
// Stripe Subscriptions
|
||||
'customer.subscription.created',
|
||||
|
||||
// TODO: Test these events which should be able to all use the same code path
|
||||
'customer.subscription.updated',
|
||||
'customer.subscription.paused',
|
||||
'customer.subscription.resumed',
|
||||
'customer.subscription.deleted'
|
||||
|
||||
// TODO: Handle these events
|
||||
// 'customer.subscription.pending_update_applied',
|
||||
// 'customer.subscription.pending_update_expired',
|
||||
// 'customer.subscription.trial_will_end'
|
||||
])
|
||||
|
||||
export function registerV1StripeWebhook(app: HonoApp) {
|
||||
return app.post('webhooks/stripe', async (ctx) => {
|
||||
const logger = ctx.get('logger')
|
||||
const body = await ctx.req.text()
|
||||
const signature = ctx.req.header('Stripe-Signature')
|
||||
assert(
|
||||
signature,
|
||||
400,
|
||||
'error invalid stripe webhook event: missing signature'
|
||||
)
|
||||
|
||||
let event: Stripe.Event
|
||||
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
env.STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
} catch (err) {
|
||||
throw new HttpError({
|
||||
message: 'error invalid stripe webhook event: signature mismatch',
|
||||
cause: err,
|
||||
statusCode: 400
|
||||
})
|
||||
}
|
||||
|
||||
// Shouldn't ever happen because the signatures _should_ be different, but
|
||||
// it's a useful sanity check just in case.
|
||||
assert(
|
||||
event.livemode === env.isStripeLive,
|
||||
400,
|
||||
'error invalid stripe webhook event: livemode mismatch'
|
||||
)
|
||||
|
||||
if (!relevantStripeEvents.has(event.type)) {
|
||||
return ctx.json({ status: 'ok' })
|
||||
}
|
||||
|
||||
logger.info('stripe webhook', event.type, event.data?.object)
|
||||
|
||||
try {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
const checkoutSession = event.data.object
|
||||
const { subscription: subscriptionOrId } = checkoutSession
|
||||
assert(subscriptionOrId, 400, 'missing subscription')
|
||||
const { consumerId, plan, userId, projectId, deploymentId } =
|
||||
checkoutSession.metadata ?? {}
|
||||
assert(consumerId, 400, 'missing metadata.consumerId')
|
||||
assert(plan !== undefined, 400, 'missing metadata.plan')
|
||||
|
||||
const subscriptionId =
|
||||
typeof subscriptionOrId === 'string'
|
||||
? subscriptionOrId
|
||||
: subscriptionOrId.id
|
||||
|
||||
const [subscription, consumer, deployment] = await Promise.all([
|
||||
// Make sure we have the full subscription instead of just the id
|
||||
typeof subscriptionOrId === 'string'
|
||||
? stripe.subscriptions.retrieve(subscriptionId)
|
||||
: subscriptionOrId,
|
||||
|
||||
db.query.consumers.findFirst({
|
||||
where: and(eq(schema.consumers.id, consumerId)),
|
||||
with: { project: true }
|
||||
}),
|
||||
|
||||
deploymentId
|
||||
? db.query.deployments.findFirst({
|
||||
where: and(eq(schema.deployments.id, deploymentId))
|
||||
})
|
||||
: undefined
|
||||
])
|
||||
assert(
|
||||
subscription,
|
||||
404,
|
||||
`stripe subscription "${subscriptionId}" not found`
|
||||
)
|
||||
assert(consumer, 404, `consumer "${consumerId}" not found`)
|
||||
if (deploymentId) {
|
||||
assert(deployment, 404, `deployment "${deploymentId}" not found`)
|
||||
}
|
||||
const { project } = consumer
|
||||
assert(project, 404, `project "${projectId}" not found`)
|
||||
|
||||
// TODO: Treat this as a transaction...
|
||||
await Promise.all([
|
||||
// Ensure the underlying Stripe Subscription has all the necessary
|
||||
// metadata
|
||||
stripe.subscriptions.update(subscription.id, {
|
||||
metadata: {
|
||||
...subscription.metadata,
|
||||
...checkoutSession.metadata
|
||||
}
|
||||
}),
|
||||
|
||||
// Sync our Consumer's state with the Stripe Subscription's state
|
||||
syncConsumerWithStripeSubscription({
|
||||
consumer,
|
||||
deployment,
|
||||
project,
|
||||
subscription,
|
||||
plan,
|
||||
userId,
|
||||
projectId,
|
||||
deploymentId
|
||||
})
|
||||
])
|
||||
break
|
||||
}
|
||||
|
||||
case 'customer.subscription.created': {
|
||||
// Stripe Checkout-created subscriptions won't have the metadata
|
||||
// necessary to identify the consumer, so ignore this event for now.
|
||||
const subscription = event.data.object
|
||||
const { consumerId, userId, projectId, deploymentId, plan } =
|
||||
subscription.metadata
|
||||
|
||||
// TODO: This should be coming from Stripe Checkout, and a subsequent
|
||||
// webhook event should record the subscription and initialize the
|
||||
// consumer, but it feels wrong to me to just be logging and ignore
|
||||
// this event. In the future, if we support both Stripe Checkout and
|
||||
// non-Stripe Checkout-based subscription flows, then this codepath
|
||||
// should act very similarly to `customer.subscription.updated`.
|
||||
if (
|
||||
!consumerId ||
|
||||
!userId ||
|
||||
!projectId ||
|
||||
!deploymentId ||
|
||||
plan === undefined
|
||||
) {
|
||||
break
|
||||
}
|
||||
|
||||
// Intentional fallthrough
|
||||
}
|
||||
|
||||
case 'customer.subscription.paused':
|
||||
case 'customer.subscription.resumed':
|
||||
case 'customer.subscription.deleted':
|
||||
case 'customer.subscription.updated': {
|
||||
// https://docs.stripe.com/billing/subscriptions/overview#subscription-statuses
|
||||
const subscription = event.data.object
|
||||
const { consumerId, userId, projectId, deploymentId, plan } =
|
||||
subscription.metadata
|
||||
assert(consumerId, 'missing metadata.consumerId')
|
||||
assert(plan !== undefined, 400, 'missing metadata.plan')
|
||||
|
||||
logger.info('stripe webhook', event.type, {
|
||||
consumerId,
|
||||
userId,
|
||||
projectId,
|
||||
deploymentId,
|
||||
plan,
|
||||
status: subscription.status
|
||||
})
|
||||
|
||||
const [consumer, deployment] = await Promise.all([
|
||||
db.query.consumers.findFirst({
|
||||
where: eq(schema.consumers.id, consumerId),
|
||||
with: { project: true }
|
||||
}),
|
||||
|
||||
deploymentId
|
||||
? db.query.deployments.findFirst({
|
||||
where: and(eq(schema.deployments.id, deploymentId))
|
||||
})
|
||||
: undefined
|
||||
])
|
||||
assert(consumer, 404, `consumer "${consumerId}" not found`)
|
||||
if (deploymentId) {
|
||||
assert(deployment, 404, `deployment "${deploymentId}" not found`)
|
||||
}
|
||||
const { project } = consumer
|
||||
|
||||
// Sync our Consumer's state with the Stripe Subscription's state
|
||||
await syncConsumerWithStripeSubscription({
|
||||
consumer,
|
||||
deployment,
|
||||
project,
|
||||
subscription,
|
||||
plan,
|
||||
userId,
|
||||
projectId,
|
||||
deploymentId
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
logger.warn(
|
||||
`unexpected unhandled event "${event.id}" type "${event.type}"`,
|
||||
event.data?.object
|
||||
)
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new HttpError({
|
||||
message: `error processing stripe webhook event "${event.id}" type "${event.type}": ${err.message}`,
|
||||
cause: err.cause ?? err,
|
||||
statusCode: err.statusCode ?? err
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.json({ status: 'ok' })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync our database Consumer's state with the Stripe Subscription's state.
|
||||
*
|
||||
* For anything billing-related, Stripe's resources is always considered the
|
||||
* single source of truth. Our database's `Consumer` state should always be
|
||||
* derived from the corresponding Stripe subscription.
|
||||
*/
|
||||
export async function syncConsumerWithStripeSubscription({
|
||||
consumer,
|
||||
project,
|
||||
deployment,
|
||||
subscription,
|
||||
plan,
|
||||
userId,
|
||||
projectId,
|
||||
deploymentId
|
||||
}: {
|
||||
consumer: RawConsumer
|
||||
project: RawProject
|
||||
deployment?: RawDeployment
|
||||
subscription: Stripe.Subscription
|
||||
plan: string | null | undefined
|
||||
userId?: string
|
||||
projectId?: string
|
||||
deploymentId?: string
|
||||
}): Promise<RawConsumer> {
|
||||
// These extra checks aren't really necessary, but they're nice sanity checks
|
||||
// to ensure metadata consistency with our consumer
|
||||
assert(
|
||||
consumer.userId === userId,
|
||||
400,
|
||||
`consumer "${consumer.id}" user "${consumer.userId}" does not match stripe checkout metadata user "${userId}"`
|
||||
)
|
||||
assert(
|
||||
consumer.projectId === projectId,
|
||||
400,
|
||||
`consumer "${consumer.id}" project "${consumer.projectId}" does not match stripe checkout metadata project "${projectId}"`
|
||||
)
|
||||
|
||||
consumer._stripeSubscriptionId = subscription.id
|
||||
consumer.stripeStatus = subscription.status
|
||||
consumer.plan = plan as any // TODO: types
|
||||
setConsumerStripeSubscriptionStatus(consumer)
|
||||
|
||||
if (deploymentId) {
|
||||
consumer.deploymentId = deploymentId
|
||||
}
|
||||
|
||||
const pricingPlan = plan
|
||||
? deployment?.pricingPlans.find((p) => p.slug === plan)
|
||||
: undefined
|
||||
|
||||
if (pricingPlan) {
|
||||
for (const lineItem of pricingPlan.lineItems) {
|
||||
const stripeSubscriptionItemId =
|
||||
consumer._stripeSubscriptionItemIdMap[lineItem.slug]
|
||||
|
||||
const stripePriceId: string | undefined = stripeSubscriptionItemId
|
||||
? undefined
|
||||
: await getStripePriceIdForPricingPlanLineItem({
|
||||
pricingPlan,
|
||||
pricingPlanLineItem: lineItem,
|
||||
project
|
||||
})
|
||||
|
||||
const stripeSubscriptionItem: Stripe.SubscriptionItem | undefined =
|
||||
subscription.items.data.find((item) =>
|
||||
stripeSubscriptionItemId
|
||||
? item.id === stripeSubscriptionItemId
|
||||
: item.price.id === stripePriceId
|
||||
)
|
||||
|
||||
assert(
|
||||
stripeSubscriptionItem,
|
||||
500,
|
||||
`Error post-processing stripe subscription "${subscription.id}" for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
|
||||
)
|
||||
|
||||
consumer._stripeSubscriptionItemIdMap[lineItem.slug] =
|
||||
stripeSubscriptionItem.id
|
||||
assert(
|
||||
consumer._stripeSubscriptionItemIdMap[lineItem.slug],
|
||||
500,
|
||||
`Error post-processing stripe subscription "${subscription.id}" for line-item "${lineItem.slug}" on plan "${pricingPlan.slug}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const [updatedConsumer] = await db
|
||||
.update(schema.consumers)
|
||||
.set(consumer)
|
||||
.where(eq(schema.consumers.id, consumer.id))
|
||||
.returning()
|
||||
assert(updatedConsumer, 500, `consumer "${consumer.id}" not found`)
|
||||
|
||||
// TODO: invoke provider webhooks
|
||||
// event.data.customer = consumer.getPublicDocument()
|
||||
// await invokeWebhooks(consumer.project, event)
|
||||
|
||||
return updatedConsumer
|
||||
}
|
|
@ -0,0 +1,53 @@
|
|||
import { drizzle } from '@fisch0920/drizzle-orm/postgres-js'
|
||||
import postgres from 'postgres'
|
||||
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
import * as schema from './schema'
|
||||
|
||||
type PostgresClient = ReturnType<typeof postgres>
|
||||
|
||||
let _postgresClient: PostgresClient | undefined
|
||||
const postgresClient =
|
||||
_postgresClient ?? (_postgresClient = postgres(env.DATABASE_URL))
|
||||
|
||||
export const db = drizzle({ client: postgresClient, schema })
|
||||
|
||||
export * as schema from './schema'
|
||||
export {
|
||||
createIdForModel,
|
||||
idMaxLength,
|
||||
idPrefixMap,
|
||||
type ModelType
|
||||
} from './schema/common'
|
||||
export * from './schemas'
|
||||
export type * from './types'
|
||||
export * from './utils'
|
||||
export {
|
||||
and,
|
||||
arrayContained,
|
||||
arrayContains,
|
||||
arrayOverlaps,
|
||||
asc,
|
||||
between,
|
||||
desc,
|
||||
eq,
|
||||
exists,
|
||||
gt,
|
||||
gte,
|
||||
ilike,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
not,
|
||||
notBetween,
|
||||
notExists,
|
||||
notIlike,
|
||||
notInArray,
|
||||
notLike,
|
||||
or
|
||||
} from '@fisch0920/drizzle-orm'
|
|
@ -0,0 +1,75 @@
|
|||
import { relations } from '@fisch0920/drizzle-orm'
|
||||
import { index, pgTable, text, timestamp } from '@fisch0920/drizzle-orm/pg-core'
|
||||
|
||||
import { userIdSchema } from '../schemas'
|
||||
import {
|
||||
accountPrimaryId,
|
||||
authProviderTypeEnum,
|
||||
createSelectSchema,
|
||||
timestamps,
|
||||
userId
|
||||
} from './common'
|
||||
import { users } from './user'
|
||||
|
||||
export const accounts = pgTable(
|
||||
'accounts',
|
||||
{
|
||||
...accountPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
userId: userId()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
provider: authProviderTypeEnum().notNull(),
|
||||
|
||||
/** Provider-specific account ID (or email in the case of `password` provider) */
|
||||
accountId: text().notNull(),
|
||||
|
||||
password: text(),
|
||||
|
||||
/** Provider-specific username */
|
||||
accountUsername: text(),
|
||||
|
||||
/** Standard OAuth2 access token */
|
||||
accessToken: text(),
|
||||
|
||||
/** Standard OAuth2 refresh token */
|
||||
refreshToken: text(),
|
||||
|
||||
/** Standard OAuth2 access token expires at */
|
||||
accessTokenExpiresAt: timestamp(),
|
||||
|
||||
/** Standard OAuth2 refresh token expires at */
|
||||
refreshTokenExpiresAt: timestamp(),
|
||||
|
||||
/** OAuth scope(s) */
|
||||
scope: text()
|
||||
},
|
||||
(table) => [
|
||||
index('account_provider_idx').on(table.provider),
|
||||
index('account_userId_idx').on(table.userId),
|
||||
index('account_createdAt_idx').on(table.createdAt),
|
||||
index('account_updatedAt_idx').on(table.updatedAt),
|
||||
index('account_deletedAt_idx').on(table.deletedAt)
|
||||
]
|
||||
)
|
||||
|
||||
export const accountsRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
references: [users.id]
|
||||
})
|
||||
}))
|
||||
|
||||
export const accountSelectSchema = createSelectSchema(accounts, {
|
||||
userId: userIdSchema
|
||||
})
|
||||
.omit({
|
||||
password: true,
|
||||
accessToken: true,
|
||||
refreshToken: true,
|
||||
accessTokenExpiresAt: true,
|
||||
refreshTokenExpiresAt: true
|
||||
})
|
||||
.strip()
|
||||
.openapi('Account')
|
|
@ -0,0 +1,18 @@
|
|||
import { jsonb, pgTable, text, timestamp } from '@fisch0920/drizzle-orm/pg-core'
|
||||
|
||||
import { timestamps } from './common'
|
||||
|
||||
// Simple key-value store of JSON data for OpenAuth-related state.
|
||||
// TODO: remove this and/or replace this with non-openauth version
|
||||
export const authData = pgTable('auth_data', {
|
||||
// Example ID keys:
|
||||
// "oauth:refresh\u001fuser:f99d3004946f9abb\u001f2cae301e-3fdc-40c4-8cda-83b25a616d06"
|
||||
// "signing:key\u001ff001a516-838d-4c88-aa9e-719d8fc9d5a3"
|
||||
// "email\u001ft@t.com\u001fpassword"
|
||||
// "encryption:key\u001f14d3c324-f9c7-4867-81a9-b0b77b0db0be"
|
||||
id: text().primaryKey(),
|
||||
...timestamps,
|
||||
|
||||
value: jsonb().$type<Record<string, any>>().notNull(),
|
||||
expiry: timestamp()
|
||||
})
|
|
@ -0,0 +1,20 @@
|
|||
import { expect, test } from 'vitest'
|
||||
|
||||
import { getIdSchemaForModelType } from '../schemas'
|
||||
import {
|
||||
createIdForModel,
|
||||
idMaxLength,
|
||||
idPrefixMap,
|
||||
type ModelType
|
||||
} from './common'
|
||||
|
||||
for (const modelType of Object.keys(idPrefixMap)) {
|
||||
test(`${modelType} id`, () => {
|
||||
for (let i = 0; i < 100; ++i) {
|
||||
const id = createIdForModel(modelType as ModelType)
|
||||
expect(id.startsWith(idPrefixMap[modelType as ModelType])).toBe(true)
|
||||
expect(id.length).toBeLessThanOrEqual(idMaxLength)
|
||||
expect(getIdSchemaForModelType(modelType as ModelType).parse(id)).toBe(id)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -0,0 +1,202 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
import { type Equal, sql, type Writable } from '@fisch0920/drizzle-orm'
|
||||
import {
|
||||
pgEnum,
|
||||
type PgTimestampBuilderInitial,
|
||||
type PgTimestampConfig,
|
||||
type PgTimestampStringBuilderInitial,
|
||||
type PgVarcharBuilderInitial,
|
||||
type PgVarcharConfig,
|
||||
timestamp as timestampImpl,
|
||||
varchar
|
||||
} from '@fisch0920/drizzle-orm/pg-core'
|
||||
import { createSchemaFactory } from '@fisch0920/drizzle-zod'
|
||||
import { z } from '@hono/zod-openapi'
|
||||
import { createId as createCuid2 } from '@paralleldrive/cuid2'
|
||||
|
||||
export const namespaceMaxLength = 256 as const
|
||||
export const projectSlugMaxLength = 256 as const
|
||||
export const projectNameMaxLength = 1024 as const
|
||||
|
||||
// prefix is max 5 characters
|
||||
// separator is 1 character
|
||||
// cuid2 is max 24 characters
|
||||
// so use 32 characters to be safe for storing ids
|
||||
export const idMaxLength = 32 as const
|
||||
|
||||
export const idPrefixMap = {
|
||||
team: 'team',
|
||||
project: 'proj',
|
||||
deployment: 'depl',
|
||||
consumer: 'csmr',
|
||||
logEntry: 'log',
|
||||
|
||||
// auth
|
||||
user: 'user',
|
||||
account: 'acct'
|
||||
} as const
|
||||
|
||||
export type ModelType = keyof typeof idPrefixMap
|
||||
|
||||
export function createIdForModel(modelType: ModelType): string {
|
||||
const prefix = idPrefixMap[modelType]
|
||||
assert(prefix, 500, `Invalid model type: ${modelType}`)
|
||||
|
||||
return `${prefix}_${createCuid2()}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the primary `id` key to use for a given model type.
|
||||
*/
|
||||
function getPrimaryId(modelType: ModelType) {
|
||||
return {
|
||||
id: id()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => createIdForModel(modelType))
|
||||
}
|
||||
}
|
||||
|
||||
export const projectPrimaryId = getPrimaryId('project')
|
||||
export const deploymentPrimaryId = getPrimaryId('deployment')
|
||||
export const consumerPrimaryId = getPrimaryId('consumer')
|
||||
export const logEntryPrimaryId = getPrimaryId('logEntry')
|
||||
export const teamPrimaryId = getPrimaryId('team')
|
||||
export const userPrimaryId = getPrimaryId('user')
|
||||
export const accountPrimaryId = getPrimaryId('account')
|
||||
|
||||
/**
|
||||
* All of our model primary ids have the following format:
|
||||
*
|
||||
* `${modelPrefix}_${cuid2}`
|
||||
*/
|
||||
export function id<U extends string, T extends Readonly<[U, ...U[]]>>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, typeof idMaxLength> {
|
||||
return varchar({ length: idMaxLength, ...config })
|
||||
}
|
||||
|
||||
export const projectId = id
|
||||
export const deploymentId = id
|
||||
export const consumerId = id
|
||||
export const logEntryId = id
|
||||
export const teamId = id
|
||||
export const userId = id
|
||||
|
||||
export function stripeId<U extends string, T extends Readonly<[U, ...U[]]>>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, 255> {
|
||||
return varchar({ length: 255, ...config })
|
||||
}
|
||||
|
||||
/**
|
||||
* `namespace/project-slug`
|
||||
*/
|
||||
export function projectIdentifier<
|
||||
U extends string,
|
||||
T extends Readonly<[U, ...U[]]>
|
||||
>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, 514> {
|
||||
return varchar({ length: 514, ...config })
|
||||
}
|
||||
|
||||
/**
|
||||
* `namespace/project-slug@hash`
|
||||
*/
|
||||
export function deploymentIdentifier<
|
||||
U extends string,
|
||||
T extends Readonly<[U, ...U[]]>
|
||||
>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, 530> {
|
||||
return varchar({ length: 530, ...config })
|
||||
}
|
||||
|
||||
export function username<U extends string, T extends Readonly<[U, ...U[]]>>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, typeof namespaceMaxLength> {
|
||||
return varchar({ length: namespaceMaxLength, ...config })
|
||||
}
|
||||
|
||||
export function teamSlug<U extends string, T extends Readonly<[U, ...U[]]>>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, typeof namespaceMaxLength> {
|
||||
return varchar({ length: namespaceMaxLength, ...config })
|
||||
}
|
||||
|
||||
export function projectNamespace<
|
||||
U extends string,
|
||||
T extends Readonly<[U, ...U[]]>
|
||||
>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, typeof namespaceMaxLength> {
|
||||
return varchar({ length: namespaceMaxLength, ...config })
|
||||
}
|
||||
|
||||
export function projectSlug<U extends string, T extends Readonly<[U, ...U[]]>>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, typeof projectSlugMaxLength> {
|
||||
return varchar({ length: projectSlugMaxLength, ...config })
|
||||
}
|
||||
|
||||
export function projectName<U extends string, T extends Readonly<[U, ...U[]]>>(
|
||||
config?: PgVarcharConfig<T | Writable<T>, never>
|
||||
): PgVarcharBuilderInitial<'', Writable<T>, typeof projectNameMaxLength> {
|
||||
return varchar({ length: projectNameMaxLength, ...config })
|
||||
}
|
||||
|
||||
/**
|
||||
* Timestamp with mode `string`
|
||||
*/
|
||||
export function timestamp<
|
||||
TMode extends PgTimestampConfig['mode'] & {} = 'string'
|
||||
>(
|
||||
config?: PgTimestampConfig<TMode>
|
||||
): Equal<TMode, 'string'> extends true
|
||||
? PgTimestampStringBuilderInitial<''>
|
||||
: PgTimestampBuilderInitial<''> {
|
||||
return timestampImpl<TMode>({
|
||||
mode: 'string' as unknown as TMode,
|
||||
withTimezone: true,
|
||||
...config
|
||||
})
|
||||
}
|
||||
|
||||
export const timestamps = {
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp()
|
||||
.notNull()
|
||||
.default(sql`now()`),
|
||||
deletedAt: timestamp()
|
||||
}
|
||||
|
||||
export const userRoleEnum = pgEnum('UserRole', ['user', 'admin'])
|
||||
export const teamMemberRoleEnum = pgEnum('TeamMemberRole', ['user', 'admin'])
|
||||
export const logEntryTypeEnum = pgEnum('LogEntryType', ['log'])
|
||||
export const logEntryLevelEnum = pgEnum('LogEntryLevel', [
|
||||
'trace',
|
||||
'debug',
|
||||
'info',
|
||||
'warn',
|
||||
'error'
|
||||
])
|
||||
export const pricingIntervalEnum = pgEnum('PricingInterval', [
|
||||
'day',
|
||||
'week',
|
||||
'month',
|
||||
'year'
|
||||
])
|
||||
export const pricingCurrencyEnum = pgEnum('PricingCurrency', ['usd'])
|
||||
export const authProviderTypeEnum = pgEnum('AuthProviderType', [
|
||||
'github',
|
||||
'password'
|
||||
])
|
||||
|
||||
export const { createInsertSchema, createSelectSchema, createUpdateSchema } =
|
||||
createSchemaFactory({
|
||||
zodInstance: z,
|
||||
coerce: {
|
||||
// Coerce dates / strings to timetamps
|
||||
date: true
|
||||
}
|
||||
})
|
|
@ -0,0 +1,253 @@
|
|||
import {
|
||||
type StripeSubscriptionItemIdMap,
|
||||
stripeSubscriptionItemIdMapSchema
|
||||
} from '@agentic/platform-types'
|
||||
import { relations } from '@fisch0920/drizzle-orm'
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text
|
||||
} from '@fisch0920/drizzle-orm/pg-core'
|
||||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
import {
|
||||
consumerIdSchema,
|
||||
deploymentIdSchema,
|
||||
projectIdSchema,
|
||||
userIdSchema
|
||||
} from '../schemas'
|
||||
import {
|
||||
consumerPrimaryId,
|
||||
createInsertSchema,
|
||||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
deploymentId,
|
||||
projectId,
|
||||
stripeId,
|
||||
timestamps,
|
||||
userId
|
||||
} from './common'
|
||||
import { deployments, deploymentSelectSchema } from './deployment'
|
||||
import { projects, projectSelectSchema } from './project'
|
||||
import { users, userSelectSchema } from './user'
|
||||
|
||||
// TODO: Consumers should be valid for any enabled project like in RapidAPI and GCP.
|
||||
// This may require a separate model to aggregate User Applications.
|
||||
// https://docs.rapidapi.com/docs/keys#section-different-api-keys-per-application
|
||||
|
||||
/**
|
||||
* A `Consumer` represents a user who has subscribed to a `Project` and is used
|
||||
* to track usage and billing.
|
||||
*
|
||||
* Consumers are linked to a corresponding Stripe Customer and Subscription.
|
||||
* The Stripe customer will either be the user's default Stripe Customer if the
|
||||
* project uses the default Agentic platform account, or a customer on the project
|
||||
* owner's connected Stripe account if the project has Stripe Connect enabled.
|
||||
*/
|
||||
export const consumers = pgTable(
|
||||
'consumers',
|
||||
{
|
||||
...consumerPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
// API key for this consumer
|
||||
// (called "token" for backwards compatibility)
|
||||
token: text().notNull(),
|
||||
|
||||
// The slug of the PricingPlan in the target deployment that this consumer
|
||||
// is subscribed to.
|
||||
plan: text(),
|
||||
|
||||
// Whether the consumer has made at least one successful API call after
|
||||
// initializing their subscription.
|
||||
activated: boolean().default(false).notNull(),
|
||||
|
||||
// TODO: Re-add coupon support
|
||||
// coupon: text(),
|
||||
|
||||
// only used during initial creation
|
||||
source: text(),
|
||||
|
||||
userId: userId()
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
|
||||
// The project this user is subscribed to
|
||||
projectId: projectId()
|
||||
.notNull()
|
||||
.references(() => projects.id, {
|
||||
onDelete: 'cascade'
|
||||
}),
|
||||
|
||||
// The specific deployment this user is subscribed to, since pricing can
|
||||
// change across deployment versions)
|
||||
deploymentId: deploymentId()
|
||||
.notNull()
|
||||
.references(() => deployments.id, {
|
||||
onDelete: 'cascade'
|
||||
}),
|
||||
|
||||
// Stripe subscription status (synced via webhooks). Should move from
|
||||
// `incomplete` to `active` after the first successful payment.
|
||||
stripeStatus: text().default('incomplete').notNull(),
|
||||
|
||||
// Whether the consumer's subscription is currently active, depending on
|
||||
// `stripeStatus`.
|
||||
isStripeSubscriptionActive: boolean().default(true).notNull(),
|
||||
|
||||
// Main Stripe Subscription id
|
||||
_stripeSubscriptionId: stripeId(),
|
||||
|
||||
// [pricingPlanLineItemSlug: string]: string
|
||||
_stripeSubscriptionItemIdMap: jsonb()
|
||||
.$type<StripeSubscriptionItemIdMap>()
|
||||
.default({})
|
||||
.notNull(),
|
||||
|
||||
// Denormalized from User or possibly separate for stripe connect
|
||||
// TODO: is this necessary?
|
||||
_stripeCustomerId: stripeId().notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('consumer_token_idx').on(table.token),
|
||||
index('consumer_userId_idx').on(table.userId),
|
||||
index('consumer_projectId_idx').on(table.projectId),
|
||||
index('consumer_deploymentId_idx').on(table.deploymentId),
|
||||
index('consumer_isStripeSubscriptionActive_idx').on(
|
||||
table.isStripeSubscriptionActive
|
||||
),
|
||||
index('consumer_createdAt_idx').on(table.createdAt),
|
||||
index('consumer_updatedAt_idx').on(table.updatedAt),
|
||||
index('consumer_deletedAt_idx').on(table.deletedAt)
|
||||
]
|
||||
)
|
||||
|
||||
export const consumersRelations = relations(consumers, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [consumers.userId],
|
||||
references: [users.id]
|
||||
}),
|
||||
project: one(projects, {
|
||||
fields: [consumers.projectId],
|
||||
references: [projects.id]
|
||||
}),
|
||||
deployment: one(deployments, {
|
||||
fields: [consumers.deploymentId],
|
||||
references: [deployments.id]
|
||||
})
|
||||
}))
|
||||
|
||||
export const consumerSelectBaseSchema = createSelectSchema(consumers, {
|
||||
id: consumerIdSchema,
|
||||
userId: userIdSchema,
|
||||
projectId: projectIdSchema,
|
||||
deploymentId: deploymentIdSchema,
|
||||
|
||||
_stripeSubscriptionItemIdMap: stripeSubscriptionItemIdMapSchema
|
||||
})
|
||||
.omit({
|
||||
_stripeSubscriptionId: true,
|
||||
_stripeSubscriptionItemIdMap: true,
|
||||
_stripeCustomerId: true
|
||||
})
|
||||
.extend({
|
||||
user: z
|
||||
.lazy(() => userSelectSchema)
|
||||
.optional()
|
||||
.openapi('User', { type: 'object' }),
|
||||
|
||||
project: z
|
||||
.lazy(() => projectSelectSchema)
|
||||
.optional()
|
||||
.openapi('Project', { type: 'object' }),
|
||||
|
||||
// deployment: z
|
||||
// .lazy(() => deploymentSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('Deployment', { type: 'object' })
|
||||
|
||||
// TODO: Improve the self-referential typing here that `@hono/zod-openapi`
|
||||
// trips up on.
|
||||
deployment: z
|
||||
.any()
|
||||
.refine(
|
||||
(deployment): boolean =>
|
||||
!deployment || deploymentSelectSchema.safeParse(deployment).success,
|
||||
{
|
||||
message: 'Invalid lastDeployment'
|
||||
}
|
||||
)
|
||||
.transform((deployment): any => {
|
||||
if (!deployment) return undefined
|
||||
return deploymentSelectSchema.parse(deployment)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
// These are all derived virtual URLs that are not stored in the database
|
||||
export const derivedConsumerFields = {
|
||||
/**
|
||||
* A private admin URL for managing the customer's subscription. This URL
|
||||
* is only accessible by the customer.
|
||||
*
|
||||
* @example https://agentic.so/app/consumers/cons_123
|
||||
*/
|
||||
adminUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe(
|
||||
"A private admin URL for managing the customer's subscription. This URL is only accessible by the customer."
|
||||
)
|
||||
} as const
|
||||
|
||||
export const consumerSelectSchema = consumerSelectBaseSchema
|
||||
.transform((consumer) => ({
|
||||
...consumer,
|
||||
adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}`
|
||||
}))
|
||||
.pipe(consumerSelectBaseSchema.extend(derivedConsumerFields).strip())
|
||||
.describe(
|
||||
`A Consumer represents a user who has subscribed to a Project and is used
|
||||
to track usage and billing.
|
||||
|
||||
Consumers are linked to a corresponding Stripe Customer and Subscription.
|
||||
The Stripe customer will either be the user's default Stripe Customer if the
|
||||
project uses the default Agentic platform account, or a customer on the project
|
||||
owner's connected Stripe account if the project has Stripe Connect enabled.`
|
||||
)
|
||||
.openapi('Consumer')
|
||||
|
||||
export const consumerAdminSelectSchema = consumerSelectBaseSchema
|
||||
.extend({
|
||||
_stripeCustomerId: z.string().nonempty()
|
||||
})
|
||||
.transform((consumer) => ({
|
||||
...consumer,
|
||||
adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}`
|
||||
}))
|
||||
.openapi('AdminConsumer')
|
||||
|
||||
export const consumerInsertSchema = createInsertSchema(consumers, {
|
||||
deploymentId: deploymentIdSchema.optional(),
|
||||
|
||||
plan: z.string().nonempty()
|
||||
})
|
||||
.pick({
|
||||
plan: true,
|
||||
source: true,
|
||||
deploymentId: true
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const consumerUpdateSchema = createUpdateSchema(consumers, {
|
||||
deploymentId: deploymentIdSchema.optional()
|
||||
})
|
||||
.pick({
|
||||
plan: true,
|
||||
deploymentId: true
|
||||
})
|
||||
.strict()
|
|
@ -0,0 +1,337 @@
|
|||
import {
|
||||
agenticProjectConfigSchema,
|
||||
defaultRequestsRateLimit,
|
||||
type OriginAdapter,
|
||||
type PricingPlanList,
|
||||
type RateLimit,
|
||||
resolvedAgenticProjectConfigSchema,
|
||||
type Tool,
|
||||
type ToolConfig
|
||||
} from '@agentic/platform-types'
|
||||
import {
|
||||
isValidDeploymentHash,
|
||||
parseDeploymentIdentifier
|
||||
} from '@agentic/platform-validators'
|
||||
import { relations } from '@fisch0920/drizzle-orm'
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from '@fisch0920/drizzle-orm/pg-core'
|
||||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
import {
|
||||
deploymentIdentifierSchema,
|
||||
deploymentIdSchema,
|
||||
projectIdSchema,
|
||||
teamIdSchema,
|
||||
userIdSchema
|
||||
} from '../schemas'
|
||||
import {
|
||||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
deploymentIdentifier,
|
||||
deploymentPrimaryId,
|
||||
pricingIntervalEnum,
|
||||
projectId,
|
||||
projectName,
|
||||
teamId,
|
||||
timestamps,
|
||||
userId
|
||||
} from './common'
|
||||
import { projects, projectSelectSchema } from './project'
|
||||
import { teams } from './team'
|
||||
import { users } from './user'
|
||||
|
||||
/**
|
||||
* A Deployment is a single, immutable instance of a Project. Each deployment
|
||||
* contains pricing plans, origin server config (OpenAPI or MCP server), tool
|
||||
* definitions, and metadata.
|
||||
*
|
||||
* Deployments are private to a developer or team until they are published, at
|
||||
* which point they are accessible to any customers with access to the parent
|
||||
* Project.
|
||||
*/
|
||||
export const deployments = pgTable(
|
||||
'deployments',
|
||||
{
|
||||
...deploymentPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
identifier: deploymentIdentifier().unique().notNull(),
|
||||
hash: text().notNull(),
|
||||
version: text(),
|
||||
|
||||
published: boolean().default(false).notNull(),
|
||||
|
||||
// display name
|
||||
name: projectName().notNull(),
|
||||
|
||||
description: text().default('').notNull(),
|
||||
readme: text(), // URL to uploaded markdown document
|
||||
iconUrl: text(),
|
||||
sourceUrl: text(),
|
||||
homepageUrl: text(),
|
||||
|
||||
userId: userId()
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
teamId: teamId().references(() => teams.id),
|
||||
projectId: projectId()
|
||||
.notNull()
|
||||
.references(() => projects.id, {
|
||||
onDelete: 'cascade'
|
||||
}),
|
||||
|
||||
// Tool definitions exposed by the origin server
|
||||
tools: jsonb().$type<Tool[]>().notNull(),
|
||||
|
||||
// Tool configs customize the behavior of tools for different pricing plans
|
||||
toolConfigs: jsonb().$type<ToolConfig[]>().default([]).notNull(),
|
||||
|
||||
// Origin API adapter config (url, openapi/mcp/raw, internal/external hosting, etc)
|
||||
origin: jsonb().$type<OriginAdapter>().notNull(),
|
||||
|
||||
// Array<PricingPlan>
|
||||
pricingPlans: jsonb().$type<PricingPlanList>().notNull(),
|
||||
|
||||
// Which pricing intervals are supported for subscriptions to this project
|
||||
pricingIntervals: pricingIntervalEnum()
|
||||
.array()
|
||||
.default(['month'])
|
||||
.notNull(),
|
||||
|
||||
// Default rate limit across all pricing plans
|
||||
defaultRateLimit: jsonb()
|
||||
.$type<RateLimit>()
|
||||
.notNull()
|
||||
.default(defaultRequestsRateLimit)
|
||||
|
||||
// TODO: metadata config (logo, keywords, examples, etc)
|
||||
// TODO: webhooks
|
||||
// TODO: coupons
|
||||
// TODO: third-party auth provider config
|
||||
// NOTE: will need consumer.authProviders as well as user.authProviders for
|
||||
// this because custom oauth credentials that are deployment-specific. will
|
||||
// prolly also need to hash the individual AuthProviders in
|
||||
// deployment.authProviders to compare across deployments.
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('deployment_identifier_idx').on(table.identifier),
|
||||
index('deployment_userId_idx').on(table.userId),
|
||||
index('deployment_teamId_idx').on(table.teamId),
|
||||
index('deployment_projectId_idx').on(table.projectId),
|
||||
index('deployment_published_idx').on(table.published),
|
||||
index('deployment_version_idx').on(table.version),
|
||||
index('deployment_createdAt_idx').on(table.createdAt),
|
||||
index('deployment_updatedAt_idx').on(table.updatedAt),
|
||||
index('deployment_deletedAt_idx').on(table.deletedAt)
|
||||
]
|
||||
)
|
||||
|
||||
export const deploymentsRelations = relations(deployments, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [deployments.userId],
|
||||
references: [users.id]
|
||||
}),
|
||||
team: one(teams, {
|
||||
fields: [deployments.teamId],
|
||||
references: [teams.id]
|
||||
}),
|
||||
project: one(projects, {
|
||||
fields: [deployments.projectId],
|
||||
references: [projects.id]
|
||||
})
|
||||
}))
|
||||
|
||||
// TODO: virtual hasFreeTier
|
||||
// TODO: virtual url
|
||||
// TODO: virtual openApiUrl
|
||||
// TODO: virtual saasUrl
|
||||
// TODO: virtual authProviders?
|
||||
// TODO: virtual openapi spec? (hide openapi.servers)
|
||||
|
||||
export const deploymentSelectBaseSchema = createSelectSchema(deployments, {
|
||||
id: deploymentIdSchema,
|
||||
userId: userIdSchema,
|
||||
teamId: teamIdSchema.optional(),
|
||||
projectId: projectIdSchema,
|
||||
identifier: deploymentIdentifierSchema,
|
||||
|
||||
hash: (schema) =>
|
||||
schema.refine((hash) => isValidDeploymentHash(hash), {
|
||||
message: 'Invalid deployment hash'
|
||||
}),
|
||||
|
||||
name: resolvedAgenticProjectConfigSchema.shape.name,
|
||||
version: resolvedAgenticProjectConfigSchema.shape.version,
|
||||
description: resolvedAgenticProjectConfigSchema.shape.description,
|
||||
readme: resolvedAgenticProjectConfigSchema.shape.readme,
|
||||
iconUrl: resolvedAgenticProjectConfigSchema.shape.iconUrl,
|
||||
sourceUrl: resolvedAgenticProjectConfigSchema.shape.sourceUrl,
|
||||
homepageUrl: resolvedAgenticProjectConfigSchema.shape.homepageUrl,
|
||||
origin: resolvedAgenticProjectConfigSchema.shape.origin,
|
||||
pricingPlans: resolvedAgenticProjectConfigSchema.shape.pricingPlans,
|
||||
pricingIntervals: resolvedAgenticProjectConfigSchema.shape.pricingIntervals,
|
||||
tools: resolvedAgenticProjectConfigSchema.shape.tools,
|
||||
toolConfigs: resolvedAgenticProjectConfigSchema.shape.toolConfigs,
|
||||
defaultRateLimit: resolvedAgenticProjectConfigSchema.shape.defaultRateLimit
|
||||
})
|
||||
.omit({
|
||||
origin: true
|
||||
})
|
||||
.extend({
|
||||
// user: z
|
||||
// .lazy(() => userSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('User', { type: 'object' }),
|
||||
// team: z
|
||||
// .lazy(() => teamSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('Team', { type: 'object' }),
|
||||
// project: z
|
||||
// .lazy(() => projectSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('Project', { type: 'object' })
|
||||
|
||||
// TODO: Improve the self-referential typing here that `@hono/zod-openapi`
|
||||
// trips up on.
|
||||
project: z
|
||||
.any()
|
||||
.refine(
|
||||
(project): boolean =>
|
||||
!project || projectSelectSchema.safeParse(project).success,
|
||||
{
|
||||
message: 'Invalid lastDeployment'
|
||||
}
|
||||
)
|
||||
.transform((project): any => {
|
||||
if (!project) return undefined
|
||||
return projectSelectSchema.parse(project)
|
||||
})
|
||||
.optional()
|
||||
// .openapi('Project', { type: 'object' })
|
||||
|
||||
// TODO: Circular references make this schema less than ideal
|
||||
// project: z.object({}).optional().openapi('Project', { type: 'object' })
|
||||
})
|
||||
|
||||
// These are all derived virtual URLs that are not stored in the database
|
||||
export const derivedDeploymentFields = {
|
||||
/**
|
||||
* The public base HTTP URL for the deployment supporting HTTP POST requests
|
||||
* for individual tools at `/tool-name` subpaths.
|
||||
*
|
||||
* @example https://gateway.agentic.so/@agentic/search@latest
|
||||
*/
|
||||
gatewayBaseUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe(
|
||||
'The public base HTTP URL for the deployment supporting HTTP POST requests for individual tools at `/tool-name` subpaths.'
|
||||
),
|
||||
|
||||
/**
|
||||
* The public MCP URL for the deployment supporting the Streamable HTTP
|
||||
* transport.
|
||||
*
|
||||
* @example https://gateway.agentic.so/@agentic/search@latest/mcp
|
||||
*/
|
||||
gatewayMcpUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe(
|
||||
'The public MCP URL for the deployment supporting the Streamable HTTP transport.'
|
||||
),
|
||||
|
||||
/**
|
||||
* The public marketplace URL for the deployment's project.
|
||||
*
|
||||
* Note that only published deployments are visible on the marketplace.
|
||||
*
|
||||
* @example https://agentic.so/marketplace/projects/@agentic/search
|
||||
*/
|
||||
marketplaceUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe("The public marketplace URL for the deployment's project."),
|
||||
|
||||
/**
|
||||
* A private admin URL for managing the deployment. This URL is only accessible
|
||||
* by project owners.
|
||||
*
|
||||
* @example https://agentic.so/app/projects/@agentic/search/deployments/123
|
||||
*/
|
||||
adminUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe(
|
||||
'A private admin URL for managing the deployment. This URL is only accessible by project owners.'
|
||||
)
|
||||
} as const
|
||||
|
||||
export const deploymentSelectSchema = deploymentSelectBaseSchema
|
||||
.transform((deployment) => {
|
||||
const { projectIdentifier, deploymentIdentifier } =
|
||||
parseDeploymentIdentifier(deployment.identifier)
|
||||
|
||||
return {
|
||||
...deployment,
|
||||
gatewayBaseUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${deploymentIdentifier}`,
|
||||
gatewayMcpUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${deploymentIdentifier}/mcp`,
|
||||
marketplaceUrl: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${projectIdentifier}`,
|
||||
adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/projects/${projectIdentifier}/deployments/${deployment.hash}`
|
||||
}
|
||||
})
|
||||
.pipe(deploymentSelectBaseSchema.extend(derivedDeploymentFields).strip())
|
||||
.describe(
|
||||
`A Deployment is a single, immutable instance of a Project. Each deployment contains pricing plans, origin server config (OpenAPI or MCP server), tool definitions, and metadata.
|
||||
|
||||
Deployments are private to a developer or team until they are published, at which point they are accessible to any customers with access to the parent Project.`
|
||||
)
|
||||
.openapi('Deployment')
|
||||
|
||||
export const deploymentAdminSelectSchema = deploymentSelectBaseSchema
|
||||
.extend({
|
||||
origin: resolvedAgenticProjectConfigSchema.shape.origin,
|
||||
_secret: z.string().nonempty()
|
||||
})
|
||||
.transform((deployment) => {
|
||||
const { projectIdentifier, deploymentIdentifier } =
|
||||
parseDeploymentIdentifier(deployment.identifier)
|
||||
|
||||
return {
|
||||
...deployment,
|
||||
gatewayBaseUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${deploymentIdentifier}`,
|
||||
gatewayMcpUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${deploymentIdentifier}/mcp`,
|
||||
marketplaceUrl: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${projectIdentifier}`,
|
||||
adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/projects/${projectIdentifier}/deployments/${deployment.hash}`
|
||||
}
|
||||
})
|
||||
.openapi('AdminDeployment')
|
||||
|
||||
export const deploymentInsertSchema = agenticProjectConfigSchema.strict()
|
||||
|
||||
// TODO: Deployments should be immutable, so we should not allow updates aside
|
||||
// from publishing. But editing a project's description should be possible from
|
||||
// the admin UI, so maybe we allow only updates to some properties? Or we
|
||||
// denormalize these fields in `project`?
|
||||
export const deploymentUpdateSchema = createUpdateSchema(deployments)
|
||||
.pick({
|
||||
deletedAt: true,
|
||||
description: true
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const deploymentPublishSchema = createUpdateSchema(deployments, {
|
||||
version: z.string().nonempty()
|
||||
})
|
||||
.pick({
|
||||
version: true
|
||||
})
|
||||
.strict()
|
|
@ -0,0 +1,10 @@
|
|||
export * from './account'
|
||||
export * from './auth-data'
|
||||
export * from './common'
|
||||
export * from './consumer'
|
||||
export * from './deployment'
|
||||
export * from './log-entry'
|
||||
export * from './project'
|
||||
export * from './team'
|
||||
export * from './team-member'
|
||||
export * from './user'
|
|
@ -0,0 +1,127 @@
|
|||
import { relations } from '@fisch0920/drizzle-orm'
|
||||
import {
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
varchar
|
||||
} from '@fisch0920/drizzle-orm/pg-core'
|
||||
|
||||
import {
|
||||
consumerIdSchema,
|
||||
deploymentIdSchema,
|
||||
projectIdSchema,
|
||||
userIdSchema
|
||||
} from '../schemas'
|
||||
import {
|
||||
consumerId,
|
||||
createSelectSchema,
|
||||
deploymentId,
|
||||
logEntryLevelEnum,
|
||||
logEntryPrimaryId,
|
||||
logEntryTypeEnum,
|
||||
projectId,
|
||||
timestamps,
|
||||
userId
|
||||
} from './common'
|
||||
import { consumers } from './consumer'
|
||||
import { deployments } from './deployment'
|
||||
import { projects } from './project'
|
||||
import { users } from './user'
|
||||
|
||||
/**
|
||||
* A `LogEntry` is an internal audit log entry.
|
||||
*/
|
||||
export const logEntries = pgTable(
|
||||
'log_entries',
|
||||
{
|
||||
...logEntryPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
// core data (required)
|
||||
type: logEntryTypeEnum().notNull().default('log'),
|
||||
level: logEntryLevelEnum().notNull().default('info'),
|
||||
message: text().notNull(),
|
||||
|
||||
// context info (required)
|
||||
environment: text(),
|
||||
service: text(),
|
||||
requestId: varchar({ length: 512 }),
|
||||
traceId: varchar({ length: 512 }),
|
||||
|
||||
// relations (optional)
|
||||
userId: userId(),
|
||||
projectId: projectId(),
|
||||
deploymentId: deploymentId(),
|
||||
consumerId: consumerId(),
|
||||
|
||||
// misc metadata (optional)
|
||||
metadata: jsonb().$type<Record<string, unknown>>().default({}).notNull()
|
||||
},
|
||||
(table) => [
|
||||
index('log_entry_type_idx').on(table.type),
|
||||
// TODO: Don't add these extra indices until we need them. They'll become
|
||||
// very large very fast.
|
||||
// index('log_entry_level_idx').on(table.level),
|
||||
// index('log_entry_environment_idx').on(table.environment),
|
||||
// index('log_entry_service_idx').on(table.service),
|
||||
// index('log_entry_requestId_idx').on(table.requestId),
|
||||
// index('log_entry_traceId_idx').on(table.traceId),
|
||||
index('log_entry_userId_idx').on(table.userId),
|
||||
index('log_entry_projectId_idx').on(table.projectId),
|
||||
index('log_entry_deploymentId_idx').on(table.deploymentId),
|
||||
// index('log_entry_consumerId_idx').on(table.consumerId),
|
||||
index('log_entry_createdAt_idx').on(table.createdAt),
|
||||
index('log_entry_updatedAt_idx').on(table.updatedAt),
|
||||
index('log_entry_deletedAt_idx').on(table.deletedAt)
|
||||
]
|
||||
)
|
||||
|
||||
export const logEntriesRelations = relations(logEntries, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [logEntries.userId],
|
||||
references: [users.id]
|
||||
}),
|
||||
project: one(projects, {
|
||||
fields: [logEntries.projectId],
|
||||
references: [projects.id]
|
||||
}),
|
||||
deployment: one(deployments, {
|
||||
fields: [logEntries.deploymentId],
|
||||
references: [deployments.id]
|
||||
}),
|
||||
consumer: one(consumers, {
|
||||
fields: [logEntries.consumerId],
|
||||
references: [consumers.id]
|
||||
})
|
||||
}))
|
||||
|
||||
export const logEntrySelectSchema = createSelectSchema(logEntries, {
|
||||
userId: userIdSchema.optional(),
|
||||
projectId: projectIdSchema.optional(),
|
||||
deploymentId: deploymentIdSchema.optional(),
|
||||
consumerId: consumerIdSchema.optional()
|
||||
})
|
||||
// .extend({
|
||||
// user: z
|
||||
// .lazy(() => userSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('User', { type: 'object' }),
|
||||
|
||||
// project: z
|
||||
// .lazy(() => projectSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('Project', { type: 'object' }),
|
||||
|
||||
// deployment: z
|
||||
// .lazy(() => deploymentSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('Deployment', { type: 'object' }),
|
||||
|
||||
// consumer: z
|
||||
// .lazy(() => consumerSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('Consumer', { type: 'object' })
|
||||
// })
|
||||
.strip()
|
||||
.openapi('LogEntry')
|
|
@ -0,0 +1,392 @@
|
|||
import {
|
||||
agenticProjectConfigSchema,
|
||||
pricingIntervalSchema,
|
||||
type StripeMeterIdMap,
|
||||
stripeMeterIdMapSchema,
|
||||
type StripePriceIdMap,
|
||||
stripePriceIdMapSchema,
|
||||
type StripeProductIdMap,
|
||||
stripeProductIdMapSchema
|
||||
} from '@agentic/platform-types'
|
||||
import { relations } from '@fisch0920/drizzle-orm'
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from '@fisch0920/drizzle-orm/pg-core'
|
||||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
import {
|
||||
deploymentIdSchema,
|
||||
projectIdentifierSchema,
|
||||
projectIdSchema,
|
||||
teamIdSchema,
|
||||
userIdSchema
|
||||
} from '../schemas'
|
||||
import {
|
||||
createInsertSchema,
|
||||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
deploymentId,
|
||||
pricingCurrencyEnum,
|
||||
pricingIntervalEnum,
|
||||
projectIdentifier,
|
||||
projectName,
|
||||
projectNamespace,
|
||||
projectPrimaryId,
|
||||
projectSlug,
|
||||
stripeId,
|
||||
teamId,
|
||||
timestamps,
|
||||
userId
|
||||
} from './common'
|
||||
import { deployments, deploymentSelectSchema } from './deployment'
|
||||
import { teams, teamSelectSchema } from './team'
|
||||
import { users, userSelectSchema } from './user'
|
||||
|
||||
/**
|
||||
* A Project represents a single Agentic API product. Is is comprised of a
|
||||
* series of immutable Deployments, each of which contains pricing data, origin
|
||||
* API config, OpenAPI or MCP specs, tool definitions, and various metadata.
|
||||
*
|
||||
* You can think of Agentic Projects as similar to Vercel projects. They both
|
||||
* hold some common configuration and are comprised of a series of immutable
|
||||
* Deployments.
|
||||
*
|
||||
* Internally, Projects manage all of the Stripe billing resources across
|
||||
* Deployments (Stripe Products, Prices, and Meters for usage-based billing).
|
||||
*/
|
||||
export const projects = pgTable(
|
||||
'projects',
|
||||
{
|
||||
...projectPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
// display name
|
||||
name: projectName().notNull(),
|
||||
|
||||
// identifier is `@namespace/slug`
|
||||
identifier: projectIdentifier().unique().notNull(),
|
||||
|
||||
// namespace is either a username or team slug
|
||||
namespace: projectNamespace().notNull(),
|
||||
|
||||
// slug is a unique identifier for the project within its namespace
|
||||
slug: projectSlug().notNull(),
|
||||
|
||||
// Defaulting to `true` for now to hide all projects from the marketplace
|
||||
// by default. Will need to manually set to `true` to allow projects to be
|
||||
// visible on the marketplace.
|
||||
private: boolean().default(true).notNull(),
|
||||
|
||||
// Admin-controlled tags for organizing and featuring on the marketplace
|
||||
tags: text().array(),
|
||||
|
||||
// TODO: allow for multiple aliases like vercel
|
||||
// alias: text(),
|
||||
|
||||
userId: userId()
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
teamId: teamId(),
|
||||
|
||||
// Most recently published Deployment if one exists
|
||||
lastPublishedDeploymentId: deploymentId(),
|
||||
|
||||
// Most recent Deployment if one exists
|
||||
lastDeploymentId: deploymentId(),
|
||||
|
||||
// Semver version of the most recently published Deployment (if one exists)
|
||||
// (denormalized for convenience)
|
||||
lastPublishedDeploymentVersion: text(),
|
||||
|
||||
applicationFeePercent: integer().default(20).notNull(),
|
||||
|
||||
// TODO: This is going to need to vary from dev to prod
|
||||
//isStripeConnectEnabled: boolean().default(false).notNull(),
|
||||
|
||||
// Default pricing interval for subscriptions to this project
|
||||
// Note: This is essentially hard-coded and not configurable by users for now.
|
||||
defaultPricingInterval: pricingIntervalEnum().default('month').notNull(),
|
||||
|
||||
// Pricing currency used across all prices and subscriptions to this project
|
||||
pricingCurrency: pricingCurrencyEnum().default('usd').notNull(),
|
||||
|
||||
// All deployments share the same underlying proxy secret, which allows
|
||||
// origin servers to verify that requests are coming from Agentic's API
|
||||
// gateway.
|
||||
_secret: text().notNull(),
|
||||
|
||||
// Auth token used to access the platform API on behalf of this project
|
||||
// _providerToken: text().notNull(),
|
||||
|
||||
// TODO: Full-text search
|
||||
// _text: text().default('').notNull(),
|
||||
|
||||
// Stripe coupons associated with this project, mapping from unique coupon
|
||||
// object hash to stripe coupon id.
|
||||
// `[hash: string]: string`
|
||||
// _stripeCouponsMap: jsonb()
|
||||
// .$type<Record<string, string>>()
|
||||
// .default({})
|
||||
// .notNull(),
|
||||
|
||||
// Stripe billing Products associated with this project across deployments,
|
||||
// mapping from PricingPlanLineItem **slug** to Stripe Product id.
|
||||
// NOTE: This map uses slugs as keys, unlike `_stripePriceIdMap`, because
|
||||
// Stripe Products are agnostic to the PricingPlanLineItem config. This is
|
||||
// important for them to be shared across deployments even if the pricing
|
||||
// details change.
|
||||
_stripeProductIdMap: jsonb()
|
||||
.$type<StripeProductIdMap>()
|
||||
.default({})
|
||||
.notNull(),
|
||||
|
||||
// Stripe billing Prices associated with this project, mapping from unique
|
||||
// PricingPlanLineItem **hash** to Stripe Price id.
|
||||
// NOTE: This map uses hashes as keys, because Stripe Prices are dependent
|
||||
// on the PricingPlanLineItem config. This is important for them to be shared
|
||||
// across deployments even if the pricing details change.
|
||||
_stripePriceIdMap: jsonb().$type<StripePriceIdMap>().default({}).notNull(),
|
||||
|
||||
// Stripe billing LineItems associated with this project, mapping from unique
|
||||
// PricingPlanLineItem **slug** to Stripe Meter id.
|
||||
// NOTE: This map uses slugs as keys, unlike `_stripePriceIdMap`, because
|
||||
// Stripe Products are agnostic to the PricingPlanLineItem config. This is
|
||||
// important for them to be shared across deployments even if the pricing
|
||||
// details change.
|
||||
_stripeMeterIdMap: jsonb().$type<StripeMeterIdMap>().default({}).notNull(),
|
||||
|
||||
// Connected Stripe account (standard or express).
|
||||
// If not defined, then subscriptions for this project route through our
|
||||
// main Stripe account.
|
||||
_stripeAccountId: stripeId()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('project_identifier_idx').on(table.identifier),
|
||||
index('project_namespace_idx').on(table.namespace),
|
||||
index('project_userId_idx').on(table.userId),
|
||||
index('project_teamId_idx').on(table.teamId),
|
||||
// index('project_alias_idx').on(table.alias),
|
||||
index('project_private_idx').on(table.private),
|
||||
index('project_tags_idx').on(table.tags),
|
||||
index('project_lastPublishedDeploymentId_idx').on(
|
||||
table.lastPublishedDeploymentId
|
||||
),
|
||||
index('project_createdAt_idx').on(table.createdAt),
|
||||
index('project_updatedAt_idx').on(table.updatedAt),
|
||||
index('project_deletedAt_idx').on(table.deletedAt)
|
||||
]
|
||||
)
|
||||
|
||||
export const projectsRelations = relations(projects, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [projects.userId],
|
||||
references: [users.id]
|
||||
}),
|
||||
team: one(teams, {
|
||||
fields: [projects.teamId],
|
||||
references: [teams.id]
|
||||
}),
|
||||
lastPublishedDeployment: one(deployments, {
|
||||
fields: [projects.lastPublishedDeploymentId],
|
||||
references: [deployments.id],
|
||||
relationName: 'lastPublishedDeployment'
|
||||
}),
|
||||
lastDeployment: one(deployments, {
|
||||
fields: [projects.lastDeploymentId],
|
||||
references: [deployments.id],
|
||||
relationName: 'lastDeployment'
|
||||
})
|
||||
// deployments: many(deployments, {
|
||||
// relationName: 'deployments'
|
||||
// }),
|
||||
// publishedDeployments: many(deployments, {
|
||||
// relationName: 'publishedDeployments'
|
||||
// })
|
||||
}))
|
||||
|
||||
export const projectSelectBaseSchema = createSelectSchema(projects, {
|
||||
id: projectIdSchema,
|
||||
userId: userIdSchema,
|
||||
teamId: teamIdSchema.optional(),
|
||||
identifier: projectIdentifierSchema,
|
||||
name: agenticProjectConfigSchema.shape.name,
|
||||
slug: agenticProjectConfigSchema.shape.slug,
|
||||
tags: z.array(z.string()).optional(),
|
||||
lastPublishedDeploymentId: deploymentIdSchema.optional(),
|
||||
lastDeploymentId: deploymentIdSchema.optional(),
|
||||
|
||||
applicationFeePercent: (schema) => schema.nonnegative(),
|
||||
|
||||
defaultPricingInterval: pricingIntervalSchema,
|
||||
|
||||
_stripeProductIdMap: stripeProductIdMapSchema,
|
||||
_stripePriceIdMap: stripePriceIdMapSchema,
|
||||
_stripeMeterIdMap: stripeMeterIdMapSchema
|
||||
})
|
||||
.omit({
|
||||
applicationFeePercent: true,
|
||||
_secret: true,
|
||||
// _text: true,
|
||||
_stripeProductIdMap: true,
|
||||
_stripePriceIdMap: true,
|
||||
_stripeMeterIdMap: true,
|
||||
_stripeAccountId: true
|
||||
})
|
||||
.extend({
|
||||
user: z
|
||||
.lazy(() => userSelectSchema)
|
||||
.optional()
|
||||
.openapi('User', { type: 'object' }),
|
||||
|
||||
team: z
|
||||
.lazy(() => teamSelectSchema)
|
||||
.optional()
|
||||
.openapi('Team', { type: 'object' }),
|
||||
|
||||
// TODO: Improve the self-referential typing here that `@hono/zod-openapi`
|
||||
// trips up on.
|
||||
lastPublishedDeployment: z
|
||||
.any()
|
||||
.refine(
|
||||
(deployment): boolean =>
|
||||
!deployment || deploymentSelectSchema.safeParse(deployment).success,
|
||||
{
|
||||
message: 'Invalid lastPublishedDeployment'
|
||||
}
|
||||
)
|
||||
.transform((deployment): any => {
|
||||
if (!deployment) return undefined
|
||||
|
||||
return deploymentSelectSchema.parse(deployment)
|
||||
})
|
||||
.optional(),
|
||||
|
||||
lastDeployment: z
|
||||
.any()
|
||||
.refine(
|
||||
(deployment): boolean =>
|
||||
!deployment || deploymentSelectSchema.safeParse(deployment).success,
|
||||
{
|
||||
message: 'Invalid lastDeployment'
|
||||
}
|
||||
)
|
||||
.transform((deployment): any => {
|
||||
if (!deployment) return undefined
|
||||
return deploymentSelectSchema.parse(deployment)
|
||||
})
|
||||
.optional(),
|
||||
|
||||
deployment: z
|
||||
.any()
|
||||
.refine(
|
||||
(deployment): boolean =>
|
||||
!deployment || deploymentSelectSchema.safeParse(deployment).success,
|
||||
{
|
||||
message: 'Invalid lastDeployment'
|
||||
}
|
||||
)
|
||||
.transform((deployment): any => {
|
||||
if (!deployment) return undefined
|
||||
return deploymentSelectSchema.parse(deployment)
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
|
||||
// These are all derived virtual URLs that are not stored in the database
|
||||
export const derivedProjectFields = {
|
||||
/**
|
||||
* The public base HTTP URL for the project supporting HTTP POST requests for
|
||||
* individual tools at `/tool-name` subpaths.
|
||||
*
|
||||
* @example https://gateway.agentic.so/@agentic/search
|
||||
*/
|
||||
gatewayBaseUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe(
|
||||
'The public base HTTP URL for the project supporting HTTP POST requests for individual tools at `/tool-name` subpaths.'
|
||||
),
|
||||
|
||||
/**
|
||||
* The public MCP URL for the project supporting the Streamable HTTP transport.
|
||||
*
|
||||
* @example https://gateway.agentic.so/@agentic/search/mcp
|
||||
*/
|
||||
gatewayMcpUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe(
|
||||
'The public MCP URL for the project supporting the Streamable HTTP transport.'
|
||||
),
|
||||
|
||||
/**
|
||||
* The public marketplace URL for the project.
|
||||
*
|
||||
* @example https://agentic.so/marketplace/projects/@agentic/search
|
||||
*/
|
||||
marketplaceUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe('The public marketplace URL for the project.'),
|
||||
|
||||
/**
|
||||
* A private admin URL for managing the project. This URL is only accessible
|
||||
* by project owners.
|
||||
*
|
||||
* @example https://agentic.so/app/projects/@agentic/search
|
||||
*/
|
||||
adminUrl: z
|
||||
.string()
|
||||
.url()
|
||||
.describe(
|
||||
'A private admin URL for managing the project. This URL is only accessible by project owners.'
|
||||
)
|
||||
} as const
|
||||
|
||||
export const projectSelectSchema = projectSelectBaseSchema
|
||||
.transform((project) => ({
|
||||
...project,
|
||||
gatewayBaseUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${project.identifier}`,
|
||||
gatewayMcpUrl: `${env.AGENTIC_GATEWAY_BASE_URL}/${project.identifier}/mcp`,
|
||||
marketplaceUrl: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${project.identifier}`,
|
||||
adminUrl: `${env.AGENTIC_WEB_BASE_URL}/app/projects/${project.identifier}`
|
||||
}))
|
||||
.pipe(projectSelectBaseSchema.extend(derivedProjectFields).strip())
|
||||
.describe(
|
||||
`A Project represents a single Agentic API product. It is comprised of a series of immutable Deployments, each of which contains pricing data, origin API config, OpenAPI or MCP specs, tool definitions, and various metadata.
|
||||
|
||||
You can think of Agentic Projects as similar to Vercel projects. They both hold some common configuration and are comprised of a series of immutable Deployments.
|
||||
|
||||
Internally, Projects manage all of the Stripe billing resources across Deployments (Stripe Products, Prices, and Meters for usage-based billing).`
|
||||
)
|
||||
.openapi('Project')
|
||||
|
||||
export const projectInsertSchema = createInsertSchema(projects, {
|
||||
identifier: projectIdentifierSchema,
|
||||
|
||||
name: agenticProjectConfigSchema.shape.name,
|
||||
slug: agenticProjectConfigSchema.shape.slug
|
||||
})
|
||||
.pick({
|
||||
name: true,
|
||||
slug: true
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const projectUpdateSchema = createUpdateSchema(projects)
|
||||
.pick({
|
||||
name: true
|
||||
// alias: true
|
||||
})
|
||||
.strict()
|
||||
|
||||
// TODO: virtual saasUrl
|
||||
// TODO: virtual aliasUrl
|
|
@ -0,0 +1,93 @@
|
|||
import { relations } from '@fisch0920/drizzle-orm'
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
pgTable,
|
||||
primaryKey
|
||||
} from '@fisch0920/drizzle-orm/pg-core'
|
||||
|
||||
import { userIdSchema } from '../schemas'
|
||||
import {
|
||||
createInsertSchema,
|
||||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
teamId,
|
||||
teamMemberRoleEnum,
|
||||
teamSlug,
|
||||
timestamp,
|
||||
timestamps,
|
||||
userId
|
||||
} from './common'
|
||||
import { teams } from './team'
|
||||
import { users } from './user'
|
||||
|
||||
export const teamMembers = pgTable(
|
||||
'team_members',
|
||||
{
|
||||
...timestamps,
|
||||
|
||||
userId: userId()
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
teamSlug: teamSlug()
|
||||
.notNull()
|
||||
.references(() => teams.slug, { onDelete: 'cascade' }),
|
||||
teamId: teamId()
|
||||
.notNull()
|
||||
.references(() => teams.id, { onDelete: 'cascade' }),
|
||||
role: teamMemberRoleEnum().default('user').notNull(),
|
||||
|
||||
confirmed: boolean().default(false).notNull(),
|
||||
confirmedAt: timestamp()
|
||||
},
|
||||
(table) => [
|
||||
primaryKey({ columns: [table.userId, table.teamId] }),
|
||||
index('team_member_user_idx').on(table.userId),
|
||||
index('team_member_team_idx').on(table.teamId),
|
||||
index('team_member_slug_idx').on(table.teamSlug),
|
||||
index('team_member_createdAt_idx').on(table.createdAt),
|
||||
index('team_member_updatedAt_idx').on(table.updatedAt),
|
||||
index('team_member_deletedAt_idx').on(table.deletedAt)
|
||||
]
|
||||
)
|
||||
|
||||
export const teamMembersRelations = relations(teamMembers, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [teamMembers.userId],
|
||||
references: [users.id]
|
||||
}),
|
||||
team: one(teams, {
|
||||
fields: [teamMembers.teamId],
|
||||
references: [teams.id]
|
||||
})
|
||||
}))
|
||||
|
||||
export const teamMemberSelectSchema = createSelectSchema(teamMembers)
|
||||
// .extend({
|
||||
// user: z
|
||||
// .lazy(() => userSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('User', { type: 'object' }),
|
||||
|
||||
// team: z
|
||||
// .lazy(() => teamSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('Team', { type: 'object' })
|
||||
// })
|
||||
.strip()
|
||||
.openapi('TeamMember')
|
||||
|
||||
export const teamMemberInsertSchema = createInsertSchema(teamMembers, {
|
||||
userId: userIdSchema
|
||||
})
|
||||
.pick({
|
||||
userId: true,
|
||||
role: true
|
||||
})
|
||||
.strict()
|
||||
|
||||
export const teamMemberUpdateSchema = createUpdateSchema(teamMembers)
|
||||
.pick({
|
||||
role: true
|
||||
})
|
||||
.strict()
|
|
@ -0,0 +1,76 @@
|
|||
import { isValidTeamSlug } from '@agentic/platform-validators'
|
||||
import { relations } from '@fisch0920/drizzle-orm'
|
||||
import {
|
||||
index,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from '@fisch0920/drizzle-orm/pg-core'
|
||||
|
||||
import { userIdSchema } from '../schemas'
|
||||
import {
|
||||
createInsertSchema,
|
||||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
teamPrimaryId,
|
||||
teamSlug,
|
||||
timestamps,
|
||||
userId
|
||||
} from './common'
|
||||
import { teamMembers } from './team-member'
|
||||
import { users } from './user'
|
||||
|
||||
export const teams = pgTable(
|
||||
'teams',
|
||||
{
|
||||
...teamPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
slug: teamSlug().unique().notNull(),
|
||||
name: text().notNull(),
|
||||
|
||||
ownerId: userId().notNull()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('team_slug_idx').on(table.slug),
|
||||
index('team_createdAt_idx').on(table.createdAt),
|
||||
index('team_updatedAt_idx').on(table.updatedAt),
|
||||
index('team_deletedAt_idx').on(table.deletedAt)
|
||||
]
|
||||
)
|
||||
|
||||
export const teamsRelations = relations(teams, ({ one, many }) => ({
|
||||
owner: one(users, {
|
||||
fields: [teams.ownerId],
|
||||
references: [users.id]
|
||||
}),
|
||||
members: many(teamMembers)
|
||||
}))
|
||||
|
||||
export const teamSelectSchema = createSelectSchema(teams)
|
||||
// .extend({
|
||||
// owner: z
|
||||
// .lazy(() => userSelectSchema)
|
||||
// .optional()
|
||||
// .openapi('User', { type: 'object' })
|
||||
// })
|
||||
.strip()
|
||||
.openapi('Team')
|
||||
|
||||
export const teamInsertSchema = createInsertSchema(teams, {
|
||||
slug: (schema) =>
|
||||
schema.refine((slug) => isValidTeamSlug(slug), {
|
||||
message: 'Invalid team slug'
|
||||
})
|
||||
})
|
||||
.omit({ id: true, createdAt: true, updatedAt: true, ownerId: true })
|
||||
.strict()
|
||||
|
||||
export const teamUpdateSchema = createUpdateSchema(teams, {
|
||||
ownerId: userIdSchema
|
||||
})
|
||||
.pick({
|
||||
name: true,
|
||||
ownerId: true
|
||||
})
|
||||
.strict()
|
|
@ -0,0 +1,66 @@
|
|||
import { relations } from '@fisch0920/drizzle-orm'
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from '@fisch0920/drizzle-orm/pg-core'
|
||||
|
||||
import { accounts } from './account'
|
||||
import {
|
||||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
stripeId,
|
||||
timestamps,
|
||||
username,
|
||||
// username,
|
||||
userPrimaryId,
|
||||
userRoleEnum
|
||||
} from './common'
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
...userPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
username: username().unique().notNull(),
|
||||
role: userRoleEnum().default('user').notNull(),
|
||||
|
||||
email: text().notNull().unique(),
|
||||
isEmailVerified: boolean().default(false).notNull(),
|
||||
|
||||
name: text(),
|
||||
bio: text(),
|
||||
image: text(),
|
||||
|
||||
//isStripeConnectEnabledByDefault: boolean().default(true).notNull(),
|
||||
|
||||
stripeCustomerId: stripeId()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('user_email_idx').on(table.email),
|
||||
uniqueIndex('user_username_idx').on(table.username),
|
||||
index('user_createdAt_idx').on(table.createdAt),
|
||||
index('user_updatedAt_idx').on(table.updatedAt)
|
||||
// index('user_deletedAt_idx').on(table.deletedAt)
|
||||
]
|
||||
)
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts)
|
||||
}))
|
||||
|
||||
export const userSelectSchema = createSelectSchema(users)
|
||||
.strip()
|
||||
.openapi('User')
|
||||
|
||||
export const userUpdateSchema = createUpdateSchema(users)
|
||||
.pick({
|
||||
name: true,
|
||||
bio: true,
|
||||
image: true
|
||||
//isStripeConnectEnabledByDefault: true
|
||||
})
|
||||
.strict()
|
|
@ -0,0 +1,128 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
import {
|
||||
isNamespaceAllowed,
|
||||
isValidCuid,
|
||||
isValidDeploymentIdentifier,
|
||||
isValidProjectIdentifier,
|
||||
isValidTeamSlug,
|
||||
isValidUsername
|
||||
} from '@agentic/platform-validators'
|
||||
import { z } from '@hono/zod-openapi'
|
||||
|
||||
import type { consumersRelations } from './schema/consumer'
|
||||
import type { deploymentsRelations } from './schema/deployment'
|
||||
import type { projectsRelations } from './schema/project'
|
||||
import { idPrefixMap, type ModelType } from './schema/common'
|
||||
|
||||
export function getIdSchemaForModelType(modelType: ModelType) {
|
||||
const idPrefix = idPrefixMap[modelType]
|
||||
assert(idPrefix, 500, `Invalid model type: ${modelType}`)
|
||||
|
||||
// Convert model type to PascalCase
|
||||
const modelDisplayName =
|
||||
modelType.charAt(0).toUpperCase() + modelType.slice(1)
|
||||
const example = `${idPrefix}_tz4a98xxat96iws9zmbrgj3a`
|
||||
|
||||
return z
|
||||
.string()
|
||||
.refine(
|
||||
(id) => {
|
||||
const parts = id.split('_')
|
||||
if (parts.length !== 2) return false
|
||||
if (parts[0] !== idPrefix) return false
|
||||
if (!isValidCuid(parts[1])) return false
|
||||
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: `Invalid ${modelDisplayName} id`
|
||||
}
|
||||
)
|
||||
.describe(`${modelDisplayName} id (e.g. "${example}")`)
|
||||
// TODO: is this necessary?
|
||||
// .openapi(`${modelDisplayName}Id`, { example })
|
||||
}
|
||||
|
||||
export const userIdSchema = getIdSchemaForModelType('user')
|
||||
export const teamIdSchema = getIdSchemaForModelType('team')
|
||||
export const consumerIdSchema = getIdSchemaForModelType('consumer')
|
||||
export const projectIdSchema = getIdSchemaForModelType('project')
|
||||
export const deploymentIdSchema = getIdSchemaForModelType('deployment')
|
||||
export const logEntryIdSchema = getIdSchemaForModelType('logEntry')
|
||||
|
||||
export const projectIdentifierSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
(id) =>
|
||||
isValidProjectIdentifier(id, { strict: false }) ||
|
||||
projectIdSchema.safeParse(id).success,
|
||||
{
|
||||
message: 'Invalid project identifier'
|
||||
}
|
||||
)
|
||||
.describe('Public project identifier (e.g. "@namespace/project-slug")')
|
||||
.openapi('ProjectIdentifier')
|
||||
|
||||
export const deploymentIdentifierSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
(id) =>
|
||||
isValidDeploymentIdentifier(id, { strict: false }) ||
|
||||
deploymentIdSchema.safeParse(id).success,
|
||||
{
|
||||
message: 'Invalid deployment identifier'
|
||||
}
|
||||
)
|
||||
.describe(
|
||||
'Public deployment identifier (e.g. "@namespace/project-slug@{hash|version|latest}")'
|
||||
)
|
||||
.openapi('DeploymentIdentifier')
|
||||
|
||||
export const usernameSchema = z
|
||||
.string()
|
||||
.refine((username) => isValidUsername(username), {
|
||||
message: 'Invalid username'
|
||||
})
|
||||
.refine((username) => isNamespaceAllowed(username), {
|
||||
message:
|
||||
'Username is not allowed (reserved, offensive, or otherwise confusing)'
|
||||
})
|
||||
|
||||
export const teamSlugSchema = z
|
||||
.string()
|
||||
.refine((slug) => isValidTeamSlug(slug), {
|
||||
message: 'Invalid team slug'
|
||||
})
|
||||
.refine((slug) => isNamespaceAllowed(slug), {
|
||||
message:
|
||||
'Team slug is not allowed (reserved, offensive, or otherwise confusing)'
|
||||
})
|
||||
|
||||
export const paginationSchema = z.object({
|
||||
offset: z.coerce.number().int().nonnegative().default(0).optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).default(10).optional(),
|
||||
sort: z.enum(['asc', 'desc']).default('desc').optional(),
|
||||
sortBy: z.enum(['createdAt', 'updatedAt']).default('createdAt').optional()
|
||||
})
|
||||
|
||||
export type ProjectRelationFields = keyof ReturnType<
|
||||
(typeof projectsRelations)['config']
|
||||
>
|
||||
export const projectRelationsSchema: z.ZodType<ProjectRelationFields> = z.enum([
|
||||
'user',
|
||||
'team',
|
||||
'lastPublishedDeployment',
|
||||
'lastDeployment'
|
||||
])
|
||||
|
||||
export type DeploymentRelationFields = keyof ReturnType<
|
||||
(typeof deploymentsRelations)['config']
|
||||
>
|
||||
export const deploymentRelationsSchema: z.ZodType<DeploymentRelationFields> =
|
||||
z.enum(['user', 'team', 'project'])
|
||||
|
||||
export type ConsumerRelationFields = keyof ReturnType<
|
||||
(typeof consumersRelations)['config']
|
||||
>
|
||||
export const consumerRelationsSchema: z.ZodType<ConsumerRelationFields> =
|
||||
z.enum(['user', 'project', 'deployment'])
|
|
@ -0,0 +1,63 @@
|
|||
import type { Simplify } from 'type-fest'
|
||||
import { expectTypeOf, test } from 'vitest'
|
||||
|
||||
import type {
|
||||
Consumer,
|
||||
LogEntry,
|
||||
RawConsumer,
|
||||
RawConsumerUpdate,
|
||||
RawDeployment,
|
||||
RawLogEntry,
|
||||
RawProject,
|
||||
RawUser,
|
||||
User
|
||||
} from './types'
|
||||
|
||||
type UserKeys = Exclude<keyof User & keyof RawUser, 'authProviders'>
|
||||
type LogEntryKeys = keyof RawLogEntry & keyof LogEntry
|
||||
type ConsumerKeys = keyof RawConsumer & keyof Consumer
|
||||
|
||||
type TODOFixedConsumer = Simplify<
|
||||
Omit<
|
||||
Consumer,
|
||||
| 'user'
|
||||
| 'project'
|
||||
| 'deployment'
|
||||
| 'gatewayBaseUrl'
|
||||
| 'gatewayMcpUrl'
|
||||
| 'marketplaceUrl'
|
||||
| 'adminUrl'
|
||||
> & {
|
||||
user?: RawUser | null
|
||||
project?: RawProject | null
|
||||
deployment?: RawDeployment | null
|
||||
}
|
||||
>
|
||||
|
||||
test('User types are compatible', () => {
|
||||
expectTypeOf<RawUser>().toExtend<User>()
|
||||
|
||||
expectTypeOf<User[UserKeys]>().toEqualTypeOf<RawUser[UserKeys]>()
|
||||
})
|
||||
|
||||
test('LogEntry types are compatible', () => {
|
||||
expectTypeOf<RawLogEntry>().toExtend<LogEntry>()
|
||||
|
||||
expectTypeOf<LogEntry[LogEntryKeys]>().toEqualTypeOf<
|
||||
RawLogEntry[LogEntryKeys]
|
||||
>()
|
||||
})
|
||||
|
||||
test('Consumer types are compatible', () => {
|
||||
expectTypeOf<RawConsumer>().toExtend<TODOFixedConsumer>()
|
||||
|
||||
expectTypeOf<TODOFixedConsumer[ConsumerKeys]>().toEqualTypeOf<
|
||||
RawConsumer[ConsumerKeys]
|
||||
>()
|
||||
|
||||
// Ensure that we can pass any Consumer as a RawConsumerUpdate
|
||||
expectTypeOf<Consumer>().toExtend<RawConsumerUpdate>()
|
||||
|
||||
// Ensure that we can pass any RawConsumer as a RawConsumerUpdate
|
||||
expectTypeOf<RawConsumer>().toExtend<RawConsumerUpdate>()
|
||||
})
|
|
@ -0,0 +1,82 @@
|
|||
import type {
|
||||
BuildQueryResult,
|
||||
ExtractTablesWithRelations,
|
||||
InferInsertModel,
|
||||
InferSelectModel
|
||||
} from '@fisch0920/drizzle-orm'
|
||||
import type { z } from '@hono/zod-openapi'
|
||||
import type { Simplify } from 'type-fest'
|
||||
|
||||
import type * as schema from './schema'
|
||||
|
||||
export type Tables = ExtractTablesWithRelations<typeof schema>
|
||||
|
||||
export type User = z.infer<typeof schema.userSelectSchema>
|
||||
export type RawUser = InferSelectModel<typeof schema.users>
|
||||
|
||||
export type Team = z.infer<typeof schema.teamSelectSchema>
|
||||
export type TeamWithMembers = BuildQueryResult<
|
||||
Tables,
|
||||
Tables['teams'],
|
||||
{ with: { members: true } }
|
||||
>
|
||||
export type RawTeam = InferSelectModel<typeof schema.teams>
|
||||
|
||||
export type TeamMember = z.infer<typeof schema.teamMemberSelectSchema>
|
||||
export type TeamMemberWithTeam = BuildQueryResult<
|
||||
Tables,
|
||||
Tables['teamMembers'],
|
||||
{ with: { team: true } }
|
||||
>
|
||||
export type RawTeamMember = InferSelectModel<typeof schema.teamMembers>
|
||||
|
||||
export type Project = z.infer<typeof schema.projectSelectSchema>
|
||||
export type ProjectWithLastPublishedDeployment = BuildQueryResult<
|
||||
Tables,
|
||||
Tables['projects'],
|
||||
{ with: { lastPublishedDeployment: true } }
|
||||
>
|
||||
export type RawProject = Simplify<
|
||||
InferSelectModel<typeof schema.projects> & {
|
||||
lastPublishedDeployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes)
|
||||
lastDeployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes)
|
||||
}
|
||||
>
|
||||
|
||||
export type Deployment = z.infer<typeof schema.deploymentSelectSchema>
|
||||
export type DeploymentWithProject = BuildQueryResult<
|
||||
Tables,
|
||||
Tables['deployments'],
|
||||
{ with: { project: true } }
|
||||
>
|
||||
export type RawDeployment = Simplify<
|
||||
InferSelectModel<typeof schema.deployments> & {
|
||||
project?: RawProject | null // TODO: remove null (requires drizzle-orm changes)
|
||||
}
|
||||
>
|
||||
|
||||
export type Consumer = z.infer<typeof schema.consumerSelectSchema>
|
||||
export type ConsumerWithProjectAndDeployment = BuildQueryResult<
|
||||
Tables,
|
||||
Tables['consumers'],
|
||||
{ with: { project: true; deployment: true } }
|
||||
>
|
||||
export type RawConsumer = Simplify<
|
||||
InferSelectModel<typeof schema.consumers> & {
|
||||
user?: RawUser | undefined | null // TODO: remove null (requires drizzle-orm changes)
|
||||
project?: RawProject | undefined | null // TODO: remove null (requires drizzle-orm changes)
|
||||
deployment?: RawDeployment | undefined | null // TODO: remove null (requires drizzle-orm changes)
|
||||
}
|
||||
>
|
||||
export type RawConsumerUpdate = Partial<
|
||||
Omit<
|
||||
InferInsertModel<typeof schema.consumers>,
|
||||
'id' | 'projectId' | 'userId' | 'deploymentId'
|
||||
>
|
||||
>
|
||||
|
||||
export type LogEntry = z.infer<typeof schema.logEntrySelectSchema>
|
||||
export type RawLogEntry = InferSelectModel<typeof schema.logEntries>
|
||||
|
||||
export type Account = z.infer<typeof schema.accountSelectSchema>
|
||||
export type RawAccount = InferSelectModel<typeof schema.accounts>
|
|
@ -0,0 +1,60 @@
|
|||
import type { PricingPlan, PricingPlanLineItem } from '@agentic/platform-types'
|
||||
import { hashObject } from '@agentic/platform-core'
|
||||
|
||||
import type { RawProject } from './types'
|
||||
|
||||
/**
|
||||
* Gets the hash used to uniquely map a PricingPlanLineItem to its
|
||||
* corresponding Stripe Price in a stable way across deployments within a
|
||||
* project.
|
||||
*
|
||||
* This hash is used as the key for the `Project._stripePriceIdMap`.
|
||||
*/
|
||||
export async function getPricingPlanLineItemHashForStripePrice({
|
||||
pricingPlan,
|
||||
pricingPlanLineItem,
|
||||
project
|
||||
}: {
|
||||
pricingPlan: PricingPlan
|
||||
pricingPlanLineItem: PricingPlanLineItem
|
||||
project: RawProject
|
||||
}): Promise<string> {
|
||||
// TODO: use pricingPlan.slug as well here?
|
||||
// TODO: not sure if this is needed or not...
|
||||
// With pricing plan slug:
|
||||
// - 'price:free:base:<hash>'
|
||||
// - 'price:basic-monthly:base:<hash>'
|
||||
// - 'price:basic-monthly:requests:<hash>'
|
||||
// Without pricing plan slug:
|
||||
// - 'price:base:<hash>'
|
||||
// - 'price:base:<hash>'
|
||||
// - 'price:requests:<hash>'
|
||||
|
||||
const hash = await hashObject({
|
||||
...pricingPlanLineItem,
|
||||
projectId: project.id,
|
||||
stripeAccountId: project._stripeAccountId,
|
||||
currency: project.pricingCurrency
|
||||
})
|
||||
|
||||
return `price:${pricingPlan.slug}:${pricingPlanLineItem.slug}:${hash}`
|
||||
}
|
||||
|
||||
export async function getStripePriceIdForPricingPlanLineItem({
|
||||
pricingPlan,
|
||||
pricingPlanLineItem,
|
||||
project
|
||||
}: {
|
||||
pricingPlan: PricingPlan
|
||||
pricingPlanLineItem: PricingPlanLineItem
|
||||
project: RawProject
|
||||
}): Promise<string | undefined> {
|
||||
const pricingPlanLineItemHash =
|
||||
await getPricingPlanLineItemHashForStripePrice({
|
||||
pricingPlan,
|
||||
pricingPlanLineItem,
|
||||
project
|
||||
})
|
||||
|
||||
return project._stripePriceIdMap[pricingPlanLineItemHash]
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`Storage > uploadFileUrlToStorage data-uri 1`] = `"https://storage.agentic.so/@dev/test/ef4238fba78887e0974cd48809a66e284cdd78ce92d6b2d485c25a552fb39631.svg"`;
|
||||
|
||||
exports[`Storage > uploadFileUrlToStorage data-uri 2 1`] = `"https://storage.agentic.so/@dev/test/efbc1f0409b730d93b01c918be9e024bf7777801cfd252221c39e36c08c1b4fb"`;
|
||||
|
||||
exports[`Storage > uploadFileUrlToStorage url 1`] = `"https://storage.agentic.so/@dev/test/6da6ef895be2a42606b99e5e3b9c25687c92c81986a9718e07f32e574d41cf7a.svg"`;
|
|
@ -0,0 +1,10 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
|
||||
import type { AuthenticatedHonoContext } from './types'
|
||||
import { ensureAuthUser } from './ensure-auth-user'
|
||||
|
||||
export async function aclAdmin(ctx: AuthenticatedHonoContext) {
|
||||
const user = await ensureAuthUser(ctx)
|
||||
assert(user, 401, 'Authentication required')
|
||||
assert(user.role === 'admin', 403, 'Access denied')
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
|
||||
import type { RawProject } from '@/db'
|
||||
|
||||
export function aclPublicProject(
|
||||
project: RawProject | undefined,
|
||||
projectId?: string
|
||||
): asserts project {
|
||||
assert(
|
||||
project,
|
||||
404,
|
||||
`Public project not found${projectId ? ` "${projectId}"` : ''}`
|
||||
)
|
||||
|
||||
assert(
|
||||
!project.private && project.lastPublishedDeploymentId,
|
||||
404,
|
||||
`Public project not found "${project.id}"`
|
||||
)
|
||||
|
||||
assert(!project.deletedAt, 410, `Project has been deleted "${project.id}"`)
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
|
||||
import { and, db, eq, schema, type TeamMember } from '@/db'
|
||||
|
||||
import type { AuthenticatedHonoContext } from './types'
|
||||
import { ensureAuthUser } from './ensure-auth-user'
|
||||
|
||||
export async function aclTeamAdmin(
|
||||
ctx: AuthenticatedHonoContext,
|
||||
{
|
||||
teamId,
|
||||
teamSlug,
|
||||
teamMember
|
||||
}: {
|
||||
teamId?: string
|
||||
teamSlug?: string
|
||||
teamMember?: TeamMember
|
||||
} & (
|
||||
| {
|
||||
teamId: string
|
||||
teamSlug?: never
|
||||
}
|
||||
| {
|
||||
teamId?: never
|
||||
teamSlug: string
|
||||
}
|
||||
)
|
||||
) {
|
||||
const teamLabel = teamId ?? teamSlug
|
||||
assert(teamLabel, 500, 'Either teamSlug or teamId must be provided')
|
||||
|
||||
const user = await ensureAuthUser(ctx)
|
||||
|
||||
if (user.role === 'admin') {
|
||||
// TODO: Allow admins to access all team resources
|
||||
return
|
||||
}
|
||||
|
||||
if (!teamMember) {
|
||||
teamMember = await db.query.teamMembers.findFirst({
|
||||
where: and(
|
||||
teamId
|
||||
? eq(schema.teamMembers.teamId, teamId)
|
||||
: eq(schema.teamMembers.teamSlug, teamSlug!),
|
||||
eq(schema.teamMembers.userId, user.id)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
assert(teamMember, 403, `User does not have access to team "${teamLabel}"`)
|
||||
|
||||
assert(
|
||||
teamMember.role === 'admin',
|
||||
403,
|
||||
`User does not have "admin" role for team "${teamLabel}"`
|
||||
)
|
||||
|
||||
assert(
|
||||
teamMember.userId === user.id,
|
||||
403,
|
||||
`User does not have access to team "${teamLabel}"`
|
||||
)
|
||||
|
||||
assert(
|
||||
teamMember.confirmed,
|
||||
403,
|
||||
`User has not confirmed their invitation to team "${teamLabel}"`
|
||||
)
|
||||
}
|
|
@ -0,0 +1,65 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
|
||||
import { and, db, eq, type RawTeamMember, schema } from '@/db'
|
||||
|
||||
import type { AuthenticatedHonoContext } from './types'
|
||||
import { ensureAuthUser } from './ensure-auth-user'
|
||||
|
||||
export async function aclTeamMember(
|
||||
ctx: AuthenticatedHonoContext,
|
||||
{
|
||||
teamId,
|
||||
teamSlug,
|
||||
teamMember,
|
||||
userId
|
||||
}: {
|
||||
teamId?: string
|
||||
teamSlug?: string
|
||||
teamMember?: RawTeamMember
|
||||
userId?: string
|
||||
} & (
|
||||
| { teamSlug: string }
|
||||
| { teamId: string }
|
||||
| { teamMember: RawTeamMember }
|
||||
)
|
||||
) {
|
||||
const teamLabel = teamId ?? teamSlug
|
||||
assert(teamLabel, 500, 'Either teamSlug or teamId must be provided')
|
||||
|
||||
const user = await ensureAuthUser(ctx)
|
||||
|
||||
if (user.role === 'admin') {
|
||||
// TODO: Allow admins to access all team resources
|
||||
return
|
||||
}
|
||||
|
||||
userId ??= user.id
|
||||
|
||||
if (!teamMember) {
|
||||
teamMember = await db.query.teamMembers.findFirst({
|
||||
where: and(
|
||||
teamId
|
||||
? eq(schema.teamMembers.teamId, teamId)
|
||||
: eq(schema.teamMembers.teamSlug, teamSlug!),
|
||||
eq(schema.teamMembers.userId, userId)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
assert(teamMember, 403, `User does not have access to team "${teamLabel}"`)
|
||||
if (!ctx.get('teamMember')) {
|
||||
ctx.set('teamMember', teamMember)
|
||||
}
|
||||
|
||||
assert(
|
||||
teamMember.userId === userId,
|
||||
403,
|
||||
`User does not have access to team "${teamLabel}"`
|
||||
)
|
||||
|
||||
assert(
|
||||
teamMember.confirmed,
|
||||
403,
|
||||
`User has not confirmed their invitation to team "${teamLabel}"`
|
||||
)
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
|
||||
import type { AuthenticatedHonoContext } from './types'
|
||||
import { ensureAuthUser } from './ensure-auth-user'
|
||||
|
||||
export async function acl<
|
||||
TModel extends Record<string, unknown>,
|
||||
TUserField extends keyof TModel = 'userId',
|
||||
TTeamField extends keyof TModel = 'teamId'
|
||||
>(
|
||||
ctx: AuthenticatedHonoContext,
|
||||
model: TModel,
|
||||
{
|
||||
label,
|
||||
userField = 'userId' as TUserField,
|
||||
teamField = 'teamId' as TTeamField
|
||||
}: {
|
||||
label: string
|
||||
userField?: TUserField
|
||||
teamField?: TTeamField
|
||||
}
|
||||
) {
|
||||
const user = await ensureAuthUser(ctx)
|
||||
const teamMember = ctx.get('teamMember')
|
||||
|
||||
const userFieldValue = model[userField]
|
||||
const teamFieldValue = model[teamField]
|
||||
|
||||
const isAuthUserOwner = userFieldValue && userFieldValue === user.id
|
||||
const isAuthUserAdmin = user.role === 'admin'
|
||||
const hasTeamAccess =
|
||||
teamMember && teamFieldValue && teamFieldValue === teamMember.teamId
|
||||
|
||||
assert(
|
||||
isAuthUserOwner || isAuthUserAdmin || hasTeamAccess,
|
||||
403,
|
||||
`User does not have access to ${label} "${model.id ?? userFieldValue}"`
|
||||
)
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
export interface AuthStorageAdapter {
|
||||
get(key: string[]): Promise<Record<string, any> | undefined>
|
||||
remove(key: string[]): Promise<void>
|
||||
set(key: string[], value: any, expiry?: Date): Promise<void>
|
||||
scan(prefix: string[]): AsyncIterable<[string[], any]>
|
||||
}
|
||||
|
||||
const SEPERATOR = String.fromCodePoint(0x1f)
|
||||
|
||||
export function joinKey(key: string[]) {
|
||||
return key.join(SEPERATOR)
|
||||
}
|
||||
|
||||
export function splitKey(key: string) {
|
||||
return key.split(SEPERATOR)
|
||||
}
|
||||
|
||||
export namespace AuthStorage {
|
||||
function encode(key: string[]) {
|
||||
return key.map((k) => k.replaceAll(SEPERATOR, ''))
|
||||
}
|
||||
export function get<T>(adapter: AuthStorageAdapter, key: string[]) {
|
||||
return adapter.get(encode(key)) as Promise<T | null>
|
||||
}
|
||||
|
||||
export function set(
|
||||
adapter: AuthStorageAdapter,
|
||||
key: string[],
|
||||
value: any,
|
||||
ttl?: number
|
||||
) {
|
||||
const expiry = ttl ? new Date(Date.now() + ttl * 1000) : undefined
|
||||
return adapter.set(encode(key), value, expiry)
|
||||
}
|
||||
|
||||
export function remove(adapter: AuthStorageAdapter, key: string[]) {
|
||||
return adapter.remove(encode(key))
|
||||
}
|
||||
|
||||
export function scan<T>(
|
||||
adapter: AuthStorageAdapter,
|
||||
key: string[]
|
||||
): AsyncIterable<[string[], T]> {
|
||||
return adapter.scan(encode(key))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import { sign } from 'hono/jwt'
|
||||
|
||||
import type { RawUser } from '@/db'
|
||||
import { env } from '@/lib/env'
|
||||
|
||||
export async function createAuthToken(user: RawUser): Promise<string> {
|
||||
return sign(
|
||||
{
|
||||
type: 'user',
|
||||
id: user.id,
|
||||
username: user.username
|
||||
},
|
||||
env.JWT_SECRET
|
||||
)
|
||||
}
|
|
@ -0,0 +1,62 @@
|
|||
import { and, db, eq, gt, isNull, like, or, schema } from '@/db'
|
||||
|
||||
import { type AuthStorageAdapter, joinKey, splitKey } from './auth-storage'
|
||||
|
||||
export function DrizzleAuthStorage(): AuthStorageAdapter {
|
||||
return {
|
||||
async get(key: string[]) {
|
||||
const id = joinKey(key)
|
||||
const entry = await db.query.authData.findFirst({
|
||||
where: eq(schema.authData.id, id)
|
||||
})
|
||||
if (!entry) return undefined
|
||||
|
||||
if (entry.expiry && Date.now() >= entry.expiry.getTime()) {
|
||||
await db.delete(schema.authData).where(eq(schema.authData.id, id))
|
||||
return undefined
|
||||
}
|
||||
|
||||
return entry.value
|
||||
},
|
||||
|
||||
async set(key: string[], value: Record<string, any>, expiry?: Date) {
|
||||
const id = joinKey(key)
|
||||
|
||||
await db
|
||||
.insert(schema.authData)
|
||||
.values({
|
||||
id,
|
||||
value,
|
||||
expiry
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: schema.authData.id,
|
||||
set: {
|
||||
value,
|
||||
expiry: expiry ?? null
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
async remove(key: string[]) {
|
||||
const id = joinKey(key)
|
||||
await db.delete(schema.authData).where(eq(schema.authData.id, id))
|
||||
},
|
||||
|
||||
async *scan(prefix: string[]) {
|
||||
const now = new Date()
|
||||
const idPrefix = joinKey(prefix)
|
||||
|
||||
const entries = await db.query.authData.findMany({
|
||||
where: and(
|
||||
like(schema.authData.id, `${idPrefix}%`),
|
||||
or(isNull(schema.authData.expiry), gt(schema.authData.expiry, now))
|
||||
)
|
||||
})
|
||||
|
||||
for (const entry of entries) {
|
||||
yield [splitKey(entry.id), entry.value]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,172 @@
|
|||
import type { SetRequired, Simplify } from 'type-fest'
|
||||
import { assert } from '@agentic/platform-core'
|
||||
|
||||
import { and, db, eq, type RawAccount, type RawUser, schema } from '@/db'
|
||||
|
||||
import { createAvatar } from '../create-avatar'
|
||||
import { getUniqueNamespace } from '../ensure-unique-namespace'
|
||||
import { uploadFileUrlToStorage } from '../storage'
|
||||
|
||||
/**
|
||||
* After a user completes an authentication flow, we'll have partial account info
|
||||
* and partial suer info. This function takes these partial values and maps them
|
||||
* to a valid database Account and User.
|
||||
*
|
||||
* This will result in the Account being upserted, and may result in a new User
|
||||
* being created.
|
||||
*/
|
||||
export async function upsertOrLinkUserAccount({
|
||||
partialAccount,
|
||||
partialUser
|
||||
}: {
|
||||
partialAccount: Simplify<
|
||||
SetRequired<
|
||||
Partial<
|
||||
Pick<
|
||||
RawAccount,
|
||||
| 'provider'
|
||||
| 'accountId'
|
||||
| 'accountUsername'
|
||||
| 'accessToken'
|
||||
| 'refreshToken'
|
||||
| 'accessTokenExpiresAt'
|
||||
| 'refreshTokenExpiresAt'
|
||||
| 'scope'
|
||||
| 'password'
|
||||
>
|
||||
>,
|
||||
'provider' | 'accountId'
|
||||
>
|
||||
>
|
||||
partialUser: Simplify<
|
||||
SetRequired<
|
||||
Partial<
|
||||
Pick<
|
||||
RawUser,
|
||||
'email' | 'name' | 'username' | 'image' | 'isEmailVerified'
|
||||
>
|
||||
>,
|
||||
'email'
|
||||
>
|
||||
>
|
||||
}): Promise<RawUser> {
|
||||
const { provider, accountId } = partialAccount
|
||||
|
||||
const [existingAccount, existingUser] = await Promise.all([
|
||||
db.query.accounts.findFirst({
|
||||
where: and(
|
||||
eq(schema.accounts.provider, provider),
|
||||
eq(schema.accounts.accountId, accountId)
|
||||
),
|
||||
with: {
|
||||
user: true
|
||||
}
|
||||
}),
|
||||
|
||||
db.query.users.findFirst({
|
||||
where: eq(schema.users.email, partialUser.email)
|
||||
})
|
||||
])
|
||||
|
||||
async function resolveUserProfileImage({ prefix }: { prefix: string }) {
|
||||
// Set a default profile image if one isn't provided
|
||||
partialUser.image = await uploadFileUrlToStorage(
|
||||
partialUser.image ?? createAvatar(partialUser.email),
|
||||
{
|
||||
prefix
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (existingAccount && existingUser) {
|
||||
// Happy path case: the user is just logging in with an existing account
|
||||
// that's already linked to a user.
|
||||
assert(
|
||||
existingAccount.userId === existingUser.id,
|
||||
`Error authenticating with ${provider}: Account id "${existingAccount.id}" user id "${existingAccount.userId}" does not match expected user id "${existingUser.id}"`
|
||||
)
|
||||
assert(provider !== 'password', 500)
|
||||
|
||||
// Update the account with the up-to-date provider data, including any OAuth
|
||||
// tokens.
|
||||
await db
|
||||
.update(schema.accounts)
|
||||
.set(partialAccount)
|
||||
.where(eq(schema.accounts.id, existingAccount.id))
|
||||
|
||||
return existingUser
|
||||
} else if (existingUser && !existingAccount) {
|
||||
// Linking a new account to an existing user
|
||||
await db.insert(schema.accounts).values({
|
||||
...partialAccount,
|
||||
userId: existingUser.id
|
||||
})
|
||||
|
||||
// TODO: Same caveat as below: if the existing user has a different email than
|
||||
// the one in the account we're linking, we should throw an error unless it's
|
||||
// a "trusted" provider.
|
||||
if (provider === 'password' && existingUser.email !== partialUser.email) {
|
||||
await resolveUserProfileImage({ prefix: existingUser.username })
|
||||
|
||||
const [user] = await db
|
||||
.update(schema.users)
|
||||
.set(partialUser)
|
||||
.where(eq(schema.users.id, existingUser.id))
|
||||
.returning()
|
||||
assert(
|
||||
user,
|
||||
500,
|
||||
`Error updating existing user during ${provider} authentication`
|
||||
)
|
||||
return user
|
||||
}
|
||||
|
||||
return existingUser
|
||||
} else if (existingAccount && !existingUser) {
|
||||
assert(
|
||||
existingAccount.user,
|
||||
404,
|
||||
`Error authenticating with ${provider}: Account id "${existingAccount.id}" is linked to a user with a different email address than their ${provider} account, but the linked account user id "${existingAccount.userId}" is not found.`
|
||||
)
|
||||
|
||||
// Existing account is linked to a user with a different email address than
|
||||
// this provider account. This should be fine since it's pretty common for
|
||||
// users to have multiple email addresses, but we may want to limit the
|
||||
// ability to automatically link accounts like this in the future to only
|
||||
// certain, trusted providers like `better-auth` does.
|
||||
return existingAccount.user
|
||||
} else {
|
||||
const username = await getUniqueNamespace(
|
||||
partialUser.username || partialUser.email.split('@')[0]!.toLowerCase(),
|
||||
{ label: 'Username' }
|
||||
)
|
||||
|
||||
await resolveUserProfileImage({ prefix: username })
|
||||
|
||||
// This is a user's first time signing up with the platform, so create both
|
||||
// a new user and linked account.
|
||||
return db.transaction(async (tx) => {
|
||||
// Create a new user
|
||||
const [user] = await tx
|
||||
.insert(schema.users)
|
||||
.values({
|
||||
...partialUser,
|
||||
username
|
||||
})
|
||||
.returning()
|
||||
assert(
|
||||
user,
|
||||
500,
|
||||
`Error creating new user during ${provider} authentication`
|
||||
)
|
||||
|
||||
// Create a new account linked to the new user
|
||||
await tx.insert(schema.accounts).values({
|
||||
...partialAccount,
|
||||
userId: user.id
|
||||
})
|
||||
|
||||
return user
|
||||
})
|
||||
}
|
||||
}
|
|
@ -0,0 +1,357 @@
|
|||
import type Stripe from 'stripe'
|
||||
import { assert } from '@agentic/platform-core'
|
||||
|
||||
import type { AuthenticatedHonoContext } from '@/lib/types'
|
||||
import {
|
||||
getStripePriceIdForPricingPlanLineItem,
|
||||
type RawConsumer,
|
||||
type RawDeployment,
|
||||
type RawProject,
|
||||
type RawUser
|
||||
} from '@/db'
|
||||
import { stripe } from '@/lib/external/stripe'
|
||||
|
||||
import { env } from '../env'
|
||||
|
||||
export async function createStripeCheckoutSession(
|
||||
ctx: AuthenticatedHonoContext,
|
||||
{
|
||||
consumer,
|
||||
user,
|
||||
deployment,
|
||||
project,
|
||||
plan
|
||||
}: {
|
||||
consumer: RawConsumer
|
||||
user: RawUser
|
||||
deployment: RawDeployment
|
||||
project: RawProject
|
||||
plan?: string
|
||||
}
|
||||
): Promise<{ id: string; url: string }> {
|
||||
const logger = ctx.get('logger')
|
||||
const stripeConnectParams = project._stripeAccountId
|
||||
? [
|
||||
{
|
||||
stripeAccount: project._stripeAccountId
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
const stripeCustomerId = consumer._stripeCustomerId || user.stripeCustomerId
|
||||
assert(
|
||||
stripeCustomerId,
|
||||
500,
|
||||
`Missing valid stripe customer. Please contact support for deployment "${deployment.id}" and consumer "${consumer.id}"`
|
||||
)
|
||||
|
||||
const pricingPlan = plan
|
||||
? deployment.pricingPlans.find((pricingPlan) => pricingPlan.slug === plan)
|
||||
: undefined
|
||||
|
||||
const action: 'create' | 'update' | 'cancel' = consumer._stripeSubscriptionId
|
||||
? plan
|
||||
? 'update'
|
||||
: 'cancel'
|
||||
: 'create'
|
||||
|
||||
// TODO: test cancel => resubscribe flow
|
||||
|
||||
if (consumer._stripeSubscriptionId) {
|
||||
// customer has an existing subscription
|
||||
const existingStripeSubscription = await stripe.subscriptions.retrieve(
|
||||
consumer._stripeSubscriptionId,
|
||||
...stripeConnectParams
|
||||
)
|
||||
const existingStripeSubscriptionItems =
|
||||
existingStripeSubscription.items.data
|
||||
logger.debug()
|
||||
logger.debug(
|
||||
'existing stripe subscription',
|
||||
JSON.stringify(existingStripeSubscription, null, 2)
|
||||
)
|
||||
logger.debug()
|
||||
|
||||
assert(
|
||||
existingStripeSubscription.metadata?.userId === consumer.userId,
|
||||
500,
|
||||
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.userId for consumer "${consumer.id}"`
|
||||
)
|
||||
assert(
|
||||
existingStripeSubscription.metadata?.consumerId === consumer.id,
|
||||
500,
|
||||
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.consumerId for consumer "${consumer.id}"`
|
||||
)
|
||||
assert(
|
||||
existingStripeSubscription.metadata?.projectId === project.id,
|
||||
500,
|
||||
`Error updating stripe subscription: invalid existing subscription "${existingStripeSubscription.id}" metadata.projectId for consumer "${consumer.id}"`
|
||||
)
|
||||
|
||||
if (!plan) {
|
||||
const billingPortalSession = await stripe.billingPortal.sessions.create(
|
||||
{
|
||||
customer: stripeCustomerId,
|
||||
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers`,
|
||||
flow_data: {
|
||||
type: 'subscription_cancel',
|
||||
subscription_cancel: {
|
||||
subscription: consumer._stripeSubscriptionId
|
||||
},
|
||||
after_completion: {
|
||||
type: 'redirect',
|
||||
redirect: {
|
||||
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}?checkout=canceled`
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
return {
|
||||
id: billingPortalSession.id,
|
||||
url: billingPortalSession.url
|
||||
}
|
||||
}
|
||||
|
||||
assert(
|
||||
pricingPlan,
|
||||
404,
|
||||
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
||||
)
|
||||
|
||||
const updateParams: Stripe.SubscriptionUpdateParams = {
|
||||
collection_method: 'charge_automatically',
|
||||
description:
|
||||
pricingPlan.description ??
|
||||
`Subscription to ${project.name} ${pricingPlan.name}`,
|
||||
metadata: {
|
||||
plan: plan ?? null,
|
||||
consumerId: consumer.id,
|
||||
userId: consumer.userId,
|
||||
projectId: project.id,
|
||||
deploymentId: deployment.id
|
||||
}
|
||||
}
|
||||
|
||||
const items: Stripe.SubscriptionUpdateParams.Item[] = await Promise.all(
|
||||
pricingPlan.lineItems.map(async (lineItem) => {
|
||||
const priceId = await getStripePriceIdForPricingPlanLineItem({
|
||||
pricingPlan,
|
||||
pricingPlanLineItem: lineItem,
|
||||
project
|
||||
})
|
||||
assert(
|
||||
priceId,
|
||||
500,
|
||||
`Error updating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line-item "${lineItem.slug}"`
|
||||
)
|
||||
|
||||
// An existing Stripe Subscription Item may or may not exist for this
|
||||
// LineItem. It should exist if this is an update to an existing
|
||||
// LineItem. It won't exist if it's a new LineItem.
|
||||
const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug]
|
||||
|
||||
return {
|
||||
price: priceId,
|
||||
id,
|
||||
metadata: {
|
||||
lineItemSlug: lineItem.slug
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Sanity check that LineItems we think should exist are all present in
|
||||
// the current subscription's items.
|
||||
for (const item of items) {
|
||||
if (item.id) {
|
||||
const existingItem = existingStripeSubscriptionItems.find(
|
||||
(existingItem) => item.id === existingItem.id
|
||||
)
|
||||
|
||||
assert(
|
||||
existingItem,
|
||||
500,
|
||||
`Error updating stripe subscription: invalid pricing plan "${plan}" missing existing Subscription Item for "${item.id}"`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for (const existingItem of existingStripeSubscriptionItems) {
|
||||
const updatedItem = items.find((item) => item.id === existingItem.id)
|
||||
|
||||
if (!updatedItem) {
|
||||
const deletedItem: Stripe.SubscriptionUpdateParams.Item = {
|
||||
id: existingItem.id,
|
||||
deleted: true
|
||||
}
|
||||
|
||||
items.push(deletedItem)
|
||||
}
|
||||
}
|
||||
|
||||
assert(
|
||||
items.length || !plan,
|
||||
500,
|
||||
`Error updating stripe subscription "${consumer._stripeSubscriptionId}"`
|
||||
)
|
||||
|
||||
for (const item of items) {
|
||||
if (!item.id) {
|
||||
delete item.id
|
||||
}
|
||||
}
|
||||
|
||||
updateParams.items = items
|
||||
|
||||
if (pricingPlan.trialPeriodDays) {
|
||||
const trialEnd =
|
||||
Math.trunc(Date.now() / 1000) +
|
||||
24 * 60 * 60 * pricingPlan.trialPeriodDays
|
||||
|
||||
// Reuse the existing trial end date if one exists. Otherwise, set a new
|
||||
// one for the updated subscription.
|
||||
updateParams.trial_end = existingStripeSubscription.trial_end ?? trialEnd
|
||||
} else if (existingStripeSubscription.trial_end) {
|
||||
// If the existing subscription has a trial end date, but the updated
|
||||
// subscription doesn't, we should end the trial now.
|
||||
updateParams.trial_end = 'now'
|
||||
}
|
||||
|
||||
logger.info('>>> subscription', action, { items })
|
||||
|
||||
// TODO: Stripe Connect
|
||||
// if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
|
||||
// updateParams.application_fee_percent = project.applicationFeePercent
|
||||
// }
|
||||
|
||||
const subscription = await stripe.subscriptions.update(
|
||||
consumer._stripeSubscriptionId,
|
||||
updateParams,
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
logger.info('<<< subscription', action, subscription)
|
||||
|
||||
const billingPortalSession = await stripe.billingPortal.sessions.create(
|
||||
{
|
||||
customer: stripeCustomerId,
|
||||
return_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers`
|
||||
},
|
||||
...stripeConnectParams
|
||||
)
|
||||
|
||||
return {
|
||||
id: billingPortalSession.id,
|
||||
url: billingPortalSession.url
|
||||
}
|
||||
} else {
|
||||
// Creating a new subscription for this consumer for the first time.
|
||||
assert(
|
||||
pricingPlan,
|
||||
404,
|
||||
`Unable to update stripe subscription for invalid pricing plan "${plan}"`
|
||||
)
|
||||
|
||||
const items: Stripe.Checkout.SessionCreateParams.LineItem[] =
|
||||
await Promise.all(
|
||||
pricingPlan.lineItems.map(async (lineItem) => {
|
||||
// An existing Stripe Subscription Item may or may not exist for this
|
||||
// LineItem. It should exist if this is an update to an existing
|
||||
// LineItem. It won't exist if it's a new LineItem.
|
||||
const id = consumer._stripeSubscriptionItemIdMap[lineItem.slug]
|
||||
assert(
|
||||
!id,
|
||||
500,
|
||||
`Error creating stripe subscription: consumer contains a Stripe Subscription Item for LineItem "${lineItem.slug}" and pricing plan "${pricingPlan.slug}"`
|
||||
)
|
||||
|
||||
const priceId = await getStripePriceIdForPricingPlanLineItem({
|
||||
pricingPlan,
|
||||
pricingPlanLineItem: lineItem,
|
||||
project
|
||||
})
|
||||
assert(
|
||||
priceId,
|
||||
500,
|
||||
`Error creating stripe subscription: missing expected Stripe Price for plan "${pricingPlan.slug}" line item "${lineItem.slug}"`
|
||||
)
|
||||
|
||||
return {
|
||||
price: priceId,
|
||||
// TODO: Make this customizable
|
||||
quantity: lineItem.usageType === 'licensed' ? 1 : undefined
|
||||
// metadata: {
|
||||
// lineItemSlug: lineItem.slug
|
||||
// }
|
||||
} satisfies Stripe.Checkout.SessionCreateParams.LineItem
|
||||
})
|
||||
)
|
||||
|
||||
assert(
|
||||
items.length,
|
||||
500,
|
||||
`Error creating stripe subscription: invalid plan "${plan}"`
|
||||
)
|
||||
|
||||
const checkoutSessionParams: Stripe.Checkout.SessionCreateParams = {
|
||||
customer: stripeCustomerId,
|
||||
mode: 'subscription',
|
||||
line_items: items,
|
||||
success_url: `${env.AGENTIC_WEB_BASE_URL}/app/consumers/${consumer.id}?checkout=success&plan=${plan}`,
|
||||
cancel_url: `${env.AGENTIC_WEB_BASE_URL}/marketplace/projects/${project.identifier}?checkout=canceled`,
|
||||
submit_type: 'subscribe',
|
||||
saved_payment_method_options: {
|
||||
payment_method_save: 'enabled'
|
||||
},
|
||||
subscription_data: {
|
||||
description:
|
||||
pricingPlan.description ??
|
||||
`Subscription to ${project.name} ${pricingPlan.name}`,
|
||||
trial_period_days: pricingPlan.trialPeriodDays,
|
||||
metadata: {
|
||||
plan: plan ?? null,
|
||||
consumerId: consumer.id,
|
||||
userId: consumer.userId,
|
||||
projectId: project.id,
|
||||
deploymentId: deployment.id
|
||||
}
|
||||
// TODO: Stripe Connect
|
||||
// application_fee_percent: project.applicationFeePercent
|
||||
},
|
||||
// TODO: coupons
|
||||
// coupon: filterConsumerCoupon(ctx, consumer, deployment),
|
||||
// TODO: discounts
|
||||
// collection_method: 'charge_automatically',
|
||||
// TODO: consider custom_fields
|
||||
// TODO: consider custom_text
|
||||
// TODO: consider optional_items
|
||||
metadata: {
|
||||
plan: plan ?? null,
|
||||
consumerId: consumer.id,
|
||||
userId: consumer.userId,
|
||||
projectId: project.id,
|
||||
deploymentId: deployment.id
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Stripe Connect
|
||||
// if (project.isStripeConnectEnabled && project.applicationFeePercent > 0) {
|
||||
// createParams.application_fee_percent = project.applicationFeePercent
|
||||
// }
|
||||
|
||||
logger.debug('checkout session line_items', items)
|
||||
const checkoutSession = await stripe.checkout.sessions.create(
|
||||
checkoutSessionParams,
|
||||
...stripeConnectParams
|
||||
)
|
||||
assert(checkoutSession.url, 500, 'Missing stripe checkout session URL')
|
||||
|
||||
return {
|
||||
id: checkoutSession.id,
|
||||
url: checkoutSession.url
|
||||
}
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Ładowanie…
Reference in New Issue