diff --git a/.changeset/authful-user-key-hygiene.md b/.changeset/authful-user-key-hygiene.md new file mode 100644 index 000000000..e077d8ed7 --- /dev/null +++ b/.changeset/authful-user-key-hygiene.md @@ -0,0 +1,5 @@ +--- +"@anticapture/authful": patch +--- + +Self-service (`user:*`) API keys: the internal token validation metric buckets the per-user tenant label as `user:*` so the Prometheus series set stays bounded, matching Gateful's usage metric. diff --git a/.changeset/gateful-user-key-hygiene.md b/.changeset/gateful-user-key-hygiene.md new file mode 100644 index 000000000..7e1425cbf --- /dev/null +++ b/.changeset/gateful-user-key-hygiene.md @@ -0,0 +1,5 @@ +--- +"@anticapture/gateful": patch +--- + +Self-service (`user:*`) API keys: usage metrics bucket the per-user tenant label as `user:*` so the Prometheus series set stays bounded, and cached positive token verdicts use a 30s TTL (instead of 300s) so a key revoked from the dashboard stops authenticating within seconds. diff --git a/.changeset/user-api-dev-stack-lane.md b/.changeset/user-api-dev-stack-lane.md new file mode 100644 index 000000000..ceca9851f --- /dev/null +++ b/.changeset/user-api-dev-stack-lane.md @@ -0,0 +1,4 @@ +--- +--- + +chore: add a local dev-stack lane for the User API (`scripts/dev.sh`) + `pnpm user-api` root alias. Optional, mirroring Address Enrichment — runs via `railway run` when the service is available and exports `USER_API_URL` for the dashboard `/api/user` proxy; skipped otherwise so existing flows are unaffected. diff --git a/.changeset/user-api-keys-provisioning.md b/.changeset/user-api-keys-provisioning.md new file mode 100644 index 000000000..fb455611d --- /dev/null +++ b/.changeset/user-api-keys-provisioning.md @@ -0,0 +1,6 @@ +--- +"@anticapture/authful": minor +"@anticapture/user-api": minor +--- + +Add self-service API keys (DEV-950). Authful gains an optional scoped provisioning key that may only mint/revoke `user:*` tenants and cannot list all tenants (the admin key stays unrestricted). The User API brokers end-user keys through it: `POST/GET/DELETE /me/api-keys` (session-authenticated) mint into Authful under tenant `user:`, return the plaintext exactly once, and store only ownership (never the secret) — with a per-user quota and Authful-first revocation. Both surfaces stay disabled until their env is configured. diff --git a/.changeset/user-api-service.md b/.changeset/user-api-service.md new file mode 100644 index 000000000..5ba271962 --- /dev/null +++ b/.changeset/user-api-service.md @@ -0,0 +1,11 @@ +--- +"@anticapture/user-api": minor +--- + +New user identity & session service (`apps/user-api`). Provides SIWE +authentication via better-auth (EOA + EIP-1271, per-host domain resolution for +whitelabel) and session-scoped draft proposals: identity comes from the +session cookie, drafts are keyed by user with a `daoId` filter, the share +endpoint is public with a server-derived `isOwner`, and migrated rows are +claimed on first sign-in. Reached only through the dashboard's `/api/user` +proxy, independent of Gateful. diff --git a/.claude/skills/local-dev-stack/SKILL.md b/.claude/skills/local-dev-stack/SKILL.md index 645746442..594ed8cc5 100644 --- a/.claude/skills/local-dev-stack/SKILL.md +++ b/.claude/skills/local-dev-stack/SKILL.md @@ -46,6 +46,7 @@ the first. | Dashboard | 3000 | http://localhost:3000 | always | | Address Enrichment | 3001 | http://localhost:3001 | optional, skipped if no Railway svc | | Relayer (ENS) | 3002 | http://localhost:3002 | ENS only | +| User API | 4003 | http://localhost:4003 | optional | Client SDK codegen runs in watch mode (no port) and regenerates on API/gateful changes. diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 4e11750e4..a153b7f07 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -11,6 +11,7 @@ jobs: runs-on: ubuntu-latest outputs: gateful_url: ${{ steps.vercel-env.outputs.gateful_url }} + user_api_url: ${{ steps.vercel-env.outputs.user_api_url }} trusted: ${{ steps.trust.outputs.trusted }} configured: ${{ steps.vercel-upsert.outputs.configured }} @@ -50,7 +51,7 @@ jobs: echo "trusted=${trusted}" >> "${GITHUB_OUTPUT}" echo "PR author '${PR_AUTHOR}' (association=${AUTHOR_ASSOCIATION}) trusted=${trusted}." - - name: Compute branch-scoped Gateful URL + - name: Compute branch-scoped preview service URLs id: vercel-env shell: bash env: @@ -61,16 +62,19 @@ jobs: # Trusted PRs get their own ephemeral Railway preview; untrusted PRs # (no Vercel/Railway preview is provisioned for them) fall back to the - # shared dev Gateful so the downstream wait/typecheck/test jobs have a - # live target instead of a preview host that never comes up. + # shared dev services so the downstream wait/typecheck/test jobs have + # a live target instead of a preview host that never comes up. if [ "${IS_TRUSTED}" = "true" ]; then gateful_url="https://gateful-anticapture-pr-${PR_NUMBER}.up.railway.app" + user_api_url="https://user-api-anticapture-pr-${PR_NUMBER}.up.railway.app" else gateful_url="https://dev-gateful.up.railway.app" + user_api_url="https://user-api-dev-9002.up.railway.app" fi echo "gateful_url=${gateful_url}" >> "${GITHUB_OUTPUT}" + echo "user_api_url=${user_api_url}" >> "${GITHUB_OUTPUT}" - - name: Upsert branch-scoped Gateful URL into Vercel + - name: Upsert branch-scoped service URLs into Vercel id: vercel-upsert if: steps.trust.outputs.trusted == 'true' shell: bash @@ -80,6 +84,7 @@ jobs: VERCEL_TEAM_ID: ${{ secrets.VERCEL_TEAM_ID }} PR_BRANCH: ${{ github.event.pull_request.head.ref }} GATEFUL_URL: ${{ steps.vercel-env.outputs.gateful_url }} + USER_API_URL: ${{ steps.vercel-env.outputs.user_api_url }} run: | set -euo pipefail @@ -89,20 +94,27 @@ jobs: fi api_url="https://api.vercel.com/v10/projects/${VERCEL_PROJECT_ID}/env?upsert=true&teamId=${VERCEL_TEAM_ID}" - payload=$(jq -n \ - --arg key "ANTICAPTURE_API_URL" \ - --arg value "${GATEFUL_URL}" \ - --arg gitBranch "${PR_BRANCH}" \ - '{ key: $key, value: $value, type: "plain", target: ["preview"], gitBranch: $gitBranch }') - - curl --fail-with-body --silent --show-error \ - --request POST \ - --url "${api_url}" \ - --header "Authorization: Bearer ${VERCEL_TOKEN}" \ - --header "Content-Type: application/json" \ - --data "${payload}" - - echo "Configured ANTICAPTURE_API_URL=${GATEFUL_URL} for Vercel preview branch ${PR_BRANCH}." + upsert() { + payload=$(jq -n \ + --arg key "$1" \ + --arg value "$2" \ + --arg gitBranch "${PR_BRANCH}" \ + '{ key: $key, value: $value, type: "plain", target: ["preview"], gitBranch: $gitBranch }') + + curl --fail-with-body --silent --show-error \ + --request POST \ + --url "${api_url}" \ + --header "Authorization: Bearer ${VERCEL_TOKEN}" \ + --header "Content-Type: application/json" \ + --data "${payload}" + + echo "Configured $1=$2 for Vercel preview branch ${PR_BRANCH}." + } + + # ANTICAPTURE_API_URL feeds the data proxy (/api/gateful); + # USER_API_URL feeds the auth/session proxy (/api/user). + upsert "ANTICAPTURE_API_URL" "${GATEFUL_URL}" + upsert "USER_API_URL" "${USER_API_URL}" echo "configured=true" >> "${GITHUB_OUTPUT}" wait-for-gateful: @@ -166,6 +178,7 @@ jobs: VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} GATEFUL_URL: ${{ needs.configure-vercel-preview.outputs.gateful_url }} + USER_API_URL: ${{ needs.configure-vercel-preview.outputs.user_api_url }} # Git metadata so Vercel attributes the deploy to the PR author (avatar + # "Created by"); a bare `vercel deploy` shows "No attribution data available". GIT_SHA: ${{ github.event.pull_request.head.sha }} @@ -192,6 +205,7 @@ jobs: url=$(npx --yes vercel@latest deploy --yes --token="${VERCEL_TOKEN}" \ --build-env ANTICAPTURE_API_URL="${GATEFUL_URL}" \ --env ANTICAPTURE_API_URL="${GATEFUL_URL}" \ + --env USER_API_URL="${USER_API_URL}" \ --meta githubCommitSha="${GIT_SHA}" \ --meta githubCommitRef="${GIT_REF}" \ --meta githubCommitMessage="${GIT_MSG}" \ diff --git a/apps/authful/src/app.ts b/apps/authful/src/app.ts index 38869a0af..7842362ae 100644 --- a/apps/authful/src/app.ts +++ b/apps/authful/src/app.ts @@ -9,6 +9,7 @@ import { validateController } from "@/controllers/validate"; import { exporter } from "@/instrumentation"; import { metricsMiddleware } from "@/middlewares/metrics"; import { requestLogger } from "@/middlewares/logger"; +import { scopedTokenAuth } from "@/middlewares/token-auth"; import { logger } from "@/logger"; import type { TokensService } from "@/services/tokens"; @@ -17,6 +18,7 @@ export type AppConfig = { db: AuthfulDrizzle; adminApiKey: string; internalApiKey: string; + provisioningApiKey?: string; }; export function createApp({ @@ -24,6 +26,7 @@ export function createApp({ db, adminApiKey, internalApiKey, + provisioningApiKey, }: AppConfig): Hono { const app = new Hono(); @@ -48,9 +51,11 @@ export function createApp({ return c.body(body, 200, { "Content-Type": contentType }); }); - // Admin surface: humans minting/listing/revoking tokens. - app.use("/tokens", bearerAuth({ token: adminApiKey })); - app.use("/tokens/*", bearerAuth({ token: adminApiKey })); + // Token-management surface: the admin key (unrestricted) or the optional + // provisioning key (scoped to `user:*`, enforced in the controller). + const tokenAuth = scopedTokenAuth({ adminApiKey, provisioningApiKey }); + app.use("/tokens", tokenAuth); + app.use("/tokens/*", tokenAuth); // Internal surface: Gateful validating tokens. app.use("/validate", bearerAuth({ token: internalApiKey })); diff --git a/apps/authful/src/controllers/authful.integration.test.ts b/apps/authful/src/controllers/authful.integration.test.ts index ced23c8fe..035e2f841 100644 --- a/apps/authful/src/controllers/authful.integration.test.ts +++ b/apps/authful/src/controllers/authful.integration.test.ts @@ -23,6 +23,7 @@ import { TokensService, hashToken } from "@/services/tokens"; const ADMIN_KEY = "test-admin-key-0123456789"; const INTERNAL_KEY = "test-internal-key-0123456789"; +const PROVISIONING_KEY = "test-provisioning-key-0123456789"; const adminHeaders = { Authorization: `Bearer ${ADMIN_KEY}`, @@ -32,6 +33,10 @@ const internalHeaders = { Authorization: `Bearer ${INTERNAL_KEY}`, "Content-Type": "application/json", }; +const provisioningHeaders = { + Authorization: `Bearer ${PROVISIONING_KEY}`, + "Content-Type": "application/json", +}; describe("authful app", () => { let client: PGlite; @@ -50,6 +55,7 @@ describe("authful app", () => { db, adminApiKey: ADMIN_KEY, internalApiKey: INTERNAL_KEY, + provisioningApiKey: PROVISIONING_KEY, }); }); @@ -105,6 +111,18 @@ describe("authful app", () => { expect(res.status).toBe(200); }); + it("rejects an unknown bearer token", async () => { + const res = await app.request("/tokens", { + method: "POST", + headers: { + Authorization: "Bearer not-a-real-key-000000000000", + "Content-Type": "application/json", + }, + body: JSON.stringify({ tenant: "user:1", name: "x" }), + }); + expect(res.status).toBe(401); + }); + it("returns 503 when the DB probe fails", async () => { const failingDb = { execute: () => Promise.reject(new Error("db down")), @@ -126,6 +144,88 @@ describe("authful app", () => { }); }); + describe("provisioning scope", () => { + const provMint = (body: Record) => + app.request("/tokens", { + method: "POST", + headers: provisioningHeaders, + body: JSON.stringify(body), + }); + + it("mints a user:* token", async () => { + const res = await provMint({ tenant: "user:abc", name: "my-agent" }); + expect(res.status).toBe(201); + const body = (await res.json()) as MintResponse; + expect(body.tenant).toBe("user:abc"); + expect(body.token).toMatch(/^act_/); + }); + + it("cannot mint a first-party (non-user) tenant", async () => { + const res = await provMint({ tenant: "uniswap", name: "sneaky" }); + expect(res.status).toBe(403); + const before = await app.request("/tokens", { headers: adminHeaders }); + const { items } = (await before.json()) as { items: unknown[] }; + expect(items).toHaveLength(0); + }); + + it("cannot list all tokens (no tenant filter)", async () => { + const res = await app.request("/tokens", { + headers: provisioningHeaders, + }); + expect(res.status).toBe(403); + }); + + it("cannot list a non-user tenant", async () => { + const res = await app.request("/tokens?tenant=uniswap", { + headers: provisioningHeaders, + }); + expect(res.status).toBe(403); + }); + + it("lists only its own user:* tenant", async () => { + await provMint({ tenant: "user:abc", name: "one" }); + await provMint({ tenant: "user:abc", name: "two" }); + await mint({ tenant: "uniswap" }); // must not leak into the result + + const res = await app.request("/tokens?tenant=user:abc", { + headers: provisioningHeaders, + }); + expect(res.status).toBe(200); + const { items } = (await res.json()) as { + items: { tenant: string; lastUsedAt: string | null }[]; + }; + expect(items).toHaveLength(2); + expect(items.every((t) => t.tenant === "user:abc")).toBe(true); + }); + + it("revokes its own user:* token", async () => { + const minted = (await ( + await provMint({ tenant: "user:abc", name: "temp" }) + ).json()) as MintResponse; + const res = await app.request(`/tokens/${minted.id}`, { + method: "DELETE", + headers: provisioningHeaders, + }); + expect(res.status).toBe(204); + }); + + it("cannot revoke a first-party token — 404, not 403 (no oracle)", async () => { + const firstParty = await mint({ tenant: "uniswap" }); + const res = await app.request(`/tokens/${firstParty.id}`, { + method: "DELETE", + headers: provisioningHeaders, + }); + expect(res.status).toBe(404); + + // The token is untouched — admin can still see it active. + const list = await app.request("/tokens", { headers: adminHeaders }); + const { items } = (await list.json()) as { + items: { id: string; revokedAt: string | null }[]; + }; + expect(items.find((t) => t.id === firstParty.id)?.revokedAt).toBeNull(); + }); + }); + describe("POST /tokens", () => { it("mints a token and returns the plaintext exactly once", async () => { const body = await mint({}); @@ -299,6 +399,23 @@ describe("authful app", () => { }); }); + it("buckets user tenants to user:* to bound the metric's cardinality", async () => { + const add = vi.spyOn(tokenValidationRequestTotal, "add"); + const minted = await mint({ tenant: "user:1", name: "my-agent" }); + + await app.request("/validate", { + method: "POST", + headers: internalHeaders, + body: JSON.stringify({ tokenHash: hashToken(minted.token) }), + }); + + expect(add).toHaveBeenCalledWith(1, { + tenant: "user:*", + name: "my-agent", + result: "valid", + }); + }); + it("rejects an unknown hash", async () => { const add = vi.spyOn(tokenValidationRequestTotal, "add"); const res = await app.request("/validate", { diff --git a/apps/authful/src/controllers/tokens/index.ts b/apps/authful/src/controllers/tokens/index.ts index 2ab5cf4f3..a9af40959 100644 --- a/apps/authful/src/controllers/tokens/index.ts +++ b/apps/authful/src/controllers/tokens/index.ts @@ -2,14 +2,21 @@ import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi"; import { ErrorResponseSchema, + ListTokensQuerySchema, MintTokenBodySchema, MintTokenResponseSchema, TokenListResponseSchema, TokenParamsSchema, toTokenMetadata, } from "@/mappers/tokens"; +import { USER_TENANT_PREFIX } from "@/middlewares/token-auth"; import type { TokensService } from "@/services/tokens"; +const forbiddenResponse = { + description: "Scope not permitted for this operation", + content: { "application/json": { schema: ErrorResponseSchema } }, +}; + export function tokensController(app: Hono, service: TokensService) { app.openapi( createRoute({ @@ -33,10 +40,23 @@ export function tokensController(app: Hono, service: TokensService) { "application/json": { schema: MintTokenResponseSchema }, }, }, + 403: forbiddenResponse, }, }), async (c) => { const body = c.req.valid("json"); + // The provisioning key may only mint end-user keys. + if ( + c.get("authScope") === "provisioning" && + !body.tenant.startsWith(USER_TENANT_PREFIX) + ) { + return c.json( + { + error: `provisioning scope may only mint ${USER_TENANT_PREFIX}* tenants`, + }, + 403, + ); + } const { token, plaintext } = await service.mint(body); return c.json({ ...toTokenMetadata(token), token: plaintext }, 201); }, @@ -48,16 +68,36 @@ export function tokensController(app: Hono, service: TokensService) { operationId: "listTokens", path: "/tokens", summary: "List token metadata (never hashes or plaintext)", + description: + "Admin lists all tenants (optionally filtered by ?tenant). The " + + "provisioning scope may list only a single user:* tenant it owns.", tags: ["tokens"], + request: { query: ListTokensQuerySchema }, responses: { 200: { - description: "All tokens, newest first", + description: "Matching tokens, newest first", content: { "application/json": { schema: TokenListResponseSchema } }, }, + 403: forbiddenResponse, }, }), async (c) => { - const tokens = await service.list(); + const { tenant } = c.req.valid("query"); + // Unfiltered listing exposes every tenant's metadata — admin only. The + // provisioning key may list, but only its own user:* tenant (so the User + // API can read its users' keys, e.g. lastUsedAt). + if ( + c.get("authScope") !== "admin" && + (!tenant || !tenant.startsWith(USER_TENANT_PREFIX)) + ) { + return c.json( + { + error: `provisioning scope may only list a single ${USER_TENANT_PREFIX}* tenant`, + }, + 403, + ); + } + const tokens = await service.list(tenant); return c.json({ items: tokens.map(toTokenMetadata) }, 200); }, ); @@ -81,7 +121,11 @@ export function tokensController(app: Hono, service: TokensService) { }), async (c) => { const { id } = c.req.valid("param"); - const revoked = await service.revoke(id); + // Provisioning scope can only revoke `user:*` tokens; a non-matching id + // returns 404 (same as missing) to avoid a first-party-token oracle. + const requireTenantPrefix = + c.get("authScope") === "provisioning" ? USER_TENANT_PREFIX : undefined; + const revoked = await service.revoke(id, { requireTenantPrefix }); if (!revoked) return c.json({ error: "Token not found" }, 404); return c.body(null, 204); }, diff --git a/apps/authful/src/env.ts b/apps/authful/src/env.ts index 801f473a6..1fdf267f7 100644 --- a/apps/authful/src/env.ts +++ b/apps/authful/src/env.ts @@ -7,6 +7,17 @@ dotenv.config(); const CI = isRailwayPreviewEnv(); +// A secret that may be absent: accepts a real value, and treats unset OR +// empty-string (a blank var defined in some environments) as undefined. +// zod v4 note: `z.preprocess(fn, schema.optional())` REJECTS a truly unset +// var ("expected nonoptional" — the inner optional doesn't make the pipe's +// input optional), which crashed boots in environments without the var. +const optionalSecret = z + .string() + .min(16) + .optional() + .or(z.literal("").transform(() => undefined)); + const envSchema = z .object({ PORT: z.coerce.number().default(4002), @@ -15,17 +26,16 @@ const envSchema = z ADMIN_API_KEY: z.string().min(16), // Guards service-facing endpoints (/validate), shared with Gateful. INTERNAL_API_KEY: z.string().min(16), + // Optional scoped key for the User API to broker end-user keys: restricted + // to `user:*` tenants (mint/revoke only, no listing). Absent until the + // environment enables the User API's key provisioning. + PROVISIONING_API_KEY: optionalSecret, // CI/preview only: a fixed, known token seeded into the DB on boot so every // service in the same Railway PR preview shares a working API key. Required // in preview environments; ignored on dev/production. The seeded token's // tenant/name/rate-limit are fixed constants (see index.ts) — not worth env // vars, since the value only matters to ephemeral previews. - // Empty string (a blank var defined outside previews) is treated as unset, - // so it's ignored on dev/production instead of failing the min(16) guard. - SEED_TOKEN_PLAINTEXT: z.preprocess( - (v) => (v === "" ? undefined : v), - z.string().min(16).optional(), - ), + SEED_TOKEN_PLAINTEXT: optionalSecret, }) .superRefine((data, ctx) => { if (CI && !data.SEED_TOKEN_PLAINTEXT) { diff --git a/apps/authful/src/index.ts b/apps/authful/src/index.ts index bb5b8a558..f27591345 100644 --- a/apps/authful/src/index.ts +++ b/apps/authful/src/index.ts @@ -42,6 +42,7 @@ const app = createApp({ db, adminApiKey: env.ADMIN_API_KEY, internalApiKey: env.INTERNAL_API_KEY, + provisioningApiKey: env.PROVISIONING_API_KEY, }); app.doc("/docs/json", { diff --git a/apps/authful/src/mappers/tokens/index.ts b/apps/authful/src/mappers/tokens/index.ts index fba825ad3..96b9d0d5e 100644 --- a/apps/authful/src/mappers/tokens/index.ts +++ b/apps/authful/src/mappers/tokens/index.ts @@ -19,6 +19,15 @@ export const TokenListResponseSchema = z .object({ items: z.array(TokenMetadataSchema) }) .openapi("TokenListResponse"); +export const ListTokensQuerySchema = z + .object({ + tenant: z + .string() + .optional() + .openapi({ description: "Filter to a single tenant's tokens." }), + }) + .openapi("ListTokensQuery"); + export const MintTokenBodySchema = z .object({ tenant: z.string().min(1), diff --git a/apps/authful/src/middlewares/token-auth.ts b/apps/authful/src/middlewares/token-auth.ts new file mode 100644 index 000000000..b460d8a95 --- /dev/null +++ b/apps/authful/src/middlewares/token-auth.ts @@ -0,0 +1,57 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +import { createMiddleware } from "hono/factory"; + +/** + * Tenant prefix reserved for end-user API keys. The provisioning key (used by + * the User API to broker keys on behalf of signed-in users) may only mint and + * revoke tokens under this prefix — never a first-party tenant like "uniswap". + */ +export const USER_TENANT_PREFIX = "user:"; + +export type AuthScope = "admin" | "provisioning"; + +// Global context-variable typing so handlers can read c.get("authScope") +// without threading a custom Env generic through the whole app (which would +// break controllers that take the default-typed app). +declare module "hono" { + interface ContextVariableMap { + authScope: AuthScope; + } +} + +const digest = (value: string) => createHash("sha256").update(value).digest(); + +// Constant-time compare over fixed-length digests (raw strings differ in +// length, which timingSafeEqual rejects and which itself leaks length). +const safeEqual = (a: string, b: string) => + timingSafeEqual(digest(a), digest(b)); + +const bearer = (header: string | undefined): string | undefined => + header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined; + +/** + * Authenticates the token-management surface as one of two scopes: + * - `admin` — the full admin key; unrestricted (mint/list/revoke any tenant). + * - `provisioning` — the optional provisioning key; restricted to `user:*` + * tenants and forbidden from listing (enforced by the controller via + * `c.get("authScope")`). + */ +export const scopedTokenAuth = (opts: { + adminApiKey: string; + provisioningApiKey?: string; +}) => + createMiddleware(async (c, next) => { + const token = bearer(c.req.header("Authorization")); + if (!token) return c.json({ error: "unauthorized" }, 401); + + if (safeEqual(token, opts.adminApiKey)) { + c.set("authScope", "admin"); + return next(); + } + if (opts.provisioningApiKey && safeEqual(token, opts.provisioningApiKey)) { + c.set("authScope", "provisioning"); + return next(); + } + return c.json({ error: "unauthorized" }, 401); + }); diff --git a/apps/authful/src/repositories/tokens/index.ts b/apps/authful/src/repositories/tokens/index.ts index 429a93d49..0b2e2c4fe 100644 --- a/apps/authful/src/repositories/tokens/index.ts +++ b/apps/authful/src/repositories/tokens/index.ts @@ -12,6 +12,14 @@ export class TokensRepository { return this.db.select().from(tokens).orderBy(desc(tokens.createdAt)); } + async listByTenant(tenant: string): Promise { + return this.db + .select() + .from(tokens) + .where(eq(tokens.tenant, tenant)) + .orderBy(desc(tokens.createdAt)); + } + async create(token: NewToken): Promise { const [created] = await this.db.insert(tokens).values(token).returning(); return created!; @@ -23,6 +31,10 @@ export class TokensRepository { }); } + async findById(id: string): Promise { + return this.db.query.tokens.findFirst({ where: eq(tokens.id, id) }); + } + /** Sets revoked_at; idempotent. Returns false when the id doesn't exist. */ async revoke(id: string): Promise { const result = await this.db diff --git a/apps/authful/src/services/tokens/index.ts b/apps/authful/src/services/tokens/index.ts index 3d78a218d..6e007af7d 100644 --- a/apps/authful/src/services/tokens/index.ts +++ b/apps/authful/src/services/tokens/index.ts @@ -1,6 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; import { tokenValidationRequestTotal } from "@/metrics"; +import { USER_TENANT_PREFIX } from "@/middlewares/token-auth"; import type { DBToken, TokensRepository } from "@/repositories/tokens"; export const TOKEN_PREFIX = "act_"; @@ -72,11 +73,25 @@ export class TokensService { return { created: true, token }; } - async list(): Promise { - return this.repo.list(); + async list(tenant?: string): Promise { + return tenant ? this.repo.listByTenant(tenant) : this.repo.list(); } - async revoke(id: string): Promise { + /** + * Revokes a token. When `requireTenantPrefix` is given (provisioning scope), + * a token whose tenant lacks that prefix is treated as not-found (404) rather + * than 403 — so the provisioning key can't probe for first-party token ids. + */ + async revoke( + id: string, + opts: { requireTenantPrefix?: string } = {}, + ): Promise { + if (opts.requireTenantPrefix !== undefined) { + const token = await this.repo.findById(id); + if (!token || !token.tenant.startsWith(opts.requireTenantPrefix)) { + return false; + } + } return this.repo.revoke(id); } @@ -88,7 +103,12 @@ export class TokensService { } await this.repo.touchLastUsed(token.id); tokenValidationRequestTotal.add(1, { - tenant: token.tenant, + // Self-service keys mint one `user:` tenant per user — unbounded. + // Bucket them so the Prometheus label set stays bounded; ops tenants + // keep their verbatim label. Mirrors gateful's usage middleware. + tenant: token.tenant.startsWith(USER_TENANT_PREFIX) + ? `${USER_TENANT_PREFIX}*` + : token.tenant, name: token.name, result: "valid", }); diff --git a/apps/gateful/src/auth/token-auth.ts b/apps/gateful/src/auth/token-auth.ts index eeb00e92d..c130628a4 100644 --- a/apps/gateful/src/auth/token-auth.ts +++ b/apps/gateful/src/auth/token-auth.ts @@ -33,6 +33,10 @@ export interface TokenCacheStore { const POSITIVE_TTL_SECONDS = 300; const NEGATIVE_TTL_SECONDS = 60; +// Self-service (`user:*`) keys are revocable from the dashboard, so their +// positive verdicts get a short TTL: a revoke must stop authenticating in +// seconds, not minutes. Ops tenants keep the longer outage-tolerant TTL. +const USER_POSITIVE_TTL_SECONDS = 30; export function hashBearerToken(token: string): string { return createHash("sha256").update(token).digest("hex"); @@ -81,7 +85,11 @@ export function tokenAuthMiddleware({ } if (!cached) { - const ttl = verdict.valid ? POSITIVE_TTL_SECONDS : NEGATIVE_TTL_SECONDS; + const positiveTtl = + verdict.valid && verdict.tenant.startsWith("user:") + ? USER_POSITIVE_TTL_SECONDS + : POSITIVE_TTL_SECONDS; + const ttl = verdict.valid ? positiveTtl : NEGATIVE_TTL_SECONDS; await cache ?.set(cacheKey, JSON.stringify(verdict), { EX: ttl }) .catch((err: unknown) => { diff --git a/apps/gateful/src/auth/usage.ts b/apps/gateful/src/auth/usage.ts index 452bc6e2c..56e794666 100644 --- a/apps/gateful/src/auth/usage.ts +++ b/apps/gateful/src/auth/usage.ts @@ -40,7 +40,10 @@ export function usageMiddleware(daoApis: Map) { const auth = c.get("auth"); if (auth) { tenantRequestTotal.add(1, { - tenant: auth.tenant, + // Self-service keys mint one `user:` tenant per user — + // unbounded. Bucket them so the Prometheus label set stays + // bounded; ops tenants keep their verbatim label. + tenant: auth.tenant.startsWith("user:") ? "user:*" : auth.tenant, name: auth.name, route: normalizeRoute(c.req.path, daoApis), }); diff --git a/apps/user-api/.env.example b/apps/user-api/.env.example new file mode 100644 index 000000000..44eba2001 --- /dev/null +++ b/apps/user-api/.env.example @@ -0,0 +1,34 @@ +# User API — user identity & session service + +PORT=4003 + +# Dedicated Postgres for the User API (its own DB, not the DAO/general schema). +DATABASE_URL=postgresql://postgres:admin@localhost:5432/user_api + +# Session signing key — openssl rand -base64 32 +BETTER_AUTH_SECRET=change-me-to-a-32+-char-random-secret + +# Comma-separated frontend hosts allowed to sign in (main dashboard, localhost +# for local dev, every whitelabel host). Single source of truth per host: the +# SIWE domain a signed message must match, the better-auth baseURL (cookie/CSRF +# scope, derived as https:// — http for localhost), and the CSRF origin +# allowlist. There is deliberately NO single BETTER_AUTH_URL — the service +# serves many origins and scopes each request to its own. +AUTH_SIWE_DOMAINS=localhost:3000 + +# RPC for EIP-1271 / smart-contract-wallet signature verification. +RPC_URL=https://eth.llamarpc.com + +# Google OAuth (optional): both must be set together. +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= + +# Magic link (optional): set to enable email sign-in. Sender defaults to +# Resend's sandbox for dev. +# RESEND_API_KEY= +# RESEND_FROM_EMAIL=auth@anticapture.com + +# Self-service API keys (optional): both must be set together. The User API +# brokers user-scoped keys into Authful over the private network. +# AUTHFUL_URL=http://localhost:4002 +# AUTHFUL_PROVISIONING_API_KEY= diff --git a/apps/user-api/README.md b/apps/user-api/README.md new file mode 100644 index 000000000..2529e154c --- /dev/null +++ b/apps/user-api/README.md @@ -0,0 +1,85 @@ +# @anticapture/user-api + +User identity & session service for the Anticapture platform. Owns +authentication (SIWE now; Google OAuth and magic link to follow) and off-chain +user data (draft proposals, user settings — added in later steps). + +## Position in the architecture + +Reached only through the dashboard's `/api/user/*` Next proxy (HTTP-only session +cookies). It does **not** sit behind Gateful — Gateful stays the API-key data +plane for the DAO APIs. The User API talks to Authful east-west (private network) to +mint user-scoped API keys. + +``` +Browser ──/api/user/*──> User API (better-auth: SIWE/Google/magic-link) ──> User API DB +Browser ──/api/gateful/*─> Gateful ──> DAO APIs (read models) +User API ──(east-west, provisioning key)──> Authful +``` + +## Auth model + +- **SIWE** via better-auth's `siwe` plugin. One better-auth instance per host in + `AUTH_SIWE_DOMAINS` so main and whitelabel domains each sign with their real + host (the plugin binds a message to a single domain — this preserves SIWE's + anti-phishing property across domains). Sessions are host-only cookies, so a + user re-signs when moving between domains; find-or-create by address means it + is the **same account** across them. +- EOA and EIP-1271 / ERC-6492 (smart-contract wallets) verified via viem. + +## Database + +The User API runs on its **own** Postgres database (default `public` schema). The +better-auth core tables are generated by the better-auth CLI: + +```bash +pnpm --filter @anticapture/user-api auth:generate # writes src/database/auth-schema.ts +pnpm --filter @anticapture/user-api db:generate # drizzle migration +pnpm --filter @anticapture/user-api db:migrate +``` + +## Env + +See `.env.example`. Required: `DATABASE_URL`, `BETTER_AUTH_SECRET`, +`AUTH_SIWE_DOMAINS`, `RPC_URL`. + +> **There is no `BETTER_AUTH_URL`.** The service serves many frontend origins +> (localhost, the main domain, whitelabel hosts), so a single base URL can't be +> right. `AUTH_SIWE_DOMAINS` is the one list of allowed hosts, and per host we +> derive the SIWE domain, the better-auth `baseURL` (cookie/CSRF scope — +> `https://`, `http` for localhost), and the trusted-origin allowlist. +> better-auth mounts at `baseURL` + `basePath` (`/api/auth`); the dashboard +> proxy strips `/api/user` before forwarding, so the service receives +> `/api/auth/*` and the origin (no path) is exactly what's needed. + +### Browser-facing URLs the service generates + +better-auth self-generates some URLs the **browser** must be able to open: +the Google OAuth `redirect_uri` and the magic-link verify URL, both under +`{origin}/api/auth/*`. The dashboard serves them via a Next rewrite +(`/api/auth/:path*` → the `/api/user` proxy), and magic-link emails point at +the dashboard's `/auth/magic-link` interstitial (which triggers the verify +from the browser, so mail-pipeline prefetchers can't burn the single-use +token). The proxy, the rewrite and the interstitial all ship with the +dashboard PR — this service and that dashboard release deploy together. + +Behind a managed edge (e.g. Railway) the dashboard proxy's host arrives as +`x-anticapture-host` (the edge overwrites `x-forwarded-*`); `PORT` must match +the generated domain's target port (4003). + +## Preview environments (Railway PR envs) + +Login-gated flows are reviewable from the Vercel preview link with a real +wallet. When `RAILWAY_ENVIRONMENT_NAME` is a PR preview (never +dev/production — derived from the deploy, not from config), **dynamic +hosts** activate: every Vercel preview URL is unique, so better-auth +instances for `*.vercel.app` hosts are built on demand instead of coming +from `AUTH_SIWE_DOMAINS`. All other hosts still fail closed, and signatures +are verified on-chain exactly like everywhere else. + +Security posture: preview databases are ephemeral and empty, PR environments +must only inherit **throwaway** values (never real secrets — same rule as +authful's seeded preview token), and untrusted (fork) PRs get no preview +environment at all (see `tests.yaml`'s trust gate). Google OAuth stays off +in previews (its redirect URIs are registered per exact host); magic link +stays off unless a sandbox Resend key is provided. diff --git a/apps/user-api/drizzle.config.ts b/apps/user-api/drizzle.config.ts new file mode 100644 index 000000000..7e2ec95f0 --- /dev/null +++ b/apps/user-api/drizzle.config.ts @@ -0,0 +1,17 @@ +import dotenv from "dotenv"; +import { defineConfig } from "drizzle-kit"; + +dotenv.config(); + +// The User API runs on its own Postgres database (default `public` schema), so — +// unlike the DAO API's namespaced `general`/`anticapture` schemas — no +// schemaFilter is needed and the better-auth CLI's unqualified table output +// works directly. +export default defineConfig({ + schema: "./src/database/schema.ts", + out: "./drizzle", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/apps/user-api/drizzle/0000_lazy_lake.sql b/apps/user-api/drizzle/0000_lazy_lake.sql new file mode 100644 index 000000000..4e02a2fe1 --- /dev/null +++ b/apps/user-api/drizzle/0000_lazy_lake.sql @@ -0,0 +1,64 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean DEFAULT false NOT NULL, + "image" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "wallet_address" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "address" text NOT NULL, + "chain_id" integer NOT NULL, + "is_primary" boolean DEFAULT false, + "created_at" timestamp NOT NULL +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "wallet_address" ADD CONSTRAINT "wallet_address_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "account_userId_idx" ON "account" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "session_userId_idx" ON "session" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "verification_identifier_idx" ON "verification" USING btree ("identifier");--> statement-breakpoint +CREATE INDEX "walletAddress_userId_idx" ON "wallet_address" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/user-api/drizzle/0001_needy_molly_hayes.sql b/apps/user-api/drizzle/0001_needy_molly_hayes.sql new file mode 100644 index 000000000..027ed9e19 --- /dev/null +++ b/apps/user-api/drizzle/0001_needy_molly_hayes.sql @@ -0,0 +1,16 @@ +CREATE TABLE "drafts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text, + "author_address" text, + "dao_id" text NOT NULL, + "title" text DEFAULT '' NOT NULL, + "discussion_url" text DEFAULT '' NOT NULL, + "body" text DEFAULT '' NOT NULL, + "actions" jsonb DEFAULT '[]'::jsonb NOT NULL, + "created_at" bigint NOT NULL, + "updated_at" bigint NOT NULL +); +--> statement-breakpoint +ALTER TABLE "drafts" ADD CONSTRAINT "drafts_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "drafts_user_id_dao_id_index" ON "drafts" USING btree ("user_id","dao_id");--> statement-breakpoint +CREATE INDEX "drafts_author_address_index" ON "drafts" USING btree ("author_address"); \ No newline at end of file diff --git a/apps/user-api/drizzle/0002_ordinary_lord_tyger.sql b/apps/user-api/drizzle/0002_ordinary_lord_tyger.sql new file mode 100644 index 000000000..b4bf77dca --- /dev/null +++ b/apps/user-api/drizzle/0002_ordinary_lord_tyger.sql @@ -0,0 +1,11 @@ +CREATE TABLE "user_api_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "authful_token_id" uuid NOT NULL, + "label" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "user_api_keys" ADD CONSTRAINT "user_api_keys_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_api_keys_user_id_index" ON "user_api_keys" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/user-api/drizzle/meta/0000_snapshot.json b/apps/user-api/drizzle/meta/0000_snapshot.json new file mode 100644 index 000000000..e6363a751 --- /dev/null +++ b/apps/user-api/drizzle/meta/0000_snapshot.json @@ -0,0 +1,438 @@ +{ + "id": "1a4ade1e-99dc-40dc-a3cb-4883433d47a5", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_address": { + "name": "wallet_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "walletAddress_userId_idx": { + "name": "walletAddress_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_address_user_id_user_id_fk": { + "name": "wallet_address_user_id_user_id_fk", + "tableFrom": "wallet_address", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/user-api/drizzle/meta/0001_snapshot.json b/apps/user-api/drizzle/meta/0001_snapshot.json new file mode 100644 index 000000000..dcfa0755f --- /dev/null +++ b/apps/user-api/drizzle/meta/0001_snapshot.json @@ -0,0 +1,563 @@ +{ + "id": "72f99871-3ad0-4bf5-8f9e-0a6ecdc1d68b", + "prevId": "1a4ade1e-99dc-40dc-a3cb-4883433d47a5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.drafts": { + "name": "drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_address": { + "name": "author_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dao_id": { + "name": "dao_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "discussion_url": { + "name": "discussion_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "drafts_user_id_dao_id_index": { + "name": "drafts_user_id_dao_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dao_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drafts_author_address_index": { + "name": "drafts_author_address_index", + "columns": [ + { + "expression": "author_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drafts_user_id_user_id_fk": { + "name": "drafts_user_id_user_id_fk", + "tableFrom": "drafts", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_address": { + "name": "wallet_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "walletAddress_userId_idx": { + "name": "walletAddress_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_address_user_id_user_id_fk": { + "name": "wallet_address_user_id_user_id_fk", + "tableFrom": "wallet_address", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/user-api/drizzle/meta/0002_snapshot.json b/apps/user-api/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..c883205f4 --- /dev/null +++ b/apps/user-api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,640 @@ +{ + "id": "da1db9e9-9975-4054-9b53-5bb66bf52def", + "prevId": "72f99871-3ad0-4bf5-8f9e-0a6ecdc1d68b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.drafts": { + "name": "drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_address": { + "name": "author_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dao_id": { + "name": "dao_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "discussion_url": { + "name": "discussion_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "drafts_user_id_dao_id_index": { + "name": "drafts_user_id_dao_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dao_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drafts_author_address_index": { + "name": "drafts_author_address_index", + "columns": [ + { + "expression": "author_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drafts_user_id_user_id_fk": { + "name": "drafts_user_id_user_id_fk", + "tableFrom": "drafts", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_keys": { + "name": "user_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authful_token_id": { + "name": "authful_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_api_keys_user_id_index": { + "name": "user_api_keys_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_keys_user_id_user_id_fk": { + "name": "user_api_keys_user_id_user_id_fk", + "tableFrom": "user_api_keys", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_address": { + "name": "wallet_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "walletAddress_userId_idx": { + "name": "walletAddress_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_address_user_id_user_id_fk": { + "name": "wallet_address_user_id_user_id_fk", + "tableFrom": "wallet_address", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/user-api/drizzle/meta/_journal.json b/apps/user-api/drizzle/meta/_journal.json new file mode 100644 index 000000000..c20aa2b36 --- /dev/null +++ b/apps/user-api/drizzle/meta/_journal.json @@ -0,0 +1,27 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1783637738663, + "tag": "0000_lazy_lake", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1783638916278, + "tag": "0001_needy_molly_hayes", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783685472681, + "tag": "0002_ordinary_lord_tyger", + "breakpoints": true + } + ] +} diff --git a/apps/user-api/package.json b/apps/user-api/package.json new file mode 100644 index 000000000..e84d366c9 --- /dev/null +++ b/apps/user-api/package.json @@ -0,0 +1,52 @@ +{ + "name": "@anticapture/user-api", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "dist/index.js", + "description": "User API — user identity & session service (SIWE, Google, magic link) and off-chain user data (drafts)", + "scripts": { + "dev": "tsx watch --import ./src/instrumentation.ts src/index.ts", + "start": "node --import ./dist/instrumentation.js dist/index.js", + "build": "tsup", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "lint": "eslint src", + "lint:fix": "eslint src --fix", + "clean": "rm -rf node_modules *.tsbuildinfo dist", + "auth:generate": "better-auth generate --config ./src/auth-instance.ts --output ./src/database/auth-schema.ts --yes", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "migrate:drafts": "tsx scripts/migrate-drafts.ts" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "@anticapture/observability": "workspace:*", + "@hono/node-server": "^1.19.9", + "@hono/swagger-ui": "^0.5.3", + "@hono/zod-openapi": "^1.2.2", + "@opentelemetry/api": "^1.9.0", + "better-auth": "^1.3.7", + "dotenv": "^16.5.0", + "drizzle-kit": "^0.31.9", + "drizzle-orm": "^0.45.1", + "hono": "^4.12.7", + "pg": "^8.17.2", + "resend": "^6.6.0", + "viem": "^2.41.2", + "zod": "^4.3.6" + }, + "devDependencies": { + "@better-auth/cli": "^1.3.7", + "@electric-sql/pglite": "^0.3.15", + "@types/node": "^20.16.5", + "@types/pg": "^8.15.6", + "tsup": "^8.5.1", + "tsx": "^4.21.0", + "typescript": "^5.8.3", + "vitest": "^3.0.5" + } +} diff --git a/apps/user-api/scripts/migrate-drafts.test.ts b/apps/user-api/scripts/migrate-drafts.test.ts new file mode 100644 index 000000000..853c27aca --- /dev/null +++ b/apps/user-api/scripts/migrate-drafts.test.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { eq } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { afterAll, beforeEach, describe, expect, it } from "vitest"; + +import * as fullSchema from "@/database/schema"; +import { + account, + drafts, + session, + user, + verification, + walletAddress, +} from "@/database/schema"; +import { DraftsRepository } from "@/repositories/drafts"; +import { ProposalDraftsService } from "@/services/drafts"; +import { migrateDrafts, type SourceDraft } from "./migrate-drafts"; + +const ADDR = "0xAbC0000000000000000000000000000000000001"; + +const source = (over: Partial = {}): SourceDraft => ({ + id: crypto.randomUUID(), + daoId: "ens", + author: ADDR, + title: "migrated", + discussionUrl: "", + body: "body", + actions: [], + createdAt: 1700000000000, + updatedAt: 1700000000001, + ...over, +}); + +describe("migrateDrafts", () => { + const client = new PGlite(); + const db = drizzle(client, { schema: fullSchema }); + + beforeEach(async () => { + const tables = { + user, + session, + account, + verification, + walletAddress, + drafts, + }; + const { apply } = await pushSchema(tables as any, db as any); + await apply(); + await db.delete(drafts); + }); + + afterAll(async () => { + await client.close(); + }); + + it("copies rows unclaimed, preserving id/timestamps and lowercasing author", async () => { + const row = source(); + const report = await migrateDrafts([row], db); + + expect(report).toEqual({ read: 1, inserted: 1, skipped: 0, invalid: 0 }); + const [stored] = await db + .select() + .from(drafts) + .where(eq(drafts.id, row.id)); + expect(stored).toMatchObject({ + id: row.id, + userId: null, + authorAddress: ADDR.toLowerCase(), + daoId: "ens", + createdAt: 1700000000000, + updatedAt: 1700000000001, + }); + }); + + it("is idempotent — a second run skips existing ids", async () => { + const rows = [source(), source()]; + await migrateDrafts(rows, db); + const second = await migrateDrafts(rows, db); + + expect(second).toEqual({ read: 2, inserted: 0, skipped: 2, invalid: 0 }); + expect(await db.$count(drafts)).toBe(2); + }); + + it("skips malformed rows without aborting the batch", async () => { + const rows = [ + source(), + source({ id: "not-a-uuid" }), + source({ author: "0xnothex" }), + source({ daoId: "" }), + ]; + const report = await migrateDrafts(rows, db); + + expect(report).toEqual({ read: 4, inserted: 1, skipped: 0, invalid: 3 }); + expect(await db.$count(drafts)).toBe(1); + }); + + it("dry-run writes nothing", async () => { + const report = await migrateDrafts([source(), source()], db, { + dryRun: true, + }); + expect(report.inserted).toBe(0); + expect(await db.$count(drafts)).toBe(0); + }); + + it("migrated rows are claimed on the author's first login", async () => { + const row = source(); + await migrateDrafts([row], db); + + // Simulate that wallet having signed in: a user + linked walletAddress. + const [u] = await db + .insert(user) + .values({ + id: crypto.randomUUID(), + name: "u", + email: `${ADDR.toLowerCase()}@wallet.local`, + emailVerified: false, + createdAt: new Date(), + updatedAt: new Date(), + }) + .returning(); + await db.insert(walletAddress).values({ + id: crypto.randomUUID(), + userId: u!.id, + address: ADDR.toLowerCase(), + chainId: 1, + isPrimary: true, + createdAt: new Date(), + }); + + const service = new ProposalDraftsService(new DraftsRepository(db)); + const list = await service.listForUser(u!.id, "ens"); + + expect(list).toHaveLength(1); + expect(list[0]!.id).toBe(row.id); + expect(list[0]!.userId).toBe(u!.id); + }); +}); diff --git a/apps/user-api/scripts/migrate-drafts.ts b/apps/user-api/scripts/migrate-drafts.ts new file mode 100644 index 000000000..1a4fbf47f --- /dev/null +++ b/apps/user-api/scripts/migrate-drafts.ts @@ -0,0 +1,168 @@ +/** + * One-shot migration: copies draft proposals from a DAO API's Postgres + * (`general.proposal_drafts`) into the User API's `drafts` table. + * + * Run once per DAO source database (each DAO API has its own): + * + * SOURCE_DATABASE_URL=postgres://…dao-db DATABASE_URL=postgres://…user-db \ + * pnpm --filter @anticapture/user-api migrate:drafts [--dry-run] + * + * Migrated rows land unclaimed (`user_id NULL`) with the original author + * wallet in `author_address`; the User API claims them onto a user on that + * wallet's first SIWE login (ProposalDraftsService.listForUser). Draft ids are + * preserved verbatim so existing share links keep resolving. Idempotent: + * re-running skips ids already present (also how cross-DAO id collisions are + * handled — first writer wins, the rest are logged as skipped). + */ +import { fileURLToPath } from "node:url"; + +import { sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { isAddress } from "viem"; + +import * as schema from "@/database/schema"; +import { drafts } from "@/database/schema"; +import type { UserApiDrizzle } from "@/database/types"; + +export type SourceDraft = { + id: string; + daoId: string; + author: string; + title: string; + discussionUrl: string; + body: string; + actions: unknown[]; + createdAt: number; + updatedAt: number; +}; + +export type MigrationReport = { + read: number; + inserted: number; + skipped: number; + invalid: number; +}; + +const isUuid = (v: string) => + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v); + +/** Imported rows are untrusted DB content — reject anything malformed. */ +const isValid = (row: SourceDraft): boolean => + isUuid(row.id) && + row.daoId.length > 0 && + // Non-strict: legacy rows may carry any casing; they're lowercased on write. + isAddress(row.author, { strict: false }) && + Number.isFinite(row.createdAt) && + Number.isFinite(row.updatedAt); + +/** + * Pure core: inserts the given source rows into the User API `drafts` table, + * skipping invalid rows and ids that already exist. Separated from the CLI so + * it can be exercised against an in-memory DB in tests. + */ +export async function migrateDrafts( + rows: SourceDraft[], + db: UserApiDrizzle, + opts: { dryRun?: boolean } = {}, +): Promise { + const report: MigrationReport = { + read: rows.length, + inserted: 0, + skipped: 0, + invalid: 0, + }; + + for (const row of rows) { + if (!isValid(row)) { + report.invalid++; + continue; + } + if (opts.dryRun) continue; + + const inserted = await db + .insert(drafts) + .values({ + id: row.id, + userId: null, + authorAddress: row.author.toLowerCase(), + daoId: row.daoId, + title: row.title, + discussionUrl: row.discussionUrl, + body: row.body, + actions: row.actions, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }) + .onConflictDoNothing() + .returning(); + + if (inserted.length > 0) report.inserted++; + else report.skipped++; + } + + return report; +} + +/** Reads `general.proposal_drafts` from a DAO source database. */ +async function readSource(sourceUrl: string): Promise { + const pool = new Pool({ connectionString: sourceUrl }); + try { + const { rows } = await pool.query( + `SELECT id, dao_id, author, title, discussion_url, body, actions, + created_at, updated_at + FROM general.proposal_drafts`, + ); + return rows.map((r) => ({ + id: r.id, + daoId: r.dao_id, + author: r.author, + title: r.title, + discussionUrl: r.discussion_url, + body: r.body, + actions: (r.actions ?? []) as unknown[], + // bigint columns arrive as strings via node-postgres. + createdAt: Number(r.created_at), + updatedAt: Number(r.updated_at), + })); + } finally { + await pool.end(); + } +} + +async function main() { + const sourceUrl = process.env.SOURCE_DATABASE_URL; + const destUrl = process.env.DATABASE_URL; + const dryRun = process.argv.includes("--dry-run"); + + if (!sourceUrl || !destUrl) { + console.error( + "SOURCE_DATABASE_URL (DAO db) and DATABASE_URL (user-api db) are required", + ); + process.exit(1); + } + + const rows = await readSource(sourceUrl); + const db = drizzle(destUrl, { schema }); + + // Fail loud if the destination table is missing (wrong DATABASE_URL). + await db.execute(sql`select 1 from drafts limit 1`).catch((err) => { + console.error( + "destination `drafts` table not reachable — check DATABASE_URL", + ); + throw err; + }); + + const report = await migrateDrafts(rows, db, { dryRun }); + console.error( + `${dryRun ? "[dry-run] " : ""}drafts migration: ` + + `read=${report.read} inserted=${report.inserted} ` + + `skipped=${report.skipped} invalid=${report.invalid}`, + ); + process.exit(0); +} + +// Run only when invoked directly, not when imported by tests. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + void main(); +} diff --git a/apps/user-api/src/app.ts b/apps/user-api/src/app.ts new file mode 100644 index 000000000..9c32b35ce --- /dev/null +++ b/apps/user-api/src/app.ts @@ -0,0 +1,74 @@ +import { collectPrometheusMetrics } from "@anticapture/observability"; +import { OpenAPIHono as Hono } from "@hono/zod-openapi"; +import { sql } from "drizzle-orm"; + +import { forwardedHost, type AuthResolver } from "@/auth"; +import { apiKeysController } from "@/controllers/api-keys"; +import { draftsController } from "@/controllers/drafts"; +import type { UserApiDrizzle } from "@/database/types"; +import { exporter } from "@/instrumentation"; +import { logger } from "@/logger"; +import { metricsMiddleware } from "@/middlewares/metrics"; +import { requestLogger } from "@/middlewares/logger"; +import type { ApiKeysService } from "@/services/api-keys"; +import type { ProposalDraftsService } from "@/services/drafts"; + +export type AppConfig = { + db: UserApiDrizzle; + authResolver: AuthResolver; + draftsService: ProposalDraftsService; + // Present only when Authful provisioning is configured — the API-key surface + // stays absent otherwise (env-gated, like Google / magic link). + apiKeysService?: ApiKeysService; +}; + +export function createApp({ + db, + authResolver, + draftsService, + apiKeysService, +}: AppConfig): Hono { + const app = new Hono(); + + app.use("*", requestLogger()); + app.use("*", metricsMiddleware()); + + // Railway healthcheck: probe the DB so a bad DATABASE_URL / down Postgres + // marks the service unhealthy instead of serving auth traffic that 500s. + app.get("/health", async (c) => { + try { + await db.execute(sql`select 1`); + return c.json({ status: "ok" }); + } catch (err) { + logger.error({ err }, "Health check DB probe failed"); + return c.json({ status: "error" }, 503); + } + }); + + app.get("/metrics", async (c) => { + const { body, contentType } = await collectPrometheusMetrics(exporter); + return c.body(body, 200, { "Content-Type": contentType }); + }); + + // Public capability discovery: which sign-in methods this deployment + // serves. The frontend gates its Email/Google buttons on this instead of + // its own env, so a method the server can't handle is never offered. + app.get("/auth/methods", (c) => c.json(authResolver.methods)); + + // Better-auth owns all of /api/auth/* (SIWE nonce/verify, session, sign-out, + // and later Google/magic-link). The instance is resolved per request Host so + // whitelabel domains verify SIWE against their own host; an unlisted host is + // rejected before any session can be issued. + app.on(["POST", "GET"], "/api/auth/*", (c) => { + const auth = authResolver.resolve(forwardedHost(c.req.raw.headers)); + if (!auth) return c.json({ error: "untrusted_host" }, 400); + return auth.handler(c.req.raw); + }); + + draftsController(app, draftsService, authResolver); + if (apiKeysService) { + apiKeysController(app, apiKeysService, authResolver); + } + + return app; +} diff --git a/apps/user-api/src/auth-instance.ts b/apps/user-api/src/auth-instance.ts new file mode 100644 index 000000000..72bec0b46 --- /dev/null +++ b/apps/user-api/src/auth-instance.ts @@ -0,0 +1,52 @@ +import { createPublicClient, http } from "viem"; +import { mainnet } from "viem/chains"; + +import { createAuthResolver, type VerifySiweMessage } from "@/auth"; +import { isRailwayPreviewEnv } from "@/ci"; +import { db } from "@/database"; +import { createMagicLinkSender } from "@/email/magic-link"; +import { env } from "@/env"; + +// Verifies both EOA signatures and EIP-1271 / ERC-6492 smart-contract wallets +// (Safe, etc.). `publicClient.verifyMessage` recovers the signer for EOAs and +// falls back to the on-chain isValidSignature call for contract wallets. +// TODO: governance today is mainnet-only (ENS, SHU). When a draft-enabled DAO +// ships on another chain, resolve the client by the message's chainId. +const publicClient = createPublicClient({ + chain: mainnet, + transport: http(env.RPC_URL), +}); + +// Each optional method is wired only when its config is present, so the +// service runs SIWE-only until Resend / Google credentials are set. +const google = + env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET + ? { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } + : undefined; + +const verifyMessage: VerifySiweMessage = ({ message, signature, address }) => + publicClient.verifyMessage({ + address: address as `0x${string}`, + message, + signature: signature as `0x${string}`, + }); + +export const authResolver = createAuthResolver({ + db, + secret: env.BETTER_AUTH_SECRET, + // baseURL + trusted origins are derived per host from this list — see + // createAuthResolver. No single BETTER_AUTH_URL: the service serves many + // frontend origins (localhost, main, whitelabels). + domains: env.AUTH_SIWE_DOMAINS, + verifyMessage, + magicLink: createMagicLinkSender(env.RESEND_API_KEY, env.RESEND_FROM_EMAIL), + google, + // Railway PR previews only (derived from the environment name, not + // configuration): serve unique *.vercel.app preview hosts on demand so + // real SIWE works from Vercel preview links. Never active on dev/prod. + previewDynamicHosts: isRailwayPreviewEnv(), +}); + +// The better-auth CLI introspects this export to generate the schema (which +// is domain-independent), via `pnpm auth:generate`. +export const auth = authResolver.primary; diff --git a/apps/user-api/src/auth.ts b/apps/user-api/src/auth.ts new file mode 100644 index 000000000..e82f102c8 --- /dev/null +++ b/apps/user-api/src/auth.ts @@ -0,0 +1,215 @@ +import { betterAuth, type BetterAuthPlugin } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { generateRandomString } from "better-auth/crypto"; +import { magicLink, siwe } from "better-auth/plugins"; + +import type { UserApiDrizzle } from "@/database/types"; + +export type VerifySiweMessage = (params: { + message: string; + signature: string; + address: string; + chainId: number; +}) => Promise; + +export type SendMagicLink = (params: { + email: string; + url: string; +}) => Promise; + +export type AuthConfig = { + db: UserApiDrizzle; + secret: string; + /** + * Allowed frontend hosts (main dashboard, localhost for local dev, and each + * whitelabel host). This one list drives everything per host: the SIWE + * `domain` a signed message must match, the better-auth `baseURL` (so the + * session cookie and CSRF are scoped to the origin the request actually came + * from — never a single hard-coded domain), and the trusted-origin allowlist. + * One better-auth instance is built per entry. Must have at least one entry + * (env validation enforces it). + */ + domains: string[]; + /** Signature verifier — injected so tests can verify EOAs offline. */ + verifyMessage: VerifySiweMessage; + /** + * Enables magic-link sign-in when provided (email delivery is injected). + * Omitted (e.g. no email provider configured) leaves the method disabled. + */ + magicLink?: SendMagicLink; + /** Enables Google OAuth when the credentials are provided. */ + google?: { clientId: string; clientSecret: string }; + /** + * Railway PR previews only: resolve better-auth instances on demand for + * *.vercel.app hosts (each Vercel preview URL is unique, so a static + * allowlist can't cover them). Must NEVER be enabled on dev/production — + * the caller derives it from the Railway environment name, not from + * configuration, so it can't drift on. + */ + previewDynamicHosts?: boolean; +}; + +/** Hosts eligible for on-demand instances in preview mode. */ +const isPreviewHost = (host: string): boolean => + /^[a-z0-9-]+(\.[a-z0-9-]+)*\.vercel\.app$/i.test(host); + +/** Ceiling for cached dynamic instances (scanner-junk guard). */ +const MAX_DYNAMIC_INSTANCES = 100; + +/** + * Which sign-in methods this deployment actually serves. SIWE is always on; + * the optional ones mirror the exact config presence that registers their + * plugin, so a frontend reading this never renders a button whose endpoint + * would 404. + */ +export type AuthMethods = { + siwe: true; + magicLink: boolean; + google: boolean; +}; + +export type AuthResolver = { + /** + * Picks the better-auth instance whose SIWE domain matches the request + * Host. Returns undefined for hosts outside `domains` so callers can fail + * closed — an unlisted host must never get a session issued or read. + */ + resolve: (host: string | undefined) => Auth | undefined; + /** Instance bound to the first configured domain. */ + primary: Auth; + /** Sign-in methods enabled by this deployment's configuration. */ + methods: AuthMethods; +}; + +/** + * The browser-facing host behind the dashboard's /api/user proxy. The proxy + * sets it so we resolve the right per-host SIWE instance; the direct Host + * header would be the internal service host, never in AUTH_SIWE_DOMAINS. + * + * Prefers the custom x-anticapture-host: when a managed edge (e.g. Railway) + * sits in front of this service, it overwrites the x-forwarded-* set with its + * own host, so the proxy's value would be lost. A custom x- header passes + * through untouched. x-forwarded-host remains the fallback for edge-less + * setups (local dev). + */ +export const forwardedHost = ( + headers: Pick, +): string | undefined => + headers.get("x-anticapture-host") ?? + headers.get("x-forwarded-host") ?? + headers.get("host") ?? + undefined; + +/** + * The browser-visible origin for a frontend host. Localhost is served over + * http (dev); every other host is https. Used as each better-auth instance's + * baseURL so cookies/CSRF are scoped to the requesting frontend's own origin. + */ +export const originForHost = (host: string): string => + /^(localhost|127\.0\.0\.1)(:|$)/.test(host) + ? `http://${host}` + : `https://${host}`; + +function createAuth(config: AuthConfig, domain: string) { + const plugins: BetterAuthPlugin[] = [ + siwe({ + domain, + // Wallet-only users have no email; better-auth stores a placeholder. + anonymous: true, + getNonce: async () => generateRandomString(32, "a-z", "A-Z", "0-9"), + verifyMessage: async ({ message, signature, address, chainId }) => { + try { + return await config.verifyMessage({ + message, + signature, + address, + chainId, + }); + } catch { + return false; + } + }, + }), + ]; + + if (config.magicLink) { + const send = config.magicLink; + plugins.push( + magicLink({ sendMagicLink: ({ email, url }) => send({ email, url }) }), + ); + } + + return betterAuth({ + database: drizzleAdapter(config.db, { provider: "pg" }), + secret: config.secret, + // Per-host origin: the session cookie and CSRF scope to the frontend that + // made the request, so the same service serves localhost, the main domain, + // and every whitelabel host correctly. All origins are trusted so a + // session issued via one instance is accepted by the others. + baseURL: originForHost(domain), + // The instance's own domain is always trusted — for static instances it + // is already in the list; for preview-mode dynamic instances (a unique + // *.vercel.app host) it is what makes CSRF checks pass on that host. + trustedOrigins: [...new Set([...config.domains, domain])].map( + originForHost, + ), + // No wallet⟷email linking in v1 (product decision): a wallet login and a + // Google/email login are deliberately separate users (SIWE users carry a + // placeholder email, so they never collide). Same-email methods DO link: + // magic link and Google with one email converge on one user, and the + // link copies the provider's name/photo onto the local profile (local + // email/emailVerified are never touched) so a magic-link-born account + // still gets the Google picture. + account: { accountLinking: { updateUserInfoOnLink: true } }, + ...(config.google + ? { + socialProviders: { + google: { + clientId: config.google.clientId, + clientSecret: config.google.clientSecret, + }, + }, + } + : {}), + plugins, + }); +} + +// Derived from the concrete return of createAuth (not betterAuth's generic +// default) so instances remain assignable to this alias. +export type Auth = ReturnType; + +export function createAuthResolver(config: AuthConfig): AuthResolver { + // Instances are cheap — they share the DB and secret; only the SIWE domain + // differs, so sessions issued via one host validate on any of them. + const instances = new Map( + config.domains.map((domain) => [domain, createAuth(config, domain)]), + ); + // domains is non-empty by env validation. + const primary = instances.get(config.domains[0]!)!; + + const resolve = (host: string | undefined): Auth | undefined => { + if (!host) return undefined; + const existing = instances.get(host); + if (existing) return existing; + + // Preview envs only: every Vercel preview deployment gets a unique URL, + // so instances for them are built on demand. Anything that isn't a + // *.vercel.app host still fails closed, exactly like dev/production. + if (!config.previewDynamicHosts || !isPreviewHost(host)) return undefined; + + const dynamic = createAuth(config, host); + if (instances.size < MAX_DYNAMIC_INSTANCES) instances.set(host, dynamic); + return dynamic; + }; + + return { + resolve, + primary, + methods: { + siwe: true, + magicLink: Boolean(config.magicLink), + google: Boolean(config.google), + }, + }; +} diff --git a/apps/user-api/src/ci.ts b/apps/user-api/src/ci.ts new file mode 100644 index 000000000..b705d5b17 --- /dev/null +++ b/apps/user-api/src/ci.ts @@ -0,0 +1,12 @@ +/** + * Detects a Railway PR preview ("CI") environment, mirroring + * `apps/authful/src/ci.ts`. Railway sets RAILWAY_ENVIRONMENT_NAME per + * environment; anything that isn't `dev` or `production` is a transient + * preview deploy. Local runs (no Railway env) count as `dev` — preview-only + * affordances never activate on a developer machine. + */ +export function isRailwayPreviewEnv(): boolean { + return !["dev", "production"].includes( + process.env["RAILWAY_ENVIRONMENT_NAME"] || "dev", + ); +} diff --git a/apps/user-api/src/clients/authful/index.ts b/apps/user-api/src/clients/authful/index.ts new file mode 100644 index 000000000..3ac8f52cb --- /dev/null +++ b/apps/user-api/src/clients/authful/index.ts @@ -0,0 +1,83 @@ +/** + * East-west client for Authful's token surface, authenticated with the scoped + * provisioning key (restricted to `user:*` tenants). Called directly over the + * private network — never through Gateful. + */ +export type MintedToken = { + id: string; + token: string; // plaintext, returned exactly once by Authful +}; + +export type TokenUsage = { + id: string; + lastUsedAt: string | null; +}; + +export interface AuthfulClient { + mint(tenant: string, name: string): Promise; + revoke(tokenId: string): Promise; + /** Token metadata for a single tenant (used to surface lastUsedAt). */ + listByTenant(tenant: string): Promise; +} + +export class AuthfulHttpClient implements AuthfulClient { + private readonly baseUrl: string; + + constructor( + baseUrl: string, + private readonly provisioningApiKey: string, + ) { + // Hono 404s doubled paths ("//tokens"), so a trailing slash in + // AUTHFUL_URL must never leak into the endpoints — same trim gateful + // applies to its TOKEN_SERVICE_URL. + this.baseUrl = baseUrl.replace(/\/+$/, ""); + } + + private headers() { + return { + Authorization: `Bearer ${this.provisioningApiKey}`, + "Content-Type": "application/json", + }; + } + + async mint(tenant: string, name: string): Promise { + const res = await fetch(`${this.baseUrl}/tokens`, { + method: "POST", + headers: this.headers(), + // Product decision: user API keys are unbounded for now (free, no + // metering); gateful treats rateLimitPerMin <= 0 as no rate limit. + // Monetization later will mint with a real per-plan limit. + body: JSON.stringify({ tenant, name, rateLimitPerMin: 0 }), + }); + if (!res.ok) { + throw new Error(`authful mint failed: ${res.status}`); + } + const body = (await res.json()) as { id: string; token: string }; + return { id: body.id, token: body.token }; + } + + async revoke(tokenId: string): Promise { + const res = await fetch(`${this.baseUrl}/tokens/${tokenId}`, { + method: "DELETE", + headers: this.headers(), + }); + // 404 means already gone (revoked or never existed) — idempotent success. + if (!res.ok && res.status !== 404) { + throw new Error(`authful revoke failed: ${res.status}`); + } + } + + async listByTenant(tenant: string): Promise { + const res = await fetch( + `${this.baseUrl}/tokens?tenant=${encodeURIComponent(tenant)}`, + { headers: this.headers() }, + ); + if (!res.ok) { + throw new Error(`authful list failed: ${res.status}`); + } + const body = (await res.json()) as { + items: { id: string; lastUsedAt: string | null }[]; + }; + return body.items.map((t) => ({ id: t.id, lastUsedAt: t.lastUsedAt })); + } +} diff --git a/apps/user-api/src/controllers/api-keys/api-keys.integration.test.ts b/apps/user-api/src/controllers/api-keys/api-keys.integration.test.ts new file mode 100644 index 000000000..99709fd51 --- /dev/null +++ b/apps/user-api/src/controllers/api-keys/api-keys.integration.test.ts @@ -0,0 +1,259 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { drizzle } from "drizzle-orm/pglite"; +import { recoverMessageAddress } from "viem"; +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { createApp } from "@/app"; +import { createAuthResolver } from "@/auth"; +import type { AuthfulClient } from "@/clients/authful"; +import * as fullSchema from "@/database/schema"; +import { + account, + drafts, + session, + user, + userApiKeys, + verification, + walletAddress, +} from "@/database/schema"; +import { ApiKeysRepository } from "@/repositories/api-keys"; +import { DraftsRepository } from "@/repositories/drafts"; +import { ApiKeysService } from "@/services/api-keys"; +import { ProposalDraftsService } from "@/services/drafts"; + +const HOST = "localhost:3000"; +const ORIGIN = `http://${HOST}`; + +const verifyMessage = async ({ + message, + signature, + address, +}: { + message: string; + signature: string; + address: string; +}) => { + const recovered = await recoverMessageAddress({ + message, + signature: signature as `0x${string}`, + }); + return recovered.toLowerCase() === address.toLowerCase(); +}; + +type TestApp = ReturnType; + +describe("api-keys + Authful brokering integration", () => { + let client: PGlite; + let app: TestApp; + + // Fake Authful: records mint/revoke calls, returns a deterministic plaintext. + // listByTenant reports a fixed lastUsedAt so the enrichment path is covered. + const authful: AuthfulClient & { + mint: ReturnType; + revoke: ReturnType; + listByTenant: ReturnType; + } = { + mint: vi.fn(async (tenant: string) => ({ + id: crypto.randomUUID(), + token: `act_${tenant}`, + })), + revoke: vi.fn(async () => undefined), + listByTenant: vi.fn(async () => []), + }; + + beforeAll(async () => { + client = new PGlite(); + const db = drizzle(client, { schema: fullSchema }); + const tables = { + user, + session, + account, + verification, + walletAddress, + drafts, + userApiKeys, + }; + const { apply } = await pushSchema(tables as any, db as any); + await apply(); + + const authResolver = createAuthResolver({ + db, + secret: "integration-test-secret-0123456789abcdef", + domains: [HOST], + verifyMessage, + }); + + app = createApp({ + db, + authResolver, + draftsService: new ProposalDraftsService(new DraftsRepository(db)), + // small quota to exercise the limit + apiKeysService: new ApiKeysService(new ApiKeysRepository(db), authful, 2), + }); + }); + + afterAll(async () => { + await client.close(); + }); + + const baseHeaders = { host: HOST, origin: ORIGIN }; + + const signIn = async () => { + const wallet = privateKeyToAccount(generatePrivateKey()); + const nonceRes = await app.request("/api/auth/siwe/nonce", { + method: "POST", + headers: { ...baseHeaders, "content-type": "application/json" }, + body: JSON.stringify({ walletAddress: wallet.address, chainId: 1 }), + }); + const { nonce } = (await nonceRes.json()) as any; + const message = createSiweMessage({ + domain: HOST, + address: wallet.address, + chainId: 1, + nonce, + uri: ORIGIN, + version: "1", + }); + const signature = await wallet.signMessage({ message }); + const verifyRes = await app.request("/api/auth/siwe/verify", { + method: "POST", + headers: { ...baseHeaders, "content-type": "application/json" }, + body: JSON.stringify({ + message, + signature, + walletAddress: wallet.address, + chainId: 1, + }), + }); + const cookie = verifyRes.headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); + return cookie; + }; + + const authed = (cookie: string, extra: Record = {}) => ({ + ...baseHeaders, + cookie, + ...extra, + }); + + const createKey = (cookie: string, label = "my-agent") => + app.request("/me/api-keys", { + method: "POST", + headers: authed(cookie, { "content-type": "application/json" }), + body: JSON.stringify({ label }), + }); + + it("requires a session", async () => { + const res = await app.request("/me/api-keys", { headers: baseHeaders }); + expect(res.status).toBe(401); + }); + + it("mints via Authful under the user's tenant and returns plaintext once", async () => { + const cookie = await signIn(); + const res = await createKey(cookie, "prod-agent"); + expect(res.status).toBe(201); + const body = (await res.json()) as any; + + expect(body.label).toBe("prod-agent"); + expect(body.token).toMatch(/^act_user:/); // tenant = user: + expect(authful.mint).toHaveBeenCalledWith( + expect.stringMatching(/^user:/), + "self-service", + ); + }); + + it("lists the session user's keys without the plaintext", async () => { + const cookie = await signIn(); + await createKey(cookie, "a"); + const res = await app.request("/me/api-keys", { headers: authed(cookie) }); + const { items } = (await res.json()) as any; + + expect(items.length).toBe(1); + expect(items[0].label).toBe("a"); + expect(items[0]).not.toHaveProperty("token"); + expect(items[0]).toHaveProperty("lastUsedAt"); + }); + + it("surfaces lastUsedAt from Authful for the listed keys", async () => { + const cookie = await signIn(); + await createKey(cookie, "used"); + const mintedId = (await authful.mint.mock.results.at(-1)!.value).id; + authful.listByTenant.mockResolvedValueOnce([ + { id: mintedId, lastUsedAt: "2026-01-02T03:04:05.000Z" }, + ]); + + const res = await app.request("/me/api-keys", { headers: authed(cookie) }); + const { items } = (await res.json()) as any; + const used = items.find((k: { label: string }) => k.label === "used"); + expect(used.lastUsedAt).toBe("2026-01-02T03:04:05.000Z"); + }); + + it("still lists keys when Authful is unreachable (lastUsedAt null)", async () => { + const cookie = await signIn(); + await createKey(cookie, "resilient"); + authful.listByTenant.mockRejectedValueOnce(new Error("authful down")); + + const res = await app.request("/me/api-keys", { headers: authed(cookie) }); + expect(res.status).toBe(200); + const { items } = (await res.json()) as any; + expect( + items.find((k: { label: string }) => k.label === "resilient"), + ).toBeTruthy(); + }); + + it("does not list another user's keys", async () => { + const alice = await signIn(); + const bob = await signIn(); + await createKey(alice, "alice-key"); + + const res = await app.request("/me/api-keys", { headers: authed(bob) }); + const { items } = (await res.json()) as any; + expect(items).toHaveLength(0); + }); + + it("revokes in Authful then locally, and 404s a foreign key", async () => { + const owner = await signIn(); + const attacker = await signIn(); + const created = (await (await createKey(owner, "temp")).json()) as any; + + // Attacker cannot revoke it — 404, and Authful is never called. + authful.revoke.mockClear(); + const foreign = await app.request(`/me/api-keys/${created.id}`, { + method: "DELETE", + headers: authed(attacker), + }); + expect(foreign.status).toBe(404); + expect(authful.revoke).not.toHaveBeenCalled(); + + // Owner revokes — Authful called, key drops from the list. + const res = await app.request(`/me/api-keys/${created.id}`, { + method: "DELETE", + headers: authed(owner), + }); + expect(res.status).toBe(204); + expect(authful.revoke).toHaveBeenCalledOnce(); + + const list = await app.request("/me/api-keys", { headers: authed(owner) }); + const { items } = (await list.json()) as any; + expect( + items.find((k: { id: string }) => k.id === created.id), + ).toBeUndefined(); + }); + + it("enforces the per-user key quota", async () => { + const cookie = await signIn(); + expect((await createKey(cookie)).status).toBe(201); + expect((await createKey(cookie)).status).toBe(201); + const third = await createKey(cookie); + expect(third.status).toBe(403); + await expect(third.json() as any).resolves.toEqual({ + error: "api_key_limit_reached", + }); + }); +}); diff --git a/apps/user-api/src/controllers/api-keys/index.ts b/apps/user-api/src/controllers/api-keys/index.ts new file mode 100644 index 000000000..51b2d486a --- /dev/null +++ b/apps/user-api/src/controllers/api-keys/index.ts @@ -0,0 +1,140 @@ +import { createRoute, type OpenAPIHono } from "@hono/zod-openapi"; + +import type { AuthResolver } from "@/auth"; +import { + ApiKeyListResponseSchema, + ApiKeyParamsSchema, + CreateApiKeyBodySchema, + CreatedApiKeyResponseSchema, +} from "@/mappers/api-keys"; +import { ErrorResponseSchema, unauthorizedResponses } from "@/mappers/errors"; +import { sessionAuth } from "@/middlewares/session"; +import type { ApiKeyRow } from "@/repositories/api-keys"; +import { + ApiKeyQuotaExceededError, + type ApiKeysService, +} from "@/services/api-keys"; + +const toResponse = (row: ApiKeyRow, lastUsedAt: string | null = null) => ({ + id: row.id, + label: row.label, + createdAt: row.createdAt.toISOString(), + revokedAt: row.revokedAt?.toISOString() ?? null, + lastUsedAt, +}); + +export function apiKeysController( + app: OpenAPIHono, + service: ApiKeysService, + resolver: AuthResolver, +) { + const auth = sessionAuth(resolver); + + app.openapi( + createRoute({ + method: "get", + operationId: "listApiKeys", + path: "/me/api-keys", + summary: "List the session user's active API keys", + middleware: [auth] as const, + responses: { + 200: { + description: "Active API keys, newest first", + content: { "application/json": { schema: ApiKeyListResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const { id: userId } = c.get("sessionUser"); + const keys = await service.list(userId); + return c.json( + ApiKeyListResponseSchema.parse({ + items: keys.map((k) => toResponse(k, k.lastUsedAt)), + }), + 200, + ); + }, + ); + + app.openapi( + createRoute({ + method: "post", + operationId: "createApiKey", + path: "/me/api-keys", + summary: "Create an API key", + description: + "Mints a key scoped to the session user via Authful. The plaintext is " + + "returned exactly once and never stored here.", + middleware: [auth] as const, + request: { + body: { + // Without this a body-less POST skips JSON validation entirely and + // surfaces as a 500 instead of the intended 400. + required: true, + content: { "application/json": { schema: CreateApiKeyBodySchema } }, + }, + }, + responses: { + 201: { + description: "The created key, including its one-time plaintext", + content: { + "application/json": { schema: CreatedApiKeyResponseSchema }, + }, + }, + 403: { + description: "API key quota reached", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const { label } = c.req.valid("json"); + const { id: userId } = c.get("sessionUser"); + try { + const { key, plaintext } = await service.create(userId, label); + return c.json( + CreatedApiKeyResponseSchema.parse({ + ...toResponse(key), + token: plaintext, + }), + 201, + ); + } catch (err) { + if (err instanceof ApiKeyQuotaExceededError) { + return c.json({ error: "api_key_limit_reached" }, 403); + } + throw err; + } + }, + ); + + app.openapi( + createRoute({ + method: "delete", + operationId: "revokeApiKey", + path: "/me/api-keys/{id}", + summary: "Revoke an API key", + description: + "Only the owner can revoke. Ownership comes from the session.", + middleware: [auth] as const, + request: { params: ApiKeyParamsSchema }, + responses: { + 204: { description: "Key revoked" }, + 404: { + description: "Key not found", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const { id } = c.req.valid("param"); + const { id: userId } = c.get("sessionUser"); + const revoked = await service.revoke(id, userId); + if (!revoked) return c.json({ error: "api_key_not_found" }, 404); + return c.body(null, 204); + }, + ); +} diff --git a/apps/user-api/src/controllers/auth-methods.integration.test.ts b/apps/user-api/src/controllers/auth-methods.integration.test.ts new file mode 100644 index 000000000..02f8d71f0 --- /dev/null +++ b/apps/user-api/src/controllers/auth-methods.integration.test.ts @@ -0,0 +1,155 @@ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { drizzle } from "drizzle-orm/pglite"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { createApp } from "@/app"; +import { createAuthResolver } from "@/auth"; +import * as fullSchema from "@/database/schema"; +import { + account, + drafts, + session, + user, + verification, + walletAddress, +} from "@/database/schema"; +import { DraftsRepository } from "@/repositories/drafts"; +import { ProposalDraftsService } from "@/services/drafts"; + +const HOST = "localhost:3000"; +const ORIGIN = `http://${HOST}`; + +const buildApp = (opts: { + magicLink?: Parameters[0]["magicLink"]; + google?: Parameters[0]["google"]; +}) => { + const client = new PGlite(); + const db = drizzle(client, { schema: fullSchema }); + + const authResolver = createAuthResolver({ + db, + secret: "integration-test-secret-0123456789abcdef", + domains: [HOST], + verifyMessage: async () => false, + magicLink: opts.magicLink, + google: opts.google, + }); + + const app = createApp({ + db, + authResolver, + draftsService: new ProposalDraftsService(new DraftsRepository(db)), + }); + + return { client, db, app }; +}; + +const applySchema = async (db: ReturnType) => { + const tables = { + user, + session, + account, + verification, + walletAddress, + drafts, + }; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const { apply } = await pushSchema(tables as any, db as any); + await apply(); +}; + +const headers = { + host: HOST, + origin: ORIGIN, + "content-type": "application/json", +}; + +describe("optional auth methods", () => { + describe("magic link enabled", () => { + const sendMagicLink = vi.fn( + async (_params: { email: string; url: string }) => undefined, + ); + const { client, db, app } = buildApp({ magicLink: sendMagicLink }); + + beforeAll(() => applySchema(db)); + afterAll(async () => { + await client.close(); + }); + + it("sends a magic link with a sign-in url when requested", async () => { + const res = await app.request("/api/auth/sign-in/magic-link", { + method: "POST", + headers, + body: JSON.stringify({ email: "alice@example.com" }), + }); + + expect(res.status).toBe(200); + expect(sendMagicLink).toHaveBeenCalledTimes(1); + const arg = sendMagicLink.mock.calls[0]![0]; + expect(arg.email).toBe("alice@example.com"); + expect(arg.url).toContain("/api/auth/magic-link/verify"); + }); + + it("advertises magic link in /auth/methods", async () => { + const res = await app.request("/auth/methods", { headers }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + siwe: true, + magicLink: true, + google: false, + }); + }); + }); + + describe("magic link disabled (no sender configured)", () => { + const { client, db, app } = buildApp({}); + + beforeAll(() => applySchema(db)); + afterAll(async () => { + await client.close(); + }); + + it("does not expose the magic-link endpoint", async () => { + const res = await app.request("/api/auth/sign-in/magic-link", { + method: "POST", + headers, + body: JSON.stringify({ email: "alice@example.com" }), + }); + + // 404 (route absent) — the method is off until a sender is wired. + expect(res.status).toBe(404); + }); + + it("advertises only SIWE in /auth/methods", async () => { + const res = await app.request("/auth/methods", { headers }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + siwe: true, + magicLink: false, + google: false, + }); + }); + }); + + describe("google configured", () => { + const { client, db, app } = buildApp({ + google: { clientId: "gid", clientSecret: "gsecret" }, + }); + + beforeAll(() => applySchema(db)); + afterAll(async () => { + await client.close(); + }); + + it("advertises google in /auth/methods", async () => { + const res = await app.request("/auth/methods", { headers }); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + siwe: true, + magicLink: false, + google: true, + }); + }); + }); +}); diff --git a/apps/user-api/src/controllers/drafts/drafts.integration.test.ts b/apps/user-api/src/controllers/drafts/drafts.integration.test.ts new file mode 100644 index 000000000..2170f1e9b --- /dev/null +++ b/apps/user-api/src/controllers/drafts/drafts.integration.test.ts @@ -0,0 +1,399 @@ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { drizzle } from "drizzle-orm/pglite"; +import { recoverMessageAddress } from "viem"; +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { z, type ZodType } from "zod"; + +import { createApp } from "@/app"; +import { createAuthResolver } from "@/auth"; +import * as fullSchema from "@/database/schema"; +import { + account, + drafts, + session, + user, + verification, + walletAddress, +} from "@/database/schema"; +import { DraftListResponseSchema, DraftResponseSchema } from "@/mappers/drafts"; +import { ErrorResponseSchema } from "@/mappers/errors"; +import { DraftsRepository } from "@/repositories/drafts"; +import { ProposalDraftsService } from "@/services/drafts"; + +// better-auth's SIWE plugin has no exported response schema — this is the +// only shape this suite reads from it. +const NonceResponseSchema = z.object({ nonce: z.string() }); + +// Runtime-validates every response body against its route's own zod schema +// instead of casting, so a controller/schema mismatch fails the test. +async function readJson(res: Response, schema: ZodType): Promise { + return schema.parse(await res.json()); +} + +const HOST = "localhost:3000"; +const ORIGIN = `http://${HOST}`; +const DAO_ID = "ens"; + +// Offline EOA verifier — production injects viem's publicClient.verifyMessage +// (EIP-1271 capable); tests must not hit an RPC. +const verifyMessage = async ({ + message, + signature, + address, +}: { + message: string; + signature: string; + address: string; +}) => { + const recovered = await recoverMessageAddress({ + message, + signature: signature as `0x${string}`, + }); + return recovered.toLowerCase() === address.toLowerCase(); +}; + +type TestApp = ReturnType; + +describe("drafts + SIWE session integration", () => { + let client: PGlite; + let app: TestApp; + let quotaApp: TestApp; + + beforeAll(async () => { + client = new PGlite(); + const db = drizzle(client, { schema: fullSchema }); + // Only actual tables — the generated auth-schema also exports relations. + const tables = { + user, + session, + account, + verification, + walletAddress, + drafts, + }; + + // drizzle-kit's PgDatabase generic can't unify with our schema's inferred + // relations type; this is a drizzle-kit typing gap, not a test-side any. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { apply } = await pushSchema(tables, db as any); + await apply(); + + const authResolver = createAuthResolver({ + db, + secret: "integration-test-secret-0123456789abcdef", + domains: [HOST], + verifyMessage, + }); + + const repo = new DraftsRepository(db); + app = createApp({ + db, + authResolver, + draftsService: new ProposalDraftsService(repo), + }); + // Same DB, tiny quota — exercises the limit without 100 inserts. + quotaApp = createApp({ + db, + authResolver, + draftsService: new ProposalDraftsService(repo, 2), + }); + }); + + afterAll(async () => { + await client.close(); + }); + + const baseHeaders = { host: HOST, origin: ORIGIN }; + + const signIn = async (wallet: ReturnType) => { + const nonceRes = await app.request("/api/auth/siwe/nonce", { + method: "POST", + headers: { ...baseHeaders, "content-type": "application/json" }, + body: JSON.stringify({ walletAddress: wallet.address, chainId: 1 }), + }); + expect(nonceRes.status).toBe(200); + const { nonce } = await readJson(nonceRes, NonceResponseSchema); + + const message = createSiweMessage({ + domain: HOST, + address: wallet.address, + chainId: 1, + nonce, + uri: ORIGIN, + version: "1", + }); + const signature = await wallet.signMessage({ message }); + + const verifyRes = await app.request("/api/auth/siwe/verify", { + method: "POST", + headers: { ...baseHeaders, "content-type": "application/json" }, + body: JSON.stringify({ + message, + signature, + walletAddress: wallet.address, + chainId: 1, + }), + }); + expect(verifyRes.status).toBe(200); + + const setCookies = verifyRes.headers.getSetCookie(); + expect(setCookies.length).toBeGreaterThan(0); + const cookie = setCookies.map((c) => c.split(";")[0]).join("; "); + return { cookie, address: wallet.address }; + }; + + const newWallet = () => privateKeyToAccount(generatePrivateKey()); + + const authed = (cookie: string, extra: Record = {}) => ({ + ...baseHeaders, + cookie, + ...extra, + }); + + const createDraft = async ( + cookie: string, + body: Record = {}, + target: TestApp = app, + ) => + target.request("/drafts", { + method: "POST", + headers: authed(cookie, { "content-type": "application/json" }), + body: JSON.stringify({ daoId: DAO_ID, title: "t", ...body }), + }); + + it("signs in via SIWE and issues a session cookie", async () => { + const { cookie } = await signIn(newWallet()); + expect(cookie).toContain("better-auth"); + }); + + it("rejects drafts routes without a session", async () => { + const res = await app.request(`/drafts?daoId=${DAO_ID}`, { + headers: baseHeaders, + }); + expect(res.status).toBe(401); + }); + + it("rejects requests from hosts outside the allowlist", async () => { + const res = await app.request(`/drafts?daoId=${DAO_ID}`, { + headers: { host: "evil.example", origin: "http://evil.example" }, + }); + expect(res.status).toBe(400); + await expect(readJson(res, ErrorResponseSchema)).resolves.toEqual({ + error: "untrusted_host", + }); + }); + + it("creates a draft with a server-generated id and the author wallet", async () => { + const wallet = newWallet(); + const { cookie } = await signIn(wallet); + + const res = await createDraft(cookie, { title: "My proposal" }); + expect(res.status).toBe(201); + const draft = await readJson(res, DraftResponseSchema); + + expect(draft.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + expect(draft.authorAddress).toBe(wallet.address.toLowerCase()); + expect(draft.isOwner).toBe(true); + expect(draft.daoId).toBe(DAO_ID); + }); + + it("ignores a client-supplied id on create", async () => { + const { cookie } = await signIn(newWallet()); + const res = await createDraft(cookie, { + id: "11111111-1111-1111-1111-111111111111", + }); + expect(res.status).toBe(201); + const draft = await readJson(res, DraftResponseSchema); + expect(draft.id).not.toBe("11111111-1111-1111-1111-111111111111"); + }); + + it("lists only the session user's drafts for the requested dao", async () => { + const alice = await signIn(newWallet()); + const bob = await signIn(newWallet()); + + const created = await readJson( + await createDraft(alice.cookie), + DraftResponseSchema, + ); + await createDraft(alice.cookie, { daoId: "shu" }); + await createDraft(bob.cookie); + + const res = await app.request(`/drafts?daoId=${DAO_ID}`, { + headers: authed(alice.cookie), + }); + expect(res.status).toBe(200); + const { items } = await readJson(res, DraftListResponseSchema); + + expect(items.some((d) => d.id === created.id)).toBe(true); + expect(items.every((d) => d.daoId === DAO_ID)).toBe(true); + expect(items.every((d) => d.isOwner)).toBe(true); + }); + + it("serves the share endpoint publicly with isOwner from the session", async () => { + const owner = await signIn(newWallet()); + const other = await signIn(newWallet()); + const draft = await readJson( + await createDraft(owner.cookie), + DraftResponseSchema, + ); + + const anonymous = await app.request(`/drafts/${draft.id}`, { + headers: baseHeaders, + }); + expect(anonymous.status).toBe(200); + await expect( + readJson(anonymous, DraftResponseSchema), + ).resolves.toMatchObject({ + isOwner: false, + }); + + const asOwner = await app.request(`/drafts/${draft.id}`, { + headers: authed(owner.cookie), + }); + await expect(readJson(asOwner, DraftResponseSchema)).resolves.toMatchObject( + { + isOwner: true, + }, + ); + + const asOther = await app.request(`/drafts/${draft.id}`, { + headers: authed(other.cookie), + }); + await expect(readJson(asOther, DraftResponseSchema)).resolves.toMatchObject( + { + isOwner: false, + }, + ); + }); + + it("returns identical 404s for foreign and nonexistent drafts (no oracle)", async () => { + const owner = await signIn(newWallet()); + const attacker = await signIn(newWallet()); + const draft = await readJson( + await createDraft(owner.cookie), + DraftResponseSchema, + ); + + const patch = () => ({ + method: "PUT", + headers: authed(attacker.cookie, { "content-type": "application/json" }), + body: JSON.stringify({ title: "hijacked" }), + }); + const foreign = await app.request(`/drafts/${draft.id}`, patch()); + const missing = await app.request( + "/drafts/00000000-0000-4000-8000-000000000000", + patch(), + ); + + expect(foreign.status).toBe(404); + expect(missing.status).toBe(404); + await expect(readJson(foreign, ErrorResponseSchema)).resolves.toEqual( + await readJson(missing, ErrorResponseSchema), + ); + + // And the draft is untouched. + const check = await app.request(`/drafts/${draft.id}`, { + headers: baseHeaders, + }); + await expect(readJson(check, DraftResponseSchema)).resolves.toMatchObject({ + title: "t", + }); + }); + + it("lets the owner update and delete", async () => { + const owner = await signIn(newWallet()); + const draft = await readJson( + await createDraft(owner.cookie), + DraftResponseSchema, + ); + + const updated = await app.request(`/drafts/${draft.id}`, { + method: "PUT", + headers: authed(owner.cookie, { "content-type": "application/json" }), + body: JSON.stringify({ title: "updated" }), + }); + expect(updated.status).toBe(200); + await expect(readJson(updated, DraftResponseSchema)).resolves.toMatchObject( + { + title: "updated", + }, + ); + + const foreignDelete = await app.request(`/drafts/${draft.id}`, { + method: "DELETE", + headers: authed((await signIn(newWallet())).cookie), + }); + expect(foreignDelete.status).toBe(404); + + const deleted = await app.request(`/drafts/${draft.id}`, { + method: "DELETE", + headers: authed(owner.cookie), + }); + expect(deleted.status).toBe(204); + + const gone = await app.request(`/drafts/${draft.id}`, { + headers: baseHeaders, + }); + expect(gone.status).toBe(404); + }); + + it("enforces the per-user draft quota", async () => { + const { cookie } = await signIn(newWallet()); + + expect((await createDraft(cookie, {}, quotaApp)).status).toBe(201); + expect((await createDraft(cookie, {}, quotaApp)).status).toBe(201); + + const third = await createDraft(cookie, {}, quotaApp); + expect(third.status).toBe(403); + await expect(readJson(third, ErrorResponseSchema)).resolves.toEqual({ + error: "draft_limit_reached", + }); + }); + + it("claims migrated unclaimed drafts on first list (case-insensitive)", async () => { + const wallet = newWallet(); + const db = drizzle(client, { schema: fullSchema }); + // Simulates a row bulk-copied from a DAO DB: no owner yet, author address + // in mixed case, original id preserved. + const [migrated] = await db + .insert(drafts) + .values({ + userId: null, + authorAddress: wallet.address, // checksummed casing on purpose + daoId: DAO_ID, + title: "migrated draft", + discussionUrl: "", + body: "", + actions: [], + createdAt: 1700000000000, + updatedAt: 1700000000000, + }) + .returning(); + + const { cookie } = await signIn(wallet); + const res = await app.request(`/drafts?daoId=${DAO_ID}`, { + headers: authed(cookie), + }); + const { items } = await readJson(res, DraftListResponseSchema); + + const claimed = items.find((d) => d.id === migrated!.id); + expect(claimed).toBeDefined(); + expect(claimed!.isOwner).toBe(true); + expect(claimed!.title).toBe("migrated draft"); + }); + + it("rejects a create without daoId", async () => { + const { cookie } = await signIn(newWallet()); + const res = await app.request("/drafts", { + method: "POST", + headers: authed(cookie, { "content-type": "application/json" }), + body: JSON.stringify({ title: "no dao" }), + }); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/user-api/src/controllers/drafts/index.ts b/apps/user-api/src/controllers/drafts/index.ts new file mode 100644 index 000000000..e4b3e989f --- /dev/null +++ b/apps/user-api/src/controllers/drafts/index.ts @@ -0,0 +1,222 @@ +import { createRoute, type OpenAPIHono } from "@hono/zod-openapi"; + +import type { AuthResolver } from "@/auth"; +import { + CreateDraftBodySchema, + DraftListResponseSchema, + DraftParamsSchema, + DraftResponseSchema, + ListDraftsQuerySchema, + UpdateDraftBodySchema, +} from "@/mappers/drafts"; +import { ErrorResponseSchema, unauthorizedResponses } from "@/mappers/errors"; +import { optionalSession, sessionAuth } from "@/middlewares/session"; +import type { DraftRow } from "@/repositories/drafts"; +import { + DraftQuotaExceededError, + type ProposalDraftsService, +} from "@/services/drafts"; + +const toResponse = (row: DraftRow, sessionUserId: string | null) => ({ + id: row.id, + daoId: row.daoId, + authorAddress: row.authorAddress, + title: row.title, + discussionUrl: row.discussionUrl, + body: row.body, + actions: row.actions as Record[], + createdAt: row.createdAt, + updatedAt: row.updatedAt, + // Never derived client-side: a Google author has no wallet to compare, and + // the caller's identity comes exclusively from the session. + isOwner: row.userId !== null && row.userId === sessionUserId, +}); + +export function draftsController( + app: OpenAPIHono, + service: ProposalDraftsService, + resolver: AuthResolver, +) { + const auth = sessionAuth(resolver); + const maybeAuth = optionalSession(resolver); + + app.openapi( + createRoute({ + method: "get", + operationId: "listProposalDrafts", + path: "/drafts", + summary: "List the session user's drafts for a DAO", + middleware: [auth] as const, + request: { query: ListDraftsQuerySchema }, + responses: { + 200: { + description: + "Proposal drafts owned by the session user, newest first", + content: { "application/json": { schema: DraftListResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const { daoId } = c.req.valid("query"); + const { id: userId } = c.get("sessionUser"); + const rows = await service.listForUser(userId, daoId); + return c.json( + DraftListResponseSchema.parse({ + items: rows.map((r) => toResponse(r, userId)), + }), + 200, + ); + }, + ); + + app.openapi( + createRoute({ + method: "get", + operationId: "getDraft", + path: "/drafts/{id}", + summary: "Get a draft by ID", + description: + "Public share endpoint — anyone with the ID can view the draft. " + + "isOwner reflects the caller's session, when one is present.", + middleware: [maybeAuth] as const, + request: { params: DraftParamsSchema }, + responses: { + 200: { + description: "The requested draft", + content: { "application/json": { schema: DraftResponseSchema } }, + }, + 404: { + description: "Draft not found", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + }, + }), + async (c) => { + const { id } = c.req.valid("param"); + const sessionUser = c.get("sessionUser"); + const draft = await service.getById(id, sessionUser?.id ?? null); + if (!draft) return c.json({ error: "draft_not_found" }, 404); + return c.json( + DraftResponseSchema.parse(toResponse(draft, sessionUser?.id ?? null)), + 200, + ); + }, + ); + + app.openapi( + createRoute({ + method: "post", + operationId: "createDraft", + path: "/drafts", + summary: "Create a draft", + description: + "The draft id is generated server-side (it doubles as the share " + + "capability) and the author is the session user.", + middleware: [auth] as const, + request: { + body: { + // Without this a body-less POST skips JSON validation entirely and + // surfaces as a DB 500 instead of the intended 400. + required: true, + content: { "application/json": { schema: CreateDraftBodySchema } }, + }, + }, + responses: { + 201: { + description: "The created draft", + content: { "application/json": { schema: DraftResponseSchema } }, + }, + 403: { + description: "Draft quota reached", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const body = c.req.valid("json"); + const { id: userId } = c.get("sessionUser"); + try { + const draft = await service.create({ ...body, userId }); + return c.json( + DraftResponseSchema.parse(toResponse(draft, userId)), + 201, + ); + } catch (err) { + if (err instanceof DraftQuotaExceededError) { + return c.json({ error: "draft_limit_reached" }, 403); + } + throw err; + } + }, + ); + + app.openapi( + createRoute({ + method: "put", + operationId: "updateDraft", + path: "/drafts/{id}", + summary: "Update a draft", + description: + "Only the owner can update. Ownership comes from the session.", + middleware: [auth] as const, + request: { + params: DraftParamsSchema, + body: { + required: true, + content: { "application/json": { schema: UpdateDraftBodySchema } }, + }, + }, + responses: { + 200: { + description: "The updated draft", + content: { "application/json": { schema: DraftResponseSchema } }, + }, + 404: { + // Identical for nonexistent and foreign rows — no existence oracle. + description: "Draft not found", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const { id } = c.req.valid("param"); + const patch = c.req.valid("json"); + const { id: userId } = c.get("sessionUser"); + const draft = await service.update(id, userId, patch); + if (!draft) return c.json({ error: "draft_not_found" }, 404); + return c.json(DraftResponseSchema.parse(toResponse(draft, userId)), 200); + }, + ); + + app.openapi( + createRoute({ + method: "delete", + operationId: "deleteDraft", + path: "/drafts/{id}", + summary: "Delete a draft", + description: + "Only the owner can delete. Ownership comes from the session.", + middleware: [auth] as const, + request: { params: DraftParamsSchema }, + responses: { + 204: { description: "Draft deleted" }, + 404: { + // Identical for nonexistent and foreign rows — no existence oracle. + description: "Draft not found", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const { id } = c.req.valid("param"); + const { id: userId } = c.get("sessionUser"); + const deleted = await service.delete(id, userId); + if (!deleted) return c.json({ error: "draft_not_found" }, 404); + return c.body(null, 204); + }, + ); +} diff --git a/apps/user-api/src/controllers/preview-hosts.integration.test.ts b/apps/user-api/src/controllers/preview-hosts.integration.test.ts new file mode 100644 index 000000000..51a9bcccf --- /dev/null +++ b/apps/user-api/src/controllers/preview-hosts.integration.test.ts @@ -0,0 +1,152 @@ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { drizzle } from "drizzle-orm/pglite"; +import { recoverMessageAddress } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { createApp } from "@/app"; +import { createAuthResolver, type VerifySiweMessage } from "@/auth"; +import * as fullSchema from "@/database/schema"; +import { + account, + drafts, + session, + user, + verification, + walletAddress, +} from "@/database/schema"; +import { DraftsRepository } from "@/repositories/drafts"; +import { ProposalDraftsService } from "@/services/drafts"; + +const STATIC_HOST = "app.anticapture.com"; +const PREVIEW_HOST = "anticapture-abc123-ful.vercel.app"; + +// Throwaway test-only EOA — signs real SIWE messages offline. +const wallet = privateKeyToAccount(`0x${"11".repeat(32)}`); + +// Offline EOA verifier (mirrors what publicClient.verifyMessage does for EOAs). +const verifyMessage: VerifySiweMessage = async ({ + message, + signature, + address, +}) => { + const recovered = await recoverMessageAddress({ + message, + signature: signature as `0x${string}`, + }); + return recovered.toLowerCase() === address.toLowerCase(); +}; + +const siweLogin = async (app: ReturnType, host: string) => { + const headers = { + host, + origin: `https://${host}`, + "content-type": "application/json", + }; + const nonceRes = await app.request("/api/auth/siwe/nonce", { + method: "POST", + headers, + body: JSON.stringify({ walletAddress: wallet.address, chainId: 1 }), + }); + if (nonceRes.status !== 200) return nonceRes; + + const { nonce } = (await nonceRes.json()) as { nonce: string }; + const message = createSiweMessage({ + domain: host, + address: wallet.address, + chainId: 1, + nonce, + uri: `https://${host}`, + version: "1", + }); + const signature = await wallet.signMessage({ message }); + return app.request("/api/auth/siwe/verify", { + method: "POST", + headers, + body: JSON.stringify({ + message, + signature, + walletAddress: wallet.address, + chainId: 1, + }), + }); +}; + +const buildApp = async (previewDynamicHosts: boolean) => { + const client = new PGlite(); + const db = drizzle(client, { schema: fullSchema }); + const tables = { + user, + session, + account, + verification, + walletAddress, + drafts, + }; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + const { apply } = await pushSchema(tables as any, db as any); + await apply(); + + const authResolver = createAuthResolver({ + db, + secret: "integration-test-secret-0123456789abcdef", + domains: [STATIC_HOST], + verifyMessage, + previewDynamicHosts, + }); + + const app = createApp({ + db, + authResolver, + draftsService: new ProposalDraftsService(new DraftsRepository(db)), + }); + return { client, app }; +}; + +describe("preview dynamic hosts", () => { + describe("preview mode on", () => { + let client: PGlite; + let app: ReturnType; + + beforeAll(async () => { + ({ client, app } = await buildApp(true)); + }); + afterAll(async () => { + await client.close(); + }); + + it("signs in with real SIWE on a dynamic vercel.app host", async () => { + const res = await siweLogin(app, PREVIEW_HOST); + expect(res.status).toBe(200); + expect(res.headers.getSetCookie().join(";")).toContain( + "better-auth.session_token", + ); + }); + + it("still refuses hosts outside *.vercel.app", async () => { + const res = await siweLogin(app, "evil.example.com"); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "untrusted_host" }); + }); + }); + + describe("preview mode off (dev/production posture)", () => { + let client: PGlite; + let app: ReturnType; + + beforeAll(async () => { + ({ client, app } = await buildApp(false)); + }); + afterAll(async () => { + await client.close(); + }); + + it("refuses dynamic vercel.app hosts", async () => { + const res = await siweLogin(app, PREVIEW_HOST); + expect(res.status).toBe(400); + await expect(res.json()).resolves.toEqual({ error: "untrusted_host" }); + }); + }); +}); diff --git a/apps/user-api/src/database/auth-schema.ts b/apps/user-api/src/database/auth-schema.ts new file mode 100644 index 000000000..601237b49 --- /dev/null +++ b/apps/user-api/src/database/auth-schema.ts @@ -0,0 +1,123 @@ +import { relations } from "drizzle-orm"; +import { + pgTable, + text, + timestamp, + boolean, + integer, + index, +} from "drizzle-orm/pg-core"; + +export const user = pgTable("user", { + id: text("id").primaryKey(), + name: text("name").notNull(), + email: text("email").notNull().unique(), + emailVerified: boolean("email_verified").default(false).notNull(), + image: text("image"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), +}); + +export const session = pgTable( + "session", + { + id: text("id").primaryKey(), + expiresAt: timestamp("expires_at").notNull(), + token: text("token").notNull().unique(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + }, + (table) => [index("session_userId_idx").on(table.userId)], +); + +export const account = pgTable( + "account", + { + id: text("id").primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: timestamp("access_token_expires_at"), + refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("account_userId_idx").on(table.userId)], +); + +export const verification = pgTable( + "verification", + { + id: text("id").primaryKey(), + identifier: text("identifier").notNull(), + value: text("value").notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at") + .defaultNow() + .$onUpdate(() => /* @__PURE__ */ new Date()) + .notNull(), + }, + (table) => [index("verification_identifier_idx").on(table.identifier)], +); + +export const walletAddress = pgTable( + "wallet_address", + { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + address: text("address").notNull(), + chainId: integer("chain_id").notNull(), + isPrimary: boolean("is_primary").default(false), + createdAt: timestamp("created_at").notNull(), + }, + (table) => [index("walletAddress_userId_idx").on(table.userId)], +); + +export const userRelations = relations(user, ({ many }) => ({ + sessions: many(session), + accounts: many(account), + walletAddresss: many(walletAddress), +})); + +export const sessionRelations = relations(session, ({ one }) => ({ + user: one(user, { + fields: [session.userId], + references: [user.id], + }), +})); + +export const accountRelations = relations(account, ({ one }) => ({ + user: one(user, { + fields: [account.userId], + references: [user.id], + }), +})); + +export const walletAddressRelations = relations(walletAddress, ({ one }) => ({ + user: one(user, { + fields: [walletAddress.userId], + references: [user.id], + }), +})); diff --git a/apps/user-api/src/database/index.ts b/apps/user-api/src/database/index.ts new file mode 100644 index 000000000..aef2cfee4 --- /dev/null +++ b/apps/user-api/src/database/index.ts @@ -0,0 +1,17 @@ +import { drizzle } from "drizzle-orm/node-postgres"; + +import { env } from "@/env"; +import * as schema from "./schema"; + +// Timeouts so a wedged Postgres can't hang connections (or requests) forever. +export const db = drizzle({ + connection: { + connectionString: env.DATABASE_URL, + connectionTimeoutMillis: 10_000, + statement_timeout: 30_000, + }, + schema, +}); + +export * from "./schema"; +export * from "./types"; diff --git a/apps/user-api/src/database/schema.ts b/apps/user-api/src/database/schema.ts new file mode 100644 index 000000000..6401e85f9 --- /dev/null +++ b/apps/user-api/src/database/schema.ts @@ -0,0 +1,71 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + index, + jsonb, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { user } from "./auth-schema"; + +// Better-auth core tables (user, session, account, verification, and the SIWE +// plugin's walletAddress table) are generated by the better-auth CLI: +// +// pnpm --filter @anticapture/user-api auth:generate +// +// The User API runs on its own Postgres database (default `public` schema), so +// the CLI's unqualified output works as-is — no pgSchema namespacing needed. +export * from "./auth-schema"; + +export const drafts = pgTable( + "drafts", + { + // Server-generated: a client-chosen id would let a malicious caller mint + // guessable share links (the id is the share capability). + id: uuid("id").primaryKey().defaultRandom(), + // Owner. Null only for rows migrated from the DAO APIs whose author + // wallet has not signed in yet — claimed on first SIWE login via + // authorAddress (see ProposalDraftsService.claimMigrated). + userId: text("user_id").references(() => user.id, { onDelete: "cascade" }), + // Wallet that authored the draft, when known (SIWE authors and migrated + // rows): display + claim key. Null for email/Google authors. + authorAddress: text("author_address"), + daoId: text("dao_id").notNull(), + title: text("title").notNull().default(""), + discussionUrl: text("discussion_url").notNull().default(""), + body: text("body").notNull().default(""), + actions: jsonb("actions") + .notNull() + .default(sql`'[]'::jsonb`), + // Unix milliseconds, matching the DAO API rows these migrate from. + createdAt: bigint("created_at", { mode: "number" }).notNull(), + updatedAt: bigint("updated_at", { mode: "number" }).notNull(), + }, + (table) => [ + index().on(table.userId, table.daoId), + index().on(table.authorAddress), + ], +); + +// Ownership record for user-minted API keys. The plaintext and hash live only +// in Authful (tenant `user:`); here we keep just who owns which Authful +// token, plus the user's label. Never stores secrets. +export const userApiKeys = pgTable( + "user_api_keys", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + authfulTokenId: uuid("authful_token_id").notNull(), + label: text("label").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + }, + (table) => [index().on(table.userId)], +); diff --git a/apps/user-api/src/database/types.ts b/apps/user-api/src/database/types.ts new file mode 100644 index 000000000..2d402e791 --- /dev/null +++ b/apps/user-api/src/database/types.ts @@ -0,0 +1,11 @@ +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { PgliteDatabase } from "drizzle-orm/pglite"; + +import type * as schema from "./schema"; + +// Union with PGlite so integration tests can run the whole app in-memory, +// mirroring the authful service's pattern. Kept free of the runtime client +// (and its env validation) so tests can import types without a DATABASE_URL. +export type UserApiDrizzle = + | NodePgDatabase + | PgliteDatabase; diff --git a/apps/user-api/src/email/magic-link.test.ts b/apps/user-api/src/email/magic-link.test.ts new file mode 100644 index 000000000..8b870ad7e --- /dev/null +++ b/apps/user-api/src/email/magic-link.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { magicLinkLandingUrl } from "@/email/magic-link"; + +describe("magicLinkLandingUrl", () => { + it("rewrites the verify endpoint into the interstitial page, keeping token and callbackURL", () => { + const verify = + "http://localhost:3000/api/auth/magic-link/verify?token=abc123&callbackURL=http%3A%2F%2Flocalhost%3A3000%2Fens%2Fproposals"; + + expect(magicLinkLandingUrl(verify)).toBe( + "http://localhost:3000/auth/magic-link?token=abc123&callbackURL=http%3A%2F%2Flocalhost%3A3000%2Fens%2Fproposals", + ); + }); + + it("stays on the origin that issued the link (per-host instances)", () => { + const verify = + "https://dev.anticapture.com/api/auth/magic-link/verify?token=t"; + + expect(magicLinkLandingUrl(verify)).toBe( + "https://dev.anticapture.com/auth/magic-link?token=t", + ); + }); +}); diff --git a/apps/user-api/src/email/magic-link.ts b/apps/user-api/src/email/magic-link.ts new file mode 100644 index 000000000..9b651a2a8 --- /dev/null +++ b/apps/user-api/src/email/magic-link.ts @@ -0,0 +1,53 @@ +import { Resend } from "resend"; + +import type { SendMagicLink } from "@/auth"; + +/** + * The verify endpoint consumes its single-use token on the first GET, and + * mail pipelines routinely prefetch links (AV scanners, client previews, + * click-tracking) — a direct link would arrive already burned when the user + * clicks. The email therefore links the dashboard's interstitial page, which + * triggers the verify from the browser (JS/user gesture) that prefetchers + * don't execute. + */ +export const magicLinkLandingUrl = (verifyUrl: string): string => { + const verify = new URL(verifyUrl); + const landing = new URL("/auth/magic-link", verify.origin); + landing.search = verify.search; // token + callbackURL + return landing.toString(); +}; + +/** + * Builds a magic-link email sender backed by Resend, or returns undefined when + * no API key is configured — which leaves magic-link sign-in disabled (the + * service stays SIWE-only). Mirrors the dashboard's existing Resend usage. + */ +export const createMagicLinkSender = ( + apiKey: string | undefined, + from: string, +): SendMagicLink | undefined => { + if (!apiKey) return undefined; + const resend = new Resend(apiKey); + + return async ({ email, url }) => { + const landingUrl = magicLinkLandingUrl(url); + const { error } = await resend.emails.send({ + from, + to: email, + subject: "Sign in to Anticapture", + html: ` +
+

