diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e5ff2e84 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,1324 @@ +# @nestri/core — Domain Module Guide + +## Structure + +Every domain module lives in `packages/core/src//` as either a top-level namespace or a nested sub-module: + +``` +src// + ├── .sql.ts # (optional) Drizzle table for the parent entity + ├── index.ts # Parent namespace (e.g. User, Game, Team) + ├── .sql.ts # Sub-module table (e.g. fingerprint.sql.ts) + └── .ts # Sub-module namespace (e.g. export namespace Fingerprint) +``` + +### Sub-modules nested under parents + +| File | Namespace | Why | +| ----------------------- | --------------- | ------------------------------------- | +| `user/linked-account.*` | `LinkedAccount` | A user's OAuth/gaming identities | +| `user/fingerprint.*` | `Fingerprint` | SSH key fingerprints | +| `game/download.*` | `GameDownload` | Per-host game depot downloads | +| `user/library.*` | `Library` | User's owned games with playtime | +| `team/member.*` | `Member` | Team membership with role | +| `game/depot.*` | `Depot` | Platform-specific game content depots | + +Existing top-level modules: `user/`, `team/`, `game/`, `pairing-code/`, `steam/`, `auth/`, `db/`. + +Modules that don't own their own table (like `steam/`) only need a single `index.ts` exposing reusable `fn()` functions — no `.sql.ts` file. + +## Pattern: `.sql.ts` (Drizzle Table) + +```ts +// src//.sql.ts +import { pgTable, text, boolean, jsonb, uniqueIndex, index, pgEnum } from 'drizzle-orm/pg-core'; + +import { id, timestamps, ulid } from '../db/types.js'; + +// Enum (only if needed — co-located with its table) +export const SomeEnum = pgEnum('some_enum', ['a', 'b']); + +// FK imports — use the sql.ts files, never the index.ts (avoids circular deps) +import { UserTable } from '../user/user.sql.js'; + +export const SomeTable = pgTable( + 'some_table', + { + ...id, // char(30) PK, prefix: som_ + ...timestamps, // time_created, time_updated, time_deleted (all utc) + + // FK column — always use ulid() + .references() + userId: ulid('user_id') + .notNull() + .references(() => UserTable.id, { onDelete: 'cascade' }), + + // Scalar columns + name: text('name').notNull(), + email: text('email'), // nullable = omit .notNull() + flag: boolean('flag').notNull().default(false), + metadata: jsonb('metadata').$type<{}>(), // JSON blob + + // Enum column + provider: SomeEnum('provider').notNull() + }, + (t) => [ + uniqueIndex('some_table_provider_unique').on(t.provider, t.providerAccountId), + index('some_table_sync_idx') + .on(t.userId) + .where(sql`${t.localValue} is distinct from ${t.remoteValue}`), + index('some_table_user_idx').on(t.userId) + ] +); +``` + +### DB types (`src/db/types.ts`) + +| Helper | Output | +| ------------ | --------------------------------------------------------------- | +| `ulid(name)` | `char(30)` — for PKs and FKs | +| `id` | `{ id: ulid('id').primaryKey().notNull() }` — spread as `...id` | +| `utc(name)` | `timestamp with time zone` | +| `timestamps` | `{ timeCreated, timeUpdated (auto), timeDeleted }` | + +### Naming conventions + +- Table name: `snake_case` (e.g. `linked_account`, `team_member`) +- Column name: `snake_case` (e.g. `user_id`, `provider_account_id`, `time_created`) +- TypeScript field names: `camelCase` matching the column (drizzle maps them) +- Index names: `{table}_{column(s)}_unique` / `{table}_{column}_idx` + +--- + +## Pattern: `index.ts` (Domain Namespace) + +```ts +// src//index.ts +import { eq, and, isNull, sql } from 'drizzle-orm'; +import z from 'zod'; + +import { Database } from '../db/index.js'; +import { Examples } from '../examples.js'; +import { fn } from '../fn.js'; +import { SomeTable, SomeEnum } from './.sql.js'; + +export namespace SomeModule { + // ── Info schema ───────────────────────────────────────────────────── + // Single source of truth for the entity shape. + // Every field typed here; .meta() adds OpenAPI metadata. + // When a field changes here, TypeScript catches every usage. + export const Info = z + .object({ + id: z.string().meta({ + description: '…', + example: Examples.SomeModule.id, + }), + // For enum fields, use z.enum(SomeEnum.enumValues) to stay in sync: + provider: z.enum(SomeEnum.enumValues).meta({ … }), + // Nullable + optional for JSON-blob / optional fields: + metadata: z.record(z.string(), z.unknown()).nullable().optional().meta({ … }), + }) + .meta({ + ref: 'SomeModule', + description: '…', + example: Examples.SomeModule, + }); + + export type Info = z.infer; + + // ── create ─────────────────────────────────────────────────────────── + // Use Info.pick({…}) for the schema — keeps fields in sync with Info. + // Input is the parsed object. + // Use Database.use() for single-operation writes. + export const create = fn( + Info.pick({ id: true, name: true, email: true }), + async (input) => { + await Database.use(async (tx) => { + await tx.insert(SomeTable).values({ + id: input.id, + name: input.name, + email: input.email ?? null, + }); + }); + return input.id; + } + ); + + // ── Single-field lookups ───────────────────────────────────────────── + // Use Info.shape. for the schema. + // The callback receives the raw value, not { field: value }. + export const fromID = fn(Info.shape.id, async (id) => { + return Database.use(async (tx) => { + return tx + .select() + .from(SomeTable) + .where(and(eq(SomeTable.id, id), isNull(SomeTable.timeDeleted))) + .then((rows) => rows.at(0) ?? null); + }); + }); + + export const fromSlug = fn(Info.shape.slug, async (slug) => { + // … + }); + + // ── Multi-field lookups ────────────────────────────────────────────── + // Use Info.pick({ field1: true, field2: true }) + export const findByProvider = fn( + Info.pick({ provider: true, providerAccountId: true }), + async (input) => { + return Database.use(async (tx) => { + return tx + .select() + .from(SomeTable) + .where(and( + eq(SomeTable.provider, input.provider), + eq(SomeTable.providerAccountId, input.providerAccountId), + isNull(SomeTable.timeDeleted), + )) + .then((rows) => rows.at(0) ?? null); + }); + } + ); + + // ── List (no args) ─────────────────────────────────────────────────── + // Plain async function — no fn() wrapper since there's no input. + export async function list() { + return Database.use(async (tx) => { + return tx + .select() + .from(SomeTable) + .where(isNull(SomeTable.timeDeleted)) + .orderBy(SomeTable.timeCreated); + }); + } + + // ── List by FK ─────────────────────────────────────────────────────── + export const listByUser = fn(Info.shape.userId, async (userId) => { + return Database.use(async (tx) => { + return tx + .select() + .from(SomeTable) + .where(and(eq(SomeTable.userId, userId), isNull(SomeTable.timeDeleted))) + .orderBy(SomeTable.timeCreated); + }); + }); + + // ── Update ─────────────────────────────────────────────────────────── + // If the update uses Info fields + possibly extra fields: + export const updateSomething = fn( + Info.pick({ id: true, otherField: true }).extend({ + extraField: z.string(), + }), + async (input) => { + await Database.use(async (tx) => { + await tx + .update(SomeTable) + .set({ otherField: input.otherField }) + .where(eq(SomeTable.id, input.id)); + }); + } + ); + + // ── Soft-delete ────────────────────────────────────────────────────── + // Use sql\`now()\` for the timestamp (consistent with DB time). + export const remove = fn(Info.shape.id, async (id) => { + await Database.use(async (tx) => { + await tx + .update(SomeTable) + .set({ timeDeleted: sql`now()` }) + .where(eq(SomeTable.id, id)); + }); + }); + + // ── serialize ──────────────────────────────────────────────────────── + // Converts a DB row into the public API shape. Keeps serialization + // decoupled from the DB layer. + export function serialize(input: typeof SomeTable.$inferSelect): z.infer { + return { + id: input.id, + name: input.name, + // Cast enums since drizzle returns a string at runtime: + provider: input.provider as Info['provider'], + }; + } + + // ── listByUserWithGames ────────────────────────────────────────────── + // JOIN query with serialization inside the fn() boundary. + // The .then() chain maps raw rows to the public shape immediately. + export const listByUserWithGames = fn(Info.shape.userId, async (userId) => { + return Database.use(async (tx) => { + return tx + .select({ + library: UserLibraryTable, + game: GameTable + }) + .from(UserLibraryTable) + .leftJoin(GameTable, eq(UserLibraryTable.gameId, GameTable.id)) + .where(and(eq(UserLibraryTable.userId, userId), isNull(UserLibraryTable.timeDeleted))) + .orderBy(UserLibraryTable.timeCreated) + .then((rows) => + rows + .filter((row) => row.game !== null) + .map((row) => ({ + id: row.library.id, + game: Game.serialize(row.game!), + playtime2w: row.library.playtime2w, + playtimeForever: row.library.playtimeForever, + lastPlayed: row.library.lastPlayed?.toISOString() ?? null + })) + ); + }); + }); +} +``` + +### Key rules for `fn()` usage + +| Case | Schema | Callback receives | +| -------------------- | ------------------------------------------ | ------------------------------------- | +| Single field | `Info.shape.field` | Raw value (`string`, `boolean`, etc.) | +| Multiple fields | `Info.pick({a:true, b:true})` | `{ a, b }` object | +| Full entity | `Info` | Full `Info` object | +| Info fields + extras | `Info.pick({…}).extend({extra: z.type()})` | `{ …fields, extra }` | +| No input | Regular `async function` | n/a | + +### What `fn()` does + +```ts +fn(schema, callback); +// → (input) => { schema.parse(input); return callback(parsed); } +// The returned function also has a .schema property for OpenAPI introspection. +``` + +--- + +## Serialization Boundary Rule + +**All data transformation — including JOIN deserialization, field mapping, date stringification, and null filtering — happens INSIDE the `fn()` boundary, right after the DB query in a `.then()` chain. The API route is a dumb pass-through.** + +### Why this matters + +| Approach | Queries | Boundary clarity | +| ----------------------------------------------------- | ------------ | -------------------------------------- | +| **Bad**: Raw rows from core, map/filter in route | N+1 (or raw) | Leaky — route knows DB schema | +| **Bad**: JOIN in core, but serialize in route | 1 | Still leaky — route owns shaping logic | +| **Good**: JOIN + serialize in `.then()` inside `fn()` | 1 | Clean — core returns JSON-safe objects | + +### The pattern + +```ts +// GOOD: serialization inside fn() +export const listByUserWithGames = fn(Info.shape.userId, async (userId) => { + return Database.use(async (tx) => { + return tx + .select({ library: UserLibraryTable, game: GameTable }) + .from(UserLibraryTable) + .leftJoin(GameTable, eq(UserLibraryTable.gameId, GameTable.id)) + .where(...) + .orderBy(...) + .then((rows) => + rows + .filter((row) => row.game !== null) + .map((row) => ({ + id: row.library.id, + game: Game.serialize(row.game!), + lastPlayed: row.library.lastPlayed?.toISOString() ?? null + })) + ); + }); +}); + +// GOOD: API route is a thin pass-through +async (c) => { + const data = await Library.listByUserWithGames(Actor.userID); + return c.json({ data }); +} +``` + +### Rules + +1. **Never let raw Drizzle `$inferSelect` rows escape the core module.** If a function returns joined data, it must be shaped before the return. +2. **Use `.then()` after the query for map/filter/serialize.** Keeps the async pipeline declarative and co-located with the SQL. +3. **Re-use sibling `serialize()` functions for joined tables.** e.g. `Game.serialize(row.game!)` when joining `GameTable`. +4. **API routes only do:** auth checks, input validation, calling the core fn, and `c.json({ data })`. No `.map()`, no `.filter()`, no field remapping. + +--- + +### Mutation Rule: Single-Query Reads via `.returning()` + +Never execute a separate `tx.select()` or trigger a lookup function immediately after an `insert` or `upsert` mutation to fetch the updated state of a row. + +Postgres natively supports the `RETURNING` clause. Always append `.returning()` directly to your mutation chains and destructure the resulting array (`const [row] = await tx...`). This ensures mutations remain atomic, avoids unnecessary connection pool overhead, and removes the latency penalty of running two sequential database operations. + +--- + +## Pattern: ID generation (`src/id.ts`) + +```ts +Identifier.ascending('user') // → "usr_" +Identifier.ascending('team') // → "tem_" +Identifier.ascending('linkedAccount') // → "lac_" + +// Prefixes are defined in Identifier.prefixes: +{ + user: 'usr', + linkedAccount: 'lac', + team: 'tem', + teamMember: 'mem', + verification: 'ver', +} +``` + +The IDs are 30-char strings: `{prefix}_{26 base62 chars}`. They are monotonically increasing (time-sortable) when using `ascending()`. + +--- + +## Pattern: Examples (`src/examples.ts`) + +```ts +export namespace Examples { + export const Id = (prefix: keyof typeof Identifier.prefixes) => + `${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`; + + export const User = { id: Id('user'), name: '…', email: '…', … }; + export const LinkedAccount = { id: Id('linkedAccount'), provider: 'steam', … }; + export const Team = { id: Id('team'), slug: 'my-team', … }; + export const Member = { id: Id('teamMember'), role: 'owner' as const, … }; + export const Fingerprint = { id: Id('userFingerprint'), fingerprint: '…', … }; + export const GameDownload = { id: Id('gameDownload'), hostId: 'hst_…', gameId: Id('game'), status: 'downloading', … }; + export const Library = { id: Id('userLibrary'), playtimeForever: 150000, … }; + export const Depot = { id: Id('gameDepot'), depotId: 730, … }; +} +``` + +Every entity in `Examples` must be added and imported by the `Info` schema's `.meta({ example: … })`. + +--- + +## Pattern: Environment (`src/env.ts`) + +```ts +export namespace Env { + export const Info = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + FRONTEND_URL: z.string().optional(), + STEAM_API_KEY: z.string().optional(), + AUTH_ISSUER_URL: z.string().optional() // used by API auth middleware to verify tokens + }); + export type Info = z.infer; + export const env: Info = Info.parse(process.env); +} +``` + +--- + +## Pattern: OpenAuth Subjects (`src/auth/subjects.ts`) + +```ts +import { createSubjects } from '@nestri/auth/subject'; +import { z } from 'zod'; + +export const subjects = createSubjects({ + user: z.object({ + userID: z.string(), + linkedAccountID: z.string() + }) +}); +``` + +The JWT contains `{ type: 'user', properties: { userID, linkedAccountID } }`. +The API verifies via `client.verify(subjects, token)` from `@nestri/auth/client`. + +--- + +## Entity relationships + +``` +User ───1:N─── LinkedAccount ← auth methods (Steam, Epic, etc.) + │ + ├─── 1:N ─── Fingerprint ← SSH public keys + ├─── 1:N ─── Library ← owned games with playtime + │ + └─── N:M ─── Team ← via Member + └─ role: owner | admin | member + +Game ───1:N─── Download ← per-host game depot downloads +``` + +- **User**: Person record. Email is nullable (gaming accounts don't provide one). +- **LinkedAccount**: A gaming/OAuth identity. `(provider, providerAccountId)` is unique. +- **Team**: Organization for billing/collaboration. First team is auto-created as "personal" team. +- **TeamMember**: Joins User → Team with a role. `(teamId, userId)` is unique. + +--- + +## Database access + +```ts +// Auto-scoped transaction (creates one if outside a transaction): +await Database.use(async (tx) => { + await tx.insert(SomeTable).values({ … }); + const result = await tx.select().from(SomeTable).where(…); +}); + +// Explicit transaction: +await Database.transaction(async (tx) => { + // All operations in one atomic transaction +}); + +// Side-effect queued after transaction commits: +Database.effect(() => sendEmail(…)); +``` + +--- + +## Soft-delete convention + +Every table has `time_deleted` (nullable timestamp). Queries filter with `isNull(table.timeDeleted)`. Deletion sets `timeDeleted: sql\`now()\`` — never hard-deletes. + +--- + +## Dependency rule + +- `.sql.ts` files may import from other `.sql.ts` files (for FK references). +- `index.ts` files may import from other `index.ts` files and `.sql.ts` files. +- Never import an `index.ts` from within a `.sql.ts` — that creates circular deps. + +--- + +## Actor Model (`packages/core/src/actor.ts`) + +The Actor model identifies who/what is making a request. It uses `AsyncLocalStorage` (via `Context.create()`) so the actor is accessible anywhere in the call chain without passing it around. + +### Actor types + +```ts +type ActorInfo = + | { type: 'public'; properties: {} } + | { type: 'user'; properties: { userID: string; linkedAccountID: string } } + | { + type: 'member'; + properties: { userID: string; teamID: string; role: 'owner' | 'admin' | 'member' }; + } + | { type: 'system'; properties: { teamID: string } } + | { type: 'admin'; properties: {} }; +``` + +### API + +```ts +Actor.use(); // → ActorInfo (throws if no context set) +Actor.with(value, fn); // Run fn in the given actor context +Actor.assert(type); // Assert current actor type, returns narrowed type +Actor.type; // → 'public' | 'user' | 'member' | 'system' | 'admin' +Actor.userID; // → string (user/member only) +Actor.linkedAccountID; // → string (user only) +Actor.useTeam; // → string (member/system only — the teamID) +Actor.role; // → 'owner' | 'admin' | 'member' (member only) +Actor.isSignedIn; // → boolean (true if not public) +``` + +### When to pull from Actor vs pass as param + +Functions that create resources owned by the current user (e.g. `Team.create`) pull `userID` from the actor context rather than requiring it as a parameter. This avoids passing `ownerId`/`userId` through the entire call chain. + +Domain functions that need the actor's identity import `Actor` and call `Actor.userID` inside the `fn()` callback: + +```ts +export const create = fn(Info.pick({ id: true, name: true, slug: true }), async (input) => { + const ownerId = Actor.userID; // from AsyncLocalStorage + // ... +}); +``` + +### Actor.userID inside Database.transaction() + +When doing find-or-create logic that must scope to the authenticated user, pull `Actor.userID` **inside** the `Database.transaction()` callback. This keeps scoping co-located with the DB logic: + +```ts +export const link = fn( + z.object({ steamId: z.string(), profile: z.record(z.string(), z.unknown()).optional() }), + async (input) => { + return Database.transaction(async () => { + const existing = await LinkedAccount.findByProvider({ ... }); + if (existing) return existing.id; + const id = Identifier.ascending('linkedAccount'); + await LinkedAccount.create({ id, userId: Actor.userID, provider: 'steam', ... }); + return id; + }); + } +); +``` + +This ensures the API endpoint is scoped to the user only — no `userId` param is passed from the route handler. The `Actor.userID` is set by the auth middleware's `Actor.with()`, propagated via `AsyncLocalStorage`. + +Callers must wrap actor-dependent work in `Actor.with()` before calling these functions. The API middleware does this automatically for HTTP requests. The auth worker sets it up explicitly: + +```ts +await Actor.with({ type: 'user', properties: { userID, linkedAccountID } }, async () => { + await Team.createPersonal({ displayName: personaname }); +}); +``` + +--- + +## Auth Flow + +### Auth Worker (`apps/auth/src/index.ts`) + +A Cloudflare Worker using `@nestri/auth` (OpenAuth). Entry point is the `success` callback after OAuth: + +1. Steam returns `steamid` → worker fetches profile from Steam API +2. Inside `Database.transaction()`: looks up existing `LinkedAccount.findByProvider`; if found returns existing user, else creates `User` + `LinkedAccount` +3. Wraps post-login setup in `Actor.with()`, then checks `Member.listByUser(userID)` +4. If no memberships, calls `Team.createPersonal({ displayName })` +5. Issues JWT via `context.subject('user', { userID, linkedAccountID })` + +### Admin Auth via Shared Secret + +Server-to-server calls can authenticate as an `admin` actor by setting the `x-nestri-admin-token` header to the value of `ADMIN_SHARED_SECRET`. This bypasses JWT auth entirely and grants a system-level actor with no user scope — useful for operations like adding games to the DB, syncing data, or other admin tasks. + +Configure `ADMIN_SHARED_SECRET` in `.env`; defaults to the same value as `SSH_AUTH_KEY` in dev (`dev-ssh-auth-key-change-in-prod`). + +### Auth Middleware (`apps/api/app/middleware/auth.ts`) + +Hono middleware that runs on every API request: + +1. Checks `x-nestri-admin-token` header — if it matches `Env.get().ADMIN_SHARED_SECRET`, sets actor to `admin` and proceeds immediately +2. Otherwise, reads `Authorization: Bearer ` header +3. Verifies via `client.verify(subjects, token)` from `@nestri/auth/client` +4. If valid `user` subject: + - Checks `x-nestri-team` header for team-scoped access + - If team header present, verifies membership via `Member.findByTeamAndUser` + - Sets actor to `member` (with role) or `user` type +5. If no token/invalid: sets actor to `public` type +6. Exports `notPublic` guard middleware — throws `VisibleError('authentication', UNAUTHORIZED, …)` if actor is `public`. Caught by `onError` → 401 JSON response. The `admin` actor passes this guard (it's not `public`), so admin routes can use `.use(notPublic)` like any other protected route. + +### OpenAuth Subjects (`src/auth/subjects.ts`) + +```ts +export const subjects = createSubjects({ + user: z.object({ userID: z.string(), linkedAccountID: z.string() }) +}); +``` + +The JWT contains `{ type: 'user', properties: { userID, linkedAccountID } }`. +Env var `AUTH_ISSUER_URL` configures which issuer to trust for token verification. + +--- + +## Team Discriminator System + +When creating a personal team (`Team.createPersonal`), the slug is derived from the display name: + +```ts +// 1. Sanitize: lowercase, replace non-alphanumeric with dashes, trim edges, max 50 chars +const baseSlug = displayName.toLowerCase().replace(/[^a-z0-9]+/g, '-')... + +// 2. Check if slug exists (fromSlug) +// 3. If taken, append random 4-digit discriminator: "name-1234" +const slug = existing ? `${baseSlug}-${discriminator}` : baseSlug; +``` + +This mirrors Discord's discriminator pattern. The discriminator is part of the slug string — not a separate column. + +--- + +## `fn()` and Actor Context + +`fn()` itself is unchanged — it validates input against a zod schema and calls the callback. The actor context is available inside `fn()` callbacks because `Actor.with()` uses `AsyncLocalStorage`, which propagates automatically through `await` chains. + +Every `fn()` callback can import and use `Actor` directly. No special wiring needed. + +--- + +## API Error Pattern (`packages/core/src/error.ts`) + +Centralized error types used by both the domain layer and the API. + +### ErrorResponse + +Zod schema for OpenAPI error responses: + +```ts +import { z } from 'zod'; + +export const ErrorResponse = z + .object({ + type: z.enum([ + 'validation', + 'authentication', + 'forbidden', + 'not_found', + 'already_exists', + 'rate_limit', + 'internal' + ]), + code: z.string(), + message: z.string(), + param: z.string().optional(), + details: z.any().optional() + }) + .meta({ ref: 'ErrorResponse' }); +``` + +### ErrorCodes + +Structured error code constants: + +| Category | Codes | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `Validation` | `MISSING_REQUIRED_FIELD`, `ALREADY_EXISTS`, `TEAM_ALREADY_EXISTS`, `INVALID_PARAMETER`, `INVALID_FORMAT`, `INVALID_STATE`, `IN_USE` | +| `Authentication` | `UNAUTHORIZED`, `INVALID_TOKEN`, `EXPIRED_TOKEN`, `INVALID_CREDENTIALS` | +| `Permission` | `FORBIDDEN`, `INSUFFICIENT_PERMISSIONS`, `ACCOUNT_RESTRICTED` | +| `NotFound` | `RESOURCE_NOT_FOUND` | +| `RateLimit` | `TOO_MANY_REQUESTS`, `QUOTA_EXCEEDED` | +| `Server` | `INTERNAL_ERROR`, `SERVICE_UNAVAILABLE`, `DEPENDENCY_FAILURE` | + +### VisibleError + +Throw this for any user-facing error. It carries structured data and converts cleanly to HTTP: + +```ts +throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + `User ${id} does not exist` +); +``` + +- `.statusCode()` — maps `type` → HTTP status (validation→400, authentication→401, forbidden→403, not_found→404, already_exists→409, rate_limit→429, internal→500) +- `.toResponse()` → `{ type, code, message, param?, details? }` + +The API's global `onError` handler catches `VisibleError` + `HTTPException` + unknown errors, logging each and returning the correct JSON shape. + +--- + +## API Route Pattern (`apps/api/app/routes/`) + +Every API domain is a TypeScript `namespace` with a `.route` property — a plain `new Hono()` instance with chained route definitions. + +### Route → Domain function flow + +``` +HTTP request ──► Route handler (thin) ──► Core domain fn() ──► DB + │ │ + │ validates input │ pulls Actor.userID + │ calls domain fn │ handles business logic + │ returns c.json({data}) │ inside Database.transaction() +``` + +Route handlers are **thin wrappers** — they validate input, call a core function, and return the result. All business logic lives in `packages/core/src//`. + +### Creating a route module + +```ts +// app/routes/.ts +import { z } from 'zod'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; +import { Thing } from '@nestri/core/thing/index'; +import { Examples } from '@nestri/core/examples'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { ErrorResponses, notPublic, Result, validator } from '../utils'; + +export namespace ThingApi { + export const route = new Hono() + .use(notPublic) + .get( + '/', + describeRoute({ + tags: ['Thing'], + summary: 'List things', + description: 'List all things', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + Thing.Info.array().meta({ + description: 'All things', + example: [Examples.Thing] + }) + ) + } + }, + description: 'All things' + }, + 400: ErrorResponses[400], + 404: ErrorResponses[404], + 429: ErrorResponses[429] + } + }), + async (c) => c.json({ data: await Thing.list() }) + ) + .get( + '/:id', + describeRoute({/* … */}), + validator( + 'param', + z.object({ + id: z.string().meta({ + description: 'ID of the thing', + example: Examples.Thing.id + }) + }) + ), + async (c) => { + const thing = await Thing.fromID(c.req.valid('param').id); + if (!thing) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + `Thing ${id} not found` + ); + } + return c.json({ data: thing }); + } + ); +} +``` + +### Grouped routes: `/(group)/(sub-route)` + +For domains with multiple sub-routes (e.g. Steam with `/link`, `/sync`, `/unlink`), group them under one route file. The namespace name is `XxxApi` (e.g. `SteamApi`), the route path is `/(group)`: + +```ts +// app/routes/steam.ts +import { z } from "zod"; +import { Hono } from "hono"; +import { describeRoute } from "hono-openapi"; +import { Steam } from "@nestri/core/steam/index"; +import { ErrorResponses, notPublic, Result, validator } from "../utils"; + +export namespace SteamApi { + export const route = new Hono() + .use(notPublic) + .post("/link", // → POST /steam/link + describeRoute({ tags: ["Steam"], summary: "Link a Steam account", ... }), + validator("json", z.object({ steamId: z.string() })), + async (c) => { + const { steamId } = c.req.valid("json"); + const result = await Steam.link({ steamId }); // ← calls core fn + return c.json({ data: { linkedAccountId: result, steamId } }); + }, + ) + .post("/sync", // → POST /steam/sync + // ... + ); +} +``` + +Registered in the app entry as `/steam`: + +```ts +// app/index.ts +import { SteamApi } from "./routes/steam.js"; + +const routes = app + .route("/", IndexApi.route) + .route("/users", UserApi.route) + .route("/steam", SteamApi.route) // mounts all /steam/* routes + .onError(…); +``` + +This keeps the route path and the namespace name aligned — the Hono instance at `SteamApi.route` is mounted at `/steam`. + +### Key conventions + +| Element | Pattern | +| ---------------- | --------------------------------------------------------------------------------- | +| Structure | `export namespace XxxApi { export const route = new Hono() … }` | +| Group route | `POST "/link"` at `XxxApi` → mounted at `/xxx` → `POST /xxx/link` | +| Auth guard | `.use(notPublic)` at the namespace level (or per-route) | +| Route is thin | validates input → calls core fn → returns `c.json({ data: … })` | +| Core fn | reusable `fn()` in `packages/core/src//` owns all logic | +| OpenAPI | `describeRoute({ tags, summary, description, responses })` wraps each handler | +| Response schema | `Result(Schema)` → `resolver(z.object({ data: schema }))` | +| Error responses | `ErrorResponses[statusCode]` for 400, 401, 403, 404, 409, 429, 500 | +| Param validation | `validator("param", z.object({…}))` — uses custom wrapper that formats Zod errors | +| Body validation | `validator("json", z.object({…}))` — same wrapper for request body | +| Not found | `throw new VisibleError("not_found", ErrorCodes.NotFound.RESOURCE_NOT_FOUND, …)` | +| Metadata | Use `.meta()` (NOT `.openapi()`) — Zod v4 native + `zod-openapi` v6 | + +### Registering a route in the app + +```ts +// app/index.ts +import { SteamApi } from "./routes/steam.js"; +import { ThingApi } from "./routes/thing.js"; + +const routes = app + .route("/", IndexApi.route) + .route("/users", UserApi.route) + .route("/steam", SteamApi.route) // mount group at /steam + .route("/things", ThingApi.route) // mount group at /things + .onError(…); +``` + +The first argument to `.route()` is the URL prefix. All sub-routes defined on that Hono instance are relative to this prefix. + +--- + +## API Utils (`apps/api/app/utils/`) + +| File | Export | Purpose | +| -------------- | -------------------- | ---------------------------------------------------------------------------------------------- | +| `index.ts` | — | Barrel re-export of all utils | +| `auth.ts` | `auth`, `notPublic` | Re-exports from `middleware/auth` | +| `error.ts` | `ErrorResponses` | `{ 400, 401, 403, 404, 409, 429, 500 }` → OpenAPI response objects | +| `result.ts` | `Result` | `resolver(z.object({ data: T }))` — standard `{ data: … }` response shape | +| `validator.ts` | `validator` | Wraps `hono-openapi/zod`'s validator with standardized Zod error formatting (400 + error code) | +| `hook.ts` | `Hook`, `zValidator` | Type declarations (re-exported from `@hono/zod-validator`) | + +--- + +## Main API Entry (`apps/api/app/index.ts`) + +The entry point wires everything together. Key structure: + +```ts +import 'zod-openapi'; // augment Zod v4 with OpenAPI metadata types +import { Hono } from 'hono'; +import { logger } from 'hono/logger'; +import { cors } from 'hono/cors'; +import { showRoutes } from 'hono/dev'; +import { openAPISpecs } from 'hono-openapi'; +import { HTTPException } from 'hono/http-exception'; + +export const app = new Hono(); + +// Global middleware (order matters) +app + .use(logger()) + .use(async (c, next) => { + c.header('Cache-Control', 'no-store'); + return next(); + }) + .use(cors({ origin: Env.env.FRONTEND_URL || 'http://localhost:5173', credentials: true })) + .use(auth); + +// Routes + error handler +const routes = app + .route('/', IndexApi.route) + .route('/things', ThingApi.route) + .onError((error, c) => { + if (error instanceof VisibleError) { + return c.json(error.toResponse(), error.statusCode()); + } + if (error instanceof HTTPException) { + return c.json( + { + type: 'validation', + code: ErrorCodes.Validation.INVALID_PARAMETER, + message: 'Invalid request' + }, + error.status + ); + } + return c.json( + { + type: 'internal', + code: ErrorCodes.Server.INTERNAL_ERROR, + message: 'Internal server error' + }, + 500 + ); + }); + +// OpenAPI spec at /doc +app.get( + '/doc', + openAPISpecs(routes, { documentation: { info: { title: 'API', version: '0.0.1' } } }) +); + +showRoutes(app); + +export default { port: process.env.PORT ?? 3000, fetch: app.fetch }; +``` + +### Dev / production + +``` +bun --watch app/index.ts # dev with hot reload +bun app/index.ts # production +``` + +No Vite needed — Bun runs TypeScript natively. + +--- + +## Important: `.meta()` vs `.openapi()` + +| Library | Method | Notes | +| -------------------------- | ------------ | ------------------------------------------------------------------------------------ | +| `zod-openapi` v4 (old) | `.openapi()` | Required `import "zod-openapi/extend"` | +| `zod-openapi` v6 (current) | `.meta()` | Native Zod v4 method; no import needed — auto-augments via `declare module 'zod/v4'` | + +- **Domain schemas** (`@nestri/core/*/index.ts`) use `.meta()` for descriptions/examples. +- **API route schemas** (`apps/api/app/routes/*.ts`) use `.meta()` for OpenAPI response/docs metadata. +- **Never** use `.openapi()` — it doesn't exist in `zod-openapi` v6. + +--- + +## Error flow summary + +``` +Route handler + │ + ├─ throws VisibleError ──► onError → c.json(error.toResponse(), error.statusCode()) + │ + ├─ throws HTTPException ─► onError → c.json({ type: "validation", … }, error.status) + │ + ├─ throws raw Error ─────► onError → c.json({ type: "internal", … }, 500) + │ (includes VisibleError for Actor model / context issues) + │ + └─ returns normally ─────► c.json({ data: … }) +``` + +--- + +# Alchemy (IaC) + +This project uses [Alchemy](https://alchemy.run) (v0.93.12) for infrastructure-as-code — the equivalent of SST, but targeting Cloudflare Workers instead of AWS Lambda. + +## Project structure + +``` +web/ + alchemy.run.ts # Entry point — creates scope, imports infra + infra/ + stage.ts # Stage detection (Scope.getCurrentScope().stage) + secret.ts # Encrypted secrets via alchemy.secret() + auth.ts # Auth Worker resource + api.ts # API Worker resource +``` + +## `alchemy.run.ts` — entry point + +```ts +import alchemy from 'alchemy'; + +const app = await alchemy('nestri', { + password: process.env.ALCHEMY_PASSWORD // required for secrets +}); + +// Import infra modules in dependency order (SST-style) +await import('./infra/stage.ts'); +await import('./infra/secret.ts'); +await import('./infra/auth.ts'); +await import('./infra/api.ts'); + +await app.finalize(); +``` + +Key rules: + +- `alchemy(appName, opts)` creates a **scope** — resources register into this scope automatically +- `app.finalize()` must be called at the end to persist state +- Import order matters — resources that depend on others must be imported after +- `--dev` flag runs locally via Miniflare; omit it to deploy to Cloudflare + +## Infra resources + +Each resource is imported from `alchemy/cloudflare` and called with an ID + props: + +```ts +import { Worker, KVNamespace, D1Database } from 'alchemy/cloudflare'; + +export const kv = await KVNamespace('my-kv'); +export const db = await D1Database('my-db'); + +export const worker = await Worker('my-worker', { + entrypoint: 'apps/some-app/src/index.ts', + compatibility: 'node', // enables nodejs_compat flag + url: true, // assign workers.dev URL + bindings: { + KV: kv, // resource binding → KVNamespace at runtime + DB: db, // → D1Database + PLAIN_VAR: 'hello' // → plain_text binding + } +}); +``` + +### Supported resources (subset) + +| Resource | Import | Purpose | +| ------------- | -------------------- | ----------------------------------------------- | +| `Worker` | `alchemy/cloudflare` | Cloudflare Worker (entrypoint or inline script) | +| `KVNamespace` | `alchemy/cloudflare` | KV storage | +| `D1Database` | `alchemy/cloudflare` | D1 SQL database | +| `R2Bucket` | `alchemy/cloudflare` | R2 object storage | +| `Queue` | `alchemy/cloudflare` | Queue/pub-sub | + +### Compatibility flag + +Always add `compatibility: 'node'` to Workers that use Node.js built-ins (`node:async_hooks`, `crypto`, `node:stream`, etc.): + +```ts +Worker('api', { + entrypoint: 'apps/api/app/index.ts', + compatibility: 'node' // enables nodejs_compat +}); +``` + +## Stage detection + +```ts +// infra/stage.ts +import { Scope } from 'alchemy'; +const scope = Scope.getCurrentScope(); +export const stage = scope?.stage ?? 'dev'; +export const isPermanent = ['production', 'dev'].includes(stage); +``` + +Use stage for conditional infrastructure: + +```ts +const api = await Worker('api', { + ...(isPermanent && { + observability: { enabled: true }, + logpush: true + }) +}); +``` + +Pass `--stage` flag at runtime: `bun alchemy.run.ts --stage production` + +## Secrets and environment variables + +Three levels of env management, from most-secure to least: + +### 1. `alchemy.secret.env.X` (preferred) + +```ts +// infra/secret.ts +import alchemy from 'alchemy'; + +export const secret = { + steamApiKey: alchemy.secret.env.STEAM_API_KEY // reads process.env at deploy time + // Equivalent to: + // steamApiKey: alchemy.secret(process.env.STEAM_API_KEY), +}; +``` + +- Reads from `process.env` at deploy time +- Throws a descriptive error if the env var is missing +- Encrypted in Alchemy state files (`.alchemy/`) +- Deployed as `secret_text` binding (hidden from Cloudflare API) + +### 2. `alchemy.env()` (non-secret config) + +```ts +export const frontendUrl = alchemy.env('FRONTEND_URL', 'http://localhost:5173'); +``` + +- Optional default value +- Plain text — not encrypted +- Deployed as `plain_text` binding + +### 3. Plain strings in `bindings` (inline) + +```ts +bindings: { + MY_VAR: 'hello'; +} +``` + +- Hard-coded, visible in state files +- Deployed as `plain_text` binding + +### How bindings map to runtime types + +| Alchemy binding type | Deployed as | Runtime type | +| -------------------- | -------------- | -------------------------- | +| `Worker` | `service` | `Service` (has `.fetch()`) | +| `KVNamespace` | `kv_namespace` | `KVNamespace` | +| `D1Database` | `d1` | `D1Database` | +| `alchemy.secret()` | `secret_text` | `string` | +| plain `string` | `plain_text` | `string` | +| `Json(...)` | `json` | `typeof json` | + +## Service bindings (Worker → Worker) + +Pass one Worker as a binding to another: + +```ts +// infra/auth.ts +export const auth = await Worker('auth', { + entrypoint: 'apps/auth/src/index.ts', + compatibility: 'node', + bindings: { ... }, +}); + +// infra/api.ts +import { auth } from './auth.ts'; +export const api = await Worker('api', { + entrypoint: 'apps/api/app/index.ts', + bindings: { AUTH: auth }, +}); +``` + +At runtime, `env.AUTH` is a `Service` — call it directly: + +```ts +const response = await env.AUTH.fetch(request); +``` + +### OpenAuth client + service binding + +The `@openauthjs/openauth/client` only accepts a URL string for `issuer`, so use a custom `fetch` to route through the service binding: + +```ts +function getClient(env: Record) { + return createClient({ + issuer: 'https://auth.internal', // dummy — used for path construction + clientID: 'api', + fetch: (input, init) => { + const url = new URL(typeof input === 'string' ? input : input.url); + const request = new Request(url.pathname + url.search, init); + return (env.AUTH as { fetch: typeof fetch }).fetch(request); + } + }); +} +``` + +## Env propagation to Workers + +CF Workers receive env vars as the second argument to the `fetch` handler (`env`), NOT via `process.env`. Bridge the gap with a lazy + overridable schema: + +```ts +// packages/core/src/env.ts +import { memo } from '../utils/memo.ts'; + +let _overrides: Record = {}; + +export namespace Env { + export const Info = z.object({ + FRONTEND_URL: z.string().optional(), + STEAM_API_KEY: z.string().optional(), + AUTH_ISSUER_URL: z.string().optional() + }); + export type Info = z.infer; + + const _get = memo(() => Info.parse({ ...process.env, ..._overrides })); + + export function get(): Info { + return _get(); + } + + export function init(bindings: Record) { + _overrides = bindings; + _get.reset(); + } +} +``` + +Wire in the Hono entrypoint: + +```ts +export default { + fetch(request, env, ctx) { + Env.init(env); // merge CF bindings into Env + return app.fetch(request, env, ctx); + } +}; +``` + +Now any module that imports `Env.get()` gets the correct values — on Bun dev `process.env` provides them, on CF Workers the bindings override. + +## CLI usage + +```sh +# Local dev (Miniflare) +bun alchemy.run.ts --dev + +# Deploy to Cloudflare +bun alchemy.run.ts --stage production + +# Destroy all resources +bun alchemy.run.ts --destroy + +# With custom stage +bun alchemy.run.ts --stage wanjohiryan + +# Password (for encrypting secrets) +export ALCHEMY_PASSWORD="some-passphrase" +``` + +When deploying, set `CLOUDFLARE_API_TOKEN` or configure `alchemy login`. + +## Common patterns + +### Conditional infra per-stage + +```ts +Worker('api', { + ...(isPermanent && { logpush: true }), + ...(stage === 'production' && { scaling: { min: 3, max: 10 } }) +}); +``` + +### Across-app resource references + +Alchemy uses top-level await in infra files — resources resolve at import time within the active scope. The scope propagates via `AsyncLocalStorage`, so any `await import()` after `alchemy(appName)` picks it up. + +### .alchemy/ directory + +Created automatically — contains Miniflare state, build output, and encrypted state files. Add to `.gitignore`. + +```gitignore +.alchemy/ +``` + +--- + +### Index Rule: Null-Safe Exclusions (`IS DISTINCT FROM`) + +When writing indices to track data drift, synchronization deltas, or pending background worker states where values might be nullable, **always build a partial index utilizing Postgres-native `IS DISTINCT FROM`**. + +Standard inequality operators (`!=` or `<>`) evaluate to `NULL` if either column is `NULL`, causing them to bypass standard `WHERE` index filters. Using `is distinct from` allows Postgres to treat `NULL` as a real value for state comparison: + +- Excludes perfectly synchronized records completely from the index footprint. +- Optimizes heavy background worker poll queries directly into small, lightning-fast index scans. + +2. Add to the Pattern: index.ts (Domain Namespace) section + +Replace the existing create block and add the upsert block inside SomeModule: + +```ts +// ── create ─────────────────────────────────────────────────────────── +// Use Info.pick({…}) for the schema — keeps fields in sync with Info. +// Always use .returning() to get the updated row context in one database trip. +export const create = fn(Info.pick({ id: true, name: true, email: true }), async (input) => { + return Database.use(async (tx) => { + const [row] = await tx + .insert(SomeTable) + .values({ + id: input.id, + name: input.name, + email: input.email ?? null + }) + .returning(); + return row; + }); +}); + +// ── upsert ─────────────────────────────────────────────────────────── +// Simple copies use the input values directly. For coalesce-style set +// expressions, reference the excluded pseudo-table with unqualified +// identifiers: sql`excluded.${sql.identifier(SomeTable.name.name)}` — +// interpolating a column object (or its .name string) is invalid. +export const upsert = fn(Info.pick({ id: true, name: true }), async (input) => { + return Database.use(async (tx) => { + const [row] = await tx + .insert(SomeTable) + .values({ id: input.id, name: input.name }) + .onConflictDoUpdate({ + target: SomeTable.id, + set: { name: input.name } + }) + .returning(); + return row; + }); +}); +``` diff --git a/alchemy.run.ts b/alchemy.run.ts new file mode 100644 index 00000000..fd2455ef --- /dev/null +++ b/alchemy.run.ts @@ -0,0 +1,130 @@ +import * as Alchemy from 'alchemy'; +import { adopt } from 'alchemy/AdoptPolicy'; +import * as Cloudflare from 'alchemy/Cloudflare'; +import { Redacted } from 'effect'; +import * as Effect from 'effect/Effect'; + +const steamApiKey = Redacted.make(process.env.STEAM_API_KEY!); +const sshAuthKey = process.env.SSH_AUTH_KEY || 'dev-ssh-auth-key-change-in-prod'; +const adminSharedSecret = + process.env.ADMIN_SHARED_SECRET || 'dev-admin-shared-secret-change-in-prod'; + +const AuthStorage = Cloudflare.KV.Namespace('auth-storage'); + +const Database = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + const database = stage === 'production' ? 'defaultdb' : 'sandbox'; + return yield* Cloudflare.Hyperdrive.Connection('db', { + origin: { + scheme: 'postgres', + host: 'public-nestri-pg-1-atdogthbymao.db.upclouddatabases.com', + port: 11569, + database, + user: 'upadmin', + password: Redacted.make(process.env.DATABASE_PASSWORD!) + }, + dev: { + scheme: 'postgres', + host: 'localhost', + port: 5432, + database: 'nestri', + user: 'postgres', + password: Redacted.make('postgres') + } + }); +}); + +export const Auth = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + const isPermanent = ['production', 'sandbox', 'dev'].includes(stage); + return yield* Cloudflare.Worker('auth', { + main: 'apps/auth/src/index.ts', + compatibility: { flags: ['nodejs_compat'] }, + env: { + AuthStorage, + HYPERDRIVE: Database, + STEAM_API_KEY: steamApiKey, + SSH_AUTH_KEY: sshAuthKey + }, + ...(isPermanent ? { observability: { enabled: true } } : {}) + }); +}); + +export const Api = Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + const isPermanent = ["production", "sandbox", "dev"].includes(stage); + const prefix = stage === "production" ? "" : `${stage}.`; + const authDomain = ["production", "sandbox"].includes(stage) + ? `${prefix}auth.nestri.io` + : undefined; + return yield* Cloudflare.Worker("api", { + main: "apps/api/app/index.ts", + compatibility: { flags: ["nodejs_compat"] }, + env: { + AUTH: Auth, + AUTH_ISSUER_URL: authDomain + ? `https://${authDomain}` + : "http://localhost:1337", + HYPERDRIVE: Database, + STEAM_API_KEY: steamApiKey, + ADMIN_SHARED_SECRET: adminSharedSecret, + }, + ...(isPermanent ? { observability: { enabled: true } } : {}), + }); +}); + +export default Alchemy.Stack( + 'nestri', + { + providers: Cloudflare.providers(), + state: Alchemy.localState() + }, + Effect.gen(function* () { + const { stage } = yield* Alchemy.Stack; + + yield* Database; + const auth = yield* Auth; + const api = yield* Api; + + if (stage === "production" || stage === "sandbox") { + const zone = yield* Cloudflare.Zone.Zone("zone", { + name: "nestri.io", + }).pipe(adopt(true)); + + const prefix = stage === "production" ? "" : `${stage}.`; + + yield* Cloudflare.DNS.Record("auth-dns", { + zoneId: zone.zoneId, + name: `${prefix}auth.nestri.io`, + type: "AAAA", + content: "100::", + proxied: true, + }); + + yield* Cloudflare.DNS.Record("api-dns", { + zoneId: zone.zoneId, + name: `${prefix}api.nestri.io`, + type: "AAAA", + content: "100::", + proxied: true, + }); + + yield* Cloudflare.Workers.WorkerRoute("auth-route", { + zoneId: zone.zoneId, + pattern: `${prefix}auth.nestri.io/*`, + script: auth.workerName, + }); + + yield* Cloudflare.Workers.WorkerRoute("api-route", { + zoneId: zone.zoneId, + pattern: `${prefix}api.nestri.io/*`, + script: api.workerName, + }); + } + + return { + authUrl: auth.url.as(), + apiUrl: api.url.as() + }; + }) +); diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 00000000..a14702c4 --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,34 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store diff --git a/apps/api/app/index.ts b/apps/api/app/index.ts new file mode 100644 index 00000000..66feb5e6 --- /dev/null +++ b/apps/api/app/index.ts @@ -0,0 +1,107 @@ +import type { Api } from '../../../alchemy.run.ts'; +import type { InferEnv } from 'alchemy/Cloudflare'; + +import { Env } from '@nestri/core/env'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { Hono } from 'hono'; +import { openAPISpecs } from 'hono-openapi'; +import { cors } from 'hono/cors'; +import { HTTPException } from 'hono/http-exception'; +import { logger } from 'hono/logger'; +import { type ContentfulStatusCode } from 'hono/utils/http-status'; + +import { auth } from './middleware/auth.js'; +import { AccessTokenApi } from './routes/access-token.js'; +import { GameApi } from './routes/game.js'; +import { IndexApi } from './routes/index.js'; +import { LibraryApi } from './routes/library.js'; +import { MachineApi } from './routes/machine.js'; +import { PairingCodeApi } from './routes/pairing-code.js'; +import { SteamApi } from './routes/steam.js'; +import { UserApi } from './routes/user.js'; + +export const app = new Hono(); + +app + .use(logger()) + .use(async (c, next) => { + c.header('Cache-Control', 'no-store'); + return next(); + }) + .use( + cors({ + origin: () => Env.get().FRONTEND_URL || 'http://localhost:5173', + credentials: true + }) + ) + .use(auth); + +const routes = app + .route('/', IndexApi.route) + .route('/user', UserApi.route) + .route('/steam', SteamApi.route) + .route('/library', LibraryApi.route) + .route('/games', GameApi.route) + .route('/pairing-code', PairingCodeApi.route) + .route('/machine', MachineApi.route) + .route('/access-token', AccessTokenApi.route) + .onError((error, c) => { + if (error instanceof VisibleError) { + // eslint-disable-next-line no-console + console.error('api error:', error); + return c.json(error.toResponse(), error.statusCode() as ContentfulStatusCode); + } + + if (error instanceof HTTPException) { + // eslint-disable-next-line no-console + console.error('http error:', error); + return c.json( + { + type: 'validation', + code: ErrorCodes.Validation.INVALID_PARAMETER, + message: 'Invalid request' + }, + error.status + ); + } + // eslint-disable-next-line no-console + console.error('unhandled error:', error); + return c.json( + { + type: 'internal', + code: ErrorCodes.Server.INTERNAL_ERROR, + message: 'Internal server error' + }, + 500 + ); + }); + +app.get( + '/doc', + openAPISpecs(routes, { + documentation: { + info: { + title: 'Nestri API', + description: 'API', + version: '0.0.1' + }, + components: { + securitySchemes: { + Bearer: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT' + } + } + }, + security: [{ Bearer: [] }] + } + }) +); + +export default { + fetch(request: Request, env: InferEnv, ctx: ExecutionContext) { + Env.init(env as unknown as Record); + return app.fetch(request, env, ctx); + } +}; diff --git a/apps/api/app/middleware/auth.ts b/apps/api/app/middleware/auth.ts new file mode 100644 index 00000000..cffcc635 --- /dev/null +++ b/apps/api/app/middleware/auth.ts @@ -0,0 +1,271 @@ +import { createClient } from '@nestri/auth/client'; +import { AccessToken } from '@nestri/core/access-token/index'; +import { Actor } from '@nestri/core/actor'; +import { subjects } from '@nestri/core/auth/subjects'; +import { Env } from '@nestri/core/env'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { Machine } from '@nestri/core/machine/index'; +import { Member } from '@nestri/core/team/member'; +import type { MiddlewareHandler } from 'hono'; + +/** + * Reaches the auth worker over its service binding. + * + * The origin has to survive. A binding routes by binding rather than by + * hostname, so the host is arbitrary — but `new Request` still demands an + * absolute URL, and stripping down to a bare path threw `Invalid URL` before + * the token was even looked at. + */ +function bindingFetch(env: Record) { + return (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + return (env.AUTH as { fetch: typeof fetch }).fetch(new Request(url, init)); + }; +} + +/** + * The issuer must be the auth worker's **public** URL. + * + * `verify` checks a token's `iss` claim against the issuer the client was + * built with, and the auth worker derives what it advertises from the URL it + * was reached on. Tokens are minted through the public URL, so they carry it. + * A placeholder like `https://auth.internal` addresses the binding perfectly + * well — the hostname is ignored there — and then disagrees with every real + * token. Discovery through the binding does not help: it answers with the + * placeholder too, because that is the host it was asked on. + * + * The failure is silent by nature. A rejected claim is reported as `err`, + * which is indistinguishable from an expired or forged token, so the whole + * bearer path returns 401 and looks like ordinary auth working correctly. + * Hence the explicit throw rather than a fallback: a misconfiguration here + * takes down every user session, and it should say so. + */ +function getClient(env: Record) { + const configured = Env.get().AUTH_ISSUER_URL; + if (!configured) { + throw new Error( + 'AUTH_ISSUER_URL is not configured; every bearer token would be rejected as unsigned' + ); + } + // The trailing slash matters twice, and both failures are quiet. It is + // appended to build the discovery URL, where `…:1337//.well-known/…` is a + // 404; and it is compared literally against the `iss` claim, which carries + // no trailing slash. A worker URL from the platform arrives with one. + const issuer = configured.replace(/\/+$/, ''); + return createClient({ + issuer, + clientID: 'api', + fetch: bindingFetch(env) + }); +} + +export const auth: MiddlewareHandler = async (c, next) => { + const adminToken = c.req.header('x-nestri-admin-token'); + if (adminToken && adminToken === Env.get().ADMIN_SHARED_SECRET) { + return Actor.with({ type: 'admin', properties: {} }, next); + } + + // A registered nessh host proves it is itself, rather than asserting an id + // nobody checks. Wrong credentials fall through to public rather than + // erroring, so probing tells an attacker nothing about which ids exist. + const machineId = c.req.header('x-nestri-machine-id'); + const machineSecret = c.req.header('x-nestri-machine-secret'); + if (machineId && machineSecret) { + const machine = await Machine.authenticate({ id: machineId, secret: machineSecret }); + if (machine) { + await Machine.touchLastSeen(machine.id); + return Actor.with( + { + type: 'machine', + properties: { + machineID: machine.id, + ownerUserID: machine.ownerUserId, + ...(machine.teamId ? { teamID: machine.teamId } : {}) + } + }, + next + ); + } + return Actor.with({ type: 'public', properties: {} }, next); + } + + const authHeader = c.req.header('authorization'); + if (!authHeader) { + return Actor.with({ type: 'public', properties: {} }, next); + } + + const match = authHeader.match(/^Bearer (.+)$/); + if (!match) { + return Actor.with({ type: 'public', properties: {} }, next); + } + + const token = match[1]; + + // A personal access token is resolved from the database, never through JWT + // verification. The prefix decides which, so a PAT does not pay for a + // well-known lookup and a JWT does not pay for a query. + if (AccessToken.looksLikeToken(token)) { + const pat = await AccessToken.authenticate(token); + if (!pat) { + return Actor.with({ type: 'public', properties: {} }, next); + } + await AccessToken.touchLastUsed(pat.id); + + if (pat.teamId) { + // The team grant is re-checked against live membership rather than + // trusted from the row, so someone removed from a team loses what + // their old token carried without anyone remembering to revoke it. + const membership = await Member.findByTeamAndUser({ + teamId: pat.teamId, + userId: pat.ownerUserId + }); + if (!membership) { + return Actor.with({ type: 'public', properties: {} }, next); + } + return Actor.with( + { + type: 'member', + properties: { + userID: pat.ownerUserId, + role: membership.role, + teamID: pat.teamId + } + }, + next + ); + } + + return Actor.with( + { + type: 'user', + properties: { + userID: pat.ownerUserId, + // A PAT is tied to neither a Steam account nor a device, so + // it carries neither. A route needing those must read them + // from the user rather than assume the caller came by SSH. + linkedAccountID: '', + fingerprint: undefined + } + }, + next + ); + } + + // A token that cannot be verified — malformed, expired, or because the + // auth service is unreachable — makes the caller unauthenticated, not the + // request a server fault. `verify` reports the first two in `err` and + // *throws* the third, and an uncaught throw turned a bad token into a 500. + let verified; + try { + verified = await getClient(c.env).verify(subjects, token); + } catch (error) { + // eslint-disable-next-line no-console + console.error('token verification failed:', error); + return Actor.with({ type: 'public', properties: {} }, next); + } + if (verified.err) { + return Actor.with({ type: 'public', properties: {} }, next); + } + + const { subject } = verified; + if (subject.type === 'user') { + const teamID = c.req.header('x-nestri-team'); + if (teamID) { + const membership = await Member.findByTeamAndUser({ + teamId: teamID, + userId: subject.properties.userID + }); + if (membership) { + return Actor.with( + { + type: 'member', + properties: { + userID: subject.properties.userID, + role: membership.role, + teamID + } + }, + next + ); + } + } + return Actor.with( + { + type: 'user', + properties: { + userID: subject.properties.userID, + linkedAccountID: subject.properties.linkedAccountID, + fingerprint: subject.properties.fingerprint + } + }, + next + ); + } + + return Actor.with({ type: 'public', properties: {} }, next); +}; + +/** + * Requires an authenticated caller of any kind, machines included. + * + * It deliberately does *not* single machines out: `/games` applies this to the + * whole group, and download-state — the one route a box exists to call — sits + * inside it. What stops a box from acting as its owner is `Actor.userID`, + * which refuses a machine outright, so a route written for a human cannot + * silently accept a box no matter which guard it sits behind. + */ +export const notPublic: MiddlewareHandler = async (_, next) => { + const actor = Actor.use(); + if (actor.type === 'public') { + throw new VisibleError( + 'authentication', + ErrorCodes.Authentication.UNAUTHORIZED, + 'Missing authorization header' + ); + } + return next(); +}; + +/** Requires credentials belonging to a registered nessh host. */ +export const machineOnly: MiddlewareHandler = async (_, next) => { + const actor = Actor.use(); + if (actor.type !== 'machine') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Machine credentials required' + ); + } + return next(); +}; + +/** + * A box reporting about itself, or an operator reaching in. + * + * The two are not equivalent and routes behind this must not treat them so: a + * machine may only speak for itself, while admin still has to say which host + * it means. Keeping admin is what lets an operator repair state by hand. + */ +export const machineOrAdmin: MiddlewareHandler = async (_, next) => { + const actor = Actor.use(); + if (actor.type !== 'machine' && actor.type !== 'admin') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Machine or admin credentials required' + ); + } + return next(); +}; + +export const adminOnly: MiddlewareHandler = async (_, next) => { + const actor = Actor.use(); + if (actor.type !== 'admin') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Admin access required' + ); + } + return next(); +}; diff --git a/apps/api/app/routes/access-token.ts b/apps/api/app/routes/access-token.ts new file mode 100644 index 00000000..db137a47 --- /dev/null +++ b/apps/api/app/routes/access-token.ts @@ -0,0 +1,207 @@ +import { AccessToken } from '@nestri/core/access-token/index'; +import { Actor } from '@nestri/core/actor'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { Examples } from '@nestri/core/examples'; +import { Identifier } from '@nestri/core/id'; +import { Member } from '@nestri/core/team/member'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; +import { z } from 'zod'; + +import { ErrorResponses, notPublic, Result, validator } from '../utils'; + +/** + * Personal access tokens. + * + * The credential for anything that is not a browser: a nessh box registering + * itself, or a script driving the API. A session JWT cannot do this job — it + * is short-lived and cannot be revoked without rotating signing keys for + * everyone, which is wrong for something that sits in a config file for + * months. + * + * Minting requires a *user session*, deliberately. Allowing the admin token to + * mint one for an arbitrary user would turn a credential that can read and + * write all API data into one that can *become* any user, and that boundary is + * the reason `Actor.userID` refuses admin at all. + */ +/** + * Decide what a new token is scoped to. + * + * Team scope is the default, because a box or a script is nearly always doing + * team work and a user-scoped token silently cannot see any of it. But the + * default only applies when it is *unambiguous*: with several teams, guessing + * would hand out a token reaching resources the caller did not have in mind. + * + * Note team scope is broader than user scope, never narrower — hence `null` as + * an explicit way to ask for the narrow one. It still cannot exceed what the + * user themselves may do: the grant is re-checked against live membership on + * every request, with their own role. + * + * @param requested `undefined` to take the default, `null` to force user + * scope, or a team id to name one. + */ +async function resolveTeamScope(requested: string | null | undefined): Promise { + if (requested === null) { + return null; + } + + if (requested !== undefined) { + // Verified here rather than trusted from the body: a token is only ever + // as scoped as what was checked at the moment it was made. + const membership = await Member.findByTeamAndUser({ + teamId: requested, + userId: Actor.userID + }); + if (!membership) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.FORBIDDEN, + 'You are not a member of that team' + ); + } + return requested; + } + + const memberships = await Member.listByUser(Actor.userID); + if (memberships.length === 0) { + return null; + } + if (memberships.length > 1) { + throw new VisibleError( + 'validation', + ErrorCodes.Validation.INVALID_PARAMETER, + 'You belong to several teams — name the one this token is for, or pass teamId: null to scope it to yourself', + 'teamId' + ); + } + return memberships[0]!.teamId; +} + +export namespace AccessTokenApi { + export const route = new Hono() + .post( + '/', + notPublic, + describeRoute({ + tags: ['AccessToken'], + summary: 'Create a personal access token', + description: + 'Mint a long-lived, revocable token for the calling user. The token is returned once and never again — only its digest is stored.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + id: z.string().meta({ example: Examples.AccessToken.id }), + token: z.string().meta({ + description: 'Shown once. Store it now; it cannot be retrieved.' + }) + }) + ) + } + }, + description: 'A freshly minted token' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + validator( + 'json', + z.object({ + name: z.string().min(1).max(64).meta({ + description: 'What this token is for, so it can be recognised in a list', + example: Examples.AccessToken.name + }), + teamId: z.string().nullable().optional().meta({ + description: + 'Team to scope the token to. Omit to default to your team when you have exactly one; pass null to force a token scoped to you alone.' + }), + expiresInDays: z.number().int().min(1).max(365).optional().meta({ + description: 'Optional lifetime. Omit for a token that does not expire.' + }) + }) + ), + async (c) => { + const { name, teamId, expiresInDays } = c.req.valid('json'); + + const actor = Actor.use(); + if (actor.type !== 'user' && actor.type !== 'member') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Creating an access token requires a user session' + ); + } + + const scopedTeamId = await resolveTeamScope(teamId); + + const created = await AccessToken.create({ + id: Identifier.ascending('accessToken'), + ownerUserId: Actor.userID, + teamId: scopedTeamId, + name, + expiresInDays + }); + + return c.json({ data: { id: created.id, token: created.token } }); + } + ) + .get( + '/', + notPublic, + describeRoute({ + tags: ['AccessToken'], + summary: 'List your access tokens', + description: 'Returns metadata only. The token values are not stored and cannot be shown.', + responses: { + 200: { + content: { 'application/json': { schema: Result(z.array(AccessToken.Info)) } }, + description: 'Tokens belonging to the caller' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + async (c) => { + return c.json({ data: await AccessToken.listByOwner(Actor.userID) }); + } + ) + .delete( + '/:id', + notPublic, + describeRoute({ + tags: ['AccessToken'], + summary: 'Revoke an access token', + description: + 'Revokes immediately. Revocation is the reason these exist rather than long-lived JWTs.', + responses: { + 200: { + content: { 'application/json': { schema: Result(z.object({ id: z.string() })) } }, + description: 'The token no longer works' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403], + 404: ErrorResponses[404] + } + }), + async (c) => { + // Scoped to the owner in the query itself, so revoking someone + // else's token is a 404 rather than a permission check that + // could be forgotten. + const revoked = await AccessToken.revoke({ + id: c.req.param('id'), + ownerUserId: Actor.userID + }); + if (!revoked) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such token, or it is not yours' + ); + } + return c.json({ data: { id: revoked.id } }); + } + ); +} diff --git a/apps/api/app/routes/game.ts b/apps/api/app/routes/game.ts new file mode 100644 index 00000000..034d4019 --- /dev/null +++ b/apps/api/app/routes/game.ts @@ -0,0 +1,614 @@ +import { Actor } from '@nestri/core/actor'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { Examples } from '@nestri/core/examples'; +import { Depot } from '@nestri/core/game/depot'; +import { GameDownload } from '@nestri/core/game/download'; +import { GameDownloadStatus } from '@nestri/core/game/download.sql'; +import { Game } from '@nestri/core/game/index'; +import { Identifier } from '@nestri/core/id'; +import { Library } from '@nestri/core/user/library'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; +import { z } from 'zod'; + +import { ErrorResponses, adminOnly, machineOrAdmin, notPublic, Result, validator } from '../utils'; + +const SyncGameSchema = z.object({ + steamAppId: z.number().int(), + name: z.string(), + type: z.string().optional(), + clientIcon: z.string().optional(), + icon: z.string().optional(), + shortDescription: z.string().optional(), + description: z.string().optional(), + developers: z.array(z.string()).optional(), + publishers: z.array(z.string()).optional(), + primaryGenre: z.string().optional(), + genres: z.array(z.string()).optional(), + categories: z.array(z.string()).optional(), + oslist: z.array(z.string()).optional(), + sizeDownload: z.number().optional(), + sizeOnDisk: z.number().optional(), + controllerSupport: z.string().optional(), + steamDeckCompat: z.string().optional(), + reviewScorePercent: z.number().int().optional(), + reviewCount: z.number().int().optional(), + metacriticScore: z.number().int().optional(), + steamChangeNumber: z.number().int().optional(), + publicBuildId: z.number().int().optional(), + releaseDate: z.string().optional(), + enriched: z.boolean().default(false), + depots: z + .array( + z.object({ + depotId: z.number().int(), + branch: z.string().default('public'), + steamManifestId: z.string().optional(), + steamBuildId: z.number().int().optional(), + sizeDownload: z.number().optional(), + sizeOnDisk: z.number().optional(), + oslist: z.string().optional() + }) + ) + .optional() +}); + +const SyncLibrarySchema = z.object({ + steamAppId: z.number().int(), + playtimeForeverMin: z.number().int().optional(), + playtime2WeeksMin: z.number().int().optional(), + lastPlayed: z.string().optional() +}); + +export namespace GameApi { + export const route = new Hono() + .use(notPublic) + .get( + '/', + describeRoute({ + tags: ['Games'], + summary: 'List games', + description: 'List all games in the catalog, with optional search', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.array(Game.Info).meta({ + description: 'All games matching the optional query', + example: [Examples.Game] + }) + ) + } + }, + description: 'List of games' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401] + } + }), + validator( + 'query', + z.object({ + q: z.string().optional().meta({ + description: 'Search query to filter games by name', + example: 'Counter-Strike' + }) + }) + ), + async (c) => { + const { q } = c.req.valid('query'); + const games = await Game.searchByName(q ?? ''); + return c.json({ data: games }); + } + ) + .get( + '/:id', + describeRoute({ + tags: ['Games'], + summary: 'Get a game by ID', + description: 'Retrieve a single game from the catalog', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + Game.Info.meta({ + description: 'The game', + example: Examples.Game + }) + ) + } + }, + description: 'The game' + }, + 401: ErrorResponses[401], + 404: ErrorResponses[404] + } + }), + validator( + 'param', + z.object({ + id: z.string().meta({ + description: 'ID of the game', + example: Examples.Game.id + }) + }) + ), + async (c) => { + const { id } = c.req.valid('param'); + const game = await Game.fromID(id); + if (!game) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + `Game ${id} not found` + ); + } + return c.json({ data: game }); + } + ) + .post( + '/sync', + adminOnly, + describeRoute({ + tags: ['Games'], + summary: 'Batch sync games, library entries, and depots', + description: + 'Bulk upsert games, library entries, and depot info from Steam sync. Admin only.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + gamesSynced: z.number(), + libraryEntries: z.number(), + depotEntries: z.number(), + failedEntries: z.array(z.number()) + }) + ) + } + }, + description: 'Sync result' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + validator( + 'json', + z.object({ + userId: z.string(), + games: z.array(SyncGameSchema).default([]), + library: z.array(SyncLibrarySchema).default([]) + }) + ), + async (c) => { + const { userId, games, library } = c.req.valid('json'); + + const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId)); + const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g])); + const gameIdByAppId = new Map(); + + const failedSteamIDs = new Set(); + const gamePromises = []; + + // 1. Queue Games + for (const g of games) { + const existing = existingByAppId.get(g.steamAppId); + const gameId = existing?.id ?? Identifier.ascending('game'); + + gameIdByAppId.set(g.steamAppId, gameId); + + const slug = + g.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || `app-${g.steamAppId}`; + const now = new Date().toISOString(); + const { enriched } = g; + + gamePromises.push( + Game.upsert({ + id: gameId, + steamAppId: g.steamAppId, + slug, + name: g.name, + type: g.type ?? null, + clientIcon: g.clientIcon ?? null, + icon: g.icon ?? null, + shortDescription: g.shortDescription ?? null, + description: g.description ?? null, + developers: g.developers ?? null, + publishers: g.publishers ?? null, + primaryGenre: g.primaryGenre ?? null, + genres: g.genres ?? null, + categories: g.categories ?? null, + oslist: g.oslist ?? null, + sizeDownload: g.sizeDownload ?? null, + sizeOnDisk: g.sizeOnDisk ?? null, + controllerSupport: g.controllerSupport ?? null, + steamDeckCompat: g.steamDeckCompat ?? null, + reviewScorePercent: g.reviewScorePercent ?? null, + reviewCount: g.reviewCount ?? null, + metacriticScore: g.metacriticScore ?? null, + steamChangeNumber: g.steamChangeNumber ?? null, + publicBuildId: g.publicBuildId ?? null, + releaseDate: g.releaseDate ?? null, + timeEnriched: enriched ? now : (existing?.timeEnriched?.toISOString() ?? null) + }) + ); + } + + const gameResults = await Promise.allSettled(gamePromises); + let gamesSynced = 0; + + const depotPromises = []; + const depotSteamIds = []; + const libraryPromises = []; + const librarySteamIds = []; + + // 2. Evaluate Games & Queue Dependents + for (let i = 0; i < gameResults.length; i++) { + const g = games[i]; + + if (gameResults[i].status === 'rejected') { + failedSteamIDs.add(g.steamAppId); + // Drop it from the map so the Library loop below ignores it + gameIdByAppId.delete(g.steamAppId); + continue; + } + + gamesSynced++; + + if (g.depots) { + const gameId = gameIdByAppId.get(g.steamAppId)!; + for (const d of g.depots) { + const depotId = Identifier.ascending('gameDepot'); + depotPromises.push( + Depot.upsert({ + id: depotId, + gameId: gameId, + depotId: d.depotId, + branch: d.branch, + steamManifestId: d.steamManifestId ?? null, + steamBuildId: d.steamBuildId ?? null, + sizeDownload: d.sizeDownload ?? null, + sizeOnDisk: d.sizeOnDisk ?? null, + oslist: d.oslist ?? null, + status: 'pending' as const + }) + ); + depotSteamIds.push(g.steamAppId); + } + } + } + + for (const l of library) { + // This naturally filters out entries for games that failed in step 2 + const gameId = gameIdByAppId.get(l.steamAppId); + if (!gameId) continue; + + const entryId = Identifier.ascending('userLibrary'); + libraryPromises.push( + Library.upsert({ + id: entryId, + userId, + gameId, + playtime2w: l.playtime2WeeksMin ?? null, + playtimeForever: l.playtimeForeverMin ?? null, + lastPlayed: l.lastPlayed ?? null + }) + ); + librarySteamIds.push(l.steamAppId); + } + + // 3. Execute Dependents in parallel + const [depotResults, libraryResults] = await Promise.all([ + Promise.allSettled(depotPromises), + Promise.allSettled(libraryPromises) + ]); + + let depotEntries = 0; + for (let i = 0; i < depotResults.length; i++) { + if (depotResults[i].status === 'rejected') { + failedSteamIDs.add(depotSteamIds[i]); + } else { + depotEntries++; + } + } + + let libraryEntries = 0; + for (let i = 0; i < libraryResults.length; i++) { + if (libraryResults[i].status === 'rejected') { + failedSteamIDs.add(librarySteamIds[i]); + } else { + libraryEntries++; + } + } + + return c.json({ + data: { + gamesSynced, + libraryEntries, + depotEntries, + failedEntries: Array.from(failedSteamIDs) + } + }); + } + ) + .get( + '/:id/download-state', + describeRoute({ + tags: ['Games'], + summary: 'Get download states for a game', + description: + 'Returns the per-host download states for a game. Optionally filter by hostId. Protected read route for initial/fallback data; SSH-connected clients use the live SSH snapshot.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.array(GameDownload.Info).meta({ + description: 'Download states for the game', + example: [Examples.GameDownload] + }) + ) + } + }, + description: 'Download states' + }, + 401: ErrorResponses[401], + 404: ErrorResponses[404] + } + }), + validator( + 'param', + z.object({ + id: z.string().meta({ + description: 'ID of the game', + example: Examples.Game.id + }) + }) + ), + validator( + 'query', + z.object({ + hostId: z.string().optional().meta({ + description: 'Optional host ID to filter by', + example: Examples.GameDownload.hostId + }) + }) + ), + async (c) => { + const { id } = c.req.valid('param'); + const { hostId } = c.req.valid('query'); + + const game = await Game.fromID(id); + if (!game) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + `Game ${id} not found` + ); + } + + const rows = hostId + ? await GameDownload.findByHostAndGame({ hostId, gameId: id }).then((row) => + row ? [row] : [] + ) + : await GameDownload.listByGame(id); + const data = rows.map((row) => GameDownload.serialize(row)); + return c.json({ data }); + } + ) + .post( + '/download-state', + machineOrAdmin, + describeRoute({ + tags: ['Games'], + summary: 'Report a download state change', + description: + 'Update the shared per-host download state for a game. Called by nessh on terminal events (start/verifying/complete/fail). A registered host reports as itself and cannot name another; admin must supply the hostId explicitly.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + downloadId: z.string(), + download: GameDownload.Info + }) + ) + } + }, + description: 'Download state updated' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 403: ErrorResponses[403], + 404: ErrorResponses[404] + } + }), + validator( + 'json', + z.object({ + hostId: z.string().optional().meta({ + description: + 'The nessh host reporting the download. Required for admin callers; ignored for machines, which report as themselves.', + example: Examples.GameDownload.hostId + }), + steamAppId: z.number().int().meta({ + description: 'Steam application ID', + example: Examples.Game.steamAppId + }), + status: z.enum(GameDownloadStatus.enumValues).meta({ + description: 'New download status', + example: Examples.GameDownload.status + }), + progressBytes: z.number().int().optional().meta({ + description: 'Bytes downloaded so far', + example: Examples.GameDownload.progressBytes + }), + totalBytes: z.number().int().optional().meta({ + description: 'Total bytes to download', + example: Examples.GameDownload.totalBytes + }), + errorMessage: z.string().nullable().optional().meta({ + description: 'Error message if status is failed', + example: null + }) + }) + ), + async (c) => { + const { hostId, steamAppId, status, progressBytes, totalBytes, errorMessage } = + c.req.valid('json'); + + // A machine reports as itself. Taking the id from the body would + // mean any holder of a shared secret could write download state + // under any box's id, which is the whole reason boxes register. + const actor = Actor.use(); + let reportingHostId: string; + if (actor.type === 'machine') { + if (hostId && hostId !== actor.properties.machineID) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.FORBIDDEN, + 'A machine may only report its own download state' + ); + } + reportingHostId = actor.properties.machineID; + } else { + if (!hostId) { + throw new VisibleError( + 'validation', + ErrorCodes.Validation.MISSING_REQUIRED_FIELD, + 'hostId is required when reporting on behalf of a host', + 'hostId' + ); + } + reportingHostId = hostId; + } + + const game = await Game.fromSteamAppID(steamAppId); + if (!game) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + `Game with steamAppId ${steamAppId} not found` + ); + } + + const row = await GameDownload.upsertState({ + hostId: reportingHostId, + gameId: game.id, + status, + progressBytes: progressBytes ?? undefined, + totalBytes: totalBytes ?? undefined, + errorMessage: errorMessage ?? undefined + }); + + return c.json({ + data: { downloadId: row.id, download: GameDownload.serialize(row) } + }); + } + ) + .post( + '/', + adminOnly, + describeRoute({ + tags: ['Games'], + summary: 'Create or update a game', + description: 'Upsert a game by Steam app ID. Admin only.', + responses: { + 201: { + content: { + 'application/json': { + schema: Result( + Game.Info.meta({ + description: 'The created or updated game', + example: Examples.Game + }) + ) + } + }, + description: 'Game created or updated' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + validator( + 'json', + z.object({ + steamAppId: z.number().int().meta({ + description: 'Steam application ID', + example: Examples.Game.steamAppId + }), + name: z.string().meta({ + description: 'Game title', + example: Examples.Game.name + }), + slug: z.string().optional().meta({ + description: 'URL-friendly slug', + example: Examples.Game.slug + }), + type: z.string().nullable().optional().meta({ + description: 'Content type', + example: Examples.Game.type + }), + clientIcon: z.string().nullable().optional().meta({ + description: 'Steam client icon hash (256×256 square)', + example: Examples.Game.clientIcon + }), + icon: z.string().nullable().optional().meta({ + description: 'Steam icon hash (32×32)', + example: Examples.Game.icon + }), + shortDescription: z.string().nullable().optional().meta({ + description: 'Short description', + example: Examples.Game.shortDescription + }), + description: z.string().nullable().optional().meta({ + description: 'Full description', + example: Examples.Game.description + }), + developers: z.array(z.string()).nullable().optional().meta({ + description: 'Game developers', + example: Examples.Game.developers + }), + publishers: z.array(z.string()).nullable().optional().meta({ + description: 'Game publishers', + example: Examples.Game.publishers + }), + genres: z.array(z.string()).nullable().optional().meta({ + description: 'Game genres', + example: Examples.Game.genres + }), + oslist: z.array(z.string()).nullable().optional().meta({ + description: 'Supported OS list', + example: Examples.Game.oslist + }), + releaseDate: z.string().nullable().optional().meta({ + description: 'Release date ISO string', + example: Examples.Game.releaseDate + }) + }) + ), + async (c) => { + const body = c.req.valid('json'); + const id = Identifier.ascending('game'); + const slug = + body.slug ?? + body.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, ''); + const game = await Game.upsert({ ...body, id, slug }); + return c.json({ data: game[0] }, 201); + } + ); +} diff --git a/apps/api/app/routes/index.ts b/apps/api/app/routes/index.ts new file mode 100644 index 00000000..45430b40 --- /dev/null +++ b/apps/api/app/routes/index.ts @@ -0,0 +1,19 @@ +import { Database } from '@nestri/core/db/index'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { Hono } from 'hono'; + +export namespace IndexApi { + export const route = new Hono() + .get('/', (c) => c.text('Hello World!')) + .get('/health', async (c) => { + const ok = await Database.ping(); + if (!ok) { + throw new VisibleError( + 'internal', + ErrorCodes.Server.DEPENDENCY_FAILURE, + 'Database connection failed' + ); + } + return c.json({ status: 'ok' }); + }); +} diff --git a/apps/api/app/routes/library.ts b/apps/api/app/routes/library.ts new file mode 100644 index 00000000..0ac1c9c7 --- /dev/null +++ b/apps/api/app/routes/library.ts @@ -0,0 +1,211 @@ +import { Actor } from '@nestri/core/actor'; +import { Examples } from '@nestri/core/examples'; +import { GameDownload } from '@nestri/core/game/download'; +import { Game } from '@nestri/core/game/index'; +import { Identifier } from '@nestri/core/id'; +import { Library } from '@nestri/core/user/library'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; +import { z } from 'zod'; + +import { ErrorResponses, adminOnly, notPublic, Result, validator } from '../utils'; + +export namespace LibraryApi { + export const route = new Hono() + .use(notPublic) + .get( + '/', + describeRoute({ + tags: ['Library'], + summary: "List the user's Steam library", + description: + "Returns all games in the authenticated user's library with playtime info and shared per-host download states.", + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z + .array( + z.object({ + id: Library.Info.shape.id, + game: Game.Info, + playtime2w: Library.Info.shape.playtime2w, + playtimeForever: Library.Info.shape.playtimeForever, + lastPlayed: Library.Info.shape.lastPlayed, + download: GameDownload.Info.nullable() + }) + ) + .meta({ + description: 'Library entries with game data', + example: [Examples.Library] + }) + ) + } + }, + description: 'Library entries' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401] + } + }), + async (c) => { + const data = await Library.listByUserWithGames(Actor.userID); + return c.json({ data }); + } + ) + .post( + '/sync', + adminOnly, + describeRoute({ + tags: ['Library'], + summary: "Sync a user's Steam library", + description: + 'Batch upsert games and library entries for a user from Steam owned games data. Admin only.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + gamesSynced: z.number(), + libraryEntries: z.number(), + failedEntries: z.array(z.number()) + }) + ) + } + }, + description: 'Sync result' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + validator( + 'json', + z.object({ + userId: z.string().meta({ + description: 'The user to sync library for', + example: Examples.User.id + }), + games: z + .array( + z.object({ + steamAppId: z.number().int().meta({ + description: 'Steam application ID', + example: Examples.Game.steamAppId + }), + name: z.string().meta({ + description: 'Game title', + example: Examples.Game.name + }), + playtimeForever: z.number().int().optional().meta({ + description: 'Total playtime in minutes', + example: Examples.Library.playtimeForever + }), + playtime2w: z.number().int().optional().meta({ + description: 'Playtime in last 2 weeks in minutes', + example: Examples.Library.playtime2w + }), + rtimeLastPlayed: z.number().int().optional().meta({ + description: 'Last played unix timestamp', + example: 1_700_000_000 + }) + }) + ) + .meta({ + description: 'Games to sync', + example: [Examples.Game] + }) + }) + ), + async (c) => { + const { userId, games } = c.req.valid('json'); + + const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId)); + const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g])); + + const failedSteamIDs = new Set(); + const gamePromises = []; + const gameIds = []; // Storing generated IDs to use in the next step + + // 1. Queue Games + for (const g of games) { + const existing = existingByAppId.get(g.steamAppId); + const gameId = existing?.id ?? Identifier.ascending('game'); + gameIds.push(gameId); // Aligns with games array index + + const slug = + g.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') || `app-${g.steamAppId}`; + + gamePromises.push( + Game.upsert({ + id: gameId, + steamAppId: g.steamAppId, + slug, + name: g.name + }) + ); + } + + const gameResults = await Promise.allSettled(gamePromises); + let gamesSynced = 0; + + const libraryPromises = []; + const librarySteamIds = []; // To track which promise belongs to which app + + // 2. Evaluate Games & Queue Libraries for Successes + for (let i = 0; i < gameResults.length; i++) { + const g = games[i]; + + if (gameResults[i].status === 'rejected') { + failedSteamIDs.add(g.steamAppId); + continue; // Skip queuing library upsert if the game failed + } + + gamesSynced++; + + const entryId = Identifier.ascending('userLibrary'); + const lastPlayed = g.rtimeLastPlayed + ? new Date(g.rtimeLastPlayed * 1000).toISOString() + : null; + + libraryPromises.push( + Library.upsert({ + id: entryId, + userId, + gameId: gameIds[i], + playtime2w: g.playtime2w ?? null, + playtimeForever: g.playtimeForever ?? null, + lastPlayed + }) + ); + librarySteamIds.push(g.steamAppId); + } + + // 3. Execute Libraries + const libraryResults = await Promise.allSettled(libraryPromises); + let libraryEntries = 0; + + for (let i = 0; i < libraryResults.length; i++) { + if (libraryResults[i].status === 'rejected') { + failedSteamIDs.add(librarySteamIds[i]); + } else { + libraryEntries++; + } + } + + return c.json({ + data: { + gamesSynced, + libraryEntries, + failedEntries: Array.from(failedSteamIDs) + } + }); + } + ); +} diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts new file mode 100644 index 00000000..6dd9153b --- /dev/null +++ b/apps/api/app/routes/machine.ts @@ -0,0 +1,231 @@ +import { Actor } from '@nestri/core/actor'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { Examples } from '@nestri/core/examples'; +import { Identifier } from '@nestri/core/id'; +import { Machine } from '@nestri/core/machine/index'; +import { Member } from '@nestri/core/team/member'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; +import { z } from 'zod'; + +import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils'; + +/** + * Host registration. + * + * A box does not get to say who it is. It registers once against its owner's + * session, is handed an id and a secret, and authenticates as itself from then + * on — so `hostId` on a download report is something the API assigned rather + * than a free-form string any holder of a shared secret could invent. + */ +export namespace MachineApi { + export const route = new Hono() + .post( + '/register', + notPublic, + describeRoute({ + tags: ['Machine'], + summary: 'Register a nessh host', + description: + 'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + machineId: z.string().meta({ example: Examples.Machine.id }), + secret: z.string().meta({ + description: 'Shown once. Store it on the box; it cannot be retrieved.' + }) + }) + ) + } + }, + description: 'The box is registered' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + validator( + 'json', + z.object({ + label: z.string().min(1).max(64).meta({ + description: 'Human-readable name for the box', + example: Examples.Machine.label + }), + teamId: z.string().optional().meta({ + description: 'Register the box into a team rather than to the user alone' + }) + }) + ), + async (c) => { + const { label, teamId } = c.req.valid('json'); + + // `notPublic` also admits admin, which has no user to own the box. + // Registering is an act of ownership, so it needs a real one. + const actor = Actor.use(); + if (actor.type !== 'user' && actor.type !== 'member') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Registering a machine requires a user session' + ); + } + + const registered = await Machine.register({ + id: Identifier.ascending('machine'), + ownerUserId: Actor.userID, + teamId: teamId ?? (actor.type === 'member' ? actor.properties.teamID : null), + label + }); + + return c.json({ data: { machineId: registered.id, secret: registered.secret } }); + } + ) + .patch( + '/:id', + notPublic, + describeRoute({ + tags: ['Machine'], + summary: 'Move a box into a team, or out of one', + description: + 'Scope a machine you own to a team you belong to, or pass teamId: null to make it yours alone again. This is not ownership transfer — the owner does not change.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Machine.Info) } }, + description: 'The machine, rescoped' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403], + 404: ErrorResponses[404] + } + }), + validator( + 'json', + z.object({ + teamId: z.string().nullable().meta({ + description: 'Team to scope the box to, or null to scope it to you alone' + }) + }) + ), + async (c) => { + const { teamId } = c.req.valid('json'); + + const actor = Actor.use(); + if (actor.type !== 'user' && actor.type !== 'member') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Rescoping a machine requires a user session' + ); + } + + // Verified before the write. `setTeam` scopes to the owner but + // knows nothing about who belongs to the target team, so this is + // the only place that check exists. + if (teamId) { + const membership = await Member.findByTeamAndUser({ + teamId, + userId: Actor.userID + }); + if (!membership) { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.FORBIDDEN, + 'You are not a member of that team' + ); + } + } + + const machine = await Machine.setTeam({ + id: c.req.param('id'), + ownerUserId: Actor.userID, + teamId + }); + if (!machine) { + // Owner-scoped in the query, so someone else's machine is a + // 404 rather than a 403 — no way to probe for ids. + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such machine, or it is not yours' + ); + } + return c.json({ data: machine }); + } + ) + .get( + '/entitlement', + machineOnly, + describeRoute({ + tags: ['Machine'], + summary: 'Ask whether a user may use this box', + description: + 'Answers for the calling machine only — the machine is taken from its credentials, never from the query, so a box cannot ask about another. Membership is read live, so removing someone from a team removes their access.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Machine.Entitlement) } }, + description: 'Whether the user may use this machine, and why' + }, + 403: ErrorResponses[403] + } + }), + validator('query', z.object({ userId: z.string().min(1) })), + async (c) => { + const { userId } = c.req.valid('query'); + return c.json({ + data: await Machine.entitlement({ machineId: Actor.machineID, userId }) + }); + } + ) + .get( + '/me', + machineOnly, + describeRoute({ + tags: ['Machine'], + summary: 'Describe the calling machine', + description: + 'Returns the registration record for the credentials used. A box calls this at startup to confirm its credentials still work before relying on them.', + responses: { + 200: { + content: { 'application/json': { schema: Result(Machine.Info) } }, + description: 'The calling machine' + }, + 403: ErrorResponses[403], + 404: ErrorResponses[404] + } + }), + async (c) => { + const machine = await Machine.fromID(Actor.machineID); + if (!machine) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'This machine no longer exists' + ); + } + return c.json({ data: machine }); + } + ) + .get( + '/', + notPublic, + describeRoute({ + tags: ['Machine'], + summary: 'List your registered hosts', + responses: { + 200: { + content: { 'application/json': { schema: Result(z.array(Machine.Info)) } }, + description: 'Machines owned by the caller' + }, + 401: ErrorResponses[401], + 403: ErrorResponses[403] + } + }), + async (c) => { + return c.json({ data: await Machine.listByOwner(Actor.userID) }); + } + ); +} diff --git a/apps/api/app/routes/pairing-code.ts b/apps/api/app/routes/pairing-code.ts new file mode 100644 index 00000000..ccee0a21 --- /dev/null +++ b/apps/api/app/routes/pairing-code.ts @@ -0,0 +1,148 @@ +import { Actor } from '@nestri/core/actor'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { Examples } from '@nestri/core/examples'; +import { Identifier } from '@nestri/core/id'; +import { PairingCode } from '@nestri/core/pairing-code/index'; +import { Fingerprint } from '@nestri/core/user/fingerprint'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; +import { z } from 'zod'; + +import { adminOnly, ErrorResponses, notPublic, Result, validator } from '../utils'; + +/** + * Device enrolment. + * + * A pairing code says "this SSH key is also me". It is deliberately not the + * same thing as an invite, which says "you may use my box" — same shape of + * secret, completely different authority, and merging them would let one be + * redeemed for the other. + * + * Generating requires an authenticated session; claiming is done by nessh on + * behalf of a device that has no identity yet, so it authenticates with the + * shared admin token instead. + */ +export namespace PairingCodeApi { + export const route = new Hono() + .post( + '/', + notPublic, + describeRoute({ + tags: ['PairingCode'], + summary: 'Generate a pairing code', + description: + 'Create a short-lived, single-use code that enrols another SSH key onto the current user.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + code: z.string().meta({ example: Examples.PairingCode.code }), + expiresInMinutes: z.number() + }) + ) + } + }, + description: 'A freshly generated pairing code' + }, + 401: ErrorResponses[401], + 429: ErrorResponses[429] + } + }), + validator( + 'json', + z.object({ + ttlMinutes: z.number().int().min(1).max(60).default(10).meta({ + description: 'How long the code stays valid. Short by design.' + }) + }) + ), + async (c) => { + const { ttlMinutes } = c.req.valid('json'); + const code = await PairingCode.create({ + id: Identifier.ascending('pairingCode'), + targetUserId: Actor.userID, + ttlMinutes + }); + + return c.json({ data: { code, expiresInMinutes: ttlMinutes } }); + } + ) + .post( + '/claim', + adminOnly, + describeRoute({ + tags: ['PairingCode'], + summary: 'Claim a pairing code for an SSH key', + description: + 'Redeem a code and bind the supplied SSH fingerprint to the user who generated it. Admin only: the calling device has no identity yet, which is the entire point.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + userId: z.string().meta({ example: Examples.PairingCode.targetUserId }) + }) + ) + } + }, + description: 'The fingerprint now belongs to this user' + }, + 400: ErrorResponses[400], + 403: ErrorResponses[403], + 404: ErrorResponses[404] + } + }), + validator( + 'json', + z.object({ + code: z.string().min(1).meta({ example: Examples.PairingCode.code }), + fingerprint: z.string().min(1).meta({ + description: 'SSH public key fingerprint of the device being enrolled' + }) + }) + ), + async (c) => { + const { code, fingerprint } = c.req.valid('json'); + + // Refuse before claiming: a code is single-use, so burning one on + // a device that cannot be enrolled would strand the user. + const existing = await Fingerprint.findByFingerprint(fingerprint); + + const claimed = await PairingCode.claim({ code, fingerprint }); + if (!claimed) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'That pairing code is unknown, already used, or expired' + ); + } + + if (existing && existing.userId !== claimed.targetUserId) { + // Handing a device between accounts is a different operation + // with its own consequences for anything already linked to it; + // `Steam.resolveSshIdentity` refuses the same case. + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.FORBIDDEN, + 'That SSH key is already enrolled to another user' + ); + } + + if (existing) { + await Fingerprint.touchLastSeen(existing.id); + } else { + await Fingerprint.create({ + id: Identifier.ascending('userFingerprint'), + userId: claimed.targetUserId, + fingerprint, + name: null + }); + } + + return c.json({ data: { userId: claimed.targetUserId } }); + } + ); +} diff --git a/apps/api/app/routes/steam.ts b/apps/api/app/routes/steam.ts new file mode 100644 index 00000000..b68d6ee6 --- /dev/null +++ b/apps/api/app/routes/steam.ts @@ -0,0 +1,86 @@ +import { Actor } from '@nestri/core/actor'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { Examples } from '@nestri/core/examples'; +import { Steam } from '@nestri/core/steam/index'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; +import { z } from 'zod'; + +import { ErrorResponses, notPublic, Result, validator } from '../utils'; + +export namespace SteamApi { + export const route = new Hono().use(notPublic).post( + '/link', + describeRoute({ + tags: ['Steam'], + summary: 'Link a Steam account', + description: 'Link a Steam account to a user (admin) or yourself (user)', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.object({ + linkedAccountId: z.string().meta({ + description: 'The ID of the linked account', + example: Examples.LinkedAccount.id + }), + steamId: z.string().meta({ + description: 'The Steam ID that was linked', + example: '76561197960287930' + }) + }) + ) + } + }, + description: 'Steam account linked' + }, + 400: ErrorResponses[400], + 401: ErrorResponses[401], + 403: ErrorResponses[403], + 429: ErrorResponses[429] + } + }), + validator( + 'json', + z.object({ + steamId: z.string().min(1).meta({ + description: 'Steam ID to link', + example: '76561197960287930' + }), + userId: z.string().optional().meta({ + description: 'User ID to link to (admin only; omitted when linking your own account)', + example: 'usr_XXXXXXXXXXXXXXXXXXXXXXXXX' + }), + profile: z + .record(z.string(), z.unknown()) + .optional() + .meta({ + description: 'Steam profile data', + example: { personaname: 'Player', avatarfull: 'https://...' } + }) + }) + ), + async (c) => { + const body = c.req.valid('json'); + const actor = Actor.use(); + + if (body.userId && actor.type !== 'admin') { + throw new VisibleError( + 'forbidden', + ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS, + 'Only admin can link a Steam account for another user' + ); + } + + const linkedAccountID = await Steam.link({ + steamId: body.steamId, + profile: body.profile, + userId: body.userId + }); + return c.json({ + data: { linkedAccountId: linkedAccountID, steamId: body.steamId } + }); + } + ); +} diff --git a/apps/api/app/routes/user.ts b/apps/api/app/routes/user.ts new file mode 100644 index 00000000..675d1a4e --- /dev/null +++ b/apps/api/app/routes/user.ts @@ -0,0 +1,104 @@ +import { Actor } from '@nestri/core/actor'; +import { ErrorCodes, VisibleError } from '@nestri/core/error'; +import { Examples } from '@nestri/core/examples'; +import { User } from '@nestri/core/user/index'; +import { Hono } from 'hono'; +import { describeRoute } from 'hono-openapi'; +import { z } from 'zod'; + +import { ErrorResponses, notPublic, Result, validator } from '../utils'; + +export namespace UserApi { + export const route = new Hono() + .use(notPublic) + .get( + '/', + describeRoute({ + tags: ['User'], + summary: 'Get current user', + description: "Get the authenticated user's profile", + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + User.Info.meta({ + description: 'Current user profile', + example: Examples.User + }) + ) + } + }, + description: 'Current user' + }, + 400: ErrorResponses[400], + 404: ErrorResponses[404], + 429: ErrorResponses[429] + } + }), + async (c) => { + const user = await User.fromID(Actor.userID); + + if (!user) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'Authenticated user not found' + ); + } + + return c.json({ data: user }); + } + ) + .get( + '/:id', + describeRoute({ + tags: ['User'], + summary: 'Get user', + description: 'Get a user by their ID', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + User.Info.meta({ + description: 'User details', + example: Examples.User + }) + ) + } + }, + description: 'User details' + }, + 400: ErrorResponses[400], + 429: ErrorResponses[429] + } + }), + validator( + 'param', + z.object({ + id: z.string().meta({ + description: 'ID of the user to get', + example: Examples.User.id + }) + }) + ), + async (c) => { + const userID = c.req.valid('param').id; + + const user = await User.fromID(userID); + + if (!user) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + `User ${userID} does not exist` + ); + } + + return c.json({ + data: user + }); + } + ); +} diff --git a/apps/api/app/utils/auth.ts b/apps/api/app/utils/auth.ts new file mode 100644 index 00000000..5bc3152d --- /dev/null +++ b/apps/api/app/utils/auth.ts @@ -0,0 +1 @@ +export { auth, notPublic, adminOnly, machineOnly, machineOrAdmin } from '../middleware/auth.js'; diff --git a/apps/api/app/utils/error.ts b/apps/api/app/utils/error.ts new file mode 100644 index 00000000..00d2443d --- /dev/null +++ b/apps/api/app/utils/error.ts @@ -0,0 +1,127 @@ +import { ErrorResponse } from '@nestri/core/error'; +import { resolver } from 'hono-openapi/zod'; + +export const ErrorResponses = { + 400: { + content: { + 'application/json': { + schema: resolver( + ErrorResponse.meta({ + description: 'Validation error', + example: { + type: 'validation', + code: 'invalid_parameter', + message: 'The request was invalid', + param: 'email' + } + }) + ) + } + }, + description: + 'Bad Request - The request could not be understood or was missing required parameters.' + }, + 401: { + content: { + 'application/json': { + schema: resolver( + ErrorResponse.meta({ + description: 'Authentication error', + example: { + type: 'authentication', + code: 'unauthorized', + message: 'Authentication required' + } + }) + ) + } + }, + description: + 'Unauthorized - Authentication is required and has failed or has not been provided.' + }, + 403: { + content: { + 'application/json': { + schema: resolver( + ErrorResponse.meta({ + description: 'Permission error', + example: { + type: 'forbidden', + code: 'permission_denied', + message: 'You do not have permission to access this resource' + } + }) + ) + } + }, + description: 'Forbidden - You do not have permission to access this resource.' + }, + 404: { + content: { + 'application/json': { + schema: resolver( + ErrorResponse.meta({ + description: 'Not found error', + example: { + type: 'not_found', + code: 'resource_not_found', + message: 'The requested resource could not be found' + } + }) + ) + } + }, + description: 'Not Found - The requested resource does not exist.' + }, + 409: { + content: { + 'application/json': { + schema: resolver( + ErrorResponse.meta({ + description: 'Conflict Error', + example: { + type: 'already_exists', + code: 'resource_already_exists', + message: 'The resource could not be created because it already exists' + } + }) + ) + } + }, + description: 'Conflict - The resource could not be created because it already exists.' + }, + 429: { + content: { + 'application/json': { + schema: resolver( + ErrorResponse.meta({ + description: 'Rate limit error', + example: { + type: 'rate_limit', + code: 'too_many_requests', + message: 'Rate limit exceeded' + } + }) + ) + } + }, + description: 'Too Many Requests - You have made too many requests in a short period of time.' + }, + 500: { + content: { + 'application/json': { + schema: resolver( + ErrorResponse.meta({ + description: 'Server error', + example: { + type: 'internal', + code: 'internal_error', + message: 'Internal server error' + } + }) + ) + } + }, + description: 'Internal Server Error - Something went wrong on our end.' + } +}; diff --git a/apps/api/app/utils/hook.ts b/apps/api/app/utils/hook.ts new file mode 100644 index 00000000..b498efb4 --- /dev/null +++ b/apps/api/app/utils/hook.ts @@ -0,0 +1,64 @@ +import type { + Env, + ValidationTargets, + Context, + TypedResponse, + Input, + MiddlewareHandler +} from 'hono'; +import { ZodError, ZodSchema, z } from 'zod'; + +type Hook< + T, + E extends Env, + P extends string, + Target extends keyof ValidationTargets = keyof ValidationTargets, + O = {} +> = ( + result: ( + | { + success: true; + data: T; + } + | { + success: false; + error: ZodError; + data: T; + } + ) & { + target: Target; + }, + c: Context +) => Response | void | TypedResponse | Promise>; +type HasUndefined = undefined extends T ? true : false; +declare const zValidator: < + T extends ZodSchema, + Target extends keyof ValidationTargets, + E extends Env, + P extends string, + In = z.input, + Out = z.output, + I extends Input = { + in: HasUndefined extends true + ? { + [K in Target]?: + | (In extends ValidationTargets[K] + ? In + : { [K2 in keyof In]?: ValidationTargets[K][K2] | undefined }) + | undefined; + } + : { + [K_1 in Target]: In extends ValidationTargets[K_1] + ? In + : { [K2_1 in keyof In]: ValidationTargets[K_1][K2_1] }; + }; + out: { [K_2 in Target]: Out }; + }, + V extends I = I +>( + target: Target, + schema: T, + hook?: Hook, E, P, Target, {}> | undefined +) => MiddlewareHandler; + +export { type Hook, zValidator }; diff --git a/apps/api/app/utils/index.ts b/apps/api/app/utils/index.ts new file mode 100644 index 00000000..8d4af21c --- /dev/null +++ b/apps/api/app/utils/index.ts @@ -0,0 +1,4 @@ +export * from './auth'; +export * from './error'; +export * from './result'; +export * from './validator'; diff --git a/apps/api/app/utils/result.ts b/apps/api/app/utils/result.ts new file mode 100644 index 00000000..3c578f88 --- /dev/null +++ b/apps/api/app/utils/result.ts @@ -0,0 +1,6 @@ +import { resolver } from 'hono-openapi/zod'; +import { z } from 'zod'; + +export function Result(schema: T) { + return resolver(z.object({ data: schema })); +} diff --git a/apps/api/app/utils/validator.ts b/apps/api/app/utils/validator.ts new file mode 100644 index 00000000..38a09a20 --- /dev/null +++ b/apps/api/app/utils/validator.ts @@ -0,0 +1,70 @@ +import { ErrorCodes } from '@nestri/core/error'; +import type { MiddlewareHandler, ValidationTargets } from 'hono'; +import { validator as zodValidator } from 'hono-openapi/zod'; +import { z, ZodSchema } from 'zod'; + +import type { Hook } from './hook'; + +type ZodIssueExtended = z.ZodIssue & { + expected?: unknown; + received?: unknown; +}; + +export const validator = ( + target: Target, + schema: T +): MiddlewareHandler< + Record, + string, + { + in: { + [K in Target]: z.input; + }; + out: { + [K in Target]: z.output; + }; + } +> => { + const standardErrorHandler: Hook, any, any, Target> = (result, c) => { + if (!result.success) { + const issues = result.error.issues || result.error.errors || []; + const firstIssue = issues[0]; + const fieldPath = Array.isArray(firstIssue?.path) + ? firstIssue.path.join('.') + : firstIssue?.path; + + let errorCode = ErrorCodes.Validation.INVALID_PARAMETER; + if (firstIssue?.code === 'invalid_type' && firstIssue?.received === 'undefined') { + errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD; + } else if ( + ['invalid_string', 'invalid_date', 'invalid_regex'].includes(firstIssue?.code as string) + ) { + errorCode = ErrorCodes.Validation.INVALID_FORMAT; + } + + const response = { + type: 'validation', + code: errorCode, + message: firstIssue?.message, + param: fieldPath, + details: + issues.length > 1 + ? { + issues: issues.map((issue: ZodIssueExtended) => ({ + path: Array.isArray(issue.path) ? issue.path.join('.') : issue.path, + code: issue.code, + message: issue.message, + expected: issue.expected, + received: issue.received + })) + } + : undefined + }; + + console.log('Validation error in validator:', response); + return c.json(response, 400); + } + }; + + return zodValidator(target, schema, standardErrorHandler); +}; diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 00000000..204cb800 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,23 @@ +{ + "name": "api", + "type": "module", + "dependencies": { + "@hono/zod-validator": "^0.9.0", + "@nestri/auth": "workspace:", + "@nestri/core": "workspace:", + "hono": "catalog:", + "hono-openapi": "^0.4.8", + "jose": "^6.2.3", + "redis": "^6.0.0", + "zod": "catalog:", + "zod-openapi": "^6.0.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:" + }, + "peerDependencies": { + "typescript": "catalog:" + } +} diff --git a/apps/api/test/routes.test.ts b/apps/api/test/routes.test.ts new file mode 100644 index 00000000..6ee38276 --- /dev/null +++ b/apps/api/test/routes.test.ts @@ -0,0 +1,542 @@ +import { describe, expect, test } from 'bun:test'; + +import { app } from '../app/index'; +import { TEST_ADMIN_SECRET } from './setup'; +import './setup'; + +function adminHeaders(): Record { + return { 'x-nestri-admin-token': TEST_ADMIN_SECRET }; +} + +describe('Index', () => { + test('GET / returns hello world', async () => { + const res = await app.request('/'); + expect(res.status).toBe(200); + expect(await res.text()).toBe('Hello World!'); + }); +}); + +describe('Auth middleware', () => { + test('public access to a protected route returns 401', async () => { + const res = await app.request('/games'); + expect(res.status).toBe(401); + const body = (await res.json()) as any; + expect(body.type).toBe('authentication'); + expect(body.code).toBe('unauthorized'); + }); + + test('admin token gains access to protected routes', async () => { + const res = await app.request('/games', { + headers: adminHeaders() + }); + expect(res.status).toBe(200); + }); + + test('wrong admin token is treated as public → 401', async () => { + const res = await app.request('/games', { + headers: { 'x-nestri-admin-token': 'wrong-secret' } + }); + expect(res.status).toBe(401); + const body = (await res.json()) as any; + expect(body.type).toBe('authentication'); + }); + + test('a bearer token that cannot be verified is unauthenticated, not a server error', async () => { + // A token nobody can verify makes the *caller* unauthenticated; it does + // not make the request a server fault. `verify` reports a malformed or + // expired token in `err`, but throws when it cannot reach the auth + // service at all, and that throw used to surface as a 500. + const res = await app.request('/games', { + headers: { authorization: 'Bearer not-a-real-token' } + }); + expect(res.status).toBe(401); + const body = (await res.json()) as any; + expect(body.type).toBe('authentication'); + }); + + test('missing authorization on admin-only route returns 401', async () => { + const res = await app.request('/games/sync', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}) + }); + // notPublic runs before adminOnly → 401 + expect(res.status).toBe(401); + const body = (await res.json()) as any; + expect(body.code).toBe('unauthorized'); + }); +}); + +describe('Validation', () => { + test('malformed JSON body returns 400', async () => { + const res = await app.request('/games/sync', { + method: 'POST', + headers: { + ...adminHeaders(), + 'content-type': 'application/json' + }, + body: '{not-json' + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.type).toBe('validation'); + }); + + test('missing required fields returns 400 with code', async () => { + const res = await app.request('/games/download-state', { + method: 'POST', + headers: { + ...adminHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({ status: 'downloading' }) + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.type).toBe('validation'); + }); + + test('invalid status enum in download-state returns 400', async () => { + const res = await app.request('/games/download-state', { + method: 'POST', + headers: { + ...adminHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({ + hostId: 'hst_test', + steamAppId: 440, + status: 'bogus_status' + }) + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.type).toBe('validation'); + }); + + test('non-existent game returns 404', async () => { + const res = await app.request('/games/gam_nonexistent', { + headers: adminHeaders() + }); + expect(res.status).toBe(404); + const body = (await res.json()) as any; + expect(body.type).toBe('not_found'); + }); + + test('missing content-type header returns 400', async () => { + const res = await app.request('/games/sync', { + method: 'POST', + headers: adminHeaders(), + body: JSON.stringify({}) + }); + expect(res.status).toBe(400); + }); +}); + +describe('Error response shape', () => { + test('404 on unknown game has standard error shape', async () => { + const res = await app.request('/games/gam_nonexistent', { + headers: adminHeaders() + }); + expect(res.status).toBe(404); + const body = (await res.json()) as any; + expect(body).toHaveProperty('type'); + expect(body).toHaveProperty('code'); + expect(body).toHaveProperty('message'); + }); + + test('429 error responses have standard shape', async () => { + const res = await app.request('/games/gam_nonexistent', { + headers: adminHeaders() + }); + expect(res.status).toBe(404); + const body = (await res.json()) as any; + expect(body.type).toBe('not_found'); + expect(body.code).toBe('resource_not_found'); + }); +}); + +describe('OpenAPI doc', () => { + test('GET /doc returns 200 with JSON', async () => { + const res = await app.request('/doc'); + expect(res.status).toBe(200); + const body = (await res.json()) as any; + expect(body).toHaveProperty('openapi'); + expect(body.info.title).toBe('Nestri API'); + }); + + test('GET /doc contains expected route paths', async () => { + const res = await app.request('/doc'); + const body = (await res.json()) as any; + const paths = Object.keys(body.paths); + expect(paths).toContain('/games'); + expect(paths).toContain('/games/sync'); + expect(paths).toContain('/games/{id}'); + expect(paths).toContain('/games/{id}/download-state'); + expect(paths).toContain('/games/download-state'); + expect(paths).toContain('/library'); + expect(paths).toContain('/library/sync'); + expect(paths).toContain('/steam/link'); + expect(paths).toContain('/user'); + }); + + test('doc has security schemes defined', async () => { + const res = await app.request('/doc'); + const body = (await res.json()) as any; + expect(body.components.securitySchemes.Bearer).toMatchObject({ + type: 'http', + scheme: 'bearer' + }); + }); +}); + +describe('CORS', () => { + test('CORS preflight returns headers', async () => { + const res = await app.request('/games', { + method: 'OPTIONS', + headers: { + origin: 'http://localhost:5173', + 'access-control-request-method': 'GET' + } + }); + expect(res.status).toBe(204); + expect(res.headers.get('access-control-allow-origin')).toBeTruthy(); + }); + + test('response includes cache-control no-store', async () => { + const res = await app.request('/'); + expect(res.headers.get('cache-control')).toBe('no-store'); + }); +}); + +describe('Download state route', () => { + test('POST /games/download-state requires hostId and steamAppId', async () => { + const res = await app.request('/games/download-state', { + method: 'POST', + headers: { + ...adminHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({ + hostId: 'hst_test', + status: 'downloading' + // missing steamAppId + }) + }); + expect(res.status).toBe(400); + }); + + test('POST /games/download-state validates status enum', async () => { + const valid = ['pending', 'verifying', 'downloading', 'ready', 'failed'] as const; + for (const status of valid) { + //eslint-disable-next-line + const res = await app.request('/games/download-state', { + method: 'POST', + headers: { + ...adminHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({ + hostId: 'hst_test', + steamAppId: 440, + status + }) + }); + // Validation should pass (200 or 404 if game not in DB) + expect(res.status).not.toBe(400); + } + }); + + test('an unauthenticated caller cannot report download state', async () => { + const res = await app.request('/games/download-state', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ hostId: 'mch_test', steamAppId: 440, status: 'ready' }) + }); + // The route group's `notPublic` runs first, so this is 401 rather than + // the 403 `machineOrAdmin` would give an authenticated non-host. + expect(res.status).toBe(401); + }); + + test('an admin caller must say which host it is reporting for', async () => { + // hostId is optional in the schema now because a machine supplies it + // from its own identity. Admin has no identity to take it from, so + // leaving it out has to fail rather than write under an empty host. + const res = await app.request('/games/download-state', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ steamAppId: 440, status: 'ready' }) + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.code).toBe('missing_required_field'); + expect(body.param).toBe('hostId'); + }); +}); + +describe('Access tokens', () => { + test('creating a token requires authentication', async () => { + const res = await app.request('/access-token', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'living-room-box' }) + }); + expect(res.status).toBe(401); + }); + + test('the admin token cannot mint a token for anyone', async () => { + // This is the boundary that makes admin safe to hand out for tooling: + // it reads and writes API data but cannot *become* a user. Minting a + // PAT on someone's behalf would erase exactly that. + const res = await app.request('/access-token', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'living-room-box' }) + }); + expect(res.status).toBe(403); + const body = (await res.json()) as any; + expect(body.message).toContain('user session'); + }); + + test('a token needs a name', async () => { + const res = await app.request('/access-token', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ name: '' }) + }); + expect(res.status).toBe(400); + }); + + test('expiry is capped at a year', async () => { + const res = await app.request('/access-token', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'box', expiresInDays: 4000 }) + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.type).toBe('validation'); + }); + + test('teamId accepts null to force a token scoped to the user alone', async () => { + // Team scope is the default and is *broader* than user scope, so there + // has to be an explicit way to ask for the narrow one. Null is it; + // omitting the field means "take the default", which is not the same. + const res = await app.request('/access-token', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'box', teamId: null }) + }); + // Admin is refused at the handler, but only after validation — so a + // 403 here proves null passed the schema rather than being rejected. + expect(res.status).toBe(403); + }); + + test('revoking someone else’s token requires authentication', async () => { + const res = await app.request('/access-token/pat_whatever', { method: 'DELETE' }); + expect(res.status).toBe(401); + }); + + test('an unknown access token is unauthenticated, not a server error', async () => { + // A `pat_` prefix routes to the database rather than JWT verification. + // A miss there must read as "not signed in", the same as a bad JWT. + const res = await app.request('/games', { + headers: { authorization: 'Bearer pat_nosuchtokenvalue' } + }); + expect(res.status).toBe(401); + const body = (await res.json()) as any; + expect(body.type).toBe('authentication'); + }); +}); + +describe('Box access', () => { + test('rescoping a machine requires authentication', async () => { + const res = await app.request('/machine/mch_whatever', { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ teamId: null }) + }); + expect(res.status).toBe(401); + }); + + test('the admin token cannot rescope a machine', async () => { + // Rescoping is an owner action and the query is scoped to a user id; + // admin has none, so it must be refused rather than 500 later. + const res = await app.request('/machine/mch_whatever', { + method: 'PATCH', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ teamId: null }) + }); + expect(res.status).toBe(403); + const body = (await res.json()) as any; + expect(body.message).toContain('user session'); + }); + + test('teamId is required on the body, and may be null', async () => { + // Null is "make it mine alone" — a different thing from omitting the + // field, which would leave the scope ambiguous. + const missing = await app.request('/machine/mch_whatever', { + method: 'PATCH', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({}) + }); + expect(missing.status).toBe(400); + + const explicitNull = await app.request('/machine/mch_whatever', { + method: 'PATCH', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ teamId: null }) + }); + // Past validation, refused at the handler for being admin. + expect(explicitNull.status).toBe(403); + }); + + test('entitlement requires machine credentials, not a user session', async () => { + // The machine is taken from its credentials, never the query, so a box + // cannot ask about another box. + const res = await app.request('/machine/entitlement?userId=usr_x', { + headers: adminHeaders() + }); + expect(res.status).toBe(403); + const body = (await res.json()) as any; + expect(body.message).toContain('Machine credentials'); + }); + + test('entitlement needs a userId to answer about', async () => { + const res = await app.request('/machine/entitlement'); + // machineOnly refuses before validation; either way it does not answer. + expect([400, 403]).toContain(res.status); + }); +}); + +describe('Machine registration', () => { + test('registering a machine requires authentication', async () => { + const res = await app.request('/machine/register', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ label: 'living-room-box' }) + }); + expect(res.status).toBe(401); + }); + + test('the admin token cannot register a machine', async () => { + // Registering is an act of ownership and the resulting row references a + // user. Admin is authenticated but owns nothing, so it must be refused + // here rather than fail later on a null owner. + const res = await app.request('/machine/register', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ label: 'living-room-box' }) + }); + expect(res.status).toBe(403); + const body = (await res.json()) as any; + expect(body.message).toContain('user session'); + }); + + test('registering a machine requires a label', async () => { + const res = await app.request('/machine/register', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ label: '' }) + }); + expect(res.status).toBe(400); + const body = (await res.json()) as any; + expect(body.type).toBe('validation'); + }); + + test('describing yourself requires machine credentials', async () => { + const res = await app.request('/machine/me', { headers: adminHeaders() }); + expect(res.status).toBe(403); + const body = (await res.json()) as any; + expect(body.message).toContain('Machine credentials'); + }); +}); + +describe('Steam routes', () => { + test('POST /steam/link requires auth', async () => { + const res = await app.request('/steam/link', { method: 'POST' }); + expect(res.status).toBe(401); + }); + + test('POST /steam/link validates steamId', async () => { + const res = await app.request('/steam/link', { + method: 'POST', + headers: { + ...adminHeaders(), + 'content-type': 'application/json' + }, + body: JSON.stringify({}) + }); + expect(res.status).toBe(400); + }); +}); + +describe('User routes', () => { + test('GET /user requires auth', async () => { + const res = await app.request('/user'); + expect(res.status).toBe(401); + }); +}); + +describe('Library routes', () => { + test('GET /library requires auth', async () => { + const res = await app.request('/library'); + expect(res.status).toBe(401); + }); +}); + +describe('Pairing code routes', () => { + test('POST /pairing-code requires auth', async () => { + // Generating a code says "this key is also me", so it can only be done + // from a session that already is that user. + const res = await app.request('/pairing-code', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}) + }); + expect(res.status).toBe(401); + }); + + test('POST /pairing-code/claim rejects an unauthenticated caller', async () => { + // Claiming is done for a device with no identity yet, so it carries the + // admin token rather than a user session. Without it, no. + const res = await app.request('/pairing-code/claim', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ code: 'NESSH-7F2Q', fingerprint: 'aa:bb' }) + }); + expect([401, 403]).toContain(res.status); + }); + + test('POST /pairing-code/claim requires both a code and a fingerprint', async () => { + for (const body of [{}, { code: 'NESSH-7F2Q' }, { fingerprint: 'aa:bb' }]) { + // eslint-disable-next-line + const res = await app.request('/pairing-code/claim', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify(body) + }); + expect(res.status).toBe(400); + } + }); + + test('POST /pairing-code/claim rejects an empty code', async () => { + // An empty string must not be treated as "any code". + const res = await app.request('/pairing-code/claim', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ code: '', fingerprint: 'aa:bb' }) + }); + expect(res.status).toBe(400); + }); + + test('POST /pairing-code caps how long a code stays valid', async () => { + // Short-lived by design; a long-lived code is a shared password. + const res = await app.request('/pairing-code', { + method: 'POST', + headers: { ...adminHeaders(), 'content-type': 'application/json' }, + body: JSON.stringify({ ttlMinutes: 60 * 24 }) + }); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts new file mode 100644 index 00000000..d2499cc0 --- /dev/null +++ b/apps/api/test/setup.ts @@ -0,0 +1,16 @@ +import { beforeEach } from 'bun:test'; + +import { Env } from '@nestri/core/env'; + +const TEST_ADMIN_SECRET = 'test-admin-secret-42'; +const TEST_FRONTEND_URL = 'http://localhost:5173'; + +beforeEach(() => { + Env.init({ + NODE_ENV: 'test', + ADMIN_SHARED_SECRET: TEST_ADMIN_SECRET, + FRONTEND_URL: TEST_FRONTEND_URL + }); +}); + +export { TEST_ADMIN_SECRET, TEST_FRONTEND_URL }; diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 00000000..38859591 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "lib": ["esnext"], + "types": ["@cloudflare/workers-types", "node", "@types/bun"], + "noEmit": true, + "skipLibCheck": true + } +} diff --git a/apps/auth/package.json b/apps/auth/package.json new file mode 100644 index 00000000..625137e3 --- /dev/null +++ b/apps/auth/package.json @@ -0,0 +1,20 @@ +{ + "name": "auth", + "type": "module", + "scripts": { + "dev": "vite" + }, + "dependencies": { + "@nestri/auth": "workspace:", + "@nestri/core": "workspace:" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@tsconfig/node22": "catalog:", + "@types/bun": "catalog:", + "@types/node": "catalog:" + }, + "peerDependencies": { + "typescript": "catalog:" + } +} diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts new file mode 100644 index 00000000..98aa6e15 --- /dev/null +++ b/apps/auth/src/index.ts @@ -0,0 +1,112 @@ +import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types'; +import { issuer } from '@nestri/auth/index'; +import { SshProvider } from '@nestri/auth/provider/ssh'; +import { SteamProvider } from '@nestri/auth/provider/steam'; +import { CloudflareStorage } from '@nestri/auth/storage/cloudflare'; +import { subjects } from '@nestri/core/auth/subjects'; +import { Database } from '@nestri/core/db/index'; +import { Env } from '@nestri/core/env'; +import { Identifier } from '@nestri/core/id'; +import { Steam } from '@nestri/core/steam/index'; +import { User } from '@nestri/core/user/index'; +import { LinkedAccount } from '@nestri/core/user/linked-account'; + +type Env = { + AuthStorage: KVNamespace; + HYPERDRIVE: Hyperdrive; + STEAM_API_KEY: string; + SSH_AUTH_KEY: string; +}; + +export default { + async fetch(request: Request, env: Env, ctx: ExecutionContext) { + Env.init(env as unknown as Record); + const inner = issuer({ + subjects, + storage: CloudflareStorage({ + namespace: env.AuthStorage + }), + providers: { + steam: SteamProvider(), + ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY }) + }, + async success(context, response) { + if (response.provider === 'steam') { + const { steamid } = response; + const profileUrl = new URL( + 'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/' + ); + profileUrl.searchParams.set('key', env.STEAM_API_KEY); + profileUrl.searchParams.set('steamids', steamid); + + const profileRes = await fetch(profileUrl.toString()); + const profileData = (await profileRes.json()) as { + response?: { players?: Array> }; + }; + + const player = profileData?.response?.players?.[0] as any; + const personaname: string = player?.personaname ?? 'Player'; + const avatarfull: string = player?.avatarfull; + + const { userID, linkedAccountID } = await Database.transaction(async () => { + const existing = await LinkedAccount.findByProvider({ + provider: 'steam', + providerAccountId: steamid + }); + + if (existing) { + const user = await User.fromID(existing.userId); + if (!user) throw new Error('User not found for linked account'); + return { userID: user.id, linkedAccountID: existing.id }; + } + + const newUserID = Identifier.ascending('user'); + await User.create({ + id: newUserID, + name: personaname, + email: undefined, + emailVerified: false, + image: avatarfull ?? null + }); + + const newLinkedAccountID = Identifier.ascending('linkedAccount'); + await LinkedAccount.create({ + id: newLinkedAccountID, + userId: newUserID, + provider: 'steam', + providerAccountId: steamid, + profile: player ?? {} + }); + + return { userID: newUserID, linkedAccountID: newLinkedAccountID }; + }); + + return context.subject('user', { + userID, + linkedAccountID + }); + } + + if (response.provider === 'ssh') { + const { fingerprint, steamId, username, profile } = response; + const { userID, linkedAccountID } = await Steam.resolveSshIdentity({ + fingerprint, + steamId, + username, + profile + }); + + return context.subject('user', { + userID, + linkedAccountID, + fingerprint + }); + } + + throw new Error('Unknown provider'); + } + }); + + return inner.fetch(request, env, ctx); + } +}; diff --git a/apps/auth/test/worker.test.ts b/apps/auth/test/worker.test.ts new file mode 100644 index 00000000..9e0de1e1 --- /dev/null +++ b/apps/auth/test/worker.test.ts @@ -0,0 +1,227 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + +import { createClient } from '@nestri/auth/client'; +import { issuer } from '@nestri/auth/index'; +import { SshProvider } from '@nestri/auth/provider/ssh'; +import { SteamProvider } from '@nestri/auth/provider/steam'; +import { MemoryStorage } from '@nestri/auth/storage/memory'; +import { subjects } from '@nestri/core/auth/subjects'; + +const storage = MemoryStorage(); + +const auth = issuer({ + subjects, + storage, + allow: async () => true, + providers: { + steam: SteamProvider(), + ssh: SshProvider({ sshAuthKey: 'test-ssh-key' }) + }, + async success(context, response) { + if (response.provider === 'steam') { + return context.subject('user', { + userID: 'usr_test123', + linkedAccountID: 'lac_test456' + }); + } + if (response.provider === 'ssh') { + return context.subject('user', { + userID: 'usr_test123', + linkedAccountID: 'lac_test456', + fingerprint: response.fingerprint + }); + } + throw new Error('unknown provider'); + } +}); + +beforeEach(() => { + globalThis.fetch = mock(async (input: string | URL | Request, _init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + + if (url.includes('steamcommunity.com/openid/login')) { + return new Response('ns:http://specs.openid.net/auth/2.0\nis_valid:true\n', { status: 200 }); + } + + if (url.includes('api.steampowered.com')) { + return new Response( + JSON.stringify({ + response: { + players: [ + { + personaname: 'TestPlayer', + avatarfull: + 'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg', + steamid: '76561197960287956' + } + ] + } + }), + { status: 200 } + ); + } + + return new Response('not found', { status: 404 }); + }) as unknown as typeof fetch; +}); + +afterEach(() => { + globalThis.fetch = fetch; +}); + +describe('Steam auth flow', () => { + test('authorize redirects to Steam OpenID', async () => { + const response = await auth.request('https://auth.internal/steam/authorize'); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toMatch(/steamcommunity\.com\/openid/); + }); + + test('full code flow and token verification', async () => { + const client = createClient({ + issuer: 'https://auth.internal', + clientID: 'api', + fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) + }); + + const { challenge, url } = await client.authorize( + 'https://client.example.com/callback', + 'code', + { pkce: true, provider: 'steam' } + ); + + // Step 1: hit the authorize URL → redirects to Steam OpenID + const authResponse = await auth.request(url); + expect(authResponse.status).toBe(302); + const cookie = authResponse.headers.get('set-cookie')!; + expect(cookie).toBeDefined(); + + // Step 2: simulate Steam redirecting back to our callback with valid OpenID params + const callbackUrl = + 'https://auth.internal/steam/callback?' + + 'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' + + 'openid.mode=id_res&' + + 'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' + + 'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' + + 'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956'; + + const callbackResponse = await auth.request(callbackUrl, { + headers: { cookie } + }); + expect(callbackResponse.status).toBe(302); + + const location = new URL(callbackResponse.headers.get('location')!); + const code = location.searchParams.get('code'); + expect(code).not.toBeNull(); + + const exchanged = await client.exchange( + code!, + 'https://client.example.com/callback', + challenge.verifier + ); + if (exchanged.err) throw exchanged.err; + const tokens = exchanged.tokens!; + + expect(tokens.access).toBeString(); + expect(tokens.refresh).toBeString(); + + const verified = await client.verify(subjects, tokens.access); + if (verified.err) throw verified.err; + expect(verified.subject).toEqual({ + type: 'user', + properties: { + userID: 'usr_test123', + linkedAccountID: 'lac_test456' + } + }); + }); +}); + +describe('SSH login', () => { + test('valid login returns tokens', async () => { + const loginResponse = await auth.request('https://auth.internal/ssh/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer test-ssh-key' + }, + body: JSON.stringify({ + fingerprint: 'SHA256:abc123', + steamId: '76561198012345678' + }) + }); + + expect(loginResponse.status).toBe(200); + const body: any = await loginResponse.json(); + expect(body.accessToken).toBeString(); + expect(body.refreshToken).toBeString(); + }); + + test('invalid auth key returns 401', async () => { + const response = await auth.request('https://auth.internal/ssh/login', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer wrong-key' + }, + body: JSON.stringify({ + fingerprint: 'SHA256:abc123', + steamId: '76561198012345678' + }) + }); + + expect(response.status).toBe(401); + }); +}); + +describe('User info', () => { + async function getTokens() { + const client = createClient({ + issuer: 'https://auth.internal', + clientID: 'api', + fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init)) + }); + + const { challenge, url } = await client.authorize( + 'https://client.example.com/callback', + 'code', + { pkce: true, provider: 'steam' } + ); + + const authResponse = await auth.request(url); + const cookie = authResponse.headers.get('set-cookie')!; + + const callbackUrl = + 'https://auth.internal/steam/callback?' + + 'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' + + 'openid.mode=id_res&' + + 'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' + + 'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' + + 'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956'; + + const callbackResponse = await auth.request(callbackUrl, { headers: { cookie } }); + const location = new URL(callbackResponse.headers.get('location')!); + const code = location.searchParams.get('code'); + const exchanged = await client.exchange( + code!, + 'https://client.example.com/callback', + challenge.verifier + ); + if (exchanged.err) throw exchanged.err; + return { client, tokens: exchanged.tokens! }; + } + + test('returns subject properties for valid access token', async () => { + const { tokens } = await getTokens(); + + const infoRes = await auth.request('https://auth.internal/userinfo', { + headers: { Authorization: `Bearer ${tokens.access}` } + }); + + expect(infoRes.status).toBe(200); + const userinfo = await infoRes.json(); + expect(userinfo).toMatchObject({ + userID: 'usr_test123', + linkedAccountID: 'lac_test456' + }); + }); +}); diff --git a/apps/auth/tsconfig.json b/apps/auth/tsconfig.json new file mode 100644 index 00000000..42a311af --- /dev/null +++ b/apps/auth/tsconfig.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node22/tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "jsxImportSource": "react", + "types": ["@cloudflare/workers-types", "node", "bun"] + } +} diff --git a/deno.json b/deno.json new file mode 100644 index 00000000..0967ef42 --- /dev/null +++ b/deno.json @@ -0,0 +1 @@ +{} diff --git a/deno.lock b/deno.lock new file mode 100644 index 00000000..954e49af --- /dev/null +++ b/deno.lock @@ -0,0 +1,3487 @@ +{ + "version": "5", + "specifiers": { + "npm:@cloudflare/workers-types@^4.20250805.0": "4.20260702.1", + "npm:@cloudflare/workers-types@^5.20260722.1": "5.20260722.1", + "npm:@fontsource-variable/geist-mono@^5.2.7": "5.3.0", + "npm:@fontsource-variable/geist@^5.2.8": "5.3.0", + "npm:@fontsource/ibm-plex-serif@^5.2.7": "5.3.0", + "npm:@fontsource/instrument-serif@^5.3.0": "5.3.0", + "npm:@hono/zod-validator@0.9": "0.9.0_hono@4.12.33_zod@4.4.3", + "npm:@standard-schema/spec@1.0.0-beta.3": "1.0.0-beta.3", + "npm:@sveltejs/adapter-cloudflare@^7.0.4": "7.2.9_@sveltejs+kit@2.70.1__@sveltejs+vite-plugin-svelte@7.2.0___svelte@5.56.7___vite@8.1.5____@types+node@26.1.1____esbuild@0.25.12____yaml@2.9.0___@types+node@26.1.1__svelte@5.56.7__typescript@7.0.2__vite@8.1.5___@types+node@26.1.1___esbuild@0.25.12___yaml@2.9.0__@types+node@26.1.1_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.7__vite@8.1.5___@types+node@26.1.1___esbuild@0.25.12___yaml@2.9.0__@types+node@26.1.1_@types+node@26.1.1_svelte@5.56.7_typescript@7.0.2_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0", + "npm:@sveltejs/kit@^2.65.0": "2.70.1_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.7__vite@8.1.5___@types+node@26.1.1___esbuild@0.25.12___yaml@2.9.0__@types+node@26.1.1_svelte@5.56.7_typescript@7.0.2_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1", + "npm:@sveltejs/vite-plugin-svelte@^7.1.2": "7.2.0_svelte@5.56.7_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1", + "npm:@tailwindcss/forms@~0.5.11": "0.5.11_tailwindcss@4.3.3", + "npm:@tailwindcss/typography@~0.5.19": "0.5.20_tailwindcss@4.3.3", + "npm:@tailwindcss/vite@^4.1.18": "4.3.3_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1", + "npm:@tsconfig/node22@^22.0.5": "22.0.5", + "npm:@types/bun@latest": "1.3.14", + "npm:@types/node@24": "26.1.1", + "npm:@types/node@^26.1.1": "26.1.1", + "npm:alchemy@~0.93.12": "0.93.12_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1_postgres@3.4.9", + "npm:arctic@2.2.2": "2.2.2", + "npm:aws4fetch@1.0.20": "1.0.20", + "npm:drizzle-kit@~0.31.10": "0.31.10", + "npm:drizzle-orm@~0.45.2": "0.45.2_@cloudflare+workers-types@5.20260722.1_kysely@0.29.4_postgres@3.4.9", + "npm:evlog@^2.20.0": "2.22.1_hono@4.12.33_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1", + "npm:globals@^17.3.0": "17.7.0", + "npm:hono-openapi@~0.4.8": "0.4.8_@hono+zod-validator@0.9.0__hono@4.12.33__zod@4.4.3_hono@4.12.33_valibot@1.0.0-beta.15__typescript@7.0.2_zod@4.4.3_zod-openapi@6.0.0__zod@4.4.3_typescript@7.0.2", + "npm:hono@^4.12.31": "4.12.33", + "npm:jose@5.9.6": "5.9.6", + "npm:jose@^6.2.3": "6.2.3", + "npm:oxfmt@0.58": "0.61.0_svelte@5.56.7", + "npm:oxfmt@0.61": "0.61.0_svelte@5.56.7", + "npm:oxlint@^1.73.0": "1.76.0", + "npm:oxlint@^1.76.0": "1.76.0", + "npm:postgres@^3.4.9": "3.4.9", + "npm:postgresql@^0.0.1": "0.0.1", + "npm:redis@6": "6.1.0", + "npm:svelte-check@^4.7.1": "4.7.3_svelte@5.56.7_typescript@7.0.2", + "npm:svelte@^5.56.4": "5.56.7", + "npm:tailwindcss@^4.1.18": "4.3.3", + "npm:typescript@^7.0.1-rc": "7.0.2", + "npm:valibot@1.0.0-beta.15": "1.0.0-beta.15_typescript@7.0.2", + "npm:vite@^8.1.1": "8.1.5_@types+node@26.1.1_esbuild@0.25.12_yaml@2.9.0", + "npm:zod-openapi@6": "6.0.0_zod@4.4.3", + "npm:zod@^4.4.3": "4.4.3" + }, + "npm": { + "@apidevtools/json-schema-ref-parser@11.9.3": { + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dependencies": [ + "@jsdevtools/ono", + "@types/json-schema", + "js-yaml" + ] + }, + "@aws-sdk/core@3.975.3": { + "integrity": "sha512-7ur3kCKuvPLqlsZ2XlvnNBVQ7KkpSu6Y6dOTwSPHLrFpTEfZM8isLBJc4cgv96WB7GifeVM436mpycwxBd2vEA==", + "dependencies": [ + "@aws-sdk/types", + "@aws-sdk/xml-builder", + "@aws/lambda-invoke-store", + "@smithy/core", + "@smithy/signature-v4", + "@smithy/types", + "bowser", + "tslib" + ] + }, + "@aws-sdk/credential-provider-cognito-identity@3.972.58": { + "integrity": "sha512-s5uoABv5eOzuH/S+XngHjHSrY8mK0UTBUFs8pm1ynBNuxXmYp176zarDyxN9lUS3Rry0wjzNvJUV09QROaO98g==", + "dependencies": [ + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-env@3.972.59": { + "integrity": "sha512-Ny5e4Mfh3QPmiAc0AiUe+cbTXDlxkU3Rc+EpWOfyWeWEy6yp7Fa1KmfNeCc+1a8by9zQ9gtohmiQUkMPScF3ng==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-http@3.972.61": { + "integrity": "sha512-8jAjgStl5Ytq4+HF3X/9f+EmRinaRbGRRtQGktlPfBRVx73H+R1y48vIeXerQtYGFaUqkEp3fT6jP854rVO2yQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-ini@3.973.4": { + "integrity": "sha512-e6ZvVsj90aRALf1kHP+J4iqC1496ZpVgqI/+u0LJ5HL7q7ATauGy4gdDvRCP13L1pN/fMiZLah162PGIYkbUVQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-login", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-login@3.972.66": { + "integrity": "sha512-g2fsqm87r/nKthLZ0VkkDBElkGg0PvSa8d97HQ6EilMbJTZ6hxa8FxkSZyJfgPfFdZn0TTmkOffQmTSUcAHIng==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-node@3.972.70": { + "integrity": "sha512-3xzvkGdykBunxqh8WudmUpSyLWvIhfI6aBQo1b5rb3mDO5mNLadK+0hiI0qBQBMVynJbfLO+Ajy9dztMwy9O8w==", + "dependencies": [ + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-ini", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-process@3.972.59": { + "integrity": "sha512-DlZF2/MhLlatDdlrIy3CUCpfdbLrKx+3SMjVo+WyHnPpwzkc/M3vwAHw4OVJf7DMvO+4vfRqSCMc/E9I1auN0g==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-sso@3.973.3": { + "integrity": "sha512-hmdDHoy2G5Es2e8IgelNMYUuSQI6uCIAKZMJ2u2PdKDhxvbk1uWD/g4+R7R5c/tJfKEB1+KjjWiaoCr/S+ZTiQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/token-providers", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-provider-web-identity@3.972.65": { + "integrity": "sha512-gHQb/Kt0chjk/JQDa/GJDqmAvEuVn8n7z10wK2h0LFM9TUDRkohgOO4aEF+s2sBLM0br7Cl5W6P7phgjrrJvLQ==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/credential-providers@3.1091.0": { + "integrity": "sha512-lPX4DF7wJ5Zjlgwk7MIJys36Dum3vsqAweVRVQjWtfbL7+fD0fwNxrRivYlNddD/PPCNJnOzMHlcEEhc1CVNRg==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/credential-provider-cognito-identity", + "@aws-sdk/credential-provider-env", + "@aws-sdk/credential-provider-http", + "@aws-sdk/credential-provider-ini", + "@aws-sdk/credential-provider-login", + "@aws-sdk/credential-provider-node", + "@aws-sdk/credential-provider-process", + "@aws-sdk/credential-provider-sso", + "@aws-sdk/credential-provider-web-identity", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/credential-provider-imds", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/nested-clients@3.997.33": { + "integrity": "sha512-dVZOroI/r3/ENvqNGgjMPul+jjlz9GddfVusgTXlVjfZj5isibOxecLkGQbRPp8XOuX+RAfjXLFgPkD1JS5xrw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/signature-v4-multi-region", + "@aws-sdk/types", + "@smithy/core", + "@smithy/fetch-http-handler", + "@smithy/node-http-handler", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/signature-v4-multi-region@3.996.41": { + "integrity": "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==", + "dependencies": [ + "@aws-sdk/types", + "@smithy/signature-v4", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/token-providers@3.1088.0": { + "integrity": "sha512-4ObatWt2qpJg5FBk4LOOKrTQYzaqeewAtdO3r9ZO8lH9YqLtpTzLyIdy0mJ+nVdfYOnqISkKNfmzP22bNDhwyw==", + "dependencies": [ + "@aws-sdk/core", + "@aws-sdk/nested-clients", + "@aws-sdk/types", + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/types@3.974.2": { + "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "dependencies": [ + "@smithy/types", + "tslib" + ] + }, + "@aws-sdk/xml-builder@3.972.36": { + "integrity": "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==", + "dependencies": [ + "@smithy/types", + "tslib" + ] + }, + "@aws/lambda-invoke-store@0.3.0": { + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==" + }, + "@better-auth/core@1.6.23_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_@cloudflare+workers-types@5.20260722.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.4_nanostores@1.4.1": { + "integrity": "sha512-beEhOs0uVeOxYOZKUfIEBd/nQV2Bd4/6wyLxZ0OFkn6CMTK2Vi+hXuZLnyPBeB6RdHpebEoJWiHqwHxBIxgPDQ==", + "dependencies": [ + "@better-auth/utils", + "@better-fetch/fetch", + "@cloudflare/workers-types@5.20260722.1", + "@opentelemetry/semantic-conventions", + "@standard-schema/spec@1.1.0", + "better-call", + "jose@6.2.3", + "kysely", + "nanostores", + "zod" + ], + "optionalPeers": [ + "@cloudflare/workers-types@5.20260722.1" + ] + }, + "@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__@cloudflare+workers-types@5.20260722.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.4__nanostores@1.4.1_@better-auth+utils@0.4.2_drizzle-orm@0.45.2__@cloudflare+workers-types@5.20260722.1__kysely@0.29.4__postgres@3.4.9_@better-fetch+fetch@1.3.1_@cloudflare+workers-types@5.20260722.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.4_nanostores@1.4.1_postgres@3.4.9": { + "integrity": "sha512-2+/PTVfIP9E7iz6af8TB3lhnowHUj9ljC66kECmHaFEdUqPgzHoWux9epotKwO7XDg2ui4ttWQ8CMeNFLvQeKQ==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils", + "drizzle-orm@0.45.2_@cloudflare+workers-types@5.20260722.1_kysely@0.29.4_postgres@3.4.9" + ], + "optionalPeers": [ + "drizzle-orm@0.45.2_@cloudflare+workers-types@5.20260722.1_kysely@0.29.4_postgres@3.4.9" + ] + }, + "@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__@cloudflare+workers-types@5.20260722.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.4__nanostores@1.4.1_@better-auth+utils@0.4.2_kysely@0.29.4_@better-fetch+fetch@1.3.1_@cloudflare+workers-types@5.20260722.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_nanostores@1.4.1": { + "integrity": "sha512-zbNJsMbG09exfkGyvFqBLLqWoMPAUWjxCuUnEK5AsjbYoZeIjj/QGZgdf4CapVWryKxjA9Q6Jlr6fbiPpC3VAg==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils", + "kysely" + ], + "optionalPeers": [ + "kysely" + ] + }, + "@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__@cloudflare+workers-types@5.20260722.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.4__nanostores@1.4.1_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_@cloudflare+workers-types@5.20260722.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.4_nanostores@1.4.1": { + "integrity": "sha512-krIiR0pIVkaKlAzm690n5bcMW4NGbqeMg0HQSD9fz/KcQF/eWLqcq9gG/BhHTj2i/y96qH+W5JWPmaSOS5iTgQ==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils" + ] + }, + "@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__@cloudflare+workers-types@5.20260722.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.4__nanostores@1.4.1_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_@cloudflare+workers-types@5.20260722.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.4_nanostores@1.4.1": { + "integrity": "sha512-7+QdevitGlKBbP6JbiSk5SBnzPsKV/mDrQBGBn8hwByQLeJwqpqbuBPw7ZI8vzUlFfAAnyFiqwP3Eb8mxnp7pA==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils" + ] + }, + "@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__@cloudflare+workers-types@5.20260722.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.4__nanostores@1.4.1_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_@cloudflare+workers-types@5.20260722.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.4_nanostores@1.4.1": { + "integrity": "sha512-2qSdzidq4tkb1eS5TTqb4Nzg0mdZWm3Qky9SYeXeb8PpVQbC2sxqJhEM5mK7y12uU6I8hc64wO9f7AFVNL+6UQ==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils" + ] + }, + "@better-auth/telemetry@1.6.23_@better-auth+core@1.6.23__@better-auth+utils@0.4.2__@better-fetch+fetch@1.3.1__@cloudflare+workers-types@5.20260722.1__better-call@1.3.7___zod@4.4.3__jose@6.2.3__kysely@0.29.4__nanostores@1.4.1_@better-auth+utils@0.4.2_@better-fetch+fetch@1.3.1_@cloudflare+workers-types@5.20260722.1_better-call@1.3.7__zod@4.4.3_jose@6.2.3_kysely@0.29.4_nanostores@1.4.1": { + "integrity": "sha512-/R2Kb+z2BpDOOWwVHqOk+c0VNpuwfCv4Hp5Yr9003WIZPax/zyNraGLB9CFE8qF2gZW8Dsz419k4I8CPrGzpDA==", + "dependencies": [ + "@better-auth/core", + "@better-auth/utils", + "@better-fetch/fetch" + ] + }, + "@better-auth/utils@0.4.2": { + "integrity": "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==", + "dependencies": [ + "@noble/hashes" + ] + }, + "@better-fetch/fetch@1.3.1": { + "integrity": "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==" + }, + "@cloudflare/kv-asset-handler@0.5.0": { + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==" + }, + "@cloudflare/unenv-preset@2.16.1_unenv@2.0.0-rc.24_workerd@1.20260714.1": { + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dependencies": [ + "unenv@2.0.0-rc.24", + "workerd@1.20260714.1" + ], + "optionalPeers": [ + "workerd@1.20260714.1" + ] + }, + "@cloudflare/unenv-preset@2.7.7_unenv@2.0.0-rc.21": { + "integrity": "sha512-HtZuh166y0Olbj9bqqySckz0Rw9uHjggJeoGbDx5x+sgezBXlxO6tQSig2RZw5tgObF8mWI8zaPvQMkQZtAODw==", + "dependencies": [ + "unenv@2.0.0-rc.21" + ] + }, + "@cloudflare/workerd-darwin-64@1.20260424.1": { + "integrity": "sha512-yFR1XaJbSDLg/qbwtrYaU2xwFXatIPKR5nrMQCN1q/m6+Qe/j6r+kCnFEvOJjMZOm9iCKsE6Qly5clgl4u32qw==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@cloudflare/workerd-darwin-64@1.20260714.1": { + "integrity": "sha512-ZWXqAN8G7Cx9hMRQuk+59ziJhR3j1F4iO+Qs8aHdfKZ3Dq5Yi/57xvkJTgCGBnW1YU/L78r8f6HEy51bwbTpNw==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@cloudflare/workerd-darwin-arm64@1.20260424.1": { + "integrity": "sha512-LqWKcE7x/9KyC2iQvKPeb20hKST3dYXDZlYTvFymgR1DfLS0OFOCzVGTloVNd7WqvK4SkdzBYfxo7QMIAeBK0w==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@cloudflare/workerd-darwin-arm64@1.20260714.1": { + "integrity": "sha512-tueWxWC3wyCbMG6zRAxsMXX0YLgrRWbiAPYFQ2uJ7dUH8G+5E7UTWaQS9B1HdJ0bpKFW1NWxhs1o2noKVFSUYg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@cloudflare/workerd-linux-64@1.20260424.1": { + "integrity": "sha512-YlEBFbAYZHe/ylzl8WEYQEU/jr+0XMqXaST2oBk5oVjksdb1NGuJaggluCdZAzuJJ8UqdTmyhY5u/qrasbiFWA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@cloudflare/workerd-linux-64@1.20260714.1": { + "integrity": "sha512-1VChTZRb0l0F7R4e1G5RtLKV4oFi6x+rQgxh2+yu887j3l/3TLgatuv1L8/5zhc9gKEhATTxOh0e52Rtd9dDWQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@cloudflare/workerd-linux-arm64@1.20260424.1": { + "integrity": "sha512-qJ0X0m6cL8fWDUPDg8K4IxYZXNJI6XbeOihqjnqKbAClrjdPDn8VUSd+z2XiCQ5NylMtMrpa/skC9UfaR6mh8g==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@cloudflare/workerd-linux-arm64@1.20260714.1": { + "integrity": "sha512-rMm3G+NirG2UdgHIRDdF1asNC6FqgIzZzkRG+VDhhDGcVxAQwvrMT1E38BivEvHr3G04MB4AfhcOczX0+GtRkQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@cloudflare/workerd-windows-64@1.20260424.1": { + "integrity": "sha512-tZ7Z9qmYNAP6z1/+8r/zKbk8F8DZmpmwNzMeN+zkde2Wnhfr3FBqOkJXT/5zmli8HPoWrIXxSiyqcNDMy8V2Zg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@cloudflare/workerd-windows-64@1.20260714.1": { + "integrity": "sha512-cGqnU3Hg2YZS/k3SAqrMp1DjpdsyFde72tWltdl6ZT9+SFz/Zrk/8gyTU1TcxC4YApXeNVH5TyU5cOGPgUJ0pg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@cloudflare/workers-types@4.20260702.1": { + "integrity": "sha512-mOhf5TUEB1m2vPrxtqoIGfz0fUC9xyxRDx5gWHy5s+OCo6dcV+g7wI1R7gYCMFohhqF/2y2xeKVwMwCJjfn/WA==" + }, + "@cloudflare/workers-types@5.20260722.1": { + "integrity": "sha512-8+kivCgFGzwrAfNOWgSpzy/VDvmT/i5KWBgQhnygv3d1kajNn6mCYTbLKpouG0aY8mXjhv+IQm1a8r2K/H4pqQ==" + }, + "@cspotcode/source-map-support@0.8.1": { + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dependencies": [ + "@jridgewell/trace-mapping@0.3.9" + ] + }, + "@drizzle-team/brocli@0.10.2": { + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==" + }, + "@emnapi/core@1.11.1": { + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dependencies": [ + "@emnapi/wasi-threads", + "tslib" + ] + }, + "@emnapi/runtime@1.11.1": { + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dependencies": [ + "tslib" + ] + }, + "@emnapi/runtime@1.11.2": { + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dependencies": [ + "tslib" + ] + }, + "@emnapi/wasi-threads@1.2.2": { + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dependencies": [ + "tslib" + ] + }, + "@esbuild-kit/core-utils@3.3.2": { + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "dependencies": [ + "esbuild@0.18.20", + "source-map-support" + ], + "deprecated": true + }, + "@esbuild-kit/esm-loader@2.6.5": { + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "dependencies": [ + "@esbuild-kit/core-utils", + "get-tsconfig" + ], + "deprecated": true + }, + "@esbuild/aix-ppc64@0.25.12": { + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/aix-ppc64@0.28.1": { + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.18.20": { + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm64@0.25.12": { + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm64@0.28.1": { + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.18.20": { + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-arm@0.25.12": { + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-arm@0.28.1": { + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.18.20": { + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/android-x64@0.25.12": { + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/android-x64@0.28.1": { + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.18.20": { + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-arm64@0.25.12": { + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-arm64@0.28.1": { + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.18.20": { + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/darwin-x64@0.25.12": { + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/darwin-x64@0.28.1": { + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.18.20": { + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-arm64@0.25.12": { + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-arm64@0.28.1": { + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.18.20": { + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-x64@0.25.12": { + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-x64@0.28.1": { + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.18.20": { + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm64@0.25.12": { + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm64@0.28.1": { + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.18.20": { + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-arm@0.25.12": { + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-arm@0.28.1": { + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.18.20": { + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-ia32@0.25.12": { + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-ia32@0.28.1": { + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.18.20": { + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-loong64@0.25.12": { + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-loong64@0.28.1": { + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.18.20": { + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-mips64el@0.25.12": { + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-mips64el@0.28.1": { + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.18.20": { + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-ppc64@0.25.12": { + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-ppc64@0.28.1": { + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.18.20": { + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-riscv64@0.25.12": { + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-riscv64@0.28.1": { + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.18.20": { + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-s390x@0.25.12": { + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-s390x@0.28.1": { + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.18.20": { + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/linux-x64@0.25.12": { + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/linux-x64@0.28.1": { + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.25.12": { + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-arm64@0.28.1": { + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.18.20": { + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-x64@0.25.12": { + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-x64@0.28.1": { + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.25.12": { + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-arm64@0.28.1": { + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.18.20": { + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-x64@0.25.12": { + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-x64@0.28.1": { + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.25.12": { + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/openharmony-arm64@0.28.1": { + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.18.20": { + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/sunos-x64@0.25.12": { + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/sunos-x64@0.28.1": { + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.18.20": { + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-arm64@0.25.12": { + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-arm64@0.28.1": { + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.18.20": { + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-ia32@0.25.12": { + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-ia32@0.28.1": { + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.18.20": { + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@esbuild/win32-x64@0.25.12": { + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@esbuild/win32-x64@0.28.1": { + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@fontsource-variable/geist-mono@5.3.0": { + "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==" + }, + "@fontsource-variable/geist@5.3.0": { + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==" + }, + "@fontsource/ibm-plex-serif@5.3.0": { + "integrity": "sha512-zwPfHB7EJbS5B8qnitPigJYzNdnt6PUeaoOA1gAAt//1pjpTVZN5sOU14NIAZ+C1xypL6GWsUG2VMYJW9bhJqQ==" + }, + "@fontsource/instrument-serif@5.3.0": { + "integrity": "sha512-mDiaIg0u67sYV59fie92Wz4sM8UiVlbL7fLxnFPCKkX15DMASEnQTREbaP5S5/3DCcsoAOcQ0sV9E4AeY4QqYQ==" + }, + "@hono/zod-validator@0.9.0_hono@4.12.33_zod@4.4.3": { + "integrity": "sha512-n0ZSXmCiHVIp4Y5wlOOyZCeTd/rsawA/qW1cipB8QOYKZ9N8Tk0nZUZCXho9cu374AN4JpDNKioNKBJ/W+LBug==", + "dependencies": [ + "hono", + "zod" + ] + }, + "@iarna/toml@2.2.5": { + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==" + }, + "@img/colour@1.1.0": { + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==" + }, + "@img/sharp-darwin-arm64@0.34.5": { + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-arm64" + ], + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-darwin-x64@0.34.5": { + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "optionalDependencies": [ + "@img/sharp-libvips-darwin-x64" + ], + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-darwin-arm64@1.2.4": { + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-darwin-x64@1.2.4": { + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linux-arm64@1.2.4": { + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linux-arm@1.2.4": { + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-libvips-linux-ppc64@1.2.4": { + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@img/sharp-libvips-linux-riscv64@1.2.4": { + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@img/sharp-libvips-linux-s390x@1.2.4": { + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-libvips-linux-x64@1.2.4": { + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-libvips-linuxmusl-arm64@1.2.4": { + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-libvips-linuxmusl-x64@1.2.4": { + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linux-arm64@0.34.5": { + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linux-arm@0.34.5": { + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-arm" + ], + "os": ["linux"], + "cpu": ["arm"] + }, + "@img/sharp-linux-ppc64@0.34.5": { + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-ppc64" + ], + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@img/sharp-linux-riscv64@0.34.5": { + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-riscv64" + ], + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@img/sharp-linux-s390x@0.34.5": { + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-s390x" + ], + "os": ["linux"], + "cpu": ["s390x"] + }, + "@img/sharp-linux-x64@0.34.5": { + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "optionalDependencies": [ + "@img/sharp-libvips-linux-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-linuxmusl-arm64@0.34.5": { + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-arm64" + ], + "os": ["linux"], + "cpu": ["arm64"] + }, + "@img/sharp-linuxmusl-x64@0.34.5": { + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "optionalDependencies": [ + "@img/sharp-libvips-linuxmusl-x64" + ], + "os": ["linux"], + "cpu": ["x64"] + }, + "@img/sharp-wasm32@0.34.5": { + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "dependencies": [ + "@emnapi/runtime@1.11.1" + ], + "cpu": ["wasm32"] + }, + "@img/sharp-win32-arm64@0.34.5": { + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@img/sharp-win32-ia32@0.34.5": { + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@img/sharp-win32-x64@0.34.5": { + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@isaacs/cliui@8.0.2": { + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dependencies": [ + "string-width@5.1.2", + "string-width-cjs@npm:string-width@4.2.3", + "strip-ansi@7.2.0", + "strip-ansi-cjs@npm:strip-ansi@6.0.1", + "wrap-ansi@8.1.0", + "wrap-ansi-cjs@npm:wrap-ansi@7.0.0" + ] + }, + "@jridgewell/gen-mapping@0.3.13": { + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": [ + "@jridgewell/sourcemap-codec", + "@jridgewell/trace-mapping@0.3.31" + ] + }, + "@jridgewell/remapping@2.3.5": { + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": [ + "@jridgewell/gen-mapping", + "@jridgewell/trace-mapping@0.3.31" + ] + }, + "@jridgewell/resolve-uri@3.1.2": { + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==" + }, + "@jridgewell/sourcemap-codec@1.5.5": { + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "@jridgewell/trace-mapping@0.3.31": { + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, + "@jridgewell/trace-mapping@0.3.9": { + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dependencies": [ + "@jridgewell/resolve-uri", + "@jridgewell/sourcemap-codec" + ] + }, + "@jsdevtools/ono@7.1.3": { + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==" + }, + "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1": { + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dependencies": [ + "@emnapi/core", + "@emnapi/runtime@1.11.1", + "@tybys/wasm-util" + ] + }, + "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.2": { + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dependencies": [ + "@emnapi/core", + "@emnapi/runtime@1.11.2", + "@tybys/wasm-util" + ] + }, + "@noble/ciphers@2.2.0": { + "integrity": "sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==" + }, + "@noble/hashes@2.2.0": { + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==" + }, + "@nodable/entities@3.0.0": { + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==" + }, + "@octokit/auth-token@5.1.2": { + "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==" + }, + "@octokit/core@6.1.6": { + "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", + "dependencies": [ + "@octokit/auth-token", + "@octokit/graphql", + "@octokit/request", + "@octokit/request-error", + "@octokit/types@14.1.0", + "before-after-hook", + "universal-user-agent" + ] + }, + "@octokit/endpoint@10.1.4": { + "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "dependencies": [ + "@octokit/types@14.1.0", + "universal-user-agent" + ] + }, + "@octokit/graphql@8.2.2": { + "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "dependencies": [ + "@octokit/request", + "@octokit/types@14.1.0", + "universal-user-agent" + ] + }, + "@octokit/openapi-types@24.2.0": { + "integrity": "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==" + }, + "@octokit/openapi-types@25.1.0": { + "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==" + }, + "@octokit/plugin-paginate-rest@11.6.0_@octokit+core@6.1.6": { + "integrity": "sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==", + "dependencies": [ + "@octokit/core", + "@octokit/types@13.10.0" + ] + }, + "@octokit/plugin-request-log@5.3.1_@octokit+core@6.1.6": { + "integrity": "sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==", + "dependencies": [ + "@octokit/core" + ] + }, + "@octokit/plugin-rest-endpoint-methods@13.5.0_@octokit+core@6.1.6": { + "integrity": "sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==", + "dependencies": [ + "@octokit/core", + "@octokit/types@13.10.0" + ] + }, + "@octokit/request-error@6.1.8": { + "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", + "dependencies": [ + "@octokit/types@14.1.0" + ] + }, + "@octokit/request@9.2.4": { + "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", + "dependencies": [ + "@octokit/endpoint", + "@octokit/request-error", + "@octokit/types@14.1.0", + "fast-content-type-parse", + "universal-user-agent" + ] + }, + "@octokit/rest@21.1.1": { + "integrity": "sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==", + "dependencies": [ + "@octokit/core", + "@octokit/plugin-paginate-rest", + "@octokit/plugin-request-log", + "@octokit/plugin-rest-endpoint-methods" + ] + }, + "@octokit/types@13.10.0": { + "integrity": "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==", + "dependencies": [ + "@octokit/openapi-types@24.2.0" + ] + }, + "@octokit/types@14.1.0": { + "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", + "dependencies": [ + "@octokit/openapi-types@25.1.0" + ] + }, + "@opentelemetry/semantic-conventions@1.43.0": { + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==" + }, + "@oslojs/asn1@1.0.0": { + "integrity": "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==", + "dependencies": [ + "@oslojs/binary" + ], + "deprecated": true + }, + "@oslojs/binary@1.0.0": { + "integrity": "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==", + "deprecated": true + }, + "@oslojs/crypto@1.0.1": { + "integrity": "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==", + "dependencies": [ + "@oslojs/asn1", + "@oslojs/binary" + ], + "deprecated": true + }, + "@oslojs/encoding@0.4.1": { + "integrity": "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==" + }, + "@oslojs/encoding@1.1.0": { + "integrity": "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==" + }, + "@oslojs/jwt@0.2.0": { + "integrity": "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==", + "dependencies": [ + "@oslojs/encoding@0.4.1" + ], + "deprecated": true + }, + "@oxc-project/types@0.139.0": { + "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==" + }, + "@oxfmt/binding-android-arm-eabi@0.61.0": { + "integrity": "sha512-BaS+1OVvg9sr+Xav0+KdWedQRcAzrdoEcwMZeqoc2F6ieC1s/t5eM35YQoRPQ7vAqkZ+p3tbQb1r9I9mrV5oGA==", + "os": ["android"], + "cpu": ["arm"] + }, + "@oxfmt/binding-android-arm64@0.61.0": { + "integrity": "sha512-of8atAV0M1egGcVOMbgZCvc10sFOP3ayQBNQV5h5G3fNq8gACdEswfFk9bzGrdbM23rtg0Coxi7np7oPLcueNw==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@oxfmt/binding-darwin-arm64@0.61.0": { + "integrity": "sha512-7l8+5ov4BGwtAcmpzvEik/TG3bciwyw/S3e6j5GKH7pcQqcgCVxD3AuJeP6upto+SOTBKQ4wrrdbMt0gq8fHSQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@oxfmt/binding-darwin-x64@0.61.0": { + "integrity": "sha512-Fnz4dDDXBb7udk+DmwelNjxbD6yptyxwCqwCH2ebo4RVLxVsRfFsn/AHJC49KIltPrVokamGv4SSOsiV50DTxQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@oxfmt/binding-freebsd-x64@0.61.0": { + "integrity": "sha512-mddOebKNCP+AucmzfNsk3jgbr681qAUvgMqi865GW5gWLJ/AnzXbvjQRrny0e++NAN8aphav/aRSrfFxNsNjpA==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@oxfmt/binding-linux-arm-gnueabihf@0.61.0": { + "integrity": "sha512-svx59iYL+DbaZGZUIoice4W0CjRXGExnbz7Re+awIb60rVxBS2KrU7Hnlx+nZYanLGLpjneUEgo/VFEKkSZAyQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@oxfmt/binding-linux-arm-musleabihf@0.61.0": { + "integrity": "sha512-BYK9MPJPCf6d+fLKMTruThmEyCtHzQ1zLcsrTlUVkmnoXIaHAbfpeLYQwX1tkjs7W11dyzoi6HFvKcdnvX1zNg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@oxfmt/binding-linux-arm64-gnu@0.61.0": { + "integrity": "sha512-QUaCNLq2/EC6G5ljOuFanl9Lgw6ZWp4co7rs4+KOMUzbGfA4Lq58FHRjjF9sVIG+93XSbo343MxFATrOU1qctA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@oxfmt/binding-linux-arm64-musl@0.61.0": { + "integrity": "sha512-S6uvJ6MXnRXl+zTs0CARNDvkE+cymj0EVWEKKsyKnlLlqTyQJBjw5s4D2pSIOZc+S46cy4STefzcr/sm0VzVPA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@oxfmt/binding-linux-ppc64-gnu@0.61.0": { + "integrity": "sha512-6VDlRcytvZG6UlSIdAFKDLbppo9tvPxrWzle6vHldYFMeuDPQEfMKrkwezp7FaBq1wik9ra554ZZeRPsyIkFpg==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@oxfmt/binding-linux-riscv64-gnu@0.61.0": { + "integrity": "sha512-KkBTYbzExpbmn15XjKPLu2fRV2PVlq+KWt+brad5rwIa03vdYoaDRWiS7raHII/dCTR6Ro4UpYUCH4t6lif4WQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@oxfmt/binding-linux-riscv64-musl@0.61.0": { + "integrity": "sha512-69tzIq7sJLVB9dxYYtvMzcSSsnZHSO+U2U19O2RqDqgj6+Q4O7HjSXdaszbcgqzhsUwzSH7z5kWvk8nmf6BHTg==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@oxfmt/binding-linux-s390x-gnu@0.61.0": { + "integrity": "sha512-Oqi/N0OvtOVXsPKAOOhKgGH3msRYF8BLJaNBbWiupRiKoKVyc8JRKPCfarkQJC+RgP9U8raUKLe+bNwd0HUMiA==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@oxfmt/binding-linux-x64-gnu@0.61.0": { + "integrity": "sha512-3TKwv/ed4uwJSemAA8P9XcoqETpjQI4waquF9UilhA9Mn/dhr1PdUEXWlL74mtc6ZNfmKPA9+NEJm01nRF8CVA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@oxfmt/binding-linux-x64-musl@0.61.0": { + "integrity": "sha512-uFso4u4nLkVSlMCpgjyvWV60Gt7GvDQHnk1mmRxHIkZTMB0ljpUKwCD9FYGgN9H97x2wYl0UwEjgRZaPIuhEhw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@oxfmt/binding-openharmony-arm64@0.61.0": { + "integrity": "sha512-keGLkzeOvkMpNmPp4hffXWpfoSsY6e1K8++KXD4mSSfxdvM8q9QUDsYY689TB1k6Co832DZn1MnaaVx6cIBMWQ==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@oxfmt/binding-win32-arm64-msvc@0.61.0": { + "integrity": "sha512-VzsAISkFxmNhJ5LBDEL9VuH6tJsVJMtqYit2LyIUf/HLnsCe4Pg9SMOjjVQzGWt0bnpyfJ94CrqTqcpNZzK+ug==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@oxfmt/binding-win32-ia32-msvc@0.61.0": { + "integrity": "sha512-xv4t7yzwJoYaLB6Zv28B3W+j7brEjsyv50rLTAQgmxJzddce9fAMCxed8dSAkbWES0zz2J29nYK5FaTuD2YBHg==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@oxfmt/binding-win32-x64-msvc@0.61.0": { + "integrity": "sha512-6EZXFkqOwxdDYjIn3TSNnPk3ST5E5GiYd4FiM6UF/mCL/LZSfr6D6UygTfW3R1PCQP2quCKpCEGRlij8E3VYbg==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@oxlint/binding-android-arm-eabi@1.76.0": { + "integrity": "sha512-ZHIE5Zt9AsPDcY4nOlofXt0YfneEeo+QrKMPcPzLf2Z6Q8VtV2W73d7SFJ920WUwyik783u/doKCs3KXdwG+7w==", + "os": ["android"], + "cpu": ["arm"] + }, + "@oxlint/binding-android-arm64@1.76.0": { + "integrity": "sha512-shm/ngQilHK6bs+ElJWa4oHfNj5vL1Gl/iVEJldTQjpr0/67oSgr0KUpbmcnLig5Fo0v/l6j2567A7TOL89ONA==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@oxlint/binding-darwin-arm64@1.76.0": { + "integrity": "sha512-rvJmrAPKSQ9aWJ6wIS6CK2tJjwzfW0ApQH9qokq6sfDvmHwoyIHxHFMq7z7i7GiV6fdE6s8qvBqWKPTu8RmT6Q==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@oxlint/binding-darwin-x64@1.76.0": { + "integrity": "sha512-U/zYdb7VYKGY6pA9Vd2rYl9O/HlCylcOlb5PGPvVLtg+oLGsk6H3XGKEMHKyqD3nmmtmlmwb/8SwU2vfSAtvMw==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@oxlint/binding-freebsd-x64@1.76.0": { + "integrity": "sha512-WvKG9CAriuo0XNiFzpXjDngUZcRGFNpaK2kLyMUsnJlShxkT96u+BpJQ3KqdQwGOrvI14L6V8bAwXwAYNNY6Jg==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@oxlint/binding-linux-arm-gnueabihf@1.76.0": { + "integrity": "sha512-qJ5+RH99TqFRq3UCDxkW0zJJu9c+OAHFY72vGlxZLEpuO+MpKo3POgqb8sYipL9KYm8XY6ofb0HsOuvY6hQNqQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@oxlint/binding-linux-arm-musleabihf@1.76.0": { + "integrity": "sha512-PvPCVptkgVARsucgIqFQQcSmJ6xc6GtnVB5bRBekRahTc9eObMtjHfMjy5M+C2tHt5UCMttWM9RuSk/H9NqYeg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@oxlint/binding-linux-arm64-gnu@1.76.0": { + "integrity": "sha512-3KeFDx8Bu4HPAXbuHZOr/oHvN+QT+JQhMw/NYPz7Z071xLSsG27Jfh9PIQVEY7hk1I+jr43ExqRIeJ6VKk2yLw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@oxlint/binding-linux-arm64-musl@1.76.0": { + "integrity": "sha512-oPFkkKTgl0K/EIg9fQ8oA3IGcI05/Mq1en04iFa41mmNPT+6KEiByVazTOZZJiHMBBrbsns1YJ2e1Scqwzesjw==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@oxlint/binding-linux-ppc64-gnu@1.76.0": { + "integrity": "sha512-gN7yZ0eqflA5Fhf1wvHxGUltIV3FsvmB1zhNMDEK9vSHhc7E6qg9CuPeBgPZab66Tjzq6w6kHAtNEvnTHf4cyw==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@oxlint/binding-linux-riscv64-gnu@1.76.0": { + "integrity": "sha512-S/HqMbn22mQrjtErUxEoS/a55u8kIeXvreIxiJu5G7Le3UecEd6SQZxrDIpuhtgaFnsY/nVra3ytP+pRljDilA==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@oxlint/binding-linux-riscv64-musl@1.76.0": { + "integrity": "sha512-ZIga3097VJZolGZk6SrIAUokIGfRkxRlhiHDUznZptGBfwrhD7pNfD1rzEzsCwvk/1DX0A1bLz+liuNh5QKIVQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@oxlint/binding-linux-s390x-gnu@1.76.0": { + "integrity": "sha512-ZGiiA7pFzMJSyMWYZTVlPgbTsx+Vl8ihLGMIujPwaslUF7kIPPWAbVmAlTc+9lWDV+DCiB8Ikixu+lSHeOIIWQ==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@oxlint/binding-linux-x64-gnu@1.76.0": { + "integrity": "sha512-JLiy5WuvEBFTT6ErIFV35SLzi0R7Iri6MKU6dZbTxfIx8pndbbPs3Mj780nMipBFcPkti+okAPOJ9POKkHFEgg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@oxlint/binding-linux-x64-musl@1.76.0": { + "integrity": "sha512-z7lgKQtbo/I1NIe8G5NHLesxJDv0tRSUWTpXKb9Pm3E9nKFKfO4IOSDtFroKgXtOYb0jQbcdH+0wzTyMXVes+A==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@oxlint/binding-openharmony-arm64@1.76.0": { + "integrity": "sha512-JOjKymIpb9QcYfEhZsN6h4V9Ivd474W38cNIBRv6bg2TbIvogbMTH0Mg6YWW9TiRDqfcX+/Hyfsbo5vcSE5guQ==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@oxlint/binding-win32-arm64-msvc@1.76.0": { + "integrity": "sha512-pqDWZiwcmByWUEm1NFUBNiT6aentCcaoMWJv0HbXEmuYermJ4sg8ppVrshubYP2MZ6SHccJJcpr6x469PuDFIw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@oxlint/binding-win32-ia32-msvc@1.76.0": { + "integrity": "sha512-Ba0O659kgMv6pwO3z9PdO+K3aMxQRaw9HnG+e6AtOfgwcKFvYilciQYBoUBmxfQvOCKZe1SwjMkuB542NkuDMQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@oxlint/binding-win32-x64-msvc@1.76.0": { + "integrity": "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@pkgjs/parseargs@0.11.0": { + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==" + }, + "@polka/url@1.0.0-next.29": { + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" + }, + "@poppinss/colors@4.1.6": { + "integrity": "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==", + "dependencies": [ + "kleur" + ] + }, + "@poppinss/dumper@0.6.5": { + "integrity": "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==", + "dependencies": [ + "@poppinss/colors", + "@sindresorhus/is", + "supports-color@10.2.2" + ] + }, + "@poppinss/exception@1.2.3": { + "integrity": "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==" + }, + "@redis/bloom@6.1.0_@redis+client@6.1.0": { + "integrity": "sha512-Rzascjd9J9bJsM45T/Z9CTg1QY/B63B6YO8QorLVMeXnbBDsKiSCVR/+GQ061hYPk8FpTzWmPY8tAv2sT+JEtQ==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/client@6.1.0": { + "integrity": "sha512-7u1LefkezJF0HESlhO7ZFLEPfyY+NejP3SGv+Z4pGaT3oM5GVVLa0u3f4rDLUrcw+SRo8IlX9Y8JAONeDdg1Ag==", + "dependencies": [ + "cluster-key-slot" + ] + }, + "@redis/json@6.1.0_@redis+client@6.1.0": { + "integrity": "sha512-/GFjQA6bu5pG9ClCJAI5Xx4bNXe7UTpxBBlIupBNTrn1+nY860apGnYJuaSCDV2BmEbTidpa7O2qa28oxKx+rg==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/search@6.1.0_@redis+client@6.1.0": { + "integrity": "sha512-kS5agg+3yZbrdrt8omrew7FLCD8eOm7tarG1CROekPBRe+QGDR9aOpnHIQaYsYi6wPRTH70nQiF06AIjgURefQ==", + "dependencies": [ + "@redis/client" + ] + }, + "@redis/time-series@6.1.0_@redis+client@6.1.0": { + "integrity": "sha512-uIDBtV8MmG/xpJsRqbGSO4iX6ryj37MLMP82lRpFvI7ykAVe5GyqgxigEbU+uZNv9kDPNMKw3dvI/S/J1BNBzA==", + "dependencies": [ + "@redis/client" + ] + }, + "@rolldown/binding-android-arm64@1.1.5": { + "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@rolldown/binding-darwin-arm64@1.1.5": { + "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@rolldown/binding-darwin-x64@1.1.5": { + "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@rolldown/binding-freebsd-x64@1.1.5": { + "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@rolldown/binding-linux-arm-gnueabihf@1.1.5": { + "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@rolldown/binding-linux-arm64-gnu@1.1.5": { + "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rolldown/binding-linux-arm64-musl@1.1.5": { + "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@rolldown/binding-linux-ppc64-gnu@1.1.5": { + "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@rolldown/binding-linux-s390x-gnu@1.1.5": { + "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@rolldown/binding-linux-x64-gnu@1.1.5": { + "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rolldown/binding-linux-x64-musl@1.1.5": { + "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@rolldown/binding-openharmony-arm64@1.1.5": { + "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@rolldown/binding-wasm32-wasi@1.1.5": { + "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", + "dependencies": [ + "@emnapi/core", + "@emnapi/runtime@1.11.1", + "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.1" + ], + "cpu": ["wasm32"] + }, + "@rolldown/binding-win32-arm64-msvc@1.1.5": { + "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@rolldown/binding-win32-x64-msvc@1.1.5": { + "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@rolldown/pluginutils@1.0.1": { + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" + }, + "@rollup/rollup-linux-x64-gnu@4.62.2": { + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@sec-ant/readable-stream@0.4.1": { + "integrity": "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==" + }, + "@sindresorhus/is@7.2.0": { + "integrity": "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==" + }, + "@sindresorhus/merge-streams@4.0.0": { + "integrity": "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==" + }, + "@smithy/core@3.29.6": { + "integrity": "sha512-TO3w25cdGWBeYqKNDaqH3v4O3jjMPpKwf39YlG5X5xhqWfpOWJbi5gQi1lrllukuwohdhY0TPB8jBEv6UC50Vg==", + "dependencies": [ + "@smithy/types", + "tslib" + ] + }, + "@smithy/credential-provider-imds@4.4.11": { + "integrity": "sha512-6CUvZwS0tCcVCrcvh2TpwTXxmAkuY6JGNPeKODYRLjHtUUhFLGS3dNkNdRvT/ttJyqimqnhFMTS2nqp4pDZ7oQ==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/fetch-http-handler@5.6.8": { + "integrity": "sha512-AFuLou893FesRZeQcKMh87P9x4PF2/ksPOYLLI1ctW7WJxm55SWInFSHAhaNRBPBmbgZyUcCCDKepBX+1jZBBw==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/node-config-provider@4.5.11": { + "integrity": "sha512-N7OASC3AKvY0csZe/C+9oCgGS1XJVtImlMlk4m+SFTCb9N4FLkfF5lx28lWiwl3FTZ3YawkvGMd60Ir3cB90fA==", + "dependencies": [ + "@smithy/core", + "tslib" + ] + }, + "@smithy/node-http-handler@4.9.8": { + "integrity": "sha512-ArSSIN4t1wLutcIkHzaL6N11J7xpZK7W3T0pFz9cep9zIpEr9x5+lhJRcVUgObGI3OIMbnROq7w8bwzx+Nkf8A==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/signature-v4@5.6.7": { + "integrity": "sha512-32PmEsuZV9lz7SZk3gJcm+EfIAoIVu83AJyEzgALpwmSqLvuacdAu0fvCVNMbDbegyk1S0lHUDrMWIfR47Micw==", + "dependencies": [ + "@smithy/core", + "@smithy/types", + "tslib" + ] + }, + "@smithy/types@4.16.1": { + "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "dependencies": [ + "tslib" + ] + }, + "@speed-highlight/core@1.2.17": { + "integrity": "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg==" + }, + "@standard-schema/spec@1.0.0-beta.3": { + "integrity": "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw==" + }, + "@standard-schema/spec@1.1.0": { + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "@sveltejs/acorn-typescript@1.0.11_acorn@8.17.0": { + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dependencies": [ + "acorn" + ] + }, + "@sveltejs/adapter-cloudflare@7.2.9_@sveltejs+kit@2.70.1__@sveltejs+vite-plugin-svelte@7.2.0___svelte@5.56.7___vite@8.1.5____@types+node@26.1.1____esbuild@0.25.12____yaml@2.9.0___@types+node@26.1.1__svelte@5.56.7__typescript@7.0.2__vite@8.1.5___@types+node@26.1.1___esbuild@0.25.12___yaml@2.9.0__@types+node@26.1.1_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.7__vite@8.1.5___@types+node@26.1.1___esbuild@0.25.12___yaml@2.9.0__@types+node@26.1.1_@types+node@26.1.1_svelte@5.56.7_typescript@7.0.2_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0": { + "integrity": "sha512-LEfLRYKZIiNYBPYk9Cu4dFCCIX37NO96jESNRDv7yr3vvblKU4Yh05FPUMRSrOWFKtG6bJcGIvdG/M2DSf4D1w==", + "dependencies": [ + "@cloudflare/workers-types@4.20260702.1", + "@sveltejs/kit", + "worktop" + ] + }, + "@sveltejs/kit@2.70.1_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.7__vite@8.1.5___@types+node@26.1.1___esbuild@0.25.12___yaml@2.9.0__@types+node@26.1.1_svelte@5.56.7_typescript@7.0.2_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1": { + "integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==", + "dependencies": [ + "@standard-schema/spec@1.1.0", + "@sveltejs/acorn-typescript", + "@sveltejs/vite-plugin-svelte", + "@types/cookie", + "acorn", + "cookie@0.6.0", + "devalue", + "esm-env", + "kleur", + "magic-string", + "mrmime", + "set-cookie-parser", + "sirv", + "svelte", + "typescript", + "vite" + ], + "optionalPeers": [ + "typescript" + ], + "bin": true + }, + "@sveltejs/load-config@0.2.0": { + "integrity": "sha512-1LgZ/qUqSoq+QorD83lk2hka79Px0wXNW2q5V1nZlxGhQgw1jrsIbVz5YiCeucVLo4XvFLjXukUaQjIiqowkcg==" + }, + "@sveltejs/vite-plugin-svelte@7.2.0_svelte@5.56.7_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1": { + "integrity": "sha512-1SpkuMSRLfugrVX+IrKfE1RUegzo8AQzKQ6qQPfVzbcWi5IhuTPaKb5ZrLpucleFznkc4/RTeSPoRnGWFxX+EQ==", + "dependencies": [ + "deepmerge", + "magic-string", + "obug", + "svelte", + "vite", + "vitefu" + ] + }, + "@tailwindcss/forms@0.5.11_tailwindcss@4.3.3": { + "integrity": "sha512-h9wegbZDPurxG22xZSoWtdzc41/OlNEUQERNqI/0fOwa2aVlWGu7C35E/x6LDyD3lgtztFSSjKZyuVM0hxhbgA==", + "dependencies": [ + "mini-svg-data-uri", + "tailwindcss" + ] + }, + "@tailwindcss/node@4.3.3": { + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dependencies": [ + "@jridgewell/remapping", + "enhanced-resolve", + "jiti", + "lightningcss", + "magic-string", + "source-map-js", + "tailwindcss" + ] + }, + "@tailwindcss/oxide-android-arm64@4.3.3": { + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-darwin-arm64@4.3.3": { + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-darwin-x64@4.3.3": { + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-freebsd-x64@4.3.3": { + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3": { + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@tailwindcss/oxide-linux-arm64-gnu@4.3.3": { + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-linux-arm64-musl@4.3.3": { + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-linux-x64-gnu@4.3.3": { + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-linux-x64-musl@4.3.3": { + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide-wasm32-wasi@4.3.3": { + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "dependencies": [ + "@emnapi/core", + "@emnapi/runtime@1.11.2", + "@emnapi/wasi-threads", + "@napi-rs/wasm-runtime@1.1.6_@emnapi+core@1.11.1_@emnapi+runtime@1.11.2", + "@tybys/wasm-util", + "tslib" + ], + "cpu": ["wasm32"] + }, + "@tailwindcss/oxide-win32-arm64-msvc@4.3.3": { + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@tailwindcss/oxide-win32-x64-msvc@4.3.3": { + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@tailwindcss/oxide@4.3.3": { + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "optionalDependencies": [ + "@tailwindcss/oxide-android-arm64", + "@tailwindcss/oxide-darwin-arm64", + "@tailwindcss/oxide-darwin-x64", + "@tailwindcss/oxide-freebsd-x64", + "@tailwindcss/oxide-linux-arm-gnueabihf", + "@tailwindcss/oxide-linux-arm64-gnu", + "@tailwindcss/oxide-linux-arm64-musl", + "@tailwindcss/oxide-linux-x64-gnu", + "@tailwindcss/oxide-linux-x64-musl", + "@tailwindcss/oxide-wasm32-wasi", + "@tailwindcss/oxide-win32-arm64-msvc", + "@tailwindcss/oxide-win32-x64-msvc" + ] + }, + "@tailwindcss/typography@0.5.20_tailwindcss@4.3.3": { + "integrity": "sha512-hwbzQuNUfcPvbegQFatVPl/MY/tcM9KLl963hQ5laJKPh81TEZ1+dNG9PirGvcaDBkp+BCshExAyKVPW91dozw==", + "dependencies": [ + "postcss-selector-parser", + "tailwindcss" + ] + }, + "@tailwindcss/vite@4.3.3_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1": { + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dependencies": [ + "@tailwindcss/node", + "@tailwindcss/oxide", + "tailwindcss", + "vite" + ] + }, + "@tsconfig/node22@22.0.5": { + "integrity": "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw==" + }, + "@tybys/wasm-util@0.10.3": { + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dependencies": [ + "tslib" + ] + }, + "@types/bun@1.3.14": { + "integrity": "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==", + "dependencies": [ + "bun-types" + ] + }, + "@types/cookie@0.6.0": { + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==" + }, + "@types/estree@1.0.9": { + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" + }, + "@types/json-schema@7.0.15": { + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" + }, + "@types/node@26.1.1": { + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dependencies": [ + "undici-types" + ] + }, + "@types/trusted-types@2.0.7": { + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, + "@typescript/typescript-aix-ppc64@7.0.2": { + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@typescript/typescript-darwin-arm64@7.0.2": { + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@typescript/typescript-darwin-x64@7.0.2": { + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@typescript/typescript-freebsd-arm64@7.0.2": { + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@typescript/typescript-freebsd-x64@7.0.2": { + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@typescript/typescript-linux-arm64@7.0.2": { + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@typescript/typescript-linux-arm@7.0.2": { + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@typescript/typescript-linux-loong64@7.0.2": { + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@typescript/typescript-linux-mips64el@7.0.2": { + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@typescript/typescript-linux-ppc64@7.0.2": { + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@typescript/typescript-linux-riscv64@7.0.2": { + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@typescript/typescript-linux-s390x@7.0.2": { + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@typescript/typescript-linux-x64@7.0.2": { + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@typescript/typescript-netbsd-arm64@7.0.2": { + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@typescript/typescript-netbsd-x64@7.0.2": { + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@typescript/typescript-openbsd-arm64@7.0.2": { + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@typescript/typescript-openbsd-x64@7.0.2": { + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@typescript/typescript-sunos-x64@7.0.2": { + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@typescript/typescript-win32-arm64@7.0.2": { + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@typescript/typescript-win32-x64@7.0.2": { + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "os": ["win32"], + "cpu": ["x64"] + }, + "acorn@8.17.0": { + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "bin": true + }, + "alchemy@0.93.12_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1_postgres@3.4.9": { + "integrity": "sha512-0XCVvUpIbvYuN4ZL1JHGH56XfXhIxTHYfHdp2z2UaOB35m8VqlaJjz2mG3n8C8EUIoU/vKzFPAPOYp+HYzhULQ==", + "dependencies": [ + "@aws-sdk/credential-providers", + "@cloudflare/unenv-preset@2.7.7_unenv@2.0.0-rc.21", + "@cloudflare/workers-types@4.20260702.1", + "@iarna/toml", + "@octokit/rest", + "@smithy/node-config-provider", + "@smithy/types", + "aws4fetch", + "drizzle-orm@0.45.2_@cloudflare+workers-types@4.20260702.1_postgres@3.4.9", + "env-paths", + "esbuild@0.25.12", + "execa", + "fast-json-patch", + "fast-xml-parser", + "find-process", + "glob", + "jszip", + "libsodium-wrappers", + "miniflare@4.20260424.0", + "neverthrow", + "open", + "openapi-types", + "pathe", + "picocolors", + "proper-lockfile", + "signal-exit@4.1.0", + "unenv@2.0.0-rc.21", + "vite", + "wrangler", + "ws@8.21.1", + "yaml" + ], + "optionalPeers": [ + "vite" + ], + "bin": true + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-regex@6.2.2": { + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==" + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "ansi-styles@6.2.3": { + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==" + }, + "anynum@1.0.1": { + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==" + }, + "arctic@2.2.2": { + "integrity": "sha512-m6xyOTBom5/qvAOiJPDpmHuR+vW3q5qH1LwWzWQMQE4tmWOX+CPSeTv3SFYG+CsnmPhfuXz2CfvxIQJmsS/u2w==", + "dependencies": [ + "@oslojs/crypto", + "@oslojs/encoding@1.1.0", + "@oslojs/jwt" + ], + "deprecated": true + }, + "argparse@2.0.1": { + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" + }, + "aria-query@5.3.1": { + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==" + }, + "aws4fetch@1.0.20": { + "integrity": "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g==" + }, + "axobject-query@4.1.0": { + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==" + }, + "balanced-match@1.0.2": { + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" + }, + "before-after-hook@3.0.2": { + "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==" + }, + "better-auth@1.6.23_@sveltejs+kit@2.70.1__@sveltejs+vite-plugin-svelte@7.2.0___svelte@5.56.7___vite@8.1.5____@types+node@26.1.1____esbuild@0.25.12____yaml@2.9.0___@types+node@26.1.1__svelte@5.56.7__typescript@7.0.2__vite@8.1.5___@types+node@26.1.1___esbuild@0.25.12___yaml@2.9.0__@types+node@26.1.1_drizzle-kit@0.31.10_drizzle-orm@0.45.2__@cloudflare+workers-types@5.20260722.1__kysely@0.29.4__postgres@3.4.9_svelte@5.56.7_@cloudflare+workers-types@5.20260722.1_@sveltejs+vite-plugin-svelte@7.2.0__svelte@5.56.7__vite@8.1.5___@types+node@26.1.1___esbuild@0.25.12___yaml@2.9.0__@types+node@26.1.1_@types+node@26.1.1_postgres@3.4.9_typescript@7.0.2_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0": { + "integrity": "sha512-4vOaRd9UiKGKm9R+ej0jjU1es3MiJIiNc9Qq3VCnYqOZ4/nb5272QqTxWYoDxyUXl5x6A2x2we5KZKQO9teTQQ==", + "dependencies": [ + "@better-auth/core", + "@better-auth/drizzle-adapter", + "@better-auth/kysely-adapter", + "@better-auth/memory-adapter", + "@better-auth/mongo-adapter", + "@better-auth/prisma-adapter", + "@better-auth/telemetry", + "@better-auth/utils", + "@better-fetch/fetch", + "@noble/ciphers", + "@noble/hashes", + "@sveltejs/kit", + "better-call", + "defu", + "drizzle-kit", + "drizzle-orm@0.45.2_@cloudflare+workers-types@5.20260722.1_kysely@0.29.4_postgres@3.4.9", + "jose@6.2.3", + "kysely", + "nanostores", + "svelte", + "zod" + ], + "optionalPeers": [ + "@sveltejs/kit", + "drizzle-kit", + "drizzle-orm@0.45.2_@cloudflare+workers-types@5.20260722.1_kysely@0.29.4_postgres@3.4.9", + "svelte" + ] + }, + "better-call@1.3.7_zod@4.4.3": { + "integrity": "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w==", + "dependencies": [ + "@better-auth/utils", + "@better-fetch/fetch", + "rou3", + "set-cookie-parser", + "zod" + ], + "optionalPeers": [ + "zod" + ] + }, + "blake3-wasm@2.1.5": { + "integrity": "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==" + }, + "bowser@2.14.1": { + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==" + }, + "brace-expansion@2.1.2": { + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dependencies": [ + "balanced-match" + ] + }, + "buffer-from@1.1.2": { + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "bun-types@1.3.14": { + "integrity": "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==", + "dependencies": [ + "@types/node" + ] + }, + "bundle-name@4.1.0": { + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dependencies": [ + "run-applescript" + ] + }, + "chalk@4.1.2": { + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": [ + "ansi-styles@4.3.0", + "supports-color@7.2.0" + ] + }, + "chokidar@4.0.3": { + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dependencies": [ + "readdirp" + ] + }, + "clone@2.1.2": { + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==" + }, + "clsx@2.1.1": { + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, + "cluster-key-slot@1.1.2": { + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==" + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "commander@14.0.3": { + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==" + }, + "cookie@0.6.0": { + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" + }, + "cookie@1.1.1": { + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==" + }, + "core-util-is@1.0.3": { + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "cross-spawn@7.0.6": { + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": [ + "path-key@3.1.1", + "shebang-command", + "which" + ] + }, + "cssesc@3.0.0": { + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "bin": true + }, + "deepmerge@4.3.1": { + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==" + }, + "default-browser-id@5.0.1": { + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==" + }, + "default-browser@5.5.0": { + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dependencies": [ + "bundle-name", + "default-browser-id" + ] + }, + "define-lazy-prop@3.0.0": { + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==" + }, + "defu@6.1.7": { + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==" + }, + "detect-libc@2.1.2": { + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==" + }, + "devalue@5.8.2": { + "integrity": "sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==" + }, + "drizzle-kit@0.31.10": { + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dependencies": [ + "@drizzle-team/brocli", + "@esbuild-kit/esm-loader", + "esbuild@0.25.12", + "tsx" + ], + "bin": true + }, + "drizzle-orm@0.45.2_@cloudflare+workers-types@4.20260702.1_postgres@3.4.9": { + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "dependencies": [ + "@cloudflare/workers-types@4.20260702.1", + "postgres" + ], + "optionalPeers": [ + "@cloudflare/workers-types@4.20260702.1", + "postgres" + ] + }, + "drizzle-orm@0.45.2_@cloudflare+workers-types@5.20260722.1_kysely@0.29.4_postgres@3.4.9": { + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "dependencies": [ + "@cloudflare/workers-types@5.20260722.1", + "kysely", + "postgres" + ], + "optionalPeers": [ + "@cloudflare/workers-types@5.20260722.1", + "kysely", + "postgres" + ] + }, + "eastasianwidth@0.2.0": { + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + }, + "emoji-regex@8.0.0": { + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "emoji-regex@9.2.2": { + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + }, + "empty-dir@0.1.0": { + "integrity": "sha512-EatTcJH5cVS/NXEpy7IWBSpH0WxvJdBnmRkz8Gqr2Jn4GmSqc4Q4E81SmVGOrHHHtCys1SsFKmAoHlhaFkRlmg==" + }, + "enhanced-resolve@5.24.3": { + "integrity": "sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==", + "dependencies": [ + "graceful-fs", + "tapable" + ] + }, + "env-paths@3.0.0": { + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==" + }, + "error-stack-parser-es@1.0.5": { + "integrity": "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==" + }, + "esbuild@0.18.20": { + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "optionalDependencies": [ + "@esbuild/android-arm@0.18.20", + "@esbuild/android-arm64@0.18.20", + "@esbuild/android-x64@0.18.20", + "@esbuild/darwin-arm64@0.18.20", + "@esbuild/darwin-x64@0.18.20", + "@esbuild/freebsd-arm64@0.18.20", + "@esbuild/freebsd-x64@0.18.20", + "@esbuild/linux-arm@0.18.20", + "@esbuild/linux-arm64@0.18.20", + "@esbuild/linux-ia32@0.18.20", + "@esbuild/linux-loong64@0.18.20", + "@esbuild/linux-mips64el@0.18.20", + "@esbuild/linux-ppc64@0.18.20", + "@esbuild/linux-riscv64@0.18.20", + "@esbuild/linux-s390x@0.18.20", + "@esbuild/linux-x64@0.18.20", + "@esbuild/netbsd-x64@0.18.20", + "@esbuild/openbsd-x64@0.18.20", + "@esbuild/sunos-x64@0.18.20", + "@esbuild/win32-arm64@0.18.20", + "@esbuild/win32-ia32@0.18.20", + "@esbuild/win32-x64@0.18.20" + ], + "scripts": true, + "bin": true + }, + "esbuild@0.25.12": { + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "optionalDependencies": [ + "@esbuild/aix-ppc64@0.25.12", + "@esbuild/android-arm@0.25.12", + "@esbuild/android-arm64@0.25.12", + "@esbuild/android-x64@0.25.12", + "@esbuild/darwin-arm64@0.25.12", + "@esbuild/darwin-x64@0.25.12", + "@esbuild/freebsd-arm64@0.25.12", + "@esbuild/freebsd-x64@0.25.12", + "@esbuild/linux-arm@0.25.12", + "@esbuild/linux-arm64@0.25.12", + "@esbuild/linux-ia32@0.25.12", + "@esbuild/linux-loong64@0.25.12", + "@esbuild/linux-mips64el@0.25.12", + "@esbuild/linux-ppc64@0.25.12", + "@esbuild/linux-riscv64@0.25.12", + "@esbuild/linux-s390x@0.25.12", + "@esbuild/linux-x64@0.25.12", + "@esbuild/netbsd-arm64@0.25.12", + "@esbuild/netbsd-x64@0.25.12", + "@esbuild/openbsd-arm64@0.25.12", + "@esbuild/openbsd-x64@0.25.12", + "@esbuild/openharmony-arm64@0.25.12", + "@esbuild/sunos-x64@0.25.12", + "@esbuild/win32-arm64@0.25.12", + "@esbuild/win32-ia32@0.25.12", + "@esbuild/win32-x64@0.25.12" + ], + "scripts": true, + "bin": true + }, + "esbuild@0.28.1": { + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "optionalDependencies": [ + "@esbuild/aix-ppc64@0.28.1", + "@esbuild/android-arm@0.28.1", + "@esbuild/android-arm64@0.28.1", + "@esbuild/android-x64@0.28.1", + "@esbuild/darwin-arm64@0.28.1", + "@esbuild/darwin-x64@0.28.1", + "@esbuild/freebsd-arm64@0.28.1", + "@esbuild/freebsd-x64@0.28.1", + "@esbuild/linux-arm@0.28.1", + "@esbuild/linux-arm64@0.28.1", + "@esbuild/linux-ia32@0.28.1", + "@esbuild/linux-loong64@0.28.1", + "@esbuild/linux-mips64el@0.28.1", + "@esbuild/linux-ppc64@0.28.1", + "@esbuild/linux-riscv64@0.28.1", + "@esbuild/linux-s390x@0.28.1", + "@esbuild/linux-x64@0.28.1", + "@esbuild/netbsd-arm64@0.28.1", + "@esbuild/netbsd-x64@0.28.1", + "@esbuild/openbsd-arm64@0.28.1", + "@esbuild/openbsd-x64@0.28.1", + "@esbuild/openharmony-arm64@0.28.1", + "@esbuild/sunos-x64@0.28.1", + "@esbuild/win32-arm64@0.28.1", + "@esbuild/win32-ia32@0.28.1", + "@esbuild/win32-x64@0.28.1" + ], + "scripts": true, + "bin": true + }, + "esm-env@1.2.2": { + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==" + }, + "esrap@2.3.0": { + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, + "evlog@2.22.1_hono@4.12.33_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1": { + "integrity": "sha512-5IfmtCk4Mq/ZsCtykxftgWV8LkqjQu28ssEcwWpqbnqYxkolzo4iyh+my2tM8cZoTCDMnnIFp+X9tyspVE8NsA==", + "dependencies": [ + "hono", + "vite" + ], + "optionalPeers": [ + "hono", + "vite" + ] + }, + "execa@9.6.1": { + "integrity": "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==", + "dependencies": [ + "@sindresorhus/merge-streams", + "cross-spawn", + "figures", + "get-stream", + "human-signals", + "is-plain-obj", + "is-stream", + "npm-run-path", + "pretty-ms", + "signal-exit@4.1.0", + "strip-final-newline", + "yoctocolors" + ] + }, + "exsolve@1.1.0": { + "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==" + }, + "fast-content-type-parse@2.0.1": { + "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==" + }, + "fast-json-patch@3.1.1": { + "integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==" + }, + "fast-xml-builder@1.3.0": { + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "dependencies": [ + "path-expression-matcher", + "xml-naming" + ] + }, + "fast-xml-parser@5.10.1": { + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "dependencies": [ + "@nodable/entities", + "fast-xml-builder", + "is-unsafe", + "path-expression-matcher", + "strnum", + "xml-naming" + ], + "bin": true + }, + "fdir@6.5.0_picomatch@4.0.5": { + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dependencies": [ + "picomatch" + ], + "optionalPeers": [ + "picomatch" + ] + }, + "figures@6.1.0": { + "integrity": "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==", + "dependencies": [ + "is-unicode-supported" + ] + }, + "find-process@2.1.1": { + "integrity": "sha512-SrQDx3QhlmHM90iqn9rdjCQcw/T+WlpOkHFsjoRgB+zTpDfltNA1VSNYeYELwhUTJy12UFxqjWhmhOrJc+o4sA==", + "dependencies": [ + "chalk", + "commander", + "loglevel" + ], + "bin": true + }, + "foreground-child@3.3.1": { + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dependencies": [ + "cross-spawn", + "signal-exit@4.1.0" + ] + }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "get-stream@9.0.1": { + "integrity": "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==", + "dependencies": [ + "@sec-ant/readable-stream", + "is-stream" + ] + }, + "get-tsconfig@4.14.0": { + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dependencies": [ + "resolve-pkg-maps" + ] + }, + "glob@10.5.0": { + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "dependencies": [ + "foreground-child", + "jackspeak", + "minimatch", + "minipass", + "package-json-from-dist", + "path-scurry" + ], + "deprecated": true, + "bin": true + }, + "globals@17.7.0": { + "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==" + }, + "graceful-fs@4.2.11": { + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "has-flag@4.0.0": { + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "hono-openapi@0.4.8_@hono+zod-validator@0.9.0__hono@4.12.33__zod@4.4.3_hono@4.12.33_valibot@1.0.0-beta.15__typescript@7.0.2_zod@4.4.3_zod-openapi@6.0.0__zod@4.4.3_typescript@7.0.2": { + "integrity": "sha512-LYr5xdtD49M7hEAduV1PftOMzuT8ZNvkyWfh1DThkLsIr4RkvDb12UxgIiFbwrJB6FLtFXLoOZL9x4IeDk2+VA==", + "dependencies": [ + "@hono/zod-validator", + "hono", + "json-schema-walker", + "valibot", + "zod", + "zod-openapi" + ], + "optionalPeers": [ + "@hono/zod-validator", + "hono", + "valibot", + "zod", + "zod-openapi" + ] + }, + "hono@4.12.33": { + "integrity": "sha512-+SwvkaiJtxsiPjhy9LivY/1m7UsNqCJetM1BrZl9A5DkQhlbHQDU730mMiDPWjnoCYOM8Chf3WrCJw27kNTPFQ==" + }, + "human-signals@8.0.1": { + "integrity": "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==" + }, + "immediate@3.0.6": { + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "is-docker@3.0.0": { + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "bin": true + }, + "is-fullwidth-code-point@3.0.0": { + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "is-inside-container@1.0.0": { + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dependencies": [ + "is-docker" + ], + "bin": true + }, + "is-plain-obj@4.1.0": { + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==" + }, + "is-reference@3.0.3": { + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dependencies": [ + "@types/estree" + ] + }, + "is-stream@4.0.1": { + "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==" + }, + "is-unicode-supported@2.1.0": { + "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==" + }, + "is-unsafe@2.0.0": { + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==" + }, + "is-wsl@3.1.1": { + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dependencies": [ + "is-inside-container" + ] + }, + "isarray@1.0.0": { + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "isexe@2.0.0": { + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" + }, + "jackspeak@3.4.3": { + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dependencies": [ + "@isaacs/cliui" + ], + "optionalDependencies": [ + "@pkgjs/parseargs" + ] + }, + "jiti@2.7.0": { + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "bin": true + }, + "jose@5.9.6": { + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==" + }, + "jose@6.2.3": { + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==" + }, + "js-yaml@4.3.0": { + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "dependencies": [ + "argparse" + ], + "bin": true + }, + "json-schema-walker@2.0.0": { + "integrity": "sha512-nXN2cMky0Iw7Af28w061hmxaPDaML5/bQD9nwm1lOoIKEGjHcRGxqWe4MfrkYThYAPjSUhmsp4bJNoLAyVn9Xw==", + "dependencies": [ + "@apidevtools/json-schema-ref-parser", + "clone" + ] + }, + "jszip@3.10.1": { + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": [ + "lie", + "pako", + "readable-stream", + "setimmediate" + ] + }, + "kleur@4.1.5": { + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==" + }, + "kysely@0.29.4": { + "integrity": "sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==" + }, + "libsodium-wrappers@0.8.4": { + "integrity": "sha512-mu8aAWucZjTB5O/BtGXtW4e1agy7uHxNYG7zPthmmD1jU43LCDmSWZLN4JhflbdPXj3yDO4lxM1O9hLDgIOXDw==", + "dependencies": [ + "libsodium" + ] + }, + "libsodium@0.8.4": { + "integrity": "sha512-lMcYaRi0zcs7tarATsQUYC7rstliIXZuoq0c6zXSgNtSNtdvBgkSegjWhpMJAXzKX3SUSwIp7+zEsob+j3LuRw==" + }, + "lie@3.3.0": { + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": [ + "immediate" + ] + }, + "lightningcss-android-arm64@1.32.0": { + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-arm64@1.32.0": { + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "lightningcss-darwin-x64@1.32.0": { + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "lightningcss-freebsd-x64@1.32.0": { + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "lightningcss-linux-arm-gnueabihf@1.32.0": { + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "lightningcss-linux-arm64-gnu@1.32.0": { + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-arm64-musl@1.32.0": { + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "lightningcss-linux-x64-gnu@1.32.0": { + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-linux-x64-musl@1.32.0": { + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "os": ["linux"], + "cpu": ["x64"] + }, + "lightningcss-win32-arm64-msvc@1.32.0": { + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "lightningcss-win32-x64-msvc@1.32.0": { + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "os": ["win32"], + "cpu": ["x64"] + }, + "lightningcss@1.32.0": { + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dependencies": [ + "detect-libc" + ], + "optionalDependencies": [ + "lightningcss-android-arm64", + "lightningcss-darwin-arm64", + "lightningcss-darwin-x64", + "lightningcss-freebsd-x64", + "lightningcss-linux-arm-gnueabihf", + "lightningcss-linux-arm64-gnu", + "lightningcss-linux-arm64-musl", + "lightningcss-linux-x64-gnu", + "lightningcss-linux-x64-musl", + "lightningcss-win32-arm64-msvc", + "lightningcss-win32-x64-msvc" + ] + }, + "locate-character@3.0.0": { + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==" + }, + "loglevel@1.9.2": { + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==" + }, + "lru-cache@10.4.3": { + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" + }, + "magic-string@0.30.21": { + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": [ + "@jridgewell/sourcemap-codec" + ] + }, + "mini-svg-data-uri@1.4.4": { + "integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==", + "bin": true + }, + "miniflare@4.20260424.0": { + "integrity": "sha512-B6MKBBd5TJ19daUc3Ae9rWctn1nDA/VCXykXfCsp9fTxyfGxnZY27tJs1caxgE9MWEMMKGbGHouqVtgKbKGxmw==", + "dependencies": [ + "@cspotcode/source-map-support", + "sharp", + "undici@7.24.8", + "workerd@1.20260424.1", + "ws@8.18.0", + "youch" + ], + "bin": true + }, + "miniflare@4.20260714.0": { + "integrity": "sha512-MYlTCLdWCPqvrYY2uLwOjXwmglXuiHE3TGGkbOW4BwjUPa1r07E0iuHwrNDIs/sxK21r+o90Jx58AV2KeNdJZw==", + "dependencies": [ + "@cspotcode/source-map-support", + "sharp", + "undici@7.28.0", + "workerd@1.20260714.1", + "ws@8.21.0", + "youch" + ], + "bin": true + }, + "minimatch@9.0.9": { + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dependencies": [ + "brace-expansion" + ] + }, + "minipass@7.1.3": { + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==" + }, + "mri@1.2.0": { + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==" + }, + "mrmime@2.0.1": { + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==" + }, + "nanoid@3.3.16": { + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "bin": true + }, + "nanostores@1.4.1": { + "integrity": "sha512-PGd3uPojJB9Z07d5NX3Db/SOSBbyy3wLMUGq0GpnEEJfVzY9mq7daPMAZ3jObV5D3Jn+YKND636eI5ULg7F80Q==" + }, + "neverthrow@8.2.0": { + "integrity": "sha512-kOCT/1MCPAxY5iUV3wytNFUMUolzuwd/VF/1KCx7kf6CutrOsTie+84zTGTpgQycjvfLdBBdvBvFLqFD2c0wkQ==", + "optionalDependencies": [ + "@rollup/rollup-linux-x64-gnu" + ] + }, + "npm-run-path@6.0.0": { + "integrity": "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==", + "dependencies": [ + "path-key@4.0.0", + "unicorn-magic" + ] + }, + "obug@2.1.4": { + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==" + }, + "ohash@2.0.11": { + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==" + }, + "open@10.2.0": { + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dependencies": [ + "default-browser", + "define-lazy-prop", + "is-inside-container", + "wsl-utils" + ] + }, + "openapi-types@12.1.3": { + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==" + }, + "oxfmt@0.61.0_svelte@5.56.7": { + "integrity": "sha512-DxdHBEMYpcEnHoUHjjOigUqV2TYKsvxLwUPXnVYBjgFdqrcQ/91OtwubtZ2PUodCs3sStI8R5Qw3fKNGK4e8wQ==", + "dependencies": [ + "svelte", + "tinypool" + ], + "optionalDependencies": [ + "@oxfmt/binding-android-arm-eabi", + "@oxfmt/binding-android-arm64", + "@oxfmt/binding-darwin-arm64", + "@oxfmt/binding-darwin-x64", + "@oxfmt/binding-freebsd-x64", + "@oxfmt/binding-linux-arm-gnueabihf", + "@oxfmt/binding-linux-arm-musleabihf", + "@oxfmt/binding-linux-arm64-gnu", + "@oxfmt/binding-linux-arm64-musl", + "@oxfmt/binding-linux-ppc64-gnu", + "@oxfmt/binding-linux-riscv64-gnu", + "@oxfmt/binding-linux-riscv64-musl", + "@oxfmt/binding-linux-s390x-gnu", + "@oxfmt/binding-linux-x64-gnu", + "@oxfmt/binding-linux-x64-musl", + "@oxfmt/binding-openharmony-arm64", + "@oxfmt/binding-win32-arm64-msvc", + "@oxfmt/binding-win32-ia32-msvc", + "@oxfmt/binding-win32-x64-msvc" + ], + "optionalPeers": [ + "svelte" + ], + "bin": true + }, + "oxlint@1.76.0": { + "integrity": "sha512-6QoFioEU4fNdiUx/2Eo6TRd6NG7H7njnRCz8rhB66cZmMHDTqcm1Rjvl8Wry+ZTQMBAmyb4Mlf62Mk5X+eHSOw==", + "optionalDependencies": [ + "@oxlint/binding-android-arm-eabi", + "@oxlint/binding-android-arm64", + "@oxlint/binding-darwin-arm64", + "@oxlint/binding-darwin-x64", + "@oxlint/binding-freebsd-x64", + "@oxlint/binding-linux-arm-gnueabihf", + "@oxlint/binding-linux-arm-musleabihf", + "@oxlint/binding-linux-arm64-gnu", + "@oxlint/binding-linux-arm64-musl", + "@oxlint/binding-linux-ppc64-gnu", + "@oxlint/binding-linux-riscv64-gnu", + "@oxlint/binding-linux-riscv64-musl", + "@oxlint/binding-linux-s390x-gnu", + "@oxlint/binding-linux-x64-gnu", + "@oxlint/binding-linux-x64-musl", + "@oxlint/binding-openharmony-arm64", + "@oxlint/binding-win32-arm64-msvc", + "@oxlint/binding-win32-ia32-msvc", + "@oxlint/binding-win32-x64-msvc" + ], + "bin": true + }, + "package-json-from-dist@1.0.1": { + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" + }, + "pako@1.0.11": { + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "parse-ms@4.0.0": { + "integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==" + }, + "path-expression-matcher@1.6.2": { + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==" + }, + "path-key@3.1.1": { + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==" + }, + "path-key@4.0.0": { + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==" + }, + "path-scurry@1.11.1": { + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dependencies": [ + "lru-cache", + "minipass" + ] + }, + "path-to-regexp@6.3.0": { + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + }, + "pathe@2.0.3": { + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==" + }, + "picocolors@1.1.1": { + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "picomatch@4.0.5": { + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==" + }, + "postcss-selector-parser@6.0.10": { + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dependencies": [ + "cssesc", + "util-deprecate" + ] + }, + "postcss@8.5.20": { + "integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", + "dependencies": [ + "nanoid", + "picocolors", + "source-map-js" + ] + }, + "postgres@3.4.9": { + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" + }, + "postgresql@0.0.1": { + "integrity": "sha512-qtBS+u3m4UxCVTTQBm58IR4TJWZKYVRa/egONxzkQtqEYhFc9HVno4S7//ABFRJR5LeCcrZtcBpZJ5SJkJkGEA==", + "dependencies": [ + "empty-dir" + ], + "scripts": true + }, + "pretty-ms@9.3.0": { + "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "dependencies": [ + "parse-ms" + ] + }, + "process-nextick-args@2.0.1": { + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "proper-lockfile@4.1.2": { + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dependencies": [ + "graceful-fs", + "retry", + "signal-exit@3.0.7" + ] + }, + "readable-stream@2.3.8": { + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": [ + "core-util-is", + "inherits", + "isarray", + "process-nextick-args", + "safe-buffer", + "string_decoder", + "util-deprecate" + ] + }, + "readdirp@4.1.2": { + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==" + }, + "redis@6.1.0": { + "integrity": "sha512-0kvUPM8RHP/ZMa0xYaDTcG5e8tIGW6kz6MToVT0V8iOnk6bkXp2jncGRGe2bZEk41lZwiDspUqjZCSk5ohjcKw==", + "dependencies": [ + "@redis/bloom", + "@redis/client", + "@redis/json", + "@redis/search", + "@redis/time-series" + ] + }, + "regexparam@3.0.0": { + "integrity": "sha512-RSYAtP31mvYLkAHrOlh25pCNQ5hWnT106VukGaaFfuJrZFkGRX5GhUAdPqpSDXxOhA2c4akmRuplv1mRqnBn6Q==" + }, + "resolve-pkg-maps@1.0.0": { + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==" + }, + "retry@0.12.0": { + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==" + }, + "rolldown@1.1.5": { + "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", + "dependencies": [ + "@oxc-project/types", + "@rolldown/pluginutils" + ], + "optionalDependencies": [ + "@rolldown/binding-android-arm64", + "@rolldown/binding-darwin-arm64", + "@rolldown/binding-darwin-x64", + "@rolldown/binding-freebsd-x64", + "@rolldown/binding-linux-arm-gnueabihf", + "@rolldown/binding-linux-arm64-gnu", + "@rolldown/binding-linux-arm64-musl", + "@rolldown/binding-linux-ppc64-gnu", + "@rolldown/binding-linux-s390x-gnu", + "@rolldown/binding-linux-x64-gnu", + "@rolldown/binding-linux-x64-musl", + "@rolldown/binding-openharmony-arm64", + "@rolldown/binding-wasm32-wasi", + "@rolldown/binding-win32-arm64-msvc", + "@rolldown/binding-win32-x64-msvc" + ], + "bin": true + }, + "rou3@0.7.12": { + "integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==" + }, + "run-applescript@7.1.0": { + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==" + }, + "sade@1.8.1": { + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dependencies": [ + "mri" + ] + }, + "safe-buffer@5.1.2": { + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "semver@7.8.5": { + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": true + }, + "set-cookie-parser@3.1.2": { + "integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==" + }, + "setimmediate@1.0.5": { + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, + "sharp@0.34.5": { + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "dependencies": [ + "@img/colour", + "detect-libc", + "semver" + ], + "optionalDependencies": [ + "@img/sharp-darwin-arm64", + "@img/sharp-darwin-x64", + "@img/sharp-libvips-darwin-arm64", + "@img/sharp-libvips-darwin-x64", + "@img/sharp-libvips-linux-arm", + "@img/sharp-libvips-linux-arm64", + "@img/sharp-libvips-linux-ppc64", + "@img/sharp-libvips-linux-riscv64", + "@img/sharp-libvips-linux-s390x", + "@img/sharp-libvips-linux-x64", + "@img/sharp-libvips-linuxmusl-arm64", + "@img/sharp-libvips-linuxmusl-x64", + "@img/sharp-linux-arm", + "@img/sharp-linux-arm64", + "@img/sharp-linux-ppc64", + "@img/sharp-linux-riscv64", + "@img/sharp-linux-s390x", + "@img/sharp-linux-x64", + "@img/sharp-linuxmusl-arm64", + "@img/sharp-linuxmusl-x64", + "@img/sharp-wasm32", + "@img/sharp-win32-arm64", + "@img/sharp-win32-ia32", + "@img/sharp-win32-x64" + ], + "scripts": true + }, + "shebang-command@2.0.0": { + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dependencies": [ + "shebang-regex" + ] + }, + "shebang-regex@3.0.0": { + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" + }, + "signal-exit@3.0.7": { + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "signal-exit@4.1.0": { + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==" + }, + "sirv@3.0.2": { + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dependencies": [ + "@polka/url", + "mrmime", + "totalist" + ] + }, + "source-map-js@1.2.1": { + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" + }, + "source-map-support@0.5.21": { + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": [ + "buffer-from", + "source-map" + ] + }, + "source-map@0.6.1": { + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "string-width@4.2.3": { + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": [ + "emoji-regex@8.0.0", + "is-fullwidth-code-point", + "strip-ansi@6.0.1" + ] + }, + "string-width@5.1.2": { + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dependencies": [ + "eastasianwidth", + "emoji-regex@9.2.2", + "strip-ansi@7.2.0" + ] + }, + "string_decoder@1.1.1": { + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": [ + "safe-buffer" + ] + }, + "strip-ansi@6.0.1": { + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": [ + "ansi-regex@5.0.1" + ] + }, + "strip-ansi@7.2.0": { + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dependencies": [ + "ansi-regex@6.2.2" + ] + }, + "strip-final-newline@4.0.0": { + "integrity": "sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==" + }, + "strnum@2.4.1": { + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "dependencies": [ + "anynum" + ] + }, + "supports-color@10.2.2": { + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==" + }, + "supports-color@7.2.0": { + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": [ + "has-flag" + ] + }, + "svelte-check@4.7.3_svelte@5.56.7_typescript@7.0.2": { + "integrity": "sha512-DHdTCGX62R0fCxBEaT+USdASAnoaRBaaNczkRJl0K7o3WyoCeVUbVxo6fKqpOll/B+WMWCsiFK0eFrJSNBKZIg==", + "dependencies": [ + "@jridgewell/trace-mapping@0.3.31", + "@sveltejs/load-config", + "chokidar", + "fdir", + "picocolors", + "sade", + "svelte", + "typescript" + ], + "bin": true + }, + "svelte@5.56.7": { + "integrity": "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ==", + "dependencies": [ + "@jridgewell/remapping", + "@jridgewell/sourcemap-codec", + "@sveltejs/acorn-typescript", + "@types/estree", + "@types/trusted-types", + "acorn", + "aria-query", + "axobject-query", + "clsx", + "devalue", + "esm-env", + "esrap", + "is-reference", + "locate-character", + "magic-string", + "zimmerframe" + ] + }, + "tailwindcss@4.3.3": { + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==" + }, + "tapable@2.3.3": { + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==" + }, + "tinyglobby@0.2.17": { + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dependencies": [ + "fdir", + "picomatch" + ] + }, + "tinypool@2.1.0": { + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==" + }, + "totalist@3.0.1": { + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==" + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "tsx@4.23.1": { + "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "dependencies": [ + "esbuild@0.28.1" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, + "typescript@7.0.2": { + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "optionalDependencies": [ + "@typescript/typescript-aix-ppc64", + "@typescript/typescript-darwin-arm64", + "@typescript/typescript-darwin-x64", + "@typescript/typescript-freebsd-arm64", + "@typescript/typescript-freebsd-x64", + "@typescript/typescript-linux-arm", + "@typescript/typescript-linux-arm64", + "@typescript/typescript-linux-loong64", + "@typescript/typescript-linux-mips64el", + "@typescript/typescript-linux-ppc64", + "@typescript/typescript-linux-riscv64", + "@typescript/typescript-linux-s390x", + "@typescript/typescript-linux-x64", + "@typescript/typescript-netbsd-arm64", + "@typescript/typescript-netbsd-x64", + "@typescript/typescript-openbsd-arm64", + "@typescript/typescript-openbsd-x64", + "@typescript/typescript-sunos-x64", + "@typescript/typescript-win32-arm64", + "@typescript/typescript-win32-x64" + ], + "bin": true + }, + "ufo@1.6.4": { + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==" + }, + "undici-types@8.3.0": { + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, + "undici@7.24.8": { + "integrity": "sha512-6KQ/+QxK49Z/p3HO6E5ZCZWNnCasyZLa5ExaVYyvPxUwKtbCPMKELJOqh7EqOle0t9cH/7d2TaaTRRa6Nhs4YQ==" + }, + "undici@7.28.0": { + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==" + }, + "unenv@2.0.0-rc.21": { + "integrity": "sha512-Wj7/AMtE9MRnAXa6Su3Lk0LNCfqDYgfwVjwRFVum9U7wsto1imuHqk4kTm7Jni+5A0Hn7dttL6O/zjvUvoo+8A==", + "dependencies": [ + "defu", + "exsolve", + "ohash", + "pathe", + "ufo" + ] + }, + "unenv@2.0.0-rc.24": { + "integrity": "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==", + "dependencies": [ + "pathe" + ] + }, + "unicorn-magic@0.3.0": { + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==" + }, + "universal-user-agent@7.0.3": { + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==" + }, + "util-deprecate@1.0.2": { + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "valibot@1.0.0-beta.15_typescript@7.0.2": { + "integrity": "sha512-BKy8XosZkDHWmYC+cJG74LBzP++Gfntwi33pP3D3RKztz2XV9jmFWnkOi21GoqARP8wAWARwhV6eTr1JcWzjGw==", + "dependencies": [ + "typescript" + ], + "optionalPeers": [ + "typescript" + ] + }, + "vite@8.1.5_@types+node@26.1.1_esbuild@0.25.12_yaml@2.9.0": { + "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", + "dependencies": [ + "@types/node", + "esbuild@0.25.12", + "lightningcss", + "picomatch", + "postcss", + "rolldown", + "tinyglobby", + "yaml" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@types/node", + "esbuild@0.25.12", + "yaml" + ], + "bin": true + }, + "vitefu@1.1.3_vite@8.1.5__@types+node@26.1.1__esbuild@0.25.12__yaml@2.9.0_@types+node@26.1.1": { + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dependencies": [ + "vite" + ], + "optionalPeers": [ + "vite" + ] + }, + "which@2.0.2": { + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dependencies": [ + "isexe" + ], + "bin": true + }, + "workerd@1.20260424.1": { + "integrity": "sha512-oKsB0Xo/mfkYMdSACoS06XZg09VUK4rXwHfF/1t3P++sMbwzf4UHQvMO57+zxpEB2nVrY/ZkW0bYFGq4GdAFSQ==", + "optionalDependencies": [ + "@cloudflare/workerd-darwin-64@1.20260424.1", + "@cloudflare/workerd-darwin-arm64@1.20260424.1", + "@cloudflare/workerd-linux-64@1.20260424.1", + "@cloudflare/workerd-linux-arm64@1.20260424.1", + "@cloudflare/workerd-windows-64@1.20260424.1" + ], + "scripts": true, + "bin": true + }, + "workerd@1.20260714.1": { + "integrity": "sha512-oIbQzfdyl9UQUnG6XLegcSq0Mgt/7WKDbFOoqGgOWCS+/fhyGB460uKEgdAQQ9RHCO/ttcNCX/KiMIQzdoeu3Q==", + "optionalDependencies": [ + "@cloudflare/workerd-darwin-64@1.20260714.1", + "@cloudflare/workerd-darwin-arm64@1.20260714.1", + "@cloudflare/workerd-linux-64@1.20260714.1", + "@cloudflare/workerd-linux-arm64@1.20260714.1", + "@cloudflare/workerd-windows-64@1.20260714.1" + ], + "scripts": true, + "bin": true + }, + "worktop@0.8.0-next.18": { + "integrity": "sha512-+TvsA6VAVoMC3XDKR5MoC/qlLqDixEfOBysDEKnPIPou/NvoPWCAuXHXMsswwlvmEuvX56lQjvELLyLuzTKvRw==", + "dependencies": [ + "mrmime", + "regexparam" + ] + }, + "wrangler@4.112.0_@cloudflare+workers-types@4.20260702.1": { + "integrity": "sha512-5H+XUD0TySCv1LuktFHDIEOkboH2nTfQs+35L+USt3MtntjDTMVIJprLgQcL2WBjulOyjxpd1vyTiSTJVW5MjQ==", + "dependencies": [ + "@cloudflare/kv-asset-handler", + "@cloudflare/unenv-preset@2.16.1_unenv@2.0.0-rc.24_workerd@1.20260714.1", + "@cloudflare/workers-types@4.20260702.1", + "blake3-wasm", + "esbuild@0.28.1", + "miniflare@4.20260714.0", + "path-to-regexp", + "unenv@2.0.0-rc.24", + "workerd@1.20260714.1" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@cloudflare/workers-types@4.20260702.1" + ], + "bin": true + }, + "wrap-ansi@7.0.0": { + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": [ + "ansi-styles@4.3.0", + "string-width@4.2.3", + "strip-ansi@6.0.1" + ] + }, + "wrap-ansi@8.1.0": { + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dependencies": [ + "ansi-styles@6.2.3", + "string-width@5.1.2", + "strip-ansi@7.2.0" + ] + }, + "ws@8.18.0": { + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==" + }, + "ws@8.21.0": { + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==" + }, + "ws@8.21.1": { + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==" + }, + "wsl-utils@0.1.0": { + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dependencies": [ + "is-wsl" + ] + }, + "xml-naming@0.3.0": { + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==" + }, + "yaml@2.9.0": { + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "bin": true + }, + "yoctocolors@2.1.2": { + "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==" + }, + "youch-core@0.3.3": { + "integrity": "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==", + "dependencies": [ + "@poppinss/exception", + "error-stack-parser-es" + ] + }, + "youch@4.1.0-beta.10": { + "integrity": "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==", + "dependencies": [ + "@poppinss/colors", + "@poppinss/dumper", + "@speed-highlight/core", + "cookie@1.1.1", + "youch-core" + ] + }, + "zimmerframe@1.1.4": { + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" + }, + "zod-openapi@6.0.0_zod@4.4.3": { + "integrity": "sha512-mS4eRJ4DGCPrg6elRbJqc/3nLe4EPVi8KiHRKZ7dcTR5m5orPy8EfoWmceAyGZAq71MAWuyrTTOag7W5N61ZPQ==", + "dependencies": [ + "zod" + ] + }, + "zod@4.4.3": { + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==" + } + }, + "workspace": { + "packageJson": { + "dependencies": [ + "npm:alchemy@~0.93.12", + "npm:oxfmt@0.61", + "npm:oxlint@^1.76.0" + ] + }, + "members": { + "apps/api": { + "packageJson": { + "dependencies": [ + "npm:@cloudflare/workers-types@^5.20260722.1", + "npm:@hono/zod-validator@0.9", + "npm:@types/bun@latest", + "npm:@types/node@^26.1.1", + "npm:hono-openapi@~0.4.8", + "npm:hono@^4.12.31", + "npm:jose@^6.2.3", + "npm:redis@6", + "npm:zod-openapi@6", + "npm:zod@^4.4.3" + ] + } + }, + "apps/auth": { + "packageJson": { + "dependencies": [ + "npm:@cloudflare/workers-types@^5.20260722.1", + "npm:@tsconfig/node22@^22.0.5", + "npm:@types/bun@latest", + "npm:@types/node@^26.1.1" + ] + } + }, + "apps/web": { + "packageJson": { + "dependencies": [ + "npm:@cloudflare/workers-types@^4.20250805.0", + "npm:@fontsource-variable/geist-mono@^5.2.7", + "npm:@fontsource-variable/geist@^5.2.8", + "npm:@fontsource/ibm-plex-serif@^5.2.7", + "npm:@fontsource/instrument-serif@^5.3.0", + "npm:@sveltejs/adapter-cloudflare@^7.0.4", + "npm:@sveltejs/kit@^2.65.0", + "npm:@sveltejs/vite-plugin-svelte@^7.1.2", + "npm:@tailwindcss/forms@~0.5.11", + "npm:@tailwindcss/typography@~0.5.19", + "npm:@tailwindcss/vite@^4.1.18", + "npm:@types/node@24", + "npm:evlog@^2.20.0", + "npm:globals@^17.3.0", + "npm:oxfmt@0.58", + "npm:oxlint@^1.73.0", + "npm:svelte-check@^4.7.1", + "npm:svelte@^5.56.4", + "npm:tailwindcss@^4.1.18", + "npm:typescript@^7.0.1-rc", + "npm:vite@^8.1.1", + "npm:wrangler@^4.118.0" + ] + } + }, + "packages/auth": { + "packageJson": { + "dependencies": [ + "npm:@cloudflare/workers-types@^5.20260722.1", + "npm:@standard-schema/spec@1.0.0-beta.3", + "npm:@tsconfig/node22@^22.0.5", + "npm:@types/node@^26.1.1", + "npm:arctic@2.2.2", + "npm:aws4fetch@1.0.20", + "npm:hono@^4.12.31", + "npm:jose@5.9.6", + "npm:typescript@^7.0.1-rc", + "npm:valibot@1.0.0-beta.15" + ] + } + }, + "packages/core": { + "packageJson": { + "dependencies": [ + "npm:@types/bun@latest", + "npm:@types/node@^26.1.1", + "npm:drizzle-kit@~0.31.10", + "npm:drizzle-orm@~0.45.2", + "npm:postgres@^3.4.9", + "npm:postgresql@^0.0.1", + "npm:zod-openapi@6", + "npm:zod@^4.4.3" + ] + } + } + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..c657d7c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +version: '3.8' + +services: + postgres: + image: docker.io/postgres:18-alpine + container_name: nestri_postgres + environment: + POSTGRES_USER: postgres # Matches: user: 'postgres' + POSTGRES_PASSWORD: postgres # Matches: password: 'postgres' + POSTGRES_DB: nestri # Matches: database: 'nestri' + ports: + - '5432:5432' # Matches: port: 5432 + volumes: + - nestri_data:/var/lib/postgresql + +volumes: + nestri_data: # Keeps your data safe when container restarts diff --git a/oxlintrc.json b/oxlintrc.json new file mode 100644 index 00000000..4364c076 --- /dev/null +++ b/oxlintrc.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/refs/heads/main/npm/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "import", "jsdoc", "node", "promise", "vitest"], + "categories": { + "correctness": "error", + "perf": "error" + }, + "rules": { + "no-console": "error", + "curly": ["error", "multi-line"], + "prefer-const": ["off", { "destructuring": "all" }], + "prefer-destructuring": [ + "error", + { + "VariableDeclarator": { "array": false, "object": true } + } + ], + "unicode-bom": ["error", "never"], + "eslint/no-unassigned-vars": "off", + "typescript/consistent-indexed-object-style": ["error", "record"], + "typescript/ban-ts-comment": ["error", { "ts-expect-error": "allow-with-description" }], + "vitest/require-mock-type-parameters": "off", + "vitest/prefer-snapshot-hint": "off" + }, + "options": { + "typeAware": true, + "typeCheck": true + } +} diff --git a/packages/auth/bunfig.toml b/packages/auth/bunfig.toml new file mode 100644 index 00000000..49211bd4 --- /dev/null +++ b/packages/auth/bunfig.toml @@ -0,0 +1,2 @@ +[test] +root = "./test" diff --git a/packages/auth/package.json b/packages/auth/package.json new file mode 100644 index 00000000..a7627a76 --- /dev/null +++ b/packages/auth/package.json @@ -0,0 +1,41 @@ +{ + "name": "@nestri/auth", + "version": "0.0.1", + "files": [ + "src" + ], + "type": "module", + "sideEffects": false, + "exports": { + "./*": { + "types": "./src/*.ts", + "import": "./src/*.ts" + }, + "./**/*": { + "types": "./src/**/*.ts", + "import": "./src/**/*.ts" + } + }, + "scripts": { + "build": "bun run script/build.ts", + "test": "bun test" + }, + "dependencies": { + "@standard-schema/spec": "1.0.0-beta.3", + "aws4fetch": "1.0.20", + "jose": "5.9.6" + }, + "devDependencies": { + "@cloudflare/workers-types": "catalog:", + "@tsconfig/node22": "catalog:", + "@types/node": "catalog:", + "arctic": "2.2.2", + "hono": "catalog:", + "typescript": "catalog:", + "valibot": "1.0.0-beta.15" + }, + "peerDependencies": { + "arctic": "^2.2.2", + "hono": "catalog:" + } +} diff --git a/packages/auth/script/build.ts b/packages/auth/script/build.ts new file mode 100644 index 00000000..dd0a5b7f --- /dev/null +++ b/packages/auth/script/build.ts @@ -0,0 +1,23 @@ +import { Glob, $ } from 'bun'; + +import pkg from '../package.json'; + +await $`rm -rf dist`; +const files = new Glob('./src/**/*.{ts,tsx}').scan(); +for await (const file of files) { + await Bun.build({ + format: 'esm', + outdir: 'dist/esm', + external: ['*'], + root: 'src', + entrypoints: [file] + }); +} +await Bun.build({ + format: 'esm', + outdir: 'dist/esm', + external: [...Object.keys(pkg.dependencies), ...Object.keys(pkg.peerDependencies)], + root: 'src', + entrypoints: ['./src/ui/base.tsx'] +}); +await $`tsc --outDir dist/types --declaration --emitDeclarationOnly --declarationMap`; diff --git a/packages/auth/src/client.ts b/packages/auth/src/client.ts new file mode 100644 index 00000000..4d32d29a --- /dev/null +++ b/packages/auth/src/client.ts @@ -0,0 +1,729 @@ +import type { v1 } from '@standard-schema/spec'; +/** + * Use the OpenAuth client kick off your OAuth flows, exchange tokens, refresh tokens, + * and verify tokens. + * + * First, create a client. + * + * ```ts title="client.ts" + * import { createClient } from "@openauthjs/openauth/client" + * + * const client = createClient({ + * clientID: "my-client", + * issuer: "https://auth.myserver.com" + * }) + * ``` + * + * Kick off the OAuth flow by calling `authorize`. + * + * ```ts + * const redirect_uri = "https://myserver.com/callback" + * + * const { url } = await client.authorize( + * redirect_uri, + * "code" + * ) + * ``` + * + * When the user completes the flow, `exchange` the code for tokens. + * + * ```ts + * const tokens = await client.exchange(query.get("code"), redirect_uri) + * ``` + * + * And `verify` the tokens. + * + * ```ts + * const verified = await client.verify(subjects, tokens.access) + * ``` + * + * @packageDocumentation + */ +import { createLocalJWKSet, errors, JSONWebKeySet, jwtVerify, decodeJwt } from 'jose'; + +import { + InvalidAccessTokenError, + InvalidAuthorizationCodeError, + InvalidRefreshTokenError, + InvalidSubjectError +} from './error.js'; +import { generatePKCE } from './pkce.js'; +import { SubjectSchema } from './subject.js'; + +/** + * The well-known information for an OAuth 2.0 authorization server. + * @internal + */ +export interface WellKnown { + /** + * The URI to the JWKS endpoint. + */ + jwks_uri: string; + /** + * The URI to the token endpoint. + */ + token_endpoint: string; + /** + * The URI to the authorization endpoint. + */ + authorization_endpoint: string; +} + +/** + * The tokens returned by the auth server. + */ +export interface Tokens { + /** + * The access token. + */ + access: string; + /** + * The refresh token. + */ + refresh: string; + + /** + * The number of seconds until the access token expires. + */ + expiresIn: number; +} + +interface ResponseLike { + json(): Promise; + ok: Response['ok']; +} +type FetchLike = (...args: any[]) => Promise; + +/** + * The challenge that you can use to verify the code. + */ +export type Challenge = { + /** + * The state that was sent to the redirect URI. + */ + state: string; + /** + * The verifier that was sent to the redirect URI. + */ + verifier?: string; +}; + +/** + * Configure the client. + */ +export interface ClientInput { + /** + * The client ID. This is just a string to identify your app. + * + * If you have a web app and a mobile app, you want to use different client IDs both. + * + * @example + * ```ts + * { + * clientID: "my-client" + * } + * ``` + */ + clientID: string; + /** + * The URL of your OpenAuth server. + * + * @example + * ```ts + * { + * issuer: "https://auth.myserver.com" + * } + * ``` + */ + issuer?: string; + /** + * Optionally, override the internally used fetch function. + * + * This is useful if you are using a polyfilled fetch function in your application and you + * want the client to use it too. + */ + fetch?: FetchLike; +} + +export interface AuthorizeOptions { + /** + * Enable the PKCE flow. This is for SPA apps. + * + * ```ts + * { + * pkce: true + * } + * ``` + * + * @default false + */ + pkce?: boolean; + /** + * The provider you want to use for the OAuth flow. + * + * ```ts + * { + * provider: "google" + * } + * ``` + * + * If no provider is specified, the user is directed to a page where they can select from the + * list of configured providers. + * + * If there's only one provider configured, the user will be redirected to that. + */ + provider?: string; +} + +export interface AuthorizeResult { + /** + * The challenge that you can use to verify the code. This is for the PKCE flow for SPA apps. + * + * This is an object that you _stringify_ and store it in session storage. + * + * ```ts + * sessionStorage.setItem("challenge", JSON.stringify(challenge)) + * ``` + */ + challenge: Challenge; + /** + * The URL to redirect the user to. This starts the OAuth flow. + * + * For example, for SPA apps. + * + * ```ts + * location.href = url + * ``` + */ + url: string; +} + +/** + * Returned when the exchange is successful. + */ +export interface ExchangeSuccess { + /** + * This is always `false` when the exchange is successful. + */ + err: false; + /** + * The access and refresh tokens. + */ + tokens: Tokens; +} + +/** + * Returned when the exchange fails. + */ +export interface ExchangeError { + /** + * The type of error that occurred. You can handle this by checking the type. + * + * @example + * ```ts + * import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error" + * + * console.log(err instanceof InvalidAuthorizationCodeError) + *``` + */ + err: InvalidAuthorizationCodeError; +} + +export interface RefreshOptions { + /** + * Optionally, pass in the access token. + */ + access?: string; +} + +/** + * Returned when the refresh is successful. + */ +export interface RefreshSuccess { + /** + * This is always `false` when the refresh is successful. + */ + err: false; + /** + * Returns the refreshed tokens only if they've been refreshed. + * + * If they are still valid, this will be `undefined`. + */ + tokens?: Tokens; +} + +/** + * Returned when the refresh fails. + */ +export interface RefreshError { + /** + * The type of error that occurred. You can handle this by checking the type. + * + * @example + * ```ts + * import { InvalidRefreshTokenError } from "@openauthjs/openauth/error" + * + * console.log(err instanceof InvalidRefreshTokenError) + *``` + */ + err: InvalidRefreshTokenError | InvalidAccessTokenError; +} + +export interface VerifyOptions { + /** + * Optionally, pass in the refresh token. + * + * If passed in, this will automatically refresh the access token if it has expired. + */ + refresh?: string; + /** + * @internal + */ + issuer?: string; + /** + * @internal + */ + audience?: string; + /** + * Optionally, override the internally used fetch function. + * + * This is useful if you are using a polyfilled fetch function in your application and you + * want the client to use it too. + */ + fetch?: FetchLike; +} + +export interface VerifyResult { + /** + * This is always `undefined` when the verify is successful. + */ + err?: undefined; + /** + * Returns the refreshed tokens only if they’ve been refreshed. + * + * If they are still valid, this will be undefined. + */ + tokens?: Tokens; + /** + * @internal + */ + aud: string; + /** + * The decoded subjects from the access token. + * + * Has the same shape as the subjects you defined when creating the issuer. + */ + subject: { + [type in keyof T]: { type: type; properties: v1.InferOutput }; + }[keyof T]; +} + +/** + * Returned when the verify call fails. + */ +export interface VerifyError { + /** + * The type of error that occurred. You can handle this by checking the type. + * + * @example + * ```ts + * import { InvalidRefreshTokenError } from "@openauthjs/openauth/error" + * + * console.log(err instanceof InvalidRefreshTokenError) + *``` + */ + err: InvalidRefreshTokenError | InvalidAccessTokenError; +} + +/** + * An instance of the OpenAuth client contains the following methods. + */ +export interface Client { + /** + * Start the autorization flow. For example, in SSR sites. + * + * ```ts + * const { url } = await client.authorize(, "code") + * ``` + * + * This takes a redirect URI and the type of flow you want to use. The redirect URI is the + * location where the user will be redirected to after the flow is complete. + * + * Supports both the _code_ and _token_ flows. We recommend using the _code_ flow as it's more + * secure. + * + * :::tip + * This returns a URL to redirect the user to. This starts the OAuth flow. + * ::: + * + * This returns a URL to the auth server. You can redirect the user to the URL to start the + * OAuth flow. + * + * For SPA apps, we recommend using the PKCE flow. + * + * ```ts {4} + * const { challenge, url } = await client.authorize( + * , + * "code", + * { pkce: true } + * ) + * ``` + * + * This returns a redirect URL and a challenge that you need to use later to verify the code. + */ + authorize( + redirectURI: string, + response: 'code' | 'token', + opts?: AuthorizeOptions + ): Promise; + /** + * Exchange the code for access and refresh tokens. + * + * ```ts + * const exchanged = await client.exchange(, ) + * ``` + * + * You call this after the user has been redirected back to your app after the OAuth flow. + * + * :::tip + * For SSR sites, the code is returned in the query parameter. + * ::: + * + * So the code comes from the query parameter in the redirect URI. The redirect URI here is + * the one that you passed in to the `authorize` call when starting the flow. + * + * :::tip + * For SPA sites, the code is returned through the URL hash. + * ::: + * + * If you used the PKCE flow for an SPA app, the code is returned as a part of the redirect URL + * hash. + * + * ```ts {4} + * const exchanged = await client.exchange( + * , + * , + * + * ) + * ``` + * + * You also need to pass in the previously stored challenge verifier. + * + * This method returns the access and refresh tokens. Or if it fails, it returns an error that + * you can handle depending on the error. + * + * ```ts + * import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error" + * + * if (exchanged.err) { + * if (exchanged.err instanceof InvalidAuthorizationCodeError) { + * // handle invalid code error + * } + * else { + * // handle other errors + * } + * } + * + * const { access, refresh } = exchanged.tokens + * ``` + */ + exchange( + code: string, + redirectURI: string, + verifier?: string + ): Promise; + /** + * Refreshes the tokens if they have expired. This is used in an SPA app to maintain the + * session, without logging the user out. + * + * ```ts + * const next = await client.refresh() + * ``` + * + * Can optionally take the access token as well. If passed in, this will skip the refresh + * if the access token is still valid. + * + * ```ts + * const next = await client.refresh(, { access: }) + * ``` + * + * This returns the refreshed tokens only if they've been refreshed. + * + * ```ts + * if (!next.err) { + * // tokens are still valid + * } + * if (next.tokens) { + * const { access, refresh } = next.tokens + * } + * ``` + * + * Or if it fails, it returns an error that you can handle depending on the error. + * + * ```ts + * import { InvalidRefreshTokenError } from "@openauthjs/openauth/error" + * + * if (next.err) { + * if (next.err instanceof InvalidRefreshTokenError) { + * // handle invalid refresh token error + * } + * else { + * // handle other errors + * } + * } + * ``` + */ + refresh(refresh: string, opts?: RefreshOptions): Promise; + /** + * Verify the token in the incoming request. + * + * This is typically used for SSR sites where the token is stored in an HTTP only cookie. And + * is passed to the server on every request. + * + * ```ts + * const verified = await client.verify(, ) + * ``` + * + * This takes the subjects that you had previously defined when creating the issuer. + * + * :::tip + * If the refresh token is passed in, it'll automatically refresh the access token. + * ::: + * + * This can optionally take the refresh token as well. If passed in, it'll automatically + * refresh the access token if it has expired. + * + * ```ts + * const verified = await client.verify(, , { refresh: }) + * ``` + * + * This returns the decoded subjects from the access token. And the tokens if they've been + * refreshed. + * + * ```ts + * // based on the subjects you defined earlier + * console.log(verified.subject.properties.userID) + * + * if (verified.tokens) { + * const { access, refresh } = verified.tokens + * } + * ``` + * + * Or if it fails, it returns an error that you can handle depending on the error. + * + * ```ts + * import { InvalidRefreshTokenError } from "@openauthjs/openauth/error" + * + * if (verified.err) { + * if (verified.err instanceof InvalidRefreshTokenError) { + * // handle invalid refresh token error + * } + * else { + * // handle other errors + * } + * } + * ``` + */ + verify( + subjects: T, + token: string, + options?: VerifyOptions + ): Promise | VerifyError>; +} + +/** + * Create an OpenAuth client. + * + * @param input - Configure the client. + */ +export function createClient(input: ClientInput): Client { + const jwksCache = new Map>(); + const issuerCache = new Map(); + const issuer = input.issuer || process.env.OPENAUTH_ISSUER; + if (!issuer) throw new Error('No issuer'); + const f = input.fetch ?? fetch; + + async function getIssuer() { + const cached = issuerCache.get(issuer!); + if (cached) return cached; + const wellKnown = (await (f || fetch)(`${issuer}/.well-known/oauth-authorization-server`).then( + (r) => r.json() + )) as WellKnown; + issuerCache.set(issuer!, wellKnown); + return wellKnown; + } + + async function getJWKS() { + const wk = await getIssuer(); + const cached = jwksCache.get(issuer!); + if (cached) return cached; + const keyset = (await (f || fetch)(wk.jwks_uri).then((r) => r.json())) as JSONWebKeySet; + const result = createLocalJWKSet(keyset); + jwksCache.set(issuer!, result); + return result; + } + + const result = { + async authorize(redirectURI: string, response: 'code' | 'token', opts?: AuthorizeOptions) { + const result = new URL(issuer + '/authorize'); + const challenge: Challenge = { + state: crypto.randomUUID() + }; + result.searchParams.set('client_id', input.clientID); + result.searchParams.set('redirect_uri', redirectURI); + result.searchParams.set('response_type', response); + result.searchParams.set('state', challenge.state); + if (opts?.provider) result.searchParams.set('provider', opts.provider); + if (opts?.pkce && response === 'code') { + const pkce = await generatePKCE(); + result.searchParams.set('code_challenge_method', 'S256'); + result.searchParams.set('code_challenge', pkce.challenge); + challenge.verifier = pkce.verifier; + } + return { + challenge, + url: result.toString() + }; + }, + /** + * @deprecated use `authorize` instead, it will do pkce by default unless disabled with `opts.pkce = false` + */ + async pkce( + redirectURI: string, + opts?: { + provider?: string; + } + ) { + const result = new URL(issuer + '/authorize'); + if (opts?.provider) result.searchParams.set('provider', opts.provider); + result.searchParams.set('client_id', input.clientID); + result.searchParams.set('redirect_uri', redirectURI); + result.searchParams.set('response_type', 'code'); + const pkce = await generatePKCE(); + result.searchParams.set('code_challenge_method', 'S256'); + result.searchParams.set('code_challenge', pkce.challenge); + return [pkce.verifier, result.toString()]; + }, + async exchange( + code: string, + redirectURI: string, + verifier?: string + ): Promise { + const tokens = await f(issuer + '/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + code, + redirect_uri: redirectURI, + grant_type: 'authorization_code', + client_id: input.clientID, + code_verifier: verifier || '' + }).toString() + }); + const json = (await tokens.json()) as any; + if (!tokens.ok) { + return { + err: new InvalidAuthorizationCodeError() + }; + } + return { + err: false, + tokens: { + access: json.access_token as string, + refresh: json.refresh_token as string, + expiresIn: json.expires_in as number + } + }; + }, + async refresh(refresh: string, opts?: RefreshOptions): Promise { + if (opts && opts.access) { + const decoded = decodeJwt(opts.access); + if (!decoded) { + return { + err: new InvalidAccessTokenError() + }; + } + // allow 30s window for expiration + if ((decoded.exp || 0) > Date.now() / 1000 + 30) { + return { + err: false + }; + } + } + const tokens = await f(issuer + '/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refresh + }).toString() + }); + const json = (await tokens.json()) as any; + if (!tokens.ok) { + return { + err: new InvalidRefreshTokenError() + }; + } + return { + err: false, + tokens: { + access: json.access_token as string, + refresh: json.refresh_token as string, + expiresIn: json.expires_in as number + } + }; + }, + async verify( + subjects: T, + token: string, + options?: VerifyOptions + ): Promise | VerifyError> { + const jwks = await getJWKS(); + try { + const result = await jwtVerify<{ + mode: 'access'; + type: keyof T; + properties: v1.InferInput; + }>(token, jwks, { + issuer + }); + const validated = await subjects[result.payload.type]['~standard'].validate( + result.payload.properties + ); + if (!validated.issues && result.payload.mode === 'access') { + return { + aud: result.payload.aud as string, + subject: { + type: result.payload.type, + properties: validated.value + } as any + }; + } + return { + err: new InvalidSubjectError() + }; + } catch (e) { + if (e instanceof errors.JWTExpired && options?.refresh) { + const refreshed = await this.refresh(options.refresh); + if (refreshed.err) return refreshed; + const verified = await result.verify(subjects, refreshed.tokens!.access, { + refresh: refreshed.tokens!.refresh, + issuer, + fetch: options?.fetch + }); + if (verified.err) return verified; + verified.tokens = refreshed.tokens; + return verified; + } + return { + err: new InvalidAccessTokenError() + }; + } + } + }; + return result; +} diff --git a/packages/auth/src/error.ts b/packages/auth/src/error.ts new file mode 100644 index 00000000..e00d080f --- /dev/null +++ b/packages/auth/src/error.ts @@ -0,0 +1,120 @@ +/** + * A list of errors that can be thrown by OpenAuth. + * + * You can use these errors to check the type of error and handle it. For example. + * + * ```ts + * import { InvalidAuthorizationCodeError } from "@openauthjs/openauth/error" + * + * if (err instanceof InvalidAuthorizationCodeError) { + * // handle invalid code error + * } + * ``` + * + * @packageDocumentation + */ + +/** + * The OAuth server returned an error. + */ +export class OauthError extends Error { + constructor( + public error: + | 'invalid_request' + | 'invalid_grant' + | 'unauthorized_client' + | 'access_denied' + | 'unsupported_grant_type' + | 'server_error' + | 'temporarily_unavailable', + public description: string + ) { + super(error + ' - ' + description); + } +} + +/** + * The `provider` needs to be passed in. + */ +export class MissingProviderError extends OauthError { + constructor() { + super( + 'invalid_request', + 'Must specify `provider` query parameter if `select` callback on issuer is not specified' + ); + } +} + +/** + * The given parameter is missing. + */ +export class MissingParameterError extends OauthError { + constructor(public parameter: string) { + super('invalid_request', 'Missing parameter: ' + parameter); + } +} + +/** + * The given client is not authorized to use the redirect URI that was passed in. + */ +export class UnauthorizedClientError extends OauthError { + constructor( + public clientID: string, + redirectURI: string + ) { + super( + 'unauthorized_client', + `Client ${clientID} is not authorized to use this redirect_uri: ${redirectURI}` + ); + } +} + +/** + * The browser was in an unknown state. + * + * This can happen when certain cookies have expired. Or the browser was switched in the middle + * of the authentication flow. + */ +export class UnknownStateError extends Error { + constructor() { + super( + 'The browser was in an unknown state. This could be because certain cookies expired or the browser was switched in the middle of an authentication flow.' + ); + } +} + +/** + * The given subject is invalid. + */ +export class InvalidSubjectError extends Error { + constructor() { + super('Invalid subject'); + } +} + +/** + * The given refresh token is invalid. + */ +export class InvalidRefreshTokenError extends Error { + constructor() { + super('Invalid refresh token'); + } +} + +/** + * The given access token is invalid. + */ +export class InvalidAccessTokenError extends Error { + constructor() { + super('Invalid access token'); + } +} + +/** + * The given authorization code is invalid. + */ +export class InvalidAuthorizationCodeError extends Error { + constructor() { + super('Invalid authorization code'); + } +} diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts new file mode 100644 index 00000000..97db929c --- /dev/null +++ b/packages/auth/src/index.ts @@ -0,0 +1,26 @@ +export { + /** + * @deprecated + * Use `import { createClient } from "@openauthjs/openauth/client"` instead - it will tree shake better + */ + createClient +} from './client.js'; + +export { + /** + * @deprecated + * Use `import { createSubjects } from "@openauthjs/openauth/subject"` instead - it will tree shake better + */ + createSubjects +} from './subject.js'; + +import { issuer } from './issuer.js'; + +export { + /** + * @deprecated + * Use `import { issuer } from "@openauthjs/openauth"` instead, it was renamed + */ + issuer as authorizer, + issuer +}; diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts new file mode 100644 index 00000000..ebc421ea --- /dev/null +++ b/packages/auth/src/issuer.ts @@ -0,0 +1,1136 @@ +import type { v1 } from '@standard-schema/spec'; +import { Context } from 'hono'; +import { handle as awsHandle } from 'hono/aws-lambda'; +import { deleteCookie, getCookie, setCookie } from 'hono/cookie'; +import { Hono } from 'hono/tiny'; + +/** + * The `issuer` create an OpentAuth server, a [Hono](https://hono.dev) app that's + * designed to run anywhere. + * + * The `issuer` function requires a few things: + * + * ```ts title="issuer.ts" + * import { issuer } from "@openauthjs/openauth" + * + * const app = issuer({ + * providers: { ... }, + * storage, + * subjects, + * success: async (ctx, value) => { ... } + * }) + * ``` + * + * #### Add providers + * + * You start by specifying the auth providers you are going to use. Let's say you want your users + * to be able to authenticate with GitHub and with their email and password. + * + * ```ts title="issuer.ts" + * import { GithubProvider } from "@openauthjs/openauth/provider/github" + * import { PasswordProvider } from "@openauthjs/openauth/provider/password" + * + * const app = issuer({ + * providers: { + * github: GithubProvider({ + * // ... + * }), + * password: PasswordProvider({ + * // ... + * }), + * }, + * }) + * ``` + * + * #### Handle success + * + * The `success` callback receives the payload when a user completes a provider's auth flow. + * + * ```ts title="issuer.ts" + * const app = issuer({ + * providers: { ... }, + * subjects, + * async success(ctx, value) { + * let userID + * if (value.provider === "password") { + * console.log(value.email) + * userID = ... // lookup user or create them + * } + * if (value.provider === "github") { + * console.log(value.tokenset.access) + * userID = ... // lookup user or create them + * } + * return ctx.subject("user", { + * userID + * }) + * } + * }) + * ``` + * + * Once complete, the `issuer` issues the access tokens that a client can use. The `ctx.subject` + * call is what is placed in the access token as a JWT. + * + * #### Define subjects + * + * You define the shape of these in the `subjects` field. + * + * ```ts title="subjects.ts" + * import { object, string } from "valibot" + * import { createSubjects } from "@openauthjs/openauth/subject" + * + * const subjects = createSubjects({ + * user: object({ + * userID: string() + * }) + * }) + * ``` + * + * It's good to place this in a separate file since this'll be used in your client apps as well. + * + * ```ts title="issuer.ts" + * import { subjects } from "./subjects.js" + * + * const app = issuer({ + * providers: { ... }, + * subjects, + * // ... + * }) + * ``` + * + * #### Deploy + * + * Since `issuer` is a Hono app, you can deploy it anywhere Hono supports. + * + * + * + * ```ts title="issuer.ts" + * import { serve } from "@hono/node-server" + * + * serve(app) + * ``` + * + * + * ```ts title="issuer.ts" + * import { handle } from "hono/aws-lambda" + * + * export const handler = handle(app) + * ``` + * + * + * ```ts title="issuer.ts" + * export default app + * ``` + * + * + * ```ts title="issuer.ts" + * export default app + * ``` + * + * + * + * @packageDocumentation + */ +import { Provider, ProviderOptions } from './provider/provider.js'; +import { SubjectPayload, SubjectSchema } from './subject.js'; + +/** + * Sets the subject payload in the JWT token and returns the response. + * + * ```ts + * ctx.subject("user", { + * userID + * }) + * ``` + */ +export interface OnSuccessResponder { + /** + * The `type` is the type of the subject, that was defined in the `subjects` field. + * + * The `properties` are the properties of the subject. This is the shape of the subject that + * you defined in the `subjects` field. + */ + subject( + type: Type, + properties: Extract['properties'], + opts?: { + ttl?: { + access?: number; + refresh?: number; + }; + subject?: string; + } + ): Promise; +} + +/** + * @internal + */ +export interface AuthorizationState { + redirect_uri: string; + response_type: string; + state: string; + client_id: string; + audience?: string; + pkce?: { + challenge: string; + method: 'S256'; + }; +} + +/** + * @internal + */ +export type Prettify = { + [K in keyof T]: T[K]; +} & {}; + +import { cors } from 'hono/cors'; +import { logger } from 'hono/logger'; +import { compactDecrypt, CompactEncrypt, jwtVerify, SignJWT } from 'jose'; + +import { + MissingParameterError, + OauthError, + UnauthorizedClientError, + UnknownStateError +} from './error.js'; +import { encryptionKeys, legacySigningKeys, signingKeys } from './keys.js'; +import { validatePKCE } from './pkce.js'; +import { DynamoStorage } from './storage/dynamo.js'; +import { MemoryStorage } from './storage/memory.js'; +import { Storage, StorageAdapter } from './storage/storage.js'; +import { Select } from './ui/select.js'; +import { setTheme, Theme } from './ui/theme.js'; +import { getRelativeUrl, isDomainMatch, lazy } from './util.js'; + +/** @internal */ +export const aws = awsHandle; + +export interface IssuerInput< + Providers extends Record>, + Subjects extends SubjectSchema, + Result = { + [key in keyof Providers]: Prettify< + { + provider: key; + } & (Providers[key] extends Provider ? T : {}) + >; + }[keyof Providers] +> { + /** + * The shape of the subjects that you want to return. + * + * @example + * + * ```ts title="issuer.ts" + * import { object, string } from "valibot" + * import { createSubjects } from "@openauthjs/openauth/subject" + * + * issuer({ + * subjects: createSubjects({ + * user: object({ + * userID: string() + * }) + * }) + * // ... + * }) + * ``` + */ + subjects: Subjects; + /** + * The storage adapter that you want to use. + * + * @example + * ```ts title="issuer.ts" + * import { DynamoStorage } from "@openauthjs/openauth/storage/dynamo" + * + * issuer({ + * storage: DynamoStorage() + * // ... + * }) + * ``` + */ + storage?: StorageAdapter; + /** + * The providers that you want your OpenAuth server to support. + * + * @example + * + * ```ts title="issuer.ts" + * import { GithubProvider } from "@openauthjs/openauth/provider/github" + * + * issuer({ + * providers: { + * github: GithubProvider() + * } + * }) + * ``` + * + * The key is just a string that you can use to identify the provider. It's passed back to + * the `success` callback. + * + * You can also specify multiple providers. + * + * ```ts + * { + * providers: { + * github: GithubProvider(), + * google: GoogleProvider() + * } + * } + * ``` + */ + providers: Providers; + /** + * The theme you want to use for the UI. + * + * This includes the UI the user sees when selecting a provider. And the `PasswordUI` and + * `CodeUI` that are used by the `PasswordProvider` and `CodeProvider`. + * + * @example + * ```ts title="issuer.ts" + * import { THEME_SST } from "@openauthjs/openauth/ui/theme" + * + * issuer({ + * theme: THEME_SST + * // ... + * }) + * ``` + * + * Or define your own. + * + * ```ts title="issuer.ts" + * import type { Theme } from "@openauthjs/openauth/ui/theme" + * + * const MY_THEME: Theme = { + * // ... + * } + * + * issuer({ + * theme: MY_THEME + * // ... + * }) + * ``` + */ + theme?: Theme; + /** + * Set the TTL, in seconds, for access and refresh tokens. + * + * @example + * ```ts + * { + * ttl: { + * access: 60 * 60 * 24 * 30, + * refresh: 60 * 60 * 24 * 365 + * } + * } + * ``` + */ + ttl?: { + /** + * Interval in seconds where the access token is valid. + * @default 30d + */ + access?: number; + /** + * Interval in seconds where the refresh token is valid. + * @default 1y + */ + refresh?: number; + /** + * Interval in seconds where refresh token reuse is allowed. This helps mitigrate + * concurrency issues. + * @default 60s + */ + reuse?: number; + /** + * Interval in seconds to retain refresh tokens for reuse detection. + * @default 0s + */ + retention?: number; + }; + /** + * Optionally, configure the UI that's displayed when the user visits the root URL of the + * of the OpenAuth server. + * + * ```ts title="issuer.ts" + * import { Select } from "@openauthjs/openauth/ui/select" + * + * issuer({ + * select: Select({ + * providers: { + * github: { hide: true }, + * google: { display: "Google" } + * } + * }) + * // ... + * }) + * ``` + * + * @default Select() + */ + select?(providers: Record, req: Request): Promise; + /** + * @internal + */ + start?(req: Request): Promise; + /** + * The success callback that's called when the user completes the flow. + * + * This is called after the user has been redirected back to your app after the OAuth flow. + * + * @example + * ```ts + * { + * success: async (ctx, value) => { + * let userID + * if (value.provider === "password") { + * console.log(value.email) + * userID = ... // lookup user or create them + * } + * if (value.provider === "github") { + * console.log(value.tokenset.access) + * userID = ... // lookup user or create them + * } + * return ctx.subject("user", { + * userID + * }) + * }, + * // ... + * } + * ``` + */ + success( + response: OnSuccessResponder>, + input: Result, + req: Request + ): Promise; + /** + * @internal + */ + error?(error: UnknownStateError, req: Request): Promise; + /** + * Override the logic for whether a client request is allowed to call the issuer. + * + * By default, it uses the following: + * + * - Allow if the `redirectURI` is localhost. + * - Compare `redirectURI` to the request's hostname or the `x-forwarded-host` header. If they + * are from the same sub-domain level, then allow. + * + * @example + * ```ts + * { + * allow: async (input, req) => { + * // Allow all clients + * return true + * } + * } + * ``` + */ + allow?( + input: { + clientID: string; + redirectURI: string; + audience?: string; + }, + req: Request + ): Promise; +} + +/** + * Create an OpenAuth server, a Hono app. + */ +export function issuer< + Providers extends Record>, + Subjects extends SubjectSchema, + Result = { + [key in keyof Providers]: Prettify< + { + provider: key; + } & (Providers[key] extends Provider ? T : {}) + >; + }[keyof Providers] +>(input: IssuerInput) { + const error = + input.error ?? + function (err) { + return new Response(err.message, { + status: 400, + headers: { + 'Content-Type': 'text/plain' + } + }); + }; + const ttlAccess = input.ttl?.access ?? 60 * 60 * 24 * 30; + const ttlRefresh = input.ttl?.refresh ?? 60 * 60 * 24 * 365; + const ttlRefreshReuse = input.ttl?.reuse ?? 60; + const ttlRefreshRetention = input.ttl?.retention ?? 0; + if (input.theme) { + setTheme(input.theme); + } + + const select = lazy(() => input.select ?? Select()); + const allow = lazy( + () => + input.allow ?? + (async (input: any, req: Request) => { + const redir = new URL(input.redirectURI).hostname; + if (redir === 'localhost' || redir === '127.0.0.1') { + return true; + } + const forwarded = req.headers.get('x-forwarded-host'); + const host = forwarded + ? new URL(`https://${forwarded}`).hostname + : new URL(req.url).hostname; + + return isDomainMatch(redir, host); + }) + ); + + let storage = input.storage; + if (process.env.OPENAUTH_STORAGE) { + const parsed = JSON.parse(process.env.OPENAUTH_STORAGE); + if (parsed.type === 'dynamo') storage = DynamoStorage(parsed.options); + if (parsed.type === 'memory') storage = MemoryStorage(); + if (parsed.type === 'cloudflare') + throw new Error( + 'Cloudflare storage cannot be configured through env because it requires bindings.' + ); + } + if (!storage) + throw new Error( + 'Store is not configured. Either set the `storage` option or set `OPENAUTH_STORAGE` environment variable.' + ); + const allSigning = lazy(() => + Promise.all([signingKeys(storage), legacySigningKeys(storage)]).then(([a, b]) => [...a, ...b]) + ); + const allEncryption = lazy(() => encryptionKeys(storage)); + const signingKey = lazy(() => allSigning().then((all) => all[0])); + const encryptionKey = lazy(() => allEncryption().then((all) => all[0])); + + const auth: Omit, 'name'> = { + async success(ctx: Context, properties: any, successOpts) { + return await input.success( + { + async subject(type, properties, subjectOpts) { + let authorization: AuthorizationState | null = null; + try { + authorization = await getAuthorization(ctx); + } catch (e) { + if (!(e instanceof UnknownStateError)) throw e; + // Non-browser provider (SSH, etc.) — no OAuth state; issue tokens directly. + } + const subject = subjectOpts?.subject + ? subjectOpts.subject + : await resolveSubject(type, properties); + await successOpts?.invalidate?.(await resolveSubject(type, properties)); + if (authorization) { + if (authorization.response_type === 'token') { + const location = new URL(authorization.redirect_uri); + const tokens = await generateTokens(ctx, { + subject, + type: type as string, + properties, + clientID: authorization.client_id, + ttl: { + access: subjectOpts?.ttl?.access ?? ttlAccess, + refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh + } + }); + location.hash = new URLSearchParams({ + access_token: tokens.access, + refresh_token: tokens.refresh, + state: authorization.state || '' + }).toString(); + await auth.unset(ctx, 'authorization'); + return ctx.redirect(location.toString(), 302); + } + if (authorization.response_type === 'code') { + const code = crypto.randomUUID(); + await Storage.set( + storage, + ['oauth:code', code], + { + type, + properties, + subject, + redirectURI: authorization.redirect_uri, + clientID: authorization.client_id, + pkce: authorization.pkce, + ttl: { + access: subjectOpts?.ttl?.access ?? ttlAccess, + refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh + } + }, + 60 + ); + const location = new URL(authorization.redirect_uri); + location.searchParams.set('code', code); + location.searchParams.set('state', authorization.state || ''); + await auth.unset(ctx, 'authorization'); + return ctx.redirect(location.toString(), 302); + } + throw new OauthError( + 'invalid_request', + `Unsupported response_type: ${authorization.response_type}` + ); + } + // Non-browser provider — return tokens as JSON directly. + const tokens = await generateTokens(ctx, { + subject, + type: type as string, + properties, + clientID: 'ssh', + ttl: { + access: subjectOpts?.ttl?.access ?? ttlAccess, + refresh: subjectOpts?.ttl?.refresh ?? ttlRefresh + } + }); + return ctx.json({ + accessToken: tokens.access, + refreshToken: tokens.refresh, + expiresIn: tokens.expiresIn + }); + } + }, + { + provider: ctx.get('provider'), + ...properties + }, + ctx.req.raw + ); + }, + forward(ctx, response) { + return ctx.newResponse( + response.body, + response.status as any, + Object.fromEntries(response.headers.entries()) + ); + }, + async set(ctx, key, maxAge, value) { + setCookie(ctx, key, await encrypt(value), { + maxAge, + httpOnly: true, + ...(ctx.req.url.startsWith('https://') ? { secure: true, sameSite: 'None' } : {}) + }); + }, + async get(ctx: Context, key: string) { + const raw = getCookie(ctx, key); + if (!raw) return; + return decrypt(raw).catch((ex) => { + console.error('failed to decrypt', key, ex); + }); + }, + async unset(ctx: Context, key: string) { + deleteCookie(ctx, key); + }, + async invalidate(subject: string) { + // Resolve the scan in case modifications interfere with iteration + const keys = await Array.fromAsync(Storage.scan(this.storage, ['oauth:refresh', subject])); + for (const [key] of keys) { + await Storage.remove(this.storage, key); + } + }, + storage + }; + + async function getAuthorization(ctx: Context) { + const match = (await auth.get(ctx, 'authorization')) || ctx.get('authorization'); + if (!match) throw new UnknownStateError(); + return match as AuthorizationState; + } + + async function encrypt(value: any) { + return await new CompactEncrypt(new TextEncoder().encode(JSON.stringify(value))) + .setProtectedHeader({ alg: 'RSA-OAEP-512', enc: 'A256GCM' }) + .encrypt(await encryptionKey().then((k) => k.public)); + } + + async function resolveSubject(type: string, properties: any) { + const jsonString = JSON.stringify(properties); + const encoder = new TextEncoder(); + const data = encoder.encode(jsonString); + const hashBuffer = await crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + return `${type}:${hashHex.slice(0, 16)}`; + } + + async function generateTokens( + ctx: Context, + value: { + type: string; + properties: any; + subject: string; + clientID: string; + ttl: { + access: number; + refresh: number; + }; + timeUsed?: number; + nextToken?: string; + }, + opts?: { + generateRefreshToken?: boolean; + } + ) { + const refreshToken = value.nextToken ?? crypto.randomUUID(); + if (opts?.generateRefreshToken ?? true) { + /** + * Generate and store the next refresh token after the one we are currently returning. + * Reserving these in advance avoids concurrency issues with multiple refreshes. + * Similar treatment should be given to any other values that may have race conditions, + * for example if a jti claim was added to the access token. + */ + const refreshValue = { + ...value, + nextToken: crypto.randomUUID() + }; + delete refreshValue.timeUsed; + await Storage.set( + storage!, + ['oauth:refresh', value.subject, refreshToken], + refreshValue, + value.ttl.refresh + ); + } + const accessTimeUsed = Math.floor((value.timeUsed ?? Date.now()) / 1000); + return { + access: await new SignJWT({ + mode: 'access', + type: value.type, + properties: value.properties, + aud: value.clientID, + iss: issuer(ctx), + sub: value.subject + }) + .setExpirationTime(Math.floor(accessTimeUsed + value.ttl.access)) + .setProtectedHeader( + await signingKey().then((k) => ({ + alg: k.alg, + kid: k.id, + typ: 'JWT' + })) + ) + .sign(await signingKey().then((item) => item.private)), + expiresIn: Math.floor(accessTimeUsed + value.ttl.access - Date.now() / 1000), + refresh: [value.subject, refreshToken].join(':') + }; + } + + async function decrypt(value: string) { + return JSON.parse( + new TextDecoder().decode( + await compactDecrypt(value, await encryptionKey().then((v) => v.private)).then( + (value) => value.plaintext + ) + ) + ); + } + + function issuer(ctx: Context) { + return new URL(getRelativeUrl(ctx, '/')).origin; + } + + const app = new Hono<{ + Variables: { + authorization: AuthorizationState; + }; + }>().use(logger()); + + for (const [name, value] of Object.entries(input.providers)) { + const route = new Hono(); + route.use(async (c, next) => { + c.set('provider', name); + await next(); + }); + value.init(route, { + name, + ...auth + }); + app.route(`/${name}`, route); + } + + app.get( + '/.well-known/jwks.json', + cors({ + origin: '*', + allowHeaders: ['*'], + allowMethods: ['GET'], + credentials: false + }), + async (c) => { + const all = await allSigning(); + return c.json({ + keys: all.map((item) => ({ + ...item.jwk, + alg: item.alg, + exp: item.expired ? Math.floor(item.expired.getTime() / 1000) : undefined + })) + }); + } + ); + + app.get( + '/.well-known/oauth-authorization-server', + cors({ + origin: '*', + allowHeaders: ['*'], + allowMethods: ['GET'], + credentials: false + }), + async (c) => { + const iss = issuer(c); + return c.json({ + issuer: iss, + authorization_endpoint: `${iss}/authorize`, + token_endpoint: `${iss}/token`, + jwks_uri: `${iss}/.well-known/jwks.json`, + response_types_supported: ['code', 'token'] + }); + } + ); + + app.post( + '/token', + cors({ + origin: '*', + allowHeaders: ['*'], + allowMethods: ['POST'], + credentials: false + }), + async (c) => { + const form = await c.req.formData(); + const grantType = form.get('grant_type'); + + if (grantType === 'authorization_code') { + const code = form.get('code'); + if (!code) + return c.json( + { + error: 'invalid_request', + error_description: 'Missing code' + }, + 400 + ); + const key = ['oauth:code', code.toString()]; + const payload = await Storage.get<{ + type: string; + properties: any; + clientID: string; + redirectURI: string; + subject: string; + ttl: { + access: number; + refresh: number; + }; + pkce?: AuthorizationState['pkce']; + }>(storage, key); + if (!payload) { + return c.json( + { + error: 'invalid_grant', + error_description: 'Authorization code has been used or expired' + }, + 400 + ); + } + if (payload.redirectURI !== form.get('redirect_uri')) { + return c.json( + { + error: 'invalid_redirect_uri', + error_description: 'Redirect URI mismatch' + }, + 400 + ); + } + if (payload.clientID !== form.get('client_id')) { + return c.json( + { + error: 'unauthorized_client', + error_description: 'Client is not authorized to use this authorization code' + }, + 403 + ); + } + + if (payload.pkce) { + const codeVerifier = form.get('code_verifier')?.toString(); + if (!codeVerifier) + return c.json( + { + error: 'invalid_grant', + error_description: 'Missing code_verifier' + }, + 400 + ); + + if (!(await validatePKCE(codeVerifier, payload.pkce.challenge, payload.pkce.method))) { + return c.json( + { + error: 'invalid_grant', + error_description: 'Code verifier does not match' + }, + 400 + ); + } + } + const tokens = await generateTokens(c, payload); + await Storage.remove(storage, key); + return c.json({ + access_token: tokens.access, + expires_in: tokens.expiresIn, + refresh_token: tokens.refresh + }); + } + + if (grantType === 'refresh_token') { + const refreshToken = form.get('refresh_token'); + if (!refreshToken) + return c.json( + { + error: 'invalid_request', + error_description: 'Missing refresh_token' + }, + 400 + ); + const splits = refreshToken.toString().split(':'); + const token = splits.pop()!; + const subject = splits.join(':'); + const key = ['oauth:refresh', subject, token]; + const payload = await Storage.get<{ + type: string; + properties: any; + clientID: string; + subject: string; + ttl: { + access: number; + refresh: number; + }; + nextToken: string; + timeUsed?: number; + }>(storage, key); + if (!payload) { + return c.json( + { + error: 'invalid_grant', + error_description: 'Refresh token has been used or expired' + }, + 400 + ); + } + const generateRefreshToken = !payload.timeUsed; + if (ttlRefreshReuse <= 0) { + // no reuse interval, remove the refresh token immediately + await Storage.remove(storage, key); + } else if (!payload.timeUsed) { + payload.timeUsed = Date.now(); + await Storage.set(storage, key, payload, ttlRefreshReuse + ttlRefreshRetention); + } else if (Date.now() > payload.timeUsed + ttlRefreshReuse * 1000) { + // token was reused past the allowed interval + await auth.invalidate(subject); + return c.json( + { + error: 'invalid_grant', + error_description: 'Refresh token has been used or expired' + }, + 400 + ); + } + const tokens = await generateTokens(c, payload, { + generateRefreshToken + }); + return c.json({ + access_token: tokens.access, + refresh_token: tokens.refresh, + expires_in: tokens.expiresIn + }); + } + + if (grantType === 'client_credentials') { + const provider = form.get('provider'); + if (!provider) return c.json({ error: 'missing `provider` form value' }, 400); + const match = input.providers[provider.toString()]; + if (!match) return c.json({ error: 'invalid `provider` query parameter' }, 400); + if (!match.client) + return c.json({ error: 'this provider does not support client_credentials' }, 400); + const clientID = form.get('client_id'); + const clientSecret = form.get('client_secret'); + if (!clientID) return c.json({ error: 'missing `client_id` form value' }, 400); + if (!clientSecret) return c.json({ error: 'missing `client_secret` form value' }, 400); + const response = await match.client({ + clientID: clientID.toString(), + clientSecret: clientSecret.toString(), + params: Object.fromEntries(form) as Record + }); + return input.success( + { + async subject(type, properties, opts) { + const tokens = await generateTokens(c, { + type: type as string, + subject: opts?.subject || (await resolveSubject(type, properties)), + properties, + clientID: clientID.toString(), + ttl: { + access: opts?.ttl?.access ?? ttlAccess, + refresh: opts?.ttl?.refresh ?? ttlRefresh + } + }); + return c.json({ + access_token: tokens.access, + refresh_token: tokens.refresh + }); + } + }, + { + provider: provider.toString(), + ...response + }, + c.req.raw + ); + } + + throw new Error('Invalid grant_type'); + } + ); + + app.get('/authorize', async (c) => { + const provider = c.req.query('provider'); + const response_type = c.req.query('response_type'); + const redirect_uri = c.req.query('redirect_uri'); + const state = c.req.query('state'); + const client_id = c.req.query('client_id'); + const audience = c.req.query('audience'); + const code_challenge = c.req.query('code_challenge'); + const code_challenge_method = c.req.query('code_challenge_method'); + const authorization: AuthorizationState = { + response_type, + redirect_uri, + state, + client_id, + audience, + pkce: + code_challenge && code_challenge_method + ? { + challenge: code_challenge, + method: code_challenge_method + } + : undefined + } as AuthorizationState; + c.set('authorization', authorization); + + if (!redirect_uri) { + return c.text('Missing redirect_uri', { status: 400 }); + } + + if (!response_type) { + throw new MissingParameterError('response_type'); + } + + if (!client_id) { + throw new MissingParameterError('client_id'); + } + + if (input.start) { + await input.start(c.req.raw); + } + + if ( + !(await allow()( + { + clientID: client_id, + redirectURI: redirect_uri, + audience + }, + c.req.raw + )) + ) + throw new UnauthorizedClientError(client_id, redirect_uri); + await auth.set(c, 'authorization', 60 * 60 * 24, authorization); + if (provider) return c.redirect(`/${provider}/authorize`); + const providers = Object.keys(input.providers); + if (providers.length === 1) return c.redirect(`/${providers[0]}/authorize`); + return auth.forward( + c, + await select()( + Object.fromEntries( + Object.entries(input.providers).map(([key, value]) => [key, value.type]) + ), + c.req.raw + ) + ); + }); + + app.get('/userinfo', async (c) => { + const header = c.req.header('Authorization'); + + if (!header) { + return c.json( + { + error: 'invalid_request', + error_description: 'Missing Authorization header' + }, + 400 + ); + } + + const [type, token] = header.split(' '); + + if (type !== 'Bearer') { + return c.json( + { + error: 'invalid_request', + error_description: 'Missing or invalid Authorization header' + }, + 400 + ); + } + + if (!token) { + return c.json( + { + error: 'invalid_request', + error_description: 'Missing token' + }, + 400 + ); + } + + const result = await jwtVerify<{ + mode: 'access'; + type: keyof SubjectSchema; + properties: v1.InferInput; + }>(token, () => signingKey().then((item) => item.public), { + issuer: issuer(c) + }); + + const validated = await input.subjects[result.payload.type]['~standard'].validate( + result.payload.properties + ); + + if (!validated.issues && result.payload.mode === 'access') { + return c.json(validated.value as SubjectSchema); + } + + return c.json({ + error: 'invalid_token', + error_description: 'Invalid token' + }); + }); + + app.onError(async (err, c) => { + console.error(err); + if (err instanceof UnknownStateError) { + return auth.forward(c, await error(err, c.req.raw)); + } + const authorization = await getAuthorization(c); + const url = new URL(authorization.redirect_uri); + const oauth = err instanceof OauthError ? err : new OauthError('server_error', err.message); + url.searchParams.set('error', oauth.error); + url.searchParams.set('error_description', oauth.description); + return c.redirect(url.toString()); + }); + + return app; +} diff --git a/packages/auth/src/jwt.ts b/packages/auth/src/jwt.ts new file mode 100644 index 00000000..279ff149 --- /dev/null +++ b/packages/auth/src/jwt.ts @@ -0,0 +1,13 @@ +import { JWTPayload, jwtVerify, KeyLike, SignJWT } from 'jose'; + +export namespace jwt { + export function create(payload: JWTPayload, algorithm: string, privateKey: KeyLike) { + return new SignJWT(payload) + .setProtectedHeader({ alg: algorithm, typ: 'JWT', kid: 'sst' }) + .sign(privateKey); + } + + export function verify(token: string, publicKey: KeyLike) { + return jwtVerify(token, publicKey); + } +} diff --git a/packages/auth/src/keys.ts b/packages/auth/src/keys.ts new file mode 100644 index 00000000..3b67aed3 --- /dev/null +++ b/packages/auth/src/keys.ts @@ -0,0 +1,136 @@ +import { + exportJWK, + exportPKCS8, + exportSPKI, + generateKeyPair, + importPKCS8, + importSPKI, + JWK, + KeyLike +} from 'jose'; + +import { Storage, StorageAdapter } from './storage/storage.js'; + +const signingAlg = 'ES256'; +const encryptionAlg = 'RSA-OAEP-512'; + +interface SerializedKeyPair { + id: string; + publicKey: string; + privateKey: string; + created: number; + alg: string; + expired?: number; +} + +export interface KeyPair { + id: string; + alg: string; + public: KeyLike; + private: KeyLike; + created: Date; + expired?: Date; + jwk: JWK; +} + +/** + * @deprecated use `signingKeys` instead + */ +export async function legacySigningKeys(storage: StorageAdapter): Promise { + const alg = 'RS512'; + const results = [] as KeyPair[]; + const scanner = Storage.scan(storage, ['oauth:key']); + for await (const [_key, value] of scanner) { + const publicKey = await importSPKI(value.publicKey, alg, { + extractable: true + }); + const privateKey = await importPKCS8(value.privateKey, alg); + const jwk = await exportJWK(publicKey); + jwk.kid = value.id; + results.push({ + id: value.id, + alg, + created: new Date(value.created), + public: publicKey, + private: privateKey, + expired: new Date(1735858114000), + jwk + }); + } + return results; +} + +export async function signingKeys(storage: StorageAdapter): Promise { + const results = [] as KeyPair[]; + const scanner = Storage.scan(storage, ['signing:key']); + for await (const [_key, value] of scanner) { + const publicKey = await importSPKI(value.publicKey, value.alg, { + extractable: true + }); + const privateKey = await importPKCS8(value.privateKey, value.alg); + const jwk = await exportJWK(publicKey); + jwk.kid = value.id; + jwk.use = 'sig'; + results.push({ + id: value.id, + alg: signingAlg, + created: new Date(value.created), + expired: value.expired ? new Date(value.expired) : undefined, + public: publicKey, + private: privateKey, + jwk + }); + } + results.sort((a, b) => b.created.getTime() - a.created.getTime()); + if (results.filter((item) => !item.expired).length) return results; + + const key = await generateKeyPair(signingAlg, { + extractable: true + }); + const serialized: SerializedKeyPair = { + id: crypto.randomUUID(), + publicKey: await exportSPKI(key.publicKey), + privateKey: await exportPKCS8(key.privateKey), + created: Date.now(), + alg: signingAlg + }; + await Storage.set(storage, ['signing:key', serialized.id], serialized); + return signingKeys(storage); +} + +export async function encryptionKeys(storage: StorageAdapter): Promise { + const results = [] as KeyPair[]; + const scanner = Storage.scan(storage, ['encryption:key']); + for await (const [_key, value] of scanner) { + const publicKey = await importSPKI(value.publicKey, value.alg, { + extractable: true + }); + const privateKey = await importPKCS8(value.privateKey, value.alg); + const jwk = await exportJWK(publicKey); + jwk.kid = value.id; + results.push({ + id: value.id, + alg: encryptionAlg, + created: new Date(value.created), + expired: value.expired ? new Date(value.expired) : undefined, + public: publicKey, + private: privateKey, + jwk + }); + } + results.sort((a, b) => b.created.getTime() - a.created.getTime()); + if (results.filter((item) => !item.expired).length) return results; + + const key = await generateKeyPair(encryptionAlg, { + extractable: true + }); + const serialized: SerializedKeyPair = { + id: crypto.randomUUID(), + publicKey: await exportSPKI(key.publicKey), + privateKey: await exportPKCS8(key.privateKey), + created: Date.now(), + alg: encryptionAlg + }; + await Storage.set(storage, ['encryption:key', serialized.id], serialized); + return encryptionKeys(storage); +} diff --git a/packages/auth/src/pkce.ts b/packages/auth/src/pkce.ts new file mode 100644 index 00000000..41d596ff --- /dev/null +++ b/packages/auth/src/pkce.ts @@ -0,0 +1,38 @@ +import { base64url } from 'jose'; + +function generateVerifier(length: number): string { + const buffer = new Uint8Array(length); + crypto.getRandomValues(buffer); + return base64url.encode(buffer); +} + +async function generateChallenge(verifier: string, method: 'S256' | 'plain') { + if (method === 'plain') return verifier; + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest('SHA-256', data); + return base64url.encode(new Uint8Array(hash)); +} + +export async function generatePKCE(length: number = 64) { + if (length < 43 || length > 128) { + throw new Error('Code verifier length must be between 43 and 128 characters'); + } + const verifier = generateVerifier(length); + const challenge = await generateChallenge(verifier, 'S256'); + return { + verifier, + challenge, + method: 'S256' + }; +} + +export async function validatePKCE( + verifier: string, + challenge: string, + method: 'S256' | 'plain' = 'S256' +) { + const generatedChallenge = await generateChallenge(verifier, method); + // timing safe equals? + return generatedChallenge === challenge; +} diff --git a/packages/auth/src/provider/apple.ts b/packages/auth/src/provider/apple.ts new file mode 100644 index 00000000..ed6a9530 --- /dev/null +++ b/packages/auth/src/provider/apple.ts @@ -0,0 +1,127 @@ +/** + * Use this provider to authenticate with Apple. Supports both OAuth2 and OIDC. + * + * #### Using OAuth + * + * ```ts {5-8} + * import { AppleProvider } from "@openauthjs/openauth/provider/apple" + * + * export default issuer({ + * providers: { + * apple: AppleProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * #### Using OAuth with form_post response mode + * + * When requesting name or email scopes from Apple, you must use form_post response mode: + * + * ```ts {5-9} + * import { AppleProvider } from "@openauthjs/openauth/provider/apple" + * + * export default issuer({ + * providers: { + * apple: AppleProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321", + * responseMode: "form_post" + * }) + * } + * }) + * ``` + * + * #### Using OIDC + * + * ```ts {5-7} + * import { AppleOidcProvider } from "@openauthjs/openauth/provider/apple" + * + * export default issuer({ + * providers: { + * apple: AppleOidcProvider({ + * clientID: "1234567890" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; +import { OidcProvider, OidcWrappedConfig } from './oidc.js'; + +export interface AppleConfig extends Oauth2WrappedConfig { + /** + * The response mode to use for the authorization request. + * Apple requires 'form_post' response mode when requesting name or email scopes. + * @default "query" + */ + responseMode?: 'query' | 'form_post'; +} +export interface AppleOidcConfig extends OidcWrappedConfig {} + +/** + * Create an Apple OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * // Using default query response mode (GET callback) + * AppleProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * + * // Using form_post response mode (POST callback) + * // Required when requesting name or email scope + * AppleProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321", + * responseMode: "form_post", + * scopes: ["name", "email"] + * }) + * ``` + */ +export function AppleProvider(config: AppleConfig) { + const { responseMode, ...restConfig } = config; + const additionalQuery = + responseMode === 'form_post' + ? { response_mode: 'form_post', ...config.query } + : config.query || {}; + + return Oauth2Provider({ + ...restConfig, + type: 'apple' as const, + endpoint: { + authorization: 'https://appleid.apple.com/auth/authorize', + token: 'https://appleid.apple.com/auth/token', + jwks: 'https://appleid.apple.com/auth/keys' + }, + query: additionalQuery + }); +} + +/** + * Create an Apple OIDC provider. + * + * This is useful if you just want to verify the user's email address. + * + * @param config - The config for the provider. + * @example + * ```ts + * AppleOidcProvider({ + * clientID: "1234567890" + * }) + * ``` + */ +export function AppleOidcProvider(config: AppleOidcConfig) { + return OidcProvider({ + ...config, + type: 'apple' as const, + issuer: 'https://appleid.apple.com' + }); +} diff --git a/packages/auth/src/provider/arctic.ts b/packages/auth/src/provider/arctic.ts new file mode 100644 index 00000000..0da24826 --- /dev/null +++ b/packages/auth/src/provider/arctic.ts @@ -0,0 +1,66 @@ +import type { OAuth2Tokens } from 'arctic'; +import { Context } from 'hono'; + +import { OauthError } from '../error.js'; +import { getRelativeUrl } from '../util.js'; +import { Provider } from './provider.js'; + +export interface ArcticProviderOptions { + scopes: string[]; + clientID: string; + clientSecret: string; + query?: Record; +} + +interface ProviderState { + state: string; +} + +export function ArcticProvider( + provider: new ( + clientID: string, + clientSecret: string, + callback: string + ) => { + createAuthorizationURL(state: string, scopes: string[]): URL; + validateAuthorizationCode(code: string): Promise; + refreshAccessToken(refreshToken: string): Promise; + }, + config: ArcticProviderOptions +): Provider<{ + tokenset: OAuth2Tokens; +}> { + function getClient(c: Context) { + const callback = new URL(c.req.url); + const pathname = callback.pathname.replace(/authorize.*$/, 'callback'); + const url = getRelativeUrl(c, pathname); + return new provider(config.clientID, config.clientSecret, url); + } + return { + type: 'arctic', + init(routes, ctx) { + routes.get('/authorize', async (c) => { + const client = getClient(c); + const state = crypto.randomUUID(); + await ctx.set(c, 'provider', 60 * 10, { + state + }); + return c.redirect(client.createAuthorizationURL(state, config.scopes)); + }); + + routes.get('/callback', async (c) => { + const client = getClient(c); + const provider = (await ctx.get(c, 'provider')) as ProviderState; + if (!provider) return c.redirect('../authorize'); + const code = c.req.query('code'); + const state = c.req.query('state'); + if (!code) throw new Error('Missing code'); + if (state !== provider.state) throw new OauthError('invalid_request', 'Invalid state'); + const tokens = await client.validateAuthorizationCode(code); + return ctx.success(c, { + tokenset: tokens + }); + }); + } + }; +} diff --git a/packages/auth/src/provider/code.ts b/packages/auth/src/provider/code.ts new file mode 100644 index 00000000..9464dc3f --- /dev/null +++ b/packages/auth/src/provider/code.ts @@ -0,0 +1,215 @@ +/** + * Configures a provider that supports pin code authentication. This is usually paired with the + * `CodeUI`. + * + * ```ts + * import { CodeUI } from "@openauthjs/openauth/ui/code" + * import { CodeProvider } from "@openauthjs/openauth/provider/code" + * + * export default issuer({ + * providers: { + * code: CodeProvider( + * CodeUI({ + * copy: { + * code_info: "We'll send a pin code to your email" + * }, + * sendCode: (claims, code) => console.log(claims.email, code) + * }) + * ) + * }, + * // ... + * }) + * ``` + * + * You can customize the provider using. + * + * ```ts {7-9} + * const ui = CodeUI({ + * // ... + * }) + * + * export default issuer({ + * providers: { + * code: CodeProvider( + * { ...ui, length: 4 } + * ) + * }, + * // ... + * }) + * ``` + * + * Behind the scenes, the `CodeProvider` expects callbacks that implements request handlers + * that generate the UI for the following. + * + * ```ts + * CodeProvider({ + * // ... + * request: (req, state, form, error) => Promise + * }) + * ``` + * + * This allows you to create your own UI. + * + * @packageDocumentation + */ +import { Context } from 'hono'; + +import { generateUnbiasedDigits, timingSafeCompare } from '../random.js'; +import { Provider } from './provider.js'; + +export interface CodeProviderConfig< + Claims extends Record = Record +> { + /** + * The length of the pin code. + * + * @default 6 + */ + length?: number; + /** + * The request handler to generate the UI for the code flow. + * + * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) + * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) + * ojects. + * + * Also passes in the current `state` of the flow and any `error` that occurred. + * + * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object + * in return. + */ + request: ( + req: Request, + state: CodeProviderState, + form?: FormData, + error?: CodeProviderError + ) => Promise; + /** + * Callback to send the pin code to the user. + * + * @example + * ```ts + * { + * sendCode: async (claims, code) => { + * // Send the code through the email or phone number based on the claims + * } + * } + * ``` + */ + sendCode: (claims: Claims, code: string) => Promise; +} + +/** + * The state of the code flow. + * + * | State | Description | + * | ----- | ----------- | + * | `start` | The user is asked to enter their email address or phone number to start the flow. | + * | `code` | The user needs to enter the pin code to verify their _claim_. | + */ +export type CodeProviderState = + | { + type: 'start'; + } + | { + type: 'code'; + resend?: boolean; + code: string; + claims: Record; + }; + +/** + * The errors that can happen on the code flow. + * + * | Error | Description | + * | ----- | ----------- | + * | `invalid_code` | The code is invalid. | + * | `invalid_claim` | The _claim_, email or phone number, is invalid. | + */ +export type CodeProviderError = + | { + type: 'invalid_code'; + } + | { + type: 'invalid_claim'; + key: string; + value: string; + }; + +export function CodeProvider = Record>( + config: CodeProviderConfig +): Provider<{ claims: Claims }> { + const length = config.length || 6; + function generate() { + return generateUnbiasedDigits(length); + } + + return { + type: 'code', + init(routes, ctx) { + async function transition( + c: Context, + next: CodeProviderState, + fd?: FormData, + err?: CodeProviderError + ) { + await ctx.set(c, 'provider', 60 * 60 * 24, next); + const resp = ctx.forward(c, await config.request(c.req.raw, next, fd, err)); + return resp; + } + routes.get('/authorize', async (c) => { + const resp = await transition(c, { + type: 'start' + }); + return resp; + }); + + routes.post('/authorize', async (c) => { + const code = generate(); + const fd = await c.req.formData(); + const state = await ctx.get(c, 'provider'); + const action = fd.get('action')?.toString(); + + if (action === 'request' || action === 'resend') { + const claims = Object.fromEntries(fd) as Claims; + delete claims.action; + const err = await config.sendCode(claims, code); + if (err) return transition(c, { type: 'start' }, fd, err); + return transition( + c, + { + type: 'code', + resend: action === 'resend', + claims, + code + }, + fd + ); + } + + if (fd.get('action')?.toString() === 'verify' && state.type === 'code') { + const fd = await c.req.formData(); + const compare = fd.get('code')?.toString(); + if (!state.code || !compare || !timingSafeCompare(state.code, compare)) { + return transition( + c, + { + ...state, + resend: false + }, + fd, + { type: 'invalid_code' } + ); + } + await ctx.unset(c, 'provider'); + return ctx.forward(c, await ctx.success(c, { claims: state.claims as Claims })); + } + }); + } + }; +} + +/** + * @internal + */ +export type CodeProviderOptions = Parameters[0]; diff --git a/packages/auth/src/provider/cognito.ts b/packages/auth/src/provider/cognito.ts new file mode 100644 index 00000000..b6bf3397 --- /dev/null +++ b/packages/auth/src/provider/cognito.ts @@ -0,0 +1,74 @@ +/** + * Use this provider to authenticate with a Cognito OAuth endpoint. + * + * ```ts {5-10} + * import { CognitoProvider } from "@openauthjs/openauth/provider/cognito" + * + * export default issuer({ + * providers: { + * cognito: CognitoProvider({ + * domain: "your-domain.auth.us-east-1.amazoncognito.com", + * region: "us-east-1", + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; + +export interface CognitoConfig extends Oauth2WrappedConfig { + /** + * The domain of the Cognito User Pool. + * + * @example + * ```ts + * { + * domain: "your-domain.auth.us-east-1.amazoncognito.com" + * } + * ``` + */ + domain: string; + /** + * The region the Cognito User Pool is in. + * + * @example + * ```ts + * { + * region: "us-east-1" + * } + * ``` + */ + region: string; +} + +/** + * Create a Cognito OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * CognitoProvider({ + * domain: "your-domain.auth.us-east-1.amazoncognito.com", + * region: "us-east-1", + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function CognitoProvider(config: CognitoConfig) { + const domain = `${config.domain}.auth.${config.region}.amazoncognito.com`; + + return Oauth2Provider({ + type: 'cognito', + ...config, + endpoint: { + authorization: `https://${domain}/oauth2/authorize`, + token: `https://${domain}/oauth2/token` + } + }); +} diff --git a/packages/auth/src/provider/discord.ts b/packages/auth/src/provider/discord.ts new file mode 100644 index 00000000..3083b041 --- /dev/null +++ b/packages/auth/src/provider/discord.ts @@ -0,0 +1,45 @@ +/** + * Use this provider to authenticate with Discord. + * + * ```ts {5-8} + * import { DiscordProvider } from "@openauthjs/openauth/provider/discord" + * + * export default issuer({ + * providers: { + * discord: DiscordProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; + +export interface DiscordConfig extends Oauth2WrappedConfig {} + +/** + * Create a Discord OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * DiscordProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function DiscordProvider(config: DiscordConfig) { + return Oauth2Provider({ + type: 'discord', + ...config, + endpoint: { + authorization: 'https://discord.com/oauth2/authorize', + token: 'https://discord.com/api/oauth2/token' + } + }); +} diff --git a/packages/auth/src/provider/facebook.ts b/packages/auth/src/provider/facebook.ts new file mode 100644 index 00000000..02e4bf40 --- /dev/null +++ b/packages/auth/src/provider/facebook.ts @@ -0,0 +1,84 @@ +/** + * Use this provider to authenticate with Facebook. Supports both OAuth2 and OIDC. + * + * #### Using OAuth + * + * ```ts {5-8} + * import { FacebookProvider } from "@openauthjs/openauth/provider/facebook" + * + * export default issuer({ + * providers: { + * facebook: FacebookProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * #### Using OIDC + * + * ```ts {5-7} + * import { FacebookOidcProvider } from "@openauthjs/openauth/provider/facebook" + * + * export default issuer({ + * providers: { + * facebook: FacebookOidcProvider({ + * clientID: "1234567890" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; +import { OidcProvider, OidcWrappedConfig } from './oidc.js'; + +export interface FacebookConfig extends Oauth2WrappedConfig {} +export interface FacebookOidcConfig extends OidcWrappedConfig {} + +/** + * Create a Facebook OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * FacebookProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function FacebookProvider(config: FacebookConfig) { + return Oauth2Provider({ + ...config, + type: 'facebook', + endpoint: { + authorization: 'https://www.facebook.com/v12.0/dialog/oauth', + token: 'https://graph.facebook.com/v12.0/oauth/access_token' + } + }); +} + +/** + * Create a Facebook OIDC provider. + * + * This is useful if you just want to verify the user's email address. + * + * @param config - The config for the provider. + * @example + * ```ts + * FacebookOidcProvider({ + * clientID: "1234567890" + * }) + * ``` + */ +export function FacebookOidcProvider(config: FacebookOidcConfig) { + return OidcProvider({ + ...config, + type: 'facebook', + issuer: 'https://graph.facebook.com' + }); +} diff --git a/packages/auth/src/provider/github.ts b/packages/auth/src/provider/github.ts new file mode 100644 index 00000000..83d8e66e --- /dev/null +++ b/packages/auth/src/provider/github.ts @@ -0,0 +1,45 @@ +/** + * Use this provider to authenticate with Github. + * + * ```ts {5-8} + * import { GithubProvider } from "@openauthjs/openauth/provider/github" + * + * export default issuer({ + * providers: { + * github: GithubProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; + +export interface GithubConfig extends Oauth2WrappedConfig {} + +/** + * Create a Github OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * GithubProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function GithubProvider(config: GithubConfig) { + return Oauth2Provider({ + ...config, + type: 'github', + endpoint: { + authorization: 'https://github.com/login/oauth/authorize', + token: 'https://github.com/login/oauth/access_token' + } + }); +} diff --git a/packages/auth/src/provider/google.ts b/packages/auth/src/provider/google.ts new file mode 100644 index 00000000..98fe57f8 --- /dev/null +++ b/packages/auth/src/provider/google.ts @@ -0,0 +1,85 @@ +/** + * Use this provider to authenticate with Google. Supports both OAuth2 and OIDC. + * + * #### Using OAuth + * + * ```ts {5-8} + * import { GoogleProvider } from "@openauthjs/openauth/provider/google" + * + * export default issuer({ + * providers: { + * google: GoogleProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * #### Using OIDC + * + * ```ts {5-7} + * import { GoogleOidcProvider } from "@openauthjs/openauth/provider/google" + * + * export default issuer({ + * providers: { + * google: GoogleOidcProvider({ + * clientID: "1234567890" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; +import { OidcProvider, OidcWrappedConfig } from './oidc.js'; + +export interface GoogleConfig extends Oauth2WrappedConfig {} +export interface GoogleOidcConfig extends OidcWrappedConfig {} + +/** + * Create a Google OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * GoogleProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function GoogleProvider(config: GoogleConfig) { + return Oauth2Provider({ + ...config, + type: 'google', + endpoint: { + authorization: 'https://accounts.google.com/o/oauth2/v2/auth', + token: 'https://oauth2.googleapis.com/token', + jwks: 'https://www.googleapis.com/oauth2/v3/certs' + } + }); +} + +/** + * Create a Google OIDC provider. + * + * This is useful if you just want to verify the user's email address. + * + * @param config - The config for the provider. + * @example + * ```ts + * GoogleOidcProvider({ + * clientID: "1234567890" + * }) + * ``` + */ +export function GoogleOidcProvider(config: GoogleOidcConfig) { + return OidcProvider({ + ...config, + type: 'google', + issuer: 'https://accounts.google.com' + }); +} diff --git a/packages/auth/src/provider/index.ts b/packages/auth/src/provider/index.ts new file mode 100644 index 00000000..fce4d91d --- /dev/null +++ b/packages/auth/src/provider/index.ts @@ -0,0 +1,5 @@ +export * from './code.js'; +export type { Provider } from './provider.js'; +export * from './spotify.js'; +export * from './ssh.js'; +export * from './steam.js'; diff --git a/packages/auth/src/provider/jumpcloud.ts b/packages/auth/src/provider/jumpcloud.ts new file mode 100644 index 00000000..c4efc511 --- /dev/null +++ b/packages/auth/src/provider/jumpcloud.ts @@ -0,0 +1,45 @@ +/** + * Use this provider to authenticate with JumpCloud. + * + * ```ts {5-8} + * import { JumpCloudProvider } from "@openauthjs/openauth/provider/jumpcloud" + * + * export default issuer({ + * providers: { + * jumpcloud: JumpCloudProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; + +export interface JumpCloudConfig extends Oauth2WrappedConfig {} + +/** + * Create a JumpCloud OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * JumpCloudProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function JumpCloudProvider(config: JumpCloudConfig) { + return Oauth2Provider({ + type: 'jumpcloud', + ...config, + endpoint: { + authorization: 'https://oauth.id.jumpcloud.com/oauth2/auth', + token: 'https://oauth.id.jumpcloud.com/oauth2/token' + } + }); +} diff --git a/packages/auth/src/provider/keycloak.ts b/packages/auth/src/provider/keycloak.ts new file mode 100644 index 00000000..4550ac61 --- /dev/null +++ b/packages/auth/src/provider/keycloak.ts @@ -0,0 +1,75 @@ +/** + * Use this provider to authenticate with a Keycloak server. + * + * ```ts {5-10} + * import { KeycloakProvider } from "@openauthjs/openauth/provider/keycloak" + * + * export default issuer({ + * providers: { + * keycloak: KeycloakProvider({ + * baseUrl: "https://your-keycloak-domain", + * realm: "your-realm", + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; + +export interface KeycloakConfig extends Oauth2WrappedConfig { + /** + * The base URL of the Keycloak server. + * + * @example + * ```ts + * { + * baseUrl: "https://your-keycloak-domain" + * } + * ``` + */ + baseUrl: string; + /** + * The realm in the Keycloak server to authenticate against. + * + * A realm in Keycloak is like a tenant or namespace that manages a set of + * users, credentials, roles, and groups. + * + * @example + * ```ts + * { + * realm: "your-realm" + * } + * ``` + */ + realm: string; +} + +/** + * Create a Keycloak OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * KeycloakProvider({ + * baseUrl: "https://your-keycloak-domain", + * realm: "your-realm", + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function KeycloakProvider(config: KeycloakConfig) { + const baseConfig = { + ...config, + endpoint: { + authorization: `${config.baseUrl}/realms/${config.realm}/protocol/openid-connect/auth`, + token: `${config.baseUrl}/realms/${config.realm}/protocol/openid-connect/token` + } + }; + return Oauth2Provider(baseConfig); +} diff --git a/packages/auth/src/provider/linkedin.ts b/packages/auth/src/provider/linkedin.ts new file mode 100644 index 00000000..5371713f --- /dev/null +++ b/packages/auth/src/provider/linkedin.ts @@ -0,0 +1,12 @@ +import { Oauth2Provider, type Oauth2WrappedConfig } from './oauth2.js'; + +export function LinkedInAdapter(config: Oauth2WrappedConfig) { + return Oauth2Provider({ + ...config, + type: 'linkedin', + endpoint: { + authorization: 'https://www.linkedin.com/oauth/v2/authorization', + token: 'https://www.linkedin.com/oauth/v2/accessToken' + } + }); +} diff --git a/packages/auth/src/provider/microsoft.ts b/packages/auth/src/provider/microsoft.ts new file mode 100644 index 00000000..591d85e6 --- /dev/null +++ b/packages/auth/src/provider/microsoft.ts @@ -0,0 +1,100 @@ +/** + * Use this provider to authenticate with Microsoft. Supports both OAuth2 and OIDC. + * + * #### Using OAuth + * + * ```ts {5-9} + * import { MicrosoftProvider } from "@openauthjs/openauth/provider/microsoft" + * + * export default issuer({ + * providers: { + * microsoft: MicrosoftProvider({ + * tenant: "1234567890", + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * #### Using OIDC + * + * ```ts {5-7} + * import { MicrosoftOidcProvider } from "@openauthjs/openauth/provider/microsoft" + * + * export default issuer({ + * providers: { + * microsoft: MicrosoftOidcProvider({ + * clientID: "1234567890" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; +import { OidcProvider, OidcWrappedConfig } from './oidc.js'; + +export interface MicrosoftConfig extends Oauth2WrappedConfig { + /** + * The tenant ID of the Microsoft account. + * + * This is usually the same as the client ID. + * + * @example + * ```ts + * { + * tenant: "1234567890" + * } + * ``` + */ + tenant: string; +} +export interface MicrosoftOidcConfig extends OidcWrappedConfig {} + +/** + * Create a Microsoft OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * MicrosoftProvider({ + * tenant: "1234567890", + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function MicrosoftProvider(config: MicrosoftConfig) { + return Oauth2Provider({ + ...config, + type: 'microsoft', + endpoint: { + authorization: `https://login.microsoftonline.com/${config?.tenant}/oauth2/v2.0/authorize`, + token: `https://login.microsoftonline.com/${config?.tenant}/oauth2/v2.0/token` + } + }); +} + +/** + * Create a Microsoft OIDC provider. + * + * This is useful if you just want to verify the user's email address. + * + * @param config - The config for the provider. + * @example + * ```ts + * MicrosoftOidcProvider({ + * clientID: "1234567890" + * }) + * ``` + */ +export function MicrosoftOidcProvider(config: MicrosoftOidcConfig) { + return OidcProvider({ + ...config, + type: 'microsoft', + issuer: 'https://graph.microsoft.com/oidc/userinfo' + }); +} diff --git a/packages/auth/src/provider/oauth2.ts b/packages/auth/src/provider/oauth2.ts new file mode 100644 index 00000000..048c62db --- /dev/null +++ b/packages/auth/src/provider/oauth2.ts @@ -0,0 +1,282 @@ +/** + * Use this to connect authentication providers that support OAuth 2.0. + * + * ```ts {5-12} + * import { Oauth2Provider } from "@openauthjs/openauth/provider/oauth2" + * + * export default issuer({ + * providers: { + * oauth2: Oauth2Provider({ + * clientID: "1234567890", + * clientSecret: "0987654321", + * endpoint: { + * authorization: "https://auth.myserver.com/authorize", + * token: "https://auth.myserver.com/token" + * } + * }) + * } + * }) + * ``` + * + * + * @packageDocumentation + */ + +import { createRemoteJWKSet, jwtVerify } from 'jose'; + +import { OauthError } from '../error.js'; +import { generatePKCE } from '../pkce.js'; +import { getRelativeUrl } from '../util.js'; +import { Provider } from './provider.js'; + +export interface Oauth2Config { + /** + * @internal + */ + type?: string; + /** + * The client ID. + * + * This is just a string to identify your app. + * + * @example + * ```ts + * { + * clientID: "my-client" + * } + * ``` + */ + clientID: string; + /** + * The client secret. + * + * This is a private key that's used to authenticate your app. It should be kept secret. + * + * @example + * ```ts + * { + * clientSecret: "0987654321" + * } + * ``` + */ + clientSecret: string; + /** + * The URLs of the authorization and token endpoints. + * + * @example + * ```ts + * { + * endpoint: { + * authorization: "https://auth.myserver.com/authorize", + * token: "https://auth.myserver.com/token", + * jwks: "https://auth.myserver.com/auth/keys" + * } + * } + * ``` + */ + endpoint: { + /** + * The URL of the authorization endpoint. + */ + authorization: string; + /** + * The URL of the token endpoint. + */ + token: string; + /** + * The URL of the JWKS endpoint. + */ + jwks?: string; + }; + /** + * A list of OAuth scopes that you want to request. + * + * @example + * ```ts + * { + * scopes: ["email", "profile"] + * } + * ``` + */ + scopes: string[]; + /** + * Whether to use PKCE (Proof Key for Code Exchange) for the authorization code flow. + * Some providers like x.com require this. + * @default false + */ + pkce?: boolean; + /** + * Any additional parameters that you want to pass to the authorization endpoint. + * @example + * ```ts + * { + * query: { + * access_type: "offline", + * prompt: "consent" + * } + * } + * ``` + */ + query?: Record; +} + +/** + * @internal + */ +export type Oauth2WrappedConfig = Omit; + +/** + * @internal + */ +export interface Oauth2Token { + access: string; + refresh: string; + expiry: number; + id?: Record; + raw: Record; +} + +interface ProviderState { + state: string; + redirect: string; + codeVerifier?: string; +} + +export function Oauth2Provider( + config: Oauth2Config +): Provider<{ tokenset: Oauth2Token; clientID: string }> { + const query = config.query || {}; + + // Helper function to handle token exchange and response building + async function handleCallbackLogic( + c: any, + ctx: any, + provider: ProviderState, + code: string | undefined + ) { + if (!provider || !code) { + return c.redirect(getRelativeUrl(c, './authorize')); + } + + const body = new URLSearchParams({ + client_id: config.clientID, + client_secret: config.clientSecret, + code, + grant_type: 'authorization_code', + redirect_uri: provider.redirect, + ...(provider.codeVerifier ? { code_verifier: provider.codeVerifier } : {}) + }); + + const json: any = await fetch(config.endpoint.token, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json' + }, + body: body.toString() + }).then((r) => r.json()); + + if ('error' in json) { + throw new OauthError(json.error, json.error_description); + } + + let idTokenPayload: Record | null = null; + if (config.endpoint.jwks) { + const jwksEndpoint = new URL(config.endpoint.jwks); + // @ts-expect-error bun/node mismatch + const jwks = createRemoteJWKSet(jwksEndpoint); + const { payload } = await jwtVerify(json.id_token, jwks, { + audience: config.clientID + }); + idTokenPayload = payload; + } + + return ctx.success(c, { + clientID: config.clientID, + tokenset: { + get access() { + return json.access_token; + }, + get refresh() { + return json.refresh_token; + }, + get expiry() { + return json.expires_in; + }, + get id() { + if (!idTokenPayload) return null; + return idTokenPayload; + }, + get raw() { + return json; + } + } + }); + } + + return { + type: config.type || 'oauth2', + init(routes, ctx) { + routes.get('/authorize', async (c) => { + const state = crypto.randomUUID(); + const pkce = config.pkce ? await generatePKCE() : undefined; + await ctx.set(c, 'provider', 60 * 10, { + state, + redirect: getRelativeUrl(c, './callback'), + codeVerifier: pkce?.verifier + }); + const authorization = new URL(config.endpoint.authorization); + authorization.searchParams.set('client_id', config.clientID); + authorization.searchParams.set('redirect_uri', getRelativeUrl(c, './callback')); + authorization.searchParams.set('response_type', 'code'); + authorization.searchParams.set('state', state); + authorization.searchParams.set('scope', config.scopes.join(' ')); + if (pkce) { + authorization.searchParams.set('code_challenge', pkce.challenge); + authorization.searchParams.set('code_challenge_method', pkce.method); + } + for (const [key, value] of Object.entries(query)) { + authorization.searchParams.set(key, value); + } + return c.redirect(authorization.toString()); + }); + + routes.get('/callback', async (c) => { + const provider = (await ctx.get(c, 'provider')) as ProviderState; + const code = c.req.query('code'); + const state = c.req.query('state'); + const error = c.req.query('error'); + + if (error) + throw new OauthError( + error.toString() as any, + c.req.query('error_description')?.toString() || '' + ); + if (!provider || !code || (provider.state && state !== provider.state)) { + return c.redirect(getRelativeUrl(c, './authorize')); + } + + return handleCallbackLogic(c, ctx, provider, code); + }); + + routes.post('/callback', async (c) => { + const provider = (await ctx.get(c, 'provider')) as ProviderState; + + // Handle form data from POST request + const formData = await c.req.formData(); + const code = formData.get('code')?.toString(); + const state = formData.get('state')?.toString(); + const error = formData.get('error')?.toString(); + + if (error) + throw new OauthError(error as any, formData.get('error_description')?.toString() || ''); + + if (!provider || !code || (provider.state && state !== provider.state)) { + return c.redirect(getRelativeUrl(c, './authorize')); + } + + return handleCallbackLogic(c, ctx, provider, code); + }); + } + }; +} diff --git a/packages/auth/src/provider/oidc.ts b/packages/auth/src/provider/oidc.ts new file mode 100644 index 00000000..15d46a8b --- /dev/null +++ b/packages/auth/src/provider/oidc.ts @@ -0,0 +1,173 @@ +/** + * Use this to connect authentication providers that support OIDC. + * + * ```ts {5-8} + * import { OidcProvider } from "@openauthjs/openauth/provider/oidc" + * + * export default issuer({ + * providers: { + * oauth2: OidcProvider({ + * clientId: "1234567890", + * issuer: "https://auth.myserver.com" + * }) + * } + * }) + * ``` + * + * + * @packageDocumentation + */ + +import { JWTPayload } from 'hono/utils/jwt/types'; +import { createLocalJWKSet, JSONWebKeySet, jwtVerify } from 'jose'; + +import { WellKnown } from '../client.js'; +import { OauthError } from '../error.js'; +import { getRelativeUrl, lazy } from '../util.js'; +import { Provider } from './provider.js'; + +export interface OidcConfig { + /** + * @internal + */ + type?: string; + /** + * The client ID. + * + * This is just a string to identify your app. + * + * @example + * ```ts + * { + * clientID: "my-client" + * } + * ``` + */ + clientID: string; + /** + * The URL of your authorization server. + * + * @example + * ```ts + * { + * issuer: "https://auth.myserver.com" + * } + * ``` + */ + issuer: string; + /** + * A list of OIDC scopes that you want to request. + * + * @example + * ```ts + * { + * scopes: ["openid", "profile", "email"] + * } + * ``` + */ + scopes?: string[]; + /** + * Any additional parameters that you want to pass to the authorization endpoint. + * @example + * ```ts + * { + * query: { + * prompt: "consent" + * } + * } + * ``` + */ + query?: Record; +} + +/** + * @internal + */ +export type OidcWrappedConfig = Omit; + +interface ProviderState { + state: string; + nonce: string; + redirect: string; +} + +/** + * @internal + */ +export interface IdTokenResponse { + idToken: string; + claims: Record; + raw: Record; +} + +export function OidcProvider(config: OidcConfig): Provider<{ id: JWTPayload; clientID: string }> { + const query = config.query || {}; + const scopes = config.scopes || []; + + const wk = lazy(() => + fetch(config.issuer + '/.well-known/openid-configuration').then(async (r) => { + if (!r.ok) throw new Error(await r.text()); + return r.json() as Promise; + }) + ); + + const jwks = lazy(() => + wk() + .then((r) => r.jwks_uri) + .then(async (uri) => { + const r = await fetch(uri); + if (!r.ok) throw new Error(await r.text()); + return createLocalJWKSet((await r.json()) as JSONWebKeySet); + }) + ); + + return { + type: config.type || 'oidc', + init(routes, ctx) { + routes.get('/authorize', async (c) => { + const provider: ProviderState = { + state: crypto.randomUUID(), + nonce: crypto.randomUUID(), + redirect: getRelativeUrl(c, './callback') + }; + await ctx.set(c, 'provider', 60 * 10, provider); + const authorization = new URL(await wk().then((r) => r.authorization_endpoint)); + authorization.searchParams.set('client_id', config.clientID); + authorization.searchParams.set('response_type', 'id_token'); + authorization.searchParams.set('response_mode', 'form_post'); + authorization.searchParams.set('state', provider.state); + authorization.searchParams.set('nonce', provider.nonce); + authorization.searchParams.set('redirect_uri', provider.redirect); + authorization.searchParams.set('scope', ['openid', ...scopes].join(' ')); + for (const [key, value] of Object.entries(query)) { + authorization.searchParams.set(key, value); + } + return c.redirect(authorization.toString()); + }); + + routes.post('/callback', async (c) => { + const provider = await ctx.get(c, 'provider'); + if (!provider) return c.redirect(getRelativeUrl(c, './authorize')); + const body = await c.req.formData(); + const error = body.get('error'); + if (error) + throw new OauthError( + error.toString() as any, + body.get('error_description')?.toString() || '' + ); + const idToken = body.get('id_token'); + if (!idToken) throw new OauthError('invalid_request', 'Missing id_token'); + const result = await jwtVerify(idToken.toString(), await jwks(), { + audience: config.clientID + }); + if (result.payload.nonce !== provider.nonce) { + throw new OauthError('invalid_request', 'Invalid nonce'); + } + return ctx.success(c, { + id: result.payload, + clientID: config.clientID + }); + }); + } + }; +} diff --git a/packages/auth/src/provider/password.ts b/packages/auth/src/provider/password.ts new file mode 100644 index 00000000..e5a9e766 --- /dev/null +++ b/packages/auth/src/provider/password.ts @@ -0,0 +1,606 @@ +import { v1 } from '@standard-schema/spec'; + +/** + * Configures a provider that supports username and password authentication. This is usually + * paired with the `PasswordUI`. + * + * ```ts + * import { PasswordUI } from "@openauthjs/openauth/ui/password" + * import { PasswordProvider } from "@openauthjs/openauth/provider/password" + * + * export default issuer({ + * providers: { + * password: PasswordProvider( + * PasswordUI({ + * copy: { + * error_email_taken: "This email is already taken." + * }, + * sendCode: (email, code) => console.log(email, code) + * }) + * ) + * }, + * // ... + * }) + * ``` + * + * Behind the scenes, the `PasswordProvider` expects callbacks that implements request handlers + * that generate the UI for the following. + * + * ```ts + * PasswordProvider({ + * // ... + * login: (req, form, error) => Promise + * register: (req, state, form, error) => Promise + * change: (req, state, form, error) => Promise + * }) + * ``` + * + * This allows you to create your own UI for each of these screens. + * + * @packageDocumentation + */ +import { UnknownStateError } from '../error.js'; +import { generateUnbiasedDigits, timingSafeCompare } from '../random.js'; +import { Storage } from '../storage/storage.js'; +import { Provider } from './provider.js'; + +/** + * @internal + */ +export interface PasswordHasher { + hash(password: string): Promise; + verify(password: string, compare: T): Promise; +} + +export interface PasswordConfig { + /** + * @internal + */ + length?: number; + /** + * @internal + */ + hasher?: PasswordHasher; + /** + * The request handler to generate the UI for the login screen. + * + * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) + * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) + * ojects. + * + * In case of an error, this is called again with the `error`. + * + * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object + * in return. + */ + login: (req: Request, form?: FormData, error?: PasswordLoginError) => Promise; + /** + * The request handler to generate the UI for the register screen. + * + * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) + * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) + * ojects. + * + * Also passes in the current `state` of the flow and any `error` that occurred. + * + * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object + * in return. + */ + register: ( + req: Request, + state: PasswordRegisterState, + form?: FormData, + error?: PasswordRegisterError + ) => Promise; + /** + * The request handler to generate the UI for the change password screen. + * + * Takes the standard [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) + * and optionally [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) + * ojects. + * + * Also passes in the current `state` of the flow and any `error` that occurred. + * + * Expects the [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object + * in return. + */ + change: ( + req: Request, + state: PasswordChangeState, + form?: FormData, + error?: PasswordChangeError + ) => Promise; + /** + * Callback to send the confirmation pin code to the user. + * + * @example + * ```ts + * { + * sendCode: async (email, code) => { + * // Send an email with the code + * } + * } + * ``` + */ + sendCode: (email: string, code: string) => Promise; + /** + * Callback to validate the password on sign up and password reset. + * + * @example + * ```ts + * { + * validatePassword: (password) => { + * return password.length < 8 ? "Password must be at least 8 characters" : undefined + * } + * } + * ``` + */ + validatePassword?: + | v1.StandardSchema + | ((password: string) => Promise | string | undefined); +} + +/** + * The states that can happen on the register screen. + * + * | State | Description | + * | ----- | ----------- | + * | `start` | The user is asked to enter their email address and password to start the flow. | + * | `code` | The user needs to enter the pin code to verify their email. | + */ +export type PasswordRegisterState = + | { + type: 'start'; + } + | { + type: 'code'; + code: string; + email: string; + password: string; + }; + +/** + * The errors that can happen on the register screen. + * + * | Error | Description | + * | ----- | ----------- | + * | `email_taken` | The email is already taken. | + * | `invalid_email` | The email is invalid. | + * | `invalid_code` | The code is invalid. | + * | `invalid_password` | The password is invalid. | + * | `password_mismatch` | The passwords do not match. | + */ +export type PasswordRegisterError = + | { + type: 'invalid_code'; + } + | { + type: 'email_taken'; + } + | { + type: 'invalid_email'; + } + | { + type: 'invalid_password'; + } + | { + type: 'password_mismatch'; + } + | { + type: 'validation_error'; + message?: string; + }; + +/** + * The state of the password change flow. + * + * | State | Description | + * | ----- | ----------- | + * | `start` | The user is asked to enter their email address to start the flow. | + * | `code` | The user needs to enter the pin code to verify their email. | + * | `update` | The user is asked to enter their new password and confirm it. | + */ +export type PasswordChangeState = + | { + type: 'start'; + redirect: string; + } + | { + type: 'code'; + code: string; + email: string; + redirect: string; + } + | { + type: 'update'; + redirect: string; + email: string; + }; + +/** + * The errors that can happen on the change password screen. + * + * | Error | Description | + * | ----- | ----------- | + * | `invalid_email` | The email is invalid. | + * | `invalid_code` | The code is invalid. | + * | `invalid_password` | The password is invalid. | + * | `password_mismatch` | The passwords do not match. | + */ +export type PasswordChangeError = + | { + type: 'invalid_email'; + } + | { + type: 'invalid_code'; + } + | { + type: 'invalid_password'; + } + | { + type: 'password_mismatch'; + } + | { + type: 'validation_error'; + message: string; + }; + +/** + * The errors that can happen on the login screen. + * + * | Error | Description | + * | ----- | ----------- | + * | `invalid_email` | The email is invalid. | + * | `invalid_password` | The password is invalid. | + */ +export type PasswordLoginError = + | { + type: 'invalid_password'; + } + | { + type: 'invalid_email'; + }; + +export function PasswordProvider(config: PasswordConfig): Provider<{ email: string }> { + const hasher = config.hasher ?? ScryptHasher(); + function generate() { + return generateUnbiasedDigits(6); + } + return { + type: 'password', + init(routes, ctx) { + routes.get('/authorize', async (c) => ctx.forward(c, await config.login(c.req.raw))); + + routes.post('/authorize', async (c) => { + const fd = await c.req.formData(); + async function error(err: PasswordLoginError) { + return ctx.forward(c, await config.login(c.req.raw, fd, err)); + } + const email = fd.get('email')?.toString()?.toLowerCase(); + if (!email) return error({ type: 'invalid_email' }); + const hash = await Storage.get(ctx.storage, ['email', email, 'password']); + const password = fd.get('password')?.toString(); + if (!password || !hash || !(await hasher.verify(password, hash))) + return error({ type: 'invalid_password' }); + return ctx.success( + c, + { + email: email + }, + { + invalidate: async (subject) => { + await Storage.set(ctx.storage, ['email', email, 'subject'], subject); + } + } + ); + }); + + routes.get('/register', async (c) => { + const state: PasswordRegisterState = { + type: 'start' + }; + await ctx.set(c, 'provider', 60 * 60 * 24, state); + return ctx.forward(c, await config.register(c.req.raw, state)); + }); + + routes.post('/register', async (c) => { + const fd = await c.req.formData(); + const email = fd.get('email')?.toString()?.toLowerCase(); + const action = fd.get('action')?.toString(); + const provider = await ctx.get(c, 'provider'); + + async function transition(next: PasswordRegisterState, err?: PasswordRegisterError) { + await ctx.set(c, 'provider', 60 * 60 * 24, next); + return ctx.forward(c, await config.register(c.req.raw, next, fd, err)); + } + + if (action === 'register' && provider.type === 'start') { + const password = fd.get('password')?.toString(); + const repeat = fd.get('repeat')?.toString(); + if (!email) return transition(provider, { type: 'invalid_email' }); + if (!password) return transition(provider, { type: 'invalid_password' }); + if (password !== repeat) return transition(provider, { type: 'password_mismatch' }); + if (config.validatePassword) { + let validationError: string | undefined; + try { + if (typeof config.validatePassword === 'function') { + validationError = await config.validatePassword(password); + } else { + const res = await config.validatePassword['~standard'].validate(password); + + if (res.issues?.length) { + throw new Error(res.issues.map((issue) => issue.message).join(', ')); + } + } + } catch (error) { + validationError = error instanceof Error ? error.message : undefined; + } + if (validationError) + return transition(provider, { + type: 'validation_error', + message: validationError + }); + } + const existing = await Storage.get(ctx.storage, ['email', email, 'password']); + if (existing) return transition(provider, { type: 'email_taken' }); + const code = generate(); + await config.sendCode(email, code); + return transition({ + type: 'code', + code, + password: await hasher.hash(password), + email + }); + } + + if (action === 'register' && provider.type === 'code') { + const code = generate(); + await config.sendCode(provider.email, code); + return transition({ + type: 'code', + code, + password: provider.password, + email: provider.email + }); + } + + if (action === 'verify' && provider.type === 'code') { + const code = fd.get('code')?.toString(); + if (!code || !timingSafeCompare(code, provider.code)) + return transition(provider, { type: 'invalid_code' }); + const existing = await Storage.get(ctx.storage, ['email', provider.email, 'password']); + if (existing) return transition({ type: 'start' }, { type: 'email_taken' }); + await Storage.set(ctx.storage, ['email', provider.email, 'password'], provider.password); + return ctx.success(c, { + email: provider.email + }); + } + + return transition({ type: 'start' }); + }); + + routes.get('/change', async (c) => { + let redirect = c.req.query('redirect_uri') || getRelativeUrl(c, './authorize'); + const state: PasswordChangeState = { + type: 'start', + redirect + }; + await ctx.set(c, 'provider', 60 * 60 * 24, state); + return ctx.forward(c, await config.change(c.req.raw, state)); + }); + + routes.post('/change', async (c) => { + const fd = await c.req.formData(); + const action = fd.get('action')?.toString(); + const provider = await ctx.get(c, 'provider'); + if (!provider) throw new UnknownStateError(); + + async function transition(next: PasswordChangeState, err?: PasswordChangeError) { + await ctx.set(c, 'provider', 60 * 60 * 24, next); + return ctx.forward(c, await config.change(c.req.raw, next, fd, err)); + } + + if (action === 'code') { + const email = fd.get('email')?.toString()?.toLowerCase(); + if (!email) + return transition( + { type: 'start', redirect: provider.redirect }, + { type: 'invalid_email' } + ); + const code = generate(); + await config.sendCode(email, code); + + return transition({ + type: 'code', + code, + email, + redirect: provider.redirect + }); + } + + if (action === 'verify' && provider.type === 'code') { + const code = fd.get('code')?.toString(); + if (!code || !timingSafeCompare(code, provider.code)) + return transition(provider, { type: 'invalid_code' }); + return transition({ + type: 'update', + email: provider.email, + redirect: provider.redirect + }); + } + + if (action === 'update' && provider.type === 'update') { + const existing = await Storage.get(ctx.storage, ['email', provider.email, 'password']); + if (!existing) return c.redirect(provider.redirect, 302); + + const password = fd.get('password')?.toString(); + const repeat = fd.get('repeat')?.toString(); + if (!password) return transition(provider, { type: 'invalid_password' }); + if (password !== repeat) return transition(provider, { type: 'password_mismatch' }); + + if (config.validatePassword) { + let validationError: string | undefined; + try { + if (typeof config.validatePassword === 'function') { + validationError = await config.validatePassword(password); + } else { + const res = await config.validatePassword['~standard'].validate(password); + + if (res.issues?.length) { + throw new Error(res.issues.map((issue) => issue.message).join(', ')); + } + } + } catch (error) { + validationError = error instanceof Error ? error.message : undefined; + } + if (validationError) + return transition(provider, { + type: 'validation_error', + message: validationError + }); + } + + await Storage.set( + ctx.storage, + ['email', provider.email, 'password'], + await hasher.hash(password) + ); + const subject = await Storage.get(ctx.storage, [ + 'email', + provider.email, + 'subject' + ]); + if (subject) await ctx.invalidate(subject); + + return c.redirect(provider.redirect, 302); + } + + return transition({ type: 'start', redirect: provider.redirect }); + }); + } + }; +} + +import { TextEncoder } from 'node:util'; + +import * as jose from 'jose'; + +interface HashedPassword {} + +/** + * @internal + */ +export function PBKDF2Hasher(opts?: { iterations?: number }): PasswordHasher<{ + hash: string; + salt: string; + iterations: number; +}> { + const iterations = opts?.iterations ?? 600000; + return { + async hash(password) { + const encoder = new TextEncoder(); + const bytes = encoder.encode(password); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const keyMaterial = await crypto.subtle.importKey('raw', bytes, 'PBKDF2', false, [ + 'deriveBits' + ]); + const hash = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + hash: 'SHA-256', + salt: salt, + iterations + }, + keyMaterial, + 256 + ); + const hashBase64 = jose.base64url.encode(new Uint8Array(hash)); + const saltBase64 = jose.base64url.encode(salt); + return { + hash: hashBase64, + salt: saltBase64, + iterations + }; + }, + async verify(password, compare) { + const encoder = new TextEncoder(); + const passwordBytes = encoder.encode(password); + const salt = jose.base64url.decode(compare.salt); + const params = { + name: 'PBKDF2', + hash: 'SHA-256', + salt, + iterations: compare.iterations + }; + const keyMaterial = await crypto.subtle.importKey('raw', passwordBytes, 'PBKDF2', false, [ + 'deriveBits' + ]); + const hash = await crypto.subtle.deriveBits(params, keyMaterial, 256); + const hashBase64 = jose.base64url.encode(new Uint8Array(hash)); + return hashBase64 === compare.hash; + } + }; +} +import { timingSafeEqual, randomBytes, scrypt } from 'node:crypto'; + +import { getRelativeUrl } from '../util.js'; + +/** + * @internal + */ +export function ScryptHasher(opts?: { N?: number; r?: number; p?: number }): PasswordHasher<{ + hash: string; + salt: string; + N: number; + r: number; + p: number; +}> { + const N = opts?.N ?? 16384; + const r = opts?.r ?? 8; + const p = opts?.p ?? 1; + + return { + async hash(password) { + const salt = randomBytes(16); + const keyLength = 32; // 256 bits + + const derivedKey = await new Promise((resolve, reject) => { + scrypt(password, salt, keyLength, { N, r, p }, (err, derivedKey) => { + if (err) reject(err); + else resolve(derivedKey); + }); + }); + + const hashBase64 = derivedKey.toString('base64'); + const saltBase64 = salt.toString('base64'); + + return { + hash: hashBase64, + salt: saltBase64, + N, + r, + p + }; + }, + + async verify(password, compare) { + const salt = Buffer.from(compare.salt, 'base64'); + const keyLength = 32; // 256 bits + + const derivedKey = await new Promise((resolve, reject) => { + scrypt( + password, + salt, + keyLength, + { N: compare.N, r: compare.r, p: compare.p }, + (err, derivedKey) => { + if (err) reject(err); + else resolve(derivedKey); + } + ); + }); + + return timingSafeEqual(derivedKey, Buffer.from(compare.hash, 'base64')); + } + }; +} diff --git a/packages/auth/src/provider/provider.ts b/packages/auth/src/provider/provider.ts new file mode 100644 index 00000000..41ed5cf9 --- /dev/null +++ b/packages/auth/src/provider/provider.ts @@ -0,0 +1,34 @@ +import type { Context, Hono } from 'hono'; + +import { StorageAdapter } from '../storage/storage.js'; + +export type ProviderRoute = Hono; + +export interface Provider { + type: string; + init: (route: ProviderRoute, options: ProviderOptions) => void; + client?: (input: { + clientID: string; + clientSecret: string; + params: Record; + }) => Promise; +} + +export interface ProviderOptions { + name: string; + success: ( + ctx: Context, + properties: Properties, + opts?: { + invalidate?: (subject: string) => Promise; + } + ) => Promise; + forward: (ctx: Context, response: Response) => Response; + set: (ctx: Context, key: string, maxAge: number, value: T) => Promise; + get: (ctx: Context, key: string) => Promise; + unset: (ctx: Context, key: string) => Promise; + invalidate: (subject: string) => Promise; + storage: StorageAdapter; +} +export class ProviderError extends Error {} +export class ProviderUnknownError extends ProviderError {} diff --git a/packages/auth/src/provider/slack.ts b/packages/auth/src/provider/slack.ts new file mode 100644 index 00000000..a053bf38 --- /dev/null +++ b/packages/auth/src/provider/slack.ts @@ -0,0 +1,67 @@ +/** + * Use this provider to authenticate with Slack. + * + * ```ts {5-10} + * import { SlackProvider } from "@openauthjs/openauth/provider/slack" + * + * export default issuer({ + * providers: { + * slack: SlackProvider({ + * team: "T1234567890", + * clientID: "1234567890", + * clientSecret: "0987654321", + * scopes: ["openid", "email", "profile"] + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; + +export interface SlackConfig extends Oauth2WrappedConfig { + /** + * The workspace the user is intending to authenticate. + * + * If that workspace has been previously authenticated, the user will be signed in directly, + * bypassing the consent screen. + */ + team: string; + /** + * The scopes to request from the user. + * + * | Scope | Description | + * |-|-| + * | `email` | Grants permission to access the user's email address. | + * | `profile` | Grants permission to access the user's profile information. | + * | `openid` | Grants permission to use OpenID Connect to verify the user's identity. | + */ + scopes: ('email' | 'profile' | 'openid')[]; +} + +/** + * Creates a [Slack OAuth2 provider](https://api.slack.com/authentication/sign-in-with-slack). + * + * @param {SlackConfig} config - The config for the provider. + * @example + * ```ts + * SlackProvider({ + * team: "T1234567890", + * clientID: "1234567890", + * clientSecret: "0987654321", + * scopes: ["openid", "email", "profile"] + * }) + * ``` + */ +export function SlackProvider(config: SlackConfig) { + return Oauth2Provider({ + ...config, + type: 'slack', + endpoint: { + authorization: 'https://slack.com/openid/connect/authorize', + token: 'https://slack.com/api/openid.connect.token' + } + }); +} diff --git a/packages/auth/src/provider/spotify.ts b/packages/auth/src/provider/spotify.ts new file mode 100644 index 00000000..266fcf68 --- /dev/null +++ b/packages/auth/src/provider/spotify.ts @@ -0,0 +1,45 @@ +/** + * Use this provider to authenticate with Spotify. + * + * ```ts {5-8} + * import { SpotifyProvider } from "@openauthjs/openauth/provider/spotify" + * + * export default issuer({ + * providers: { + * spotify: SpotifyProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, type Oauth2WrappedConfig } from './oauth2.js'; + +export interface SpotifyConfig extends Oauth2WrappedConfig {} + +/** + * Create a Spotify OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * SpotifyProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function SpotifyProvider(config: SpotifyConfig) { + return Oauth2Provider({ + ...config, + type: 'spotify', + endpoint: { + authorization: 'https://accounts.spotify.com/authorize', + token: 'https://accounts.spotify.com/api/token' + } + }); +} diff --git a/packages/auth/src/provider/ssh.ts b/packages/auth/src/provider/ssh.ts new file mode 100644 index 00000000..4c503bba --- /dev/null +++ b/packages/auth/src/provider/ssh.ts @@ -0,0 +1,53 @@ +import type { Context } from 'hono'; + +import type { Provider } from './provider.js'; + +export interface SshProviderConfig { + sshAuthKey: string; +} + +export interface SshLoginBody { + fingerprint: string; + steamId: string; + username?: string; + profile?: Record; +} + +export function SshProvider(config: SshProviderConfig): Provider<{ + fingerprint: string; + steamId: string; + username?: string; + profile?: Record; +}> { + return { + type: 'ssh', + init(routes, ctx) { + routes.post('/login', async (c: Context) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader) { + return c.json({ error: 'Missing Authorization header' }, 401); + } + + const bearer = authHeader.split(' ')[1]; + if (bearer !== config.sshAuthKey) { + return c.json({ error: 'Invalid authorization token' }, 401); + } + + const body = (await c.req.json()) as SshLoginBody; + if (!body.fingerprint) { + return c.json({ error: 'Fingerprint is required' }, 400); + } + if (!body.steamId || !/^\d{17}$/.test(body.steamId)) { + return c.json({ error: 'steamId is required and must be a 17-digit Steam ID' }, 400); + } + + return ctx.success(c, { + fingerprint: body.fingerprint, + steamId: body.steamId, + username: body.username, + profile: body.profile + }); + }); + } + }; +} diff --git a/packages/auth/src/provider/steam.ts b/packages/auth/src/provider/steam.ts new file mode 100644 index 00000000..cb565dfd --- /dev/null +++ b/packages/auth/src/provider/steam.ts @@ -0,0 +1,52 @@ +import { getRelativeUrl } from '../util.js'; +import { Provider } from './provider.js'; + +const STEAM_OPENID_URL = 'https://steamcommunity.com/openid/login'; + +export function SteamProvider(): Provider<{ steamid: string }> { + return { + type: 'steam', + init(routes, ctx) { + routes.get('/authorize', async (c) => { + const returnUrl = getRelativeUrl(c, './callback'); + const openidURL = + `${STEAM_OPENID_URL}?` + + `openid.ns=${encodeURIComponent('http://specs.openid.net/auth/2.0')}&` + + `openid.mode=checkid_setup&` + + `openid.return_to=${encodeURIComponent(returnUrl)}&` + + `openid.realm=${encodeURIComponent(new URL(c.req.url).origin)}&` + + `openid.identity=${encodeURIComponent('http://specs.openid.net/auth/2.0/identifier_select')}&` + + `openid.claimed_id=${encodeURIComponent('http://specs.openid.net/auth/2.0/identifier_select')}`; + return c.redirect(openidURL); + }); + + routes.get('/callback', async (c) => { + const url = new URL(c.req.url); + const params = Object.fromEntries(url.searchParams.entries()); + + const verifyRes = await fetch(STEAM_OPENID_URL, { + method: 'POST', + body: new URLSearchParams({ + ...params, + 'openid.mode': 'check_authentication' + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + } + }); + + const verifyText = await verifyRes.text(); + if (!verifyText.includes('is_valid:true')) { + throw new Error('Steam OpenID validation failed'); + } + + const steamid = params['openid.claimed_id']?.split('/').pop(); + if (!steamid) { + throw new Error('Steam ID not found'); + } + + return ctx.success(c, { steamid }); + }); + } + }; +} diff --git a/packages/auth/src/provider/twitch.ts b/packages/auth/src/provider/twitch.ts new file mode 100644 index 00000000..2523b36c --- /dev/null +++ b/packages/auth/src/provider/twitch.ts @@ -0,0 +1,45 @@ +/** + * Use this provider to authenticate with Twitch. + * + * ```ts {5-8} + * import { TwitchProvider } from "@openauthjs/openauth/provider/twitch" + * + * export default issuer({ + * providers: { + * twitch: TwitchProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; + +export interface TwitchConfig extends Oauth2WrappedConfig {} + +/** + * Create a Twitch OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * TwitchProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function TwitchProvider(config: TwitchConfig) { + return Oauth2Provider({ + type: 'twitch', + ...config, + endpoint: { + authorization: 'https://id.twitch.tv/oauth2/authorize', + token: 'https://id.twitch.tv/oauth2/token' + } + }); +} diff --git a/packages/auth/src/provider/x.ts b/packages/auth/src/provider/x.ts new file mode 100644 index 00000000..398cdfe0 --- /dev/null +++ b/packages/auth/src/provider/x.ts @@ -0,0 +1,46 @@ +/** + * Use this provider to authenticate with X.com. + * + * ```ts {5-8} + * import { XProvider } from "@openauthjs/openauth/provider/x" + * + * export default issuer({ + * providers: { + * x: XProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; + +export interface XProviderConfig extends Oauth2WrappedConfig {} + +/** + * Create a X.com OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * XProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function XProvider(config: XProviderConfig) { + return Oauth2Provider({ + ...config, + type: 'x', + endpoint: { + authorization: 'https://twitter.com/i/oauth2/authorize', + token: 'https://api.x.com/2/oauth2/token' + }, + pkce: true + }); +} diff --git a/packages/auth/src/provider/yahoo.ts b/packages/auth/src/provider/yahoo.ts new file mode 100644 index 00000000..84058709 --- /dev/null +++ b/packages/auth/src/provider/yahoo.ts @@ -0,0 +1,45 @@ +/** + * Use this provider to authenticate with Yahoo. + * + * ```ts {5-8} + * import { YahooProvider } from "@openauthjs/openauth/provider/yahoo" + * + * export default issuer({ + * providers: { + * yahoo: YahooProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * } + * }) + * ``` + * + * @packageDocumentation + */ + +import { Oauth2Provider, Oauth2WrappedConfig } from './oauth2.js'; + +export interface YahooConfig extends Oauth2WrappedConfig {} + +/** + * Create a Yahoo OAuth2 provider. + * + * @param config - The config for the provider. + * @example + * ```ts + * YahooProvider({ + * clientID: "1234567890", + * clientSecret: "0987654321" + * }) + * ``` + */ +export function YahooProvider(config: YahooConfig) { + return Oauth2Provider({ + ...config, + type: 'yahoo', + endpoint: { + authorization: 'https://api.login.yahoo.com/oauth2/request_auth', + token: 'https://api.login.yahoo.com/oauth2/get_token' + } + }); +} diff --git a/packages/auth/src/random.ts b/packages/auth/src/random.ts new file mode 100644 index 00000000..dc6dec2d --- /dev/null +++ b/packages/auth/src/random.ts @@ -0,0 +1,24 @@ +import { timingSafeEqual } from 'node:crypto'; + +export function generateUnbiasedDigits(length: number): string { + const result: number[] = []; + while (result.length < length) { + const buffer = crypto.getRandomValues(new Uint8Array(length * 2)); + for (const byte of buffer) { + if (byte < 250 && result.length < length) { + result.push(byte % 10); + } + } + } + return result.join(''); +} + +export function timingSafeCompare(a: string, b: string): boolean { + if (typeof a !== 'string' || typeof b !== 'string') { + return false; + } + if (a.length !== b.length) { + return false; + } + return timingSafeEqual(Buffer.from(a), Buffer.from(b)); +} diff --git a/packages/auth/src/storage/aws.ts b/packages/auth/src/storage/aws.ts new file mode 100644 index 00000000..5edbcd6a --- /dev/null +++ b/packages/auth/src/storage/aws.ts @@ -0,0 +1,53 @@ +import { AwsClient } from 'aws4fetch'; + +interface EC2Credentials { + AccessKeyId: string; + SecretAccessKey: string; + Token: string; + Expiration: string; + Type: string; +} + +let cachedCredentials: EC2Credentials | null = null; + +async function getCredentials(url: string): Promise { + if (cachedCredentials) { + const currentTime = new Date(); + const fiveMinutesFromNow = new Date(currentTime.getTime() + 5 * 60000); + const expirationTime = new Date(cachedCredentials.Expiration); + if (expirationTime > fiveMinutesFromNow) { + return cachedCredentials; + } + } + + const credentials = (await fetch(url).then((res) => res.json())) as EC2Credentials; + cachedCredentials = credentials; + return credentials; +} + +export async function client(): Promise { + if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { + return new AwsClient({ + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY, + sessionToken: process.env.AWS_SESSION_TOKEN, + region: process.env.AWS_REGION + }); + } + + if (process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI) { + const credentials = await getCredentials( + 'http://169.254.170.2' + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI + ); + return new AwsClient({ + accessKeyId: credentials.AccessKeyId, + secretAccessKey: credentials.SecretAccessKey, + sessionToken: credentials.Token, + region: process.env.AWS_REGION + }); + } + + throw new Error('No AWS credentials found'); +} + +export type AwsOptions = Exclude[1], null | undefined>['aws']; diff --git a/packages/auth/src/storage/cloudflare.ts b/packages/auth/src/storage/cloudflare.ts new file mode 100644 index 00000000..fb1068d1 --- /dev/null +++ b/packages/auth/src/storage/cloudflare.ts @@ -0,0 +1,76 @@ +/** + * Configure OpenAuth to use [Cloudflare KV](https://developers.cloudflare.com/kv/) as a + * storage adapter. + * + * ```ts + * import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare" + * + * const storage = CloudflareStorage({ + * namespace: "my-namespace" + * }) + * + * + * export default issuer({ + * storage, + * // ... + * }) + * ``` + * + * @packageDocumentation + */ +import type { KVNamespace } from '@cloudflare/workers-types'; + +import { joinKey, splitKey, StorageAdapter } from './storage.js'; + +/** + * Configure the Cloudflare KV store that's created. + */ +export interface CloudflareStorageOptions { + namespace: KVNamespace; +} +/** + * Creates a Cloudflare KV store. + * @param options - The config for the adapter. + */ +export function CloudflareStorage(options: CloudflareStorageOptions): StorageAdapter { + return { + async get(key: string[]) { + const value = await options.namespace.get(joinKey(key), 'json'); + if (!value) return; + return value as Record; + }, + + async set(key: string[], value: any, expiry?: Date) { + await options.namespace.put(joinKey(key), JSON.stringify(value), { + expirationTtl: expiry + ? Math.max(Math.floor((expiry.getTime() - Date.now()) / 1000), 60) + : undefined + }); + }, + + async remove(key: string[]) { + await options.namespace.delete(joinKey(key)); + }, + + async *scan(prefix: string[]) { + let cursor: string | undefined; + while (true) { + const result = await options.namespace.list({ + prefix: joinKey([...prefix, '']), + cursor + }); + + for (const key of result.keys) { + const value = await options.namespace.get(key.name, 'json'); + if (value !== null) { + yield [splitKey(key.name), value]; + } + } + if (result.list_complete) { + break; + } + cursor = result.cursor; + } + } + }; +} diff --git a/packages/auth/src/storage/dynamo.ts b/packages/auth/src/storage/dynamo.ts new file mode 100644 index 00000000..0d7932cc --- /dev/null +++ b/packages/auth/src/storage/dynamo.ts @@ -0,0 +1,189 @@ +/** + * Configure OpenAuth to use [DynamoDB](https://aws.amazon.com/dynamodb/) as a storage adapter. + * + * ```ts + * import { DynamoStorage } from "@openauthjs/openauth/storage/dynamo" + * + * const storage = DynamoStorage({ + * table: "my-table", + * pk: "pk", + * sk: "sk" + * }) + * + * export default issuer({ + * storage, + * // ... + * }) + * ``` + * + * @packageDocumentation + */ + +import { client } from './aws.js'; +import { joinKey, StorageAdapter } from './storage.js'; + +/** + * Configure the DynamoDB table that's created. + * + * @example + * ```ts + * { + * table: "my-table", + * pk: "pk", + * sk: "sk" + * } + * ``` + */ +export interface DynamoStorageOptions { + /** + * The name of the DynamoDB table. + */ + table: string; + /** + * The primary key column name. + * @default "pk" + */ + pk?: string; + /** + * The sort key column name. + * @default "sk" + */ + sk?: string; + /** + * Endpoint URL for the DynamoDB service. Useful for local testing. + * @default "https://dynamodb.{region}.amazonaws.com" + */ + endpoint?: string; + /** + * The name of the time to live attribute. + * @default "expiry" + */ + ttl?: string; +} + +/** + * Creates a DynamoDB store. + * @param options - The config for the adapter. + */ +export function DynamoStorage(options: DynamoStorageOptions): StorageAdapter { + const pk = options.pk || 'pk'; + const sk = options.sk || 'sk'; + const ttl = options.ttl || 'expiry'; + const tableName = options.table; + + function parseKey(key: string[]) { + if (key.length === 2) { + return { + pk: key[0], + sk: key[1] + }; + } + return { + pk: joinKey(key.slice(0, 2)), + sk: joinKey(key.slice(2)) + }; + } + + async function dynamo(action: string, payload: any) { + const c = await client(); + const endpoint = options.endpoint || `https://dynamodb.${c.region}.amazonaws.com`; + const response = await c.fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-amz-json-1.0', + 'X-Amz-Target': `DynamoDB_20120810.${action}` + }, + body: JSON.stringify(payload) + }); + + if (!response.ok) { + throw new Error(`DynamoDB request failed: ${response.statusText}`); + } + + return response.json() as Promise; + } + + return { + async get(key: string[]) { + const { pk: keyPk, sk: keySk } = parseKey(key); + const params = { + TableName: tableName, + Key: { + [pk]: { S: keyPk }, + [sk]: { S: keySk } + } + }; + const result = await dynamo('GetItem', params); + if (!result.Item) return; + if (result.Item[ttl] && result.Item[ttl].N < Date.now() / 1000) { + return; + } + return JSON.parse(result.Item.value.S); + }, + + async set(key: string[], value: any, expiry?: Date) { + const parsed = parseKey(key); + const params = { + TableName: tableName, + Item: { + [pk]: { S: parsed.pk }, + [sk]: { S: parsed.sk }, + ...(expiry + ? { + [ttl]: { N: Math.floor(expiry.getTime() / 1000).toString() } + } + : {}), + value: { S: JSON.stringify(value) } + } + }; + await dynamo('PutItem', params); + }, + + async remove(key: string[]) { + const { pk: keyPk, sk: keySk } = parseKey(key); + const params = { + TableName: tableName, + Key: { + [pk]: { S: keyPk }, + [sk]: { S: keySk } + } + }; + + await dynamo('DeleteItem', params); + }, + + async *scan(prefix: string[]) { + const prefixPk = prefix.length >= 2 ? joinKey(prefix.slice(0, 2)) : prefix[0]; + const prefixSk = prefix.length > 2 ? joinKey(prefix.slice(2)) : ''; + let lastEvaluatedKey = undefined; + const now = Date.now() / 1000; + while (true) { + const params = { + TableName: tableName, + ExclusiveStartKey: lastEvaluatedKey, + KeyConditionExpression: prefixSk ? `#pk = :pk AND begins_with(#sk, :sk)` : `#pk = :pk`, + ExpressionAttributeNames: { + '#pk': pk, + ...(prefixSk && { '#sk': sk }) + }, + ExpressionAttributeValues: { + ':pk': { S: prefixPk }, + ...(prefixSk && { ':sk': { S: prefixSk } }) + } + }; + + const result = await dynamo('Query', params); + + for (const item of result.Items || []) { + if (item[ttl] && item[ttl].N < now) { + continue; + } + yield [[item[pk].S, item[sk].S], JSON.parse(item.value.S)]; + } + + if (!result.LastEvaluatedKey) break; + lastEvaluatedKey = result.LastEvaluatedKey; + } + } + }; +} diff --git a/packages/auth/src/storage/memory.ts b/packages/auth/src/storage/memory.ts new file mode 100644 index 00000000..0c979bc2 --- /dev/null +++ b/packages/auth/src/storage/memory.ts @@ -0,0 +1,133 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { writeFile } from 'node:fs/promises'; + +/** + * Configure OpenAuth to use a simple in-memory store. + * + * :::caution + * This is not meant to be used in production. + * ::: + * + * This is useful for testing and development. It's not meant to be used in production. + * + * ```ts + * import { MemoryStorage } from "@openauthjs/openauth/storage/memory" + * + * const storage = MemoryStorage() + * + * export default issuer({ + * storage, + * // ... + * }) + * ``` + * + * Optionally, you can persist the store to a file. + * + * ```ts + * MemoryStorage({ + * persist: "./persist.json" + * }) + * ``` + * + * @packageDocumentation + */ +import { joinKey, splitKey, StorageAdapter } from './storage.js'; + +/** + * Configure the memory store. + */ +export interface MemoryStorageOptions { + /** + * Optionally, backup the store to a file. So it'll be persisted when the issuer restarts. + * + * @example + * ```ts + * { + * persist: "./persist.json" + * } + * ``` + */ + persist?: string; +} +export function MemoryStorage(input?: MemoryStorageOptions): StorageAdapter { + const store = [] as [string, { value: Record; expiry?: number }][]; + + if (input?.persist) { + if (existsSync(input.persist)) { + const file = readFileSync(input?.persist); + store.push(...JSON.parse(file.toString())); + } + } + + async function save() { + if (!input?.persist) return; + const file = JSON.stringify(store); + await writeFile(input.persist, file); + } + + function search(key: string) { + let left = 0; + let right = store.length - 1; + while (left <= right) { + const mid = Math.floor((left + right) / 2); + const comparison = key.localeCompare(store[mid][0]); + + if (comparison === 0) { + return { found: true, index: mid }; + } else if (comparison < 0) { + right = mid - 1; + } else { + left = mid + 1; + } + } + return { found: false, index: left }; + } + return { + async get(key: string[]) { + const match = search(joinKey(key)); + if (!match.found) return undefined; + const entry = store[match.index][1]; + if (entry.expiry && Date.now() >= entry.expiry) { + store.splice(match.index, 1); + await save(); + return undefined; + } + return entry.value; + }, + async set(key: string[], value: any, expiry?: Date) { + const joined = joinKey(key); + const match = search(joined); + // Handle both Date objects and TTL numbers while maintaining Date type in signature + const entry = [ + joined, + { + value, + expiry: expiry ? expiry.getTime() : expiry + } + ] as (typeof store)[number]; + if (!match.found) { + store.splice(match.index, 0, entry); + } else { + store[match.index] = entry; + } + await save(); + }, + async remove(key: string[]) { + const joined = joinKey(key); + const match = search(joined); + if (match.found) { + store.splice(match.index, 1); + await save(); + } + }, + async *scan(prefix: string[]) { + const now = Date.now(); + const prefixStr = joinKey(prefix); + for (const [key, entry] of store) { + if (!key.startsWith(prefixStr)) continue; + if (entry.expiry && now >= entry.expiry) continue; + yield [splitKey(key), entry.value]; + } + } + }; +} diff --git a/packages/auth/src/storage/storage.ts b/packages/auth/src/storage/storage.ts new file mode 100644 index 00000000..efa10391 --- /dev/null +++ b/packages/auth/src/storage/storage.ts @@ -0,0 +1,38 @@ +export interface StorageAdapter { + get(key: string[]): Promise | undefined>; + remove(key: string[]): Promise; + set(key: string[], value: any, expiry?: Date): Promise; + scan(prefix: string[]): AsyncIterable<[string[], any]>; +} + +const SEPERATOR = String.fromCharCode(0x1f); + +export function joinKey(key: string[]) { + return key.join(SEPERATOR); +} + +export function splitKey(key: string) { + return key.split(SEPERATOR); +} + +export namespace Storage { + function encode(key: string[]) { + return key.map((k) => k.replaceAll(SEPERATOR, '')); + } + export function get(adapter: StorageAdapter, key: string[]) { + return adapter.get(encode(key)) as Promise; + } + + export function set(adapter: StorageAdapter, key: string[], value: any, ttl?: number) { + const expiry = ttl ? new Date(Date.now() + ttl * 1000) : undefined; + return adapter.set(encode(key), value, expiry); + } + + export function remove(adapter: StorageAdapter, key: string[]) { + return adapter.remove(encode(key)); + } + + export function scan(adapter: StorageAdapter, key: string[]): AsyncIterable<[string[], T]> { + return adapter.scan(encode(key)); + } +} diff --git a/packages/auth/src/subject.ts b/packages/auth/src/subject.ts new file mode 100644 index 00000000..8395c974 --- /dev/null +++ b/packages/auth/src/subject.ts @@ -0,0 +1,129 @@ +/** + * Subjects are what the access token generated at the end of the auth flow will map to. Under + * the hood, the access token is a JWT that contains this data. + * + * #### Define subjects + * + * ```ts title="subjects.ts" + * import { object, string } from "valibot" + * + * const subjects = createSubjects({ + * user: object({ + * userID: string() + * }) + * }) + * ``` + * + * We are using [valibot](https://github.com/fabian-hiller/valibot) here. You can use any + * validation library that's following the + * [standard-schema specification](https://github.com/standard-schema/standard-schema). + * + * :::tip + * You typically want to place subjects in its own file so it can be imported by all of your apps. + * ::: + * + * You can start with one subject. Later you can add more for different types of users. + * + * #### Set the subjects + * + * Then you can pass it to the `issuer`. + * + * ```ts title="issuer.ts" + * import { subjects } from "./subjects" + * + * const app = issuer({ + * providers: { ... }, + * subjects, + * // ... + * }) + * ``` + * + * #### Add the subject payload + * + * When your user completes the flow, you can add the subject payload in the `success` callback. + * + * ```ts title="issuer.ts" + * const app = issuer({ + * providers: { ... }, + * subjects, + * async success(ctx, value) { + * let userID + * if (value.provider === "password") { + * console.log(value.email) + * userID = ... // lookup user or create them + * } + * return ctx.subject("user", { + * userID + * }) + * }, + * // ... + * }) + * ``` + * + * Here we are looking up the userID from our database and adding it to the subject payload. + * + * :::caution + * You should only store properties that won't change for the lifetime of the user. + * ::: + * + * Since these will be stored in the access token, you should avoid storing information + * that'll change often. For example, if you store the user's username, you'll need to + * revoke the access token when the user changes their username. + * + * #### Decode the subject + * + * Now when your user logs in, you can use the OpenAuth client to decode the subject. For + * example, in our SSR app we can do the following. + * + * ```ts title="app/page.tsx" + * import { subjects } from "../subjects" + * + * const verified = await client.verify(subjects, cookies.get("access_token")!) + * console.log(verified.subject.properties.userID) + * ``` + * + * All this is typesafe based on the shape of the subjects you defined. + * + * @packageDocumentation + */ +import type { v1 } from '@standard-schema/spec'; + +import { Prettify } from './util.js'; + +/** + * Subject schema is a map of types that are used to define the subjects. + */ +export type SubjectSchema = Record; + +/** @internal */ +export type SubjectPayload = Prettify< + { + [type in keyof T & string]: { + type: type; + properties: v1.InferOutput; + }; + }[keyof T & string] +>; + +/** + * Create a subject schema. + * + * @example + * ```ts + * const subjects = createSubjects({ + * user: object({ + * userID: string() + * }), + * admin: object({ + * workspaceID: string() + * }) + * }) + * ``` + * + * This is using [valibot](https://github.com/fabian-hiller/valibot) to define the shape of the + * subjects. You can use any validation library that's following the + * [standard-schema specification](https://github.com/standard-schema/standard-schema). + */ +export function createSubjects(types: Schema): Schema { + return { ...types }; +} diff --git a/packages/auth/src/ui/base.tsx b/packages/auth/src/ui/base.tsx new file mode 100644 index 00000000..d61424f6 --- /dev/null +++ b/packages/auth/src/ui/base.tsx @@ -0,0 +1,104 @@ +import { PropsWithChildren } from 'hono/jsx'; + +import { getTheme } from './theme.js'; + +import css from './css.js'; + +export function Layout( + props: PropsWithChildren<{ + size?: 'small'; + }> +) { + const theme = getTheme(); + function get(key: 'primary' | 'background' | 'logo', mode: 'light' | 'dark') { + if (!theme) return; + if (!theme[key]) return; + if (typeof theme[key] === 'string') return theme[key]; + + return theme[key][mode] as string | undefined; + } + + const radius = (() => { + if (theme?.radius === 'none') return '0'; + if (theme?.radius === 'sm') return '1'; + if (theme?.radius === 'md') return '1.25'; + if (theme?.radius === 'lg') return '1.5'; + if (theme?.radius === 'full') return '1000000000001'; + return '1'; + })(); + + const hasLogo = get('logo', 'light') && get('logo', 'dark'); + + return ( + + + {theme?.title || 'OpenAuthJS'} + + + {theme?.favicon ? ( + + ) : ( + <> + + + + + + )} +