From 319ac92723dbf82be9ea27bf70bdc1b844968f5d Mon Sep 17 00:00:00 2001 From: soju Date: Sun, 24 May 2026 12:58:48 -0700 Subject: [PATCH 1/3] feat(live): activate per-token ownership (DEP-2 Part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the sonar belt-factory per-token owner index into live mode: - liveOwnerTokenIds(address, contractLower) queries the new Token index (collection + owner + isBurned=false) - liveCandiesBalances(address) for the ERC-1155 Candies case (CandiesHolderBalance, holder_id + amount>0) - live getHoldings now populates real tokenIds (was tokenIds: []) - getNftsForOwner backed by liveOwnerTokenIds in live mode, joining codex metadata, fail-soft to fixtures when the index is unreachable Also hardens the live getHoldings fail-soft: a fully-unreachable endpoint now degrades to fixture holdings + a degraded envelope instead of throwing (README contract). Hermetic coverage in tests/live-ownership.test.ts stubs fetch with the known belt schema shapes — the belt-factory branch is not yet deployed/reindexed so there is no live endpoint to verify against. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/sonar-ownership-gap.md | 36 +++++- src/inventory.ts | 122 ++++++++++++++------ src/live-sonar.ts | 77 ++++++++++++- tests/live-ownership.test.ts | 209 +++++++++++++++++++++++++++++++++++ 4 files changed, 398 insertions(+), 46 deletions(-) create mode 100644 tests/live-ownership.test.ts diff --git a/docs/sonar-ownership-gap.md b/docs/sonar-ownership-gap.md index 228f352..4b5a40c 100644 --- a/docs/sonar-ownership-gap.md +++ b/docs/sonar-ownership-gap.md @@ -1,6 +1,8 @@ # Sonar ownership gap — belt change request -**Status:** open · **Owner:** sonar (freeside-sonar belt-factory) · **Filed:** 2026-05-23 +**Status:** belt change MERGED (sonar `cycle/sonar-belt-factory`, 2026-05-24) — inventory +activation landed (DEP-2). Pending live verification once the belt is deployed + reindexed. +· **Owner:** sonar (freeside-sonar belt-factory) · **Filed:** 2026-05-23 ## The gap @@ -41,9 +43,31 @@ owner = `to` of the latest transfer, excluding burns) or a dedicated Acceptance: `Token(where: {collection: {_eq: "0x6666…420"}, owner: {_eq: }})` returns the holder's current tokenIds; counts reconcile with `TrackedHolder.tokenCount`. -## Inventory activation (no further inventory change needed) +## Inventory activation (DONE — DEP-2, 2026-05-24) -`src/live-sonar.ts` already isolates the live queries. When the index lands, add -`liveOwnerTokenIds(address, collectionKey)` and populate `tokenIds` in the live -`getHoldings` branch + back `getNftsForOwner` with it. Until then, live `getHoldings` -returns real `tokenCount` with `tokenIds: []`, and `getNftsForOwner` stays on fixtures. +`src/live-sonar.ts` isolated the live queries; the activation is now wired: + +- `liveOwnerTokenIds(address, contractLower)` queries the new `Token` index: + `Token(where: {collection: {_eq}, owner: {_eq}, isBurned: {_eq: false}}) { tokenId }`. +- `liveCandiesBalances(address)` covers the ERC-1155 (Candies) case via + `CandiesHolderBalance(where: {holder_id: {_eq}, amount: {_gt: "0"}}) { contract tokenId amount }`. +- The live `getHoldings` branch now populates real `tokenIds` (fail-soft: a missing + `Token` sub-query degrades `tokenIds` to `[]` while keeping the real `tokenCount`; + a fully-unreachable endpoint degrades to fixture holdings + a `degraded` envelope). +- `getNftsForOwner` is backed by `liveOwnerTokenIds` in live mode (joining codex + metadata), and fail-softs to fixtures when the index is unreachable. + +Hermetic coverage: `tests/live-ownership.test.ts` exercises these paths offline by +stubbing `fetch` with the known belt schema shapes (the belt-factory branch is not +yet deployed/reindexed, so no live endpoint exists to verify against yet). + +### Unverified / open + +- **Candies filter field.** The DEP-2 spec documents the holder filter as the Hasura + relationship key `holder_id`; if the deployed schema names it `holder`, change only + `CANDIES_HOLDER_FILTER_FIELD` in `src/live-sonar.ts`. `liveCandiesBalances` is wired + and tested but NOT yet surfaced in `getHoldings`/`getNftsForOwner` (Candies is a + distinct collection; surfacing it is a follow-up once the contract address + + collectionKey are registered the way Mibera is). +- **Live reconciliation.** `tokenIds.length` should reconcile with + `TrackedHolder.tokenCount`; confirm once the belt is live (`live-smoke` test). diff --git a/src/inventory.ts b/src/inventory.ts index 3590046..4beedc0 100644 --- a/src/inventory.ts +++ b/src/inventory.ts @@ -54,8 +54,11 @@ export async function getHoldings( : [MIBERA_CONTRACT]; // Live mode (SONAR_GRAPHQL_ENDPOINT set): real holder counts + real completeness - // from the belt-gateway. tokenIds await the sonar per-token ownership index — - // see docs/sonar-ownership-gap.md (per ADR-008, that index is sonar's to publish). + // from the belt-gateway, plus per-token ownership now that the sonar belt-factory + // branch publishes the `Token` index (DEP-2 unblock — docs/sonar-ownership-gap.md). + // Fail-soft throughout: count comes from TrackedHolder; tokenIds come from the + // `Token` index but degrade to [] (not a crash) if that sub-query is unavailable, + // so a partial belt still serves real counts. if (liveSonar.isLiveMode()) { const completeness = await buildEnvelopeLive( MIBERA_CONTRACT, @@ -65,12 +68,43 @@ export async function getHoldings( const liveHoldings: ContractHolding[] = []; for (const contractAddress of targetContracts) { const chainId = options.chains?.[0] ?? MIBERA_CHAIN_ID; - const count = await liveSonar.liveHolderTokenCount( - checksummedAddress, - MIBERA_COLLECTION_KEY - ); + + let count: number; + try { + count = await liveSonar.liveHolderTokenCount( + checksummedAddress, + MIBERA_COLLECTION_KEY + ); + } catch { + // Endpoint unreachable for the count query — the completeness envelope + // is already `degraded` (buildEnvelopeLive caught the same outage). + // Degrade this contract's holdings to the fixture instead of crashing + // the whole response (README: unreachable -> fixture + degraded). + const tokens = sonarClient.getTokensByOwner( + checksummedAddress, + contractAddress, + chainId + ); + if (tokens.length === 0) continue; + liveHoldings.push({ + contractAddress, + chainId, + tokenCount: tokens.length, + tokenIds: tokens.map((t) => t.tokenId), + }); + continue; + } + if (count === 0) continue; - liveHoldings.push({ contractAddress, chainId, tokenCount: count, tokenIds: [] }); + let tokenIds: string[] = []; + try { + tokenIds = await liveSonar.liveOwnerTokenIds(checksummedAddress, contractAddress); + } catch { + // Per-token index unavailable (older belt / transient) — keep the real + // count, leave tokenIds empty. Never let this fail the whole response. + tokenIds = []; + } + liveHoldings.push({ contractAddress, chainId, tokenCount: count, tokenIds }); } return { holdings: liveHoldings, completeness }; } @@ -95,6 +129,31 @@ export async function getHoldings( return { holdings, completeness }; } +/** Build a single NFT record from a tokenId by joining codex metadata. */ +function tokenIdToNFT(tokenId: string) { + const record = codexClient.getToken(tokenId); + if (!record) { + // Minimal fallback if codex record is missing + return { + tokenId, + name: `Mibera #${tokenId}`, + description: "Unknown", + imageUrl: "", + contentType: "image/png", + attributes: [], + }; + } + const imageUrl = codexClient.getImageUrl(tokenId) ?? ""; + const grailRecord = codexClient.getGrailRecord(tokenId); + return codexToNFT( + tokenId, + record, + imageUrl, + codexClient.isGrail(tokenId), + grailRecord + ); +} + export async function getNftsForOwner( address: string, contract: string, @@ -104,41 +163,32 @@ export async function getNftsForOwner( const checksummedContract = validateAddress(contract, "contract"); const chainId = MIBERA_CHAIN_ID; - const tokens = sonarClient.getTokensByOwner( - checksummedAddress, - checksummedContract, - chainId - ); + // Resolve owner -> tokenIds. In live mode the sonar `Token` index is the + // source of truth (DEP-2 unblock); fail-soft to fixtures if it is + // unreachable so the gallery still renders something offline. In hermetic + // mode the fixture sonar client is the only source. + let tokenIds: string[]; + if (liveSonar.isLiveMode()) { + try { + tokenIds = await liveSonar.liveOwnerTokenIds(checksummedAddress, checksummedContract); + } catch { + tokenIds = sonarClient + .getTokensByOwner(checksummedAddress, checksummedContract, chainId) + .map((t) => t.tokenId); + } + } else { + tokenIds = sonarClient + .getTokensByOwner(checksummedAddress, checksummedContract, chainId) + .map((t) => t.tokenId); + } const { page, nextPageKey } = applyPagination( - tokens, + tokenIds, options.pageSize ?? 100, options.pageKey ); - const nfts = page.map((token) => { - const record = codexClient.getToken(token.tokenId); - if (!record) { - // Minimal fallback if codex record is missing - return { - tokenId: token.tokenId, - name: `Mibera #${token.tokenId}`, - description: "Unknown", - imageUrl: "", - contentType: "image/png", - attributes: [], - }; - } - const imageUrl = codexClient.getImageUrl(token.tokenId) ?? ""; - const grailRecord = codexClient.getGrailRecord(token.tokenId); - return codexToNFT( - token.tokenId, - record, - imageUrl, - codexClient.isGrail(token.tokenId), - grailRecord - ); - }); + const nfts = page.map(tokenIdToNFT); const collectionMeta = codexClient.getCollectionMeta(checksummedContract); diff --git a/src/live-sonar.ts b/src/live-sonar.ts index 3a1eb0f..95d25a2 100644 --- a/src/live-sonar.ts +++ b/src/live-sonar.ts @@ -3,10 +3,12 @@ // which keeps the test suite offline). Fail-soft: callers fall back to fixture + // a `degraded` completeness flag when the live endpoint is unreachable. // -// Scope today = what the Mibera belt actually exposes: chain head (as_of_block) -// + holder counts (TrackedHolder). Per-token current ownership (owner -> tokenIds) -// is NOT yet published by the belt — see docs/sonar-ownership-gap.md. Per ADR-008's -// belt model, that index is sonar's to publish, not inventory's to derive. +// Scope: chain head (as_of_block) + holder counts (TrackedHolder) + per-token +// current ownership. The per-token owner index (`Token` for ERC-721, +// `CandiesHolderBalance` for ERC-1155) shipped to sonar's cycle/sonar-belt-factory +// branch (DEP-2 unblock, 2026-05-24) — owner→tokenIds is now queryable. Per +// ADR-008's belt model that index is sonar's to publish, not inventory's to derive; +// inventory only consumes it. See docs/sonar-ownership-gap.md. // // Current production endpoint (2026-05-23, not committed to the sonar repo): // https:///v1/graphql @@ -68,3 +70,70 @@ export async function liveHolderTokenCount( ); return d.TrackedHolder.reduce((sum, h) => sum + h.tokenCount, 0); } + +/** + * A holder's current ERC-721 tokenIds for a collection — the per-token owner + * index that the sonar belt-factory branch now publishes (DEP-2 unblock). + * + * Query shape per the new `Token` entity (owner + collection are indexed): + * Token(where: { collection: {_eq: }, + * owner: {_eq: }, + * isBurned: {_eq: false} }) { tokenId } + * + * Returns the tokenIds as strings (matching ContractHolding.tokenIds). The + * filter uses lowercased address + lowercased contract — sonar indexes both + * lowercased (same convention as `liveHolderTokenCount`'s `address` filter). + */ +export async function liveOwnerTokenIds( + address: string, + contractLower: string +): Promise { + const addr = JSON.stringify(address.toLowerCase()); + const coll = JSON.stringify(contractLower.toLowerCase()); + const d = await query<{ Token: { tokenId: string }[] }>( + `{ Token(where: {collection: {_eq: ${coll}}, owner: {_eq: ${addr}}, isBurned: {_eq: false}}) { tokenId } }` + ); + return d.Token.map((t) => String(t.tokenId)); +} + +/** A single ERC-1155 (Candies) balance row for a holder. */ +export interface LiveCandiesBalance { + contract: string; + tokenId: string; + amount: string; +} + +/** + * A holder's current ERC-1155 (Candies) balances — the `CandiesHolderBalance` + * entity from the belt-factory branch. + * + * Query shape (amount stored as a numeric string; filter `_gt: "0"` excludes + * zero balances): + * CandiesHolderBalance(where: { holder_id: {_eq: }, + * amount: {_gt: "0"} }) + * { contract tokenId amount } + * + * NOTE (unverified — sonar belt-factory not yet deployed/reindexed): the exact + * holder filter field is the Hasura relationship key `holder_id` per the DEP-2 + * spec. If the deployed schema names it `holder` instead, change only + * `CANDIES_HOLDER_FILTER_FIELD` below. The codebase's other live queries filter + * scalar columns directly (e.g. `address`), so a relationship `_id` suffix is + * the documented-but-unconfirmed shape. + */ +const CANDIES_HOLDER_FILTER_FIELD = "holder_id"; + +export async function liveCandiesBalances( + address: string +): Promise { + const addr = JSON.stringify(address.toLowerCase()); + const d = await query<{ + CandiesHolderBalance: { contract: string; tokenId: string; amount: string }[]; + }>( + `{ CandiesHolderBalance(where: {${CANDIES_HOLDER_FILTER_FIELD}: {_eq: ${addr}}, amount: {_gt: "0"}}) { contract tokenId amount } }` + ); + return d.CandiesHolderBalance.map((c) => ({ + contract: c.contract, + tokenId: String(c.tokenId), + amount: String(c.amount), + })); +} diff --git a/tests/live-ownership.test.ts b/tests/live-ownership.test.ts new file mode 100644 index 0000000..b035c7a --- /dev/null +++ b/tests/live-ownership.test.ts @@ -0,0 +1,209 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { getHoldings, getNftsForOwner } from '../src/inventory.js'; +import { + liveOwnerTokenIds, + liveCandiesBalances, +} from '../src/live-sonar.js'; + +/** + * Hermetic live-mode tests for the DEP-2 ownership activation. + * + * The sonar belt-factory branch that publishes the per-token `Token` index + * is NOT yet deployed/reindexed, so we cannot hit a real endpoint. Instead we + * set SONAR_GRAPHQL_ENDPOINT (to flip the module into live mode) and stub + * `fetch` with a tiny GraphQL responder that returns the KNOWN belt schema + * shapes — `Token`, `CandiesHolderBalance`, `TrackedHolder`, `chain_metadata`. + * This exercises the real query-construction + join code paths offline. + */ + +const MIBERA_CONTRACT = '0x6666397DFe9a8c469BF65dc744CB1C733416c420'; +const HOLDER = '0x1111111111111111111111111111111111111111'; +const EMPTY = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; +const ENDPOINT = 'https://belt-gateway.test/v1/graphql'; + +// Minimal GraphQL responder: routes on the entity named in the query string. +// Mirrors the belt-gateway's `{ data, errors }` envelope. +function makeFetchStub(opts: { + tokenIds?: string[]; + candies?: { contract: string; tokenId: string; amount: string }[]; + trackedCount?: number; + failTokenIndex?: boolean; + capture?: (gql: string) => void; +}) { + const { + tokenIds = [], + candies = [], + trackedCount = tokenIds.length, + failTokenIndex = false, + capture, + } = opts; + return async (_url: string, init: { body: string }) => { + const { query: gql } = JSON.parse(init.body) as { query: string }; + capture?.(gql); + const data: Record = {}; + if (gql.includes('chain_metadata')) { + data.chain_metadata = [{ latest_processed_block: 30_111_222 }]; + } + if (gql.includes('TrackedHolder_aggregate')) { + data.TrackedHolder_aggregate = { aggregate: { count: 4242 } }; + } else if (gql.includes('TrackedHolder')) { + data.TrackedHolder = + trackedCount > 0 ? [{ tokenCount: trackedCount }] : []; + } + if (gql.includes('Token(')) { + if (failTokenIndex) { + return { + ok: true, + json: async () => ({ errors: [{ message: 'Token index unavailable' }] }), + }; + } + data.Token = tokenIds.map((tokenId) => ({ tokenId })); + } + if (gql.includes('CandiesHolderBalance')) { + data.CandiesHolderBalance = candies; + } + return { ok: true, json: async () => ({ data }) }; + }; +} + +describe('live ownership (DEP-2, hermetic via fetch stub)', () => { + beforeEach(() => { + process.env.SONAR_GRAPHQL_ENDPOINT = ENDPOINT; + }); + afterEach(() => { + delete process.env.SONAR_GRAPHQL_ENDPOINT; + vi.unstubAllGlobals(); + }); + + describe('liveOwnerTokenIds', () => { + it('queries the Token index with owner + collection + isBurned=false', async () => { + let captured = ''; + vi.stubGlobal( + 'fetch', + makeFetchStub({ tokenIds: ['1', '12', '2769'], capture: (g) => { + if (g.includes('Token(')) captured = g; + } }) + ); + const ids = await liveOwnerTokenIds(HOLDER, MIBERA_CONTRACT); + expect(ids).toEqual(['1', '12', '2769']); + // Address + contract are lowercased in the filter. + expect(captured).toContain(HOLDER.toLowerCase()); + expect(captured).toContain(MIBERA_CONTRACT.toLowerCase()); + expect(captured).toContain('isBurned'); + expect(captured).toContain('owner'); + expect(captured).toContain('collection'); + }); + + it('returns an empty array when the holder owns no tokens', async () => { + vi.stubGlobal('fetch', makeFetchStub({ tokenIds: [] })); + const ids = await liveOwnerTokenIds(EMPTY, MIBERA_CONTRACT); + expect(ids).toEqual([]); + }); + + it('coerces numeric tokenIds to strings', async () => { + vi.stubGlobal('fetch', async () => ({ + ok: true, + json: async () => ({ data: { Token: [{ tokenId: 7 }, { tokenId: 8 }] } }), + })); + const ids = await liveOwnerTokenIds(HOLDER, MIBERA_CONTRACT); + expect(ids).toEqual(['7', '8']); + }); + }); + + describe('liveCandiesBalances (ERC-1155)', () => { + it('queries CandiesHolderBalance with the holder filter + amount > 0', async () => { + let captured = ''; + vi.stubGlobal( + 'fetch', + makeFetchStub({ + candies: [ + { contract: '0xcandy', tokenId: '5', amount: '3' }, + { contract: '0xcandy', tokenId: '9', amount: '1' }, + ], + capture: (g) => { + if (g.includes('CandiesHolderBalance')) captured = g; + }, + }) + ); + const balances = await liveCandiesBalances(HOLDER); + expect(balances).toHaveLength(2); + expect(balances[0]).toEqual({ contract: '0xcandy', tokenId: '5', amount: '3' }); + expect(captured).toContain(HOLDER.toLowerCase()); + expect(captured).toContain('amount'); + expect(captured).toContain('_gt'); + }); + }); + + describe('getHoldings (live) populates tokenIds from the Token index', () => { + it('returns real tokenCount AND real tokenIds', async () => { + vi.stubGlobal( + 'fetch', + makeFetchStub({ tokenIds: ['1', '2', '3'], trackedCount: 3 }) + ); + const r = await getHoldings(HOLDER); + expect(r.completeness.complete).toBe(true); + expect(r.completeness.as_of_block).toBe(30_111_222); + expect(r.holdings).toHaveLength(1); + expect(r.holdings[0].tokenCount).toBe(3); + expect(r.holdings[0].tokenIds).toEqual(['1', '2', '3']); + }); + + it('fail-soft: keeps real count with empty tokenIds when Token index errors', async () => { + vi.stubGlobal( + 'fetch', + makeFetchStub({ trackedCount: 5, failTokenIndex: true }) + ); + const r = await getHoldings(HOLDER); + expect(r.holdings[0].tokenCount).toBe(5); + expect(r.holdings[0].tokenIds).toEqual([]); + }); + + it('fail-soft: degrades to fixture holdings when endpoint is fully unreachable', async () => { + vi.stubGlobal('fetch', () => { + throw new Error('network down'); + }); + const r = await getHoldings(HOLDER); + // README contract: unreachable -> fixture + degraded (never a crash). + expect(r.completeness.complete).toBe('degraded'); + // Holdings degrade to the fixture (HOLDER owns 12 there). + expect(r.holdings).toHaveLength(1); + expect(r.holdings[0].tokenCount).toBe(12); + expect(r.holdings[0].tokenIds).toHaveLength(12); + }); + }); + + describe('getNftsForOwner (live) joins codex metadata onto live tokenIds', () => { + it('builds NFTs from the live Token index (real codex traits)', async () => { + vi.stubGlobal('fetch', makeFetchStub({ tokenIds: ['1', '2769'] })); + const col = await getNftsForOwner(HOLDER, MIBERA_CONTRACT); + expect(col.nfts).toHaveLength(2); + const ids = col.nfts.map((n) => n.tokenId); + expect(ids).toContain('1'); + expect(ids).toContain('2769'); + // Codex join still works: token 1 is a real generative record. + const gen = col.nfts.find((n) => n.tokenId === '1'); + expect(gen!.name).toBe('Mibera #1'); + expect(gen!.attributes.length).toBeGreaterThanOrEqual(10); + // token 2769 is a pinned grail. + const grail = col.nfts.find((n) => n.tokenId === '2769'); + expect(grail!.name).toBe('Air'); + }); + + it('paginates over the live tokenId list', async () => { + vi.stubGlobal( + 'fetch', + makeFetchStub({ tokenIds: ['1', '2', '3', '4', '5'] }) + ); + const first = await getNftsForOwner(HOLDER, MIBERA_CONTRACT, { pageSize: 2 }); + expect(first.nfts).toHaveLength(2); + expect(first.pageKey).toBeDefined(); + }); + + it('fail-soft: falls back to fixtures when the Token index errors', async () => { + vi.stubGlobal('fetch', makeFetchStub({ failTokenIndex: true })); + // HOLDER (0x111..1) owns 12 tokens in the fixture. + const col = await getNftsForOwner(HOLDER, MIBERA_CONTRACT); + expect(col.nfts).toHaveLength(12); + }); + }); +}); From b0dab411bc61b5aa54a81ffd19d29729d86891f7 Mon Sep 17 00:00:00 2001 From: soju Date: Sun, 24 May 2026 13:02:20 -0700 Subject: [PATCH 2/3] feat(server): thin Bun HTTP + MCP transport over the library (DEP-2 Part 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a service transport exposing getHoldings / getNftsForOwner / getNftMetadata over HTTP, with an OpenAPI 3.1 spec + MCP tool manifest derived from a single ROUTES table (src/server/). The library stays the core — the server only calls it; index.ts/types.ts exports are unchanged. - src/server/routes.ts: single source-of-truth route table - src/server/openapi.ts: OpenAPI 3.1 doc with component schemas mirroring types.ts - src/server/mcp.ts: MCP tool manifest (Hyper-shaped) - src/server/server.ts: Bun.serve runtime + exported `handle` fetch handler - src/server/emit-openapi.ts: writes openapi.json (consumer drift-CI anchor) - openapi.json: emitted 3.1 spec, committed for drift-CI - tests/server-transport.test.ts: hermetic (calls handle() directly, no port/Bun) Full Hyper (hyperjs.ai) adoption deferred: it installs cleanly in isolation but vendoring ~22 Bun-coupled framework files + a @hyper alias would dominate the diff and break this repo's Node-pure library contract. The minimal server follows Hyper's route/OpenAPI/MCP conventions so a later swap is low-friction. Rationale in src/server/README.md. The library tsconfig excludes src/server so `npm run build`/`typecheck` stay Node-pure; the server has its own tsconfig.server.json + `npm run typecheck:server`. Co-Authored-By: Claude Opus 4.7 (1M context) --- .well-known/beacon.json | 7 +- README.md | 38 ++- openapi.json | 437 +++++++++++++++++++++++++++++++++ package.json | 5 +- src/server/README.md | 69 ++++++ src/server/bun.d.ts | 24 ++ src/server/emit-openapi.ts | 20 ++ src/server/mcp.ts | 48 ++++ src/server/openapi.ts | 160 ++++++++++++ src/server/routes.ts | 178 ++++++++++++++ src/server/server.ts | 125 ++++++++++ tests/server-transport.test.ts | 142 +++++++++++ tsconfig.json | 2 +- tsconfig.server.json | 9 + 14 files changed, 1253 insertions(+), 11 deletions(-) create mode 100644 openapi.json create mode 100644 src/server/README.md create mode 100644 src/server/bun.d.ts create mode 100644 src/server/emit-openapi.ts create mode 100644 src/server/mcp.ts create mode 100644 src/server/openapi.ts create mode 100644 src/server/routes.ts create mode 100644 src/server/server.ts create mode 100644 tests/server-transport.test.ts create mode 100644 tsconfig.server.json diff --git a/.well-known/beacon.json b/.well-known/beacon.json index f286965..6b4d0eb 100644 --- a/.well-known/beacon.json +++ b/.well-known/beacon.json @@ -24,10 +24,15 @@ } ], "capabilities": ["getHoldings", "getNftsForOwner", "getNftMetadata", "getProfilePicture"], + "transport": { + "http": ["GET /holdings/{address}", "GET /nfts/{contract}/owner/{address}", "GET /nfts/{contract}/{tokenId}"], + "openapi": "/openapi.json (3.1)", + "mcp": "/.well-known/mcp.json" + }, "cycle_state": { "status": "candidate", "since": "2026-05-23", "next_review": "2026-08-21" }, - "_comment": "Library stage. Live counts + ACVP envelope wired to belt-gateway; per-token ownership (getNftsForOwner/getProfilePicture rich path) + MCP/HTTP deployment pending the sonar owner-token index (docs/sonar-ownership-gap.md). composes_with omitted pending port-schema sealing (ADR-007 App A.2 / ADR-008 §D-11) — consumes sonar-api + storage-api per README." + "_comment": "Library + thin Bun HTTP/MCP transport (src/server/, DEP-2). Live counts + ACVP envelope wired to belt-gateway; per-token ownership (Token + CandiesHolderBalance) activated against sonar cycle/sonar-belt-factory (DEP-2) — covered hermetically, pending live deploy/reindex verification (docs/sonar-ownership-gap.md). Full Hyper adoption deferred for a minimal Bun.serve to keep the library Node-pure (src/server/README.md). composes_with omitted pending port-schema sealing (ADR-007 App A.2 / ADR-008 §D-11) — consumes sonar-api + storage-api per README." } diff --git a/README.md b/README.md index 79c2729..903bd3e 100644 --- a/README.md +++ b/README.md @@ -17,18 +17,35 @@ - Does **NOT** proxy chain RPC — it consumes `freeside-sonar` for indexed reads. - Does **NOT** own metadata — that's the codex (today) / `freeside-storage` (sovereign target). -## API +## API (library) ```ts -import { getHoldings, getNftsForOwner, getNftMetadata } from "@freeside/inventory"; +import { getHoldings, getNftsForOwner, getNftMetadata } from "@0xhoneyjar/inventory"; ``` | Method | Returns | Source | |--------|---------|--------| -| `getHoldings(address)` | holdings + completeness envelope | sonar (counts) | +| `getHoldings(address)` | holdings (counts + tokenIds) + completeness envelope | sonar | | `getNftsForOwner(address, contract)` | paginated NFTs w/ metadata | sonar ⨝ codex | | `getNftMetadata(contract, tokenId)` | single MetadataDocument | codex | +## Service transport (HTTP + MCP) + +Consumers (e.g. a Next.js frontend) use this building over **HTTP + MCP**, not +as an npm import. A thin Bun transport (`src/server/`) exposes the library +functions as routes and emits an OpenAPI 3.1 spec + an MCP tool manifest from a +single route table. The library stays the core — the server only calls it. + +```bash +bun run serve # GET /holdings/:address, /nfts/:contract/owner/:address, /nfts/:contract/:tokenId +bun run openapi:emit # writes openapi.json (the consumer's drift-CI anchor) +``` + +Discovery: `GET /openapi.json` (OpenAPI 3.1) and `GET /.well-known/mcp.json` +(MCP tools). Full Hyper (hyperjs.ai) adoption was deferred in favor of a +minimal `Bun.serve` to keep the library Node-pure — see +[`src/server/README.md`](src/server/README.md) for the rationale. + ## Modes - **Hermetic (default):** reads bundled fixtures — the test suite runs fully offline (`npm test`). @@ -39,12 +56,17 @@ import { getHoldings, getNftsForOwner, getNftMetadata } from "@freeside/inventor SONAR_GRAPHQL_ENDPOINT=https:///v1/graphql npm test -- live-smoke ``` -## Known gap +## Ownership activation (DEP-2) -Per-token current ownership (`owner → tokenIds`) is not yet published by the sonar belt -(`Token` entity empty for Mibera). Per ADR-008's belt model that index is **sonar's to publish**, -not inventory's to derive. Until it lands, live `getHoldings` returns real `tokenCount` with -`tokenIds: []`, and `getNftsForOwner` stays on fixtures. See [`docs/sonar-ownership-gap.md`](docs/sonar-ownership-gap.md). +Per-token current ownership (`owner → tokenIds`) is now wired to the sonar belt's +`Token` index (ERC-721) + `CandiesHolderBalance` (ERC-1155 Candies), merged to sonar's +`cycle/sonar-belt-factory` branch. Live `getHoldings` populates real `tokenIds` and +`getNftsForOwner` joins live ownership with codex metadata; both fail-soft to fixtures +when the index is unreachable. **Not yet verified against a live endpoint** — the belt +branch is merged but not yet deployed/reindexed, so the activation is covered hermetically +(`tests/live-ownership.test.ts` stubs the known belt schema shapes). Per ADR-008's belt +model that index is **sonar's to publish**, not inventory's to derive. See +[`docs/sonar-ownership-gap.md`](docs/sonar-ownership-gap.md). ## Provenance diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..0e8fbe6 --- /dev/null +++ b/openapi.json @@ -0,0 +1,437 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "inventory-api", + "version": "0.1.0", + "description": "Sovereign read-side inventory aggregator — joins sonar holdings with owned codex metadata + a completeness envelope." + }, + "paths": { + "/holdings/{address}": { + "get": { + "operationId": "getHoldings", + "summary": "Resolve a wallet's holdings + ACVP completeness envelope", + "tags": [ + "inventory" + ], + "parameters": [ + { + "name": "address", + "in": "path", + "required": true, + "description": "Holder wallet address (0x-prefixed, 40 hex chars).", + "schema": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + } + }, + { + "name": "contracts", + "in": "query", + "required": false, + "description": "Comma-separated contract addresses to filter to.", + "schema": { + "type": "string" + } + }, + { + "name": "chains", + "in": "query", + "required": false, + "description": "Comma-separated chain ids to filter to.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HoldingsResponse" + } + } + } + }, + "400": { + "description": "validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/nfts/{contract}/owner/{address}": { + "get": { + "operationId": "getNftsForOwner", + "summary": "Paginated NFTs (sonar ⨝ codex) owned by a wallet", + "tags": [ + "inventory" + ], + "parameters": [ + { + "name": "contract", + "in": "path", + "required": true, + "description": "Collection contract address (0x-prefixed, 40 hex chars).", + "schema": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + } + }, + { + "name": "address", + "in": "path", + "required": true, + "description": "Holder wallet address (0x-prefixed, 40 hex chars).", + "schema": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + } + }, + { + "name": "pageSize", + "in": "query", + "required": false, + "description": "Page size (1-100, default 100).", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "pageKey", + "in": "query", + "required": false, + "description": "Opaque pagination cursor from a prior response.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NFTCollection" + } + } + } + }, + "400": { + "description": "validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/nfts/{contract}/{tokenId}": { + "get": { + "operationId": "getNftMetadata", + "summary": "Single NFT metadata document from the codex", + "tags": [ + "inventory" + ], + "parameters": [ + { + "name": "contract", + "in": "path", + "required": true, + "description": "Collection contract address (0x-prefixed, 40 hex chars).", + "schema": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + } + }, + { + "name": "tokenId", + "in": "path", + "required": true, + "description": "Numeric token id.", + "schema": { + "type": "string", + "pattern": "^\\d+$" + } + } + ], + "responses": { + "200": { + "description": "success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MetadataDocument" + } + } + } + }, + "400": { + "description": "validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Attribute": { + "type": "object", + "required": [ + "trait_type", + "value" + ], + "properties": { + "trait_type": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, + "CompletenessEnvelope": { + "type": "object", + "required": [ + "as_of_block", + "holder_count", + "source", + "complete" + ], + "properties": { + "as_of_block": { + "type": "integer" + }, + "holder_count": { + "type": "integer" + }, + "source": { + "type": "string", + "enum": [ + "sonar" + ] + }, + "complete": { + "description": "true when the answer is provably complete; 'degraded' when upstream was unreachable.", + "oneOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "string", + "enum": [ + "degraded" + ] + } + ] + } + } + }, + "ContractHolding": { + "type": "object", + "required": [ + "contractAddress", + "chainId", + "tokenCount", + "tokenIds" + ], + "properties": { + "contractAddress": { + "type": "string" + }, + "chainId": { + "type": "integer" + }, + "tokenCount": { + "type": "integer" + }, + "tokenIds": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "HoldingsResponse": { + "type": "object", + "required": [ + "holdings", + "completeness" + ], + "properties": { + "holdings": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ContractHolding" + } + }, + "completeness": { + "$ref": "#/components/schemas/CompletenessEnvelope" + } + } + }, + "NFT": { + "type": "object", + "required": [ + "tokenId", + "name", + "description", + "imageUrl", + "contentType", + "attributes" + ], + "properties": { + "tokenId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "imageUrl": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attribute" + } + } + } + }, + "NFTCollection": { + "type": "object", + "required": [ + "contractAddress", + "name", + "symbol", + "totalSupply", + "nfts" + ], + "properties": { + "contractAddress": { + "type": "string" + }, + "name": { + "type": "string" + }, + "symbol": { + "type": "string" + }, + "totalSupply": { + "type": "integer" + }, + "nfts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/NFT" + } + }, + "pageKey": { + "type": "string" + } + } + }, + "MetadataDocument": { + "type": "object", + "required": [ + "name", + "description", + "image", + "attributes" + ], + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "image": { + "type": "string" + }, + "attributes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Attribute" + } + } + } + }, + "Error": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + }, + "code": { + "type": "string" + } + } + } + } + } +} diff --git a/package.json b/package.json index 2632c09..0df0efa 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,10 @@ "scripts": { "build": "tsc", "test": "vitest run", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "typecheck:server": "tsc --noEmit -p tsconfig.server.json", + "serve": "bun src/server/server.ts", + "openapi:emit": "bun src/server/emit-openapi.ts" }, "dependencies": { "ethereum-cryptography": "^2.2.1" diff --git a/src/server/README.md b/src/server/README.md new file mode 100644 index 0000000..ce5f7ae --- /dev/null +++ b/src/server/README.md @@ -0,0 +1,69 @@ +# inventory-api — service transport (DEP-2 Part 2) + +A **thin HTTP + MCP transport** over the existing library functions +(`index.ts` / `src/inventory.ts`). The library stays the core; this directory +only exposes it over the wire. Nothing here changes the library's exports or +shapes — downstream consumers and the vitest suite are unaffected. + +## What it provides + +| Surface | Path | Backed by | +|---------|------|-----------| +| HTTP | `GET /holdings/{address}` | `getHoldings` | +| HTTP | `GET /nfts/{contract}/owner/{address}` | `getNftsForOwner` | +| HTTP | `GET /nfts/{contract}/{tokenId}` | `getNftMetadata` | +| Spec | `GET /openapi.json` | `openapi.ts` (OpenAPI 3.1) | +| MCP | `GET /.well-known/mcp.json` | `mcp.ts` (tool manifest) | +| Health | `GET /health` | — | + +`routes.ts` is the single source of truth: one declaration per route → +runtime dispatch (`server.ts`) + OpenAPI 3.1 (`openapi.ts`) + MCP tool +manifest (`mcp.ts`). This mirrors Hyper's "one route declaration → runtime + +OpenAPI + MCP" intent. + +## Run + +```bash +bun run serve # PORT env, default 8787 +bun run openapi:emit # writes ../../openapi.json (the drift-CI anchor) +bun run typecheck:server # tsc against tsconfig.server.json +``` + +The library build (`npm run build` / `npm run typecheck`) **excludes** +`src/server/` — the published package (`dist` + `fixtures`) stays Node-pure. +The server is Bun-runtime transport, validated by its own tsconfig + the +hermetic `tests/server-transport.test.ts` (which calls the exported `handle` +fetch handler directly with Web-standard Request/Response — no port, no Bun +needed, so the offline `npm test` stays green). + +## Why a minimal `Bun.serve`, not full Hyper (deferred) + +The DEP-2 brief asked for Hyper (hyperjs.ai) **if it installs/scaffolds +cleanly**, otherwise a minimal Bun server + hand-written OpenAPI 3.1, flagging +the deferral. Hyper installs fine in isolation (`bun create hyper` → +`@usehyper/cli`, OpenAPI 3.1 + MCP confirmed working), but adopting it **here** +was judged too heavy for this repo: + +- It vendors ~22 framework source files into `src/hyper/core/` (the + source-distributed model) — a multi-thousand-line diff that would dominate + the PR and obscure the actual DEP-2 change. +- It is Bun-runtime-coupled (`Bun.serve`, `bun:test`, `Bun.CookieMap`) and + adds a `@hyper` tsconfig path alias + `hyper.config.json` + `hyper.lock.json` + + a `@usehyper/cli` dependency. This repo is a Node ESM + vitest + `tsc` + **library** whose README/beacon explicitly require unchanged exports. +- Hyper's built-in OpenAPI projection currently emits placeholder + `{ description: "success" }` responses (no component schemas), so we'd still + hand-author the response shapes the consumer wants to drift-CI against. + +The minimal server keeps the diff scoped, the library Node-pure, and the +OpenAPI doc richer (real component schemas mirroring `types.ts`). The route +table + OpenAPI/MCP shapes deliberately follow Hyper's conventions, so +**adopting full Hyper later is a low-friction swap** when this building is +promoted out of `cycle_state: candidate`. + +## Keep in sync + +`openapi.ts`'s `COMPONENT_SCHEMAS` are hand-authored to match `types.ts`. If a +library response shape changes, update the matching component schema and +re-run `bun run openapi:emit`. `tests/server-transport.test.ts` asserts every +route's 200 response references a defined component schema. diff --git a/src/server/bun.d.ts b/src/server/bun.d.ts new file mode 100644 index 0000000..b81d1da --- /dev/null +++ b/src/server/bun.d.ts @@ -0,0 +1,24 @@ +/** + * Minimal ambient declaration for the subset of the Bun global this server + * uses. Avoids pulling the full `@types/bun` dependency into a Node library. + * The runtime is Bun (server.ts is invoked via `bun src/server/server.ts`); + * only `Bun.serve` is referenced. + */ +declare namespace Bun { + interface ServeOptions { + port?: number; + hostname?: string; + fetch: (req: Request) => Response | Promise; + } + interface Server { + readonly port: number; + readonly hostname: string; + stop(closeActiveConnections?: boolean): void; + } + function serve(options: ServeOptions): Server; +} + +interface ImportMeta { + /** Bun: true when this module is the entrypoint (`bun file.ts`). */ + readonly main: boolean; +} diff --git a/src/server/emit-openapi.ts b/src/server/emit-openapi.ts new file mode 100644 index 0000000..daf5321 --- /dev/null +++ b/src/server/emit-openapi.ts @@ -0,0 +1,20 @@ +/** + * Emit the OpenAPI 3.1 document to a file (default: openapi.json at repo root). + * Run: `bun run openapi:emit` or `bun src/server/emit-openapi.ts [outPath]`. + * + * The committed openapi.json is the drift-CI anchor: the consumer (Next.js + * frontend) checks its generated client against this spec; regenerate it + * whenever the ROUTES table or response shapes change. + */ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { buildOpenAPIDocument } from "./openapi.js"; + +const outArg = process.argv[2]; +const defaultOut = fileURLToPath(new URL("../../openapi.json", import.meta.url)); +const out = outArg ?? defaultOut; + +const doc = buildOpenAPIDocument(); +writeFileSync(out, `${JSON.stringify(doc, null, 2)}\n`, "utf-8"); +// eslint-disable-next-line no-console +console.log(`wrote OpenAPI 3.1 spec -> ${out}`); diff --git a/src/server/mcp.ts b/src/server/mcp.ts new file mode 100644 index 0000000..816f057 --- /dev/null +++ b/src/server/mcp.ts @@ -0,0 +1,48 @@ +/** + * MCP tool manifest — derived from the single ROUTES table. + * + * Shape follows Hyper's `MCPManifest` (version "1.0" + tools[]), so the + * manifest is recognizable to the same tooling and a future Hyper swap keeps + * the contract. Each route becomes one tool; inputSchema is a JSON Schema + * object over the route's params (path + query merged, since an MCP tool call + * is flat). This is a static manifest, not a live MCP transport — server.ts + * serves it at /.well-known/mcp.json for discovery. + */ +import { ROUTES } from "./routes.js"; + +export interface MCPTool { + readonly name: string; + readonly description: string; + readonly method: string; + readonly path: string; + readonly inputSchema: { + readonly type: "object"; + readonly properties: Record; + readonly required: readonly string[]; + }; +} + +export interface MCPManifest { + readonly version: "1.0"; + readonly tools: readonly MCPTool[]; +} + +/** Build the MCP tool manifest from the ROUTES table. */ +export function buildMCPManifest(): MCPManifest { + const tools: MCPTool[] = ROUTES.map((r) => { + const properties: Record = {}; + const required: string[] = []; + for (const p of r.params) { + properties[p.name] = { ...p.schema, description: p.description }; + if (p.required) required.push(p.name); + } + return { + name: r.operationId, + description: r.mcpDescription, + method: r.method, + path: r.path, + inputSchema: { type: "object" as const, properties, required }, + }; + }); + return { version: "1.0", tools }; +} diff --git a/src/server/openapi.ts b/src/server/openapi.ts new file mode 100644 index 0000000..4d20e55 --- /dev/null +++ b/src/server/openapi.ts @@ -0,0 +1,160 @@ +/** + * OpenAPI 3.1 document generator — derived from the single ROUTES table. + * + * Emits a spec the consumer (Next.js frontend) can drift-CI against. The + * shape follows Hyper's `OpenAPIManifest` conventions (openapi: "3.1.0", + * info, paths) so a future swap to full Hyper is low-friction, but adds real + * component schemas + typed responses (Hyper's built-in projection currently + * emits `{ description: "success" }` placeholders). + * + * Component schemas are hand-authored to MATCH `types.ts` exactly — keep them + * in sync when the library response shapes change. + */ +import { ROUTES } from "./routes.js"; + +/** Reusable component schemas mirroring `types.ts`. */ +const COMPONENT_SCHEMAS: Record = { + Attribute: { + type: "object", + required: ["trait_type", "value"], + properties: { + trait_type: { type: "string" }, + value: { type: "string" }, + }, + }, + CompletenessEnvelope: { + type: "object", + required: ["as_of_block", "holder_count", "source", "complete"], + properties: { + as_of_block: { type: "integer" }, + holder_count: { type: "integer" }, + source: { type: "string", enum: ["sonar"] }, + complete: { + description: "true when the answer is provably complete; 'degraded' when upstream was unreachable.", + oneOf: [{ type: "boolean", enum: [true] }, { type: "string", enum: ["degraded"] }], + }, + }, + }, + ContractHolding: { + type: "object", + required: ["contractAddress", "chainId", "tokenCount", "tokenIds"], + properties: { + contractAddress: { type: "string" }, + chainId: { type: "integer" }, + tokenCount: { type: "integer" }, + tokenIds: { type: "array", items: { type: "string" } }, + }, + }, + HoldingsResponse: { + type: "object", + required: ["holdings", "completeness"], + properties: { + holdings: { type: "array", items: { $ref: "#/components/schemas/ContractHolding" } }, + completeness: { $ref: "#/components/schemas/CompletenessEnvelope" }, + }, + }, + NFT: { + type: "object", + required: ["tokenId", "name", "description", "imageUrl", "contentType", "attributes"], + properties: { + tokenId: { type: "string" }, + name: { type: "string" }, + description: { type: "string" }, + imageUrl: { type: "string" }, + contentType: { type: "string" }, + attributes: { type: "array", items: { $ref: "#/components/schemas/Attribute" } }, + }, + }, + NFTCollection: { + type: "object", + required: ["contractAddress", "name", "symbol", "totalSupply", "nfts"], + properties: { + contractAddress: { type: "string" }, + name: { type: "string" }, + symbol: { type: "string" }, + totalSupply: { type: "integer" }, + nfts: { type: "array", items: { $ref: "#/components/schemas/NFT" } }, + pageKey: { type: "string" }, + }, + }, + MetadataDocument: { + type: "object", + required: ["name", "description", "image", "attributes"], + properties: { + name: { type: "string" }, + description: { type: "string" }, + image: { type: "string" }, + attributes: { type: "array", items: { $ref: "#/components/schemas/Attribute" } }, + }, + }, + Error: { + type: "object", + required: ["error"], + properties: { + error: { type: "string" }, + code: { type: "string" }, + }, + }, +}; + +export interface OpenAPIDocConfig { + readonly title?: string; + readonly version?: string; + readonly description?: string; +} + +/** Build the OpenAPI 3.1 document from the ROUTES table. */ +export function buildOpenAPIDocument(cfg: OpenAPIDocConfig = {}): Record { + const paths: Record> = {}; + + for (const r of ROUTES) { + const parameters = r.params.map((p) => ({ + name: p.name, + in: p.in, + required: p.required, + description: p.description, + schema: p.schema, + })); + + const operation = { + operationId: r.operationId, + summary: r.summary, + tags: r.tags, + parameters, + responses: { + "200": { + description: "success", + content: { + "application/json": { + schema: { $ref: `#/components/schemas/${r.responseSchema}` }, + }, + }, + }, + "400": { + description: "validation error", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + "404": { + description: "not found", + content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, + }, + }, + }; + + if (!paths[r.path]) paths[r.path] = {}; + paths[r.path][r.method.toLowerCase()] = operation; + } + + return { + openapi: "3.1.0", + info: { + title: cfg.title ?? "inventory-api", + version: cfg.version ?? "0.1.0", + description: + cfg.description ?? + "Sovereign read-side inventory aggregator — joins sonar holdings with owned codex metadata + a completeness envelope.", + }, + paths, + components: { schemas: COMPONENT_SCHEMAS }, + }; +} diff --git a/src/server/routes.ts b/src/server/routes.ts new file mode 100644 index 0000000..ca1fa7f --- /dev/null +++ b/src/server/routes.ts @@ -0,0 +1,178 @@ +/** + * Single source-of-truth route table for the inventory-api service transport. + * + * One declaration per route → runtime dispatch (server.ts) + OpenAPI 3.1 + * (openapi.ts) + MCP tool manifest (mcp.ts). This mirrors Hyper's + * "one route declaration → runtime + OpenAPI + MCP" intent while staying a + * thin, dependency-free wrapper over the existing library functions. The + * library (index.ts / src/inventory.ts) remains the core; this is transport. + * + * See src/server/README.md for the Hyper-vs-minimal-server rationale. + */ +import { getHoldings, getNftsForOwner, getNftMetadata } from "../inventory.js"; + +/** OpenAPI parameter descriptor (path or query). */ +export interface RouteParam { + readonly name: string; + readonly in: "path" | "query"; + readonly required: boolean; + readonly description: string; + readonly schema: Record; +} + +/** + * A route definition. `handler` receives the resolved path params + parsed + * query and returns a JSON-serializable value (or throws — server.ts maps + * the library's typed errors to HTTP status codes). + */ +export interface RouteDef { + readonly method: "GET"; + /** OpenAPI-style path with `{param}` placeholders. */ + readonly path: string; + readonly operationId: string; + readonly summary: string; + /** MCP tool description (richer, model-facing). */ + readonly mcpDescription: string; + readonly tags: readonly string[]; + readonly params: readonly RouteParam[]; + /** OpenAPI schema name for the 200 response body (see openapi.ts components). */ + readonly responseSchema: string; + readonly handler: ( + pathParams: Record, + query: URLSearchParams, + ) => Promise; +} + +const ADDRESS_SCHEMA = { + type: "string", + pattern: "^0x[0-9a-fA-F]{40}$", +} as const; + +/** The three transport routes, declared once. */ +export const ROUTES: readonly RouteDef[] = [ + { + method: "GET", + path: "/holdings/{address}", + operationId: "getHoldings", + summary: "Resolve a wallet's holdings + ACVP completeness envelope", + mcpDescription: + "Get a wallet's NFT holdings for registered collections (Mibera first), " + + "with per-token tokenIds and a completeness envelope (as_of_block, holder_count, " + + "source, complete) that proves the result is complete as of a block.", + tags: ["inventory"], + params: [ + { + name: "address", + in: "path", + required: true, + description: "Holder wallet address (0x-prefixed, 40 hex chars).", + schema: ADDRESS_SCHEMA, + }, + { + name: "contracts", + in: "query", + required: false, + description: "Comma-separated contract addresses to filter to.", + schema: { type: "string" }, + }, + { + name: "chains", + in: "query", + required: false, + description: "Comma-separated chain ids to filter to.", + schema: { type: "string" }, + }, + ], + responseSchema: "HoldingsResponse", + handler: async (p, q) => { + const contracts = q.get("contracts"); + const chains = q.get("chains"); + const options: { contracts?: string[]; chains?: number[] } = {}; + if (contracts) options.contracts = contracts.split(",").map((s) => s.trim()); + if (chains) { + options.chains = chains + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)); + } + return getHoldings(p.address, options); + }, + }, + { + method: "GET", + path: "/nfts/{contract}/owner/{address}", + operationId: "getNftsForOwner", + summary: "Paginated NFTs (sonar ⨝ codex) owned by a wallet", + mcpDescription: + "Get the paginated list of NFTs (with full metadata: name, image, attributes) " + + "owned by a wallet for a given contract. Supports pageSize + pageKey cursoring.", + tags: ["inventory"], + params: [ + { + name: "contract", + in: "path", + required: true, + description: "Collection contract address (0x-prefixed, 40 hex chars).", + schema: ADDRESS_SCHEMA, + }, + { + name: "address", + in: "path", + required: true, + description: "Holder wallet address (0x-prefixed, 40 hex chars).", + schema: ADDRESS_SCHEMA, + }, + { + name: "pageSize", + in: "query", + required: false, + description: "Page size (1-100, default 100).", + schema: { type: "integer", minimum: 1, maximum: 100 }, + }, + { + name: "pageKey", + in: "query", + required: false, + description: "Opaque pagination cursor from a prior response.", + schema: { type: "string" }, + }, + ], + responseSchema: "NFTCollection", + handler: async (p, q) => { + const options: { pageSize?: number; pageKey?: string } = {}; + const pageSize = q.get("pageSize"); + const pageKey = q.get("pageKey"); + if (pageSize) options.pageSize = Number(pageSize); + if (pageKey) options.pageKey = pageKey; + return getNftsForOwner(p.address, p.contract, options); + }, + }, + { + method: "GET", + path: "/nfts/{contract}/{tokenId}", + operationId: "getNftMetadata", + summary: "Single NFT metadata document from the codex", + mcpDescription: + "Get the metadata document (name, description, image, attributes) for a single " + + "token id of a contract, sourced from the codex.", + tags: ["inventory"], + params: [ + { + name: "contract", + in: "path", + required: true, + description: "Collection contract address (0x-prefixed, 40 hex chars).", + schema: ADDRESS_SCHEMA, + }, + { + name: "tokenId", + in: "path", + required: true, + description: "Numeric token id.", + schema: { type: "string", pattern: "^\\d+$" }, + }, + ], + responseSchema: "MetadataDocument", + handler: async (p) => getNftMetadata(p.contract, p.tokenId), + }, +]; diff --git a/src/server/server.ts b/src/server/server.ts new file mode 100644 index 0000000..cb23331 --- /dev/null +++ b/src/server/server.ts @@ -0,0 +1,125 @@ +/** + * Bun HTTP transport for inventory-api. + * + * A THIN transport over the existing library functions (index.ts / + * src/inventory.ts). The library stays the core; this just exposes + * getHoldings / getNftsForOwner / getNftMetadata over HTTP, plus discovery + * docs: an OpenAPI 3.1 spec (/openapi.json) and an MCP tool manifest + * (/.well-known/mcp.json), both derived from the single ROUTES table. + * + * Why a minimal Bun.serve rather than full Hyper: see src/server/README.md. + * + * Run: bun src/server/server.ts (PORT env, default 8787) + * Spec: bun run openapi:emit (writes openapi.json to repo root) + */ +import { ROUTES, type RouteDef } from "./routes.js"; +import { buildOpenAPIDocument } from "./openapi.js"; +import { buildMCPManifest } from "./mcp.js"; + +// Library error codes (src/errors.ts) → HTTP status. +const ERROR_STATUS: Record = { + INVENTORY_INVALID_INPUT: 400, + INVENTORY_NOT_FOUND: 404, + INVENTORY_FIXTURE_LOAD: 500, +}; + +interface CompiledRoute { + readonly def: RouteDef; + readonly regex: RegExp; + readonly paramNames: readonly string[]; +} + +/** Compile `/nfts/{contract}/{tokenId}` into a matcher + param names. */ +function compileRoute(def: RouteDef): CompiledRoute { + const paramNames: string[] = []; + const pattern = def.path.replace(/\{([A-Za-z0-9_]+)\}/g, (_m, name: string) => { + paramNames.push(name); + return "([^/]+)"; + }); + return { def, regex: new RegExp(`^${pattern}$`), paramNames }; +} + +const COMPILED: readonly CompiledRoute[] = ROUTES.map(compileRoute); + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { + "content-type": "application/json", + "access-control-allow-origin": "*", + }, + }); +} + +/** Map a thrown library error to an HTTP response. */ +function errorResponse(err: unknown): Response { + const code = + err && typeof err === "object" && "code" in err + ? String((err as { code: unknown }).code) + : undefined; + const status = code && ERROR_STATUS[code] ? ERROR_STATUS[code] : 500; + const message = err instanceof Error ? err.message : "internal error"; + return json({ error: message, ...(code ? { code } : {}) }, status); +} + +/** The fetch handler — exported so it can be unit-tested without binding a port. */ +export async function handle(req: Request): Promise { + const url = new URL(req.url); + const { pathname } = url; + + if (req.method === "OPTIONS") { + return new Response(null, { + status: 204, + headers: { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, OPTIONS", + "access-control-allow-headers": "content-type", + }, + }); + } + + // Discovery + health. + if (pathname === "/" || pathname === "/health") { + return json({ ok: true, service: "inventory-api", routes: ROUTES.length }); + } + if (pathname === "/openapi.json") { + return json(buildOpenAPIDocument()); + } + if (pathname === "/.well-known/mcp.json" || pathname === "/mcp.json") { + return json(buildMCPManifest()); + } + + // Dispatch to a route. + for (const { def, regex, paramNames } of COMPILED) { + if (def.method !== req.method) continue; + const m = regex.exec(pathname); + if (!m) continue; + const pathParams: Record = {}; + paramNames.forEach((name, i) => { + pathParams[name] = decodeURIComponent(m[i + 1]); + }); + try { + const result = await def.handler(pathParams, url.searchParams); + return json(result); + } catch (err) { + return errorResponse(err); + } + } + + return json({ error: "not found", code: "ROUTE_NOT_FOUND" }, 404); +} + +/** Start the server. Skipped at import time so tests can use `handle` directly. */ +export function start(port = Number(process.env.PORT) || 8787): Bun.Server { + const server = Bun.serve({ port, fetch: handle }); + // eslint-disable-next-line no-console + console.log( + `inventory-api listening on http://${server.hostname}:${server.port} (${ROUTES.length} routes) — spec at /openapi.json, mcp at /.well-known/mcp.json`, + ); + return server; +} + +// `bun src/server/server.ts` runs this directly; importing for tests does not. +if (import.meta.main) { + start(); +} diff --git a/tests/server-transport.test.ts b/tests/server-transport.test.ts new file mode 100644 index 0000000..aeada71 --- /dev/null +++ b/tests/server-transport.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { handle } from '../src/server/server.js'; +import { buildOpenAPIDocument } from '../src/server/openapi.js'; +import { buildMCPManifest } from '../src/server/mcp.js'; + +/** + * Hermetic transport tests. We call the exported `handle(req)` fetch handler + * directly with Web-standard Request/Response — no port binding, no Bun + * runtime — so the offline vitest suite stays green. This validates the thin + * HTTP transport over the library + the OpenAPI/MCP discovery docs. + */ +const MIBERA = '0x6666397DFe9a8c469BF65dc744CB1C733416c420'; +const HOLDER = '0x1111111111111111111111111111111111111111'; +const EMPTY = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; +const BASE = 'http://localhost'; + +const get = (path: string) => handle(new Request(`${BASE}${path}`)); + +describe('server transport (hermetic, via handle())', () => { + it('GET /health returns ok', async () => { + const res = await get('/health'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.routes).toBe(3); + }); + + it('GET /holdings/:address wraps getHoldings', async () => { + const res = await get(`/holdings/${HOLDER}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.holdings[0].tokenCount).toBe(12); + expect(body.holdings[0].tokenIds).toHaveLength(12); + expect(body.completeness.source).toBe('sonar'); + }); + + it('GET /holdings/:address forwards contracts query option', async () => { + const res = await get(`/holdings/${HOLDER}?contracts=${MIBERA}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.holdings[0].contractAddress).toBe(MIBERA); + }); + + it('GET /nfts/:contract/owner/:address wraps getNftsForOwner with pagination', async () => { + const res = await get(`/nfts/${MIBERA}/owner/${HOLDER}?pageSize=5`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.nfts).toHaveLength(5); + expect(body.pageKey).toBeDefined(); + expect(body.name).toBe('Mibera'); + }); + + it('GET /nfts/:contract/:tokenId wraps getNftMetadata', async () => { + const res = await get(`/nfts/${MIBERA}/2769`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe('Air'); // pinned grail + }); + + it('maps ValidationError -> 400', async () => { + const res = await get('/holdings/not-an-address'); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.code).toBe('INVENTORY_INVALID_INPUT'); + }); + + it('maps NotFoundError -> 404', async () => { + const res = await get(`/nfts/${MIBERA}/99999`); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.code).toBe('INVENTORY_NOT_FOUND'); + }); + + it('unknown route -> 404', async () => { + const res = await get('/does/not/exist'); + expect(res.status).toBe(404); + }); + + it('empty holder returns empty holdings (graceful)', async () => { + const res = await get(`/holdings/${EMPTY}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.holdings).toHaveLength(0); + }); + + it('serves the OpenAPI 3.1 document', async () => { + const res = await get('/openapi.json'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.openapi).toBe('3.1.0'); + expect(Object.keys(body.paths)).toHaveLength(3); + }); + + it('serves the MCP manifest at /.well-known/mcp.json', async () => { + const res = await get('/.well-known/mcp.json'); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.version).toBe('1.0'); + expect(body.tools.map((t: { name: string }) => t.name)).toEqual([ + 'getHoldings', + 'getNftsForOwner', + 'getNftMetadata', + ]); + }); + + it('CORS preflight (OPTIONS) returns 204', async () => { + const res = await handle(new Request(`${BASE}/holdings/${HOLDER}`, { method: 'OPTIONS' })); + expect(res.status).toBe(204); + }); +}); + +describe('OpenAPI / MCP generators (unit)', () => { + it('OpenAPI doc has component schemas matching types.ts', () => { + const doc = buildOpenAPIDocument(); + const schemas = (doc.components as { schemas: Record }).schemas; + expect(schemas).toHaveProperty('HoldingsResponse'); + expect(schemas).toHaveProperty('NFTCollection'); + expect(schemas).toHaveProperty('MetadataDocument'); + expect(schemas).toHaveProperty('CompletenessEnvelope'); + }); + + it('every route 200 response references a defined component schema', () => { + const doc = buildOpenAPIDocument(); + const schemas = (doc.components as { schemas: Record }).schemas; + const paths = doc.paths as Record>; + for (const ops of Object.values(paths)) { + for (const op of Object.values(ops)) { + const ref = op.responses['200'].content['application/json'].schema.$ref; + const name = ref.replace('#/components/schemas/', ''); + expect(schemas).toHaveProperty(name); + } + } + }); + + it('MCP manifest declares required path params', () => { + const m = buildMCPManifest(); + const holdings = m.tools.find((t) => t.name === 'getHoldings'); + expect(holdings!.inputSchema.required).toContain('address'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index f4bf3bd..f056c59 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "skipLibCheck": true }, "include": ["index.ts", "types.ts", "src/**/*.ts"], - "exclude": ["node_modules", "dist", "tests"] + "exclude": ["node_modules", "dist", "tests", "src/server"] } diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..cf67f54 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": true, + "types": ["node"] + }, + "include": ["src/server/**/*.ts"], + "exclude": ["node_modules", "dist", "tests"] +} From 1d0f3a0174e261bcf70098ae4240cf8dca7a1f67 Mon Sep 17 00:00:00 2001 From: soju Date: Sun, 24 May 2026 13:20:32 -0700 Subject: [PATCH 3/3] feat(service): adopt Hyper-on-Bun for real (DEP-2 Part 2, replaces minimal server) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node-pure publishable-library constraint released — inventory-api is a building consumed over HTTP + MCP, not an npm package. Replace the minimal Bun.serve fallback with a genuine Hyper (hyperjs.ai) service: one route declaration per endpoint generates the runtime + OpenAPI 3.1 + MCP from a single source. - Vendor Hyper source-distributed components (bun create hyper + hyper add openapi openapi-zod mcp) under src/hyper/ (hyper.config.json + hyper.lock.json) - src/routes.ts: 3 routes (getHoldings, getNftsForOwner, getNftMetadata) with zod query schemas, meta.mcp tool descriptions, throws() + examples - src/app.ts: serves HTTP + /openapi.json + /docs (Swagger) + /.well-known/mcp.json + POST /mcp (JSON-RPC); business graph drives OpenAPI/MCP generation - src/emit-openapi.ts / src/emit-mcp.ts: write openapi.json + mcp.json (drift-CI anchors) - tests/service.test.ts: HTTP + OpenAPI + MCP coverage via app.fetch (offline) - Two local fixes to vendored components (source-distributed = editable): openapi-zod array-def cast, openapi/generate unknown-spread guard - package shape: Node-pure library -> private Bun service (dropped main/types/ exports/files; bun.lock replaces package-lock.json); tsconfig is Bun-native (@types/bun, @hyper/* alias, DOM lib for Hyper's Request/HeadersInit globals); vitest.config maps @hyper/* so tests resolve the vendored components - index.ts reframed as the internal domain barrel (route handlers + tests import it) Part 1 (live-sonar liveOwnerTokenIds/liveCandiesBalances + inventory activation) is byte-for-byte unchanged. All 119 tests pass offline; tsc clean; Bun server smoke verified (all 3 routes + /openapi.json + /docs + MCP manifest + JSON-RPC tools/call). Live sonar still not deployed — ownership paths covered hermetically. Co-Authored-By: Claude Opus 4.7 (1M context) --- .well-known/beacon.json | 7 +- README.md | 52 +- bun.lock | 293 +++++ hyper.config.json | 6 + hyper.lock.json | 158 +++ index.ts | 9 + mcp.json | 43 + openapi.json | 441 +++---- package-lock.json | 1926 ----------------------------- package.json | 29 +- src/app.ts | 80 ++ src/emit-mcp.ts | 18 + src/emit-openapi.ts | 19 + src/hyper/core/adapters/bun.ts | 33 + src/hyper/core/app.ts | 710 +++++++++++ src/hyper/core/decorate.ts | 113 ++ src/hyper/core/env.ts | 115 ++ src/hyper/core/error.ts | 90 ++ src/hyper/core/example.ts | 67 + src/hyper/core/file.ts | 41 + src/hyper/core/group.ts | 224 ++++ src/hyper/core/hash.ts | 30 + src/hyper/core/hyper.ts | 836 +++++++++++++ src/hyper/core/index.ts | 152 +++ src/hyper/core/infer.ts | 49 + src/hyper/core/middleware.ts | 134 ++ src/hyper/core/projection.ts | 201 +++ src/hyper/core/request.ts | 135 ++ src/hyper/core/resource.ts | 138 +++ src/hyper/core/response.ts | 267 ++++ src/hyper/core/route.ts | 345 ++++++ src/hyper/core/router.ts | 340 +++++ src/hyper/core/security.ts | 126 ++ src/hyper/core/standard-schema.ts | 88 ++ src/hyper/core/types.ts | 343 +++++ src/hyper/mcp/audit.ts | 52 + src/hyper/mcp/index.ts | 15 + src/hyper/mcp/server.ts | 167 +++ src/hyper/openapi-zod/index.ts | 124 ++ src/hyper/openapi/converter.ts | 39 + src/hyper/openapi/generate.ts | 171 +++ src/hyper/openapi/index.ts | 18 + src/hyper/openapi/plugin.ts | 62 + src/hyper/openapi/swagger.ts | 49 + src/routes.ts | 220 ++++ src/server/README.md | 69 -- src/server/bun.d.ts | 24 - src/server/emit-openapi.ts | 20 - src/server/mcp.ts | 48 - src/server/openapi.ts | 160 --- src/server/routes.ts | 178 --- src/server/server.ts | 125 -- tests/server-transport.test.ts | 142 --- tests/service.test.ts | 162 +++ tsconfig.json | 22 +- tsconfig.server.json | 9 - vitest.config.ts | 9 + 57 files changed, 6504 insertions(+), 3039 deletions(-) create mode 100644 bun.lock create mode 100644 hyper.config.json create mode 100644 hyper.lock.json create mode 100644 mcp.json delete mode 100644 package-lock.json create mode 100644 src/app.ts create mode 100644 src/emit-mcp.ts create mode 100644 src/emit-openapi.ts create mode 100644 src/hyper/core/adapters/bun.ts create mode 100644 src/hyper/core/app.ts create mode 100644 src/hyper/core/decorate.ts create mode 100644 src/hyper/core/env.ts create mode 100644 src/hyper/core/error.ts create mode 100644 src/hyper/core/example.ts create mode 100644 src/hyper/core/file.ts create mode 100644 src/hyper/core/group.ts create mode 100644 src/hyper/core/hash.ts create mode 100644 src/hyper/core/hyper.ts create mode 100644 src/hyper/core/index.ts create mode 100644 src/hyper/core/infer.ts create mode 100644 src/hyper/core/middleware.ts create mode 100644 src/hyper/core/projection.ts create mode 100644 src/hyper/core/request.ts create mode 100644 src/hyper/core/resource.ts create mode 100644 src/hyper/core/response.ts create mode 100644 src/hyper/core/route.ts create mode 100644 src/hyper/core/router.ts create mode 100644 src/hyper/core/security.ts create mode 100644 src/hyper/core/standard-schema.ts create mode 100644 src/hyper/core/types.ts create mode 100644 src/hyper/mcp/audit.ts create mode 100644 src/hyper/mcp/index.ts create mode 100644 src/hyper/mcp/server.ts create mode 100644 src/hyper/openapi-zod/index.ts create mode 100644 src/hyper/openapi/converter.ts create mode 100644 src/hyper/openapi/generate.ts create mode 100644 src/hyper/openapi/index.ts create mode 100644 src/hyper/openapi/plugin.ts create mode 100644 src/hyper/openapi/swagger.ts create mode 100644 src/routes.ts delete mode 100644 src/server/README.md delete mode 100644 src/server/bun.d.ts delete mode 100644 src/server/emit-openapi.ts delete mode 100644 src/server/mcp.ts delete mode 100644 src/server/openapi.ts delete mode 100644 src/server/routes.ts delete mode 100644 src/server/server.ts delete mode 100644 tests/server-transport.test.ts create mode 100644 tests/service.test.ts delete mode 100644 tsconfig.server.json diff --git a/.well-known/beacon.json b/.well-known/beacon.json index 6b4d0eb..e494db1 100644 --- a/.well-known/beacon.json +++ b/.well-known/beacon.json @@ -24,15 +24,16 @@ } ], "capabilities": ["getHoldings", "getNftsForOwner", "getNftMetadata", "getProfilePicture"], + "runtime": "hyper-on-bun", "transport": { "http": ["GET /holdings/{address}", "GET /nfts/{contract}/owner/{address}", "GET /nfts/{contract}/{tokenId}"], - "openapi": "/openapi.json (3.1)", - "mcp": "/.well-known/mcp.json" + "openapi": "/openapi.json (3.1) + /docs (Swagger UI)", + "mcp": ["GET /.well-known/mcp.json (manifest)", "POST /mcp (JSON-RPC 2.0)"] }, "cycle_state": { "status": "candidate", "since": "2026-05-23", "next_review": "2026-08-21" }, - "_comment": "Library + thin Bun HTTP/MCP transport (src/server/, DEP-2). Live counts + ACVP envelope wired to belt-gateway; per-token ownership (Token + CandiesHolderBalance) activated against sonar cycle/sonar-belt-factory (DEP-2) — covered hermetically, pending live deploy/reindex verification (docs/sonar-ownership-gap.md). Full Hyper adoption deferred for a minimal Bun.serve to keep the library Node-pure (src/server/README.md). composes_with omitted pending port-schema sealing (ADR-007 App A.2 / ADR-008 §D-11) — consumes sonar-api + storage-api per README." + "_comment": "Hyper (hyperjs.ai) service on Bun — consumed over HTTP + MCP, NOT an npm package (DEP-2). One route declaration per endpoint (src/routes.ts) generates runtime + OpenAPI 3.1 + MCP; route handlers call the domain core (src/inventory.ts). Hyper is source-distributed under src/hyper/ (hyper.lock.json). Live counts + ACVP envelope wired to belt-gateway; per-token ownership (Token + CandiesHolderBalance) activated against sonar cycle/sonar-belt-factory — covered hermetically, pending live deploy/reindex verification (docs/sonar-ownership-gap.md). composes_with omitted pending port-schema sealing (ADR-007 App A.2 / ADR-008 §D-11) — consumes sonar-api + storage-api per README." } diff --git a/README.md b/README.md index 903bd3e..710493a 100644 --- a/README.md +++ b/README.md @@ -17,34 +17,38 @@ - Does **NOT** proxy chain RPC — it consumes `freeside-sonar` for indexed reads. - Does **NOT** own metadata — that's the codex (today) / `freeside-storage` (sovereign target). -## API (library) +## Service (HTTP + MCP) -```ts -import { getHoldings, getNftsForOwner, getNftMetadata } from "@0xhoneyjar/inventory"; -``` - -| Method | Returns | Source | -|--------|---------|--------| -| `getHoldings(address)` | holdings (counts + tokenIds) + completeness envelope | sonar | -| `getNftsForOwner(address, contract)` | paginated NFTs w/ metadata | sonar ⨝ codex | -| `getNftMetadata(contract, tokenId)` | single MetadataDocument | codex | - -## Service transport (HTTP + MCP) - -Consumers (e.g. a Next.js frontend) use this building over **HTTP + MCP**, not -as an npm import. A thin Bun transport (`src/server/`) exposes the library -functions as routes and emits an OpenAPI 3.1 spec + an MCP tool manifest from a -single route table. The library stays the core — the server only calls it. +inventory-api is a **Hyper** (hyperjs.ai) service running on **Bun**, consumed +over the wire — **not an npm package** (honeyroad reads it over HTTP + MCP). +One route declaration per endpoint (`src/routes.ts`) generates the runtime, +the OpenAPI 3.1 document, and the MCP tool surface from a single source. The +route handlers are thin — they call the domain functions in `src/inventory.ts` +(the sonar ⨝ codex join + ACVP envelope), which remain the core. ```bash -bun run serve # GET /holdings/:address, /nfts/:contract/owner/:address, /nfts/:contract/:tokenId -bun run openapi:emit # writes openapi.json (the consumer's drift-CI anchor) +bun run dev # hot-reload dev server (PORT, default 8787) +bun run start # production server +bun run openapi:emit # writes openapi.json (the consumer's drift-CI anchor) +bun run mcp:emit # writes mcp.json (MCP tool manifest) +bun run typecheck # tsc --noEmit (Bun tsconfig) +npm test # vitest — runs fully offline ``` -Discovery: `GET /openapi.json` (OpenAPI 3.1) and `GET /.well-known/mcp.json` -(MCP tools). Full Hyper (hyperjs.ai) adoption was deferred in favor of a -minimal `Bun.serve` to keep the library Node-pure — see -[`src/server/README.md`](src/server/README.md) for the rationale. +| Route | Returns | Source | MCP tool | +|-------|---------|--------|----------| +| `GET /holdings/:address` | holdings (counts + tokenIds) + completeness envelope | sonar | `getHoldings` | +| `GET /nfts/:contract/owner/:address` | paginated NFTs w/ metadata | sonar ⨝ codex | `getNftsForOwner` | +| `GET /nfts/:contract/:tokenId` | single MetadataDocument | codex | `getNftMetadata` | + +Discovery: `GET /openapi.json` (OpenAPI 3.1), `GET /docs` (Swagger UI), +`GET /.well-known/mcp.json` (MCP manifest), `POST /mcp` (MCP JSON-RPC 2.0), +`GET /health`. + +The Hyper framework is **source-distributed** under `src/hyper/` (vendored via +`bun create hyper` + `hyper add openapi openapi-zod mcp` — yours to read/edit; +tracked in `hyper.lock.json`). The domain functions stay importable internally +via `index.ts` for the route handlers + tests. ## Modes @@ -53,7 +57,7 @@ minimal `Bun.serve` to keep the library Node-pure — see holder counts + a real ACVP envelope from the live belt. Fail-soft: unreachable → fixture + `degraded`. ```bash - SONAR_GRAPHQL_ENDPOINT=https:///v1/graphql npm test -- live-smoke + SONAR_GRAPHQL_ENDPOINT=https:///v1/graphql npx vitest run live-smoke ``` ## Ownership activation (DEP-2) diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..4b76c26 --- /dev/null +++ b/bun.lock @@ -0,0 +1,293 @@ +{ + "lockfileVersion": 1, + "configVersion": 0, + "workspaces": { + "": { + "name": "@freeside/inventory", + "dependencies": { + "ethereum-cryptography": "^2.2.1", + "zod": "^3.23.8", + }, + "devDependencies": { + "@types/bun": "^1.3.14", + "@types/node": "^20.12.7", + "@usehyper/cli": "^0.1.1", + "typescript": "^5.4.5", + "vitest": "^1.6.0", + }, + }, + }, + "packages": { + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@jest/schemas": ["@jest/schemas@29.6.3", "", { "dependencies": { "@sinclair/typebox": "^0.27.8" } }, "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@noble/curves": ["@noble/curves@1.4.2", "", { "dependencies": { "@noble/hashes": "1.4.0" } }, "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw=="], + + "@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.4", "", { "os": "android", "cpu": "arm" }, "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.4", "", { "os": "android", "cpu": "arm64" }, "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.4", "", { "os": "linux", "cpu": "arm" }, "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.4", "", { "os": "linux", "cpu": "none" }, "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.4", "", { "os": "none", "cpu": "arm64" }, "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], + + "@scure/base": ["@scure/base@1.1.9", "", {}, "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg=="], + + "@scure/bip32": ["@scure/bip32@1.4.0", "", { "dependencies": { "@noble/curves": "~1.4.0", "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg=="], + + "@scure/bip39": ["@scure/bip39@1.3.0", "", { "dependencies": { "@noble/hashes": "~1.4.0", "@scure/base": "~1.1.6" } }, "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.27.10", "", {}, "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA=="], + + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/node": ["@types/node@20.19.41", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ=="], + + "@usehyper/cli": ["@usehyper/cli@0.1.1", "", { "bin": { "hyper": "src/bin.ts" } }, "sha512-A1P2wFDB3vLqciL6wNMWha6BrxAR78Vlix968D/A9GdkkImvJz6MXvBq36BuqcTNKUg9FhZ+N9pEacjRHiFHEg=="], + + "@vitest/expect": ["@vitest/expect@1.6.1", "", { "dependencies": { "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "chai": "^4.3.10" } }, "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog=="], + + "@vitest/runner": ["@vitest/runner@1.6.1", "", { "dependencies": { "@vitest/utils": "1.6.1", "p-limit": "^5.0.0", "pathe": "^1.1.1" } }, "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA=="], + + "@vitest/snapshot": ["@vitest/snapshot@1.6.1", "", { "dependencies": { "magic-string": "^0.30.5", "pathe": "^1.1.1", "pretty-format": "^29.7.0" } }, "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ=="], + + "@vitest/spy": ["@vitest/spy@1.6.1", "", { "dependencies": { "tinyspy": "^2.2.0" } }, "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw=="], + + "@vitest/utils": ["@vitest/utils@1.6.1", "", { "dependencies": { "diff-sequences": "^29.6.3", "estree-walker": "^3.0.3", "loupe": "^2.3.7", "pretty-format": "^29.7.0" } }, "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g=="], + + "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], + + "acorn-walk": ["acorn-walk@8.3.5", "", { "dependencies": { "acorn": "^8.11.0" } }, "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "assertion-error": ["assertion-error@1.1.0", "", {}, "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw=="], + + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + + "chai": ["chai@4.5.0", "", { "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", "deep-eql": "^4.1.3", "get-func-name": "^2.0.2", "loupe": "^2.3.6", "pathval": "^1.1.1", "type-detect": "^4.1.0" } }, "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw=="], + + "check-error": ["check-error@1.0.3", "", { "dependencies": { "get-func-name": "^2.0.2" } }, "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg=="], + + "confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "deep-eql": ["deep-eql@4.1.4", "", { "dependencies": { "type-detect": "^4.0.0" } }, "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg=="], + + "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": "bin/esbuild" }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "ethereum-cryptography": ["ethereum-cryptography@2.2.1", "", { "dependencies": { "@noble/curves": "1.4.2", "@noble/hashes": "1.4.0", "@scure/bip32": "1.4.0", "@scure/bip39": "1.3.0" } }, "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg=="], + + "execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "get-func-name": ["get-func-name@2.0.2", "", {}, "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ=="], + + "get-stream": ["get-stream@8.0.1", "", {}, "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA=="], + + "human-signals": ["human-signals@5.0.0", "", {}, "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ=="], + + "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "local-pkg": ["local-pkg@0.5.1", "", { "dependencies": { "mlly": "^1.7.3", "pkg-types": "^1.2.1" } }, "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ=="], + + "loupe": ["loupe@2.3.7", "", { "dependencies": { "get-func-name": "^2.0.1" } }, "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + + "mimic-fn": ["mimic-fn@4.0.0", "", {}, "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw=="], + + "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": "bin/nanoid.cjs" }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "npm-run-path": ["npm-run-path@5.3.0", "", { "dependencies": { "path-key": "^4.0.0" } }, "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ=="], + + "onetime": ["onetime@6.0.0", "", { "dependencies": { "mimic-fn": "^4.0.0" } }, "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ=="], + + "p-limit": ["p-limit@5.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + + "pathval": ["pathval@1.1.1", "", {}, "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "pretty-format": ["pretty-format@29.7.0", "", { "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", "react-is": "^18.0.0" } }, "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": "dist/bin/rollup" }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "strip-final-newline": ["strip-final-newline@3.0.0", "", {}, "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw=="], + + "strip-literal": ["strip-literal@2.1.1", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinypool": ["tinypool@0.8.4", "", {}, "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ=="], + + "tinyspy": ["tinyspy@2.2.1", "", {}, "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A=="], + + "type-detect": ["type-detect@4.1.0", "", {}, "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "ufo": ["ufo@1.6.4", "", {}, "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA=="], + + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vite-node": ["vite-node@1.6.1", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.4", "pathe": "^1.1.1", "picocolors": "^1.0.0", "vite": "^5.0.0" }, "bin": "vite-node.mjs" }, "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA=="], + + "vitest": ["vitest@1.6.1", "", { "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", "@vitest/snapshot": "1.6.1", "@vitest/spy": "1.6.1", "@vitest/utils": "1.6.1", "acorn-walk": "^8.3.2", "chai": "^4.3.10", "debug": "^4.3.4", "execa": "^8.0.1", "local-pkg": "^0.5.0", "magic-string": "^0.30.5", "pathe": "^1.1.1", "picocolors": "^1.0.0", "std-env": "^3.5.0", "strip-literal": "^2.0.0", "tinybench": "^2.5.1", "tinypool": "^0.8.3", "vite": "^5.0.0", "vite-node": "1.6.1", "why-is-node-running": "^2.2.2" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "1.6.1", "@vitest/ui": "1.6.1", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "mlly/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], + + "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "rollup/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + } +} diff --git a/hyper.config.json b/hyper.config.json new file mode 100644 index 0000000..f2b0de3 --- /dev/null +++ b/hyper.config.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://hyperjs.ai/schema.json", + "registryUrl": "https://hyperjs.ai", + "baseDir": "src/hyper", + "alias": "@hyper" +} diff --git a/hyper.lock.json b/hyper.lock.json new file mode 100644 index 0000000..d08ff02 --- /dev/null +++ b/hyper.lock.json @@ -0,0 +1,158 @@ +{ + "schema": 1, + "registryUrl": "https://hyperjs.ai", + "components": { + "core": { + "version": "0.1.0", + "installedAt": "2026-05-24T20:13:18.521Z", + "alias": "@hyper", + "files": [ + { + "path": "src/hyper/core/adapters/bun.ts", + "sha256": "4f8eb0e6ee1e02aab9cfff7389179c3fdb5b33e64cff1e7448ddfaefb3b9f1e7" + }, + { + "path": "src/hyper/core/app.ts", + "sha256": "febb44d5431ee0dfa984f85f2cd16453720ea22b0004456c91ced99114c260e4" + }, + { + "path": "src/hyper/core/decorate.ts", + "sha256": "0fae17db271ab3fe0748651d3060c76b085065a99e9b9c8aa8893721264b8cbd" + }, + { + "path": "src/hyper/core/env.ts", + "sha256": "51a24721cd91e60b83dbc64d63c61165c5752c4511c6e88949e6ff8c9647567c" + }, + { + "path": "src/hyper/core/error.ts", + "sha256": "9885ff59ffa0a40c58604eecbf204d6d56185a9cb65080bb306f4552f291be3a" + }, + { + "path": "src/hyper/core/example.ts", + "sha256": "a2eb72f9af0d96bdeece06b35b2ee9eba3afede8c70e8d1b8cae1b46333ff3df" + }, + { + "path": "src/hyper/core/file.ts", + "sha256": "d4bb32c62d9778df1651dc9ed3b9f1a3538c81ed14704302c1369ad822b3c810" + }, + { + "path": "src/hyper/core/group.ts", + "sha256": "2ecc2a8c85b38a5c1051e4338d7dc2db94a0f6ede3ca21f7e7f0d86a7560266c" + }, + { + "path": "src/hyper/core/hash.ts", + "sha256": "6aca4a7885e1846f66550601ba62755fd995d3045ab0e98534830b05d9586b15" + }, + { + "path": "src/hyper/core/hyper.ts", + "sha256": "bdb9dd67d460432c1f7b4ad5dda0a9bfde6d035109cb9a985f613bd517d9c38b" + }, + { + "path": "src/hyper/core/index.ts", + "sha256": "63e5a349effd109c74ab852807e6ba77ce676b40747ff70fff2680d0234a22b3" + }, + { + "path": "src/hyper/core/infer.ts", + "sha256": "f00baa5e87627474c9941607a7b2428ffbe48404b5f55e93d724ad4a9a3be07a" + }, + { + "path": "src/hyper/core/middleware.ts", + "sha256": "44b54fb5c7b53901b9ecc660c4bf6f3de305c90d9d0c819accdca177ed9ecfa7" + }, + { + "path": "src/hyper/core/projection.ts", + "sha256": "d4073186cd677ece4b6e5e266214e4594148d71f9e5a5058a235607c3829004a" + }, + { + "path": "src/hyper/core/request.ts", + "sha256": "c4669691fb6589f65a1442518f26ca6695ce3160a0a038165456a63596b4e79a" + }, + { + "path": "src/hyper/core/resource.ts", + "sha256": "4b4e4f5fd0412fc350fc8f200d075c840ae8deb4dfa7c552dcbd7864e0522c17" + }, + { + "path": "src/hyper/core/response.ts", + "sha256": "8e183f4aadb4139206e27e2105250896dcddad9d700fd6448a87943d2b2e32df" + }, + { + "path": "src/hyper/core/route.ts", + "sha256": "aeede5c10b4bdf6d19bb342a110b776803b3a1bffa99f8fd63ad0f2a2c913b0a" + }, + { + "path": "src/hyper/core/router.ts", + "sha256": "f81563c103792e1b30d75349453368bf4cb5d6c92b5443c4b661e2ab2526784e" + }, + { + "path": "src/hyper/core/security.ts", + "sha256": "ce26fbfbb77fe2ba9df90900935bc079df4dc91e96d777983a3ad0c00b7502b7" + }, + { + "path": "src/hyper/core/standard-schema.ts", + "sha256": "6283de86db2508ac522d3ea59585d0ac0b1ab4e83b99c3a70947e679e2691ac9" + }, + { + "path": "src/hyper/core/types.ts", + "sha256": "b37b2ecf54186938e449559cf4d80d772ef3a4f2a856bc140776ecb8a3f0c288" + } + ] + }, + "mcp": { + "version": "0.1.0", + "installedAt": "2026-05-24T20:13:18.524Z", + "alias": "@hyper", + "files": [ + { + "path": "src/hyper/mcp/audit.ts", + "sha256": "6b13a9c2e22e10693248b643646a14f6d7500417e3ce5d797185ac645f1598b7" + }, + { + "path": "src/hyper/mcp/index.ts", + "sha256": "35444e98257b9d6d2639d1af143f16b75bebe491f69d0f21182cc5debe3334af" + }, + { + "path": "src/hyper/mcp/server.ts", + "sha256": "3008a688528e4b0eab27c5b9ed03225711c5638457a00bfc2343420a45a7eeab" + } + ] + }, + "openapi": { + "version": "0.1.0", + "installedAt": "2026-05-24T20:13:18.523Z", + "alias": "@hyper", + "files": [ + { + "path": "src/hyper/openapi/converter.ts", + "sha256": "698bac9a0aeafc78a6e658f58fab4f100f4d1e90ee76304330048e61a3f09e02" + }, + { + "path": "src/hyper/openapi/generate.ts", + "sha256": "3d1a43081b5a814d0e99130b3d31e2bfb1e0c706cdf66d256923cd97a145bb89" + }, + { + "path": "src/hyper/openapi/index.ts", + "sha256": "044532288c945e2b9c9e6cbcdf74561007540706ceae7afebdc5d421fe5536af" + }, + { + "path": "src/hyper/openapi/plugin.ts", + "sha256": "b0c3ea65ec3b71cb72b681d53845368653f63b8fcf83aef83eea43b3b805cae4" + }, + { + "path": "src/hyper/openapi/swagger.ts", + "sha256": "50a2379f4f0dc2cb8749bc252d353b395daf2aad9723ed68acfc8925e500d2c8" + } + ] + }, + "openapi-zod": { + "version": "0.1.0", + "installedAt": "2026-05-24T20:13:18.523Z", + "alias": "@hyper", + "files": [ + { + "path": "src/hyper/openapi-zod/index.ts", + "sha256": "fc3d5db756dd6ca5d1827b63657b956d043e5df1fbac7bdaac7d8888f1863c18" + } + ] + } + } +} \ No newline at end of file diff --git a/index.ts b/index.ts index 69b8bbf..a93ed52 100644 --- a/index.ts +++ b/index.ts @@ -1,3 +1,12 @@ +/** + * Internal domain barrel. + * + * inventory-api is consumed over HTTP + MCP (a Hyper/Bun service — see + * src/app.ts), NOT as an npm package. This barrel is the internal import + * surface for the route handlers (src/routes.ts) and the test suite — it + * re-exports the domain functions + types + typed errors. Keeping it stable + * means the routes and tests share one canonical import path. + */ export { getHoldings, getNftsForOwner, getNftMetadata, getProfilePicture } from './src/inventory.js'; export type { HoldingsResponse, diff --git a/mcp.json b/mcp.json new file mode 100644 index 0000000..8383f86 --- /dev/null +++ b/mcp.json @@ -0,0 +1,43 @@ +{ + "version": "1.0", + "tools": [ + { + "name": "getHoldings", + "description": "Get a wallet's NFT holdings for registered collections (Mibera first), with per-token tokenIds and a completeness envelope (as_of_block, holder_count, source, complete) that proves the result is complete as of a block.", + "method": "GET", + "path": "/holdings/:address", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "object" + } + } + } + }, + { + "name": "getNftsForOwner", + "description": "Get the paginated list of NFTs (with full metadata: name, image, attributes) owned by a wallet for a given contract. Supports pageSize + pageKey cursoring.", + "method": "GET", + "path": "/nfts/:contract/owner/:address", + "inputSchema": { + "type": "object", + "properties": { + "query": { + "type": "object" + } + } + } + }, + { + "name": "getNftMetadata", + "description": "Get the metadata document (name, description, image, attributes) for a single token id of a contract, sourced from the codex.", + "method": "GET", + "path": "/nfts/:contract/:tokenId", + "inputSchema": { + "type": "object", + "properties": {} + } + } + ] +} diff --git a/openapi.json b/openapi.json index 0e8fbe6..8ea83eb 100644 --- a/openapi.json +++ b/openapi.json @@ -3,13 +3,12 @@ "info": { "title": "inventory-api", "version": "0.1.0", - "description": "Sovereign read-side inventory aggregator — joins sonar holdings with owned codex metadata + a completeness envelope." + "description": "Sovereign read-side inventory aggregator — joins sonar holdings with owned codex metadata + an ACVP completeness envelope." }, "paths": { "/holdings/{address}": { "get": { "operationId": "getHoldings", - "summary": "Resolve a wallet's holdings + ACVP completeness envelope", "tags": [ "inventory" ], @@ -17,18 +16,12 @@ { "name": "address", "in": "path", - "required": true, - "description": "Holder wallet address (0x-prefixed, 40 hex chars).", - "schema": { - "type": "string", - "pattern": "^0x[0-9a-fA-F]{40}$" - } + "required": true }, { "name": "contracts", "in": "query", "required": false, - "description": "Comma-separated contract addresses to filter to.", "schema": { "type": "string" } @@ -37,7 +30,6 @@ "name": "chains", "in": "query", "required": false, - "description": "Comma-separated chain ids to filter to.", "schema": { "type": "string" } @@ -48,28 +40,58 @@ "description": "success", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/HoldingsResponse" + "example": { + "holdings": [ + { + "contractAddress": "0x6666397DFe9a8c469BF65dc744CB1C733416c420", + "chainId": 80094, + "tokenCount": 12, + "tokenIds": [ + "1", + "2", + "3" + ] + } + ], + "completeness": { + "as_of_block": 9123456, + "holder_count": 5, + "source": "sonar", + "complete": true + } } } } }, "400": { - "description": "validation error", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "not found", + "description": "declared error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "status": { + "type": "number" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "status", + "message" + ] + } + }, + "required": [ + "error" + ] } } } @@ -80,7 +102,6 @@ "/nfts/{contract}/owner/{address}": { "get": { "operationId": "getNftsForOwner", - "summary": "Paginated NFTs (sonar ⨝ codex) owned by a wallet", "tags": [ "inventory" ], @@ -88,39 +109,25 @@ { "name": "contract", "in": "path", - "required": true, - "description": "Collection contract address (0x-prefixed, 40 hex chars).", - "schema": { - "type": "string", - "pattern": "^0x[0-9a-fA-F]{40}$" - } + "required": true }, { "name": "address", "in": "path", - "required": true, - "description": "Holder wallet address (0x-prefixed, 40 hex chars).", - "schema": { - "type": "string", - "pattern": "^0x[0-9a-fA-F]{40}$" - } + "required": true }, { "name": "pageSize", "in": "query", "required": false, - "description": "Page size (1-100, default 100).", "schema": { - "type": "integer", - "minimum": 1, - "maximum": 100 + "type": "number" } }, { "name": "pageKey", "in": "query", "required": false, - "description": "Opaque pagination cursor from a prior response.", "schema": { "type": "string" } @@ -131,28 +138,59 @@ "description": "success", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/NFTCollection" + "example": { + "contractAddress": "0x6666397DFe9a8c469BF65dc744CB1C733416c420", + "name": "Mibera", + "symbol": "MIBERA", + "totalSupply": 10000, + "nfts": [ + { + "tokenId": "1", + "name": "Mibera #1", + "description": "A Freetekno of Greek origin...", + "imageUrl": "https://assets.0xhoneyjar.xyz/.../1.png", + "contentType": "image/png", + "attributes": [ + { + "trait_type": "archetype", + "value": "Freetekno" + } + ] + } + ] } } } }, "400": { - "description": "validation error", + "description": "declared error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" - } - } - } - }, - "404": { - "description": "not found", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "status": { + "type": "number" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "status", + "message" + ] + } + }, + "required": [ + "error" + ] } } } @@ -163,7 +201,6 @@ "/nfts/{contract}/{tokenId}": { "get": { "operationId": "getNftMetadata", - "summary": "Single NFT metadata document from the codex", "tags": [ "inventory" ], @@ -171,22 +208,12 @@ { "name": "contract", "in": "path", - "required": true, - "description": "Collection contract address (0x-prefixed, 40 hex chars).", - "schema": { - "type": "string", - "pattern": "^0x[0-9a-fA-F]{40}$" - } + "required": true }, { "name": "tokenId", "in": "path", - "required": true, - "description": "Numeric token id.", - "schema": { - "type": "string", - "pattern": "^\\d+$" - } + "required": true } ], "responses": { @@ -194,28 +221,82 @@ "description": "success", "content": { "application/json": { - "schema": { - "$ref": "#/components/schemas/MetadataDocument" + "example": { + "name": "Air", + "description": "Cloud...", + "image": "https://assets.0xhoneyjar.xyz/Mibera/grails/air.webp", + "attributes": [ + { + "trait_type": "Grail", + "value": "true" + } + ] } } } }, "400": { - "description": "validation error", + "description": "declared error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "status": { + "type": "number" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "status", + "message" + ] + } + }, + "required": [ + "error" + ] } } } }, "404": { - "description": "not found", + "description": "declared error", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Error" + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "status": { + "type": "number" + }, + "message": { + "type": "string" + }, + "code": { + "type": "string" + } + }, + "required": [ + "status", + "message" + ] + } + }, + "required": [ + "error" + ] } } } @@ -223,215 +304,5 @@ } } } - }, - "components": { - "schemas": { - "Attribute": { - "type": "object", - "required": [ - "trait_type", - "value" - ], - "properties": { - "trait_type": { - "type": "string" - }, - "value": { - "type": "string" - } - } - }, - "CompletenessEnvelope": { - "type": "object", - "required": [ - "as_of_block", - "holder_count", - "source", - "complete" - ], - "properties": { - "as_of_block": { - "type": "integer" - }, - "holder_count": { - "type": "integer" - }, - "source": { - "type": "string", - "enum": [ - "sonar" - ] - }, - "complete": { - "description": "true when the answer is provably complete; 'degraded' when upstream was unreachable.", - "oneOf": [ - { - "type": "boolean", - "enum": [ - true - ] - }, - { - "type": "string", - "enum": [ - "degraded" - ] - } - ] - } - } - }, - "ContractHolding": { - "type": "object", - "required": [ - "contractAddress", - "chainId", - "tokenCount", - "tokenIds" - ], - "properties": { - "contractAddress": { - "type": "string" - }, - "chainId": { - "type": "integer" - }, - "tokenCount": { - "type": "integer" - }, - "tokenIds": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "HoldingsResponse": { - "type": "object", - "required": [ - "holdings", - "completeness" - ], - "properties": { - "holdings": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ContractHolding" - } - }, - "completeness": { - "$ref": "#/components/schemas/CompletenessEnvelope" - } - } - }, - "NFT": { - "type": "object", - "required": [ - "tokenId", - "name", - "description", - "imageUrl", - "contentType", - "attributes" - ], - "properties": { - "tokenId": { - "type": "string" - }, - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "imageUrl": { - "type": "string" - }, - "contentType": { - "type": "string" - }, - "attributes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Attribute" - } - } - } - }, - "NFTCollection": { - "type": "object", - "required": [ - "contractAddress", - "name", - "symbol", - "totalSupply", - "nfts" - ], - "properties": { - "contractAddress": { - "type": "string" - }, - "name": { - "type": "string" - }, - "symbol": { - "type": "string" - }, - "totalSupply": { - "type": "integer" - }, - "nfts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/NFT" - } - }, - "pageKey": { - "type": "string" - } - } - }, - "MetadataDocument": { - "type": "object", - "required": [ - "name", - "description", - "image", - "attributes" - ], - "properties": { - "name": { - "type": "string" - }, - "description": { - "type": "string" - }, - "image": { - "type": "string" - }, - "attributes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Attribute" - } - } - } - }, - "Error": { - "type": "object", - "required": [ - "error" - ], - "properties": { - "error": { - "type": "string" - }, - "code": { - "type": "string" - } - } - } - } } } diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 81bac6f..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1926 +0,0 @@ -{ - "name": "@freeside/inventory", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@freeside/inventory", - "version": "0.1.0", - "dependencies": { - "ethereum-cryptography": "^2.2.1" - }, - "devDependencies": { - "@types/node": "^20.12.7", - "typescript": "^5.4.5", - "vitest": "^1.6.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.27.8" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@noble/curves": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.4.2.tgz", - "integrity": "sha512-TavHr8qycMChk8UwMld0ZDRvatedkzWfH8IiaeGCfymOP5i0hSCozz9vHOL0nkwk7HRMlFnAiKpS2jrUmSybcw==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "1.4.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@noble/hashes": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz", - "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", - "license": "MIT", - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@scure/base": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", - "integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==", - "license": "MIT", - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip32": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.4.0.tgz", - "integrity": "sha512-sVUpc0Vq3tXCkDGYVWGIZTRfnvu8LoTDaev7vbwh0omSvVORONr960MQWdKqJDCReIEmTj3PAr73O3aoxz7OPg==", - "license": "MIT", - "dependencies": { - "@noble/curves": "~1.4.0", - "@noble/hashes": "~1.4.0", - "@scure/base": "~1.1.6" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@scure/bip39": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.3.0.tgz", - "integrity": "sha512-disdg7gHuTDZtY+ZdkmLpPCk7fxZSu3gBiEGuoC1XYxv9cGx3Z6cpTggCgW6odSOOIXCiDjuGejW+aJKCY/pIQ==", - "license": "MIT", - "dependencies": { - "@noble/hashes": "~1.4.0", - "@scure/base": "~1.1.6" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.27.10", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", - "integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "20.19.41", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", - "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@vitest/expect": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", - "integrity": "sha512-jXL+9+ZNIJKruofqXuuTClf44eSpcHlgj3CiuNihUF3Ioujtmc0zIa3UJOW5RjDK1YLBJZnWBlPuqhYycLioog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "chai": "^4.3.10" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.1.tgz", - "integrity": "sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "1.6.1", - "p-limit": "^5.0.0", - "pathe": "^1.1.1" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.1.tgz", - "integrity": "sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.1.tgz", - "integrity": "sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^2.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.1.tgz", - "integrity": "sha512-jOrrUvXM4Av9ZWiG1EajNto0u96kWAhJ1LmPmJhXXQx/32MecEKd10pOLYgS2BQx1TgkGhloPU1ArDW2vvaY6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "diff-sequences": "^29.6.3", - "estree-walker": "^3.0.3", - "loupe": "^2.3.7", - "pretty-format": "^29.7.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-walk": { - "version": "8.3.5", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", - "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", - "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/ethereum-cryptography": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ethereum-cryptography/-/ethereum-cryptography-2.2.1.tgz", - "integrity": "sha512-r/W8lkHSiTLxUxW8Rf3u4HGB0xQweG2RyETjywylKZSzLWoWAijRz8WCuOtJ6wah+avllXBqZuk29HCCvhEIRg==", - "license": "MIT", - "dependencies": { - "@noble/curves": "1.4.2", - "@noble/hashes": "1.4.0", - "@scure/bip32": "1.4.0", - "@scure/bip39": "1.3.0" - } - }, - "node_modules/execa": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", - "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^8.0.1", - "human-signals": "^5.0.0", - "is-stream": "^3.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^5.1.0", - "onetime": "^6.0.0", - "signal-exit": "^4.1.0", - "strip-final-newline": "^3.0.0" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/get-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", - "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/human-signals": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", - "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=16.17.0" - } - }, - "node_modules/is-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", - "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/js-tokens": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", - "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/local-pkg": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.1.tgz", - "integrity": "sha512-9rrA30MRRP3gBD3HTGnC6cDFpaE1kVDWxWgqWJUN0RvDNAo+Nz/9GxB+nHOH0ifbVFy0hSA1V6vFDvnx54lTEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mlly": "^1.7.3", - "pkg-types": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, - "license": "MIT" - }, - "node_modules/mimic-fn": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", - "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/mlly/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/npm-run-path": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", - "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-run-path/node_modules/path-key": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", - "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/onetime": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", - "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-fn": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", - "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/pkg-types/node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/strip-final-newline": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", - "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-literal": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", - "integrity": "sha512-631UJ6O00eNGfMiWG78ck80dfBab8X6IVFB51jZK5Icd7XAs60Z5y7QdSd/wGIklnWvRbUNloVzhOKKmutxQ6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinypool": { - "version": "0.8.4", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", - "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", - "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.1.tgz", - "integrity": "sha512-YAXkfvGtuTzwWbDSACdJSg4A4DZiAqckWe90Zapc/sEX3XvHcw1NdurM/6od8J207tSDqNbSsgdCacBgvJKFuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.4", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.1.tgz", - "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "1.6.1", - "@vitest/runner": "1.6.1", - "@vitest/snapshot": "1.6.1", - "@vitest/spy": "1.6.1", - "@vitest/utils": "1.6.1", - "acorn-walk": "^8.3.2", - "chai": "^4.3.10", - "debug": "^4.3.4", - "execa": "^8.0.1", - "local-pkg": "^0.5.0", - "magic-string": "^0.30.5", - "pathe": "^1.1.1", - "picocolors": "^1.0.0", - "std-env": "^3.5.0", - "strip-literal": "^2.0.0", - "tinybench": "^2.5.1", - "tinypool": "^0.8.3", - "vite": "^5.0.0", - "vite-node": "1.6.1", - "why-is-node-running": "^2.2.2" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "1.6.1", - "@vitest/ui": "1.6.1", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yocto-queue": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", - "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/package.json b/package.json index 0df0efa..33cdb59 100644 --- a/package.json +++ b/package.json @@ -2,29 +2,26 @@ "name": "@0xhoneyjar/inventory", "version": "0.1.0", "type": "module", - "main": "./dist/index.js", - "types": "./dist/index.d.ts", - "exports": { - ".": { - "import": "./dist/index.js", - "types": "./dist/index.d.ts" - } - }, - "files": ["dist", "fixtures"], + "private": true, + "description": "Sovereign read-side inventory aggregator — a Hyper (Bun) service consumed over HTTP + MCP", "scripts": { - "build": "tsc", + "dev": "bun --hot src/app.ts", + "start": "bun src/app.ts", + "serve": "bun src/app.ts", "test": "vitest run", "typecheck": "tsc --noEmit", - "typecheck:server": "tsc --noEmit -p tsconfig.server.json", - "serve": "bun src/server/server.ts", - "openapi:emit": "bun src/server/emit-openapi.ts" + "openapi:emit": "bun src/emit-openapi.ts", + "mcp:emit": "bun src/emit-mcp.ts" }, "dependencies": { - "ethereum-cryptography": "^2.2.1" + "ethereum-cryptography": "^2.2.1", + "zod": "^3.23.8" }, "devDependencies": { - "vitest": "^1.6.0", + "@types/bun": "^1.3.14", + "@types/node": "^20.12.7", + "@usehyper/cli": "^0.1.1", "typescript": "^5.4.5", - "@types/node": "^20.12.7" + "vitest": "^1.6.0" } } diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..30d803a --- /dev/null +++ b/src/app.ts @@ -0,0 +1,80 @@ +/** + * inventory-api — the Hyper (Bun) service entrypoint. + * + * Consumed over HTTP + MCP (the operator's no-npm decision: honeyroad reads + * this over the wire, never as an npm package). One route declaration per + * endpoint (src/routes.ts) drives every surface here: + * + * HTTP GET /holdings/:address, /nfts/:contract/owner/:address, + * /nfts/:contract/:tokenId + * OpenAPI 3.1 GET /openapi.json (drift-CI anchor) + GET /docs (Swagger UI) + * MCP POST /mcp (JSON-RPC 2.0) + GET /.well-known/mcp.json (manifest) + * health GET /health + * + * Run: `bun run dev` (hot reload) or `bun run start`. + */ +import { Hyper, ok } from "@hyper/core"; +import { openapiHandlers, generate, type OpenAPIDoc } from "@hyper/openapi"; +import { zodConverter } from "@hyper/openapi-zod"; +import { mcpServer } from "@hyper/mcp"; +import { routes } from "./routes.js"; + +export const OPENAPI_CONFIG = { + title: "inventory-api", + version: "0.1.0", + description: + "Sovereign read-side inventory aggregator — joins sonar holdings with owned codex metadata + an ACVP completeness envelope.", + converters: [zodConverter], +} as const; + +// The business-route graph (the 3 endpoints) is the canonical source for +// OpenAPI + MCP generation — built once, never mutated by the meta routes +// below. `routes` is imported from src/routes.ts; `.build()` is memoized. +const businessApp = new Hyper({ name: "inventory-api" }).use(routes).build(); + +/** OpenAPI 3.1 document for the 3 business routes (drift-CI anchor). */ +export function buildOpenAPI(): OpenAPIDoc { + return generate(businessApp, OPENAPI_CONFIG); +} + +/** The MCP tool manifest (meta.mcp routes only). */ +export function buildMCPManifest() { + return businessApp.toMCPManifest(); +} + +// OpenAPI 3.1 (+ Swagger UI) derived from the route declarations. +const oa = openapiHandlers(businessApp, { ...OPENAPI_CONFIG, specUrl: "/openapi.json" }); + +// MCP server (JSON-RPC) over the same route graph (meta.mcp routes only). +const mcp = mcpServer(businessApp, { + info: { name: "inventory-api", version: "0.1.0" }, +}); + +// The served app: business routes + meta/discovery routes. +export const app = new Hyper({ name: "inventory-api" }) + .use(routes) + .get("/health", () => + ok({ ok: true, service: "inventory-api", routes: businessApp.routeList.length }), + ) + .get("/openapi.json", ({ req }) => oa.spec(req)) + .get("/docs", ({ req }) => oa.docs(req)) + .get("/.well-known/mcp.json", () => ok(mcp.manifest)) + // Hyper's pipeline auto-parses + consumes the POST body before the handler + // runs, so `mcp.handle` (which calls `req.json()`) would see an empty stream. + // Reconstruct a fresh Request from the already-parsed `ctx.body` and hand + // that to the JSON-RPC handler. + .post("/mcp", ({ req, body }) => + mcp.handle( + new Request(req.url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body ?? {}), + }), + ), + ); + +// `bun src/app.ts` boots Bun.serve; importing for tests does not (guarded on +// import.meta.main, which is false when imported by tests / emit scripts). +if (import.meta.main) { + app.listen(Number(process.env.PORT) || 8787); +} diff --git a/src/emit-mcp.ts b/src/emit-mcp.ts new file mode 100644 index 0000000..2f13728 --- /dev/null +++ b/src/emit-mcp.ts @@ -0,0 +1,18 @@ +/** + * Emit the MCP tool manifest to a file (default: mcp.json at repo root). + * Run: `bun run mcp:emit` or `bun src/emit-mcp.ts [outPath]`. + * + * Discovery artifact mirroring the live `GET /.well-known/mcp.json` route — + * committed so MCP-tool consumers can drift-CI against the tool surface. + */ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { buildMCPManifest } from "./app.js"; + +const outArg = process.argv[2]; +const defaultOut = fileURLToPath(new URL("../mcp.json", import.meta.url)); +const out = outArg ?? defaultOut; + +writeFileSync(out, `${JSON.stringify(buildMCPManifest(), null, 2)}\n`, "utf-8"); +// eslint-disable-next-line no-console +console.log(`wrote MCP manifest -> ${out}`); diff --git a/src/emit-openapi.ts b/src/emit-openapi.ts new file mode 100644 index 0000000..0311fdd --- /dev/null +++ b/src/emit-openapi.ts @@ -0,0 +1,19 @@ +/** + * Emit the OpenAPI 3.1 document to a file (default: openapi.json at repo root). + * Run: `bun run openapi:emit` or `bun src/emit-openapi.ts [outPath]`. + * + * The committed openapi.json is the drift-CI anchor the honeyroad frontend + * checks its generated client against. Regenerate whenever the route + * declarations (src/routes.ts) or domain response shapes change. + */ +import { writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { buildOpenAPI } from "./app.js"; + +const outArg = process.argv[2]; +const defaultOut = fileURLToPath(new URL("../openapi.json", import.meta.url)); +const out = outArg ?? defaultOut; + +writeFileSync(out, `${JSON.stringify(buildOpenAPI(), null, 2)}\n`, "utf-8"); +// eslint-disable-next-line no-console +console.log(`wrote OpenAPI 3.1 spec -> ${out}`); diff --git a/src/hyper/core/adapters/bun.ts b/src/hyper/core/adapters/bun.ts new file mode 100644 index 0000000..352d7e6 --- /dev/null +++ b/src/hyper/core/adapters/bun.ts @@ -0,0 +1,33 @@ +/** + * Bun adapter helpers. + * + * Thin wrappers around `Bun.serve` that use the native `routes` map + * emitted by the app + fall through to `fetch` for anything the map + * cannot express (e.g. catch-alls, middleware-only paths). + */ + +import type { HyperApp } from "../types.ts" + +export interface ServeOptions { + readonly port?: number + readonly hostname?: string + readonly idleTimeout?: number + readonly tls?: import("bun").TLSOptions + readonly development?: boolean +} + +/** Convenience wrapper. Callers may always prefer `Bun.serve` directly. */ +export function serve(app: HyperApp, opts: ServeOptions = {}): ReturnType { + const serveOpts: Record = { + routes: app.routes, + fetch: app.fetch, + idleTimeout: opts.idleTimeout ?? 10, + } + if (opts.port !== undefined) serveOpts.port = opts.port + if (opts.hostname !== undefined) serveOpts.hostname = opts.hostname + if (opts.tls !== undefined) serveOpts.tls = opts.tls + if (opts.development !== undefined) serveOpts.development = opts.development + // Cast: Bun.serve's Options union is too narrow for our generic shape; + // the runtime accepts every key we set. + return Bun.serve(serveOpts as unknown as Parameters[0]) +} diff --git a/src/hyper/core/app.ts b/src/hyper/core/app.ts new file mode 100644 index 0000000..e73151b --- /dev/null +++ b/src/hyper/core/app.ts @@ -0,0 +1,710 @@ +/** + * app() — builds a HyperApp from routes, groups, plugins, env, decorate. + * + * Boot order: + * 1. Merge env layers → typed env (throws on bad input with why/fix). + * 2. Run every decorate(env) → static ctx (singletons like db). + * 3. Run plugin.build(app) / plugin.context(env) → merge into static ctx. + * 4. Wire request pipeline: derive(ctx) per-request, plugins.before/after. + * + * The app is opaque for end users — everything goes through `.fetch`. + */ + +import { type ContextBlueprint, applyDerive, resolveStaticContext } from "./decorate.ts" +import { parseEnv, withEnv } from "./env.ts" +import { HyperError, asHyperError } from "./error.ts" +import { GroupBuilder, fromPlainRouter } from "./group.ts" +import { + type ClientManifest, + type MCPManifest, + type OpenAPIManifest, + type OpenAPIManifestConfig, + toClientManifest, + toMCPManifest, + toOpenAPI, +} from "./projection.ts" +import { parseBodyAuto } from "./request.ts" +import { coerce, errorResponse } from "./response.ts" +import { Router } from "./router.ts" +import { + DEFAULT_SECURITY, + METHOD_OVERRIDE_HEADERS, + METHOD_OVERRIDE_QUERY_KEYS, + applyDefaultHeaders, +} from "./security.ts" +import { SchemaValidationError, type StandardSchemaV1, parseStandard } from "./standard-schema.ts" +import type { + AppConfig, + AppContext, + BunRoutes, + HyperApp, + HyperPlugin, + InternalHandlerCtx, + InvokeInput, + InvokeResult, + Route, + SecurityDefaults, +} from "./types.ts" + +export function app(config: AppConfig = {}): HyperApp { + const security: SecurityDefaults = { ...DEFAULT_SECURITY, ...config.security } + + // 1. Collect routes ------------------------------------------------------- + const allRoutes: Route[] = [] + if (config.routes) allRoutes.push(...config.routes) + if (config.groups) { + for (const g of config.groups) { + // Accept either a GroupBuilder (has .build()) or a RouteGroup literal. + const built: import("./types.ts").RouteGroup = + typeof (g as { build?: unknown }).build === "function" + ? (g as import("./types.ts").GroupConfigEntry).build() + : (g as import("./types.ts").RouteGroup) + for (const r of built.routes) allRoutes.push(r) + } + } + if (config.router) { + const built = fromPlainRouter(config.router).build() + for (const r of built.routes) allRoutes.push(r) + } + + const router = new Router() + for (const r of allRoutes) router.add(r) + + // 2. Env (lazy — the first request triggers boot). In a real app, boot + // should be eager; but keeping it lazy makes the library usable in + // edge/test environments where no process.env is available. + // + // Once boot resolves, we cache the state directly and bypass the + // promise on subsequent requests — the hot path becomes sync. + let bootedPromise: Promise | undefined + let bootedCache: BootedState | undefined + let bootedError: unknown + const plugins: readonly HyperPlugin[] = config.plugins ?? [] + + // Precompute per-hook plugin arrays so the request pipeline skips + // any hook category that has zero installed callbacks — no empty + // `for (const p of plugins)` loops on the hot path. + const pluginsPreRoute: readonly HyperPlugin[] = plugins.filter((p) => p.request?.preRoute) + const pluginsBefore: readonly HyperPlugin[] = plugins.filter((p) => p.request?.before) + const pluginsAfter: readonly HyperPlugin[] = plugins.filter((p) => p.request?.after) + const pluginsOnError: readonly HyperPlugin[] = plugins.filter((p) => p.request?.onError) + + // Whether the app declares any env schema. When false we skip the + // AsyncLocalStorage (`withEnv`) wrapping per request — saves a Map + // alloc + ALS snapshot per fetch on plaintext-style routes. + const envRequired = config.env?.schema !== undefined + + const boot = async (): Promise => { + const envLayers: StandardSchemaV1[] = [] + if (config.env?.schema) envLayers.push(config.env.schema as StandardSchemaV1) + const env = await parseEnv(envLayers, config.env?.source) + + const blueprint: ContextBlueprint = { + decorators: config.decorate ?? [], + derives: config.derive ?? [], + } + const { ctx: staticCtx, dispose } = await resolveStaticContext(blueprint, env) + + // Plugin-installed context + for (const p of plugins) { + if (p.context) { + const added = await p.context(env) + Object.assign(staticCtx, added) + } + if (p.build) await p.build(instance) + } + + return { env, staticCtx, dispose, blueprint } + } + + const fetch = async (req: Request): Promise => { + let booted = bootedCache + if (!booted) { + if (bootedError) { + return finalize(errorResponse(asHyperError(bootedError)), isHttps(req), security) + } + if (!bootedPromise) { + bootedPromise = boot().then( + (s) => { + bootedCache = s + return s + }, + (e) => { + bootedError = e + throw e + }, + ) + } + try { + booted = await bootedPromise + } catch (e) { + return finalize(errorResponse(asHyperError(e)), isHttps(req), security) + } + } + const hooks: HookPlugins = { + preRoute: pluginsPreRoute, + before: pluginsBefore, + after: pluginsAfter, + onError: pluginsOnError, + } + // Skip the AsyncLocalStorage wrap when no env schema is declared — + // `useEnv()` is opt-in so the cost is unjustified on plaintext + // throughput benchmarks that never call it. + if (!envRequired) return handleRequest(req, booted, router, security, hooks) + return withEnv(booted.env, () => handleRequest(req, booted!, router, security, hooks)) + } + + const routes = buildBunRoutes(allRoutes, fetch) + + const invoke = async (input: InvokeInput): Promise => { + const rawPath = input.path.startsWith("/") ? input.path : `/${input.path}` + const resolvedPath = input.params + ? rawPath.replace(/:([A-Za-z0-9_]+)/g, (_, k: string) => { + const v = input.params?.[k] + if (v === undefined) throw new Error(`invoke: missing path param :${k}`) + return encodeURIComponent(v) + }) + : rawPath + const url = new URL(`http://local${resolvedPath}`) + if (input.query) { + for (const [k, v] of Object.entries(input.query)) { + if (v !== undefined) url.searchParams.set(k, String(v)) + } + } + const init: RequestInit = { + method: input.method, + ...(input.headers ? { headers: input.headers } : {}), + ...(input.body !== undefined && hasBody(input.method) + ? { + body: typeof input.body === "string" ? input.body : JSON.stringify(input.body), + headers: { + "content-type": "application/json", + ...(input.headers ?? {}), + }, + } + : {}), + } + const req = new Request(url, init) + const res = await fetch(req) + const ct = res.headers.get("content-type") ?? "" + const data = ct.includes("application/json") ? await res.json() : await res.text() + return { status: res.status, data, headers: res.headers } + } + + const toOpenAPIFn = (cfg: OpenAPIManifestConfig = {}): OpenAPIManifest => + toOpenAPI(allRoutes, cfg) + const toMCPFn = (): MCPManifest => toMCPManifest(allRoutes) + const toClientFn = (): ClientManifest => toClientManifest(allRoutes) + + const instance: HyperApp = { + fetch, + routes, + routeList: Object.freeze([...allRoutes]), + invoke, + toOpenAPI: toOpenAPIFn, + toMCPManifest: toMCPFn, + toClientManifest: toClientFn, + __config: config, + test: (overrides = {}) => makeTestApp(config, overrides), + } + return instance +} + +function makeTestApp(base: AppConfig, overrides: import("./types.ts").TestOverrides): HyperApp { + // Merge env: original source + overrides.env (overrides win). + const env: AppConfig["env"] | undefined = base.env + ? { + ...base.env, + source: { ...(base.env.source ?? {}), ...(overrides.env ?? {}) }, + } + : overrides.env !== undefined + ? { source: { ...overrides.env } } + : base.env + + const addDecorators = toArray(overrides.decorate) + const decorate = + addDecorators.length > 0 ? [...(base.decorate ?? []), ...addDecorators] : base.decorate + + const addDerives = toArray(overrides.derive) + const derive = addDerives.length > 0 ? [...(base.derive ?? []), ...addDerives] : base.derive + + let plugins = base.plugins ?? [] + if (overrides.plugins) { + const { skip = [], replace = {}, add = [] } = overrides.plugins + plugins = plugins + .filter((p) => !skip.includes(p.name)) + .map((p) => replace[p.name] ?? p) + .concat(add) + } + + return app({ + ...base, + ...(env !== undefined && { env }), + ...(decorate !== undefined && { decorate }), + ...(derive !== undefined && { derive }), + plugins, + }) +} + +function toArray(x: T | readonly T[] | undefined): readonly T[] { + if (x === undefined) return [] + return Array.isArray(x) ? (x as readonly T[]) : [x as T] +} + +interface BootedState { + readonly env: Record + readonly staticCtx: Record + readonly dispose: () => Promise + readonly blueprint: ContextBlueprint +} + +interface HookPlugins { + readonly preRoute: readonly HyperPlugin[] + readonly before: readonly HyperPlugin[] + readonly after: readonly HyperPlugin[] + readonly onError: readonly HyperPlugin[] +} + +async function handleRequest( + req: Request, + booted: BootedState, + router: Router, + security: SecurityDefaults, + hooks: HookPlugins, +): Promise { + // Pathname is extracted via indexOf — we never allocate a URL on the + // routing hot path. URL is only built on-demand if the handler reads + // `ctx.url` (via a lazy prototype getter on the handler ctx). + const rawUrl = req.url + const pathname = extractPathname(rawUrl) + const https = isHttps(req) + + // Method-override guard — refuse to reinterpret the verb from headers + // or query string. CSRF attackers love these; Hyper never honors them. + if (security.rejectMethodOverride) { + const headers = req.headers + for (let i = 0; i < METHOD_OVERRIDE_HEADERS.length; i++) { + const h = METHOD_OVERRIDE_HEADERS[i]! + if (headers.has(h)) return finalize(methodOverrideRejected(h), https, security) + } + for (let i = 0; i < METHOD_OVERRIDE_QUERY_KEYS.length; i++) { + const q = METHOD_OVERRIDE_QUERY_KEYS[i]! + if (urlHasQueryKey(rawUrl, q)) return finalize(methodOverrideRejected(q), https, security) + } + } + + // Plugin pre-route hooks may short-circuit (CORS preflight, etc.) + // before routing, so OPTIONS on unregistered paths still works. + if (hooks.preRoute.length > 0) { + for (const p of hooks.preRoute) { + const r = await p.request!.preRoute!({ req }) + if (r instanceof Response) return finalize(r, https, security) + } + } + + const match = router.find(req.method as "GET", pathname) + if (!match) { + return finalize( + new Response( + JSON.stringify({ + error: { status: 404, message: `No route for ${req.method} ${pathname}` }, + }), + { status: 404, headers: { "content-type": "application/json; charset=utf-8" } }, + ), + https, + security, + ) + } + + // Per-request ctx — skips the spread when there are no derivers. + const ctx = (await applyDerive(booted.blueprint, booted.staticCtx, booted.env, req)) as AppContext + + try { + if (hooks.before.length > 0) { + for (const p of hooks.before) { + await p.request!.before!({ req, ctx, route: match.route }) + } + } + const timeoutMs = + (match.route.meta.timeoutMs as number | undefined) ?? security.requestTimeoutMs + const res = + timeoutMs > 0 + ? await withTimeout(runPipeline(match.route, match.params, req, ctx), timeoutMs) + : await runPipeline(match.route, match.params, req, ctx) + if (hooks.after.length > 0) { + for (const p of hooks.after) { + await p.request!.after!({ req, ctx, res, route: match.route }) + } + } + return finalize(res, https, security, match.route.meta.headers) + } catch (e) { + if (hooks.onError.length > 0) { + for (const p of hooks.onError) { + await p.request!.onError!({ req, ctx, error: e, route: match.route }) + } + } + const err = e instanceof SchemaValidationError ? schemaToHyperError(e) : asHyperError(e) + return finalize(errorResponse(err), https, security) + } +} + +function schemaToHyperError(e: SchemaValidationError): HyperError { + return new HyperError({ + status: 400, + code: "validation_failed", + message: "Request failed validation.", + why: "One or more inputs did not match the declared schema.", + fix: "Check the `details` field for per-field issues and correct the payload.", + details: { + issues: e.issues.map((i) => ({ + path: i.path?.map(String) ?? [], + message: i.message, + })), + }, + }) +} + +/** + * Shared prototype for the per-request handler ctx. Every field that + * isn't strictly needed up-front is declared as a lazy getter — + * `ctx.url`, `ctx.query`, `ctx.headers`, `ctx.responseHeaders`. When + * the route declares a schema we set the parsed value as an own + * property on the instance, which shadows the getter. The handler + * pays the cost of allocating a URL / URLSearchParams / Headers only + * when it actually touches them. + * + * Using a shared prototype (not a per-request defineProperty) means + * every ictx shares one hidden class — JSC specializes it cleanly. + */ +interface LazyCtxState { + req: Request + _url?: URL + _query?: unknown + _headers?: unknown + _rh?: Headers + _cookies?: import("bun").CookieMap +} + +/** + * Each lazy accessor has both a getter (materialize-on-first-read) + * and a setter (cache override). The setter lets the pipeline write + * parsed schema output through `ictx.query = parsed` without + * tripping strict-mode's "assign to readonly property" error, while + * also giving us the hidden-class benefit of a single shared layout. + */ +const ICTX_PROTO: PropertyDescriptorMap = { + url: { + get(this: LazyCtxState): URL { + let u = this._url + if (u === undefined) { + u = new URL(this.req.url) + this._url = u + } + return u + }, + set(this: LazyCtxState, v: URL) { + this._url = v + }, + enumerable: true, + configurable: true, + }, + query: { + get(this: LazyCtxState): unknown { + let q = this._query + if (q === undefined) { + const out: Record = {} + const url = this.req.url + const qi = url.indexOf("?") + if (qi >= 0) { + const hash = url.indexOf("#", qi) + const end = hash < 0 ? url.length : hash + const sp = new URLSearchParams(url.slice(qi + 1, end)) + sp.forEach((v, k) => { + out[k] = v + }) + } + q = out + this._query = q + } + return q + }, + set(this: LazyCtxState, v: unknown) { + this._query = v + }, + enumerable: true, + configurable: true, + }, + headers: { + get(this: LazyCtxState): unknown { + let h = this._headers + if (h === undefined) { + const out: Record = {} + ;(this.req as Request).headers.forEach((v, k) => { + out[k] = v + }) + h = out + this._headers = h + } + return h + }, + set(this: LazyCtxState, v: unknown) { + this._headers = v + }, + enumerable: true, + configurable: true, + }, + responseHeaders: { + get(this: LazyCtxState): Headers { + let rh = this._rh + if (rh === undefined) { + rh = new Headers() + this._rh = rh + } + return rh + }, + set(this: LazyCtxState, v: Headers) { + this._rh = v + }, + enumerable: true, + configurable: true, + }, + cookies: { + value(this: LazyCtxState): import("bun").CookieMap { + let c = this._cookies + if (c === undefined) { + c = new Bun.CookieMap(this.req.headers.get("cookie") ?? "") + this._cookies = c + } + return c + }, + enumerable: true, + writable: false, + configurable: false, + }, +} + +// All `ictx` objects share this prototype → one hidden class. +const ICTX_PROTOTYPE: object = Object.create(null, ICTX_PROTO) + +async function runPipeline( + route: Route, + params: Record, + req: Request, + ctx: AppContext, +): Promise { + const parsedParams = route.params ? await parseStandard(route.params, params) : params + + // The ictx object is laid out with a fixed shape so V8/JSC can + // specialize it. Own-property writes for schema-declared inputs + // shadow the lazy getters on the prototype. + const ictx = Object.create(ICTX_PROTOTYPE) as InternalHandlerCtx & LazyCtxState + ictx.req = req + ;(ictx as unknown as { params: unknown }).params = parsedParams + ;(ictx as unknown as { body: unknown }).body = undefined + ;(ictx as unknown as { ctx: AppContext }).ctx = ctx + + if (route.query) { + // Schema-declared query → allocate URLSearchParams once, extract + // into a plain object, then run Standard Schema over it. + const rawUrl = req.url + const qi = rawUrl.indexOf("?") + const queryInput: Record = {} + if (qi >= 0) { + const hash = rawUrl.indexOf("#", qi) + const end = hash < 0 ? rawUrl.length : hash + const sp = new URLSearchParams(rawUrl.slice(qi + 1, end)) + sp.forEach((v, k) => { + queryInput[k] = v + }) + } + const parsed = await parseStandard(route.query, queryInput) + // Writes through the prototype's setter → caches as own state, + // shadowing the lazy getter on subsequent reads. + ;(ictx as unknown as { query: unknown }).query = parsed + } + + if (hasBody(req.method)) { + const raw = await parseBodyAuto(req) + const parsedBody = route.body ? await parseStandard(route.body, raw) : raw + ;(ictx as unknown as { body: unknown }).body = parsedBody + } + + if (route.headers) { + const headerInput: Record = {} + req.headers.forEach((v, k) => { + headerInput[k] = v + }) + const parsed = await parseStandard(route.headers, headerInput) + ;(ictx as unknown as { headers: unknown }).headers = parsed + } + + const result = await route.handler(ictx) + return coerce(result) +} + +function hasBody(method: string): boolean { + return method !== "GET" && method !== "HEAD" && method !== "OPTIONS" +} + +function finalize( + res: Response, + https: boolean, + security: SecurityDefaults, + routeOverrides?: Record, +): Response { + if (!security.headers) return res + const emitHsts = + security.hstsEnv === false + ? false + : (process.env.NODE_ENV ?? "development") === security.hstsEnv + + // Fast path: response helpers pre-bake the secure defaults, so when + // there are no route overrides AND we don't need HSTS, we can return + // the response unchanged. Probe via `x-content-type-options` — this + // is the sentinel that every Hyper helper emits. + const needsHsts = https && emitHsts !== false + if ( + !routeOverrides && + !needsHsts && + !res.headers.has("server") && + res.headers.has("x-content-type-options") + ) { + return res + } + + return applyDefaultHeaders(res, { + https, + emitHsts, + ...(routeOverrides ? { overrides: routeOverrides } : {}), + }) +} + +function methodOverrideRejected(which: string): Response { + return new Response( + JSON.stringify({ + error: { + status: 400, + code: "method_override_rejected", + message: `Hyper refuses to honor method override via '${which}'.`, + why: "Method overrides are a CSRF/verb-smuggling vector and are disabled by default.", + fix: "Call the endpoint with the real HTTP verb. If you really need overrides, set `app({ security: { rejectMethodOverride: false } })`.", + }, + }), + { status: 400, headers: { "content-type": "application/json; charset=utf-8" } }, + ) +} + +/** + * Cheap https detection from `req.url` — avoids allocating a full URL + * on the boot-error path. `req.url` is always absolute for Bun Request + * objects produced by `Bun.serve`. + */ +function isHttps(req: Request): boolean { + const u = req.url + return u.length > 5 && u.charCodeAt(4) === 115 /* 's' */ && u.startsWith("https:") +} + +/** + * Extract pathname from a fully-qualified request URL without + * constructing a `URL` object. Returns `/` for inputs without a path. + * The single allocation is the final `slice()`. + * + * extractPathname("http://host:3000/foo/bar?x=1") === "/foo/bar" + */ +function extractPathname(url: string): string { + const schemeEnd = url.indexOf("://") + if (schemeEnd < 0) return "/" + const pathStart = url.indexOf("/", schemeEnd + 3) + if (pathStart < 0) return "/" + const q = url.indexOf("?", pathStart) + const h = url.indexOf("#", pathStart) + const end = q < 0 ? h : h < 0 ? q : Math.min(q, h) + return end < 0 ? url.slice(pathStart) : url.slice(pathStart, end) +} + +/** + * Direct string scan for a query parameter key. Avoids URL / + * URLSearchParams construction on the method-override guard's hot path. + */ +function urlHasQueryKey(url: string, key: string): boolean { + const qStart = url.indexOf("?") + if (qStart < 0) return false + const hash = url.indexOf("#", qStart) + const qEnd = hash < 0 ? url.length : hash + const keyLen = key.length + let i = qStart + 1 + while (i < qEnd) { + const delim = url.indexOf("&", i) + const segEnd = delim < 0 || delim >= qEnd ? qEnd : delim + // A key is a match when either "key=" starts at i, or the raw key + // appears as a flag (no `=`) and runs to segEnd. + if ( + i + keyLen <= segEnd && + url.startsWith(key, i) && + (i + keyLen === segEnd || url.charCodeAt(i + keyLen) === 61) /* '=' */ + ) { + return true + } + if (delim < 0) return false + i = delim + 1 + } + return false +} + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new HyperError({ + status: 504, + code: "request_timeout", + message: `Handler exceeded ${ms}ms timeout.`, + why: "The handler did not produce a response in time.", + fix: "Make the handler faster, raise `security.requestTimeoutMs`, or set `.meta({ timeoutMs })` per-route.", + }), + ) + }, ms) + if (typeof (timer as { unref?: () => void }).unref === "function") { + ;(timer as { unref: () => void }).unref() + } + }) + try { + return await Promise.race([promise, timeout]) + } finally { + if (timer) clearTimeout(timer) + } +} + +function buildBunRoutes( + routes: readonly Route[], + fetch: (req: Request) => Promise, +): BunRoutes { + const map: BunRoutes = {} + // Index routes by path so we can detect fully-static paths (every + // method on that path is a `.staticResponse()`). Static paths let + // Bun.serve's native router short-circuit without calling a fn. + const byPath = new Map() + for (const r of routes) { + const list = byPath.get(r.path) + if (list) list.push(r) + else byPath.set(r.path, [r]) + } + for (const [path, list] of byPath) { + const allStatic = list.length > 0 && list.every((r) => r.kind === "static") + if (allStatic && list.length === 1 && list[0]!.staticResponse) { + // Single-method static response → native static route (Response) + map[path] = list[0]!.staticResponse + } else if (allStatic) { + // Method-keyed static responses. + const methodMap: Record = {} + for (const r of list) { + if (r.staticResponse) methodMap[r.method] = r.staticResponse + } + map[path] = methodMap + } else { + map[path] = (req: Request) => fetch(req) + } + } + return map +} diff --git a/src/hyper/core/decorate.ts b/src/hyper/core/decorate.ts new file mode 100644 index 0000000..b9a89d1 --- /dev/null +++ b/src/hyper/core/decorate.ts @@ -0,0 +1,113 @@ +/** + * Context decoration — three-tier dependency injection. + * + * 1. `decorate(env => ({ db, redis }))` at app / group / route level + * — static singletons constructed once at boot. Disposed in reverse + * order on shutdown via `Symbol.asyncDispose`. + * 2. `derive(fn)` — runs per-request; computes values from ctx/req + * (e.g., `ctx.user` from a JWT claim). + * 3. Plugin-installed context via `plugin.context`. + * + * Types flow via `declare module "@hyper/core" { interface AppContext { ... } }`. + * + * Recipe (cross-file typing): + * // src/ctx.d.ts + * import type { Db } from "./db" + * declare module "@hyper/core" { interface AppContext { db: Db } } + */ + +import type { AppContext } from "./types.ts" + +export type DecorateFactory = (env: Env) => Added | Promise + +export type DeriveFactory< + Env = unknown, + CtxIn extends AppContext = AppContext, + Added = unknown, +> = (args: { ctx: CtxIn; env: Env; req: Request }) => Added | Promise + +/** Registry built at app() time; applied to each request's ctx. */ +export interface ContextBlueprint { + readonly decorators: readonly DecorateFactory[] + readonly derives: readonly DeriveFactory[] +} + +/** + * Resolve all `decorate()` entries once at boot. Returns the merged + * static context plus a disposer (async) that runs in reverse order. + */ +export async function resolveStaticContext( + bp: ContextBlueprint, + env: Env, +): Promise<{ ctx: Record; dispose: () => Promise }> { + const merged: Record = {} + const disposers: Array<() => Promise> = [] + for (const f of bp.decorators) { + const added = await f(env) + if (added && typeof added === "object") { + for (const [k, v] of Object.entries(added as Record)) { + merged[k] = v + if (isAsyncDisposable(v)) { + disposers.push(async () => { + await (v as { [Symbol.asyncDispose]: () => PromiseLike })[Symbol.asyncDispose]() + }) + } else if (isDisposable(v)) { + disposers.push(async () => { + ;(v as { [Symbol.dispose]: () => void })[Symbol.dispose]() + }) + } + } + } + } + return { + ctx: merged, + async dispose() { + for (let i = disposers.length - 1; i >= 0; i--) { + try { + await disposers[i]?.() + } catch (err) { + console.error("hyper: disposer failed:", err) + } + } + }, + } +} + +/** + * Apply per-request `derive()` to an already-static-decorated ctx. + * + * Fast path: when there are zero derive functions we return the static + * ctx as-is — avoiding a per-request shallow clone. Plugins/consumers + * must treat the ctx as read-only (which the `AppContext` type already + * implies via declaration-merged readonly surfaces). + */ +export async function applyDerive( + bp: ContextBlueprint, + staticCtx: Record, + env: Env, + req: Request, +): Promise> { + if (bp.derives.length === 0) return staticCtx + const ctx = { ...staticCtx } + for (const f of bp.derives) { + const added = await f({ ctx: ctx as AppContext, env, req }) + if (added && typeof added === "object") Object.assign(ctx, added) + } + return ctx +} + +function isAsyncDisposable(v: unknown): boolean { + return ( + typeof v === "object" && + v !== null && + typeof (v as Record)[Symbol.asyncDispose] === "function" + ) +} + +function isDisposable(v: unknown): boolean { + return ( + typeof v === "object" && + v !== null && + typeof (v as Record)[Symbol.dispose] === "function" + ) +} diff --git a/src/hyper/core/env.ts b/src/hyper/core/env.ts new file mode 100644 index 0000000..88bf9dc --- /dev/null +++ b/src/hyper/core/env.ts @@ -0,0 +1,115 @@ +/** + * Environment configuration — layered & parsed once at boot. + * + * - `app({ env: schema })` for global, plus `.env(schema)` on group/route. + * - Layers merge by intersection: the handler sees the union of all + * declared fields with narrowed types. + * - Parse errors throw at boot with a `why`/`fix` shape listing every + * field that failed (agents fix all of them in one edit). + * - Secret marking: paths matching the provided `secret` paths are + * redacted by `@hyper/log` and never echoed to error responses. + * - `useEnv()` via AsyncLocalStorage for deep code. + */ + +import { AsyncLocalStorage } from "node:async_hooks" +import { HyperError } from "./error.ts" +import { SchemaValidationError, parseStandard } from "./standard-schema.ts" +import type { StandardSchemaV1 } from "./standard-schema.ts" + +export interface EnvConfig { + readonly schema?: StandardSchemaV1 + /** Dot-paths that should be treated as secret (auto-redacted). */ + readonly secrets?: readonly string[] + /** Source env (defaults to process.env). */ + readonly source?: Record +} + +const envStorage = new AsyncLocalStorage>() + +/** Retrieve the current request's env (runs inside an async scope). */ +export function useEnv>(): T { + const env = envStorage.getStore() + if (!env) { + throw new HyperError({ + status: 500, + message: "useEnv() called outside a request scope.", + why: "AsyncLocalStorage has no env; the app is likely not initialized.", + fix: "Ensure code paths using useEnv() run inside the app.fetch() pipeline.", + }) + } + return env as T +} + +export function withEnv(env: Record, fn: () => T): T { + return envStorage.run(env, fn) +} + +/** + * Parse a collection of layer-schemas against `source` once at boot. + * Returns the merged typed env. Throws `EnvParseError` with a per-field + * breakdown on failure. + */ +export async function parseEnv( + layers: readonly StandardSchemaV1[], + source: Record = process.env, +): Promise> { + if (layers.length === 0) return { ...source } + const out: Record = {} + const allIssues: Array<{ layer: number; path: string; message: string }> = [] + for (let i = 0; i < layers.length; i++) { + const schema = layers[i]! + try { + const parsed = (await parseStandard(schema, source)) as Record + Object.assign(out, parsed) + } catch (e) { + if (e instanceof SchemaValidationError) { + for (const issue of e.issues) { + allIssues.push({ + layer: i, + path: (issue.path ?? []).map(String).join(".") || "(root)", + message: issue.message, + }) + } + } else { + throw e + } + } + } + if (allIssues.length > 0) throw new EnvParseError(allIssues) + return out +} + +export class EnvParseError extends Error { + readonly issues: ReadonlyArray<{ layer: number; path: string; message: string }> + constructor(issues: ReadonlyArray<{ layer: number; path: string; message: string }>) { + const lines = issues.map((i) => ` layer ${i.layer} ${i.path}: ${i.message}`).join("\n") + super(`Environment did not match declared schema:\n${lines}`) + this.name = "EnvParseError" + this.issues = issues + } +} + +/** + * Mark secret paths on an env object in-place for @hyper/log consumers. + * A non-enumerable symbol keyed off the env carries the list. + */ +export const SECRET_PATHS: unique symbol = Symbol.for("@hyper/core/secret-paths") + +export function markSecrets(env: T, paths: readonly string[]): T { + Object.defineProperty(env, SECRET_PATHS, { + value: Object.freeze([...paths]), + enumerable: false, + }) + return env +} + +export function getSecretPaths(env: object): readonly string[] | undefined { + const paths = (env as Record)[SECRET_PATHS] + if (!paths) return undefined + return paths as readonly string[] +} + +/** Helper: wrap a Standard Schema field with a marker string. */ +export function secret(schema: T): T & { __hyperSecret: true } { + return schema as T & { __hyperSecret: true } +} diff --git a/src/hyper/core/error.ts b/src/hyper/core/error.ts new file mode 100644 index 0000000..60cda19 --- /dev/null +++ b/src/hyper/core/error.ts @@ -0,0 +1,90 @@ +/** + * Structured errors. + * + * Hyper distinguishes thrown errors (unexpected) from returned errors + * (contract-defined). `createError` produces the thrown shape with + * `why`/`fix` fields that surface in logs, error responses, and the + * MCP error payload — making failures actionable for both humans + * and agents. + */ + +export interface HyperErrorInit { + /** HTTP status to project (defaults to 500). */ + status?: number + /** Short machine code (e.g., "email_exists"). */ + code?: string + /** Human message. */ + message: string + /** Why this happened — explained to the caller (not internal details). */ + why?: string + /** How to fix it — agent-actionable. */ + fix?: string + /** Documentation or recovery links. */ + links?: readonly string[] + /** Arbitrary structured detail (redacted in logs if matching secret paths). */ + details?: Record + /** Underlying cause (stripped from wire response; kept in logs). */ + cause?: unknown +} + +export class HyperError extends Error { + readonly status: number + readonly code?: string + readonly why?: string + readonly fix?: string + readonly links?: readonly string[] + readonly details?: Record + + constructor(init: HyperErrorInit) { + super(init.message, { cause: init.cause }) + this.name = "HyperError" + this.status = init.status ?? 500 + if (init.code !== undefined) this.code = init.code + if (init.why !== undefined) this.why = init.why + if (init.fix !== undefined) this.fix = init.fix + if (init.links !== undefined) this.links = init.links + if (init.details !== undefined) this.details = init.details + } + + /** Wire shape — safe to serialize to clients and agents. */ + toJSON(): Record { + const base: Record = { + error: { + status: this.status, + message: this.message, + }, + } + const err = base.error as Record + if (this.code) err.code = this.code + if (this.why) err.why = this.why + if (this.fix) err.fix = this.fix + if (this.links) err.links = this.links + if (this.details) err.details = this.details + return base + } +} + +/** Factory — preferred API. */ +export function createError(init: HyperErrorInit): HyperError { + return new HyperError(init) +} + +/** Project unknown errors into a HyperError at the boundary. */ +export function asHyperError(e: unknown): HyperError { + if (e instanceof HyperError) return e + if (e instanceof Error) { + return new HyperError({ + status: 500, + message: e.message || "Internal Server Error", + why: "Handler threw an unhandled error.", + fix: "Check server logs for the stack trace.", + cause: e, + }) + } + return new HyperError({ + status: 500, + message: "Internal Server Error", + why: "Handler threw a non-Error value.", + cause: e, + }) +} diff --git a/src/hyper/core/example.ts b/src/hyper/core/example.ts new file mode 100644 index 0000000..5fcc391 --- /dev/null +++ b/src/hyper/core/example.ts @@ -0,0 +1,67 @@ +/** + * runExamples(app) — walks every route's `meta.examples` and executes each + * example against the in-process app.invoke() path. Used by `hyper test` + * and directly inside consumer test files (see @hyper/testing). + */ + +import type { HttpMethod, HyperApp, RouteExample } from "./types.ts" + +export interface ExampleResult { + readonly route: string + readonly method: string + readonly example: string + readonly ok: boolean + readonly status: number + readonly expected?: number + readonly actual?: unknown + readonly error?: string +} + +export async function runExamples(app: HyperApp): Promise { + const out: ExampleResult[] = [] + for (const route of app.routeList) { + const examples = route.meta.examples as readonly RouteExample[] | undefined + if (!examples || examples.length === 0) continue + for (const ex of examples) { + const expected = ex.output?.status + try { + const result = await app.invoke({ + method: route.method as HttpMethod, + path: route.path, + ...(ex.input?.params && { params: ex.input.params as Record }), + ...(ex.input?.query && { query: ex.input.query }), + ...(ex.input?.body !== undefined && { body: ex.input.body }), + ...(ex.input?.headers && { + headers: Object.fromEntries( + Object.entries(ex.input.headers).map(([k, v]) => [k, String(v)]), + ), + }), + }) + const statusOk = expected === undefined ? result.status < 400 : result.status === expected + const bodyOk = + ex.output?.body === undefined + ? true + : JSON.stringify(result.data) === JSON.stringify(ex.output.body) + out.push({ + route: route.path, + method: route.method, + example: ex.name, + ok: statusOk && bodyOk, + status: result.status, + ...(expected !== undefined && { expected }), + ...(ex.output?.body !== undefined && { actual: result.data }), + }) + } catch (e) { + out.push({ + route: route.path, + method: route.method, + example: ex.name, + ok: false, + status: 0, + error: (e as Error).message, + }) + } + } + } + return out +} diff --git a/src/hyper/core/file.ts b/src/hyper/core/file.ts new file mode 100644 index 0000000..2b25556 --- /dev/null +++ b/src/hyper/core/file.ts @@ -0,0 +1,41 @@ +/** + * file() — serve a file from disk via `Bun.file(path)`. + * + * Refuses `..` segments by default to prevent path traversal. Users + * can opt in explicitly when serving from a safely-sandboxed path. + */ + +import { HyperError } from "./error.ts" + +export interface FileOptions { + /** Only set this for a path you fully control and sanitize upstream. */ + readonly allowTraversal?: boolean + /** Optional content-type override; otherwise Bun sniffs from extension. */ + readonly type?: string +} + +/** + * Return a `Bun.file(path)` response helper. The response layer + * detects the BunFile shape and passes through via `sendfile`. + */ +export function file(path: string, opts: FileOptions = {}): import("bun").BunFile { + if (!opts.allowTraversal && hasTraversal(path)) { + throw new HyperError({ + status: 400, + code: "path_traversal", + message: "Refusing to serve a path containing '..' segments.", + why: "Path traversal is a common attack; Hyper rejects it at the file helper.", + fix: "Pass `allowTraversal: true` only when serving from a sandboxed prefix you control.", + }) + } + return Bun.file(path, opts.type ? { type: opts.type } : undefined) +} + +function hasTraversal(p: string): boolean { + // Normalize forward+backslashes. + const normalized = p.replace(/\\/g, "/") + for (const seg of normalized.split("/")) { + if (seg === "..") return true + } + return false +} diff --git a/src/hyper/core/group.ts b/src/hyper/core/group.ts new file mode 100644 index 0000000..052490b --- /dev/null +++ b/src/hyper/core/group.ts @@ -0,0 +1,224 @@ +/** + * group(prefix) — full composition API. + * + * - `.use(middleware)` — prepended to each route's chain + * - `.meta(obj)` — merged into each route's meta + * - `.add(...routes)` — register routes (paths rewritten with prefix) + * - `.merge(otherGroup)` — absorb another group's routes+middleware + * - `.prefix(more)` — return a new group rooted deeper + * - `.lazy(() => import(...))` — code-splitting; resolved on first match + * + * Plain-object router shape: + * app({ router: { users: { create, get } } }) + * is equivalent to a group tree — the nested-object layout maps 1:1 + * into `api.users.create()` at the client. + */ + +import { type Middleware, compileChain } from "./middleware.ts" +import type { Route, RouteGroup, RouteMeta } from "./types.ts" + +export type LazyGroup = () => Promise<{ default: GroupBuilder } | GroupBuilder> + +export class GroupBuilder { + readonly #prefix: string + readonly #routes: Route[] = [] + readonly #middleware: Middleware[] = [] + readonly #meta: RouteMeta = {} + readonly #lazyLoaders: LazyGroup[] = [] + + constructor(prefix = "") { + this.#prefix = normalizePrefix(prefix) + } + + add(...routes: Route[]): GroupBuilder { + for (const r of routes) { + this.#routes.push(this.#decorate(r)) + } + return this + } + + use(mw: Middleware): GroupBuilder { + this.#middleware.push(mw) + // Re-decorate: existing routes need the new middleware prepended. + for (let i = 0; i < this.#routes.length; i++) { + const r = this.#routes[i]! + this.#routes[i] = { + ...r, + handler: wrapWithMiddleware(r.path, r.handler, [mw]), + } + } + return this + } + + meta(meta: RouteMeta): GroupBuilder { + Object.assign(this.#meta, meta) + for (let i = 0; i < this.#routes.length; i++) { + const r = this.#routes[i]! + this.#routes[i] = { ...r, meta: { ...meta, ...r.meta } } + } + return this + } + + prefix(more: string): GroupBuilder { + return new GroupBuilder(joinPath(this.#prefix, more)) + } + + merge(other: GroupBuilder): GroupBuilder { + const built = other.build() + // Merged routes already carry their full path; don't re-prefix. + for (const r of built.routes) { + const handler = + this.#middleware.length > 0 + ? wrapWithMiddleware(r.path, r.handler, this.#middleware) + : r.handler + this.#routes.push({ ...r, meta: { ...this.#meta, ...r.meta }, handler }) + } + return this + } + + lazy(loader: LazyGroup): GroupBuilder { + this.#lazyLoaders.push(loader) + return this + } + + /** Resolve lazy groups (called by app() at construction). */ + async resolve(): Promise { + for (const loader of this.#lazyLoaders) { + const mod = await loader() + const g = mod instanceof GroupBuilder ? mod : mod.default + await g.resolve() + this.merge(g) + } + } + + build(): RouteGroup { + return { prefix: this.#prefix, routes: [...this.#routes] } + } + + /** + * Invoke a route as a function (integration tests, projections). + * Walks the group's routes and dispatches to .callable() when present. + */ + async call( + method: string, + path: string, + input: { + params?: Record + query?: Record + body?: unknown + headers?: Record + req?: Request + } = {}, + ): Promise { + const routes = [...this.#routes] + const full = joinPath("", path) + const match = routes.find((r) => r.method === method.toUpperCase() && r.path === full) + if (!match) throw new Error(`group.call: no route ${method} ${full}`) + // Prefer attached .callable() if present. + const callable = (match as { callable?: (i: unknown) => Promise }).callable + if (callable) return callable(input) as Promise + // Fallback: run via internal handler. + const req = input.req ?? new Request(`http://local${full}`, { method }) + const result = await match.handler({ + req, + url: new URL(req.url), + params: (input.params ?? {}) as Record, + query: new URLSearchParams((input.query as Record | undefined) ?? {}), + headers: new Headers((input.headers ?? {}) as Record), + body: input.body, + ctx: {}, + cookies: () => new Bun.CookieMap(req.headers.get("cookie") ?? ""), + responseHeaders: new Headers(), + }) + return result as T + } + + #decorate(r: Route): Route { + const path = joinPath(this.#prefix, r.path) + const meta = { ...this.#meta, ...r.meta } + const handler = + this.#middleware.length > 0 + ? wrapWithMiddleware(path, r.handler, this.#middleware) + : r.handler + return { ...r, path, meta, handler } + } +} + +function wrapWithMiddleware( + path: string, + handler: Route["handler"], + mws: readonly Middleware[], +): Route["handler"] { + const runner = compileChain(mws) + return (ictx) => + runner( + { + ctx: ictx.ctx, + input: { params: ictx.params, query: ictx.query, body: ictx.body, headers: ictx.headers }, + req: ictx.req, + path, + params: ictx.params, + }, + () => handler(ictx), + ) as ReturnType +} + +export function group(prefix = ""): GroupBuilder { + return new GroupBuilder(prefix) +} + +/** Create a lazy group placeholder. */ +export function lazy(loader: LazyGroup): GroupBuilder { + const g = new GroupBuilder() + g.lazy(loader) + return g +} + +function normalizePrefix(p: string): string { + if (p === "" || p === "/") return "" + let out = p.startsWith("/") ? p : `/${p}` + if (out.endsWith("/")) out = out.slice(0, -1) + return out +} + +function joinPath(prefix: string, rest: string): string { + const r = rest.startsWith("/") ? rest : `/${rest}` + if (prefix === "") return r + return `${prefix}${r}` +} + +// ------------------------------------------------------------------ +// Plain-object router → GroupBuilder +// ------------------------------------------------------------------ + +/** + * A plain-object router. Nested records of routes or + * sub-routers become a group tree. Paths come from the route's own + * `.path`; object keys provide the typed-client namespace only. + */ +export interface PlainRouter { + [key: string]: Route | PlainRouter +} + +export function fromPlainRouter(router: PlainRouter, prefix = ""): GroupBuilder { + const g = new GroupBuilder(prefix) + walk(router, g) + return g +} + +function walk(router: PlainRouter, g: GroupBuilder): void { + for (const v of Object.values(router)) { + if (isRoute(v)) g.add(v) + else walk(v, g) + } +} + +function isRoute(x: unknown): x is Route { + return ( + typeof x === "object" && + x !== null && + typeof (x as { handler?: unknown }).handler === "function" && + typeof (x as { method?: unknown }).method === "string" && + typeof (x as { path?: unknown }).path === "string" + ) +} diff --git a/src/hyper/core/hash.ts b/src/hyper/core/hash.ts new file mode 100644 index 0000000..1fe02c7 --- /dev/null +++ b/src/hyper/core/hash.ts @@ -0,0 +1,30 @@ +/** + * Hashing helpers — always `Bun.hash.xxHash3` for cache keys, ETags, + * idempotency keys. Never `crypto.createHash('md5')` or similar. + * + * Constant-time compare uses `node:crypto.timingSafeEqual` (Bun's + * zero-cost polyfill; no userland substitute). + */ + +import { timingSafeEqual as nodeTimingSafeEqual } from "node:crypto" + +export function xxh3(input: string | ArrayBufferView | ArrayBufferLike): string { + const buf = typeof input === "string" ? new TextEncoder().encode(input) : input + // Bun.hash.xxHash3 accepts ArrayBuffer/TypedArray/string. + return Bun.hash.xxHash3(buf as Parameters[0]).toString(16) +} + +/** Build an ETag for a response body. Strong by default. */ +export function etag(body: string | ArrayBufferView | ArrayBufferLike): string { + return `"${xxh3(body)}"` +} + +/** + * Constant-time string equality. Inputs must be the same length; + * otherwise returns false without any comparison (short-circuit is + * safe — leaking length is not a secret-material leak). + */ +export function timingSafeEqualStr(a: string, b: string): boolean { + if (a.length !== b.length) return false + return nodeTimingSafeEqual(Buffer.from(a), Buffer.from(b)) +} diff --git a/src/hyper/core/hyper.ts b/src/hyper/core/hyper.ts new file mode 100644 index 0000000..7d2da37 --- /dev/null +++ b/src/hyper/core/hyper.ts @@ -0,0 +1,836 @@ +/** + * Hyper — the top-level chain API. + * + * A thin, ergonomic wrapper around `app({...})` + the `route.` + * builder. Construct a server with `new Hyper()`, add routes via verb + * shortcuts, compose sub-apps / plugins / middleware / namespaces via + * the polymorphic `.use()`, and boot with `.listen()`. + * + * export default new Hyper() + * .get("/", () => "Hello Hyper") + * .listen(3000) + * + * The chain class is additive — everything still lowers to the existing + * `Route`/`HyperApp` primitives, so plugins, openapi projection, + * testing, and CLI tooling keep working unchanged. + */ + +import type { Server } from "bun" +import { app } from "./app.ts" +import { GroupBuilder, fromPlainRouter } from "./group.ts" +import { type ChainRunner, type Middleware, compileChain } from "./middleware.ts" +import { type RouteBuilder, route } from "./route.ts" +import type { HandlerCtx } from "./route.ts" +import type { StandardSchemaV1 } from "./standard-schema.ts" +import type { + AppConfig, + AppContext, + BunRoutes, + DecorateFactory, + DeriveFactory, + EnvConfigLike, + HandlerReturn, + HttpMethod, + HyperApp, + HyperPlugin, + InternalHandlerCtx, + InvokeInput, + InvokeResult, + PlainRouterConfig, + Route, + RouteGroup, + RouteHandler, + RouteMeta, + SecurityDefaults, + TestOverrides, +} from "./types.ts" + +/** Version string for the banner line. Stays in sync with `@hyper/core`. */ +const HYPER_VERSION = "0.1.0" + +/** Constructor-time options. */ +export interface HyperOptions { + /** Mount all routes added on this instance under this prefix. */ + readonly prefix?: string + /** Security baseline overrides — merged with secure-by-default. */ + readonly security?: Partial + /** Env schema + secrets + source. Parsed at boot. */ + readonly env?: EnvConfigLike + /** Optional name — surfaces in banner, logs, and error messages. */ + readonly name?: string +} + +/** Options for `.listen()`. */ +export interface ListenOptions { + readonly port?: number + readonly hostname?: string + /** Bun.serve idleTimeout (seconds). Default: 10. */ + readonly idleTimeout?: number + /** Bun.serve development flag. Default: true when NODE_ENV !== "production". */ + readonly development?: boolean + /** Print the startup banner. Default: true outside of production. */ + readonly banner?: boolean + /** Wire SIGTERM/SIGINT → drain. Default: true. */ + readonly drain?: boolean +} + +// --------------------------------------------------------------------- +// Type helpers +// --------------------------------------------------------------------- + +/** + * Extract the `:param` tokens from a path literal as a `{ [name]: string }` + * record. Mirrors the runtime grammar exactly: + * + * - Param names match `[A-Za-z_][A-Za-z0-9_]*`. + * - Anything else (including `.`, `@`, `-`, slashes) is a literal that ends + * the param name. So `"/r/:name@:version.json"` infers `{ name; version }`, + * not `{ "name@:version.json"; ... }`. + * + * Falls back to an empty record when the path has no params — destructuring + * `({ params })` stays valid on schema-less static paths. + */ +type _IdentChar = + | "_" + | "a" + | "b" + | "c" + | "d" + | "e" + | "f" + | "g" + | "h" + | "i" + | "j" + | "k" + | "l" + | "m" + | "n" + | "o" + | "p" + | "q" + | "r" + | "s" + | "t" + | "u" + | "v" + | "w" + | "x" + | "y" + | "z" + | "A" + | "B" + | "C" + | "D" + | "E" + | "F" + | "G" + | "H" + | "I" + | "J" + | "K" + | "L" + | "M" + | "N" + | "O" + | "P" + | "Q" + | "R" + | "S" + | "T" + | "U" + | "V" + | "W" + | "X" + | "Y" + | "Z" + | "0" + | "1" + | "2" + | "3" + | "4" + | "5" + | "6" + | "7" + | "8" + | "9" + +/** Walk forward from a `:`, accumulating identifier chars into the name. */ +type _ReadName

= P extends `${infer C}${infer Rest}` + ? C extends _IdentChar + ? _ReadName + : { name: Acc; rest: P } + : { name: Acc; rest: "" } + +/** + * Walk the path character-by-character, accumulating each `:name` we hit + * into a union. We can't use `${string}:${infer After}` because TS + * template-literal inference is greedy on the prefix and would skip over + * intermediate params (e.g. dropping `name` from `:name@:version`). + */ +type _Walk

= P extends `:${infer After}` + ? _ReadName extends { name: infer N; rest: infer R } + ? N extends "" + ? // ":" not followed by an identifier char — treat as literal, advance one char. + After extends `${string}${infer Tail}` + ? _Walk + : Acc + : N extends string + ? R extends string + ? _Walk + : Acc | N + : Acc + : Acc + : P extends `${infer _C}${infer Rest}` + ? _Walk + : Acc + +export type PathParams

= [_Walk

] extends [never] + ? Record + : { [K in _Walk

]: string } + +/** + * Narrow the output of a `StandardSchemaV1` or fall back to `Fallback` + * when the schema is absent (undefined in the options bag). + */ +export type InferSchema = S extends StandardSchemaV1 ? O : Fallback + +/** Per-route options accepted by the verb shortcuts. */ +export interface RouteOpts< + P extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, + Q extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, + B extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, + H extends StandardSchemaV1 | undefined = StandardSchemaV1 | undefined, +> { + readonly params?: P + readonly query?: Q + readonly body?: B + readonly headers?: H + readonly meta?: RouteMeta + readonly use?: readonly Middleware[] + /** Declared thrown-error shapes keyed by HTTP status. */ + readonly throws?: Record + /** Named error-code catalog. */ + readonly errors?: Record +} + +/** The typed handler signature for `new Hyper().(path, [opts,] handler)`. */ +export type VerbHandler< + Path extends string = string, + Opts extends RouteOpts | undefined = undefined, + Ctx extends AppContext = AppContext, +> = ( + ctx: HandlerCtx< + Opts extends { params: infer P } ? InferSchema> : PathParams, + Opts extends { query: infer Q } ? InferSchema : unknown, + Opts extends { body: infer B } ? InferSchema : unknown, + Opts extends { headers: infer H } ? InferSchema : unknown, + Ctx + >, +) => HandlerReturn | Promise + +/** + * Polymorphic dispatch — `.use()` accepts any of these shapes. + * + * Order of discrimination (see {@link Hyper.use}): + * 1. `Hyper` — sub-app composition (its own prefix honored). + * 2. `GroupBuilder` — flatten to RouteGroup, apply parent prefix. + * 3. `RouteGroup` — same as above. + * 4. `Route` | `Route[]` — register directly. + * 5. `HyperPlugin` — install (must have `name: string`). + * 6. `Middleware` — `typeof fn === "function"`, appended to the stack. + * 7. Plain object — walked as an ESM namespace / PlainRouter. + */ +export type UseArg = + // biome-ignore lint/suspicious/noExplicitAny: variance hole for heterogeneous Hyper + | Hyper + | GroupBuilder + | RouteGroup + | Route + | readonly Route[] + | HyperPlugin + | Middleware + | Record + +/** Brand used for duck-typed recognition across package boundaries. */ +export const HYPER_BUILDER_BRAND = "__hyperBuilder__" as const + +// Shared state for graceful-shutdown handler. One process-level +// listener per signal no matter how many `Hyper` instances `.listen()`. +// biome-ignore lint/suspicious/noExplicitAny: heterogeneous by design +const activeServers: Set> = new Set() +let drainInstalled = false + +function installDrainHandlersOnce(): void { + if (drainInstalled) return + drainInstalled = true + const shutdown = (signal: string): void => { + // Drain every live server; do not exit until all have resolved. + const pending: Promise[] = [] + for (const inst of activeServers) { + const s = inst.server + if (!s) continue + pending.push( + Promise.resolve(s.stop(false)) + .then(() => undefined) + .catch(() => undefined), + ) + } + void Promise.all(pending).then(() => { + // Respect the Unix convention: 128 + signal number. + const code = signal === "SIGTERM" ? 128 + 15 : signal === "SIGINT" ? 128 + 2 : 0 + process.exit(code) + }) + } + process.on("SIGTERM", () => shutdown("SIGTERM")) + process.on("SIGINT", () => shutdown("SIGINT")) +} + +/** + * The top-level chain class. + * + * Mutable by design — every method returns `this`. Once `.build()` or + * `.listen()` has produced a `HyperApp`, subsequent mutations transparently + * invalidate the cache and rebuild on next access. + */ +export class Hyper { + /** Duck-typed brand so CLI tooling can recognize a `Hyper` across package boundaries. */ + readonly __hyperBuilder__ = true + + readonly #prefix: string + readonly #options: HyperOptions + readonly #routes: Route[] = [] + readonly #middleware: Middleware[] = [] + readonly #decorators: DecorateFactory[] = [] + readonly #derives: DeriveFactory[] = [] + readonly #plugins: HyperPlugin[] = [] + #routerConfig?: PlainRouterConfig + #envConfig?: EnvConfigLike + #securityOverrides: Partial = {} + #built: HyperApp | undefined + #server: Server | undefined + + constructor(opts: HyperOptions = {}) { + this.#prefix = normalizePrefix(opts.prefix ?? "") + this.#options = opts + if (opts.security) this.#securityOverrides = { ...opts.security } + if (opts.env !== undefined) this.#envConfig = opts.env + } + + // ----------------------------------------------------------------- + // Introspection + // ----------------------------------------------------------------- + + /** The normalized prefix (e.g. "/users"). Empty string when unset. */ + get prefix(): string { + return this.#prefix + } + + /** The live `Bun.Server` — populated after `.listen()`. */ + get server(): Server | undefined { + return this.#server + } + + /** Display name (for banners / diagnostics). */ + get name(): string { + return this.#options.name ?? "hyper" + } + + /** Raw route list — forwards to the built app. */ + get routeList(): readonly Route[] { + return this.build().routeList + } + + /** `Bun.serve({ routes })` compatible map — forwards to the built app. */ + get routes(): BunRoutes { + return this.build().routes + } + + /** fetch-compatible entry point for any Bun/edge/workers adapter. */ + get fetch(): (req: Request) => Promise { + return this.build().fetch + } + + // ----------------------------------------------------------------- + // Verb shortcuts + // ----------------------------------------------------------------- + + get(path: Path, handler: VerbHandler): this + get( + path: Path, + opts: O, + handler: VerbHandler, + ): this + get(path: string, body: string): this + get(path: string, a: unknown, b?: unknown): this { + return this.#addRoute("GET", path, a, b) + } + post(path: Path, handler: VerbHandler): this + post( + path: Path, + opts: O, + handler: VerbHandler, + ): this + post(path: string, body: string): this + post(path: string, a: unknown, b?: unknown): this { + return this.#addRoute("POST", path, a, b) + } + put(path: Path, handler: VerbHandler): this + put( + path: Path, + opts: O, + handler: VerbHandler, + ): this + put(path: string, body: string): this + put(path: string, a: unknown, b?: unknown): this { + return this.#addRoute("PUT", path, a, b) + } + patch(path: Path, handler: VerbHandler): this + patch( + path: Path, + opts: O, + handler: VerbHandler, + ): this + patch(path: string, body: string): this + patch(path: string, a: unknown, b?: unknown): this { + return this.#addRoute("PATCH", path, a, b) + } + delete(path: Path, handler: VerbHandler): this + delete( + path: Path, + opts: O, + handler: VerbHandler, + ): this + delete(path: string, body: string): this + delete(path: string, a: unknown, b?: unknown): this { + return this.#addRoute("DELETE", path, a, b) + } + head(path: Path, handler: VerbHandler): this + head( + path: Path, + opts: O, + handler: VerbHandler, + ): this + head(path: string, body: string): this + head(path: string, a: unknown, b?: unknown): this { + return this.#addRoute("HEAD", path, a, b) + } + options(path: Path, handler: VerbHandler): this + options( + path: Path, + opts: O, + handler: VerbHandler, + ): this + options(path: string, body: string): this + options(path: string, a: unknown, b?: unknown): this { + return this.#addRoute("OPTIONS", path, a, b) + } + + // ----------------------------------------------------------------- + // Composition + // ----------------------------------------------------------------- + + /** Register a plain-object router. Nested keys become a group tree. */ + router(cfg: PlainRouterConfig): this { + this.#invalidate() + // Delegate to fromPlainRouter; the prefix flow mirrors sub-app use. + const g = fromPlainRouter(cfg as never, "") + for (const r of g.build().routes) { + this.#routes.push(this.#prefixAndWrap(r, "")) + } + // Keep a reference so the raw router config is still available to + // tooling that inspects `app.__config.router` (e.g. typed clients). + this.#routerConfig = cfg + return this + } + + /** Polymorphic `.use()` — see {@link UseArg}. */ + // biome-ignore lint/suspicious/noExplicitAny: sub-app Ctx is opaque at this boundary + use(prefix: string, sub: Hyper): this + use(arg: UseArg): this + use(arg1: unknown, arg2?: unknown): this { + this.#invalidate() + + // Two-arg form: (prefix, sub-app) + if (typeof arg1 === "string") { + if (!(arg2 instanceof Hyper)) { + throw new Error("Hyper.use(prefix, sub): the second argument must be a Hyper instance.") + } + return this.#useSubApp(arg2, normalizePrefix(arg1)) + } + + const arg = arg1 + if (arg instanceof Hyper) return this.#useSubApp(arg, "") + if (arg instanceof GroupBuilder) return this.#useGroup(arg.build()) + if (isRouteGroup(arg)) return this.#useGroup(arg) + if (isRoute(arg)) return this.#useRoutes([arg]) + if (Array.isArray(arg) && arg.every(isRoute)) return this.#useRoutes(arg as readonly Route[]) + if (isHyperPlugin(arg)) { + this.#plugins.push(arg) + return this + } + if (typeof arg === "function") { + this.#middleware.push(arg as Middleware) + return this + } + // ESM namespace / plain object → walk for Route-shaped values. + if (typeof arg === "object" && arg !== null) { + return this.#useNamespace(arg as Record) + } + + throw new Error( + "Hyper.use: unsupported argument. Accepts Hyper sub-app, GroupBuilder, RouteGroup, Route(s), HyperPlugin, Middleware, or ESM namespace.", + ) + } + + /** Mount a single plugin by name. Identical to `.use(plugin)`. */ + plugin(p: HyperPlugin): this { + this.#invalidate() + this.#plugins.push(p) + return this + } + + /** + * Static context decoration (db, redis, caches) — constructed once at boot. + * Returns a `Hyper` with the widened `Ctx` so downstream handlers see the + * added shape without casting. + */ + decorate(factory: (env?: unknown) => A | Promise): Hyper> { + this.#invalidate() + this.#decorators.push(factory as DecorateFactory) + return this as unknown as Hyper> + } + + /** + * Per-request context derivation. Runs once per request, after decorators + * and before the handler. Returned fields merge into `ctx` and are visible + * to the handler with full type inference. + */ + derive( + factory: (args: { ctx: Ctx; env?: unknown; req: Request }) => A | Promise, + ): Hyper> { + this.#invalidate() + this.#derives.push(factory as unknown as DeriveFactory) + return this as unknown as Hyper> + } + + /** Declare env schema. Parsed at boot; `parseEnv` throws on bad input. */ + env(cfg: EnvConfigLike): this { + this.#invalidate() + this.#envConfig = cfg + return this + } + + /** Partial overrides over the secure-by-default baseline. */ + security(overrides: Partial): this { + this.#invalidate() + this.#securityOverrides = { ...this.#securityOverrides, ...overrides } + return this + } + + // ----------------------------------------------------------------- + // Build / listen + // ----------------------------------------------------------------- + + /** + * Construct (and memoize) the underlying `HyperApp`. Safe to call + * many times; re-runs only when chain state has been mutated since + * the last call. + */ + build(): HyperApp { + if (this.#built) return this.#built + const config: AppConfig = { + routes: this.#routes, + plugins: this.#plugins, + decorate: this.#decorators, + derive: this.#derives, + ...(this.#envConfig && { env: this.#envConfig }), + security: this.#securityOverrides, + ...(this.#routerConfig && { router: this.#routerConfig }), + } + this.#built = app(config) + return this.#built + } + + /** + * Boot a real `Bun.serve` unless `process.env.HYPER_SKIP_LISTEN` is + * set. Returns `this` so the chain can still be exported cleanly: + * + * export default new Hyper().get("/", () => "hi").listen(3000) + */ + listen(portOrOpts?: number | ListenOptions): this { + const built = this.build() + + if (process.env.HYPER_SKIP_LISTEN) return this + + const opts: ListenOptions = + typeof portOrOpts === "number" ? { port: portOrOpts } : (portOrOpts ?? {}) + const isProd = process.env.NODE_ENV === "production" + const port = opts.port ?? Number(process.env.PORT ?? 3000) + const hostname = opts.hostname ?? (isProd ? "0.0.0.0" : "localhost") + const idleTimeout = opts.idleTimeout ?? 10 + const development = opts.development ?? !isProd + const bannerOn = opts.banner ?? !isProd + const drainOn = opts.drain !== false + + this.#server = Bun.serve({ + port, + hostname, + routes: built.routes, + fetch: built.fetch, + idleTimeout, + development, + }) + + if (bannerOn) { + const n = built.routeList.length + const plural = n === 1 ? "route" : "routes" + console.log( + `${this.name} ${HYPER_VERSION} listening on http://${this.#server.hostname}:${this.#server.port} (${n} ${plural})`, + ) + } + + if (drainOn) { + activeServers.add(this) + installDrainHandlersOnce() + } + + return this + } + + /** + * Stop the live server. `drain` (default true) waits for in-flight + * requests; pass `false` to immediately force-close. + */ + async stop(drain = true): Promise { + const s = this.#server + if (!s) return + activeServers.delete(this) + this.#server = undefined + await s.stop(!drain) + } + + // ----------------------------------------------------------------- + // HyperApp proxies + // ----------------------------------------------------------------- + + invoke(input: InvokeInput): Promise { + return this.build().invoke(input) + } + toOpenAPI(cfg?: Parameters[0]): ReturnType { + return this.build().toOpenAPI(cfg) + } + toMCPManifest(): ReturnType { + return this.build().toMCPManifest() + } + toClientManifest(): ReturnType { + return this.build().toClientManifest() + } + test(overrides?: TestOverrides): HyperApp { + return this.build().test(overrides) + } + + // ----------------------------------------------------------------- + // Internal + // ----------------------------------------------------------------- + + #invalidate(): void { + this.#built = undefined + } + + #addRoute( + method: HttpMethod, + path: string, + optsOrHandler: unknown, + maybeHandler?: unknown, + ): this { + this.#invalidate() + + // String shortcut: `.get("/", "Hello")` → .staticResponse(new Response("Hello")) + if (typeof optsOrHandler === "string" && maybeHandler === undefined) { + const fullPath = joinPaths(this.#prefix, path) + const r = this.#verbBuilder(method, fullPath).staticResponse( + new Response(optsOrHandler), + ) as Route + this.#routes.push(r) + return this + } + + let opts: RouteOpts | undefined + let handler: (ctx: HandlerCtx) => HandlerReturn | Promise + if (maybeHandler !== undefined) { + opts = optsOrHandler as RouteOpts + handler = maybeHandler as (ctx: HandlerCtx) => HandlerReturn | Promise + } else { + handler = optsOrHandler as (ctx: HandlerCtx) => HandlerReturn | Promise + } + + const fullPath = joinPaths(this.#prefix, path) + let builder: RouteBuilder = this.#verbBuilder(method, fullPath) + + if (opts) { + if (opts.params) builder = builder.params(opts.params) + if (opts.query) builder = builder.query(opts.query) + if (opts.body) builder = builder.body(opts.body) + if (opts.headers) builder = builder.headers(opts.headers) + if (opts.meta) builder = builder.meta(opts.meta) + if (opts.throws) builder = builder.throws(opts.throws) + if (opts.errors) builder = builder.errors(opts.errors) + if (opts.use) for (const mw of opts.use) builder = builder.use(mw) + } + + // Instance-level middleware is applied AFTER per-route opts, so the + // chain order is: instance-mw → route-opts-mw → handler. Same + // intuition as Express: parent-scoped mw wraps inner. + for (const mw of this.#middleware) builder = builder.use(mw) + + const r = builder.handle(handler) + this.#routes.push(r) + return this + } + + #verbBuilder(method: HttpMethod, path: string): RouteBuilder { + switch (method) { + case "GET": + return route.get(path) + case "POST": + return route.post(path) + case "PUT": + return route.put(path) + case "PATCH": + return route.patch(path) + case "DELETE": + return route.delete(path) + case "HEAD": + return route.head(path) + case "OPTIONS": + return route.options(path) + } + } + + // biome-ignore lint/suspicious/noExplicitAny: heterogeneous sub-app Ctx + #useSubApp(sub: Hyper, extraPrefix: string): this { + // Use the sub-app's already-built route list — its own prefix + // (from `new Hyper({ prefix })`) is already baked into each path. + const built = sub.build() + for (const r of built.routeList) { + this.#routes.push(this.#prefixAndWrap(r, extraPrefix)) + } + return this + } + + #useGroup(g: RouteGroup): this { + // A RouteGroup has its prefix already baked into each route's path. + for (const r of g.routes) { + this.#routes.push(this.#prefixAndWrap(r, "")) + } + return this + } + + #useRoutes(routes: readonly Route[]): this { + for (const r of routes) { + this.#routes.push(this.#prefixAndWrap(r, "")) + } + return this + } + + #useNamespace(ns: Record): this { + // Walk the namespace for Route-shaped values, applying this prefix + // + current middleware stack to each. Nested objects recurse. + for (const value of Object.values(ns)) { + if (isRoute(value)) { + this.#routes.push(this.#prefixAndWrap(value, "")) + } else if (value && typeof value === "object" && !Array.isArray(value)) { + // Guard against walking primitives or circular structures. A + // typical ESM namespace only contains a single level of Routes. + this.#useNamespace(value as Record) + } + } + return this + } + + #prefixAndWrap(r: Route, extra: string): Route { + const combined = joinPaths(joinPaths(this.#prefix, extra), r.path) + if (combined === r.path && this.#middleware.length === 0) return r + if (this.#middleware.length === 0) return { ...r, path: combined } + const chain: ChainRunner = compileChain(this.#middleware.slice()) + const wrapped: RouteHandler = (ictx: InternalHandlerCtx) => + chain( + { + ctx: ictx.ctx, + input: { + params: ictx.params, + query: ictx.query, + body: ictx.body, + headers: ictx.headers, + }, + req: ictx.req, + path: combined, + params: ictx.params, + }, + () => r.handler(ictx), + ) as ReturnType + return { ...r, path: combined, handler: wrapped } + } +} + +/** + * `hyper(opts?)` — factory alias for `new Hyper(opts)`. Returns the + * same class instance, so `instanceof Hyper` continues to work. + */ +export function hyper(opts?: HyperOptions): Hyper { + return new Hyper(opts) +} + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + +/** + * Combine two path segments, matching Elysia's "prefix = /users, path + * = /" → "/users" intuition. Whichever segment is empty is dropped; a + * path of "/" at the tail collapses to the prefix. + */ +export function joinPaths(prefix: string, rest: string): string { + const p = prefix === "" || prefix === "/" ? "" : prefix + const r = rest === "" || rest === "/" ? "" : rest.startsWith("/") ? rest : `/${rest}` + if (p === "" && r === "") return "/" + if (p === "") return r + if (r === "") return p + return `${p}${r}` +} + +function normalizePrefix(p: string): string { + if (p === "" || p === "/") return "" + let out = p.startsWith("/") ? p : `/${p}` + if (out.endsWith("/")) out = out.slice(0, -1) + return out +} + +function isRoute(x: unknown): x is Route { + return ( + typeof x === "object" && + x !== null && + typeof (x as Route).method === "string" && + typeof (x as Route).path === "string" && + typeof (x as Route).handler === "function" && + typeof (x as Route).kind === "string" + ) +} + +function isRouteGroup(x: unknown): x is RouteGroup { + return ( + typeof x === "object" && + x !== null && + typeof (x as RouteGroup).prefix === "string" && + Array.isArray((x as RouteGroup).routes) + ) +} + +function isHyperPlugin(x: unknown): x is HyperPlugin { + if (typeof x !== "object" || x === null) return false + if (typeof (x as HyperPlugin).name !== "string") return false + // A plugin isn't a route (would collide on `.handler`/`.method`). + if (isRoute(x)) return false + return true +} diff --git a/src/hyper/core/index.ts b/src/hyper/core/index.ts new file mode 100644 index 0000000..9297968 --- /dev/null +++ b/src/hyper/core/index.ts @@ -0,0 +1,152 @@ +/** + * @hyper/core — public entry. + */ + +export const VERSION: string = "0.1.0" + +// Core types +export type { + AppConfig, + AppContext, + BunFileLike, + BunRoutes, + EnvConfigLike, + ErrorRegistry, + HandlerReturn, + HttpMethod, + HyperApp, + HyperPlugin, + Infer, + InternalHandlerCtx, + InvokeInput, + InvokeResult, + Route, + RouteExample, + RouteGroup, + RouteHandler, + RouteMeta, + SecurityDefaults, +} from "./types.ts" + +// Standard Schema +export type { + StandardSchemaV1, + StandardSchemaV1Issue, + StandardSchemaV1Props, + StandardSchemaV1Result, +} from "./standard-schema.ts" +export { isStandardSchema, parseStandard, SchemaValidationError } from "./standard-schema.ts" + +// Builder +export { route, RouteBuilder } from "./route.ts" +export type { BuilderState, CallableRoute, HandlerCtx, InferIn } from "./route.ts" + +// Middleware +export { compileChain, onError, onFinish, onStart, onSuccess } from "./middleware.ts" +export type { ChainRunner } from "./middleware.ts" +export type { Middleware, MiddlewareArgs } from "./middleware.ts" + +// Composition +export { fromPlainRouter, group, GroupBuilder, lazy } from "./group.ts" +export type { LazyGroup, PlainRouter } from "./group.ts" + +// App +export { app } from "./app.ts" + +// Chain API — `new Hyper()` / `hyper()` +export { HYPER_BUILDER_BRAND, Hyper, hyper, joinPaths } from "./hyper.ts" +export type { HyperOptions, ListenOptions, RouteOpts, UseArg, VerbHandler } from "./hyper.ts" + +// Errors +export { asHyperError, createError, HyperError } from "./error.ts" +export type { HyperErrorInit } from "./error.ts" + +// Response helpers +export { + accepted, + badRequest, + coerce, + conflict, + created, + errorResponse, + forbidden, + html, + jsonResponse, + noContent, + notFound, + ok, + redirect, + sse, + stream, + text, + tooManyRequests, + unauthorized, + unprocessable, +} from "./response.ts" +export type { TypedResponse } from "./response.ts" + +// File +export { file } from "./file.ts" +export type { FileOptions } from "./file.ts" + +// Hash / timing-safe +export { etag, timingSafeEqualStr, xxh3 } from "./hash.ts" + +// Security +export { + applyDefaultHeaders, + assertNoProtoKeys, + DEFAULT_BODY_LIMIT_BYTES, + DEFAULT_RESPONSE_HEADERS, + DEFAULT_SECURITY, + FORBIDDEN_JSON_KEYS, + PrototypePollutionError, + SUPPRESSED_HEADERS, +} from "./security.ts" + +// Request +export { parseBodyAuto, parseJsonBody, readTextBody } from "./request.ts" + +// Decorate / derive +export { applyDerive, resolveStaticContext } from "./decorate.ts" +export type { ContextBlueprint, DecorateFactory, DeriveFactory } from "./decorate.ts" + +// Env +export { + EnvParseError, + getSecretPaths, + markSecrets, + parseEnv, + SECRET_PATHS, + secret, + useEnv, + withEnv, +} from "./env.ts" +export type { EnvConfig } from "./env.ts" + +// Type utilities +export type { InferRouterCtx, InferRouterInputs, InferRouterOutputs } from "./infer.ts" + +// Resource bundles + examples +export { resource } from "./resource.ts" +export type { ResourceHandlers, ResourceMethod, ResourceOptions } from "./resource.ts" +export { runExamples } from "./example.ts" +export type { ExampleResult } from "./example.ts" + +// Projection (OpenAPI / MCP / client manifests) +export { + projectRoute, + projectRoutes, + toClientManifest, + toMCPManifest, + toOpenAPI, +} from "./projection.ts" +export type { + ClientManifest, + MCPManifest, + MCPTool, + OpenAPIManifest, + OpenAPIManifestConfig, + ProjectedRoute, + SchemaDescriptor, +} from "./projection.ts" diff --git a/src/hyper/core/infer.ts b/src/hyper/core/infer.ts new file mode 100644 index 0000000..c6a7573 --- /dev/null +++ b/src/hyper/core/infer.ts @@ -0,0 +1,49 @@ +/** + * Type utilities for downstream consumers. + * + * These let `@hyper/client` and user code derive input/output/context + * types from a router tree without reflection. + * + * Usage: + * import { type InferRouterInputs } from "@hyper/core" + * type Inputs = InferRouterInputs + * type CreateUser = Inputs["users"]["create"] + */ + +import type { CallableRoute } from "./route.ts" +import type { AppContext, Route } from "./types.ts" + +/** Anything that looks like a plain-object router branch. */ +export type RouterLike = { [key: string]: Route | RouterLike } + +/** Extract inputs from a router tree, preserving namespace structure. */ +export type InferRouterInputs = { + [K in keyof R]: R[K] extends CallableRoute + ? { params: P; query: Q; body: B; headers: H } + : R[K] extends Route + ? unknown + : R[K] extends RouterLike + ? InferRouterInputs + : never +} + +/** Extract outputs — the handler's resolved return type. */ +export type InferRouterOutputs = { + [K in keyof R]: R[K] extends CallableRoute< + infer _M, + infer _P, + infer _Q, + infer _B, + infer _H, + infer O + > + ? Awaited + : R[K] extends Route + ? unknown + : R[K] extends RouterLike + ? InferRouterOutputs + : never +} + +/** The shared context type used by every route in the tree. */ +export type InferRouterCtx<_R> = AppContext diff --git a/src/hyper/core/middleware.ts b/src/hyper/core/middleware.ts new file mode 100644 index 0000000..9208280 --- /dev/null +++ b/src/hyper/core/middleware.ts @@ -0,0 +1,134 @@ +/** + * Middleware — with input, output access, and mapInput. + * + * Signature: + * ({ ctx, input, next, mapInput }) => Promise + * + * `next(transformedInput?)` runs the rest of the chain (or the handler) + * and returns the output. Middleware can inspect / transform / short- + * circuit the response. + * + * Lifecycle factories (`onStart`, `onSuccess`, `onError`, `onFinish`) + * are sugar over this primitive. They always produce a regular + * middleware under the hood — meta is sugar. + */ + +import type { AppContext, HandlerReturn } from "./types.ts" + +export interface MiddlewareArgs { + readonly ctx: C + /** Input resolved from `params`/`query`/`body`/`headers`. */ + readonly input: I + /** The actual Request. */ + readonly req: Request + /** Current route path (inc. matched params). */ + readonly path: string + /** Invoke the rest of the chain + handler; optionally mapInput. */ + readonly next: (mapped?: I) => Promise | HandlerReturn + /** Matched params for mapping convenience. */ + readonly params: Record +} + +export type Middleware = ( + args: MiddlewareArgs, +) => Promise | HandlerReturn + +// Lifecycle factories -------------------------------------------------------- + +export function onStart( + fn: ( + args: Pick, "ctx" | "input" | "req" | "path" | "params">, + ) => void | Promise, +): Middleware { + return async ({ ctx, input, next, req, path, params }) => { + await fn({ ctx, input, req, path, params }) + return next() + } +} + +export function onSuccess( + fn: (args: { + ctx: C + output: HandlerReturn + req: Request + }) => void | Promise, +): Middleware { + return async ({ ctx, next, req }) => { + const output = await next() + await fn({ ctx, output, req }) + return output + } +} + +export function onError( + fn: (args: { ctx: C; error: unknown; req: Request }) => void | Promise, +): Middleware { + return async ({ ctx, next, req }) => { + try { + return await next() + } catch (error) { + await fn({ ctx, error, req }) + throw error + } + } +} + +export function onFinish( + fn: (args: { + ctx: C + output?: HandlerReturn + error?: unknown + req: Request + }) => void | Promise, +): Middleware { + return async ({ ctx, next, req }) => { + try { + const output = await next() + await fn({ ctx, output, req }) + return output + } catch (error) { + await fn({ ctx, error, req }) + throw error + } + } +} + +/** + * Runner produced by `compileChain` — invokes the precompiled pipeline. + * + * `next` is captured per-request so each middleware's `next()` call is a + * single function reference, not a closure rebuilt on every dispatch. + */ +export type ChainRunner = ( + args: Omit, + base: () => Promise | HandlerReturn, +) => Promise | HandlerReturn + +/** + * Precompile a middleware chain into a single function, once, at + * route-build time. Eliminates per-request closure allocations that a + * naive composition would pay. Zero-middleware routes get the fast + * path — the compiled runner delegates directly to `base`. + */ +export function compileChain(middleware: readonly Middleware[]): ChainRunner { + if (middleware.length === 0) { + return (_args, base) => base() + } + return (args, base) => { + let i = 0 + const dispatch = (mapped?: unknown): Promise | HandlerReturn => { + if (i >= middleware.length) return base() + const mw = middleware[i++]! + const mwArgs: MiddlewareArgs = { + ctx: args.ctx, + input: mapped !== undefined ? mapped : args.input, + req: args.req, + path: args.path, + params: args.params, + next: dispatch, + } + return mw(mwArgs) + } + return dispatch() + } +} diff --git a/src/hyper/core/projection.ts b/src/hyper/core/projection.ts new file mode 100644 index 0000000..bf39996 --- /dev/null +++ b/src/hyper/core/projection.ts @@ -0,0 +1,201 @@ +/** + * Multi-protocol projection infrastructure. + * + * One route definition projects to many transports: + * HTTP — always on (the route is HTTP-first). + * typed RPC client — always on (shape is inferred from the route graph). + * MCP tool — opt-in via `meta.mcp`. + * server action — opt-in via `meta.action` or `.actionable()`. + * websocket/SSE — opt-in via the handler return type. + * + * The functions in this file walk a route graph and produce serializable + * manifests. The `app.invoke()` path is shared across protocols so + * business logic runs exactly once. + */ + +import type { Route, RouteMeta } from "./types.ts" + +/** Minimal serializable schema descriptor — the full converter lives in @hyper/openapi. */ +export interface SchemaDescriptor { + readonly kind: "unknown" | "object" | "string" | "number" | "boolean" | "array" + readonly properties?: Record +} + +/** A raw route as projected into any manifest. */ +export interface ProjectedRoute { + readonly method: string + readonly path: string + readonly name?: string + readonly tags: readonly string[] + readonly deprecated?: boolean + readonly version?: string + readonly mcp?: RouteMeta["mcp"] + readonly action?: boolean + readonly internal?: boolean + readonly params?: SchemaDescriptor + readonly query?: SchemaDescriptor + readonly body?: SchemaDescriptor + /** Thrown HTTP status codes declared via `.throws(status, schema)`. */ + readonly throws?: readonly number[] + /** Named error codes declared via `.errors({ code: schema })`. */ + readonly errors?: readonly string[] +} + +function descriptorOf(x: unknown): SchemaDescriptor | undefined { + if (!x) return undefined + return { kind: "unknown" } +} + +export function projectRoute(r: Route): ProjectedRoute { + const meta = r.meta + const params = descriptorOf(r.params) + const query = descriptorOf(r.query) + const body = descriptorOf(r.body) + const deprecated = meta.deprecated ? true : undefined + const throws = r.throws + ? Object.keys(r.throws) + .map((n) => Number(n)) + .filter((n) => Number.isFinite(n)) + : undefined + const errors = r.errors ? Object.keys(r.errors) : undefined + const base: ProjectedRoute = { + method: r.method, + path: r.path, + tags: meta.tags ?? [], + ...(meta.name !== undefined && { name: meta.name }), + ...(meta.mcp !== undefined && { mcp: meta.mcp }), + ...(meta.action !== undefined && { action: Boolean(meta.action) }), + ...(meta.internal !== undefined && { internal: meta.internal }), + ...(deprecated !== undefined && { deprecated }), + ...(meta.version !== undefined && { version: meta.version }), + ...(params && { params }), + ...(query && { query }), + ...(body && { body }), + ...(throws && throws.length > 0 && { throws }), + ...(errors && errors.length > 0 && { errors }), + } + return base +} + +export function projectRoutes(routes: readonly Route[]): readonly ProjectedRoute[] { + return routes.filter((r) => !r.meta.internal).map(projectRoute) +} + +/** Minimal OpenAPI 3.1 manifest. @hyper/openapi adds schema conversion later. */ +export interface OpenAPIManifest { + readonly openapi: "3.1.0" + readonly info: { title: string; version: string; description?: string } + readonly paths: Record> +} + +interface OpenAPIOperation { + readonly operationId?: string + readonly tags?: readonly string[] + readonly deprecated?: boolean + readonly parameters?: readonly OpenAPIParam[] + readonly requestBody?: { readonly content: Record } + readonly responses: Record +} + +interface OpenAPIParam { + readonly name: string + readonly in: "path" | "query" | "header" + readonly required: boolean +} + +export interface OpenAPIManifestConfig { + readonly title?: string + readonly version?: string + readonly description?: string +} + +function openApiPath(path: string): string { + // Convert Bun `:param` to OpenAPI `{param}` + return path.replace(/:([A-Za-z0-9_]+)/g, "{$1}") +} + +export function toOpenAPI( + routes: readonly Route[], + cfg: OpenAPIManifestConfig = {}, +): OpenAPIManifest { + const paths: Record> = {} + for (const r of routes) { + if (r.meta.internal) continue + const p = openApiPath(r.path) + const operation: OpenAPIOperation = { + ...(r.meta.name !== undefined && { operationId: r.meta.name }), + ...(r.meta.tags !== undefined && { tags: r.meta.tags }), + ...(r.meta.deprecated && { deprecated: true }), + ...(r.body !== undefined && { + requestBody: { + content: { "application/json": { schema: { $ref: "#/components/schemas/Body" } } }, + }, + }), + responses: { + "200": { description: "success" }, + }, + } + if (!paths[p]) paths[p] = {} + paths[p][r.method.toLowerCase()] = operation + } + return { + openapi: "3.1.0", + info: { + title: cfg.title ?? "Hyper API", + version: cfg.version ?? "0.0.0", + ...(cfg.description !== undefined && { description: cfg.description }), + }, + paths, + } +} + +/** MCP manifest (JSON-RPC shaped). @hyper/mcp produces the transport. */ +export interface MCPManifest { + readonly version: "1.0" + readonly tools: readonly MCPTool[] +} + +export interface MCPTool { + readonly name: string + readonly description: string + readonly method: string + readonly path: string + readonly inputSchema: { + readonly type: "object" + readonly properties: Record + } +} + +export function toMCPManifest(routes: readonly Route[]): MCPManifest { + const tools: MCPTool[] = [] + for (const r of routes) { + if (r.meta.internal) continue + if (!r.meta.mcp) continue + const cfg = r.meta.mcp as { description: string } + tools.push({ + name: r.meta.name ?? `${r.method.toLowerCase()}_${r.path.replace(/[^a-z0-9]+/gi, "_")}`, + description: cfg.description, + method: r.method, + path: r.path, + inputSchema: { + type: "object", + properties: { + ...(r.params ? { params: { type: "object" } } : {}), + ...(r.query ? { query: { type: "object" } } : {}), + ...(r.body ? { body: { type: "object" } } : {}), + }, + }, + }) + } + return { version: "1.0", tools } +} + +/** Typed-client manifest — the serializable contract @hyper/client consumes. */ +export interface ClientManifest { + readonly version: "1.0" + readonly routes: readonly ProjectedRoute[] +} + +export function toClientManifest(routes: readonly Route[]): ClientManifest { + return { version: "1.0", routes: projectRoutes(routes) } +} diff --git a/src/hyper/core/request.ts b/src/hyper/core/request.ts new file mode 100644 index 0000000..4a13f73 --- /dev/null +++ b/src/hyper/core/request.ts @@ -0,0 +1,135 @@ +/** + * Request parsing — lazy, size-capped, prototype-safe. + * + * Body parsing only runs when a route declares a `.body(schema)` or a + * middleware reads `ctx.body`. We count bytes against the body limit + * before calling `JSON.parse` so oversized payloads fail fast with 413. + */ + +import { HyperError } from "./error.ts" +import { + DEFAULT_BODY_LIMIT_BYTES, + FORBIDDEN_JSON_KEYS, + PrototypePollutionError, + assertNoProtoKeys, +} from "./security.ts" + +function jsonSafeReviver(key: string, value: unknown): unknown { + if (FORBIDDEN_JSON_KEYS.includes(key)) { + throw new HyperError({ + status: 400, + code: "proto_pollution", + message: `Refusing body containing dangerous key "${key}".`, + why: "The body contains a prototype-pollution vector.", + fix: "Remove the __proto__ / constructor / prototype key from the payload.", + cause: new PrototypePollutionError(key, []), + }) + } + return value +} + +export interface ReadBodyOptions { + readonly maxBytes?: number +} + +/** + * Read a request body as text honoring the byte limit. We avoid + * `req.text()` so we can bail fast before buffering more than allowed. + */ +export async function readTextBody(req: Request, opts: ReadBodyOptions = {}): Promise { + const max = opts.maxBytes ?? DEFAULT_BODY_LIMIT_BYTES + if (!req.body) return "" + const contentLength = req.headers.get("content-length") + if (contentLength !== null) { + const declared = Number(contentLength) + if (Number.isFinite(declared) && declared > max) { + throw payloadTooLarge(declared, max) + } + } + + const reader = req.body.getReader() + const decoder = new TextDecoder() + let total = 0 + let out = "" + try { + while (true) { + const { value, done } = await reader.read() + if (done) break + if (value) { + total += value.byteLength + if (total > max) throw payloadTooLarge(total, max) + out += decoder.decode(value, { stream: true }) + } + } + out += decoder.decode() + } finally { + reader.releaseLock?.() + } + return out +} + +/** + * Parse request body as JSON with prototype-pollution guard and size cap. + * Returns `undefined` for no-body requests. + */ +export async function parseJsonBody(req: Request, opts: ReadBodyOptions = {}): Promise { + const text = await readTextBody(req, opts) + if (text === "") return undefined + let parsed: unknown + try { + // Reviver catches __proto__ / constructor / prototype keys before + // they can pollute the prototype chain. JSON.parse's own __proto__ + // behavior is to assign to [[Prototype]] directly — so a post-hoc + // Object.keys check never sees it. We reject at read time instead. + parsed = JSON.parse(text, jsonSafeReviver) + } catch (e) { + if (e instanceof HyperError) throw e + throw new HyperError({ + status: 400, + code: "invalid_json", + message: "Request body is not valid JSON.", + why: "The body failed JSON.parse.", + fix: "Send a JSON payload matching the declared schema.", + cause: e, + }) + } + assertNoProtoKeys(parsed) + return parsed +} + +/** Auto-detect the right body parser from content-type. */ +export async function parseBodyAuto(req: Request, opts: ReadBodyOptions = {}): Promise { + const ct = (req.headers.get("content-type") ?? "").toLowerCase() + if (!ct || ct.startsWith("application/json") || ct.startsWith("application/ld+json")) { + return parseJsonBody(req, opts) + } + if (ct.startsWith("text/")) { + return readTextBody(req, opts) + } + if (ct.startsWith("application/x-www-form-urlencoded")) { + const text = await readTextBody(req, opts) + const out: Record = {} + const params = new URLSearchParams(text) + params.forEach((v, k) => { + if (k === "__proto__" || k === "constructor" || k === "prototype") return + out[k] = v + }) + return out + } + if (ct.startsWith("multipart/form-data")) { + // Buffer the form; Bun's Request.formData respects body stream. + return req.formData() + } + // Binary passthrough (rare for handlers — they should opt in explicitly). + return req.arrayBuffer() +} + +function payloadTooLarge(got: number, max: number): HyperError { + return new HyperError({ + status: 413, + code: "payload_too_large", + message: `Request body is ${got} bytes, limit is ${max}.`, + why: "Bodies exceed Hyper's configured maxBytes.", + fix: "Increase the limit on the route via `.body(schema, { maxBytes })` or trim the payload.", + }) +} diff --git a/src/hyper/core/resource.ts b/src/hyper/core/resource.ts new file mode 100644 index 0000000..07b6668 --- /dev/null +++ b/src/hyper/core/resource.ts @@ -0,0 +1,138 @@ +/** + * route.resource() — emit a standard CRUD bundle for a collection. + * + * Example: + * const users = resource("/users", { + * list: () => store.list(), + * get: ({ params }) => store.get(params.id), + * create: ({ body }) => store.create(body), + * update: ({ params, body }) => store.update(params.id, body), + * remove: ({ params }) => store.remove(params.id), + * }) + * + * Returns an array of routes ready to add to `app({ routes })`. + */ + +import { type CallableRoute, route } from "./route.ts" +import type { HandlerReturn, HttpMethod } from "./types.ts" + +type HandlerFn = (args: { + params: P + body: B + req: Request + url: URL + // biome-ignore lint/suspicious/noExplicitAny: ctx is user-augmented + ctx: any +}) => Promise | HandlerReturn + +export interface ResourceHandlers> { + readonly list?: HandlerFn, never> + readonly get?: HandlerFn<{ id: string }, never> + readonly create?: HandlerFn, T> + readonly update?: HandlerFn<{ id: string }, U> + readonly remove?: HandlerFn<{ id: string }, never> +} + +export interface ResourceOptions { + /** Human-readable resource name (for metadata). */ + readonly name?: string + /** Expose CRUD as MCP tools. */ + readonly mcp?: boolean +} + +export function resource>( + basePath: string, + handlers: ResourceHandlers, + opts: ResourceOptions = {}, +): readonly CallableRoute[] { + const name = opts.name ?? basePath.replace(/\//g, "") + const mcpMeta = (op: string, desc: string): { mcp: { description: string } } | object => + opts.mcp ? { mcp: { description: `${desc} (${name})` } } : {} + const out: CallableRoute[] = [] + + if (handlers.list) { + out.push( + route + .get(basePath) + .meta({ name: `${name}.list`, tags: [name], ...mcpMeta("list", "List") }) + .handle((c) => + handlers.list!({ + params: c.params as Record, + body: undefined as never, + req: c.req, + url: c.url, + ctx: c.ctx, + }), + ) as CallableRoute, + ) + } + if (handlers.get) { + out.push( + route + .get(`${basePath}/:id`) + .meta({ name: `${name}.get`, tags: [name], ...mcpMeta("get", "Get") }) + .handle((c) => + handlers.get!({ + params: c.params as { id: string }, + body: undefined as never, + req: c.req, + url: c.url, + ctx: c.ctx, + }), + ) as CallableRoute, + ) + } + if (handlers.create) { + out.push( + route + .post(basePath) + .meta({ name: `${name}.create`, tags: [name], ...mcpMeta("create", "Create") }) + .handle((c) => + handlers.create!({ + params: c.params as Record, + body: c.body as T, + req: c.req, + url: c.url, + ctx: c.ctx, + }), + ) as CallableRoute, + ) + } + if (handlers.update) { + out.push( + route + .patch(`${basePath}/:id`) + .meta({ name: `${name}.update`, tags: [name], ...mcpMeta("update", "Update") }) + .handle((c) => + handlers.update!({ + params: c.params as { id: string }, + body: c.body as U, + req: c.req, + url: c.url, + ctx: c.ctx, + }), + ) as CallableRoute, + ) + } + if (handlers.remove) { + out.push( + route + .delete(`${basePath}/:id`) + .meta({ name: `${name}.remove`, tags: [name], ...mcpMeta("remove", "Remove") }) + .handle((c) => + handlers.remove!({ + params: c.params as { id: string }, + body: undefined as never, + req: c.req, + url: c.url, + ctx: c.ctx, + }), + ) as CallableRoute, + ) + } + + return out +} + +/** Convenience mapping to avoid explicit HttpMethod unions in docs. */ +export type ResourceMethod = Extract diff --git a/src/hyper/core/response.ts b/src/hyper/core/response.ts new file mode 100644 index 0000000..5dbdab7 --- /dev/null +++ b/src/hyper/core/response.ts @@ -0,0 +1,267 @@ +/** + * Return helpers + response coercion. + * + * The philosophy: handlers return values. The framework converts those + * into `Response` objects with correct status codes. Helpers make the + * status explicit at the return site and keep TypeScript inference clean. + * + * Perf note: helpers pre-merge the secure-by-default response headers so + * `finalize()` in app.ts can skip the Headers-clone + new-Response cost + * on the common path (only paying for HSTS / route overrides when those + * features are actually in play). + */ + +import type { HyperError } from "./error.ts" +import type { BunFileLike, HandlerReturn } from "./types.ts" + +/** + * Bake Hyper's secure-by-default headers directly into the Response + * constructed by the helpers. The pipeline detects these via a single + * `headers.has("x-content-type-options")` probe and skips the Headers + * clone when no other mutation is needed. + */ +const DEFAULT_SECURITY_HEADERS: Readonly> = Object.freeze({ + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + "referrer-policy": "strict-origin-when-cross-origin", + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "permissions-policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()", +}) + +const JSON_HEADERS_PREBAKED: Readonly> = Object.freeze({ + "content-type": "application/json; charset=utf-8", + ...DEFAULT_SECURITY_HEADERS, +}) + +const TEXT_HEADERS_PREBAKED: Readonly> = Object.freeze({ + "content-type": "text/plain; charset=utf-8", + ...DEFAULT_SECURITY_HEADERS, +}) + +const HTML_HEADERS_PREBAKED: Readonly> = Object.freeze({ + "content-type": "text/html; charset=utf-8", + ...DEFAULT_SECURITY_HEADERS, +}) + +/** Branded helper result — carries status so TS can infer response types. */ +export interface TypedResponse extends Response { + readonly __hyper?: { status: S; body: B } +} + +// --- 2xx ------------------------------------------------------------------ + +export function ok(body: B, init?: ResponseInit): TypedResponse<200, B> { + return jsonResponse(200, body, init) as TypedResponse<200, B> +} + +export function created(body: B, init?: ResponseInit): TypedResponse<201, B> { + return jsonResponse(201, body, init) as TypedResponse<201, B> +} + +export function accepted(body: B, init?: ResponseInit): TypedResponse<202, B> { + return jsonResponse(202, body, init) as TypedResponse<202, B> +} + +export function noContent(init?: ResponseInit): TypedResponse<204, null> { + if (!init) { + return new Response(null, { + status: 204, + headers: DEFAULT_SECURITY_HEADERS, + }) as TypedResponse<204, null> + } + const headers = mergeHeaders(init.headers, DEFAULT_SECURITY_HEADERS) + return new Response(null, { ...init, status: 204, headers }) as TypedResponse<204, null> +} + +// --- 3xx ------------------------------------------------------------------ + +export function redirect( + location: string, + status: 301 | 302 | 303 | 307 | 308 = 302, +): TypedResponse<301 | 302 | 303 | 307 | 308, null> { + return new Response(null, { + status, + headers: { location }, + }) as TypedResponse<301 | 302 | 303 | 307 | 308, null> +} + +// --- 4xx error helpers (returned, not thrown) ---------------------------- + +export function badRequest( + body?: B, + init?: ResponseInit, +): TypedResponse<400, B> { + return jsonResponse(400, body ?? null, init) as unknown as TypedResponse<400, B> +} + +export function unauthorized( + body?: B, + init?: ResponseInit, +): TypedResponse<401, B> { + return jsonResponse(401, body ?? null, init) as unknown as TypedResponse<401, B> +} + +export function forbidden( + body?: B, + init?: ResponseInit, +): TypedResponse<403, B> { + return jsonResponse(403, body ?? null, init) as unknown as TypedResponse<403, B> +} + +export function notFound( + body?: B, + init?: ResponseInit, +): TypedResponse<404, B> { + return jsonResponse(404, body ?? null, init) as unknown as TypedResponse<404, B> +} + +export function conflict( + body?: B, + init?: ResponseInit, +): TypedResponse<409, B> { + return jsonResponse(409, body ?? null, init) as unknown as TypedResponse<409, B> +} + +export function unprocessable( + body?: B, + init?: ResponseInit, +): TypedResponse<422, B> { + return jsonResponse(422, body ?? null, init) as unknown as TypedResponse<422, B> +} + +export function tooManyRequests( + body?: B, + init?: ResponseInit, +): TypedResponse<429, B> { + return jsonResponse(429, body ?? null, init) as unknown as TypedResponse<429, B> +} + +// --- Body helpers --------------------------------------------------------- + +export function text(body: string, init?: ResponseInit): Response { + if (!init) { + return new Response(body, { status: 200, headers: TEXT_HEADERS_PREBAKED }) + } + const headers = mergeHeaders(init.headers, TEXT_HEADERS_PREBAKED) + return new Response(body, { ...init, status: init.status ?? 200, headers }) +} + +export function html(body: string, init?: ResponseInit): Response { + if (!init) { + return new Response(body, { status: 200, headers: HTML_HEADERS_PREBAKED }) + } + const headers = mergeHeaders(init.headers, HTML_HEADERS_PREBAKED) + return new Response(body, { ...init, status: init.status ?? 200, headers }) +} + +/** Server-Sent Events response. Pass an AsyncIterable of {data, event?, id?}. */ +export function sse( + source: AsyncIterable<{ data: string; event?: string; id?: string }>, + init?: ResponseInit, +): Response { + const stream = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder() + try { + for await (const ev of source) { + let chunk = "" + if (ev.id) chunk += `id: ${ev.id}\n` + if (ev.event) chunk += `event: ${ev.event}\n` + for (const line of ev.data.split("\n")) chunk += `data: ${line}\n` + chunk += "\n" + controller.enqueue(enc.encode(chunk)) + } + } catch (err) { + controller.error(err) + return + } + controller.close() + }, + }) + const headers = mergeHeaders(init?.headers, { + "content-type": "text/event-stream", + "cache-control": "no-cache, no-transform", + "x-accel-buffering": "no", + }) + return new Response(stream, { ...init, status: init?.status ?? 200, headers }) +} + +/** Generic streaming response (AsyncIterable of strings or bytes). */ +export function stream(source: AsyncIterable, init?: ResponseInit): Response { + const readable = new ReadableStream({ + async start(controller) { + const enc = new TextEncoder() + try { + for await (const chunk of source) { + controller.enqueue(typeof chunk === "string" ? enc.encode(chunk) : chunk) + } + } catch (err) { + controller.error(err) + return + } + controller.close() + }, + }) + return new Response(readable, { ...init, status: init?.status ?? 200 }) +} + +// --- Internal ------------------------------------------------------------- + +export function jsonResponse(status: number, body: unknown, init?: ResponseInit): Response { + const payload = body === null || body === undefined ? null : JSON.stringify(body) + if (!init) { + return new Response(payload, { status, headers: JSON_HEADERS_PREBAKED }) + } + const headers = mergeHeaders(init.headers, JSON_HEADERS_PREBAKED) + return new Response(payload, { ...init, status, headers }) +} + +function mergeHeaders(input: HeadersInit | undefined, defaults?: Record): Headers { + const h = new Headers(input) + if (defaults) { + for (const [k, v] of Object.entries(defaults)) { + if (!h.has(k)) h.set(k, v) + } + } + return h +} + +/** + * Coerce a handler return into a Response. Rules: + * - `Response` → passthrough + * - `Bun.file`-like (has `.stream`) → streamed passthrough with content-type + * - `ReadableStream` → 200 with stream body + * - `string` → 200 text/plain + * - everything else → 200 JSON + */ +export function coerce(value: HandlerReturn): Response { + if (value instanceof Response) return value + if (value === undefined || value === null) return new Response(null, { status: 204 }) + if (isBunFile(value)) return bunFileToResponse(value) + if (value instanceof ReadableStream) return new Response(value, { status: 200 }) + if (typeof value === "string") return text(value) + if (typeof value === "object" && value !== null && Symbol.asyncIterator in (value as object)) { + return stream(value as AsyncIterable) + } + return ok(value) +} + +function isBunFile(v: unknown): v is BunFileLike { + return ( + typeof v === "object" && v !== null && typeof (v as { stream?: unknown }).stream === "function" + ) +} + +function bunFileToResponse(file: BunFileLike): Response { + const headers = new Headers() + if (file.type) headers.set("content-type", file.type) + if (typeof file.size === "number") headers.set("content-length", String(file.size)) + return new Response(file.stream(), { status: 200, headers }) +} + +// Error response projection ------------------------------------------------ + +export function errorResponse(err: HyperError): Response { + return jsonResponse(err.status, err.toJSON()) +} diff --git a/src/hyper/core/route.ts b/src/hyper/core/route.ts new file mode 100644 index 0000000..1119b7a --- /dev/null +++ b/src/hyper/core/route.ts @@ -0,0 +1,345 @@ +/** + * Route builder — the fluent, immutable DX surface. + * + * Every chain step returns a new builder with an updated type state. + * `.handle(fn)` closes the builder and produces a `Route` value. + * + * Surface: + * route.(path) + * .params(schema) + * .query(schema) + * .body(schema) + * .headers(schema) + * .meta({...}) + * .use(middleware) // output access + mapInput + * .errors({ CODE: schema }) // named-code catalog + * .throws({ 404: schema }) // declared thrown shapes + * .handle(fn) // closes the builder into a Route + * + * Returned Route carries an attached `.callable(ctx, input)` for in- + * process invocation (testing, Server Actions, projections). + */ + +import type { ChainRunner, Middleware } from "./middleware.ts" +import { compileChain } from "./middleware.ts" +import type { StandardSchemaV1 } from "./standard-schema.ts" +import type { + HandlerReturn, + HttpMethod, + InternalHandlerCtx, + Route, + RouteHandler, + RouteMeta, +} from "./types.ts" + +export interface BuilderState { + params?: StandardSchemaV1 + query?: StandardSchemaV1 + body?: StandardSchemaV1 + headers?: StandardSchemaV1 + meta: RouteMeta + middleware: readonly Middleware[] + errors?: Record + throws?: Record +} + +export type InferIn = S extends StandardSchemaV1 ? O : unknown + +export interface HandlerCtx< + Params = unknown, + Query = unknown, + Body = unknown, + HeadersT = unknown, + Ctx extends import("./types.ts").AppContext = import("./types.ts").AppContext, +> { + readonly req: Request + readonly url: URL + readonly params: Params + readonly query: Query + readonly body: Body + readonly headers: HeadersT + readonly cookies: () => import("bun").CookieMap + /** Decorated app context — `ctx.log`, `ctx.db`, etc. */ + readonly ctx: Ctx +} + +/** A Route that can also be invoked as a plain async function. */ +export interface CallableRoute< + M extends HttpMethod = HttpMethod, + Params = unknown, + Query = unknown, + Body = unknown, + HeadersT = unknown, + Output = unknown, +> extends Route { + readonly callable: (input: { + params?: Params + query?: Query + body?: Body + headers?: HeadersT + req?: Request + ctx?: import("./types.ts").AppContext + }) => Promise +} + +export class RouteBuilder< + M extends HttpMethod = HttpMethod, + Params = unknown, + Query = unknown, + Body = unknown, + HeadersT = unknown, +> { + readonly #method: M + readonly #path: string + readonly #state: BuilderState + + constructor(method: M, path: string, state: BuilderState = { meta: {}, middleware: [] }) { + this.#method = method + this.#path = path + this.#state = state + } + + params( + schema: S, + ): RouteBuilder, Query, Body, HeadersT> { + return new RouteBuilder(this.#method, this.#path, { ...this.#state, params: schema }) + } + + query( + schema: S, + ): RouteBuilder, Body, HeadersT> { + return new RouteBuilder(this.#method, this.#path, { ...this.#state, query: schema }) + } + + body( + schema: S, + ): RouteBuilder, HeadersT> { + return new RouteBuilder(this.#method, this.#path, { ...this.#state, body: schema }) + } + + headers(schema: S): RouteBuilder> { + return new RouteBuilder(this.#method, this.#path, { ...this.#state, headers: schema }) + } + + meta(meta: RouteMeta): RouteBuilder { + return new RouteBuilder(this.#method, this.#path, { + ...this.#state, + meta: { ...this.#state.meta, ...meta }, + }) + } + + /** Mark deprecated — emits Sunset header + OpenAPI + client JSDoc. */ + deprecated( + opts: boolean | { reason?: string; sunset?: Date | string } = true, + ): RouteBuilder { + let value: RouteMeta["deprecated"] + if (typeof opts === "boolean") { + value = opts + } else { + const out: { reason?: string; sunset?: string } = {} + if (opts.reason !== undefined) out.reason = opts.reason + if (opts.sunset !== undefined) { + out.sunset = opts.sunset instanceof Date ? opts.sunset.toUTCString() : opts.sunset + } + value = out + } + const nextHeaders: Record = { ...(this.#state.meta.headers ?? {}) } + if (typeof value === "object" && value?.sunset) { + nextHeaders.Sunset = value.sunset + } else if (value === true) { + nextHeaders.Deprecation = "true" + } + return new RouteBuilder(this.#method, this.#path, { + ...this.#state, + meta: { ...this.#state.meta, deprecated: value, headers: nextHeaders }, + }) + } + + /** Tag this route with a version string (header or URL prefix routed). */ + version(v: string): RouteBuilder { + return new RouteBuilder(this.#method, this.#path, { + ...this.#state, + meta: { ...this.#state.meta, version: v }, + }) + } + + /** Add an example — surfaced in OpenAPI, MCP few-shot, client JSDoc, tests. */ + example(ex: import("./types.ts").RouteExample): RouteBuilder { + return new RouteBuilder(this.#method, this.#path, { + ...this.#state, + meta: { + ...this.#state.meta, + examples: [...(this.#state.meta.examples ?? []), ex], + }, + }) + } + + /** Per-route timeout in milliseconds (overrides `security.requestTimeoutMs`). */ + timeout(ms: number): RouteBuilder { + if (!Number.isFinite(ms) || ms < 0) { + throw new Error(`route.timeout(${ms}): must be a non-negative finite number of milliseconds`) + } + return new RouteBuilder(this.#method, this.#path, { + ...this.#state, + meta: { ...this.#state.meta, timeoutMs: ms }, + }) + } + + /** + * Short-circuit to a constant Response. Eligible for Bun.serve's static + * routes path — no handler invocation, no middleware, near-zero latency. + * + * route.get("/health").static(Response.json({ ok: true })) + * + * Use cases: health checks, robots.txt, static configuration, feature + * flags served from a CDN origin, etc. If you need middleware (logging, + * auth), fall back to `.handle(() => ...)`. + */ + staticResponse(res: Response): Route { + const method = this.#method + const path = this.#path + const state = this.#state + // The hot path is Bun.serve's native static routes, which consume + // `staticResponse` directly and never invoke `handler`. The dev + // router falls back to `res.clone()` — Bun's clone is O(body) for + // string bodies and doesn't materialize a buffer. + return { + method, + path, + meta: state.meta, + handler: () => res.clone(), + kind: "static", + staticResponse: res, + } + } + + /** Mark the route as a React Server Action. */ + actionable(): RouteBuilder { + return new RouteBuilder(this.#method, this.#path, { + ...this.#state, + meta: { ...this.#state.meta, action: true }, + }) + } + + use(mw: Middleware): RouteBuilder { + return new RouteBuilder(this.#method, this.#path, { + ...this.#state, + middleware: [...this.#state.middleware, mw], + }) + } + + /** Declare thrown error shapes per HTTP status (declared-throws contract). */ + throws(map: Record): RouteBuilder { + return new RouteBuilder(this.#method, this.#path, { + ...this.#state, + throws: { ...(this.#state.throws ?? {}), ...map }, + }) + } + + /** Declare named-code error catalog. */ + errors(map: Record): RouteBuilder { + return new RouteBuilder(this.#method, this.#path, { + ...this.#state, + errors: { ...(this.#state.errors ?? {}), ...map }, + }) + } + + handle( + fn: (ctx: HandlerCtx) => Promise | HandlerReturn, + ): CallableRoute { + const state = this.#state + const method = this.#method + const path = this.#path + + // Precompile the middleware chain at build time — zero-middleware + // routes get a direct-call fast path with no extra allocations. + const chain: ChainRunner = compileChain(state.middleware) + const hasMiddleware = state.middleware.length > 0 + + const handler: RouteHandler = (ictx: InternalHandlerCtx) => { + // ictx is shaped by the pipeline to exactly match HandlerCtx — + // url / query / headers are either the parsed schema output + // (set as own properties by runPipeline) or lazy prototype + // getters that materialize on first access. We skip the typed- + // copy allocation that the old implementation paid per request. + const typed = ictx as unknown as HandlerCtx + if (!hasMiddleware) return fn(typed) + return chain( + { + ctx: ictx.ctx, + input: { + params: typed.params, + query: typed.query, + body: typed.body, + headers: typed.headers, + }, + req: ictx.req, + path, + params: ictx.params, + }, + () => fn(typed), + ) + } + + const middlewareTags: string[] = [] + for (const mw of state.middleware) { + const tag = (mw as unknown as { __hyperTag?: string }).__hyperTag + if (typeof tag === "string") middlewareTags.push(tag) + } + + const r: CallableRoute = { + method, + path, + ...(state.params !== undefined && { params: state.params }), + ...(state.query !== undefined && { query: state.query }), + ...(state.body !== undefined && { body: state.body }), + ...(state.headers !== undefined && { headers: state.headers }), + meta: state.meta, + handler, + ...(state.throws !== undefined && { throws: state.throws }), + ...(state.errors !== undefined && { errors: state.errors }), + ...(middlewareTags.length > 0 && { middlewareTags }), + kind: "fn", + callable: async (input) => { + const req = input.req ?? new Request(`http://local${path}`, { method }) + return fn({ + req, + url: new URL(req.url), + params: (input.params ?? {}) as Params, + query: (input.query ?? {}) as Query, + body: (input.body ?? undefined) as Body, + headers: (input.headers ?? {}) as HeadersT, + cookies: () => new Bun.CookieMap(req.headers.get("cookie") ?? ""), + ctx: (input.ctx ?? {}) as import("./types.ts").AppContext, + }) + }, + } + return r + } +} + +export const route: { + get: (path: string) => RouteBuilder<"GET"> + post: (path: string) => RouteBuilder<"POST"> + put: (path: string) => RouteBuilder<"PUT"> + patch: (path: string) => RouteBuilder<"PATCH"> + delete: (path: string) => RouteBuilder<"DELETE"> + head: (path: string) => RouteBuilder<"HEAD"> + options: (path: string) => RouteBuilder<"OPTIONS"> + lazy: (loader: () => Promise<{ default: R } | R>) => Promise +} = { + get: (path) => new RouteBuilder("GET", path), + post: (path) => new RouteBuilder("POST", path), + put: (path) => new RouteBuilder("PUT", path), + patch: (path) => new RouteBuilder("PATCH", path), + delete: (path) => new RouteBuilder("DELETE", path), + head: (path) => new RouteBuilder("HEAD", path), + options: (path) => new RouteBuilder("OPTIONS", path), + lazy: async (loader: () => Promise<{ default: R } | R>): Promise => { + const mod = await loader() + if (mod && typeof mod === "object" && "default" in mod) { + return (mod as { default: R }).default + } + return mod as R + }, +} diff --git a/src/hyper/core/router.ts b/src/hyper/core/router.ts new file mode 100644 index 0000000..2074744 --- /dev/null +++ b/src/hyper/core/router.ts @@ -0,0 +1,340 @@ +/** + * Dev router — zero-dep trie that mirrors `Bun.serve({ routes })` + * semantics so dev and prod behave identically. + * + * Supported patterns: + * - Static: "/users" + * - Param: "/users/:id" + * - Mixed-segment: "/r/:slug.json", "/r/:name@:version.json", "/posts/:y-:m-:d", "/v:version/users" + * - Wildcard: "/api/*" + * + * A "mixed" segment is one with literal characters around or between params. + * It compiles to a regex once at route-add time; the runtime walker uses + * `pattern.exec(segment)` only for those nodes. Pure-segment params keep the + * zero-allocation static fast path unchanged. + * + * Multiple mixed patterns may share a parent node — they're tried in + * descending order of specificity (more literal characters wins), so + * `/r/:name@:version.json` is preferred over `/r/:name.json` when both + * match. Within the same specificity, registration order wins. + * + * Param names are `[A-Za-z_][A-Za-z0-9_]*`. Anything else in the segment is a + * literal byte that must match the request path verbatim. + * + * Method-keyed dispatch is handled at the Route level (one compiled handler + * per verb); the router only matches paths. + */ + +import type { HttpMethod, Route } from "./types.ts" + +export interface MatchResult { + readonly route: Route + readonly params: Record +} + +interface PureParam { + readonly name: string + readonly node: Node +} + +interface MixedParam { + /** Original pattern segment, used for diagnostics. */ + readonly pattern: string + /** Compiled regex anchored to the full segment. */ + readonly regex: RegExp + /** Capture-group names in order. */ + readonly names: readonly string[] + /** Count of literal characters — higher wins ties at match time. */ + readonly literalChars: number + readonly node: Node +} + +interface Node { + /** Static children: "users" -> Node */ + statics: Map + /** Single pure-segment param child (`:id`). */ + pureParam?: PureParam + /** + * Mixed-segment children (`:slug.json`, `:a@:b`, ...). Multiple may + * coexist at the same depth — tried in descending specificity order at + * match time. + */ + mixedParams?: MixedParam[] + /** Wildcard child: "*" */ + wildcard?: Node + /** Routes terminating at this node, keyed by method. */ + handlers?: Partial> +} + +export class Router { + readonly #root: Node = newNode() + + add(route: Route): void { + const segments = splitPath(route.path) + let cur = this.#root + for (const seg of segments) { + if (seg === "*" || seg.startsWith("*")) { + if (!cur.wildcard) cur.wildcard = newNode() + cur = cur.wildcard + break + } + const parsed = parsePatternSegment(seg) + if (parsed === "static") { + let child = cur.statics.get(seg) + if (!child) { + child = newNode() + cur.statics.set(seg, child) + } + cur = child + continue + } + if (parsed.kind === "pure") { + if (!cur.pureParam) { + cur.pureParam = { name: parsed.name, node: newNode() } + } else if (cur.pureParam.name !== parsed.name) { + throw new Error( + `Route conflict: ${route.path} has param :${parsed.name} but trie already uses :${cur.pureParam.name}`, + ) + } + cur = cur.pureParam.node + continue + } + // Mixed pattern — multiple may coexist at the same depth. + if (!cur.mixedParams) cur.mixedParams = [] + const existing = cur.mixedParams.find((m) => m.pattern === seg) + if (existing) { + cur = existing.node + continue + } + const slot: MixedParam = { + pattern: seg, + regex: parsed.regex, + names: parsed.names, + literalChars: parsed.literalChars, + node: newNode(), + } + cur.mixedParams.push(slot) + // Most specific (highest literal count) first; ties keep registration order. + cur.mixedParams.sort((a, b) => b.literalChars - a.literalChars) + cur = slot.node + } + if (!cur.handlers) cur.handlers = {} + if (cur.handlers[route.method]) { + throw new Error(`Duplicate route: ${route.method} ${route.path}`) + } + cur.handlers[route.method] = route + } + + find(method: HttpMethod, pathname: string): MatchResult | null { + const matched = walkInline(this.#root, pathname) + if (!matched) return null + const route = matched.node.handlers?.[method] + if (!route) { + // Fallback: HEAD uses GET; OPTIONS handled by caller. + if (method === "HEAD") { + const getRoute = matched.node.handlers?.GET + if (getRoute) return { route: getRoute, params: matched.params ?? EMPTY_PARAMS } + } + return null + } + return { route, params: matched.params ?? EMPTY_PARAMS } + } + + /** Enumerate all routes for introspection. */ + *all(): Generator { + yield* enumerate(this.#root) + } +} + +function newNode(): Node { + return { statics: new Map() } +} + +const EMPTY_PARAMS: Record = Object.freeze( + Object.create(null) as Record, +) as Record + +function splitPath(path: string): string[] { + const trimmed = path.startsWith("/") ? path.slice(1) : path + if (trimmed === "") return [] + return trimmed.split("/") +} + +interface WalkHit { + readonly node: Node + /** Lazily allocated — `null` means "no params were matched". */ + readonly params: Record | null +} + +/** + * Zero-allocation walker for the static fast path. + * + * Iterates the pathname by slicing between `/` delimiters directly on the + * string — no segments array, no params object, no closures. When a `:param`, + * mixed pattern, or `*` node is encountered we switch to the `walkWithParams` + * helper which handles backtracking. + */ +function walkInline(root: Node, pathname: string): WalkHit | null { + let i = pathname.charCodeAt(0) === 47 /* '/' */ ? 1 : 0 + const len = pathname.length + let node: Node = root + + // Empty path (`/` or ``) matches the root. + if (i >= len) return { node, params: null } + + while (i < len) { + let j = i + while (j < len && pathname.charCodeAt(j) !== 47) j++ + const seg = pathname.slice(i, j) + + const stat = node.statics.get(seg) + if (stat && !node.pureParam && !node.mixedParams && !node.wildcard) { + // Unambiguous static step — no backtracking possible. + node = stat + i = j + 1 + continue + } + return walkWithParams(node, pathname, i) + } + return { node, params: null } +} + +function walkWithParams(startNode: Node, pathname: string, startIndex: number): WalkHit | null { + const params: Record = {} + const hit = walkRecur(startNode, pathname, startIndex, params) + if (!hit) return null + for (const _k in params) return { node: hit, params } + return { node: hit, params: null } +} + +function walkRecur( + node: Node, + pathname: string, + i: number, + params: Record, +): Node | null { + const len = pathname.length + if (i >= len) return node + let j = i + while (j < len && pathname.charCodeAt(j) !== 47) j++ + const seg = pathname.slice(i, j) + const nextIndex = j + 1 + + // 1) Static (most specific) wins first. + const stat = node.statics.get(seg) + if (stat) { + if (nextIndex > len) return stat + const r = walkRecur(stat, pathname, nextIndex, params) + if (r) return r + } + + // 2a) Mixed-segment params first (most specific to least specific). + if (node.mixedParams) { + for (const mp of node.mixedParams) { + const m = mp.regex.exec(seg) + if (!m) continue + const captured = mp.names + for (let k = 0; k < captured.length; k++) { + const name = captured[k] + if (name !== undefined) { + const value = m[k + 1] + params[name] = value === undefined ? "" : decodeURIComponent(value) + } + } + if (nextIndex > len) return mp.node + const r = walkRecur(mp.node, pathname, nextIndex, params) + if (r) return r + for (const name of captured) delete params[name] + } + } + + // 2b) Pure-segment param. + if (node.pureParam) { + const name = node.pureParam.name + params[name] = decodeURIComponent(seg) + if (nextIndex > len) return node.pureParam.node + const r = walkRecur(node.pureParam.node, pathname, nextIndex, params) + if (r) return r + delete params[name] + } + + // 3) Wildcard catch-all. + if (node.wildcard) { + params["*"] = decodeURIComponent(pathname.slice(i)) + return node.wildcard + } + return null +} + +function* enumerate(node: Node): Generator { + if (node.handlers) { + for (const v of Object.values(node.handlers)) if (v) yield v + } + for (const child of node.statics.values()) yield* enumerate(child) + if (node.pureParam) yield* enumerate(node.pureParam.node) + if (node.mixedParams) for (const mp of node.mixedParams) yield* enumerate(mp.node) + if (node.wildcard) yield* enumerate(node.wildcard) +} + +// --------------------------------------------------------------------------- +// Pattern parsing +// --------------------------------------------------------------------------- + +type ParsedSegment = + | "static" + | { readonly kind: "pure"; readonly name: string } + | { + readonly kind: "mixed" + readonly regex: RegExp + readonly names: readonly string[] + readonly literalChars: number + } + +const IDENT_HEAD = /[A-Za-z_]/ +const IDENT_REST = /[A-Za-z0-9_]/ + +function parsePatternSegment(seg: string): ParsedSegment { + // Fast path: no `:` means pure-static. + if (seg.indexOf(":") === -1) return "static" + + // Single `:name` covering the whole segment? -> pure param (fast path). + if (seg.length > 1 && seg.charCodeAt(0) === 58 /* ':' */) { + let k = 1 + if (k < seg.length && IDENT_HEAD.test(seg.charAt(k))) { + k++ + while (k < seg.length && IDENT_REST.test(seg.charAt(k))) k++ + if (k === seg.length) return { kind: "pure", name: seg.slice(1) } + } + } + + // Mixed segment — scan and build a regex, capturing each `:name`. + const names: string[] = [] + let pattern = "^" + let literalChars = 0 + let i = 0 + while (i < seg.length) { + const ch = seg.charAt(i) + if (ch === ":" && i + 1 < seg.length && IDENT_HEAD.test(seg.charAt(i + 1))) { + let k = i + 1 + while (k < seg.length && IDENT_REST.test(seg.charAt(k))) k++ + const name = seg.slice(i + 1, k) + if (names.includes(name)) { + throw new Error(`Duplicate param :${name} in segment "${seg}"`) + } + names.push(name) + pattern += "([^/]+?)" + i = k + continue + } + pattern += escapeRegexChar(ch) + literalChars += 1 + i++ + } + pattern += "$" + return { kind: "mixed", regex: new RegExp(pattern), names, literalChars } +} + +const REGEX_META = new Set(".*+?^${}()|[]\\".split("")) +function escapeRegexChar(ch: string): string { + return REGEX_META.has(ch) ? `\\${ch}` : ch +} diff --git a/src/hyper/core/security.ts b/src/hyper/core/security.ts new file mode 100644 index 0000000..27b5c7a --- /dev/null +++ b/src/hyper/core/security.ts @@ -0,0 +1,126 @@ +/** + * Secure-by-default baseline. + * + * Applied by the app's fetch pipeline. Opt-out only — users can + * override per-route via `.meta({ headers: {...} })` or disable + * globally in `app({ security: {...} })`. + */ + +import type { SecurityDefaults } from "./types.ts" + +/** Default 1 MB body size limit. */ +export const DEFAULT_BODY_LIMIT_BYTES: number = 1_048_576 + +/** Default response headers (sans HSTS; HSTS only added on HTTPS in prod). */ +export const DEFAULT_RESPONSE_HEADERS: Readonly> = Object.freeze({ + "x-content-type-options": "nosniff", + "x-frame-options": "DENY", + "referrer-policy": "strict-origin-when-cross-origin", + "cross-origin-opener-policy": "same-origin", + "cross-origin-resource-policy": "same-origin", + "permissions-policy": "camera=(), microphone=(), geolocation=(), interest-cohort=()", +}) + +/** Headers that must never be emitted by Hyper itself. */ +export const SUPPRESSED_HEADERS: readonly string[] = Object.freeze(["server"]) + +/** + * Keys that are refused by our JSON parser at the boundary to prevent + * prototype pollution. Rejection raises a 400 HyperError. + */ +export const FORBIDDEN_JSON_KEYS: readonly string[] = Object.freeze([ + "__proto__", + "constructor", + "prototype", +]) + +export const DEFAULT_SECURITY: SecurityDefaults = { + headers: true, + bodyLimitBytes: DEFAULT_BODY_LIMIT_BYTES, + rejectProtoKeys: true, + serverHeader: false, + rejectMethodOverride: true, + requestTimeoutMs: 30_000, + hstsEnv: "production", +} + +/** Headers/body keys used to smuggle verbs via override. */ +export const METHOD_OVERRIDE_HEADERS: readonly string[] = Object.freeze([ + "x-http-method-override", + "x-method-override", + "x-http-method", +]) +export const METHOD_OVERRIDE_QUERY_KEYS: readonly string[] = Object.freeze(["_method"]) + +// Precomputed entries of the default response headers — hoisted out of +// the hot path so we don't pay an Object.entries allocation per request. +const DEFAULT_HEADER_ENTRIES: ReadonlyArray = Object.freeze( + Object.entries(DEFAULT_RESPONSE_HEADERS), +) + +/** + * Apply default headers to a Response. Always returns a new Response + * with a fresh Headers bag — the previous version tried to short-circuit + * via `headersEqual`, which was strictly more expensive than cloning + * (two Array allocations + two sorts every request). + * + * HSTS is added only when `https && emitHsts !== false`. + */ +export function applyDefaultHeaders( + res: Response, + opts: { + https: boolean + overrides?: Readonly> + /** Emit HSTS. Typically set by the app only in production + HTTPS. */ + emitHsts?: boolean + }, +): Response { + const { https, emitHsts, overrides } = opts + const headers = new Headers(res.headers) + for (let i = 0; i < DEFAULT_HEADER_ENTRIES.length; i++) { + const entry = DEFAULT_HEADER_ENTRIES[i]! + if (!headers.has(entry[0])) headers.set(entry[0], entry[1]) + } + if (https && emitHsts !== false && !headers.has("strict-transport-security")) { + headers.set("strict-transport-security", "max-age=15552000; includeSubDomains") + } + for (let i = 0; i < SUPPRESSED_HEADERS.length; i++) headers.delete(SUPPRESSED_HEADERS[i]!) + if (overrides) { + for (const k in overrides) { + const v = overrides[k] + if (v !== undefined) headers.set(k.toLowerCase(), v) + } + } + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }) +} + +/** + * Deep-walk and reject forbidden keys (prototype pollution guard). + * Throws `PrototypePollutionError` on hit. + */ +export function assertNoProtoKeys(value: unknown, path: string[] = []): void { + if (value === null || typeof value !== "object") return + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + assertNoProtoKeys(value[i], [...path, String(i)]) + } + return + } + for (const key of Object.keys(value)) { + if (FORBIDDEN_JSON_KEYS.includes(key)) { + throw new PrototypePollutionError(key, path) + } + assertNoProtoKeys((value as Record)[key], [...path, key]) + } +} + +export class PrototypePollutionError extends Error { + readonly key: string + readonly path: readonly string[] + constructor(key: string, path: readonly string[]) { + super(`Refusing body containing dangerous key "${key}" at ${path.join(".") || "(root)"}`) + this.name = "PrototypePollutionError" + this.key = key + this.path = path + } +} diff --git a/src/hyper/core/standard-schema.ts b/src/hyper/core/standard-schema.ts new file mode 100644 index 0000000..81e6cf3 --- /dev/null +++ b/src/hyper/core/standard-schema.ts @@ -0,0 +1,88 @@ +/** + * Standard Schema adapter. + * + * Hyper does not depend on any specific validation library; we accept + * anything implementing the `~standard` contract. + * + * Spec: https://github.com/standard-schema/standard-schema + */ + +export interface StandardSchemaV1 { + readonly "~standard": StandardSchemaV1Props +} + +export interface StandardSchemaV1Props { + readonly version: 1 + readonly vendor: string + readonly validate: ( + value: unknown, + ) => StandardSchemaV1Result | Promise> + readonly types?: StandardSchemaV1Types | undefined +} + +export interface StandardSchemaV1Types { + readonly input: Input + readonly output: Output +} + +export type StandardSchemaV1Result = + | StandardSchemaV1SuccessResult + | StandardSchemaV1FailureResult + +export interface StandardSchemaV1SuccessResult { + readonly value: Output + readonly issues?: undefined +} + +export interface StandardSchemaV1FailureResult { + readonly issues: readonly StandardSchemaV1Issue[] +} + +export interface StandardSchemaV1Issue { + readonly message: string + readonly path?: readonly (PropertyKey | StandardSchemaV1PathSegment)[] | undefined +} + +export interface StandardSchemaV1PathSegment { + readonly key: PropertyKey +} + +/** + * Run a Standard Schema against `value`. Returns the parsed value or + * throws a `SchemaValidationError` with the issues attached so the + * error mapper can project to a 400 with why/fix. + */ +export async function parseStandard( + schema: StandardSchemaV1, + value: unknown, +): Promise { + const result = await schema["~standard"].validate(value) + if (result.issues && result.issues.length > 0) { + throw new SchemaValidationError(result.issues) + } + + return (result as StandardSchemaV1SuccessResult).value +} + +export class SchemaValidationError extends Error { + readonly issues: readonly StandardSchemaV1Issue[] + constructor(issues: readonly StandardSchemaV1Issue[]) { + super( + issues + .map((i) => `${(i.path ?? []).map(String).join(".") || "(root)"}: ${i.message}`) + .join("; "), + ) + this.name = "SchemaValidationError" + this.issues = issues + } +} + +/** Narrowing guard. */ +export function isStandardSchema(x: unknown): x is StandardSchemaV1 { + return ( + typeof x === "object" && + x !== null && + "~standard" in x && + typeof (x as { "~standard": unknown })["~standard"] === "object" + ) +} diff --git a/src/hyper/core/types.ts b/src/hyper/core/types.ts new file mode 100644 index 0000000..d957ca9 --- /dev/null +++ b/src/hyper/core/types.ts @@ -0,0 +1,343 @@ +/** + * Public types for @hyper/core. + * + * Kept in a single file so declaration merging surfaces (AppContext, + * RouteMeta, ErrorRegistry) are easy to locate and re-export. + */ + +import type { StandardSchemaV1 } from "./standard-schema.ts" + +/** HTTP verbs supported by the builder. */ +export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" + +/** + * Consumer-augmentable app context. Decorate/derive/plugins populate + * this via `declare module "@hyper/core" { interface AppContext { ... } }`. + * + * Declared as an empty `interface` (not `type = {}`) so that TypeScript + * honors declaration-merging: every `declare module` contribution adds + * fields to this shape. Unlike `type = {}`, which matches any non-null + * value and silently accepts garbage, `interface AppContext {}` enforces + * the augmented shape in consumer code. + */ +// biome-ignore lint/suspicious/noEmptyInterface: augmentable declaration-merging surface +// biome-ignore lint/complexity/noBannedTypes: interface is the correct primitive here +export interface AppContext {} + +/** A per-route example — surfaced to OpenAPI, MCP few-shot, client JSDoc, tests. */ +export interface RouteExample { + readonly name: string + readonly input?: { + params?: Record + query?: Record + body?: unknown + headers?: Record + } + readonly output?: { + status?: number + body?: unknown + } +} + +/** Per-route metadata (OpenAPI, MCP, auth tags, etc.). Augmentable. */ +export interface RouteMeta { + /** Human-readable name. */ + name?: string + /** Free-form tags; plugins may filter on these. */ + tags?: readonly string[] + /** Set by `@hyper/mcp`; if absent, the route is not MCP-exposed. */ + mcp?: false | { description: string; [k: string]: unknown } + /** Reserved for internal tooling (dev MCP etc.). Never projected. */ + internal?: boolean + /** CSRF on/off for cookie-auth routes. Default: on. */ + csrf?: boolean + /** Marks auth-endpoint routes for the default rate-limit recipe. */ + authEndpoint?: boolean + /** Caller-defined overrides to response headers. */ + headers?: Record + /** Marks the route as deprecated — surfaces in OpenAPI + Sunset header. */ + deprecated?: boolean | { readonly reason?: string; readonly sunset?: string } + /** API version for header/prefix-based routing. */ + version?: string + /** Server action marker (`.actionable()`). */ + action?: boolean + /** Per-route hard timeout in milliseconds. Overrides `security.requestTimeoutMs`. */ + timeoutMs?: number + /** Examples — OpenAPI, MCP few-shot, client JSDoc, contract tests. */ + examples?: readonly RouteExample[] + [k: string]: unknown +} + +/** + * Named error codes catalog per route. Augmentable via declaration + * merging (same pattern as {@link AppContext}). + */ +// biome-ignore lint/suspicious/noEmptyInterface: augmentable declaration-merging surface +// biome-ignore lint/complexity/noBannedTypes: interface is the correct primitive here +export interface ErrorRegistry {} + +/** The shape returned from every parsing step — Standard Schema aligned. */ +export type Infer = S extends StandardSchemaV1 ? O : unknown + +/** A Hyper response can be a real `Response`, a `Bun.file`, or bare data. */ +export type HandlerReturn = + | Response + | BunFileLike + | object + | string + | number + | boolean + | null + | ReadableStream + | AsyncIterable + | undefined + +/** Marker for anything coercible by our response layer (Bun.file(...)). */ +export interface BunFileLike { + readonly stream: () => ReadableStream + readonly type?: string + readonly size?: number + readonly name?: string +} + +/** A compiled route — the normalized shape the app and router consume. */ +export interface Route { + readonly method: M + readonly path: string + readonly params?: StandardSchemaV1 + readonly query?: StandardSchemaV1 + readonly body?: StandardSchemaV1 + readonly headers?: StandardSchemaV1 + readonly meta: RouteMeta + readonly handler: RouteHandler + /** Declared thrown-error shapes keyed by HTTP status (projection surface). */ + readonly throws?: Record + /** Named error-code catalog (projection surface). */ + readonly errors?: Record + /** True when the handler is a function (not a pre-built Response). */ + readonly kind: "fn" | "static" + /** Optional compile-time-static Response for fast path. */ + readonly staticResponse?: Response + /** + * Tags for every middleware attached to this route, in order. Middleware + * opts in by setting `fn.__hyperTag = ""`. Consumed by + * `hyper security --check` and other introspection tools. + */ + readonly middlewareTags?: readonly string[] +} + +/** The internal handler shape after builder normalization. */ +export type RouteHandler = (ctx: InternalHandlerCtx) => Promise | HandlerReturn + +/** + * Context passed into the handler — a superset the framework builds. + * + * `url`, `query`, `headers`, and `responseHeaders` are lazy: they're + * materialized only when the handler reads them. When a route + * declares a `.query()` / `.headers()` schema the pipeline sets the + * parsed plain object as an own property (shadowing the lazy getter). + * `query` / `headers` therefore hold whatever the handler expects — + * typically a parsed record or `Record` for the + * schema-less case. + */ +export interface InternalHandlerCtx { + readonly req: Request + readonly url: URL + readonly params: Record + readonly query: unknown + readonly headers: unknown + readonly body: unknown + /** Populated by plugin.context / decorate / derive. */ + readonly ctx: AppContext + /** Lazy Bun.CookieMap accessor — parse on first touch. */ + readonly cookies: () => import("bun").CookieMap + /** Mutable response header bag; flushed into the final Response. */ + readonly responseHeaders: Headers +} + +/** A decorator factory — produces static context from the parsed env. */ +export type DecorateFactory = ( + env: Env, +) => Added | Promise + +/** A derive factory — produces per-request context from ctx + env + req. */ +export type DeriveFactory< + Env = unknown, + CtxIn extends AppContext = AppContext, + Added extends object = object, +> = (args: { ctx: CtxIn; env: Env; req: Request }) => Added | Promise + +/** Input accepted for `AppConfig.groups` — matches the GroupBuilder shape. */ +export interface GroupConfigEntry { + /** The flattened build output consumed by `app()`. */ + build(): RouteGroup +} + +/** A plain-object router; nested records of routes or sub-routers. */ +export interface PlainRouterConfig { + readonly [key: string]: Route | PlainRouterConfig +} + +/** App-level config. */ +export interface AppConfig { + /** Collected top-level routes. */ + readonly routes?: readonly Route[] + /** Collected groups (flattened at app()). Accepts `GroupBuilder`s or `RouteGroup` literals. */ + readonly groups?: readonly (GroupConfigEntry | RouteGroup)[] + /** Plain-object router (gives the typed-client tree). */ + readonly router?: PlainRouterConfig + /** Feature flags for security defaults. On by default. */ + readonly security?: Partial + /** Env schema + secrets + source. */ + readonly env?: EnvConfigLike + /** Static context decoration (db, redis, etc.) constructed at boot. */ + readonly decorate?: readonly DecorateFactory[] + /** Per-request derived context. */ + readonly derive?: readonly DeriveFactory[] + /** Plugins installed in priority order. */ + readonly plugins?: readonly HyperPlugin[] +} + +/** Plugin surface for extending `app()` with lifecycle hooks and ctx decoration. */ +export interface HyperPlugin { + readonly name: string + readonly build?: (app: HyperApp) => void | Promise + readonly request?: { + /** + * Fires BEFORE route matching. Returning a Response short-circuits + * the rest of the pipeline — ideal for CORS preflight and auth gates. + */ + readonly preRoute?: (args: { + req: Request + }) => Response | undefined | Promise + readonly before?: (args: { + req: Request + ctx: AppContext + route?: Route + }) => void | Promise + readonly after?: (args: { + req: Request + ctx: AppContext + res: Response + route?: Route + }) => void | Promise + readonly onError?: (args: { + req: Request + ctx: AppContext + error: unknown + route?: Route + }) => void | Promise + } + readonly context?: (env: unknown) => object | Promise +} + +export interface EnvConfigLike { + readonly schema?: unknown + readonly secrets?: readonly string[] + readonly source?: Record +} + +/** A single invocation — the shared path between HTTP/MCP/RPC/actions. */ +export interface InvokeInput { + readonly method: HttpMethod + readonly path: string + readonly params?: Record + readonly query?: Record + readonly body?: unknown + readonly headers?: Record + /** Optional pre-set AppContext (bypasses decorate/derive). Useful for tests. */ + readonly ctx?: AppContext +} + +export interface InvokeResult { + readonly status: number + readonly data: unknown + readonly headers: Headers +} + +/** The built app surface. */ +export interface HyperApp { + /** fetch-compatible entry point for any Bun/edge/workers adapter. */ + readonly fetch: (req: Request) => Promise + /** Bun.serve({ routes }) shape — static + dynamic routes mounted natively. */ + readonly routes: BunRoutes + /** Raw route list for introspection. */ + readonly routeList: readonly Route[] + /** Shared invoke path — HTTP/MCP/RPC/actions all funnel here. */ + readonly invoke: (input: InvokeInput) => Promise + /** OpenAPI 3.1 serializer (schema conversion provided by @hyper/openapi). */ + readonly toOpenAPI: (cfg?: { + title?: string + version?: string + description?: string + }) => import("./projection.ts").OpenAPIManifest + /** MCP manifest. @hyper/mcp adds the transport. */ + readonly toMCPManifest: () => import("./projection.ts").MCPManifest + /** Client manifest. @hyper/client consumes this. */ + readonly toClientManifest: () => import("./projection.ts").ClientManifest + /** Original AppConfig — used by `app.test()` to produce scoped clones. */ + readonly __config: AppConfig + /** + * Create a test-scoped clone. Replaces env/decorate/derive and can skip + * or swap plugins by name. Returns a fresh immutable app. + */ + readonly test: (overrides?: TestOverrides) => HyperApp +} + +/** Overrides accepted by `app.test()`. */ +export interface TestOverrides { + /** Replace env source values (merged into config.env.source). */ + readonly env?: Record + /** Additional decorators appended to config.decorate. */ + readonly decorate?: DecorateFactory | readonly DecorateFactory[] + /** Additional derive functions appended to config.derive. */ + readonly derive?: DeriveFactory | readonly DeriveFactory[] + /** Plugins to skip (by name) or replace (by name → new plugin). */ + readonly plugins?: { + readonly skip?: readonly string[] + readonly replace?: Record + readonly add?: readonly HyperPlugin[] + } +} + +/** One mounted-route value in `Bun.serve({ routes })`. */ +export type BunRouteValue = + | Response + | Record + | ((req: Request) => Response | Promise) + +/** The Bun.serve routes map shape. See https://bun.sh/docs/api/http#routing. */ +export type BunRoutes = Record + +/** A RouteGroup is a collection of routes with a shared prefix. */ +export interface RouteGroup { + readonly prefix: string + readonly routes: readonly Route[] +} + +/** Security defaults — see ./security.ts for wire values. */ +export interface SecurityDefaults { + readonly headers: boolean + readonly bodyLimitBytes: number + readonly rejectProtoKeys: boolean + readonly serverHeader: false + /** + * When true, a request carrying `X-HTTP-Method-Override` or + * `_method` is rejected with 400. Default: true. Prevents a class of + * CSRF/verb-smuggling bugs where attackers bypass safe-method checks. + */ + readonly rejectMethodOverride: boolean + /** + * Hard request timeout in ms. The framework aborts the handler and + * returns 504 if a response isn't produced in time. 0 disables. + * Default: 30_000 (30s). + */ + readonly requestTimeoutMs: number + /** + * Explicit env that allows Hyper to emit HSTS. Default: "production". + * HSTS is never emitted for HTTP, and only emitted for HTTPS when the + * current NODE_ENV (or provided `env`) matches. Prevents accidental + * HSTS pinning on dev domains. + */ + readonly hstsEnv: string | false +} diff --git a/src/hyper/mcp/audit.ts b/src/hyper/mcp/audit.ts new file mode 100644 index 0000000..7b74de8 --- /dev/null +++ b/src/hyper/mcp/audit.ts @@ -0,0 +1,52 @@ +/** + * Audit — pretty-print or JSON-dump the MCP-exposed surface, including + * auth requirements inferred from route meta. + */ + +import type { HyperApp } from "@hyper/core" + +export interface AuditReport { + readonly exposedCount: number + readonly total: number + readonly tools: readonly { + readonly name: string + readonly description: string + readonly method: string + readonly path: string + readonly requiresAuth: boolean + }[] +} + +export function auditMcp(app: HyperApp): AuditReport { + const manifest = app.toMCPManifest() + const byPath = new Map(app.routeList.map((r) => [`${r.method} ${r.path}`, r])) + const tools = manifest.tools.map((t) => { + const route = byPath.get(`${t.method} ${t.path}`) + const requiresAuth = Boolean( + route && (route.meta.authEndpoint || route.meta.tags?.includes("auth")), + ) + return { + name: t.name, + description: t.description, + method: t.method, + path: t.path, + requiresAuth, + } + }) + return { + exposedCount: tools.length, + total: app.routeList.filter((r) => !r.meta.internal).length, + tools, + } +} + +export function formatAuditHuman(report: AuditReport): string { + const lines: string[] = [] + lines.push(`MCP surface: ${report.exposedCount}/${report.total} routes exposed\n`) + for (const t of report.tools) { + const auth = t.requiresAuth ? " [auth]" : "" + lines.push(` ${t.method.padEnd(6)} ${t.path}${auth}`) + lines.push(` ${t.description}`) + } + return lines.join("\n") +} diff --git a/src/hyper/mcp/index.ts b/src/hyper/mcp/index.ts new file mode 100644 index 0000000..9f2da58 --- /dev/null +++ b/src/hyper/mcp/index.ts @@ -0,0 +1,15 @@ +/** + * @hyper/mcp — exposes declared routes over the Model Context Protocol. + * + * Usage: + * const mcp = mcpServer(app) + * Bun.serve({ port: 5174, fetch: mcp.handle }) + * + * Routes annotated with `meta.mcp = { description }` are exposed as tools. + * `hyper mcp --audit` prints the surface before it ships. + */ + +export { auditMcp, formatAuditHuman } from "./audit.ts" +export type { AuditReport } from "./audit.ts" +export { mcpServer } from "./server.ts" +export type { McpServer, McpServerConfig } from "./server.ts" diff --git a/src/hyper/mcp/server.ts b/src/hyper/mcp/server.ts new file mode 100644 index 0000000..ff276d4 --- /dev/null +++ b/src/hyper/mcp/server.ts @@ -0,0 +1,167 @@ +/** + * Minimal MCP server — JSON-RPC 2.0 over HTTP POST / stdio pipes. + * + * We implement the subset needed for tools: + * - initialize + * - tools/list + * - tools/call + * + * Anything declared with `meta.mcp = { description }` becomes a tool. + * Tool invocation funnels through the shared `app.invoke()` path so + * middleware, logging, and validation run exactly once. + */ + +import type { HttpMethod, HyperApp, MCPManifest } from "@hyper/core" + +export interface McpServer { + readonly handle: (req: Request) => Promise + readonly manifest: MCPManifest + readonly listTools: () => readonly { name: string; description: string }[] + readonly callTool: (name: string, args: unknown) => Promise +} + +interface JsonRpcRequest { + readonly jsonrpc: "2.0" + readonly id?: number | string | null + readonly method: string + readonly params?: unknown +} + +interface JsonRpcResponse { + readonly jsonrpc: "2.0" + readonly id: number | string | null + readonly result?: unknown + readonly error?: { code: number; message: string; data?: unknown } +} + +export interface McpServerConfig { + /** Override the manifest (usually omitted; taken from app). */ + readonly manifest?: MCPManifest + /** Require auth check on every tool call. Defaults to always-allow. */ + readonly authorize?: (args: { toolName: string; req: Request }) => boolean | Promise + /** Server identity (surfaced on initialize). */ + readonly info?: { name: string; version: string } +} + +export function mcpServer(app: HyperApp, cfg: McpServerConfig = {}): McpServer { + const manifest = cfg.manifest ?? app.toMCPManifest() + const byName = new Map(manifest.tools.map((t) => [t.name, t])) + + const callTool = async (name: string, args: unknown): Promise => { + const tool = byName.get(name) + if (!tool) throw rpcError(-32601, `unknown tool: ${name}`) + const input = (args ?? {}) as { + params?: Record + query?: Record + body?: unknown + } + const result = await app.invoke({ + method: tool.method as HttpMethod, + path: tool.path, + ...(input.params && { params: input.params }), + ...(input.query && { query: input.query }), + ...(input.body !== undefined && { body: input.body }), + }) + if (result.status >= 400) { + throw rpcError(-32000, `tool failed with ${result.status}`, result.data) + } + return result.data + } + + const handle = async (req: Request): Promise => { + if (req.method !== "POST") { + return json( + { jsonrpc: "2.0", id: null, error: { code: -32600, message: "expected POST" } }, + 405, + ) + } + let msg: JsonRpcRequest + try { + msg = (await req.json()) as JsonRpcRequest + } catch { + return json( + { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } }, + 400, + ) + } + try { + switch (msg.method) { + case "initialize": + return rpcOk(msg.id ?? null, { + serverInfo: cfg.info ?? { name: "hyper-mcp", version: "0.0.0" }, + capabilities: { tools: {} }, + }) + case "tools/list": + return rpcOk(msg.id ?? null, { + tools: manifest.tools.map((t) => ({ + name: t.name, + description: t.description, + inputSchema: t.inputSchema, + })), + }) + case "tools/call": { + const params = (msg.params ?? {}) as { name: string; arguments?: unknown } + if (cfg.authorize) { + const ok = await cfg.authorize({ toolName: params.name, req }) + if (!ok) { + return rpcErr(msg.id ?? null, -32001, `unauthorized: ${params.name}`) + } + } + const output = await callTool(params.name, params.arguments) + return rpcOk(msg.id ?? null, { + content: [{ type: "text", text: JSON.stringify(output) }], + }) + } + default: + return rpcErr(msg.id ?? null, -32601, `method not found: ${msg.method}`) + } + } catch (e) { + const err = e as { code?: number; message?: string; data?: unknown } + return rpcErr(msg.id ?? null, err.code ?? -32000, err.message ?? "server error", err.data) + } + } + + return { + handle, + manifest, + listTools: () => manifest.tools.map((t) => ({ name: t.name, description: t.description })), + callTool, + } +} + +function rpcError( + code: number, + message: string, + data?: unknown, +): Error & { + code: number + data?: unknown +} { + return Object.assign(new Error(message), { code, data }) +} + +function rpcOk(id: number | string | null, result: unknown): Response { + const body: JsonRpcResponse = { jsonrpc: "2.0", id, result } + return json(body, 200) +} + +function rpcErr( + id: number | string | null, + code: number, + message: string, + data?: unknown, +): Response { + const body: JsonRpcResponse = { + jsonrpc: "2.0", + id, + error: { code, message, ...(data !== undefined && { data }) }, + } + return json(body, 200) +} + +function json(body: unknown, status: number): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }) +} diff --git a/src/hyper/openapi-zod/index.ts b/src/hyper/openapi-zod/index.ts new file mode 100644 index 0000000..672ebb7 --- /dev/null +++ b/src/hyper/openapi-zod/index.ts @@ -0,0 +1,124 @@ +/** + * @hyper/openapi-zod — SchemaConverter that understands Zod v3 and v4. + * + * import { zodConverter } from "@hyper/openapi-zod" + * openapiHandlers(app, { converters: [zodConverter] }) + * + * Detection: + * - Zod schemas expose `_def.typeName` (v3) or `_def.type` (v4). + * - We sniff structurally so Zod isn't required at import-time. + * + * This converter does not rely on `zod-to-json-schema`; it walks `_def` + * directly for the subset of types we care about (object / array / string + * / number / boolean / enum / union / optional / nullable / default). + */ + +import type { JsonSchema, SchemaConverter } from "@hyper/openapi" + +interface ZodLike { + readonly _def: ZodDef + readonly parse?: (...a: unknown[]) => unknown + readonly safeParse?: (...a: unknown[]) => unknown +} + +interface ZodDef { + readonly typeName?: string + readonly type?: string + readonly [k: string]: unknown +} + +function isZod(s: unknown): s is ZodLike { + if (!s || typeof s !== "object") return false + const x = s as { _def?: unknown; parse?: unknown; safeParse?: unknown } + if (!x._def || typeof x._def !== "object") return false + return typeof x.parse === "function" || typeof x.safeParse === "function" +} + +function defName(def: ZodDef): string | undefined { + return (def.typeName ?? def.type) as string | undefined +} + +function toJson(schema: ZodLike): JsonSchema { + const def = schema._def + const name = defName(def) + switch (name) { + case "ZodString": + case "string": + return { type: "string" } + case "ZodNumber": + case "number": + return { type: "number" } + case "ZodBoolean": + case "boolean": + return { type: "boolean" } + case "ZodLiteral": + case "literal": + return { const: (def as { value: unknown }).value } + case "ZodEnum": + case "enum": { + const v = def as { values?: readonly unknown[]; entries?: Record } + const values = v.values ?? (v.entries ? Object.values(v.entries) : []) + return { enum: values } + } + case "ZodArray": + case "array": { + // Local edit (source-distributed component): cast via `unknown` because + // ZodDef.type is typed `string` but Zod's array def carries the element + // schema under `type` (v3) / `element` (v4). + const v = def as unknown as { type?: ZodLike; element?: ZodLike } + const inner = v.type ?? v.element + return { type: "array", ...(inner && { items: toJson(inner) }) } + } + case "ZodObject": + case "object": { + const shapeFn = (def as { shape?: () => Record }).shape + const shape = + typeof shapeFn === "function" + ? shapeFn() + : ((def as { shape?: Record }).shape ?? {}) + const properties: Record = {} + const required: string[] = [] + for (const [k, v] of Object.entries(shape)) { + const inner = toJson(v) + properties[k] = inner + const innerName = defName(v._def) + if (innerName !== "ZodOptional" && innerName !== "optional") required.push(k) + } + return { + type: "object", + properties, + ...(required.length > 0 && { required }), + } + } + case "ZodOptional": + case "optional": { + const inner = (def as { innerType: ZodLike }).innerType + return toJson(inner) + } + case "ZodNullable": + case "nullable": { + const inner = (def as { innerType: ZodLike }).innerType + const j = toJson(inner) + const t = j.type + return { ...j, type: Array.isArray(t) ? [...t, "null"] : t ? [t as string, "null"] : "null" } + } + case "ZodDefault": + case "default": { + const inner = (def as { innerType: ZodLike }).innerType + return { ...toJson(inner), default: (def as { defaultValue?: unknown }).defaultValue } + } + case "ZodUnion": + case "union": { + const options = (def as { options: readonly ZodLike[] }).options + return { anyOf: options.map(toJson) } + } + default: + return {} + } +} + +export const zodConverter: SchemaConverter = { + name: "zod", + canHandle: isZod, + toJsonSchema: (s) => toJson(s as ZodLike), +} diff --git a/src/hyper/openapi/converter.ts b/src/hyper/openapi/converter.ts new file mode 100644 index 0000000..4dc6efa --- /dev/null +++ b/src/hyper/openapi/converter.ts @@ -0,0 +1,39 @@ +/** + * SchemaConverter — the pluggable boundary. + * + * A converter inspects a Standard Schema value and returns an OpenAPI + * JSON Schema fragment. The base @hyper/openapi package ships a + * `fallbackConverter` that just emits `type: object`. Integrations like + * `@hyper/openapi-zod` / `-valibot` / `-arktype` extend this. + */ + +import type { StandardSchemaV1 } from "@hyper/core" + +export type JsonSchema = Record + +export interface SchemaConverter { + readonly name: string + readonly canHandle: (s: unknown) => boolean + readonly toJsonSchema: (s: unknown) => JsonSchema +} + +export const fallbackConverter: SchemaConverter = { + name: "fallback", + canHandle: () => true, + toJsonSchema: () => ({ type: "object" }), +} + +export function firstConverter( + converters: readonly SchemaConverter[], + schema: unknown, +): SchemaConverter { + for (const c of converters) if (c.canHandle(schema)) return c + return fallbackConverter +} + +/** + * Detect a Standard Schema value — gives us a baseline fall-through. + */ +export function isStandardSchema(x: unknown): x is StandardSchemaV1 { + return Boolean(x && typeof x === "object" && "~standard" in (x as Record)) +} diff --git a/src/hyper/openapi/generate.ts b/src/hyper/openapi/generate.ts new file mode 100644 index 0000000..ac01f1a --- /dev/null +++ b/src/hyper/openapi/generate.ts @@ -0,0 +1,171 @@ +/** + * Convert a Hyper app's route list into an OpenAPI 3.1 document, + * threading schemas through the registered `SchemaConverter`s. + */ + +import type { HyperApp, Route, RouteExample } from "@hyper/core" +import { type JsonSchema, type SchemaConverter, firstConverter } from "./converter.ts" + +export interface GenerateConfig { + readonly title?: string + readonly version?: string + readonly description?: string + readonly servers?: readonly { url: string; description?: string }[] + readonly converters?: readonly SchemaConverter[] +} + +export interface OpenAPIDoc { + readonly openapi: "3.1.0" + readonly info: { title: string; version: string; description?: string } + readonly servers?: readonly { url: string; description?: string }[] + readonly paths: Record> + readonly components?: { schemas?: Record } +} + +interface OpenAPIOperation { + readonly operationId?: string + readonly summary?: string + readonly tags?: readonly string[] + readonly deprecated?: boolean + readonly parameters?: readonly OpenAPIParam[] + readonly requestBody?: { + readonly content: Record }> + } + readonly responses: Record< + string, + { description: string; content?: Record } + > + readonly "x-sunset"?: string + readonly "x-version"?: string +} + +interface OpenAPIParam { + readonly name: string + readonly in: "path" | "query" | "header" + readonly required: boolean + readonly schema?: JsonSchema +} + +const PATH_PARAM = /:([A-Za-z0-9_]+)/g + +export function generate(app: HyperApp, cfg: GenerateConfig = {}): OpenAPIDoc { + const converters = cfg.converters ?? [] + const paths: Record> = {} + for (const r of app.routeList) { + if (r.meta.internal) continue + const p = toOpenApiPath(r.path) + const operation = buildOperation(r, converters) + if (!paths[p]) paths[p] = {} + paths[p][r.method.toLowerCase()] = operation + } + return { + openapi: "3.1.0", + info: { + title: cfg.title ?? "Hyper API", + version: cfg.version ?? "0.0.0", + ...(cfg.description !== undefined && { description: cfg.description }), + }, + ...(cfg.servers && { servers: cfg.servers }), + paths, + } +} + +function toOpenApiPath(path: string): string { + return path.replace(PATH_PARAM, "{$1}") +} + +function buildOperation(r: Route, converters: readonly SchemaConverter[]): OpenAPIOperation { + const parameters: OpenAPIParam[] = [] + for (const match of r.path.matchAll(PATH_PARAM)) { + parameters.push({ name: match[1]!, in: "path", required: true }) + } + if (r.query) { + const conv = firstConverter(converters, r.query) + const js = conv.toJsonSchema(r.query) + if (js.type === "object" && typeof js.properties === "object" && js.properties) { + const required = new Set((js.required as string[]) ?? []) + for (const [name, sub] of Object.entries(js.properties as Record)) { + parameters.push({ + name, + in: "query", + required: required.has(name), + schema: sub, + }) + } + } + } + + let requestBody: OpenAPIOperation["requestBody"] + if (r.body) { + const conv = firstConverter(converters, r.body) + const schema = conv.toJsonSchema(r.body) + const examples = buildBodyExamples(r.meta.examples as readonly RouteExample[] | undefined) + requestBody = { + content: { + "application/json": { + schema, + ...(examples && { examples }), + }, + }, + } + } + + const responseExamples = buildResponseExamples( + r.meta.examples as readonly RouteExample[] | undefined, + ) + const responses: OpenAPIOperation["responses"] = { + "200": { + description: "success", + // Local edit (source-distributed component): `Boolean(...)` guard so the + // spread sees a clean `false | {object}` union (buildResponseExamples + // returns `unknown`, which TS won't spread directly — TS2698). + ...(responseExamples !== undefined && { + content: { "application/json": { example: responseExamples } }, + }), + }, + } + + if (r.throws) { + for (const [status, schema] of Object.entries(r.throws)) { + const conv = firstConverter(converters, schema) + responses[status] = { + description: "declared error", + content: { "application/json": { schema: conv.toJsonSchema(schema) } }, + } + } + } + + const meta = r.meta + const deprecated = !!meta.deprecated + const sunset = + typeof meta.deprecated === "object" && meta.deprecated?.sunset + ? meta.deprecated.sunset + : undefined + return { + ...(meta.name !== undefined && { operationId: meta.name }), + ...(meta.tags !== undefined && { tags: meta.tags }), + ...(deprecated && { deprecated: true }), + ...(parameters.length > 0 && { parameters }), + ...(requestBody && { requestBody }), + responses, + ...(sunset && { "x-sunset": sunset }), + ...(meta.version !== undefined && { "x-version": meta.version }), + } +} + +function buildBodyExamples( + examples: readonly RouteExample[] | undefined, +): Record | undefined { + if (!examples) return undefined + const out: Record = {} + for (const ex of examples) { + if (ex.input?.body !== undefined) out[ex.name] = { value: ex.input.body } + } + return Object.keys(out).length > 0 ? out : undefined +} + +function buildResponseExamples(examples: readonly RouteExample[] | undefined): unknown | undefined { + if (!examples) return undefined + const ex = examples.find((e) => e.output?.body !== undefined) + return ex?.output?.body +} diff --git a/src/hyper/openapi/index.ts b/src/hyper/openapi/index.ts new file mode 100644 index 0000000..0a90925 --- /dev/null +++ b/src/hyper/openapi/index.ts @@ -0,0 +1,18 @@ +/** + * @hyper/openapi — OpenAPI 3.1 serializer + Swagger UI for Hyper apps. + * + * import { openapiHandlers } from "@hyper/openapi" + * const oa = openapiHandlers(app, { title: "My API", converters: [...] }) + * // Then mount oa.spec at /openapi.json and oa.docs at /docs. + * + * SchemaConverter is pluggable — see @hyper/openapi-zod / -valibot / -arktype. + */ + +export { fallbackConverter, firstConverter, isStandardSchema } from "./converter.ts" +export type { JsonSchema, SchemaConverter } from "./converter.ts" +export { generate } from "./generate.ts" +export type { GenerateConfig, OpenAPIDoc } from "./generate.ts" +export { openapiHandlers, openapiPlugin } from "./plugin.ts" +export type { OpenApiPluginConfig } from "./plugin.ts" +export { swaggerHtml } from "./swagger.ts" +export type { SwaggerHtmlOptions } from "./swagger.ts" diff --git a/src/hyper/openapi/plugin.ts b/src/hyper/openapi/plugin.ts new file mode 100644 index 0000000..f810d17 --- /dev/null +++ b/src/hyper/openapi/plugin.ts @@ -0,0 +1,62 @@ +/** + * openapiPlugin — exposes /openapi.json and /docs. + * + * Plugins don't add routes directly; the consumer mounts our two handlers + * explicitly (`openapiHandlers(...)`) and the plugin wires default-on + * cache headers for the spec URL. + */ + +import type { HyperApp, HyperPlugin, InvokeInput, Route } from "@hyper/core" +import type { SchemaConverter } from "./converter.ts" +import { type GenerateConfig, type OpenAPIDoc, generate } from "./generate.ts" +import { type SwaggerHtmlOptions, swaggerHtml } from "./swagger.ts" + +export interface OpenApiPluginConfig extends GenerateConfig, SwaggerHtmlOptions {} + +export function openapiPlugin(config: OpenApiPluginConfig = {}): HyperPlugin { + return { + name: "@hyper/openapi", + build() { + // Reserved for future dynamic-route registration. + }, + } +} + +/** Standalone handler pair users mount on their app. */ +export function openapiHandlers( + app: HyperApp, + config: OpenApiPluginConfig = {}, +): { + spec: (req: Request) => Response + docs: (req: Request) => Response + doc: OpenAPIDoc +} { + const doc = generate(app, config) + const docJson = JSON.stringify(doc) + const html = swaggerHtml({ + ...(config.specUrl !== undefined && { specUrl: config.specUrl }), + ...(config.title !== undefined && { title: config.title }), + }) + return { + doc, + spec: () => + new Response(docJson, { + headers: { + "content-type": "application/json; charset=utf-8", + "cache-control": "public, max-age=60", + }, + }), + docs: () => + new Response(html, { + headers: { + "content-type": "text/html; charset=utf-8", + "cache-control": "public, max-age=60", + }, + }), + } +} + +// Unused but exported for TypeScript — keeps the type dep alive. +export type _InvokeInput = InvokeInput +export type _Route = Route +export type _SchemaConverter = SchemaConverter diff --git a/src/hyper/openapi/swagger.ts b/src/hyper/openapi/swagger.ts new file mode 100644 index 0000000..1384145 --- /dev/null +++ b/src/hyper/openapi/swagger.ts @@ -0,0 +1,49 @@ +/** + * Swagger UI handler — self-contained HTML + redirect to /openapi.json. + * CDN-loaded; no bundling required. + */ + +export interface SwaggerHtmlOptions { + readonly specUrl?: string + readonly title?: string +} + +export function swaggerHtml(opts: SwaggerHtmlOptions = {}): string { + const spec = opts.specUrl ?? "/openapi.json" + const title = opts.title ?? "API Docs" + return ` + + + + ${escapeHtml(title)} + + + + +
+ + + +` +} + +function escapeHtml(s: string): string { + return s.replace( + /[&<>"']/g, + (c) => + ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[c] as string, + ) +} diff --git a/src/routes.ts b/src/routes.ts new file mode 100644 index 0000000..62149ad --- /dev/null +++ b/src/routes.ts @@ -0,0 +1,220 @@ +/** + * Hyper route declarations for inventory-api. + * + * ONE declaration per endpoint → Hyper generates the runtime, the OpenAPI 3.1 + * document (via @hyper/openapi + @hyper/openapi-zod), the typed RPC client + * (@hyper/client), and the MCP tool surface (@hyper/mcp) from this single + * source. The route handlers are thin: they call the domain functions in + * src/inventory.ts (which own the sonar ⨝ codex join + ACVP envelope). The + * domain logic is the core; these routes are transport. + * + * Errors: the domain throws typed errors (src/errors.ts) with a `.code`. + * `toHyperError` maps them to HTTP status so both the HTTP pipeline and the + * MCP invoke() path project the right status (Hyper defaults unknown throws + * to 500). + */ +import { Hyper, ok, createError, type HyperError } from "@hyper/core"; +import { z } from "zod"; +import { getHoldings, getNftsForOwner, getNftMetadata } from "./inventory.js"; + +const MIBERA_CONTRACT = "0x6666397DFe9a8c469BF65dc744CB1C733416c420"; +const SAMPLE_HOLDER = "0x1111111111111111111111111111111111111111"; + +/** Map a domain error (src/errors.ts) to a HyperError with the right status. */ +function toHyperError(e: unknown): HyperError { + const code = + e && typeof e === "object" && "code" in e + ? String((e as { code: unknown }).code) + : undefined; + const message = e instanceof Error ? e.message : "internal error"; + const status = + code === "INVENTORY_INVALID_INPUT" + ? 400 + : code === "INVENTORY_NOT_FOUND" + ? 404 + : 500; + return createError({ + status, + message, + ...(code !== undefined && { code }), + ...(status === 400 && { + fix: "Provide a 0x-prefixed 40-char hex address / numeric tokenId.", + }), + }); +} + +/** Run a domain call, translating its typed errors to HyperError. */ +async function call(fn: () => Promise): Promise { + try { + return await fn(); + } catch (e) { + throw toHyperError(e); + } +} + +// Declared error shape (projected into OpenAPI 4xx responses). +const errorBody = z.object({ + error: z.object({ + status: z.number(), + message: z.string(), + code: z.string().optional(), + }), +}); + +/** + * The inventory service routes. Exported as a Hyper instance so app.ts can + * `.use()` it and the emit scripts can build the same graph headlessly. + */ +export const routes = new Hyper() + .get( + "/holdings/:address", + { + query: z.object({ + contracts: z + .string() + .optional() + .describe("Comma-separated contract addresses to filter to."), + chains: z + .string() + .optional() + .describe("Comma-separated chain ids to filter to."), + }), + throws: { 400: errorBody }, + meta: { + name: "getHoldings", + tags: ["inventory"], + mcp: { + description: + "Get a wallet's NFT holdings for registered collections (Mibera first), " + + "with per-token tokenIds and a completeness envelope (as_of_block, " + + "holder_count, source, complete) that proves the result is complete as of a block.", + }, + examples: [ + { + name: "holdings for a holder", + input: { params: { address: SAMPLE_HOLDER } }, + output: { + body: { + holdings: [ + { + contractAddress: MIBERA_CONTRACT, + chainId: 80094, + tokenCount: 12, + tokenIds: ["1", "2", "3"], + }, + ], + completeness: { + as_of_block: 9123456, + holder_count: 5, + source: "sonar", + complete: true, + }, + }, + }, + }, + ], + }, + }, + ({ params, query }) => { + const options: { contracts?: string[]; chains?: number[] } = {}; + if (query.contracts) { + options.contracts = query.contracts.split(",").map((s) => s.trim()); + } + if (query.chains) { + options.chains = query.chains + .split(",") + .map((s) => Number(s.trim())) + .filter((n) => Number.isFinite(n)); + } + return call(() => getHoldings(params.address, options)).then(ok); + }, + ) + .get( + "/nfts/:contract/owner/:address", + { + query: z.object({ + pageSize: z.coerce + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Page size (1-100, default 100)."), + pageKey: z + .string() + .optional() + .describe("Opaque pagination cursor from a prior response."), + }), + throws: { 400: errorBody }, + meta: { + name: "getNftsForOwner", + tags: ["inventory"], + mcp: { + description: + "Get the paginated list of NFTs (with full metadata: name, image, attributes) " + + "owned by a wallet for a given contract. Supports pageSize + pageKey cursoring.", + }, + examples: [ + { + name: "nfts for owner", + input: { params: { contract: MIBERA_CONTRACT, address: SAMPLE_HOLDER } }, + output: { + body: { + contractAddress: MIBERA_CONTRACT, + name: "Mibera", + symbol: "MIBERA", + totalSupply: 10000, + nfts: [ + { + tokenId: "1", + name: "Mibera #1", + description: "A Freetekno of Greek origin...", + imageUrl: "https://assets.0xhoneyjar.xyz/.../1.png", + contentType: "image/png", + attributes: [{ trait_type: "archetype", value: "Freetekno" }], + }, + ], + }, + }, + }, + ], + }, + }, + ({ params, query }) => { + const options: { pageSize?: number; pageKey?: string } = {}; + if (query.pageSize !== undefined) options.pageSize = query.pageSize; + if (query.pageKey) options.pageKey = query.pageKey; + return call(() => getNftsForOwner(params.address, params.contract, options)).then(ok); + }, + ) + .get( + "/nfts/:contract/:tokenId", + { + throws: { 400: errorBody, 404: errorBody }, + meta: { + name: "getNftMetadata", + tags: ["inventory"], + mcp: { + description: + "Get the metadata document (name, description, image, attributes) for a single " + + "token id of a contract, sourced from the codex.", + }, + examples: [ + { + name: "single metadata", + input: { params: { contract: MIBERA_CONTRACT, tokenId: "2769" } }, + output: { + body: { + name: "Air", + description: "Cloud...", + image: "https://assets.0xhoneyjar.xyz/Mibera/grails/air.webp", + attributes: [{ trait_type: "Grail", value: "true" }], + }, + }, + }, + ], + }, + }, + ({ params }) => + call(() => getNftMetadata(params.contract, params.tokenId)).then(ok), + ); diff --git a/src/server/README.md b/src/server/README.md deleted file mode 100644 index ce5f7ae..0000000 --- a/src/server/README.md +++ /dev/null @@ -1,69 +0,0 @@ -# inventory-api — service transport (DEP-2 Part 2) - -A **thin HTTP + MCP transport** over the existing library functions -(`index.ts` / `src/inventory.ts`). The library stays the core; this directory -only exposes it over the wire. Nothing here changes the library's exports or -shapes — downstream consumers and the vitest suite are unaffected. - -## What it provides - -| Surface | Path | Backed by | -|---------|------|-----------| -| HTTP | `GET /holdings/{address}` | `getHoldings` | -| HTTP | `GET /nfts/{contract}/owner/{address}` | `getNftsForOwner` | -| HTTP | `GET /nfts/{contract}/{tokenId}` | `getNftMetadata` | -| Spec | `GET /openapi.json` | `openapi.ts` (OpenAPI 3.1) | -| MCP | `GET /.well-known/mcp.json` | `mcp.ts` (tool manifest) | -| Health | `GET /health` | — | - -`routes.ts` is the single source of truth: one declaration per route → -runtime dispatch (`server.ts`) + OpenAPI 3.1 (`openapi.ts`) + MCP tool -manifest (`mcp.ts`). This mirrors Hyper's "one route declaration → runtime + -OpenAPI + MCP" intent. - -## Run - -```bash -bun run serve # PORT env, default 8787 -bun run openapi:emit # writes ../../openapi.json (the drift-CI anchor) -bun run typecheck:server # tsc against tsconfig.server.json -``` - -The library build (`npm run build` / `npm run typecheck`) **excludes** -`src/server/` — the published package (`dist` + `fixtures`) stays Node-pure. -The server is Bun-runtime transport, validated by its own tsconfig + the -hermetic `tests/server-transport.test.ts` (which calls the exported `handle` -fetch handler directly with Web-standard Request/Response — no port, no Bun -needed, so the offline `npm test` stays green). - -## Why a minimal `Bun.serve`, not full Hyper (deferred) - -The DEP-2 brief asked for Hyper (hyperjs.ai) **if it installs/scaffolds -cleanly**, otherwise a minimal Bun server + hand-written OpenAPI 3.1, flagging -the deferral. Hyper installs fine in isolation (`bun create hyper` → -`@usehyper/cli`, OpenAPI 3.1 + MCP confirmed working), but adopting it **here** -was judged too heavy for this repo: - -- It vendors ~22 framework source files into `src/hyper/core/` (the - source-distributed model) — a multi-thousand-line diff that would dominate - the PR and obscure the actual DEP-2 change. -- It is Bun-runtime-coupled (`Bun.serve`, `bun:test`, `Bun.CookieMap`) and - adds a `@hyper` tsconfig path alias + `hyper.config.json` + `hyper.lock.json` - + a `@usehyper/cli` dependency. This repo is a Node ESM + vitest + `tsc` - **library** whose README/beacon explicitly require unchanged exports. -- Hyper's built-in OpenAPI projection currently emits placeholder - `{ description: "success" }` responses (no component schemas), so we'd still - hand-author the response shapes the consumer wants to drift-CI against. - -The minimal server keeps the diff scoped, the library Node-pure, and the -OpenAPI doc richer (real component schemas mirroring `types.ts`). The route -table + OpenAPI/MCP shapes deliberately follow Hyper's conventions, so -**adopting full Hyper later is a low-friction swap** when this building is -promoted out of `cycle_state: candidate`. - -## Keep in sync - -`openapi.ts`'s `COMPONENT_SCHEMAS` are hand-authored to match `types.ts`. If a -library response shape changes, update the matching component schema and -re-run `bun run openapi:emit`. `tests/server-transport.test.ts` asserts every -route's 200 response references a defined component schema. diff --git a/src/server/bun.d.ts b/src/server/bun.d.ts deleted file mode 100644 index b81d1da..0000000 --- a/src/server/bun.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Minimal ambient declaration for the subset of the Bun global this server - * uses. Avoids pulling the full `@types/bun` dependency into a Node library. - * The runtime is Bun (server.ts is invoked via `bun src/server/server.ts`); - * only `Bun.serve` is referenced. - */ -declare namespace Bun { - interface ServeOptions { - port?: number; - hostname?: string; - fetch: (req: Request) => Response | Promise; - } - interface Server { - readonly port: number; - readonly hostname: string; - stop(closeActiveConnections?: boolean): void; - } - function serve(options: ServeOptions): Server; -} - -interface ImportMeta { - /** Bun: true when this module is the entrypoint (`bun file.ts`). */ - readonly main: boolean; -} diff --git a/src/server/emit-openapi.ts b/src/server/emit-openapi.ts deleted file mode 100644 index daf5321..0000000 --- a/src/server/emit-openapi.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Emit the OpenAPI 3.1 document to a file (default: openapi.json at repo root). - * Run: `bun run openapi:emit` or `bun src/server/emit-openapi.ts [outPath]`. - * - * The committed openapi.json is the drift-CI anchor: the consumer (Next.js - * frontend) checks its generated client against this spec; regenerate it - * whenever the ROUTES table or response shapes change. - */ -import { writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { buildOpenAPIDocument } from "./openapi.js"; - -const outArg = process.argv[2]; -const defaultOut = fileURLToPath(new URL("../../openapi.json", import.meta.url)); -const out = outArg ?? defaultOut; - -const doc = buildOpenAPIDocument(); -writeFileSync(out, `${JSON.stringify(doc, null, 2)}\n`, "utf-8"); -// eslint-disable-next-line no-console -console.log(`wrote OpenAPI 3.1 spec -> ${out}`); diff --git a/src/server/mcp.ts b/src/server/mcp.ts deleted file mode 100644 index 816f057..0000000 --- a/src/server/mcp.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * MCP tool manifest — derived from the single ROUTES table. - * - * Shape follows Hyper's `MCPManifest` (version "1.0" + tools[]), so the - * manifest is recognizable to the same tooling and a future Hyper swap keeps - * the contract. Each route becomes one tool; inputSchema is a JSON Schema - * object over the route's params (path + query merged, since an MCP tool call - * is flat). This is a static manifest, not a live MCP transport — server.ts - * serves it at /.well-known/mcp.json for discovery. - */ -import { ROUTES } from "./routes.js"; - -export interface MCPTool { - readonly name: string; - readonly description: string; - readonly method: string; - readonly path: string; - readonly inputSchema: { - readonly type: "object"; - readonly properties: Record; - readonly required: readonly string[]; - }; -} - -export interface MCPManifest { - readonly version: "1.0"; - readonly tools: readonly MCPTool[]; -} - -/** Build the MCP tool manifest from the ROUTES table. */ -export function buildMCPManifest(): MCPManifest { - const tools: MCPTool[] = ROUTES.map((r) => { - const properties: Record = {}; - const required: string[] = []; - for (const p of r.params) { - properties[p.name] = { ...p.schema, description: p.description }; - if (p.required) required.push(p.name); - } - return { - name: r.operationId, - description: r.mcpDescription, - method: r.method, - path: r.path, - inputSchema: { type: "object" as const, properties, required }, - }; - }); - return { version: "1.0", tools }; -} diff --git a/src/server/openapi.ts b/src/server/openapi.ts deleted file mode 100644 index 4d20e55..0000000 --- a/src/server/openapi.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * OpenAPI 3.1 document generator — derived from the single ROUTES table. - * - * Emits a spec the consumer (Next.js frontend) can drift-CI against. The - * shape follows Hyper's `OpenAPIManifest` conventions (openapi: "3.1.0", - * info, paths) so a future swap to full Hyper is low-friction, but adds real - * component schemas + typed responses (Hyper's built-in projection currently - * emits `{ description: "success" }` placeholders). - * - * Component schemas are hand-authored to MATCH `types.ts` exactly — keep them - * in sync when the library response shapes change. - */ -import { ROUTES } from "./routes.js"; - -/** Reusable component schemas mirroring `types.ts`. */ -const COMPONENT_SCHEMAS: Record = { - Attribute: { - type: "object", - required: ["trait_type", "value"], - properties: { - trait_type: { type: "string" }, - value: { type: "string" }, - }, - }, - CompletenessEnvelope: { - type: "object", - required: ["as_of_block", "holder_count", "source", "complete"], - properties: { - as_of_block: { type: "integer" }, - holder_count: { type: "integer" }, - source: { type: "string", enum: ["sonar"] }, - complete: { - description: "true when the answer is provably complete; 'degraded' when upstream was unreachable.", - oneOf: [{ type: "boolean", enum: [true] }, { type: "string", enum: ["degraded"] }], - }, - }, - }, - ContractHolding: { - type: "object", - required: ["contractAddress", "chainId", "tokenCount", "tokenIds"], - properties: { - contractAddress: { type: "string" }, - chainId: { type: "integer" }, - tokenCount: { type: "integer" }, - tokenIds: { type: "array", items: { type: "string" } }, - }, - }, - HoldingsResponse: { - type: "object", - required: ["holdings", "completeness"], - properties: { - holdings: { type: "array", items: { $ref: "#/components/schemas/ContractHolding" } }, - completeness: { $ref: "#/components/schemas/CompletenessEnvelope" }, - }, - }, - NFT: { - type: "object", - required: ["tokenId", "name", "description", "imageUrl", "contentType", "attributes"], - properties: { - tokenId: { type: "string" }, - name: { type: "string" }, - description: { type: "string" }, - imageUrl: { type: "string" }, - contentType: { type: "string" }, - attributes: { type: "array", items: { $ref: "#/components/schemas/Attribute" } }, - }, - }, - NFTCollection: { - type: "object", - required: ["contractAddress", "name", "symbol", "totalSupply", "nfts"], - properties: { - contractAddress: { type: "string" }, - name: { type: "string" }, - symbol: { type: "string" }, - totalSupply: { type: "integer" }, - nfts: { type: "array", items: { $ref: "#/components/schemas/NFT" } }, - pageKey: { type: "string" }, - }, - }, - MetadataDocument: { - type: "object", - required: ["name", "description", "image", "attributes"], - properties: { - name: { type: "string" }, - description: { type: "string" }, - image: { type: "string" }, - attributes: { type: "array", items: { $ref: "#/components/schemas/Attribute" } }, - }, - }, - Error: { - type: "object", - required: ["error"], - properties: { - error: { type: "string" }, - code: { type: "string" }, - }, - }, -}; - -export interface OpenAPIDocConfig { - readonly title?: string; - readonly version?: string; - readonly description?: string; -} - -/** Build the OpenAPI 3.1 document from the ROUTES table. */ -export function buildOpenAPIDocument(cfg: OpenAPIDocConfig = {}): Record { - const paths: Record> = {}; - - for (const r of ROUTES) { - const parameters = r.params.map((p) => ({ - name: p.name, - in: p.in, - required: p.required, - description: p.description, - schema: p.schema, - })); - - const operation = { - operationId: r.operationId, - summary: r.summary, - tags: r.tags, - parameters, - responses: { - "200": { - description: "success", - content: { - "application/json": { - schema: { $ref: `#/components/schemas/${r.responseSchema}` }, - }, - }, - }, - "400": { - description: "validation error", - content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, - }, - "404": { - description: "not found", - content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } }, - }, - }, - }; - - if (!paths[r.path]) paths[r.path] = {}; - paths[r.path][r.method.toLowerCase()] = operation; - } - - return { - openapi: "3.1.0", - info: { - title: cfg.title ?? "inventory-api", - version: cfg.version ?? "0.1.0", - description: - cfg.description ?? - "Sovereign read-side inventory aggregator — joins sonar holdings with owned codex metadata + a completeness envelope.", - }, - paths, - components: { schemas: COMPONENT_SCHEMAS }, - }; -} diff --git a/src/server/routes.ts b/src/server/routes.ts deleted file mode 100644 index ca1fa7f..0000000 --- a/src/server/routes.ts +++ /dev/null @@ -1,178 +0,0 @@ -/** - * Single source-of-truth route table for the inventory-api service transport. - * - * One declaration per route → runtime dispatch (server.ts) + OpenAPI 3.1 - * (openapi.ts) + MCP tool manifest (mcp.ts). This mirrors Hyper's - * "one route declaration → runtime + OpenAPI + MCP" intent while staying a - * thin, dependency-free wrapper over the existing library functions. The - * library (index.ts / src/inventory.ts) remains the core; this is transport. - * - * See src/server/README.md for the Hyper-vs-minimal-server rationale. - */ -import { getHoldings, getNftsForOwner, getNftMetadata } from "../inventory.js"; - -/** OpenAPI parameter descriptor (path or query). */ -export interface RouteParam { - readonly name: string; - readonly in: "path" | "query"; - readonly required: boolean; - readonly description: string; - readonly schema: Record; -} - -/** - * A route definition. `handler` receives the resolved path params + parsed - * query and returns a JSON-serializable value (or throws — server.ts maps - * the library's typed errors to HTTP status codes). - */ -export interface RouteDef { - readonly method: "GET"; - /** OpenAPI-style path with `{param}` placeholders. */ - readonly path: string; - readonly operationId: string; - readonly summary: string; - /** MCP tool description (richer, model-facing). */ - readonly mcpDescription: string; - readonly tags: readonly string[]; - readonly params: readonly RouteParam[]; - /** OpenAPI schema name for the 200 response body (see openapi.ts components). */ - readonly responseSchema: string; - readonly handler: ( - pathParams: Record, - query: URLSearchParams, - ) => Promise; -} - -const ADDRESS_SCHEMA = { - type: "string", - pattern: "^0x[0-9a-fA-F]{40}$", -} as const; - -/** The three transport routes, declared once. */ -export const ROUTES: readonly RouteDef[] = [ - { - method: "GET", - path: "/holdings/{address}", - operationId: "getHoldings", - summary: "Resolve a wallet's holdings + ACVP completeness envelope", - mcpDescription: - "Get a wallet's NFT holdings for registered collections (Mibera first), " + - "with per-token tokenIds and a completeness envelope (as_of_block, holder_count, " + - "source, complete) that proves the result is complete as of a block.", - tags: ["inventory"], - params: [ - { - name: "address", - in: "path", - required: true, - description: "Holder wallet address (0x-prefixed, 40 hex chars).", - schema: ADDRESS_SCHEMA, - }, - { - name: "contracts", - in: "query", - required: false, - description: "Comma-separated contract addresses to filter to.", - schema: { type: "string" }, - }, - { - name: "chains", - in: "query", - required: false, - description: "Comma-separated chain ids to filter to.", - schema: { type: "string" }, - }, - ], - responseSchema: "HoldingsResponse", - handler: async (p, q) => { - const contracts = q.get("contracts"); - const chains = q.get("chains"); - const options: { contracts?: string[]; chains?: number[] } = {}; - if (contracts) options.contracts = contracts.split(",").map((s) => s.trim()); - if (chains) { - options.chains = chains - .split(",") - .map((s) => Number(s.trim())) - .filter((n) => Number.isFinite(n)); - } - return getHoldings(p.address, options); - }, - }, - { - method: "GET", - path: "/nfts/{contract}/owner/{address}", - operationId: "getNftsForOwner", - summary: "Paginated NFTs (sonar ⨝ codex) owned by a wallet", - mcpDescription: - "Get the paginated list of NFTs (with full metadata: name, image, attributes) " + - "owned by a wallet for a given contract. Supports pageSize + pageKey cursoring.", - tags: ["inventory"], - params: [ - { - name: "contract", - in: "path", - required: true, - description: "Collection contract address (0x-prefixed, 40 hex chars).", - schema: ADDRESS_SCHEMA, - }, - { - name: "address", - in: "path", - required: true, - description: "Holder wallet address (0x-prefixed, 40 hex chars).", - schema: ADDRESS_SCHEMA, - }, - { - name: "pageSize", - in: "query", - required: false, - description: "Page size (1-100, default 100).", - schema: { type: "integer", minimum: 1, maximum: 100 }, - }, - { - name: "pageKey", - in: "query", - required: false, - description: "Opaque pagination cursor from a prior response.", - schema: { type: "string" }, - }, - ], - responseSchema: "NFTCollection", - handler: async (p, q) => { - const options: { pageSize?: number; pageKey?: string } = {}; - const pageSize = q.get("pageSize"); - const pageKey = q.get("pageKey"); - if (pageSize) options.pageSize = Number(pageSize); - if (pageKey) options.pageKey = pageKey; - return getNftsForOwner(p.address, p.contract, options); - }, - }, - { - method: "GET", - path: "/nfts/{contract}/{tokenId}", - operationId: "getNftMetadata", - summary: "Single NFT metadata document from the codex", - mcpDescription: - "Get the metadata document (name, description, image, attributes) for a single " + - "token id of a contract, sourced from the codex.", - tags: ["inventory"], - params: [ - { - name: "contract", - in: "path", - required: true, - description: "Collection contract address (0x-prefixed, 40 hex chars).", - schema: ADDRESS_SCHEMA, - }, - { - name: "tokenId", - in: "path", - required: true, - description: "Numeric token id.", - schema: { type: "string", pattern: "^\\d+$" }, - }, - ], - responseSchema: "MetadataDocument", - handler: async (p) => getNftMetadata(p.contract, p.tokenId), - }, -]; diff --git a/src/server/server.ts b/src/server/server.ts deleted file mode 100644 index cb23331..0000000 --- a/src/server/server.ts +++ /dev/null @@ -1,125 +0,0 @@ -/** - * Bun HTTP transport for inventory-api. - * - * A THIN transport over the existing library functions (index.ts / - * src/inventory.ts). The library stays the core; this just exposes - * getHoldings / getNftsForOwner / getNftMetadata over HTTP, plus discovery - * docs: an OpenAPI 3.1 spec (/openapi.json) and an MCP tool manifest - * (/.well-known/mcp.json), both derived from the single ROUTES table. - * - * Why a minimal Bun.serve rather than full Hyper: see src/server/README.md. - * - * Run: bun src/server/server.ts (PORT env, default 8787) - * Spec: bun run openapi:emit (writes openapi.json to repo root) - */ -import { ROUTES, type RouteDef } from "./routes.js"; -import { buildOpenAPIDocument } from "./openapi.js"; -import { buildMCPManifest } from "./mcp.js"; - -// Library error codes (src/errors.ts) → HTTP status. -const ERROR_STATUS: Record = { - INVENTORY_INVALID_INPUT: 400, - INVENTORY_NOT_FOUND: 404, - INVENTORY_FIXTURE_LOAD: 500, -}; - -interface CompiledRoute { - readonly def: RouteDef; - readonly regex: RegExp; - readonly paramNames: readonly string[]; -} - -/** Compile `/nfts/{contract}/{tokenId}` into a matcher + param names. */ -function compileRoute(def: RouteDef): CompiledRoute { - const paramNames: string[] = []; - const pattern = def.path.replace(/\{([A-Za-z0-9_]+)\}/g, (_m, name: string) => { - paramNames.push(name); - return "([^/]+)"; - }); - return { def, regex: new RegExp(`^${pattern}$`), paramNames }; -} - -const COMPILED: readonly CompiledRoute[] = ROUTES.map(compileRoute); - -function json(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { - "content-type": "application/json", - "access-control-allow-origin": "*", - }, - }); -} - -/** Map a thrown library error to an HTTP response. */ -function errorResponse(err: unknown): Response { - const code = - err && typeof err === "object" && "code" in err - ? String((err as { code: unknown }).code) - : undefined; - const status = code && ERROR_STATUS[code] ? ERROR_STATUS[code] : 500; - const message = err instanceof Error ? err.message : "internal error"; - return json({ error: message, ...(code ? { code } : {}) }, status); -} - -/** The fetch handler — exported so it can be unit-tested without binding a port. */ -export async function handle(req: Request): Promise { - const url = new URL(req.url); - const { pathname } = url; - - if (req.method === "OPTIONS") { - return new Response(null, { - status: 204, - headers: { - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, OPTIONS", - "access-control-allow-headers": "content-type", - }, - }); - } - - // Discovery + health. - if (pathname === "/" || pathname === "/health") { - return json({ ok: true, service: "inventory-api", routes: ROUTES.length }); - } - if (pathname === "/openapi.json") { - return json(buildOpenAPIDocument()); - } - if (pathname === "/.well-known/mcp.json" || pathname === "/mcp.json") { - return json(buildMCPManifest()); - } - - // Dispatch to a route. - for (const { def, regex, paramNames } of COMPILED) { - if (def.method !== req.method) continue; - const m = regex.exec(pathname); - if (!m) continue; - const pathParams: Record = {}; - paramNames.forEach((name, i) => { - pathParams[name] = decodeURIComponent(m[i + 1]); - }); - try { - const result = await def.handler(pathParams, url.searchParams); - return json(result); - } catch (err) { - return errorResponse(err); - } - } - - return json({ error: "not found", code: "ROUTE_NOT_FOUND" }, 404); -} - -/** Start the server. Skipped at import time so tests can use `handle` directly. */ -export function start(port = Number(process.env.PORT) || 8787): Bun.Server { - const server = Bun.serve({ port, fetch: handle }); - // eslint-disable-next-line no-console - console.log( - `inventory-api listening on http://${server.hostname}:${server.port} (${ROUTES.length} routes) — spec at /openapi.json, mcp at /.well-known/mcp.json`, - ); - return server; -} - -// `bun src/server/server.ts` runs this directly; importing for tests does not. -if (import.meta.main) { - start(); -} diff --git a/tests/server-transport.test.ts b/tests/server-transport.test.ts deleted file mode 100644 index aeada71..0000000 --- a/tests/server-transport.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { handle } from '../src/server/server.js'; -import { buildOpenAPIDocument } from '../src/server/openapi.js'; -import { buildMCPManifest } from '../src/server/mcp.js'; - -/** - * Hermetic transport tests. We call the exported `handle(req)` fetch handler - * directly with Web-standard Request/Response — no port binding, no Bun - * runtime — so the offline vitest suite stays green. This validates the thin - * HTTP transport over the library + the OpenAPI/MCP discovery docs. - */ -const MIBERA = '0x6666397DFe9a8c469BF65dc744CB1C733416c420'; -const HOLDER = '0x1111111111111111111111111111111111111111'; -const EMPTY = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; -const BASE = 'http://localhost'; - -const get = (path: string) => handle(new Request(`${BASE}${path}`)); - -describe('server transport (hermetic, via handle())', () => { - it('GET /health returns ok', async () => { - const res = await get('/health'); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.ok).toBe(true); - expect(body.routes).toBe(3); - }); - - it('GET /holdings/:address wraps getHoldings', async () => { - const res = await get(`/holdings/${HOLDER}`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.holdings[0].tokenCount).toBe(12); - expect(body.holdings[0].tokenIds).toHaveLength(12); - expect(body.completeness.source).toBe('sonar'); - }); - - it('GET /holdings/:address forwards contracts query option', async () => { - const res = await get(`/holdings/${HOLDER}?contracts=${MIBERA}`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.holdings[0].contractAddress).toBe(MIBERA); - }); - - it('GET /nfts/:contract/owner/:address wraps getNftsForOwner with pagination', async () => { - const res = await get(`/nfts/${MIBERA}/owner/${HOLDER}?pageSize=5`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.nfts).toHaveLength(5); - expect(body.pageKey).toBeDefined(); - expect(body.name).toBe('Mibera'); - }); - - it('GET /nfts/:contract/:tokenId wraps getNftMetadata', async () => { - const res = await get(`/nfts/${MIBERA}/2769`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.name).toBe('Air'); // pinned grail - }); - - it('maps ValidationError -> 400', async () => { - const res = await get('/holdings/not-an-address'); - expect(res.status).toBe(400); - const body = await res.json(); - expect(body.code).toBe('INVENTORY_INVALID_INPUT'); - }); - - it('maps NotFoundError -> 404', async () => { - const res = await get(`/nfts/${MIBERA}/99999`); - expect(res.status).toBe(404); - const body = await res.json(); - expect(body.code).toBe('INVENTORY_NOT_FOUND'); - }); - - it('unknown route -> 404', async () => { - const res = await get('/does/not/exist'); - expect(res.status).toBe(404); - }); - - it('empty holder returns empty holdings (graceful)', async () => { - const res = await get(`/holdings/${EMPTY}`); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.holdings).toHaveLength(0); - }); - - it('serves the OpenAPI 3.1 document', async () => { - const res = await get('/openapi.json'); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.openapi).toBe('3.1.0'); - expect(Object.keys(body.paths)).toHaveLength(3); - }); - - it('serves the MCP manifest at /.well-known/mcp.json', async () => { - const res = await get('/.well-known/mcp.json'); - expect(res.status).toBe(200); - const body = await res.json(); - expect(body.version).toBe('1.0'); - expect(body.tools.map((t: { name: string }) => t.name)).toEqual([ - 'getHoldings', - 'getNftsForOwner', - 'getNftMetadata', - ]); - }); - - it('CORS preflight (OPTIONS) returns 204', async () => { - const res = await handle(new Request(`${BASE}/holdings/${HOLDER}`, { method: 'OPTIONS' })); - expect(res.status).toBe(204); - }); -}); - -describe('OpenAPI / MCP generators (unit)', () => { - it('OpenAPI doc has component schemas matching types.ts', () => { - const doc = buildOpenAPIDocument(); - const schemas = (doc.components as { schemas: Record }).schemas; - expect(schemas).toHaveProperty('HoldingsResponse'); - expect(schemas).toHaveProperty('NFTCollection'); - expect(schemas).toHaveProperty('MetadataDocument'); - expect(schemas).toHaveProperty('CompletenessEnvelope'); - }); - - it('every route 200 response references a defined component schema', () => { - const doc = buildOpenAPIDocument(); - const schemas = (doc.components as { schemas: Record }).schemas; - const paths = doc.paths as Record>; - for (const ops of Object.values(paths)) { - for (const op of Object.values(ops)) { - const ref = op.responses['200'].content['application/json'].schema.$ref; - const name = ref.replace('#/components/schemas/', ''); - expect(schemas).toHaveProperty(name); - } - } - }); - - it('MCP manifest declares required path params', () => { - const m = buildMCPManifest(); - const holdings = m.tools.find((t) => t.name === 'getHoldings'); - expect(holdings!.inputSchema.required).toContain('address'); - }); -}); diff --git a/tests/service.test.ts b/tests/service.test.ts new file mode 100644 index 0000000..199ee0a --- /dev/null +++ b/tests/service.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect } from "vitest"; +import { app, buildOpenAPI, buildMCPManifest } from "../src/app.js"; + +/** + * Hyper service tests. We call the app's Web-standard `fetch` handler + * directly (no port bind, no Bun runtime needed) so the suite runs offline + * under vitest/Node — Hyper's pipeline + response coercion are Web-standard. + * The HTTP routes, the MCP JSON-RPC endpoint, and the OpenAPI/MCP discovery + * surfaces all funnel through the same route graph (src/routes.ts), which + * calls the domain functions (Part 1 logic) in src/inventory.ts. + */ +const MIBERA = "0x6666397DFe9a8c469BF65dc744CB1C733416c420"; +const HOLDER = "0x1111111111111111111111111111111111111111"; +const EMPTY = "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"; +const BASE = "http://localhost"; + +const get = (path: string) => app.fetch(new Request(`${BASE}${path}`)); +const post = (path: string, body: unknown) => + app.fetch( + new Request(`${BASE}${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + ); + +describe("HTTP routes (via app.fetch)", () => { + it("GET /health", async () => { + const res = await get("/health"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.service).toBe("inventory-api"); + }); + + it("GET /holdings/:address wraps getHoldings", async () => { + const res = await get(`/holdings/${HOLDER}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.holdings[0].tokenCount).toBe(12); + expect(body.holdings[0].tokenIds).toHaveLength(12); + expect(body.completeness.source).toBe("sonar"); + }); + + it("GET /holdings/:address forwards the contracts query option", async () => { + const res = await get(`/holdings/${HOLDER}?contracts=${MIBERA}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.holdings[0].contractAddress).toBe(MIBERA); + }); + + it("GET /nfts/:contract/owner/:address wraps getNftsForOwner with pagination", async () => { + const res = await get(`/nfts/${MIBERA}/owner/${HOLDER}?pageSize=5`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.nfts).toHaveLength(5); + expect(body.pageKey).toBeDefined(); + expect(body.name).toBe("Mibera"); + }); + + it("GET /nfts/:contract/:tokenId wraps getNftMetadata", async () => { + const res = await get(`/nfts/${MIBERA}/2769`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.name).toBe("Air"); // pinned grail + }); + + it("maps ValidationError -> 400 with code", async () => { + const res = await get("/holdings/not-an-address"); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error.code).toBe("INVENTORY_INVALID_INPUT"); + }); + + it("maps NotFoundError -> 404 with code", async () => { + const res = await get(`/nfts/${MIBERA}/99999`); + expect(res.status).toBe(404); + const body = await res.json(); + expect(body.error.code).toBe("INVENTORY_NOT_FOUND"); + }); + + it("empty holder returns empty holdings (graceful)", async () => { + const res = await get(`/holdings/${EMPTY}`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.holdings).toHaveLength(0); + }); +}); + +describe("OpenAPI 3.1 surface", () => { + it("serves /openapi.json as 3.1.0 with the 3 routes", async () => { + const res = await get("/openapi.json"); + expect(res.status).toBe(200); + const doc = await res.json(); + expect(doc.openapi).toBe("3.1.0"); + expect(doc.info.title).toBe("inventory-api"); + expect(doc.paths["/holdings/{address}"]).toBeDefined(); + expect(doc.paths["/nfts/{contract}/owner/{address}"]).toBeDefined(); + expect(doc.paths["/nfts/{contract}/{tokenId}"]).toBeDefined(); + }); + + it("operationIds + path params + query params are projected", async () => { + const doc = buildOpenAPI(); + const holdings = doc.paths["/holdings/{address}"]!.get!; + expect(holdings.operationId).toBe("getHoldings"); + const paramNames = (holdings.parameters ?? []).map((p) => p.name); + expect(paramNames).toContain("address"); // path param + expect(paramNames).toContain("contracts"); // query param (from zod) + expect(paramNames).toContain("chains"); + }); + + it("declared 4xx errors are projected (throws)", async () => { + const doc = buildOpenAPI(); + const meta = doc.paths["/nfts/{contract}/{tokenId}"]!.get!; + expect(meta.responses["404"]).toBeDefined(); + }); + + it("response examples surface in the spec", async () => { + const doc = buildOpenAPI(); + const holdings = doc.paths["/holdings/{address}"]!.get!; + const example = holdings.responses["200"].content?.["application/json"]?.example as + | { holdings: unknown[] } + | undefined; + expect(example?.holdings).toBeDefined(); + }); +}); + +describe("MCP surface", () => { + it("serves /.well-known/mcp.json with the 3 tools", async () => { + const res = await get("/.well-known/mcp.json"); + expect(res.status).toBe(200); + const manifest = await res.json(); + const names = manifest.tools.map((t: { name: string }) => t.name).sort(); + expect(names).toEqual(["getHoldings", "getNftMetadata", "getNftsForOwner"]); + }); + + it("POST /mcp tools/list returns the tools (JSON-RPC)", async () => { + const res = await post("/mcp", { jsonrpc: "2.0", id: 1, method: "tools/list" }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.tools.map((t: { name: string }) => t.name)).toContain("getHoldings"); + }); + + it("POST /mcp tools/call getNftMetadata runs the same domain path", async () => { + const res = await post("/mcp", { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "getNftMetadata", arguments: { params: { contract: MIBERA, tokenId: "2769" } } }, + }); + expect(res.status).toBe(200); + const body = await res.json(); + const text = body.result.content[0].text; + const doc = JSON.parse(text); + expect(doc.name).toBe("Air"); + }); + + it("buildMCPManifest matches the served manifest", async () => { + const manifest = buildMCPManifest(); + expect(manifest.tools).toHaveLength(3); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index f056c59..17939e7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,17 +1,19 @@ { "compilerOptions": { - "strict": true, - "module": "ESNext", "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", "moduleResolution": "bundler", - "outDir": "./dist", - "rootDir": ".", - "declaration": true, - "declarationMap": true, - "sourceMap": true, + "strict": true, + "verbatimModuleSyntax": true, + "allowImportingTsExtensions": true, + "noEmit": true, + "skipLibCheck": true, "esModuleInterop": true, - "skipLibCheck": true + "types": ["@types/bun"], + "paths": { + "@hyper/*": ["./src/hyper/*", "./src/hyper/*/index.ts"] + } }, - "include": ["index.ts", "types.ts", "src/**/*.ts"], - "exclude": ["node_modules", "dist", "tests", "src/server"] + "include": ["src/**/*.ts", "tests/**/*.ts", "index.ts", "types.ts"] } diff --git a/tsconfig.server.json b/tsconfig.server.json deleted file mode 100644 index cf67f54..0000000 --- a/tsconfig.server.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "types": ["node"] - }, - "include": ["src/server/**/*.ts"], - "exclude": ["node_modules", "dist", "tests"] -} diff --git a/vitest.config.ts b/vitest.config.ts index 8363e16..64244d8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,15 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; + +// Mirror the tsconfig `@hyper/*` path alias for the vitest/vite resolver, so +// the Hyper service code (which imports `@hyper/core`, `@hyper/openapi`, etc.) +// resolves to the vendored source-distributed components under src/hyper/. +const hyperDir = fileURLToPath(new URL('./src/hyper', import.meta.url)); export default defineConfig({ + resolve: { + alias: [{ find: /^@hyper\/(.*)$/, replacement: `${hyperDir}/$1/index.ts` }], + }, test: { environment: 'node', include: ['tests/**/*.test.ts'],