Sign in to Anticapture

+

Click the button below to sign in. This link expires shortly and can be used once.

+

+ Sign in +

+

If you didn't request this, you can safely ignore this email.

+
+ `, + }); + // Surface delivery failures so the verify endpoint returns 5xx instead of + // silently reporting success. + if (error) throw new Error(`magic-link email failed: ${error.message}`); + }; +}; diff --git a/apps/user-api/src/env.ts b/apps/user-api/src/env.ts new file mode 100644 index 000000000..d6a1c77f2 --- /dev/null +++ b/apps/user-api/src/env.ts @@ -0,0 +1,88 @@ +import dotenv from "dotenv"; +import { z } from "zod"; + +dotenv.config(); + +const csv = z.string().transform((val) => + val + .split(",") + .map((s) => s.trim()) + .filter(Boolean), +); + +const envSchema = z + .object({ + PORT: z.coerce.number().default(4003), + DATABASE_URL: z.string(), + + // Better-auth session signing key. Own root of trust for every session + // this service issues — must be >= 32 high-entropy chars. + BETTER_AUTH_SECRET: z.string().min(32), + + // Allowed frontend hosts (main dashboard, localhost for local dev, every + // whitelabel host). Single source of truth per host: the SIWE domain a + // signed message must match, the better-auth baseURL (cookie/CSRF scope, + // derived as https:// — http for localhost), and the trusted-origin + // allowlist. There is deliberately no single BETTER_AUTH_URL: the service + // serves many origins and each request is scoped to its own. + AUTH_SIWE_DOMAINS: csv.refine((a) => a.length > 0, { + message: "AUTH_SIWE_DOMAINS must list at least one host", + }), + + // RPC endpoint used to verify EIP-1271 / smart-contract-wallet signatures. + RPC_URL: z.string().url(), + + // Optional auth methods — each stays disabled until its config is present, + // so the service boots SIWE-only out of the box. + GOOGLE_CLIENT_ID: z.string().optional(), + GOOGLE_CLIENT_SECRET: z.string().optional(), + // Magic link: enabled when RESEND_API_KEY is set. RESEND_FROM_EMAIL + // defaults to Resend's sandbox sender for local/dev. + RESEND_API_KEY: z.string().optional(), + RESEND_FROM_EMAIL: z.string().default("onboarding@resend.dev"), + + // User self-service API keys: enabled when both are set. The User API + // brokers keys into Authful over the private network using a provisioning + // key scoped to `user:*` tenants. Blank counts as unset (an empty Railway + // var or env template must not fail a SIWE-only boot) — same normalization + // as authful's optionalSecret. + AUTHFUL_URL: z + .string() + .url() + .optional() + .or(z.literal("").transform(() => undefined)), + AUTHFUL_PROVISIONING_API_KEY: z + .string() + .min(16) + .optional() + .or(z.literal("").transform(() => undefined)), + }) + .superRefine((data, ctx) => { + if (Boolean(data.GOOGLE_CLIENT_ID) !== Boolean(data.GOOGLE_CLIENT_SECRET)) { + ctx.addIssue({ + code: "custom", + path: ["GOOGLE_CLIENT_SECRET"], + message: + "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set together", + }); + } + if ( + Boolean(data.AUTHFUL_URL) !== Boolean(data.AUTHFUL_PROVISIONING_API_KEY) + ) { + ctx.addIssue({ + code: "custom", + path: ["AUTHFUL_PROVISIONING_API_KEY"], + message: + "AUTHFUL_URL and AUTHFUL_PROVISIONING_API_KEY must be set together", + }); + } + }); + +const _env = envSchema.safeParse(process.env); + +if (_env.success === false) { + console.error("Invalid environment variables", _env.error.issues); + throw new Error("Invalid environment variables"); +} + +export const env = _env.data; diff --git a/apps/user-api/src/index.ts b/apps/user-api/src/index.ts new file mode 100644 index 000000000..65006aba2 --- /dev/null +++ b/apps/user-api/src/index.ts @@ -0,0 +1,43 @@ +import { serve } from "@hono/node-server"; + +import { createApp } from "@/app"; +import { authResolver } from "@/auth-instance"; +import { AuthfulHttpClient } from "@/clients/authful"; +import { db } from "@/database"; +import { env } from "@/env"; +import { logger } from "@/logger"; +import { ApiKeysRepository } from "@/repositories/api-keys"; +import { DraftsRepository } from "@/repositories/drafts"; +import { ApiKeysService } from "@/services/api-keys"; +import { ProposalDraftsService } from "@/services/drafts"; + +// Self-service API keys are enabled only when Authful provisioning is wired +// (env validation guarantees the pair is set together). +const apiKeysService = + env.AUTHFUL_URL && env.AUTHFUL_PROVISIONING_API_KEY + ? new ApiKeysService( + new ApiKeysRepository(db), + new AuthfulHttpClient( + env.AUTHFUL_URL, + env.AUTHFUL_PROVISIONING_API_KEY, + ), + ) + : undefined; + +const app = createApp({ + db, + authResolver, + draftsService: new ProposalDraftsService(new DraftsRepository(db)), + apiKeysService, +}); + +app.doc("/docs/json", { + openapi: "3.1.0", + info: { title: "Anticapture User API", version: "1.0.0" }, +}); + +logger.info({ port: env.PORT }, "User API running"); + +serve({ fetch: app.fetch, port: env.PORT, hostname: "::" }); + +export { app }; diff --git a/apps/user-api/src/instrumentation.ts b/apps/user-api/src/instrumentation.ts new file mode 100644 index 000000000..7e74a54dd --- /dev/null +++ b/apps/user-api/src/instrumentation.ts @@ -0,0 +1,6 @@ +import { createObservabilityProvider } from "@anticapture/observability"; + +const observability = createObservabilityProvider("anticapture-user-api"); + +export const exporter = observability.exporter; +export const meterProvider = observability.meterProvider; diff --git a/apps/user-api/src/logger.ts b/apps/user-api/src/logger.ts new file mode 100644 index 000000000..b0bc53366 --- /dev/null +++ b/apps/user-api/src/logger.ts @@ -0,0 +1,3 @@ +import { createLogger } from "@anticapture/observability"; + +export const logger = createLogger("anticapture-user-api"); diff --git a/apps/user-api/src/mappers/api-keys/index.ts b/apps/user-api/src/mappers/api-keys/index.ts new file mode 100644 index 000000000..01fbc7285 --- /dev/null +++ b/apps/user-api/src/mappers/api-keys/index.ts @@ -0,0 +1,33 @@ +import { z } from "@hono/zod-openapi"; + +export const ApiKeySchema = z + .object({ + id: z.string(), + label: z.string(), + createdAt: z.string(), + revokedAt: z.string().nullable(), + // Sourced from Authful; null when never used or Authful was unreachable. + lastUsedAt: z.string().nullable(), + }) + .openapi("ApiKey"); + +export const ApiKeyListResponseSchema = z + .object({ items: z.array(ApiKeySchema) }) + .openapi("ApiKeyListResponse"); + +export const CreateApiKeyBodySchema = z + .object({ label: z.string().min(1).max(100) }) + .openapi("CreateApiKeyBody"); + +// The plaintext is included exactly once, on creation only. +export const CreatedApiKeyResponseSchema = ApiKeySchema.extend({ + token: z + .string() + .describe( + "Plaintext API key — shown exactly once, never retrievable again", + ), +}).openapi("CreatedApiKey"); + +export const ApiKeyParamsSchema = z + .object({ id: z.uuid() }) + .openapi("ApiKeyParams"); diff --git a/apps/user-api/src/mappers/drafts/index.ts b/apps/user-api/src/mappers/drafts/index.ts new file mode 100644 index 000000000..253313f78 --- /dev/null +++ b/apps/user-api/src/mappers/drafts/index.ts @@ -0,0 +1,62 @@ +import { z } from "@hono/zod-openapi"; + +const ActionSchema = z.record(z.string(), z.unknown()).openapi("DraftAction", { + description: "A single proposal action encoded as a JSON object.", +}); + +export const DraftResponseSchema = z + .object({ + id: z.string(), + daoId: z.string(), + // Wallet that authored the draft, when known. Null for email/Google + // authors — display must not assume an address exists. + authorAddress: z.string().nullable(), + title: z.string(), + discussionUrl: z.string(), + body: z.string(), + actions: z.array(ActionSchema), + createdAt: z.number(), + updatedAt: z.number(), + // Derived server-side from the session — replaces the dashboard's old + // author-vs-connected-wallet comparison (Editor vs shared Preview mode). + isOwner: z.boolean(), + }) + .openapi("Draft"); + +export type DraftResponse = z.infer; + +export const DraftListResponseSchema = z + .object({ items: z.array(DraftResponseSchema) }) + .openapi("DraftList"); + +// daoId is a plain filter string, not an authorization boundary — the User +// API deliberately does not duplicate the DAO registry (avoids a sixth +// touchpoint that drifts). Unknown DAOs just produce empty lists. +export const ListDraftsQuerySchema = z + .object({ daoId: z.string().min(1) }) + .openapi("ListDraftsQuery"); + +export const DraftParamsSchema = z + .object({ + id: z.uuid().openapi({ description: "Server-generated draft UUID." }), + }) + .openapi("DraftParams"); + +export const CreateDraftBodySchema = z + .object({ + daoId: z.string().min(1), + title: z.string().default(""), + discussionUrl: z.string().default(""), + body: z.string().default(""), + actions: z.array(ActionSchema).default([]), + }) + .openapi("CreateDraftBody"); + +export const UpdateDraftBodySchema = z + .object({ + title: z.string().optional(), + discussionUrl: z.string().optional(), + body: z.string().optional(), + actions: z.array(ActionSchema).optional(), + }) + .openapi("UpdateDraftBody"); diff --git a/apps/user-api/src/mappers/errors/index.ts b/apps/user-api/src/mappers/errors/index.ts new file mode 100644 index 000000000..a640b02b2 --- /dev/null +++ b/apps/user-api/src/mappers/errors/index.ts @@ -0,0 +1,20 @@ +import { z } from "@hono/zod-openapi"; + +export const ErrorResponseSchema = z + .object({ error: z.string() }) + .openapi("ErrorResponse"); + +/** + * OpenAPI responses every session-gated route shares: sessionAuth rejects + * untrusted Hosts with 400 and missing/invalid sessions with 401. + */ +export const unauthorizedResponses = { + 400: { + description: "Request Host is not a trusted domain", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 401: { + description: "Missing or invalid session", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, +} as const; diff --git a/apps/user-api/src/metrics.ts b/apps/user-api/src/metrics.ts new file mode 100644 index 000000000..cf57eb975 --- /dev/null +++ b/apps/user-api/src/metrics.ts @@ -0,0 +1,15 @@ +import { meterProvider } from "./instrumentation.js"; + +const meter = meterProvider.getMeter("anticapture-user-api"); + +export const httpRequestDuration = meter.createHistogram( + "http_server_request_duration_seconds", + { + description: "Duration of HTTP requests in seconds", + advice: { + explicitBucketBoundaries: [ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, + ], + }, + }, +); diff --git a/apps/user-api/src/middlewares/logger.ts b/apps/user-api/src/middlewares/logger.ts new file mode 100644 index 000000000..60ee8c77a --- /dev/null +++ b/apps/user-api/src/middlewares/logger.ts @@ -0,0 +1,34 @@ +import type { MiddlewareHandler } from "hono"; + +import { logger } from "../logger.js"; + +export const requestLogger = (): MiddlewareHandler => { + return async (c, next) => { + const start = performance.now(); + + try { + await next(); + } catch (err) { + logger.error( + { + err, + method: c.req.method, + path: c.req.path, + durationMs: Math.round(performance.now() - start), + }, + `${c.req.method} ${c.req.path} failed`, + ); + throw err; + } + + logger.info( + { + method: c.req.method, + path: c.req.path, + status: c.res.status, + durationMs: Math.round(performance.now() - start), + }, + `${c.req.method} ${c.req.path} ${c.res.status}`, + ); + }; +}; diff --git a/apps/user-api/src/middlewares/metrics.ts b/apps/user-api/src/middlewares/metrics.ts new file mode 100644 index 000000000..71bbb0625 --- /dev/null +++ b/apps/user-api/src/middlewares/metrics.ts @@ -0,0 +1,29 @@ +import type { MiddlewareHandler } from "hono"; +import { routePath } from "hono/route"; + +import { httpRequestDuration } from "../metrics.js"; + +const SKIP_PATHS = new Set(["/metrics", "/health"]); + +export const metricsMiddleware = (): MiddlewareHandler => { + return async (c, next) => { + if (SKIP_PATHS.has(c.req.path)) return next(); + + const start = performance.now(); + let status = 500; + try { + await next(); + status = c.res.status; + } catch (err) { + status = c.res?.status ?? 500; + throw err; + } finally { + const duration = (performance.now() - start) / 1000; + httpRequestDuration.record(duration, { + http_request_method: c.req.method, + http_route: routePath(c) ?? c.req.path, + http_response_status_code: status, + }); + } + }; +}; diff --git a/apps/user-api/src/middlewares/session.ts b/apps/user-api/src/middlewares/session.ts new file mode 100644 index 000000000..b6974f3e1 --- /dev/null +++ b/apps/user-api/src/middlewares/session.ts @@ -0,0 +1,47 @@ +import { createMiddleware } from "hono/factory"; + +import { forwardedHost, type AuthResolver } from "@/auth"; + +export type SessionUser = { id: string }; + +export type SessionVariables = { + sessionUser: SessionUser; +}; + +export type OptionalSessionVariables = { + sessionUser: SessionUser | null; +}; + +/** + * Requires a valid better-auth session cookie. The instance is resolved per + * request Host (fail-closed for unlisted hosts) — any instance validates any + * session since they share the secret and database. + */ +export const sessionAuth = (resolver: AuthResolver) => + createMiddleware<{ Variables: SessionVariables }>(async (c, next) => { + const auth = resolver.resolve(forwardedHost(c.req.raw.headers)); + if (!auth) return c.json({ error: "untrusted_host" }, 400); + + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + if (!session) return c.json({ error: "unauthenticated" }, 401); + + c.set("sessionUser", { id: session.user.id }); + await next(); + }); + +/** + * Resolves the session when present but never rejects — for public routes + * (shared-draft reads) that only personalize the response (isOwner). + */ +export const optionalSession = (resolver: AuthResolver) => + createMiddleware<{ Variables: OptionalSessionVariables }>(async (c, next) => { + const auth = resolver.resolve(forwardedHost(c.req.raw.headers)); + const session = auth + ? await auth.api.getSession({ headers: c.req.raw.headers }) + : null; + + c.set("sessionUser", session ? { id: session.user.id } : null); + await next(); + }); diff --git a/apps/user-api/src/repositories/api-keys/index.ts b/apps/user-api/src/repositories/api-keys/index.ts new file mode 100644 index 000000000..036c1ab2b --- /dev/null +++ b/apps/user-api/src/repositories/api-keys/index.ts @@ -0,0 +1,85 @@ +import { and, count, desc, eq, isNull } from "drizzle-orm"; +import { sql } from "drizzle-orm"; + +import type { UserApiDrizzle } from "@/database/types"; +import { userApiKeys } from "@/database/schema"; + +export type ApiKeyRow = typeof userApiKeys.$inferSelect; + +export class ApiKeysRepository { + constructor(private readonly db: UserApiDrizzle) {} + + /** + * Quota-checked insert, serialized per user: the transaction-scoped + * advisory lock queues concurrent creates for the same user, so the + * active-key count can't be read stale and the cap can't be raced past. + * Returns undefined when the quota is already full. + */ + async createWithinQuota( + input: { + userId: string; + authfulTokenId: string; + label: string; + }, + maxPerUser: number, + ): Promise { + return this.db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${input.userId}))`, + ); + + const [current] = await tx + .select({ value: count() }) + .from(userApiKeys) + .where( + and( + eq(userApiKeys.userId, input.userId), + isNull(userApiKeys.revokedAt), + ), + ); + if ((current?.value ?? 0) >= maxPerUser) return undefined; + + const [row] = await tx.insert(userApiKeys).values(input).returning(); + return row as ApiKeyRow; + }); + } + + /** Active (non-revoked) keys for a user, newest first. */ + async listActiveByUser(userId: string): Promise { + return this.db + .select() + .from(userApiKeys) + .where(and(eq(userApiKeys.userId, userId), isNull(userApiKeys.revokedAt))) + .orderBy(desc(userApiKeys.createdAt)); + } + + async countActiveByUser(userId: string): Promise { + const rows = await this.listActiveByUser(userId); + return rows.length; + } + + /** Ownership is in the WHERE clause: a foreign id matches zero rows. */ + async findOwned(id: string, userId: string): Promise { + const [row] = await this.db + .select() + .from(userApiKeys) + .where(and(eq(userApiKeys.id, id), eq(userApiKeys.userId, userId))) + .limit(1); + return row; + } + + async markRevoked(id: string, userId: string): Promise { + const rows = await this.db + .update(userApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(userApiKeys.id, id), + eq(userApiKeys.userId, userId), + isNull(userApiKeys.revokedAt), + ), + ) + .returning(); + return rows.length > 0; + } +} diff --git a/apps/user-api/src/repositories/drafts/index.ts b/apps/user-api/src/repositories/drafts/index.ts new file mode 100644 index 000000000..94a89349c --- /dev/null +++ b/apps/user-api/src/repositories/drafts/index.ts @@ -0,0 +1,129 @@ +import { and, count, desc, eq, inArray, isNull, sql } from "drizzle-orm"; + +import type { UserApiDrizzle } from "@/database/types"; +import { drafts, walletAddress } from "@/database/schema"; + +export type DraftRow = typeof drafts.$inferSelect; + +export type CreateDraftInput = { + userId: string; + authorAddress: string | null; + daoId: string; + title: string; + discussionUrl: string; + body: string; + actions: unknown[]; +}; + +export type UpdateDraftInput = Partial< + Pick +>; + +export class DraftsRepository { + constructor(private readonly db: UserApiDrizzle) {} + + async listByUserAndDao(userId: string, daoId: string): Promise { + return this.db + .select() + .from(drafts) + .where(and(eq(drafts.userId, userId), eq(drafts.daoId, daoId))) + .orderBy(desc(drafts.updatedAt)); + } + + async findById(id: string): Promise { + const [row] = await this.db + .select() + .from(drafts) + .where(eq(drafts.id, id)) + .limit(1); + return row; + } + + /** + * Quota-checked insert, serialized per user: the transaction-scoped + * advisory lock queues concurrent creates for the same user, so the count + * can't be read stale and the cap can't be raced past. Returns undefined + * when the quota is already full. + */ + async createWithinQuota( + input: CreateDraftInput, + maxPerUser: number, + ): Promise { + return this.db.transaction(async (tx) => { + await tx.execute( + sql`select pg_advisory_xact_lock(hashtext(${input.userId}))`, + ); + + const [current] = await tx + .select({ value: count() }) + .from(drafts) + .where(eq(drafts.userId, input.userId)); + if ((current?.value ?? 0) >= maxPerUser) return undefined; + + const now = Date.now(); + const [row] = await tx + .insert(drafts) + .values({ + userId: input.userId, + authorAddress: input.authorAddress?.toLowerCase() ?? null, + daoId: input.daoId, + title: input.title, + discussionUrl: input.discussionUrl, + body: input.body, + actions: input.actions, + createdAt: now, + updatedAt: now, + }) + .returning(); + return row as DraftRow; + }); + } + + /** Ownership lives in the WHERE clause: a foreign row matches zero rows. */ + async update( + id: string, + userId: string, + patch: UpdateDraftInput, + ): Promise { + const [row] = await this.db + .update(drafts) + .set({ ...patch, updatedAt: Date.now() }) + .where(and(eq(drafts.id, id), eq(drafts.userId, userId))) + .returning(); + return row; + } + + async delete(id: string, userId: string): Promise { + const rows = await this.db + .delete(drafts) + .where(and(eq(drafts.id, id), eq(drafts.userId, userId))) + .returning(); + return rows.length > 0; + } + + /** Wallets linked to the user by the SIWE plugin, lowercased. */ + async findWalletAddresses(userId: string): Promise { + const rows = await this.db + .select({ address: walletAddress.address }) + .from(walletAddress) + .where(eq(walletAddress.userId, userId)); + return rows.map((r) => r.address.toLowerCase()); + } + + /** + * Attaches migrated, still-unclaimed rows (userId IS NULL) whose + * authorAddress matches one of the user's wallets. Idempotent. + */ + async claimByAddresses(userId: string, addresses: string[]): Promise { + if (addresses.length === 0) return; + await this.db + .update(drafts) + .set({ userId }) + .where( + and( + isNull(drafts.userId), + inArray(sql`lower(${drafts.authorAddress})`, addresses), + ), + ); + } +} diff --git a/apps/user-api/src/services/api-keys/index.ts b/apps/user-api/src/services/api-keys/index.ts new file mode 100644 index 000000000..28b80d75a --- /dev/null +++ b/apps/user-api/src/services/api-keys/index.ts @@ -0,0 +1,101 @@ +import type { AuthfulClient } from "@/clients/authful"; +import type { ApiKeyRow, ApiKeysRepository } from "@/repositories/api-keys"; + +export const DEFAULT_MAX_KEYS_PER_USER = 10; + +export const USER_TENANT_PREFIX = "user:"; + +export class ApiKeyQuotaExceededError extends Error { + constructor(readonly limit: number) { + super(`API key limit of ${limit} reached`); + this.name = "ApiKeyQuotaExceededError"; + } +} + +export type CreatedApiKey = { + key: ApiKeyRow; + /** Plaintext from Authful — returned to the caller exactly once. */ + plaintext: string; +}; + +export type ApiKeyWithUsage = ApiKeyRow & { lastUsedAt: string | null }; + +export class ApiKeysService { + constructor( + private readonly repo: ApiKeysRepository, + private readonly authful: AuthfulClient, + private readonly maxKeysPerUser = DEFAULT_MAX_KEYS_PER_USER, + ) {} + + async create(userId: string, label: string): Promise { + // Fast-path reject at the cap without minting anything. Not + // authoritative — the quota is enforced atomically at insert time. + const current = await this.repo.countActiveByUser(userId); + if (current >= this.maxKeysPerUser) { + throw new ApiKeyQuotaExceededError(this.maxKeysPerUser); + } + + // Mint in Authful under this user's own tenant, then record ownership. + // The Authful token name is deliberately NOT the user's label: gateful + // exports tenant+name as Prometheus labels, so a user-supplied string + // here would mint unbounded time series. The human label lives only in + // this service's own table. + const minted = await this.authful.mint( + `${USER_TENANT_PREFIX}${userId}`, + "self-service", + ); + + try { + const key = await this.repo.createWithinQuota( + { userId, authfulTokenId: minted.id, label }, + this.maxKeysPerUser, + ); + if (!key) { + // Lost a concurrent race to the last slot — release the minted token. + await this.authful.revoke(minted.id).catch(() => undefined); + throw new ApiKeyQuotaExceededError(this.maxKeysPerUser); + } + return { key, plaintext: minted.token }; + } catch (err) { + // Don't leak an orphan usable token if the ownership write fails (the + // quota branch above has already revoked its own mint). + if (!(err instanceof ApiKeyQuotaExceededError)) { + await this.authful.revoke(minted.id).catch(() => undefined); + } + throw err; + } + } + + async list(userId: string): Promise { + // lastUsedAt lives in Authful. Enrich best-effort: if Authful is + // unreachable, still return the keys (usage shown as unknown) rather than + // failing the whole list. Both lookups depend only on userId, so the DB + // read and the Authful round-trip run concurrently. + const [rows, usage] = await Promise.all([ + this.repo.listActiveByUser(userId), + this.authful + .listByTenant(`${USER_TENANT_PREFIX}${userId}`) + .then((tokens) => new Map(tokens.map((t) => [t.id, t.lastUsedAt]))) + .catch(() => new Map()), + ]); + + return rows.map((row) => ({ + ...row, + lastUsedAt: usage.get(row.authfulTokenId) ?? null, + })); + } + + /** + * Revokes a key the user owns. Revokes in Authful first (the security- + * relevant step) so a failure there aborts before we mark it revoked + * locally — never the reverse, which would leave a live token the user + * believes is gone. Returns false if the key isn't found or isn't theirs. + */ + async revoke(id: string, userId: string): Promise { + const key = await this.repo.findOwned(id, userId); + if (!key || key.revokedAt) return false; + + await this.authful.revoke(key.authfulTokenId); + return this.repo.markRevoked(id, userId); + } +} diff --git a/apps/user-api/src/services/drafts/index.ts b/apps/user-api/src/services/drafts/index.ts new file mode 100644 index 000000000..2711aa23d --- /dev/null +++ b/apps/user-api/src/services/drafts/index.ts @@ -0,0 +1,87 @@ +import type { + CreateDraftInput, + DraftRow, + DraftsRepository, + UpdateDraftInput, +} from "@/repositories/drafts"; + +export const DEFAULT_MAX_DRAFTS_PER_USER = 100; + +export class DraftQuotaExceededError extends Error { + constructor(readonly limit: number) { + super(`draft limit of ${limit} reached`); + this.name = "DraftQuotaExceededError"; + } +} + +export class ProposalDraftsService { + constructor( + private readonly repo: DraftsRepository, + private readonly maxDraftsPerUser = DEFAULT_MAX_DRAFTS_PER_USER, + ) {} + + /** + * "Claiming" = attaching ownership: legacy drafts were migrated from the + * per-DAO databases with `user_id NULL` and only the author's wallet in + * `author_address` (accounts didn't exist back then). When a signed-in + * user touches drafts, any still-unowned row authored by one of their + * wallets is attached (claimed) to their account. Runs on EVERY entry + * point (list, direct share view, update, delete) — claim-on-first-touch — + * and stays idempotent per request: no process-local memo, because the + * one-shot migration script may insert claimable rows AFTER a user was + * first seen. Returns the user's wallets for callers that need them. + */ + private async claimMigrated(userId: string): Promise { + const wallets = await this.repo.findWalletAddresses(userId); + await this.repo.claimByAddresses(userId, wallets); + return wallets; + } + + async listForUser(userId: string, daoId: string): Promise { + await this.claimMigrated(userId); + return this.repo.listByUserAndDao(userId, daoId); + } + + /** + * Share read. When a session is present, migrated rows are claimed first + * so `isOwner` is correct even when this is the user's very first request + * after the migration. + */ + async getById( + id: string, + viewerId: string | null, + ): Promise { + if (viewerId) await this.claimMigrated(viewerId); + return this.repo.findById(id); + } + + async create( + input: Omit, + ): Promise { + // Claimed rows count toward the quota, like everywhere else. + const [primaryWallet] = await this.claimMigrated(input.userId); + + // Recorded for display on shared drafts (and as the claim key shape used + // by migrated rows). Email/Google authors simply have none. + const row = await this.repo.createWithinQuota( + { ...input, authorAddress: primaryWallet ?? null }, + this.maxDraftsPerUser, + ); + if (!row) throw new DraftQuotaExceededError(this.maxDraftsPerUser); + return row; + } + + async update( + id: string, + userId: string, + patch: UpdateDraftInput, + ): Promise { + await this.claimMigrated(userId); + return this.repo.update(id, userId, patch); + } + + async delete(id: string, userId: string): Promise { + await this.claimMigrated(userId); + return this.repo.delete(id, userId); + } +} diff --git a/apps/user-api/tsconfig.json b/apps/user-api/tsconfig.json new file mode 100644 index 000000000..8ea3c09c0 --- /dev/null +++ b/apps/user-api/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "strict": true, + "noUncheckedIndexedAccess": true, + "verbatimModuleSyntax": false, + "isolatedModules": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "module": "ESNext", + "lib": ["ES2022"], + "target": "ES2022", + "skipLibCheck": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "outDir": "./dist", + "moduleResolution": "bundler", + "types": ["vitest/globals", "node"] + }, + "include": ["./**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/apps/user-api/tsup.config.ts b/apps/user-api/tsup.config.ts new file mode 100644 index 000000000..3e674cbd9 --- /dev/null +++ b/apps/user-api/tsup.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: { + index: "src/index.ts", + // Emitted as its own file so it can be loaded via `node --import` before the + // app graph (and therefore `pg`/`http`) evaluates — see package.json `start`. + // Code splitting keeps the observability provider in a shared chunk, so the + // `--import` instance and the one referenced by `/metrics` are the same. + instrumentation: "src/instrumentation.ts", + }, + format: ["esm"], + target: "es2022", + outDir: "dist", + clean: true, + splitting: true, + external: [ + /^[a-z]/i, // Bare imports like 'dotenv', 'hono' + /^@[a-z]/i, // Scoped packages like '@hono/node-server' + ], +}); diff --git a/apps/user-api/vitest.config.ts b/apps/user-api/vitest.config.ts new file mode 100644 index 000000000..89f20778f --- /dev/null +++ b/apps/user-api/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "path"; + +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + }, + resolve: { + alias: { + "@": path.resolve(import.meta.dirname, "./src"), + }, + }, +}); diff --git a/infra/user-api/Dockerfile b/infra/user-api/Dockerfile new file mode 100644 index 000000000..a8adc96d6 --- /dev/null +++ b/infra/user-api/Dockerfile @@ -0,0 +1,34 @@ +FROM node:20-slim AS base + +FROM base AS builder +WORKDIR /app +COPY . . +RUN npx turbo@2.3.1 prune @anticapture/user-api --docker + +FROM base AS installer +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g pnpm@10.10.0 +WORKDIR /app +COPY package.json package.json +COPY pnpm-lock.yaml pnpm-lock.yaml +COPY --from=builder /app/out/json/ . +RUN pnpm install --frozen-lockfile +COPY --from=builder /app/out/full/ . +COPY --from=builder /app/turbo.json turbo.json +RUN pnpm --filter @anticapture/user-api... build + +FROM base AS runner +ENV NODE_ENV=production +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* \ + && npm install -g pnpm@10.10.0 +RUN addgroup --system app && adduser --system --ingroup app --home /home/app app +WORKDIR /app +COPY --from=installer --chown=app:app /app . +USER app +WORKDIR /app/apps/user-api +EXPOSE 4003 +# Apply pending migrations before serving — drizzle-kit migrate is a no-op +# when the database is already up to date. +ENTRYPOINT ["sh", "-c", "pnpm db:migrate && node --import ./dist/instrumentation.js dist/index.js"] diff --git a/infra/user-api/railway.json b/infra/user-api/railway.json new file mode 100644 index 000000000..d87a67581 --- /dev/null +++ b/infra/user-api/railway.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "infra/user-api/Dockerfile", + "watchPatterns": ["apps/user-api/**", "infra/user-api/**"] + }, + "deploy": { + "healthcheckPath": "/health", + "restartPolicyType": "ALWAYS", + "restartPolicyMaxRetries": 3 + } +} diff --git a/package.json b/package.json index 4b2d7903a..a0e76062e 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "indexer": "dotenv -- turbo run --filter=@anticapture/indexer", "gateful": "dotenv -- turbo run --filter=@anticapture/gateful", "authful": "dotenv -- turbo run --filter=@anticapture/authful", + "user-api": "dotenv -- turbo run --filter=@anticapture/user-api", "api": "dotenv -- turbo run --filter=@anticapture/api", "codegen": "dotenv -- node scripts/wait-for-gateful.mjs && dotenv -- turbo run codegen", "client": "turbo run --filter=@anticapture/client", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4167ba575..4534407b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,7 +106,7 @@ importers: version: 17.3.1 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(kysely@0.26.3)(pg@8.20.0) + version: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0) hono: specifier: ^4.7.10 version: 4.12.7 @@ -170,7 +170,7 @@ importers: version: 0.31.9 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(kysely@0.26.3)(pg@8.20.0) + version: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0) hono: specifier: ^4.7.10 version: 4.12.7 @@ -258,7 +258,7 @@ importers: version: 0.31.9 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(kysely@0.26.3)(pg@8.20.0) + version: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0) hono: specifier: ^4.12.7 version: 4.12.7 @@ -624,7 +624,7 @@ importers: version: 4.12.7 ponder: specifier: ^0.16.2 - version: 0.16.3(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(@types/pg@8.18.0)(bufferutil@4.0.9)(hono@4.12.7)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) + version: 0.16.3(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/node@20.19.37)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(bufferutil@4.0.9)(hono@4.12.7)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76) viem: specifier: ^2.37.11 version: 2.47.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76) @@ -676,7 +676,7 @@ importers: version: 1.13.6 drizzle-orm: specifier: ^0.45.1 - version: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(kysely@0.26.3)(pg@8.20.0) + version: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0) pg: specifier: ^8.17.2 version: 8.20.0 @@ -779,6 +779,76 @@ importers: specifier: ^3.0.5 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + apps/user-api: + dependencies: + "@anticapture/observability": + specifier: workspace:* + version: link:../../packages/observability + "@hono/node-server": + specifier: ^1.19.9 + version: 1.19.11(hono@4.12.7) + "@hono/swagger-ui": + specifier: ^0.5.3 + version: 0.5.3(hono@4.12.7) + "@hono/zod-openapi": + specifier: ^1.2.2 + version: 1.2.2(hono@4.12.7)(zod@4.4.1) + "@opentelemetry/api": + specifier: ^1.9.0 + version: 1.9.0 + better-auth: + specifier: ^1.3.7 + version: 1.6.23(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0))(next@16.1.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.100.0))(pg@8.20.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + dotenv: + specifier: ^16.5.0 + version: 16.6.1 + drizzle-kit: + specifier: ^0.31.9 + version: 0.31.9 + drizzle-orm: + specifier: ^0.45.1 + version: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0) + hono: + specifier: ^4.12.7 + version: 4.12.7 + pg: + specifier: ^8.17.2 + version: 8.20.0 + resend: + specifier: ^6.6.0 + version: 6.9.3 + viem: + specifier: ^2.41.2 + version: 2.47.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.1) + zod: + specifier: ^4.3.6 + version: 4.4.1 + devDependencies: + "@better-auth/cli": + specifier: ^1.3.7 + version: 1.4.21(@better-fetch/fetch@1.3.1)(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.29.2)(magicast@0.3.5)(nanostores@1.4.0)(next@16.1.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.100.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + "@electric-sql/pglite": + specifier: ^0.3.15 + version: 0.3.16 + "@types/node": + specifier: ^20.16.5 + version: 20.19.37 + "@types/pg": + specifier: ^8.15.6 + version: 8.18.0 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/anticapture-client: dependencies: "@faker-js/faker": @@ -1896,15 +1966,6 @@ packages: peerDependencies: "@babel/core": ^7.0.0-0 - "@babel/plugin-transform-modules-commonjs@7.27.1": - resolution: - { - integrity: sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==, - } - engines: { node: ">=6.9.0" } - peerDependencies: - "@babel/core": ^7.0.0-0 - "@babel/plugin-transform-modules-commonjs@7.28.6": resolution: { @@ -2316,6 +2377,152 @@ packages: } engines: { node: ">=18" } + "@better-auth/cli@1.4.21": + resolution: + { + integrity: sha512-bKEa8BupnZxNjLk9ZDntvgQGm5jogeE2wHdMbYifhet3GTyxgDi6pXoOK8+aqHYQGg1C3OALi9hVVWnrv7JJWQ==, + } + hasBin: true + + "@better-auth/core@1.4.21": + resolution: + { + integrity: sha512-R4s7pwShkqB21fZ599QASbXxqFcoxanLyz7DHSX6SJPNYV748wBLsm3xM9VrjfvWMpS+cQUErOCt9yWT1hMn6w==, + } + peerDependencies: + "@better-auth/utils": 0.3.0 + "@better-fetch/fetch": 1.1.21 + better-call: 1.1.8 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + "@better-auth/core@1.6.23": + resolution: + { + integrity: sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==, + } + peerDependencies: + "@better-auth/utils": 0.4.2 + "@better-fetch/fetch": 1.3.1 + "@cloudflare/workers-types": ">=4" + "@opentelemetry/api": ^1.9.0 + better-call: 1.3.7 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + "@cloudflare/workers-types": + optional: true + "@opentelemetry/api": + optional: true + + "@better-auth/drizzle-adapter@1.6.23": + resolution: + { + integrity: sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==, + } + peerDependencies: + "@better-auth/core": ^1.6.23 + "@better-auth/utils": 0.4.2 + drizzle-orm: ^0.45.2 + peerDependenciesMeta: + drizzle-orm: + optional: true + + "@better-auth/kysely-adapter@1.6.23": + resolution: + { + integrity: sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==, + } + peerDependencies: + "@better-auth/core": ^1.6.23 + "@better-auth/utils": 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + "@better-auth/memory-adapter@1.6.23": + resolution: + { + integrity: sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==, + } + peerDependencies: + "@better-auth/core": ^1.6.23 + "@better-auth/utils": 0.4.2 + + "@better-auth/mongo-adapter@1.6.23": + resolution: + { + integrity: sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==, + } + peerDependencies: + "@better-auth/core": ^1.6.23 + "@better-auth/utils": 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + "@better-auth/prisma-adapter@1.6.23": + resolution: + { + integrity: sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==, + } + peerDependencies: + "@better-auth/core": ^1.6.23 + "@better-auth/utils": 0.4.2 + "@prisma/client": ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + "@prisma/client": + optional: true + prisma: + optional: true + + "@better-auth/telemetry@1.4.21": + resolution: + { + integrity: sha512-LX+FGMZnhR2KQZ0idHH1+UwlXvkOl6P8w3Gne4TtjvUCt3QjG9FKIuP9JD3MAmEEkwGt0SoAPHPJEGTjUl3ydg==, + } + peerDependencies: + "@better-auth/core": 1.4.21 + + "@better-auth/telemetry@1.6.23": + resolution: + { + integrity: sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==, + } + peerDependencies: + "@better-auth/core": ^1.6.23 + "@better-auth/utils": 0.4.2 + "@better-fetch/fetch": 1.3.1 + + "@better-auth/utils@0.3.0": + resolution: + { + integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==, + } + + "@better-auth/utils@0.4.2": + resolution: + { + integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==, + } + + "@better-fetch/fetch@1.1.21": + resolution: + { + integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==, + } + + "@better-fetch/fetch@1.3.1": + resolution: + { + integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==, + } + "@changesets/apply-release-plan@7.1.1": resolution: { @@ -2437,6 +2644,30 @@ packages: integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==, } + "@chevrotain/cst-dts-gen@10.5.0": + resolution: + { + integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==, + } + + "@chevrotain/gast@10.5.0": + resolution: + { + integrity: sha512-pXdMJ9XeDAbgOWKuD1Fldz4ieCs6+nLNmyVhe2gZVqoO7v8HXuHYs5OV2EzUtbuai37TlOAQHrTDvxMnvMJz3A==, + } + + "@chevrotain/types@10.5.0": + resolution: + { + integrity: sha512-f1MAia0x/pAVPWH/T73BJVyO2XU5tI4/iE7cnxb7tqdNTNhQI3Uq3XkqcoteTmD4t1aM0LbHCJOhgIDn07kl2A==, + } + + "@chevrotain/utils@10.5.0": + resolution: + { + integrity: sha512-hBzuU5+JjB2cqNZyszkDHZgOSrUUT8V3dhgRl8Q9Gp6dAj/H5+KILGjbhDpc3Iy9qmqlm/akuOI2ut9VUtzJxQ==, + } + "@chromatic-com/storybook@4.1.3": resolution: { @@ -2446,12 +2677,24 @@ packages: peerDependencies: storybook: ^0.0.0-0 || ^9.0.0 || ^9.1.0-0 || ^9.2.0-0 || ^10.0.0-0 || ^10.1.0-0 || ^10.2.0-0 || ^10.3.0-0 + "@clack/core@0.5.0": + resolution: + { + integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==, + } + "@clack/core@1.2.0": resolution: { integrity: sha512-qfxof/3T3t9DPU/Rj3OmcFyZInceqj/NVtO9rwIuJqCUgh32gwPjpFQQp/ben07qKlhpwq7GzfWpST4qdJ5Drg==, } + "@clack/prompts@0.11.0": + resolution: + { + integrity: sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==, + } + "@clack/prompts@1.2.0": resolution: { @@ -6577,6 +6820,13 @@ packages: "@cfworker/json-schema": optional: true + "@mrleebo/prisma-ast@0.13.1": + resolution: + { + integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==, + } + engines: { node: ">=16" } + "@mswjs/interceptors@0.41.3": resolution: { @@ -6694,6 +6944,13 @@ packages: } engines: { node: ^14.21.3 || >=16 } + "@noble/ciphers@2.2.0": + resolution: + { + integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==, + } + engines: { node: ">= 20.19.0" } + "@noble/curves@1.2.0": resolution: { @@ -6783,6 +7040,13 @@ packages: } engines: { node: ^14.21.3 || >=16 } + "@noble/hashes@2.2.0": + resolution: + { + integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==, + } + engines: { node: ">= 20.19.0" } + "@node-rs/jieba-android-arm-eabi@1.10.4": resolution: { @@ -7435,7 +7699,7 @@ packages: { integrity: sha512-IHnV6A+zxU7XwmKFinmYjUcwlyK9+xkG3/s9KcQhI9BjQKycrJ1JRO+FbNYPwZiPKW3je/DR0k7w8/gLa5eaxQ==, } - deprecated: 'The package is now available as "qr": npm install qr' + deprecated: 'Switch to "qr" (new package name) for security updates: npm install qr' "@peculiar/asn1-cms@2.7.0": resolution: @@ -7598,6 +7862,18 @@ packages: typescript: optional: true + "@prisma/client@5.22.0": + resolution: + { + integrity: sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==, + } + engines: { node: ">=16.13" } + peerDependencies: + prisma: "*" + peerDependenciesMeta: + prisma: + optional: true + "@protobufjs/aspromise@1.1.2": resolution: { @@ -12015,6 +12291,158 @@ packages: integrity: sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==, } + better-auth@1.4.21: + resolution: + { + integrity: sha512-qdrIZS7xnGF2HPBV5wYNPWTkPojhauOOjz1+MhLvwFy+zXpgLofQmWsI5I9DY+ef845NKt93XcgpyAc4RPPT9A==, + } + peerDependencies: + "@lynx-js/react": "*" + "@prisma/client": ^5.0.0 || ^6.0.0 || ^7.0.0 + "@sveltejs/kit": ^2.0.0 + "@tanstack/react-start": ^1.0.0 + "@tanstack/solid-start": ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: ">=0.31.4" + drizzle-orm: ">=0.41.0" + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + "@lynx-js/react": + optional: true + "@prisma/client": + optional: true + "@sveltejs/kit": + optional: true + "@tanstack/react-start": + optional: true + "@tanstack/solid-start": + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-auth@1.6.23: + resolution: + { + integrity: sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==, + } + peerDependencies: + "@lynx-js/react": "*" + "@prisma/client": ^5.0.0 || ^6.0.0 || ^7.0.0 + "@sveltejs/kit": ^2.0.0 + "@tanstack/react-start": ^1.0.0 + "@tanstack/solid-start": ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: ">=0.31.4" + drizzle-orm: ^0.45.2 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + "@lynx-js/react": + optional: true + "@prisma/client": + optional: true + "@sveltejs/kit": + optional: true + "@tanstack/react-start": + optional: true + "@tanstack/solid-start": + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.1.8: + resolution: + { + integrity: sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw==, + } + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + better-call@1.3.7: + resolution: + { + integrity: sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==, + } + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + better-path-resolve@1.0.0: resolution: { @@ -12022,6 +12450,13 @@ packages: } engines: { node: ">=4" } + better-sqlite3@12.11.1: + resolution: + { + integrity: sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==, + } + engines: { node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x } + big.js@5.2.2: resolution: { @@ -12041,6 +12476,12 @@ packages: } engines: { node: ">=8" } + bindings@1.5.0: + resolution: + { + integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==, + } + bintrees@1.0.2: resolution: { @@ -12315,6 +12756,17 @@ packages: } engines: { node: ">=6.0.0" } + c12@3.3.4: + resolution: + { + integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==, + } + peerDependencies: + magicast: "*" + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: { @@ -12546,6 +12998,12 @@ packages: } engines: { node: ">=20.18.1" } + chevrotain@10.5.0: + resolution: + { + integrity: sha512-Pkv5rBY3+CsHOYfV5g/Vs5JY9WTHHDEKOlohI2XeygaZhUeqhAlldZ8Hz9cRmxu709bvS08YzxHdTPHhffc13A==, + } + chokidar@3.6.0: resolution: { @@ -12567,6 +13025,12 @@ packages: } engines: { node: ">= 20.19.0" } + chownr@1.1.4: + resolution: + { + integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==, + } + chownr@3.0.0: resolution: { @@ -13001,6 +13465,12 @@ packages: integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==, } + confbox@0.2.4: + resolution: + { + integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==, + } + config-chain@1.1.13: resolution: { @@ -13945,10 +14415,10 @@ packages: } engines: { node: ">= 0.4" } - defu@6.1.4: + defu@6.1.7: resolution: { - integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==, + integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==, } delay@5.0.0: @@ -15450,6 +15920,13 @@ packages: } engines: { node: ">= 0.8.0" } + expand-template@2.0.3: + resolution: + { + integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==, + } + engines: { node: ">=6" } + expect-type@1.3.0: resolution: { @@ -15487,6 +15964,12 @@ packages: } engines: { node: ">= 18" } + exsolve@1.1.0: + resolution: + { + integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==, + } + ext@1.7.0: resolution: { @@ -15743,6 +16226,12 @@ packages: } engines: { node: ">=0.10.0" } + file-uri-to-path@1.0.0: + resolution: + { + integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==, + } + filesize@10.1.6: resolution: { @@ -15969,6 +16458,12 @@ packages: } engines: { node: ">= 0.8" } + fs-constants@1.0.0: + resolution: + { + integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==, + } + fs-extra@10.1.0: resolution: { @@ -16140,6 +16635,13 @@ packages: integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==, } + giget@3.3.0: + resolution: + { + integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==, + } + hasBin: true + git-raw-commits@4.0.0: resolution: { @@ -16149,6 +16651,12 @@ packages: deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. hasBin: true + github-from-package@0.0.0: + resolution: + { + integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==, + } + github-slugger@1.5.0: resolution: { @@ -18293,6 +18801,20 @@ packages: } engines: { node: ">=14.0.0" } + kysely@0.28.17: + resolution: + { + integrity: sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q==, + } + engines: { node: ">=20.0.0" } + + kysely@0.29.2: + resolution: + { + integrity: sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg==, + } + engines: { node: ">=22.0.0" } + language-subtag-registry@0.3.23: resolution: { @@ -18453,6 +18975,13 @@ packages: } engines: { node: ">= 12.0.0" } + lilconfig@2.1.0: + resolution: + { + integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==, + } + engines: { node: ">=10" } + lilconfig@3.1.3: resolution: { @@ -19549,6 +20078,12 @@ packages: typescript: optional: true + mkdirp-classic@0.5.3: + resolution: + { + integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==, + } + mkdirp@3.0.1: resolution: { @@ -19661,6 +20196,19 @@ packages: engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } hasBin: true + nanostores@1.4.0: + resolution: + { + integrity: sha512-i0tloweeudshAEuddpDxcg9Ik6pkPfVsHIgKyf143JrgG7/MOh0+q7BypdLXZPoOP7fOYt1eTcwGkyiVmhJFkA==, + } + engines: { node: ^20.0.0 || >=22.0.0 } + + napi-build-utils@2.0.0: + resolution: + { + integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==, + } + napi-postinstall@0.3.2: resolution: { @@ -19745,6 +20293,13 @@ packages: integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==, } + node-abi@3.94.0: + resolution: + { + integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==, + } + engines: { node: ">=10" } + node-abort-controller@3.1.1: resolution: { @@ -20073,6 +20628,12 @@ packages: integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==, } + ohash@2.0.11: + resolution: + { + integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==, + } + on-exit-leak-free@0.2.0: resolution: { @@ -20638,6 +21199,12 @@ packages: } engines: { node: ">=0.12" } + perfect-debounce@2.1.0: + resolution: + { + integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==, + } + pg-cloudflare@1.3.0: resolution: { @@ -20880,6 +21447,12 @@ packages: integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==, } + pkg-types@2.3.1: + resolution: + { + integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==, + } + pkijs@3.4.0: resolution: { @@ -21684,6 +22257,15 @@ packages: integrity: sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==, } + prebuild-install@7.1.3: + resolution: + { + integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==, + } + engines: { node: ">=10" } + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + prelude-ls@1.2.1: resolution: { @@ -22108,6 +22690,12 @@ packages: } engines: { node: ">= 0.10" } + rc9@3.0.1: + resolution: + { + integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==, + } + rc@1.2.8: resolution: { @@ -22662,6 +23250,12 @@ packages: integrity: sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==, } + regexp-to-ast@0.5.0: + resolution: + { + integrity: sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==, + } + regexp.prototype.flags@1.5.4: resolution: { @@ -23029,6 +23623,12 @@ packages: engines: { node: ">=18.0.0", npm: ">=8.0.0" } hasBin: true + rou3@0.7.12: + resolution: + { + integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==, + } + router@2.2.0: resolution: { @@ -23347,6 +23947,12 @@ packages: integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==, } + set-cookie-parser@3.1.0: + resolution: + { + integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==, + } + set-function-length@1.2.2: resolution: { @@ -23533,6 +24139,18 @@ packages: } engines: { node: ">=14" } + simple-concat@1.0.1: + resolution: + { + integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==, + } + + simple-get@4.0.1: + resolution: + { + integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==, + } + sirv@2.0.4: resolution: { @@ -24308,6 +24926,19 @@ packages: } engines: { node: ">=6" } + tar-fs@2.1.5: + resolution: + { + integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==, + } + + tar-stream@2.2.0: + resolution: + { + integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==, + } + engines: { node: ">=6" } + tar@7.2.0: resolution: { @@ -24836,6 +25467,12 @@ packages: integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==, } + tunnel-agent@0.6.0: + resolution: + { + integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==, + } + turbo-darwin-64@2.8.16: resolution: { @@ -26479,6 +27116,13 @@ packages: } engines: { node: ">=12.20" } + yocto-spinner@0.2.3: + resolution: + { + integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==, + } + engines: { node: ">=18.19" } + yoctocolors-cjs@2.1.3: resolution: { @@ -27185,7 +27829,7 @@ snapshots: "@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.28.5)": dependencies: @@ -27200,12 +27844,12 @@ snapshots: "@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.28.5)": dependencies: @@ -27245,12 +27889,12 @@ snapshots: "@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)": dependencies: @@ -27265,42 +27909,42 @@ snapshots: "@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-typescript@7.28.6(@babel/core@7.28.5)": dependencies: @@ -27652,22 +28296,6 @@ snapshots: transitivePeerDependencies: - supports-color - "@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.28.5)": - dependencies: - "@babel/core": 7.28.5 - "@babel/helper-module-transforms": 7.28.6(@babel/core@7.28.5) - "@babel/helper-plugin-utils": 7.27.1 - transitivePeerDependencies: - - supports-color - - "@babel/plugin-transform-modules-commonjs@7.27.1(@babel/core@7.29.0)": - dependencies: - "@babel/core": 7.29.0 - "@babel/helper-module-transforms": 7.28.6(@babel/core@7.29.0) - "@babel/helper-plugin-utils": 7.27.1 - transitivePeerDependencies: - - supports-color - "@babel/plugin-transform-modules-commonjs@7.28.6(@babel/core@7.28.5)": dependencies: "@babel/core": 7.28.5 @@ -27893,12 +28521,12 @@ snapshots: "@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.28.5)": dependencies: "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-transform-react-display-name@7.28.0(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-transform-react-jsx-development@7.27.1(@babel/core@7.28.5)": dependencies: @@ -27919,7 +28547,7 @@ snapshots: "@babel/core": 7.28.5 "@babel/helper-annotate-as-pure": 7.27.3 "@babel/helper-module-imports": 7.28.6 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-jsx": 7.27.1(@babel/core@7.28.5) "@babel/types": 7.29.0 transitivePeerDependencies: @@ -27930,7 +28558,7 @@ snapshots: "@babel/core": 7.29.0 "@babel/helper-annotate-as-pure": 7.27.3 "@babel/helper-module-imports": 7.28.6 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-syntax-jsx": 7.27.1(@babel/core@7.29.0) "@babel/types": 7.29.0 transitivePeerDependencies: @@ -27940,13 +28568,13 @@ snapshots: dependencies: "@babel/core": 7.28.5 "@babel/helper-annotate-as-pure": 7.27.3 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-transform-react-pure-annotations@7.27.1(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 "@babel/helper-annotate-as-pure": 7.27.3 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/plugin-transform-regenerator@7.28.6(@babel/core@7.28.5)": dependencies: @@ -28065,7 +28693,7 @@ snapshots: "@babel/core": 7.28.5 "@babel/helper-annotate-as-pure": 7.27.3 "@babel/helper-create-class-features-plugin": 7.28.6(@babel/core@7.28.5) - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/helper-skip-transparent-expression-wrappers": 7.27.1 "@babel/plugin-syntax-typescript": 7.28.6(@babel/core@7.28.5) transitivePeerDependencies: @@ -28076,7 +28704,7 @@ snapshots: "@babel/core": 7.29.0 "@babel/helper-annotate-as-pure": 7.27.3 "@babel/helper-create-class-features-plugin": 7.28.6(@babel/core@7.29.0) - "@babel/helper-plugin-utils": 7.28.6 + "@babel/helper-plugin-utils": 7.29.7 "@babel/helper-skip-transparent-expression-wrappers": 7.27.1 "@babel/plugin-syntax-typescript": 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: @@ -28297,7 +28925,7 @@ snapshots: "@babel/preset-react@7.28.5(@babel/core@7.28.5)": dependencies: "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/helper-validator-option": 7.27.1 "@babel/plugin-transform-react-display-name": 7.28.0(@babel/core@7.28.5) "@babel/plugin-transform-react-jsx": 7.27.1(@babel/core@7.28.5) @@ -28309,7 +28937,7 @@ snapshots: "@babel/preset-react@7.28.5(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/helper-validator-option": 7.27.1 "@babel/plugin-transform-react-display-name": 7.28.0(@babel/core@7.29.0) "@babel/plugin-transform-react-jsx": 7.27.1(@babel/core@7.29.0) @@ -28321,10 +28949,10 @@ snapshots: "@babel/preset-typescript@7.28.5(@babel/core@7.28.5)": dependencies: "@babel/core": 7.28.5 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/helper-validator-option": 7.27.1 "@babel/plugin-syntax-jsx": 7.27.1(@babel/core@7.28.5) - "@babel/plugin-transform-modules-commonjs": 7.27.1(@babel/core@7.28.5) + "@babel/plugin-transform-modules-commonjs": 7.28.6(@babel/core@7.28.5) "@babel/plugin-transform-typescript": 7.28.6(@babel/core@7.28.5) transitivePeerDependencies: - supports-color @@ -28332,10 +28960,10 @@ snapshots: "@babel/preset-typescript@7.28.5(@babel/core@7.29.0)": dependencies: "@babel/core": 7.29.0 - "@babel/helper-plugin-utils": 7.27.1 + "@babel/helper-plugin-utils": 7.29.7 "@babel/helper-validator-option": 7.27.1 "@babel/plugin-syntax-jsx": 7.27.1(@babel/core@7.29.0) - "@babel/plugin-transform-modules-commonjs": 7.27.1(@babel/core@7.29.0) + "@babel/plugin-transform-modules-commonjs": 7.28.6(@babel/core@7.29.0) "@babel/plugin-transform-typescript": 7.28.6(@babel/core@7.29.0) transitivePeerDependencies: - supports-color @@ -28401,6 +29029,167 @@ snapshots: "@bcoe/v8-coverage@1.0.2": {} + "@better-auth/cli@1.4.21(@better-fetch/fetch@1.3.1)(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(drizzle-kit@0.31.9)(jose@6.1.3)(kysely@0.29.2)(magicast@0.3.5)(nanostores@1.4.0)(next@16.1.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.100.0))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))": + dependencies: + "@babel/core": 7.29.0 + "@babel/preset-react": 7.28.5(@babel/core@7.29.0) + "@babel/preset-typescript": 7.28.5(@babel/core@7.29.0) + "@better-auth/core": 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0) + "@better-auth/telemetry": 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0)) + "@better-auth/utils": 0.3.0 + "@clack/prompts": 0.11.0 + "@mrleebo/prisma-ast": 0.13.1 + "@prisma/client": 5.22.0 + "@types/pg": 8.18.0 + better-auth: 1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0))(next@16.1.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.100.0))(pg@8.20.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + better-sqlite3: 12.11.1 + c12: 3.3.4(magicast@0.3.5) + chalk: 5.6.2 + commander: 12.1.0 + dotenv: 17.3.1 + drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0) + open: 10.2.0 + pg: 8.20.0 + prettier: 3.8.1 + prompts: 2.4.2 + semver: 7.7.4 + yocto-spinner: 0.2.3 + zod: 4.4.1 + transitivePeerDependencies: + - "@aws-sdk/client-rds-data" + - "@better-fetch/fetch" + - "@cloudflare/workers-types" + - "@electric-sql/pglite" + - "@libsql/client" + - "@libsql/client-wasm" + - "@lynx-js/react" + - "@neondatabase/serverless" + - "@op-engineering/op-sqlite" + - "@opentelemetry/api" + - "@planetscale/database" + - "@sveltejs/kit" + - "@tanstack/react-start" + - "@tanstack/solid-start" + - "@tidbcloud/serverless" + - "@types/better-sqlite3" + - "@types/sql.js" + - "@vercel/postgres" + - "@xata.io/client" + - better-call + - bun-types + - drizzle-kit + - expo-sqlite + - gel + - jose + - knex + - kysely + - magicast + - mongodb + - mysql2 + - nanostores + - next + - pg-native + - postgres + - prisma + - react + - react-dom + - solid-js + - sql.js + - sqlite3 + - supports-color + - svelte + - vitest + - vue + + "@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.4.1))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0)": + dependencies: + "@better-auth/utils": 0.3.0 + "@better-fetch/fetch": 1.1.21 + "@standard-schema/spec": 1.1.0 + better-call: 1.1.8(zod@4.4.1) + jose: 6.1.3 + kysely: 0.28.17 + nanostores: 1.4.0 + zod: 4.4.1 + + "@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0)": + dependencies: + "@better-auth/utils": 0.3.0 + "@better-fetch/fetch": 1.3.1 + "@standard-schema/spec": 1.1.0 + better-call: 1.3.7(zod@4.4.1) + jose: 6.1.3 + kysely: 0.29.2 + nanostores: 1.4.0 + zod: 4.4.1 + + "@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0)": + dependencies: + "@better-auth/utils": 0.4.2 + "@better-fetch/fetch": 1.3.1 + "@opentelemetry/semantic-conventions": 1.40.0 + "@standard-schema/spec": 1.1.0 + better-call: 1.3.7(zod@4.4.1) + jose: 6.1.3 + kysely: 0.29.2 + nanostores: 1.4.0 + zod: 4.4.1 + optionalDependencies: + "@opentelemetry/api": 1.9.0 + + "@better-auth/drizzle-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0))": + dependencies: + "@better-auth/core": 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0) + "@better-auth/utils": 0.4.2 + optionalDependencies: + drizzle-orm: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0) + + "@better-auth/kysely-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.2)": + dependencies: + "@better-auth/core": 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0) + "@better-auth/utils": 0.4.2 + optionalDependencies: + kysely: 0.29.2 + + "@better-auth/memory-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)": + dependencies: + "@better-auth/core": 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0) + "@better-auth/utils": 0.4.2 + + "@better-auth/mongo-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)": + dependencies: + "@better-auth/core": 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0) + "@better-auth/utils": 0.4.2 + + "@better-auth/prisma-adapter@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0)": + dependencies: + "@better-auth/core": 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0) + "@better-auth/utils": 0.4.2 + optionalDependencies: + "@prisma/client": 5.22.0 + + "@better-auth/telemetry@1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))": + dependencies: + "@better-auth/core": 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0) + "@better-auth/utils": 0.3.0 + "@better-fetch/fetch": 1.1.21 + + "@better-auth/telemetry@1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)": + dependencies: + "@better-auth/core": 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0) + "@better-auth/utils": 0.4.2 + "@better-fetch/fetch": 1.3.1 + + "@better-auth/utils@0.3.0": {} + + "@better-auth/utils@0.4.2": + dependencies: + "@noble/hashes": 2.2.0 + + "@better-fetch/fetch@1.1.21": {} + + "@better-fetch/fetch@1.3.1": {} + "@changesets/apply-release-plan@7.1.1": dependencies: "@changesets/config": 3.1.4 @@ -28559,6 +29348,21 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + "@chevrotain/cst-dts-gen@10.5.0": + dependencies: + "@chevrotain/gast": 10.5.0 + "@chevrotain/types": 10.5.0 + lodash: 4.17.21 + + "@chevrotain/gast@10.5.0": + dependencies: + "@chevrotain/types": 10.5.0 + lodash: 4.17.21 + + "@chevrotain/types@10.5.0": {} + + "@chevrotain/utils@10.5.0": {} + "@chromatic-com/storybook@4.1.3(storybook@10.2.17(@testing-library/dom@10.4.0)(bufferutil@4.0.9)(prettier@3.8.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(utf-8-validate@5.0.10))": dependencies: "@neoconfetti/react": 1.0.0 @@ -28571,11 +29375,22 @@ snapshots: - "@chromatic-com/cypress" - "@chromatic-com/playwright" + "@clack/core@0.5.0": + dependencies: + picocolors: 1.1.1 + sisteransi: 1.0.5 + "@clack/core@1.2.0": dependencies: fast-wrap-ansi: 0.1.6 sisteransi: 1.0.5 + "@clack/prompts@0.11.0": + dependencies: + "@clack/core": 0.5.0 + picocolors: 1.1.1 + sisteransi: 1.0.5 + "@clack/prompts@1.2.0": dependencies: "@clack/core": 1.2.0 @@ -32496,6 +33311,11 @@ snapshots: transitivePeerDependencies: - supports-color + "@mrleebo/prisma-ast@0.13.1": + dependencies: + chevrotain: 10.5.0 + lilconfig: 2.1.0 + "@mswjs/interceptors@0.41.3": dependencies: "@open-draft/deferred-promise": 2.2.0 @@ -32548,6 +33368,8 @@ snapshots: "@noble/ciphers@1.3.0": {} + "@noble/ciphers@2.2.0": {} + "@noble/curves@1.2.0": dependencies: "@noble/hashes": 1.3.2 @@ -32588,6 +33410,8 @@ snapshots: "@noble/hashes@1.8.0": {} + "@noble/hashes@2.2.0": {} + "@node-rs/jieba-android-arm-eabi@1.10.4": optional: true @@ -33136,6 +33960,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + "@prisma/client@5.22.0": {} + "@protobufjs/aspromise@1.1.2": {} "@protobufjs/base64@1.1.2": {} @@ -37094,16 +37920,101 @@ snapshots: bech32@1.1.4: {} + better-auth@1.4.21(@prisma/client@5.22.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.9)(drizzle-orm@0.41.0(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0))(next@16.1.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.100.0))(pg@8.20.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + "@better-auth/core": 1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.4.1))(jose@6.1.3)(kysely@0.28.17)(nanostores@1.4.0) + "@better-auth/telemetry": 1.4.21(@better-auth/core@1.4.21(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0)) + "@better-auth/utils": 0.3.0 + "@better-fetch/fetch": 1.1.21 + "@noble/ciphers": 2.2.0 + "@noble/hashes": 2.2.0 + better-call: 1.1.8(zod@4.4.1) + defu: 6.1.7 + jose: 6.1.3 + kysely: 0.28.17 + nanostores: 1.4.0 + zod: 4.4.1 + optionalDependencies: + "@prisma/client": 5.22.0 + better-sqlite3: 12.11.1 + drizzle-kit: 0.31.9 + drizzle-orm: 0.41.0(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0) + next: 16.1.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.100.0) + pg: 8.20.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + + better-auth@1.6.23(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(better-sqlite3@12.11.1)(drizzle-kit@0.31.9)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0))(next@16.1.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.100.0))(pg@8.20.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)): + dependencies: + "@better-auth/core": 1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0) + "@better-auth/drizzle-adapter": 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0)) + "@better-auth/kysely-adapter": 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(kysely@0.29.2) + "@better-auth/memory-adapter": 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + "@better-auth/mongo-adapter": 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2) + "@better-auth/prisma-adapter": 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@prisma/client@5.22.0) + "@better-auth/telemetry": 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.4.1))(jose@6.1.3)(kysely@0.29.2)(nanostores@1.4.0))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + "@better-auth/utils": 0.4.2 + "@better-fetch/fetch": 1.3.1 + "@noble/ciphers": 2.2.0 + "@noble/hashes": 2.2.0 + better-call: 1.3.7(zod@4.4.1) + defu: 6.1.7 + jose: 6.1.3 + kysely: 0.29.2 + nanostores: 1.4.0 + zod: 4.4.1 + optionalDependencies: + "@prisma/client": 5.22.0 + better-sqlite3: 12.11.1 + drizzle-kit: 0.31.9 + drizzle-orm: 0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0) + next: 16.1.3(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.100.0) + pg: 8.20.0 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.37)(jiti@2.6.1)(jsdom@24.1.3(bufferutil@4.0.9)(utf-8-validate@5.0.10))(lightningcss@1.31.1)(msw@2.12.10(@types/node@20.19.37)(typescript@5.9.3))(sass@1.100.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - "@cloudflare/workers-types" + - "@opentelemetry/api" + + better-call@1.1.8(zod@4.4.1): + dependencies: + "@better-auth/utils": 0.3.0 + "@better-fetch/fetch": 1.3.1 + rou3: 0.7.12 + set-cookie-parser: 2.7.1 + optionalDependencies: + zod: 4.4.1 + + better-call@1.3.7(zod@4.4.1): + dependencies: + "@better-auth/utils": 0.4.2 + "@better-fetch/fetch": 1.3.1 + rou3: 0.7.12 + set-cookie-parser: 3.1.0 + optionalDependencies: + zod: 4.4.1 + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 + better-sqlite3@12.11.1: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + big.js@5.2.2: {} big.js@6.2.2: {} binary-extensions@2.3.0: {} + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + bintrees@1.0.2: {} bl@4.1.0: @@ -37328,6 +38239,23 @@ snapshots: bytestreamjs@2.0.1: {} + c12@3.3.4(magicast@0.3.5): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.3.1 + exsolve: 1.1.0 + giget: 3.3.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.1 + rc9: 3.0.1 + optionalDependencies: + magicast: 0.3.5 + cac@6.7.14: {} cacheable-lookup@7.0.0: {} @@ -37464,6 +38392,15 @@ snapshots: undici: 7.22.0 whatwg-mimetype: 4.0.0 + chevrotain@10.5.0: + dependencies: + "@chevrotain/cst-dts-gen": 10.5.0 + "@chevrotain/gast": 10.5.0 + "@chevrotain/types": 10.5.0 + "@chevrotain/utils": 10.5.0 + lodash: 4.17.21 + regexp-to-ast: 0.5.0 + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -37484,6 +38421,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + chownr@3.0.0: {} chromatic@13.3.4: {} @@ -37693,6 +38632,8 @@ snapshots: confbox@0.1.8: {} + confbox@0.2.4: {} + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -38263,7 +39204,7 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 - defu@6.1.4: {} + defu@6.1.7: {} delay@5.0.0: {} @@ -38529,20 +39470,34 @@ snapshots: transitivePeerDependencies: - supports-color - drizzle-orm@0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(kysely@0.26.3)(pg@8.20.0): + drizzle-orm@0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.26.3)(pg@8.20.0): optionalDependencies: "@electric-sql/pglite": 0.2.13 "@opentelemetry/api": 1.9.0 + "@prisma/client": 5.22.0 "@types/pg": 8.18.0 + better-sqlite3: 12.11.1 kysely: 0.26.3 pg: 8.20.0 - drizzle-orm@0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(kysely@0.26.3)(pg@8.20.0): + drizzle-orm@0.41.0(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0): optionalDependencies: "@electric-sql/pglite": 0.3.16 "@opentelemetry/api": 1.9.0 + "@prisma/client": 5.22.0 "@types/pg": 8.18.0 - kysely: 0.26.3 + better-sqlite3: 12.11.1 + kysely: 0.29.2 + pg: 8.20.0 + + drizzle-orm@0.45.1(@electric-sql/pglite@0.3.16)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.29.2)(pg@8.20.0): + optionalDependencies: + "@electric-sql/pglite": 0.3.16 + "@opentelemetry/api": 1.9.0 + "@prisma/client": 5.22.0 + "@types/pg": 8.18.0 + better-sqlite3: 12.11.1 + kysely: 0.29.2 pg: 8.20.0 dunder-proto@1.0.1: @@ -39425,6 +40380,8 @@ snapshots: exit@0.1.2: {} + expand-template@2.0.3: {} + expect-type@1.3.0: {} expect@29.7.0: @@ -39509,6 +40466,8 @@ snapshots: transitivePeerDependencies: - supports-color + exsolve@1.1.0: {} + ext@1.7.0: dependencies: type: 2.7.3 @@ -39635,6 +40594,8 @@ snapshots: file-type@3.9.0: {} + file-uri-to-path@1.0.0: {} + filesize@10.1.6: {} fill-range@7.1.1: @@ -39780,6 +40741,8 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -39878,12 +40841,16 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + giget@3.3.0: {} + git-raw-commits@4.0.0: dependencies: dargs: 8.1.0 meow: 12.1.1 split2: 4.2.0 + github-from-package@0.0.0: {} + github-slugger@1.5.0: {} glob-parent@5.1.2: @@ -40033,7 +41000,7 @@ snapshots: dependencies: cookie-es: 1.2.2 crossws: 0.3.5 - defu: 6.1.4 + defu: 6.1.7 destr: 2.0.5 iron-webcrypto: 1.2.1 node-mock-http: 1.0.3 @@ -41438,6 +42405,10 @@ snapshots: kysely@0.26.3: {} + kysely@0.28.17: {} + + kysely@0.29.2: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -41515,6 +42486,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.31.1 lightningcss-win32-x64-msvc: 1.31.1 + lilconfig@2.1.0: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} @@ -42368,6 +43341,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + mkdirp-classic@0.5.3: {} + mkdirp@3.0.1: {} mlly@1.8.0: @@ -42460,6 +43435,10 @@ snapshots: nanoid@3.3.11: {} + nanostores@1.4.0: {} + + napi-build-utils@2.0.0: {} + napi-postinstall@0.3.2: {} natural-compare@1.4.0: {} @@ -42507,6 +43486,10 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-abi@3.94.0: + dependencies: + semver: 7.7.4 + node-abort-controller@3.1.1: {} node-addon-api@2.0.2: {} @@ -42740,6 +43723,8 @@ snapshots: node-fetch-native: 1.6.7 ufo: 1.6.1 + ohash@2.0.11: {} + on-exit-leak-free@0.2.0: {} on-exit-leak-free@2.1.2: {} @@ -43144,6 +44129,8 @@ snapshots: sha.js: 2.4.12 to-buffer: 1.2.2 + perfect-debounce@2.1.0: {} + pg-cloudflare@1.3.0: optional: true @@ -43316,6 +44303,12 @@ snapshots: mlly: 1.8.0 pathe: 2.0.3 + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.0 + pathe: 2.0.3 + pkijs@3.4.0: dependencies: "@noble/hashes": 1.4.0 @@ -43337,7 +44330,7 @@ snapshots: pngjs@5.0.0: {} - ponder@0.16.3(@opentelemetry/api@1.9.0)(@types/node@20.19.37)(@types/pg@8.18.0)(bufferutil@4.0.9)(hono@4.12.7)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): + ponder@0.16.3(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/node@20.19.37)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(bufferutil@4.0.9)(hono@4.12.7)(lightningcss@1.31.1)(sass@1.100.0)(terser@5.46.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(viem@2.47.2(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@3.25.76))(zod@3.25.76): dependencies: "@babel/code-frame": 7.27.1 "@commander-js/extra-typings": 12.1.0(commander@12.1.0) @@ -43354,7 +44347,7 @@ snapshots: dataloader: 2.2.3 detect-package-manager: 3.0.2 dotenv: 16.6.1 - drizzle-orm: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0)(@types/pg@8.18.0)(kysely@0.26.3)(pg@8.20.0) + drizzle-orm: 0.41.0(@electric-sql/pglite@0.2.13)(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0)(@types/pg@8.18.0)(better-sqlite3@12.11.1)(kysely@0.26.3)(pg@8.20.0) glob: 10.5.0 graphql: 16.8.2 graphql-yoga: 5.17.1(graphql@16.8.2) @@ -43952,6 +44945,21 @@ snapshots: preact@10.29.0: {} + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.1: @@ -44161,6 +45169,11 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -44617,6 +45630,8 @@ snapshots: regex-parser@2.3.1: {} + regexp-to-ast@0.5.0: {} + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -44905,6 +45920,8 @@ snapshots: "@rollup/rollup-win32-x64-msvc": 4.45.1 fsevents: 2.3.3 + rou3@0.7.12: {} + router@2.2.0: dependencies: debug: 4.4.3 @@ -45132,6 +46149,8 @@ snapshots: set-cookie-parser@2.7.1: {} + set-cookie-parser@3.1.0: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -45280,6 +46299,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + sirv@2.0.4: dependencies: "@polka/url": 1.0.0-next.29 @@ -45774,6 +46801,21 @@ snapshots: tapable@2.3.0: {} + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar@7.2.0: dependencies: "@isaacs/fs-minipass": 4.0.1 @@ -46067,6 +47109,10 @@ snapshots: tty-browserify@0.0.1: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + turbo-darwin-64@2.8.16: optional: true @@ -47282,6 +48328,10 @@ snapshots: yocto-queue@1.2.2: {} + yocto-spinner@0.2.3: + dependencies: + yoctocolors: 2.1.2 + yoctocolors-cjs@2.1.3: {} yoctocolors@2.1.2: {} diff --git a/scripts/dev.sh b/scripts/dev.sh index 8ca6a3e0c..abc5532aa 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -27,7 +27,8 @@ PORT_GATEFUL=4001 PORT_DASHBOARD=3000 PORT_ADDRESS_ENRICHMENT=3001 PORT_RELAYER=3002 -PORTS=("$PORT_INDEXER" "$PORT_API" "$PORT_GATEFUL" "$PORT_DASHBOARD" "$PORT_ADDRESS_ENRICHMENT" "$PORT_RELAYER") +PORT_USER_API=4003 +PORTS=("$PORT_INDEXER" "$PORT_API" "$PORT_GATEFUL" "$PORT_DASHBOARD" "$PORT_ADDRESS_ENRICHMENT" "$PORT_RELAYER" "$PORT_USER_API") # DAO name → short ID mapping (used to run the API) dao_id_for() { @@ -58,6 +59,7 @@ C_CODEGEN="\033[33m" # yellow C_DASHBOARD="\033[32m" # green C_ADDRESS_ENRICHMENT="\033[96m" # bright cyan C_RELAYER="\033[93m" # bright yellow +C_USER_API="\033[95m" # bright magenta C_SCRIPT="\033[90m" # gray C_RESET="\033[0m" @@ -247,6 +249,22 @@ else log "Skipping optional Address Enrichment (Railway CLI/service unavailable)" fi +# 4. User API (optional; identity + drafts + API keys). Needs its own Postgres, +# supplied by the Railway dev env — so it runs like enrichment: via `railway +# run`, skipped when the service/CLI isn't available. When up, the dashboard's +# /api/user proxy targets it; when skipped, auth/drafts UIs are simply inert. +USER_API_AVAILABLE=false +if railway_service_available "user-api"; then + log "Starting optional User API..." + run_with_prefix "$C_USER_API" "👤 user-api" "" "" railway run -e dev -s user-api pnpm user-api dev & + if wait_for_optional_port "$PORT_USER_API" "User API"; then + USER_API_AVAILABLE=true + export USER_API_URL="http://localhost:${PORT_USER_API}" + fi +else + log "Skipping optional User API (Railway CLI/service unavailable)" +fi + # Watchdog: when API recovers after being down, touch the sentinel file so tsx reloads the gateful if [ "$RUN_API" = true ]; then ( @@ -295,6 +313,11 @@ if [ "$ADDRESS_ENRICHMENT_AVAILABLE" = true ]; then else printf " ${C_ADDRESS_ENRICHMENT}💰 Enrichment${C_RESET} skipped (optional)\n" fi +if [ "$USER_API_AVAILABLE" = true ]; then + printf " ${C_USER_API}👤 User API${C_RESET} http://localhost:${PORT_USER_API}\n" +else + printf " ${C_USER_API}👤 User API${C_RESET} skipped (optional)\n" +fi printf " ${C_GATEFUL}🚪 Gateful${C_RESET} http://localhost:${PORT_GATEFUL}\n" printf " ${C_RELAYER}📡 Relayer${C_RESET} http://localhost:${PORT_RELAYER}\n" printf " ${C_CODEGEN}🤝 REST Client${C_RESET} codegen + build watch\n"