kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: WIP add better-auth
rodzic
6a19da284a
commit
07dc9e60d1
|
@ -1,6 +0,0 @@
|
|||
# ------------------------------------------------------------------------------
|
||||
# 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.
|
||||
# ------------------------------------------------------------------------------
|
|
@ -7,10 +7,15 @@
|
|||
|
||||
DATABASE_URL=
|
||||
|
||||
JWT_SECRET=
|
||||
BETTER_AUTH_SECRET=
|
||||
BETTER_AUTH_URL=
|
||||
|
||||
JWT_SECRET=
|
||||
SENTRY_DSN=
|
||||
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
|
|
@ -34,6 +34,7 @@
|
|||
"@hono/zod-openapi": "^0.19.6",
|
||||
"@redocly/openapi-core": "^1.34.3",
|
||||
"@sentry/node": "^9.19.0",
|
||||
"better-auth": "^1.2.8",
|
||||
"eventid": "^2.0.1",
|
||||
"exit-hook": "catalog:",
|
||||
"hono": "^4.7.9",
|
||||
|
|
|
@ -2,6 +2,7 @@ import { OpenAPIHono } from '@hono/zod-openapi'
|
|||
import { fromError } from 'zod-validation-error'
|
||||
|
||||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import { auth } from '@/lib/auth'
|
||||
import * as middleware from '@/lib/middleware'
|
||||
import { registerOpenAPIErrorResponses } from '@/lib/openapi-utils'
|
||||
|
||||
|
@ -17,6 +18,7 @@ import { registerV1DeploymentsListDeployments } from './deployments/list-deploym
|
|||
import { registerV1DeploymentsPublishDeployment } from './deployments/publish-deployment'
|
||||
import { registerV1DeploymentsUpdateDeployment } from './deployments/update-deployment'
|
||||
import { registerHealthCheck } from './health-check'
|
||||
// import { registerV1OAuthRedirect } from './oauth-redirect'
|
||||
import { registerV1ProjectsCreateProject } from './projects/create-project'
|
||||
import { registerV1ProjectsGetProject } from './projects/get-project'
|
||||
import { registerV1ProjectsListProjects } from './projects/list-projects'
|
||||
|
@ -98,11 +100,16 @@ registerV1DeploymentsUpdateDeployment(privateRouter)
|
|||
registerV1DeploymentsListDeployments(privateRouter)
|
||||
registerV1DeploymentsPublishDeployment(privateRouter)
|
||||
|
||||
// Internal admin routes
|
||||
registerV1AdminConsumersGetConsumerByToken(privateRouter)
|
||||
|
||||
// Webhook event handlers
|
||||
registerV1StripeWebhook(publicRouter)
|
||||
|
||||
// Admin routes
|
||||
registerV1AdminConsumersGetConsumerByToken(privateRouter)
|
||||
// OAuth redirect
|
||||
// registerV1OAuthRedirect(publicRouter)
|
||||
|
||||
publicRouter.on(['POST', 'GET'], 'auth/**', (c) => auth.handler(c.req.raw))
|
||||
|
||||
// Setup routes and middleware
|
||||
apiV1.route('/', publicRouter)
|
||||
|
|
|
@ -0,0 +1,42 @@
|
|||
import type { OpenAPIHono } from '@hono/zod-openapi'
|
||||
import { assert } from '@agentic/platform-core'
|
||||
|
||||
// TODO: Unused in favor of `better-auth`
|
||||
export function registerV1OAuthRedirect(app: OpenAPIHono) {
|
||||
return app.all('oauth', async (ctx) => {
|
||||
if (ctx.req.query('state')) {
|
||||
const { state: state64, ...query } = ctx.req.query()
|
||||
|
||||
// google oauth + others
|
||||
const { uri, ...state } = JSON.parse(
|
||||
Buffer.from(state64!, 'base64').toString()
|
||||
) as any
|
||||
|
||||
assert(
|
||||
uri,
|
||||
404,
|
||||
`Error oauth redirect not found "${new URLSearchParams(ctx.req.query()).toString()}"`
|
||||
)
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
...state,
|
||||
...query
|
||||
})
|
||||
|
||||
ctx.redirect(`${uri}?${searchParams.toString()}`)
|
||||
} else {
|
||||
// github oauth
|
||||
// https://developer.github.com/apps/building-oauth-apps/authorizing-oauth-apps/#redirect-urls
|
||||
const { uri, ...params } = ctx.req.query()
|
||||
|
||||
assert(
|
||||
uri,
|
||||
404,
|
||||
`Error oauth redirect not found "${new URLSearchParams(ctx.req.query()).toString()}"`
|
||||
)
|
||||
|
||||
const searchParams = new URLSearchParams(params)
|
||||
ctx.redirect(`${uri}?${searchParams.toString()}`)
|
||||
}
|
||||
})
|
||||
}
|
|
@ -1,27 +0,0 @@
|
|||
import type { hc } from 'hono/client'
|
||||
import { expectTypeOf, test } from 'vitest'
|
||||
|
||||
import type { User } from '@/db'
|
||||
|
||||
import type { ApiRoutes } from './index'
|
||||
|
||||
type ApiClient = ReturnType<typeof hc<ApiRoutes>>
|
||||
|
||||
type GetUserResponse = Awaited<
|
||||
ReturnType<Awaited<ReturnType<ApiClient['users'][':userId']['$get']>>['json']>
|
||||
>
|
||||
|
||||
test('User types are compatible', async () => {
|
||||
expectTypeOf<GetUserResponse>().toEqualTypeOf<User>()
|
||||
|
||||
// const client = hc<ApiRoutes>('http://localhost:3000/v1')
|
||||
|
||||
// const user = await client.users[':userId'].$post({
|
||||
// param: {
|
||||
// userId: '123'
|
||||
// },
|
||||
// json: {
|
||||
// firstName: 'John'
|
||||
// }
|
||||
// })
|
||||
})
|
|
@ -12,7 +12,7 @@ const relevantStripeEvents = new Set<Stripe.Event.Type>([
|
|||
])
|
||||
|
||||
export function registerV1StripeWebhook(app: OpenAPIHono) {
|
||||
return app.post('/webhooks/stripe', async (ctx) => {
|
||||
return app.post('webhooks/stripe', async (ctx) => {
|
||||
const body = await ctx.req.text()
|
||||
const signature = ctx.req.header('Stripe-Signature')
|
||||
assert(signature, 400, 'missing signature')
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
// import { validators } from '@agentic/platform-validators'
|
||||
import { betterAuth } from 'better-auth'
|
||||
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
|
||||
|
||||
import { createIdForModel, db } from '@/db'
|
||||
|
||||
import { env } from './env'
|
||||
|
||||
export const auth = betterAuth({
|
||||
adapter: drizzleAdapter(db, {
|
||||
provider: 'pg'
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true
|
||||
},
|
||||
socialProviders: {
|
||||
github: {
|
||||
clientId: env.GITHUB_CLIENT_ID,
|
||||
clientSecret: env.GITHUB_CLIENT_SECRET
|
||||
}
|
||||
},
|
||||
user: {
|
||||
modelName: 'users',
|
||||
additionalFields: {
|
||||
role: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
defaultValue: 'user',
|
||||
input: false // don't allow user to set role
|
||||
},
|
||||
// username: {
|
||||
// type: 'string',
|
||||
// required: false
|
||||
// },
|
||||
stripeCustomerId: {
|
||||
type: 'string',
|
||||
required: false
|
||||
}
|
||||
}
|
||||
},
|
||||
session: {
|
||||
modelName: 'sessions'
|
||||
},
|
||||
account: {
|
||||
modelName: 'accounts'
|
||||
},
|
||||
verification: {
|
||||
modelName: 'verifications'
|
||||
},
|
||||
advanced: {
|
||||
database: {
|
||||
generateId: ({ model }) => createIdForModel(model as any)
|
||||
}
|
||||
}
|
||||
// TODO
|
||||
// plugins: [
|
||||
// username({
|
||||
// usernameValidator: validators.username
|
||||
// })
|
||||
// ]
|
||||
})
|
|
@ -12,14 +12,19 @@ export const envSchema = z.object({
|
|||
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
BETTER_AUTH_SECRET: z.string().nonempty(),
|
||||
BETTER_AUTH_URL: z.string().url(),
|
||||
|
||||
JWT_SECRET: z.string().nonempty(),
|
||||
SENTRY_DSN: z.string().url(),
|
||||
PORT: z.number().default(3000),
|
||||
LOG_LEVEL: logLevelsSchema.default('info'),
|
||||
|
||||
STRIPE_SECRET_KEY: z.string().nonempty(),
|
||||
STRIPE_PUBLISHABLE_KEY: z.string().nonempty(),
|
||||
STRIPE_WEBHOOK_SECRET: z.string().nonempty()
|
||||
STRIPE_WEBHOOK_SECRET: z.string().nonempty(),
|
||||
|
||||
GITHUB_CLIENT_ID: z.string().nonempty(),
|
||||
GITHUB_CLIENT_SECRET: z.string().nonempty()
|
||||
})
|
||||
export type Env = z.infer<typeof envSchema>
|
||||
|
||||
|
@ -32,4 +37,4 @@ export const isDev = env.NODE_ENV === 'development'
|
|||
export const isProd = env.NODE_ENV === 'production'
|
||||
export const isBrowser = (globalThis as any).window !== undefined
|
||||
|
||||
export const isStripeLive = env.STRIPE_PUBLISHABLE_KEY.startsWith('pk_live_')
|
||||
export const isStripeLive = env.STRIPE_SECRET_KEY.startsWith('sk_live_')
|
||||
|
|
|
@ -0,0 +1,158 @@
|
|||
import ky from 'ky'
|
||||
|
||||
import { env } from './env'
|
||||
|
||||
const USER_AGENT = 'agentic-platform'
|
||||
|
||||
/**
|
||||
* GitHub (user-level) OAuth token response.
|
||||
*
|
||||
* @see https://docs.github.com/apps/oauth
|
||||
*/
|
||||
export interface GitHubUserTokenResponse {
|
||||
/**
|
||||
* The user access token (always starts with `ghu_`).
|
||||
* Example: `ghu_xxx…`
|
||||
*/
|
||||
access_token: string
|
||||
|
||||
/**
|
||||
* Seconds until `access_token` expires.
|
||||
* Omitted (`undefined`) if you’ve disabled token expiration.
|
||||
* Constant `28800` (8 hours) when present.
|
||||
*/
|
||||
expires_in?: number
|
||||
|
||||
/**
|
||||
* Refresh token for renewing the user access token (starts with `ghr_`).
|
||||
* Omitted (`undefined`) if you’ve disabled token expiration.
|
||||
*/
|
||||
refresh_token?: string
|
||||
|
||||
/**
|
||||
* Seconds until `refresh_token` expires.
|
||||
* Omitted (`undefined`) if you’ve disabled token expiration.
|
||||
* Constant `15897600` (6 months) when present.
|
||||
*/
|
||||
refresh_token_expires_in?: number
|
||||
|
||||
/**
|
||||
* Scopes granted to the token.
|
||||
* Always an empty string because the token is limited to
|
||||
* the intersection of app-level and user-level permissions.
|
||||
*/
|
||||
scope: ''
|
||||
|
||||
/**
|
||||
* Token type – always `'bearer'`.
|
||||
*/
|
||||
token_type: 'bearer'
|
||||
}
|
||||
|
||||
export interface GitHubUser {
|
||||
login: string
|
||||
id: number
|
||||
user_view_type?: string
|
||||
node_id: string
|
||||
avatar_url: string
|
||||
gravatar_id: string | null
|
||||
url: string
|
||||
html_url: string
|
||||
followers_url: string
|
||||
following_url: string
|
||||
gists_url: string
|
||||
starred_url: string
|
||||
subscriptions_url: string
|
||||
organizations_url: string
|
||||
repos_url: string
|
||||
events_url: string
|
||||
received_events_url: string
|
||||
type: string
|
||||
site_admin: boolean
|
||||
name: string | null
|
||||
company: string | null
|
||||
blog: string | null
|
||||
location: string | null
|
||||
email: string | null
|
||||
notification_email?: string | null
|
||||
hireable: boolean | null
|
||||
bio: string | null
|
||||
twitter_username?: string | null
|
||||
public_repos: number
|
||||
public_gists: number
|
||||
followers: number
|
||||
following: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
plan?: {
|
||||
collaborators: number
|
||||
name: string
|
||||
space: number
|
||||
private_repos: number
|
||||
[k: string]: unknown
|
||||
}
|
||||
private_gists?: number
|
||||
total_private_repos?: number
|
||||
owned_private_repos?: number
|
||||
disk_usage?: number
|
||||
collaborators?: number
|
||||
}
|
||||
|
||||
export interface GitHubUserEmail {
|
||||
email: string
|
||||
primary: boolean
|
||||
verified: boolean
|
||||
visibility?: string | null
|
||||
}
|
||||
|
||||
export async function exchangeOAuthCodeForAccessToken({
|
||||
code,
|
||||
clientId = env.GITHUB_CLIENT_ID,
|
||||
clientSecret = env.GITHUB_CLIENT_SECRET,
|
||||
redirectUri
|
||||
}: {
|
||||
code: string
|
||||
clientId?: string
|
||||
clientSecret?: string
|
||||
redirectUri?: string
|
||||
}): Promise<GitHubUserTokenResponse> {
|
||||
return ky
|
||||
.post('https://github.com/login/oauth/access_token', {
|
||||
json: {
|
||||
code,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri
|
||||
},
|
||||
headers: {
|
||||
'user-agent': USER_AGENT
|
||||
}
|
||||
})
|
||||
.json<GitHubUserTokenResponse>()
|
||||
}
|
||||
|
||||
export async function getMe({ token }: { token: string }): Promise<GitHubUser> {
|
||||
return ky
|
||||
.get('https://api.github.com/user', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'user-agent': USER_AGENT
|
||||
}
|
||||
})
|
||||
.json<GitHubUser>()
|
||||
}
|
||||
|
||||
export async function getUserEmails({
|
||||
token
|
||||
}: {
|
||||
token: string
|
||||
}): Promise<GitHubUserEmail[]> {
|
||||
return ky
|
||||
.get('https://api.github.com/user/emails', {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'user-agent': USER_AGENT
|
||||
}
|
||||
})
|
||||
.json<GitHubUserEmail[]>()
|
||||
}
|
|
@ -1,42 +1,55 @@
|
|||
import { assert } from '@agentic/platform-core'
|
||||
import { createMiddleware } from 'hono/factory'
|
||||
import * as jwt from 'hono/jwt'
|
||||
|
||||
// import * as jwt from 'hono/jwt'
|
||||
import type { AuthenticatedEnv } from '@/lib/types'
|
||||
import { db, eq, schema } from '@/db'
|
||||
import { env } from '@/lib/env'
|
||||
import { auth } from '@/lib/auth'
|
||||
|
||||
export const authenticate = createMiddleware<AuthenticatedEnv>(
|
||||
async function authenticateMiddleware(ctx, next) {
|
||||
const credentials = ctx.req.raw.headers.get('Authorization')
|
||||
assert(credentials, 401, 'Unauthorized')
|
||||
|
||||
const parts = credentials.split(/\s+/)
|
||||
assert(
|
||||
parts.length === 1 ||
|
||||
(parts.length === 2 && parts[0]?.toLowerCase() === 'bearer'),
|
||||
401,
|
||||
'Unauthorized'
|
||||
)
|
||||
const token = parts.at(-1)
|
||||
assert(token, 401, 'Unauthorized')
|
||||
|
||||
const payload = await jwt.verify(token, env.JWT_SECRET)
|
||||
assert(payload, 401, 'Unauthorized')
|
||||
assert(payload.type === 'user', 401, 'Unauthorized')
|
||||
assert(
|
||||
payload.userId && typeof payload.userId === 'string',
|
||||
401,
|
||||
'Unauthorized'
|
||||
)
|
||||
ctx.set('userId', payload.userId)
|
||||
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.id, payload.userId)
|
||||
const session = await auth.api.getSession({
|
||||
// TODO: investigate this type issue
|
||||
headers: ctx.req.raw.headers as any
|
||||
})
|
||||
assert(user, 401, 'Unauthorized')
|
||||
ctx.set('user', user as any)
|
||||
assert(session, 401, 'Unauthorized')
|
||||
assert(session.user?.id, 401, 'Unauthorized')
|
||||
assert(session.session, 401, 'Unauthorized')
|
||||
|
||||
ctx.set('userId', session.user.id)
|
||||
ctx.set('user', session.user)
|
||||
ctx.set('session', session.session)
|
||||
|
||||
await next()
|
||||
|
||||
// const credentials = ctx.req.raw.headers.get('Authorization')
|
||||
// assert(credentials, 401, 'Unauthorized')
|
||||
|
||||
// const parts = credentials.split(/\s+/)
|
||||
// assert(
|
||||
// parts.length === 1 ||
|
||||
// (parts.length === 2 && parts[0]?.toLowerCase() === 'bearer'),
|
||||
// 401,
|
||||
// 'Unauthorized'
|
||||
// )
|
||||
// const token = parts.at(-1)
|
||||
// assert(token, 401, 'Unauthorized')
|
||||
|
||||
// const payload = await jwt.verify(token, env.JWT_SECRET)
|
||||
// assert(payload, 401, 'Unauthorized')
|
||||
// assert(payload.type === 'user', 401, 'Unauthorized')
|
||||
// assert(
|
||||
// payload.userId && typeof payload.userId === 'string',
|
||||
// 401,
|
||||
// 'Unauthorized'
|
||||
// )
|
||||
// ctx.set('userId', payload.userId)
|
||||
|
||||
// const user = await db.query.users.findFirst({
|
||||
// where: eq(schema.users.id, payload.userId)
|
||||
// })
|
||||
// assert(user, 401, 'Unauthorized')
|
||||
// ctx.set('user', user as any)
|
||||
|
||||
// await next()
|
||||
}
|
||||
)
|
||||
|
|
|
@ -12,13 +12,13 @@ import { aclTeamMember } from '@/lib/acl-team-member'
|
|||
export const team = createMiddleware<AuthenticatedEnv>(
|
||||
async function teamMiddleware(ctx, next) {
|
||||
const teamId = ctx.req.query('teamId')
|
||||
const user = ctx.get('user')
|
||||
const userId = ctx.get('userId')
|
||||
|
||||
if (teamId && user) {
|
||||
if (teamId && userId) {
|
||||
const teamMember = await db.query.teamMembers.findFirst({
|
||||
where: and(
|
||||
eq(schema.teamMembers.teamId, teamId),
|
||||
eq(schema.teamMembers.userId, user.id)
|
||||
eq(schema.teamMembers.userId, userId)
|
||||
)
|
||||
})
|
||||
assert(teamMember, 403, 'Unauthorized')
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import type { Context } from 'hono'
|
||||
|
||||
import type { RawTeamMember, RawUser } from '@/db'
|
||||
import type { RawTeamMember } from '@/db'
|
||||
|
||||
import type { auth } from './auth'
|
||||
import type { Env } from './env'
|
||||
import type { Logger } from './logger'
|
||||
|
||||
|
@ -10,6 +11,9 @@ export type { OpenAPI3 as LooseOpenAPI3Spec } from 'openapi-typescript'
|
|||
export type Environment = Env['NODE_ENV']
|
||||
export type Service = 'api'
|
||||
|
||||
export type AuthUser = typeof auth.$Infer.Session.user
|
||||
export type AuthSession = typeof auth.$Infer.Session.session
|
||||
|
||||
export type DefaultEnvVariables = {
|
||||
requestId: string
|
||||
logger: Logger
|
||||
|
@ -17,7 +21,8 @@ export type DefaultEnvVariables = {
|
|||
|
||||
export type AuthenticatedEnvVariables = DefaultEnvVariables & {
|
||||
userId: string
|
||||
user?: RawUser
|
||||
user?: AuthUser
|
||||
session?: AuthSession
|
||||
teamMember?: RawTeamMember
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import type { Simplify } from 'type-fest'
|
||||
import { getEnv, sanitizeSearchParams } from '@agentic/platform-core'
|
||||
import defaultKy, { type KyInstance } from 'ky'
|
||||
|
||||
import type { operations } from './openapi'
|
||||
import { getEnv, sanitizeSearchParams } from './utils'
|
||||
|
||||
export class AgenticApiClient {
|
||||
static readonly DEFAULT_API_BASE_URL = 'https://api.agentic.so'
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
export function getEnv(name: string): string | undefined {
|
||||
try {
|
||||
return typeof process !== 'undefined'
|
||||
? // eslint-disable-next-line no-process-env
|
||||
process.env?.[name]
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new `URLSearchParams` object with all values coerced to strings
|
||||
* that correctly handles arrays of values as repeated keys (or CSV) and
|
||||
* correctly removes `undefined` keys and values.
|
||||
*/
|
||||
export function sanitizeSearchParams(
|
||||
searchParams:
|
||||
| Record<
|
||||
string,
|
||||
string | number | boolean | string[] | number[] | boolean[] | undefined
|
||||
>
|
||||
| object,
|
||||
{
|
||||
csv = false
|
||||
}: {
|
||||
/**
|
||||
* Whether to use comma-separated-values for arrays or multiple entries.
|
||||
*
|
||||
* Defaults to `false` and will use multiple entries.
|
||||
*/
|
||||
csv?: boolean
|
||||
} = {}
|
||||
): URLSearchParams {
|
||||
const entries = Object.entries(searchParams).flatMap(([key, value]) => {
|
||||
if (key === undefined || value === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => [key, String(v)])
|
||||
}
|
||||
|
||||
return [[key, String(value)]]
|
||||
}) as [string, string][]
|
||||
|
||||
if (!csv) {
|
||||
return new URLSearchParams(entries)
|
||||
}
|
||||
|
||||
const csvEntries: Record<string, string> = {}
|
||||
for (const [key, value] of entries) {
|
||||
csvEntries[key] = csvEntries[key] ? `${csvEntries[key]},${value}` : value
|
||||
}
|
||||
|
||||
return new URLSearchParams(csvEntries)
|
||||
}
|
|
@ -115,3 +115,61 @@ export function hashObject(
|
|||
...options
|
||||
})
|
||||
}
|
||||
|
||||
export function getEnv(name: string): string | undefined {
|
||||
try {
|
||||
return typeof process !== 'undefined'
|
||||
? // eslint-disable-next-line no-process-env
|
||||
process.env?.[name]
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new `URLSearchParams` object with all values coerced to strings
|
||||
* that correctly handles arrays of values as repeated keys (or CSV) and
|
||||
* correctly removes `undefined` keys and values.
|
||||
*/
|
||||
export function sanitizeSearchParams(
|
||||
searchParams:
|
||||
| Record<
|
||||
string,
|
||||
string | number | boolean | string[] | number[] | boolean[] | undefined
|
||||
>
|
||||
| object,
|
||||
{
|
||||
csv = false
|
||||
}: {
|
||||
/**
|
||||
* Whether to use comma-separated-values for arrays or multiple entries.
|
||||
*
|
||||
* Defaults to `false` and will use multiple entries.
|
||||
*/
|
||||
csv?: boolean
|
||||
} = {}
|
||||
): URLSearchParams {
|
||||
const entries = Object.entries(searchParams).flatMap(([key, value]) => {
|
||||
if (key === undefined || value === undefined) {
|
||||
return []
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((v) => [key, String(v)])
|
||||
}
|
||||
|
||||
return [[key, String(value)]]
|
||||
}) as [string, string][]
|
||||
|
||||
if (!csv) {
|
||||
return new URLSearchParams(entries)
|
||||
}
|
||||
|
||||
const csvEntries: Record<string, string> = {}
|
||||
for (const [key, value] of entries) {
|
||||
csvEntries[key] = csvEntries[key] ? `${csvEntries[key]},${value}` : value
|
||||
}
|
||||
|
||||
return new URLSearchParams(csvEntries)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,50 @@
|
|||
import { pgTable, text, timestamp } from '@fisch0920/drizzle-orm/pg-core'
|
||||
|
||||
import {
|
||||
accountPrimaryId,
|
||||
sessionPrimaryId,
|
||||
timestamps,
|
||||
userId,
|
||||
verificationPrimaryId
|
||||
} from './common'
|
||||
import { users } from './user'
|
||||
|
||||
// These tables are all managed by better-auth.
|
||||
|
||||
export const sessions = pgTable('sessions', {
|
||||
...sessionPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
expiresAt: timestamp('expiresAt').notNull(),
|
||||
ipAddress: text('ipAddress'),
|
||||
userAgent: text('userAgent'),
|
||||
userId: userId()
|
||||
.notNull()
|
||||
.references(() => users.id)
|
||||
})
|
||||
|
||||
export const accounts = pgTable('accounts', {
|
||||
...accountPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
accountId: text('accountId').notNull(),
|
||||
providerId: text('providerId').notNull(),
|
||||
userId: userId()
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
accessToken: text('accessToken'),
|
||||
refreshToken: text('refreshToken'),
|
||||
idToken: text('idToken'),
|
||||
expiresAt: timestamp('expiresAt').notNull(),
|
||||
password: text('password')
|
||||
})
|
||||
|
||||
export const verifications = pgTable('verifications', {
|
||||
...verificationPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
identifier: text('identifier').notNull(),
|
||||
value: text('value').notNull(),
|
||||
|
||||
expiresAt: timestamp('expiresAt').notNull()
|
||||
})
|
|
@ -16,19 +16,31 @@ import { createId as createCuid2 } from '@paralleldrive/cuid2'
|
|||
|
||||
const usernameAndTeamSlugLength = 64 as const
|
||||
|
||||
// prefix is max 4 characters
|
||||
// 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 = {
|
||||
user: 'user',
|
||||
team: 'team',
|
||||
project: 'proj',
|
||||
deployment: 'depl',
|
||||
consumer: 'csmr',
|
||||
logEntry: 'log'
|
||||
logEntry: 'log',
|
||||
|
||||
// better-auth
|
||||
user: 'user',
|
||||
account: 'acct',
|
||||
session: 'sess',
|
||||
verification: 'veri',
|
||||
'rate-limit': 'ratel',
|
||||
organization: 'org',
|
||||
member: 'mem',
|
||||
invitation: 'inv',
|
||||
jwks: 'jwks',
|
||||
passkey: 'passk',
|
||||
'two-factor': '2fa'
|
||||
} as const
|
||||
|
||||
export type ModelType = keyof typeof idPrefixMap
|
||||
|
@ -57,6 +69,9 @@ export const consumerPrimaryId = getPrimaryId('consumer')
|
|||
export const logEntryPrimaryId = getPrimaryId('logEntry')
|
||||
export const teamPrimaryId = getPrimaryId('team')
|
||||
export const userPrimaryId = getPrimaryId('user')
|
||||
export const sessionPrimaryId = getPrimaryId('session')
|
||||
export const accountPrimaryId = getPrimaryId('account')
|
||||
export const verificationPrimaryId = getPrimaryId('verification')
|
||||
|
||||
/**
|
||||
* All of our model primary ids have the following format:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
export * from './auth'
|
||||
export * from './common'
|
||||
export * from './consumer'
|
||||
export * from './deployment'
|
||||
|
|
|
@ -1,61 +1,61 @@
|
|||
import { z } from '@hono/zod-openapi'
|
||||
import parseJson from 'parse-json'
|
||||
|
||||
export const authProviderTypeSchema = z
|
||||
.union([
|
||||
z.literal('github'),
|
||||
z.literal('google'),
|
||||
z.literal('spotify'),
|
||||
z.literal('twitter'),
|
||||
z.literal('linkedin'),
|
||||
z.literal('stripe')
|
||||
])
|
||||
.openapi('AuthProviderType')
|
||||
export type AuthProviderType = z.infer<typeof authProviderTypeSchema>
|
||||
// export const authProviderTypeSchema = z
|
||||
// .union([
|
||||
// z.literal('github'),
|
||||
// z.literal('google'),
|
||||
// z.literal('spotify'),
|
||||
// z.literal('twitter'),
|
||||
// z.literal('linkedin'),
|
||||
// z.literal('stripe')
|
||||
// ])
|
||||
// .openapi('AuthProviderType')
|
||||
// export type AuthProviderType = z.infer<typeof authProviderTypeSchema>
|
||||
|
||||
export const authProviderSchema = z.object({
|
||||
provider: authProviderTypeSchema,
|
||||
// export const authProviderSchema = z.object({
|
||||
// provider: authProviderTypeSchema,
|
||||
|
||||
/** Provider-specific user id */
|
||||
id: z.string(),
|
||||
// /** Provider-specific user id */
|
||||
// id: z.string(),
|
||||
|
||||
/** Provider-specific username */
|
||||
username: z.string().optional(),
|
||||
// /** Provider-specific username */
|
||||
// username: z.string().optional(),
|
||||
|
||||
/** Standard oauth2 access token */
|
||||
accessToken: z.string().optional(),
|
||||
// /** Standard oauth2 access token */
|
||||
// accessToken: z.string().optional(),
|
||||
|
||||
/** Standard oauth2 refresh token */
|
||||
refreshToken: z.string().optional(),
|
||||
// /** Standard oauth2 refresh token */
|
||||
// refreshToken: z.string().optional(),
|
||||
|
||||
/** Stripe public key */
|
||||
publicKey: z.string().optional(),
|
||||
// /** Stripe public key */
|
||||
// publicKey: z.string().optional(),
|
||||
|
||||
/** OAuth scope(s) */
|
||||
scope: z.string().optional()
|
||||
})
|
||||
export type AuthProvider = z.infer<typeof authProviderSchema>
|
||||
// /** OAuth scope(s) */
|
||||
// scope: z.string().optional()
|
||||
// })
|
||||
// export type AuthProvider = z.infer<typeof authProviderSchema>
|
||||
|
||||
export const publicAuthProviderSchema = authProviderSchema
|
||||
.omit({
|
||||
accessToken: true,
|
||||
refreshToken: true,
|
||||
publicKey: true
|
||||
})
|
||||
.strip()
|
||||
.openapi('AuthProvider')
|
||||
export type PublicAuthProvider = z.infer<typeof publicAuthProviderSchema>
|
||||
// export const publicAuthProviderSchema = authProviderSchema
|
||||
// .omit({
|
||||
// accessToken: true,
|
||||
// refreshToken: true,
|
||||
// publicKey: true
|
||||
// })
|
||||
// .strip()
|
||||
// .openapi('AuthProvider')
|
||||
// export type PublicAuthProvider = z.infer<typeof publicAuthProviderSchema>
|
||||
|
||||
export const authProvidersSchema = z.record(
|
||||
authProviderTypeSchema,
|
||||
authProviderSchema.optional()
|
||||
)
|
||||
export type AuthProviders = z.infer<typeof authProvidersSchema>
|
||||
// export const authProvidersSchema = z.record(
|
||||
// authProviderTypeSchema,
|
||||
// authProviderSchema.optional()
|
||||
// )
|
||||
// export type AuthProviders = z.infer<typeof authProvidersSchema>
|
||||
|
||||
export const publicAuthProvidersSchema = z
|
||||
.record(authProviderTypeSchema, publicAuthProviderSchema.optional())
|
||||
.openapi('AuthProviders')
|
||||
export type PublicAuthProviders = z.infer<typeof publicAuthProvidersSchema>
|
||||
// export const publicAuthProvidersSchema = z
|
||||
// .record(authProviderTypeSchema, publicAuthProviderSchema.optional())
|
||||
// .openapi('AuthProviders')
|
||||
// export type PublicAuthProviders = z.infer<typeof publicAuthProvidersSchema>
|
||||
|
||||
export const webhookSchema = z
|
||||
.object({
|
||||
|
|
|
@ -1,64 +1,46 @@
|
|||
import { sha256 } from '@agentic/platform-core'
|
||||
import { validators } from '@agentic/platform-validators'
|
||||
import { relations } from '@fisch0920/drizzle-orm'
|
||||
import {
|
||||
boolean,
|
||||
index,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
uniqueIndex
|
||||
} from '@fisch0920/drizzle-orm/pg-core'
|
||||
import { hashSync } from 'bcryptjs'
|
||||
|
||||
import {
|
||||
createInsertSchema,
|
||||
createSelectSchema,
|
||||
createUpdateSchema,
|
||||
stripeId,
|
||||
timestamp,
|
||||
timestamps,
|
||||
username,
|
||||
// username,
|
||||
userPrimaryId,
|
||||
userRoleEnum
|
||||
} from './common'
|
||||
import { type AuthProviders, publicAuthProvidersSchema } from './schemas'
|
||||
import { teams } from './team'
|
||||
|
||||
// This table is mostly managed by better-auth.
|
||||
|
||||
export const users = pgTable(
|
||||
'users',
|
||||
{
|
||||
...userPrimaryId,
|
||||
...timestamps,
|
||||
|
||||
username: username().notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
email: text('email').notNull().unique(),
|
||||
emailVerified: boolean('emailVerified').default(false).notNull(),
|
||||
image: text('image'),
|
||||
|
||||
// TODO: re-add username
|
||||
// username: username().notNull().unique(),
|
||||
role: userRoleEnum().default('user').notNull(),
|
||||
|
||||
email: text().unique(),
|
||||
password: text(),
|
||||
|
||||
// metadata
|
||||
firstName: text(),
|
||||
lastName: text(),
|
||||
image: text(),
|
||||
|
||||
emailConfirmed: boolean().default(false).notNull(),
|
||||
emailConfirmedAt: timestamp(),
|
||||
emailConfirmToken: text().unique().notNull(),
|
||||
passwordResetToken: text().unique(),
|
||||
|
||||
isStripeConnectEnabledByDefault: boolean().default(true).notNull(),
|
||||
|
||||
// third-party auth providers
|
||||
authProviders: jsonb().$type<AuthProviders>().default({}).notNull(),
|
||||
|
||||
stripeCustomerId: stripeId()
|
||||
},
|
||||
(table) => [
|
||||
uniqueIndex('user_email_idx').on(table.email),
|
||||
uniqueIndex('user_username_idx').on(table.username),
|
||||
uniqueIndex('user_emailConfirmToken_idx').on(table.emailConfirmToken),
|
||||
uniqueIndex('user_passwordResetToken_idx').on(table.passwordResetToken),
|
||||
// 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)
|
||||
|
@ -70,49 +52,49 @@ export const usersRelations = relations(users, ({ many }) => ({
|
|||
}))
|
||||
|
||||
export const userSelectSchema = createSelectSchema(users, {
|
||||
authProviders: publicAuthProvidersSchema
|
||||
// authProviders: publicAuthProvidersSchema
|
||||
})
|
||||
.omit({ password: true, emailConfirmToken: true, passwordResetToken: true })
|
||||
// .omit({ password: true, emailConfirmToken: true, passwordResetToken: true })
|
||||
.strip()
|
||||
.openapi('User')
|
||||
|
||||
export const userInsertSchema = createInsertSchema(users, {
|
||||
username: (schema) =>
|
||||
schema.refine((username) => validators.username(username), {
|
||||
message: 'Invalid username'
|
||||
}),
|
||||
// export const userInsertSchema = createInsertSchema(users, {
|
||||
// username: (schema) =>
|
||||
// schema.refine((username) => validators.username(username), {
|
||||
// message: 'Invalid username'
|
||||
// }),
|
||||
|
||||
email: (schema) => schema.email().optional()
|
||||
})
|
||||
.pick({
|
||||
username: true,
|
||||
email: true,
|
||||
password: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
image: true
|
||||
})
|
||||
.strict()
|
||||
.transform((user) => {
|
||||
return {
|
||||
...user,
|
||||
emailConfirmToken: sha256(),
|
||||
password: user.password ? hashSync(user.password) : undefined
|
||||
}
|
||||
})
|
||||
// email: (schema) => schema.email().optional()
|
||||
// })
|
||||
// .pick({
|
||||
// username: true,
|
||||
// email: true,
|
||||
// password: true,
|
||||
// firstName: true,
|
||||
// lastName: true,
|
||||
// image: true
|
||||
// })
|
||||
// .strict()
|
||||
// .transform((user) => {
|
||||
// return {
|
||||
// ...user,
|
||||
// emailConfirmToken: sha256(),
|
||||
// password: user.password ? hashSync(user.password) : undefined
|
||||
// }
|
||||
// })
|
||||
|
||||
export const userUpdateSchema = createUpdateSchema(users)
|
||||
.pick({
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
image: true,
|
||||
password: true,
|
||||
isStripeConnectEnabledByDefault: true
|
||||
})
|
||||
.strict()
|
||||
.transform((user) => {
|
||||
return {
|
||||
...user,
|
||||
password: user.password ? hashSync(user.password) : undefined
|
||||
}
|
||||
})
|
||||
// export const userUpdateSchema = createUpdateSchema(users)
|
||||
// .pick({
|
||||
// firstName: true,
|
||||
// lastName: true,
|
||||
// image: true,
|
||||
// password: true,
|
||||
// isStripeConnectEnabledByDefault: true
|
||||
// })
|
||||
// .strict()
|
||||
// .transform((user) => {
|
||||
// return {
|
||||
// ...user,
|
||||
// password: user.password ? hashSync(user.password) : undefined
|
||||
// }
|
||||
// })
|
||||
|
|
|
@ -77,3 +77,5 @@ export type RawConsumerUpdate = Partial<
|
|||
|
||||
export type LogEntry = z.infer<typeof schema.logEntrySelectSchema>
|
||||
export type RawLogEntry = InferSelectModel<typeof schema.logEntries>
|
||||
|
||||
export type RawSession = InferSelectModel<typeof schema.sessions>
|
||||
|
|
490
pnpm-lock.yaml
490
pnpm-lock.yaml
Plik diff jest za duży
Load Diff
Ładowanie…
Reference in New Issue