feat: add s3/r2 storage support to backend api and api-client

pull/718/head
Travis Fischer 2025-07-02 04:58:30 -05:00
rodzic bbbbb9a387
commit 62b8246cc3
17 zmienionych plików z 1175 dodań i 196 usunięć

Wyświetl plik

@ -9,6 +9,7 @@ DATABASE_URL=
AGENTIC_WEB_BASE_URL=
AGENTIC_GATEWAY_BASE_URL=
AGENTIC_STORAGE_BASE_URL='https://storage.agentic.so'
JWT_SECRET=
@ -29,3 +30,11 @@ 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

@ -25,7 +25,8 @@
"drizzle-kit:prod": "dotenvx run -o -f .env.production -- drizzle-kit",
"clean": "del dist",
"test": "run-s test:*",
"test:typecheck": "tsc --noEmit"
"test:typecheck": "tsc --noEmit",
"test:unit": "dotenvx run -- vitest run"
},
"dependencies": {
"@agentic/platform": "workspace:*",
@ -34,6 +35,8 @@
"@agentic/platform-hono": "workspace:*",
"@agentic/platform-types": "workspace:*",
"@agentic/platform-validators": "workspace:*",
"@aws-sdk/client-s3": "^3.840.0",
"@aws-sdk/s3-request-presigner": "^3.840.0",
"@dicebear/collection": "catalog:",
"@dicebear/core": "catalog:",
"@fisch0920/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

@ -48,7 +48,8 @@ export function registerV1GitHubOAuthInitFlow(
const state = crypto.randomUUID()
// TODO: unique identifier
// 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`

Wyświetl plik

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

Wyświetl plik

@ -39,6 +39,7 @@ import { registerV1GetPublicProjectByIdentifier } from './projects/get-public-pr
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'
@ -95,6 +96,9 @@ registerV1CreateTeamMember(privateRouter)
registerV1UpdateTeamMember(privateRouter)
registerV1DeleteTeamMember(privateRouter)
// Storage
registerV1GetSignedStorageUploadUrl(privateRouter)
// Public projects
registerV1ListPublicProjects(publicRouter)
registerV1GetPublicProjectByIdentifier(publicRouter) // must be before `registerV1GetPublicProject`

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

@ -1,7 +1,8 @@
import { and, db, eq, gt, isNull, like, or, schema } from '@/db'
import { joinKey, splitKey, type StorageAdapter } from '@/lib/storage'
export function DrizzleAuthStorage(): StorageAdapter {
import { type AuthStorageAdapter, joinKey, splitKey } from './auth-storage'
export function DrizzleAuthStorage(): AuthStorageAdapter {
return {
async get(key: string[]) {
const id = joinKey(key)

Wyświetl plik

@ -12,6 +12,11 @@ export const envSchema = baseEnvSchema
AGENTIC_WEB_BASE_URL: z.string().url(),
AGENTIC_GATEWAY_BASE_URL: z.string().url(),
AGENTIC_STORAGE_BASE_URL: z
.string()
.url()
.optional()
.default('https://storage.agentic.so'),
JWT_SECRET: z.string().nonempty(),
@ -30,7 +35,13 @@ export const envSchema = baseEnvSchema
// Used to simplify recreating the demo `@agentic/search` project during
// development while we're frequently resetting the database
AGENTIC_SEARCH_PROXY_SECRET: z.string().nonempty()
AGENTIC_SEARCH_PROXY_SECRET: z.string().nonempty(),
S3_BUCKET: z.string().nonempty().optional().default('agentic'),
S3_REGION: z.string().nonempty().optional().default('auto'),
S3_ENDPOINT: z.string().nonempty().url(),
S3_ACCESS_KEY_ID: z.string().nonempty(),
S3_ACCESS_KEY_SECRET: z.string().nonempty()
})
.strip()
export type RawEnv = z.infer<typeof envSchema>

Wyświetl plik

@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import {
deleteStorageObject,
getStorageObject,
putStorageObject
} from './storage'
describe('Storage', () => {
it('putObject, getObject, deleteObject', async () => {
if (!process.env.ACCESS_KEY_ID) {
// TODO: ignore on CI
expect(true).toEqual(true)
return
}
await putStorageObject('test.txt', 'hello world', {
ContentType: 'text/plain'
})
const obj = await getStorageObject('test.txt')
expect(obj.ContentType).toEqual('text/plain')
const body = await obj.Body?.transformToString()
expect(body).toEqual('hello world')
const res = await deleteStorageObject('test.txt')
expect(res.$metadata.httpStatusCode).toEqual(204)
})
})

Wyświetl plik

@ -1,46 +1,91 @@
export interface StorageAdapter {
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]>
import {
DeleteObjectCommand,
type DeleteObjectCommandInput,
GetObjectCommand,
type GetObjectCommandInput,
PutObjectCommand,
type PutObjectCommandInput,
S3Client
} from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { env } from './env'
// This storage client is designed to work with any S3-compatible storage provider.
// For Cloudflare R2, see https://developers.cloudflare.com/r2/examples/aws/aws-sdk-js-v3/
const Bucket = env.S3_BUCKET
export const storageClient = new S3Client({
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_ACCESS_KEY_SECRET
}
})
// This ensures that buckets are created automatically if they don't exist on
// Cloudflare R2. It won't affect other providers.
// @see https://developers.cloudflare.com/r2/examples/aws/custom-header/
storageClient.middlewareStack.add(
(next, _) => async (args) => {
const r = args.request as RequestInit
r.headers = {
'cf-create-bucket-if-missing': 'true',
...r.headers
}
return next(args)
},
{ step: 'build', name: 'customHeaders' }
)
export async function getStorageObject(
key: string,
opts?: Omit<GetObjectCommandInput, 'Bucket' | 'Key'>
) {
return storageClient.send(new GetObjectCommand({ Bucket, Key: key, ...opts }))
}
const SEPERATOR = String.fromCodePoint(0x1f)
export function joinKey(key: string[]) {
return key.join(SEPERATOR)
export async function putStorageObject(
key: string,
value: PutObjectCommandInput['Body'],
opts?: Omit<PutObjectCommandInput, 'Bucket' | 'Key' | 'Body'>
) {
return storageClient.send(
new PutObjectCommand({ Bucket, Key: key, Body: value, ...opts })
)
}
export function splitKey(key: string) {
return key.split(SEPERATOR)
export async function deleteStorageObject(
key: string,
opts?: Omit<DeleteObjectCommandInput, 'Bucket' | 'Key'>
) {
return storageClient.send(
new DeleteObjectCommand({ Bucket, Key: key, ...opts })
)
}
export namespace Storage {
function encode(key: string[]) {
return key.map((k) => k.replaceAll(SEPERATOR, ''))
}
export function get<T>(adapter: StorageAdapter, key: string[]) {
return adapter.get(encode(key)) as Promise<T | null>
}
export function set(
adapter: StorageAdapter,
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: StorageAdapter, key: string[]) {
return adapter.remove(encode(key))
}
export function scan<T>(
adapter: StorageAdapter,
key: string[]
): AsyncIterable<[string[], T]> {
return adapter.scan(encode(key))
}
export function getStorageObjectInternalUrl(key: string) {
return `${env.AGENTIC_STORAGE_BASE_URL}/${Bucket}/${key}`
}
export function getStorageObjectPublicUrl(key: string) {
return `${env.AGENTIC_STORAGE_BASE_URL}/${key}`
}
export async function getStorageSignedUploadUrl(
key: string,
{
expiresIn = 5 * 60 * 1000 // 5 minutes
}: {
expiresIn?: number
} = {}
) {
return getSignedUrl(
storageClient,
new PutObjectCommand({ Bucket, Key: key }),
{ expiresIn }
)
}

Wyświetl plik

@ -1,145 +0,0 @@
import { issuer } from '@agentic/openauth'
import { GithubProvider } from '@agentic/openauth/provider/github'
import { PasswordProvider } from '@agentic/openauth/provider/password'
import { assert, pick } from '@agentic/platform-core'
import { isValidPassword } from '@agentic/platform-validators'
import { type RawUser } from '@/db'
import { subjects } from '@/lib/auth/subjects'
import { upsertOrLinkUserAccount } from '@/lib/auth/upsert-or-link-user-account'
import { DrizzleAuthStorage } from '@/lib/drizzle-auth-storage'
import { env } from '@/lib/env'
import { getGitHubClient } from '@/lib/external/github'
import { resend } from './lib/external/resend'
// Initialize OpenAuth issuer which is a Hono app for all auth routes.
// TODO: fix this type...
export const authRouter: any = issuer({
subjects,
storage: DrizzleAuthStorage(),
ttl: {
access: 60 * 60 * 24 * 30, // 30 days
refresh: 60 * 60 * 24 * 365 // 1 year
// Used for creating longer-lived tokens for testing
// access: 60 * 60 * 24 * 366, // 1 year
// refresh: 60 * 60 * 24 * 365 * 5 // 5 years
},
providers: {
github: GithubProvider({
clientID: env.GITHUB_CLIENT_ID,
clientSecret: env.GITHUB_CLIENT_SECRET,
scopes: ['user:email']
}),
password: PasswordProvider({
loginUrl: async () => `${env.WEB_AUTH_BASE_URL}/login`,
registerUrl: async () => `${env.WEB_AUTH_BASE_URL}/signup`,
changeUrl: async () => `${env.WEB_AUTH_BASE_URL}/forgot-password`,
sendCode: async (email, code) => {
// eslint-disable-next-line no-console
console.log('sending verify code email', { email, code })
await resend.sendVerifyCodeEmail({ code, to: email })
},
validatePassword: (password) => {
if (password.length < 3) {
return 'Password must be at least 3 characters'
}
if (password.length > 1024) {
return 'Password must be less than 1024 characters'
}
if (!isValidPassword(password)) {
return 'Invalid password'
}
return undefined
}
})
},
success: async (ctx, value) => {
const { provider } = value
let user: RawUser | undefined
// eslint-disable-next-line no-console
console.log('Auth success', provider, JSON.stringify(value, null, 2))
function getPartialOAuthAccount() {
assert(provider === 'github', `Unsupported OAuth provider "${provider}"`)
const now = Date.now()
return {
provider,
accessToken: value.tokenset.access,
refreshToken: value.tokenset.refresh,
// `expires_in` and `refresh_token_expires_in` are given in seconds
accessTokenExpiresAt: new Date(
now + value.tokenset.raw.expires_in * 1000
),
refreshTokenExpiresAt: new Date(
now + value.tokenset.raw.refresh_token_expires_in * 1000
),
scope: (value.tokenset.raw.scope as string) || undefined
}
}
if (provider === 'github') {
const client = getGitHubClient({ accessToken: value.tokenset.access })
const { data: ghUser } = await client.rest.users.getAuthenticated()
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.'
)
user = await upsertOrLinkUserAccount({
partialAccount: {
accountId: `${ghUser.id}`,
accountUsername: ghUser.login.toLowerCase(),
...getPartialOAuthAccount()
},
partialUser: {
email: ghUser.email,
isEmailVerified: true,
name: ghUser.name || undefined,
username: ghUser.login.toLowerCase(),
image: ghUser.avatar_url
}
})
} else if (provider === 'password') {
user = await upsertOrLinkUserAccount({
partialAccount: {
provider,
accountId: value.email
},
partialUser: {
email: value.email,
isEmailVerified: true
}
})
} else {
assert(
user,
400,
`Authentication error: unsupported auth provider "${provider}"`
)
}
assert(
user,
500,
`Authentication error for auth provider "${provider}": Unexpected error initializing user`
)
return ctx.subject('user', pick(user, 'id', 'username'))
}
})

Wyświetl plik

@ -29,6 +29,7 @@
"@agentic/platform-core": "workspace:*",
"@agentic/platform-types": "workspace:*",
"@standard-schema/spec": "catalog:",
"file-type": "^21.0.0",
"ky": "catalog:",
"type-fest": "catalog:"
},

Wyświetl plik

@ -11,7 +11,8 @@ import type {
User
} from '@agentic/platform-types'
import type { Simplify } from 'type-fest'
import { assert, sanitizeSearchParams } from '@agentic/platform-core'
import { assert, sanitizeSearchParams, sha256 } from '@agentic/platform-core'
import { fileTypeFromBuffer } from 'file-type'
import defaultKy, { type KyInstance } from 'ky'
import type { OnUpdateAuthSessionFunction } from './types'
@ -336,6 +337,103 @@ export class AgenticApiClient {
.json()
}
/**
* 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.
*
* @example
* ```ts
* const { signedUploadUrl, publicObjectUrl } = await client.getSignedStorageUploadUrl({
* projectIdentifier: '@username/my-project',
* key: '9f86d081884c7d659a2feaa0c55ad015a.png'
* })
* ```
*/
async getSignedStorageUploadUrl(
searchParams: OperationParameters<'getSignedStorageUploadUrl'>
): Promise<{
/** The signed upload URL. */
signedUploadUrl: string
/** The public URL the object will have once uploaded. */
publicObjectUrl: string
}> {
return this.ky
.get(`v1/storage/signed-upload-url`, {
searchParams: sanitizeSearchParams(searchParams)
})
.json()
}
/**
* Uploads a file to Agentic's blob storage for a given project.
*
* @example
* ```ts
* const publicObjectUrl = await client.uploadFileToStorage(
* new URL('https://example.com/image.png'),
* { projectIdentifier: '@username/my-project' }
* )
* ```
*/
async uploadFileToStorage(
source: string | ArrayBuffer | URL,
{
projectIdentifier
}: {
projectIdentifier: string
}
): Promise<string> {
let sourceBuffer: ArrayBuffer
if (typeof source === 'string') {
try {
source = new URL(source)
} catch {
// Not a URL
throw new Error(`Invalid source file URL: ${source}`)
}
}
if (source instanceof URL) {
sourceBuffer = await defaultKy.get(source).arrayBuffer()
} else if (source instanceof ArrayBuffer) {
sourceBuffer = source
} else {
throw new Error(`Invalid source file: ${source}`)
}
const [hash, fileType] = await Promise.all([
sha256(sourceBuffer),
fileTypeFromBuffer(sourceBuffer)
])
const key = fileType ? `${hash}.${fileType.ext}` : hash
const { signedUploadUrl, publicObjectUrl } =
await this.getSignedStorageUploadUrl({
projectIdentifier,
key
})
try {
// Check if the object already exists.
await defaultKy.head(publicObjectUrl)
} catch {
// Object doesn't exist yet, so upload it.
await defaultKy.post(signedUploadUrl, {
body: sourceBuffer,
headers: {
'Content-Type': fileType?.mime ?? 'application/octet-stream'
}
})
}
return publicObjectUrl
}
/** Lists projects that have been published publicly to the marketplace. */
async listPublicProjects<
TPopulate extends NonNullable<

Wyświetl plik

@ -247,6 +247,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/v1/storage/signed-upload-url": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** @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. */
get: operations["getSignedStorageUploadUrl"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/v1/projects": {
parameters: {
query?: never;
@ -1811,6 +1828,46 @@ export interface operations {
404: components["responses"]["404"];
};
};
getSignedStorageUploadUrl: {
parameters: {
query: {
/** @description Public project identifier (e.g. "@namespace/project-slug") */
projectIdentifier: components["schemas"]["ProjectIdentifier"];
/** @description Should be a hash of the contents of the file to upload with the correct file extension (eg, "9f86d081884c7d659a2feaa0c55ad015a.png"). */
key: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description A signed upload URL */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
/**
* Format: uri
* @description The signed upload URL.
*/
signedUploadUrl: string;
/**
* Format: uri
* @description The public URL the object will have once uploaded.
*/
publicObjectUrl: string;
};
};
};
400: components["responses"]["400"];
401: components["responses"]["401"];
403: components["responses"]["403"];
404: components["responses"]["404"];
};
};
listProjects: {
parameters: {
query?: {

Plik diff jest za duży Load Diff