Porównaj commity

...

No commits in common. "@agentic/llamaindex@7.3.6" and "main" have entirely different histories.

1282 zmienionych plików z 698169 dodań i 16195 usunięć

Wyświetl plik

@ -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": []
}

Wyświetl plik

@ -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

Wyświetl plik

@ -1,5 +0,0 @@
{
"root": true,
"extends": ["@fisch0920/eslint-config/node"],
"ignorePatterns": ["out"]
}

Wyświetl plik

@ -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

25
.github/workflows/release.yml vendored 100644
Wyświetl plik

@ -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 }}

22
.gitignore vendored
Wyświetl plik

@ -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

Wyświetl plik

@ -1 +0,0 @@
npm run precommit

Wyświetl plik

@ -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

Wyświetl plik

@ -1,11 +0,0 @@
{
"singleQuote": true,
"jsxSingleQuote": true,
"semi": false,
"useTabs": false,
"tabWidth": 2,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"trailingComma": "none"
}

76
.vscode/launch.json vendored 100644
Wyświetl plik

@ -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
// }
// ]
}

219
CLAUDE.md 100644
Wyświetl plik

@ -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

60
Tiltfile 100644
Wyświetl plik

@ -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',
)

Wyświetl plik

@ -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=

Wyświetl plik

@ -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!
}
})

Wyświetl plik

@ -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:"
}
}

39
apps/api/readme.md 100644
Wyświetl plik

@ -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/)

Wyświetl plik

@ -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())
})
}

Wyświetl plik

@ -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 }))
})
}

Wyświetl plik

@ -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())
})
}

Wyświetl plik

@ -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')

Wyświetl plik

@ -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 }))
}

Wyświetl plik

@ -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 }))
})
}

Wyświetl plik

@ -0,0 +1,3 @@
import { DrizzleAuthStorage } from '@/lib/auth/drizzle-auth-storage'
export const authStorage = DrizzleAuthStorage()

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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 })
})
}

Wyświetl plik

@ -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 })
})
}

Wyświetl plik

@ -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)
})
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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)
)
})
}

Wyświetl plik

@ -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)
)
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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
})

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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')
}
}

Wyświetl plik

@ -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
})
)
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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)
)
})
}

Wyświetl plik

@ -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)
)
})
}

Wyświetl plik

@ -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
})

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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' })
})
}

Wyświetl plik

@ -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>

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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
})

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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 })
})
}

Wyświetl plik

@ -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))
})
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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'
}
})
})

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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'
}
})
})

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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'
}
})
})

Wyświetl plik

@ -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))
})
}

Wyświetl plik

@ -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
}

Wyświetl plik

@ -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'

Wyświetl plik

@ -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')

Wyświetl plik

@ -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()
})

Wyświetl plik

@ -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)
}
})
}

Wyświetl plik

@ -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
}
})

Wyświetl plik

@ -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()

Wyświetl plik

@ -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()

Wyświetl plik

@ -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'

Wyświetl plik

@ -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')

Wyświetl plik

@ -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

Wyświetl plik

@ -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()

Wyświetl plik

@ -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()

Wyświetl plik

@ -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()

Wyświetl plik

@ -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'])

Wyświetl plik

@ -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>()
})

Wyświetl plik

@ -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>

Wyświetl plik

@ -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]
}

Wyświetl plik

@ -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"`;

Wyświetl plik

@ -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')
}

Wyświetl plik

@ -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}"`)
}

Wyświetl plik

@ -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}"`
)
}

Wyświetl plik

@ -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}"`
)
}

Wyświetl plik

@ -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}"`
)
}

Wyświetl plik

@ -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))
}
}

Wyświetl plik

@ -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
)
}

Wyświetl plik

@ -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]
}
}
}
}

Wyświetl plik

@ -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
})
}
}

Wyświetl plik

@ -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