diff --git a/app/components/Error.tsx b/app/components/Error.tsx index 9d147f5..aec26c7 100644 --- a/app/components/Error.tsx +++ b/app/components/Error.tsx @@ -1,6 +1,6 @@ import { AlertCircle } from 'lucide-react'; import { isRouteErrorResponse, useRouteError } from 'react-router'; -import ResponseError from '~/server/headscale/api-error'; +import ResponseError from '~/server/headscale/api/error'; import cn from '~/utils/cn'; import Card from './Card'; import Code from './Code'; diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 4e479b0..be7066c 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -3,7 +3,8 @@ import { createReadableStreamFromReadable } from '@react-router/node'; import { isbot } from 'isbot'; import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import { renderToPipeableStream } from 'react-dom/server'; -import { AppLoadContext, EntryContext, ServerRouter } from 'react-router'; +import type { AppLoadContext, EntryContext } from 'react-router'; +import { ServerRouter } from 'react-router'; export const streamTimeout = 5_000; export default function handleRequest( diff --git a/app/layouts/dashboard.tsx b/app/layouts/dashboard.tsx index ffd8aea..2bfa884 100644 --- a/app/layouts/dashboard.tsx +++ b/app/layouts/dashboard.tsx @@ -2,7 +2,7 @@ import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router'; import { ErrorPopup } from '~/components/Error'; import type { LoadContext } from '~/server'; import { pruneEphemeralNodes } from '~/server/db/pruner'; -import ResponseError from '~/server/headscale/api-error'; +import ResponseError from '~/server/headscale/api/error'; import log from '~/utils/log'; export async function loader({ diff --git a/app/openapi-operation-hashes.json b/app/openapi-operation-hashes.json new file mode 100644 index 0000000..37e97e3 --- /dev/null +++ b/app/openapi-operation-hashes.json @@ -0,0 +1,81 @@ +{ + "0.26.0": { + "GET /api/v1/apikey": "efe31b6dc980e158", + "POST /api/v1/apikey": "39953a96c1da5312", + "POST /api/v1/apikey/expire": "ca56add866802f17", + "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", + "POST /api/v1/debug/node": "204f9ae3f9f738c6", + "GET /api/v1/node": "8bb18b8c7cfb4f20", + "POST /api/v1/node/backfillips": "6da4d1d3922a8001", + "POST /api/v1/node/register": "539f7cb3a84d43d4", + "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", + "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", + "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", + "POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784", + "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", + "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", + "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", + "GET /api/v1/policy": "d6c639be304cd3c0", + "PUT /api/v1/policy": "6cbe80bde771a388", + "GET /api/v1/preauthkey": "14db6a04f90d7a7e", + "POST /api/v1/preauthkey": "0b4308e049d4eb58", + "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", + "GET /api/v1/user": "228831b58ccc5a17", + "POST /api/v1/user": "a4e1d889d7962da5", + "DELETE /api/v1/user/{id}": "3d553e4b74296884", + "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" + }, + "0.26.1": { + "GET /api/v1/apikey": "efe31b6dc980e158", + "POST /api/v1/apikey": "39953a96c1da5312", + "POST /api/v1/apikey/expire": "ca56add866802f17", + "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", + "POST /api/v1/debug/node": "204f9ae3f9f738c6", + "GET /api/v1/node": "8bb18b8c7cfb4f20", + "POST /api/v1/node/backfillips": "6da4d1d3922a8001", + "POST /api/v1/node/register": "539f7cb3a84d43d4", + "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", + "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", + "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", + "POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784", + "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", + "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", + "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", + "GET /api/v1/policy": "d6c639be304cd3c0", + "PUT /api/v1/policy": "6cbe80bde771a388", + "GET /api/v1/preauthkey": "14db6a04f90d7a7e", + "POST /api/v1/preauthkey": "0b4308e049d4eb58", + "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", + "GET /api/v1/user": "228831b58ccc5a17", + "POST /api/v1/user": "a4e1d889d7962da5", + "DELETE /api/v1/user/{id}": "3d553e4b74296884", + "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" + }, + "0.27.0": { + "GET /api/v1/apikey": "efe31b6dc980e158", + "POST /api/v1/apikey": "39953a96c1da5312", + "POST /api/v1/apikey/expire": "ca56add866802f17", + "DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1", + "POST /api/v1/debug/node": "204f9ae3f9f738c6", + "GET /api/v1/health": "5e447272e72b2e5f", + "GET /api/v1/node": "8bb18b8c7cfb4f20", + "POST /api/v1/node/backfillips": "6da4d1d3922a8001", + "POST /api/v1/node/register": "539f7cb3a84d43d4", + "GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37", + "DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724", + "POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d", + "POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784", + "POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90", + "POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5", + "POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922", + "GET /api/v1/policy": "d6c639be304cd3c0", + "PUT /api/v1/policy": "6cbe80bde771a388", + "GET /api/v1/preauthkey": "14db6a04f90d7a7e", + "POST /api/v1/preauthkey": "0b4308e049d4eb58", + "POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f", + "GET /api/v1/user": "228831b58ccc5a17", + "POST /api/v1/user": "a4e1d889d7962da5", + "DELETE /api/v1/user/{id}": "3d553e4b74296884", + "POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7" + } +} diff --git a/app/routes/acls/acl-action.ts b/app/routes/acls/acl-action.ts index fc319cf..f200548 100644 --- a/app/routes/acls/acl-action.ts +++ b/app/routes/acls/acl-action.ts @@ -1,6 +1,6 @@ import { ActionFunctionArgs, data } from 'react-router'; import { LoadContext } from '~/server'; -import ResponseError from '~/server/headscale/api-error'; +import ResponseError from '~/server/headscale/api/error'; import { Capabilities } from '~/server/web/roles'; import { data400, data403 } from '~/utils/res'; diff --git a/app/routes/acls/acl-loader.ts b/app/routes/acls/acl-loader.ts index 19ffd3c..53c253e 100644 --- a/app/routes/acls/acl-loader.ts +++ b/app/routes/acls/acl-loader.ts @@ -1,6 +1,6 @@ import { LoaderFunctionArgs } from 'react-router'; import { LoadContext } from '~/server'; -import ResponseError from '~/server/headscale/api-error'; +import ResponseError from '~/server/headscale/api/error'; import { Capabilities } from '~/server/web/roles'; import { data403 } from '~/utils/res'; diff --git a/app/routes/auth/login/action.ts b/app/routes/auth/login/action.ts index 10f728d..f27ed17 100644 --- a/app/routes/auth/login/action.ts +++ b/app/routes/auth/login/action.ts @@ -1,6 +1,6 @@ import { ActionFunctionArgs, data, redirect } from 'react-router'; import { LoadContext } from '~/server'; -import ResponseError from '~/server/headscale/api-error'; +import ResponseError from '~/server/headscale/api/error'; import { Key } from '~/types'; import log from '~/utils/log'; diff --git a/app/routes/machines/overview.tsx b/app/routes/machines/overview.tsx index d692a68..a990bc6 100644 --- a/app/routes/machines/overview.tsx +++ b/app/routes/machines/overview.tsx @@ -6,7 +6,6 @@ import Link from '~/components/Link'; import Tooltip from '~/components/Tooltip'; import type { LoadContext } from '~/server'; import { Capabilities } from '~/server/web/roles'; -import type { Machine, User } from '~/types'; import cn from '~/utils/cn'; import { mapNodes } from '~/utils/node-info'; import MachineRow from './components/machine-row'; @@ -40,10 +39,8 @@ export async function loader({ Capabilities.write_machines, ); - const [{ nodes }, { users }] = await Promise.all([ - context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key), - context.client.get<{ users: User[] }>('v1/user', session.api_key), - ]); + const api = context.hsApi.getRuntimeClient(session.api_key); + const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]); let magic: string | undefined; if (context.hs.readable()) { diff --git a/app/server/headscale/api-client.ts b/app/server/headscale/api-client.ts index 7536652..5a565c8 100644 --- a/app/server/headscale/api-client.ts +++ b/app/server/headscale/api-client.ts @@ -1,9 +1,8 @@ import { readFile } from 'node:fs/promises'; import { data } from 'react-router'; -import { Agent, Dispatcher, request } from 'undici'; -import { errors } from 'undici'; +import { Agent, Dispatcher, errors, request } from 'undici'; import log from '~/utils/log'; -import ResponseError from './api-error'; +import ResponseError from './api/error'; function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException { const keys = Object.keys(error as Record); diff --git a/app/server/headscale/api-error.ts b/app/server/headscale/api-error.ts deleted file mode 100644 index bd87ee6..0000000 --- a/app/server/headscale/api-error.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Represents an error that occurred during a response -// Thrown when status codes are >= 400 -export default class ResponseError extends Error { - status: number; - response: string; - responseObject?: Record; - - constructor(status: number, response: string) { - super(`Response Error (${status}): ${response}`); - this.name = 'ResponseError'; - this.status = status; - this.response = response; - - try { - // Try to parse the response as JSON to get a response object - this.responseObject = JSON.parse(response); - } catch {} - } -} diff --git a/app/server/headscale/api/endpoints/api-keys.ts b/app/server/headscale/api/endpoints/api-keys.ts new file mode 100644 index 0000000..3da564d --- /dev/null +++ b/app/server/headscale/api/endpoints/api-keys.ts @@ -0,0 +1,27 @@ +import type { Key } from '~/types'; +import { defineApiEndpoints } from '../factory'; + +export interface ApiKeyEndpoints { + /** + * Retrieves all API keys from the Headscale instance. + * + * @returns An array of `Key` objects representing the API keys. + */ + getApiKeys(): Promise; +} + +export default defineApiEndpoints((client, apiKey) => ({ + getApiKeys: async () => { + if (client.isAtleast('0.27.0')) { + console.log('wow we are at least 0.27.0!'); + } + + const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>( + 'GET', + 'v1/apikey', + apiKey, + ); + + return apiKeys; + }, +})); diff --git a/app/server/headscale/api/endpoints/index.ts b/app/server/headscale/api/endpoints/index.ts new file mode 100644 index 0000000..fdd4c0f --- /dev/null +++ b/app/server/headscale/api/endpoints/index.ts @@ -0,0 +1,48 @@ +import { + composeEndpoints, + type ExtractApiEndpoints, + type UnionToIntersection, +} from '../factory'; +import type { HeadscaleApiInterface } from '../index'; +import apiKeyEndpoints from './api-keys'; +import nodeEndpoints from './nodes'; +import policyEndpoints from './policy'; +import preAuthKeyEndpoints from './pre-auth-keys'; +import userEndpoints from './users'; + +/** + * A constant list of all endpoint groups. + * Add new endpoint groups here. + */ +export const endpointSets = [ + apiKeyEndpoints, + nodeEndpoints, + policyEndpoints, + preAuthKeyEndpoints, + userEndpoints, +] as const; + +/** + * All of the available API methods when interacting with Headscale's API. + * We have wrapped each operation with nice methods and parameters to make it + * easier to do integration testing by spinning up an actual Headscale instance + * and calling these methods against it. + * + * We also have the benefit of supporting multiple Headscale versions by + * passing in different internal implementations based on the OpenAPI spec. + */ +export type RuntimeApiClient = UnionToIntersection< + ExtractApiEndpoints<(typeof endpointSets)[number]> +>; + +/** + * Composes all endpoint groups into a single runtime API client. + * + * @param client - The client helpers for making API requests. + * @param apiKey - The API key for authentication. + * @returns A fully composed runtime API client. + */ +export default ( + client: HeadscaleApiInterface['clientHelpers'], + apiKey: string, +) => composeEndpoints(endpointSets, client, apiKey); diff --git a/app/server/headscale/api/endpoints/nodes.ts b/app/server/headscale/api/endpoints/nodes.ts new file mode 100644 index 0000000..6be7db7 --- /dev/null +++ b/app/server/headscale/api/endpoints/nodes.ts @@ -0,0 +1,150 @@ +import type { Machine } from '~/types'; +import { defineApiEndpoints } from '../factory'; + +export interface NodeEndpoints { + /** + * Retrieves all nodes (machines) from the Headscale instance. + * + * @returns An array of `Machine` objects representing the nodes. + */ + getNodes(): Promise; + + /** + * Retrieves a specific node (machine) by its ID. + * + * @param id The ID of the node to retrieve. + * @returns A `Machine` object representing the node. + */ + getNode(id: string): Promise; + + /** + * Deletes a specific node (machine) by its ID. + * + * @param id The ID of the node to delete. + */ + deleteNode(id: string): Promise; + + /** + * Registers a new node (machine) with the given user and key. + * + * @param user The user to associate with the node. + * @param key The registration key for the node. + * @returns A `Machine` object representing the newly registered node. + */ + registerNode(user: string, key: string): Promise; + + /** + * Approves routes for a specific node (machine) by its ID. + * + * @param id The ID of the node. + * @param routes An array of routes to approve for the node. + */ + approveNodeRoutes(id: string, routes: string[]): Promise; + + /** + * Expires a specific node (machine) by its ID. + * + * @param id The ID of the node to expire. + */ + expireNode(id: string): Promise; + + /** + * Renames a specific node (machine) by its ID. + * + * @param id The ID of the node to rename. + * @param newName The new name for the node. + */ + renameNode(id: string, newName: string): Promise; + + /** + * Sets tags for a specific node (machine) by its ID. + * + * @param id The ID of the node. + * @param tags An array of tags to set for the node. + */ + setNodeTags(id: string, tags: string[]): Promise; + + /** + * Sets the user for a specific node (machine) by its ID. + * + * @param id The ID of the node. + * @param user The user to set for the node. + */ + setNodeUser(id: string, user: string): Promise; +} + +export default defineApiEndpoints((client, apiKey) => ({ + getNodes: async () => { + const { nodes } = await client.apiFetch<{ nodes: Machine[] }>( + 'GET', + 'v1/node', + apiKey, + ); + + return nodes; + }, + + getNode: async (nodeId) => { + const { node } = await client.apiFetch<{ node: Machine }>( + 'GET', + `v1/node/${nodeId}`, + apiKey, + ); + + return node; + }, + + deleteNode: async (nodeId) => { + await client.apiFetch('DELETE', `v1/node/${nodeId}`, apiKey); + }, + + registerNode: async (user, key) => { + const qp = new URLSearchParams(); + qp.append('user', user); + qp.append('key', key); + const { node } = await client.apiFetch<{ node: Machine }>( + 'POST', + `v1/node/register?${qp.toString()}`, + apiKey, + { + user, + key, + }, + ); + + return node; + }, + + approveNodeRoutes: async (nodeId, routes) => { + await client.apiFetch( + 'POST', + `v1/node/${nodeId}/approve_routes`, + apiKey, + { routes }, + ); + }, + + expireNode: async (nodeId) => { + await client.apiFetch('POST', `v1/node/${nodeId}/expire`, apiKey); + }, + + renameNode: async (nodeId, newName) => { + await client.apiFetch( + 'POST', + `v1/node/${nodeId}/rename/${newName}`, + apiKey, + ); + }, + + setNodeTags: async (nodeId, tags) => { + await client.apiFetch('POST', `v1/node/${nodeId}/tags`, apiKey, { + tags, + }); + }, + + setNodeUser: async (nodeId, user) => { + await client.apiFetch('POST', `v1/node/${nodeId}/user`, apiKey, { + user, + }); + }, +})); diff --git a/app/server/headscale/api/endpoints/policy.ts b/app/server/headscale/api/endpoints/policy.ts new file mode 100644 index 0000000..75d066f --- /dev/null +++ b/app/server/headscale/api/endpoints/policy.ts @@ -0,0 +1,41 @@ +import { defineApiEndpoints } from '../factory'; + +export interface PolicyEndpoints { + /** + * Retrieves the current ACL policy from the Headscale instance. + * + * @returns The ACL policy as a string. + */ + getPolicy(): Promise; + + /** + * Sets the ACL policy for the Headscale instance. + * + * @param policy The ACL policy as a string. + * @returns The expiration date of the new policy. + */ + setPolicy(policy: string): Promise; +} + +export default defineApiEndpoints((client, apiKey) => ({ + getPolicy: async () => { + const { policy } = await client.apiFetch<{ policy: string }>( + 'GET', + 'v1/policy', + apiKey, + ); + + return policy; + }, + + setPolicy: async (policy) => { + const { updatedAt } = await client.apiFetch<{ updatedAt: string }>( + 'PUT', + 'v1/policy', + apiKey, + { policy }, + ); + + return new Date(updatedAt); + }, +})); diff --git a/app/server/headscale/api/endpoints/pre-auth-keys.ts b/app/server/headscale/api/endpoints/pre-auth-keys.ts new file mode 100644 index 0000000..8c71e28 --- /dev/null +++ b/app/server/headscale/api/endpoints/pre-auth-keys.ts @@ -0,0 +1,69 @@ +import type { PreAuthKey } from '~/types'; +import { defineApiEndpoints } from '../factory'; + +export interface PreAuthKeyEndpoints { + /** + * Retrieves all pre-authentication keys for a specific user. + * + * @param user The user to retrieve pre-authentication keys for. + * @returns An array of `PreAuthKey` objects representing the pre-authentication keys. + */ + getPreAuthKeys(user: string): Promise; + + /** + * Creates a new pre-authentication key for a specific user. + * + * @param user The user to create the pre-authentication key for. + * @param ephemeral Whether the key is ephemeral. + * @param uses The number of uses for the key. + * @param expiration The expiration date of the key, or `null` for no expiration. + * @param aclTags An array of ACL tags to associate with the key, or `null` for none. + * @returns A `PreAuthKey` object representing the newly created pre-authentication key. + */ + createPreAuthKey( + user: string, + ephemeral: boolean, + uses: number, + expiration: Date | null, + aclTags: string[] | null, + ): Promise; + + /** + * Expires a specific pre-authentication key for a user. + * + * @param user The user associated with the pre-authentication key. + * @param key The pre-authentication key to expire. + */ + expirePreAuthKey(user: string, key: string): Promise; +} + +export default defineApiEndpoints((client, apiKey) => ({ + getPreAuthKeys: async (user) => { + const { preAuthKeys } = await client.apiFetch<{ + preAuthKeys: PreAuthKey[]; + }>('GET', 'v1/preauthkey', apiKey, { user }); + + return preAuthKeys; + }, + + createPreAuthKey: async (user, ephemeral, uses, expiration, aclTags) => { + const { preAuthKey } = await client.apiFetch<{ + preAuthKey: PreAuthKey; + }>('POST', 'v1/preauthkey', apiKey, { + user, + ephemeral, + uses, + expiration: expiration ? expiration.toISOString() : null, + aclTags, + }); + + return preAuthKey; + }, + + expirePreAuthKey: async (user, key) => { + await client.apiFetch('POST', 'v1/preauthkey/expire', apiKey, { + user, + key, + }); + }, +})); diff --git a/app/server/headscale/api/endpoints/users.ts b/app/server/headscale/api/endpoints/users.ts new file mode 100644 index 0000000..8da52ca --- /dev/null +++ b/app/server/headscale/api/endpoints/users.ts @@ -0,0 +1,88 @@ +import type { User } from '~/types'; +import { defineApiEndpoints } from '../factory'; + +export interface UserEndpoints { + /** + * Retrieves users from the Headscale instance, optionally filtering by ID, name, or email. + * + * @param id Optional ID of the user to retrieve. + * @param name Optional name of the user to retrieve. + * @param email Optional email of the user to retrieve. + * @returns An array of `User` objects representing the users. + */ + getUsers(id?: string, name?: string, email?: string): Promise; + + /** + * Creates a new user in the Headscale instance. + * + * @param username The username of the new user. + * @param email Optional email of the new user. + * @param displayName Optional display name of the new user. + * @param pictureUrl Optional picture URL of the new user. + * @returns A `User` object representing the newly created user. + */ + createUser( + username: string, + email?: string, + displayName?: string, + pictureUrl?: string, + ): Promise; + + /** + * Deletes a specific user by its ID. + * + * @param id The ID of the user to delete. + */ + deleteUser(id: string): Promise; + + /** + * Renames a specific user by its ID. + * + * @param id The ID of the user to rename. + * @param newName The new name for the user. + */ + renameUser(id: string, newName: string): Promise; +} + +export default defineApiEndpoints((client, apiKey) => ({ + getUsers: async (id, name, email) => { + const moreThanOneFilter = + [id, name, email].filter((v) => v !== undefined).length > 1; + + if (moreThanOneFilter) { + throw new Error('Only one of id, name, or email filters can be provided'); + } + + const { users } = await client.apiFetch<{ users: User[] }>( + 'GET', + 'v1/user', + apiKey, + { id, name, email }, + ); + + return users; + }, + + createUser: async (username, email, displayName, pictureUrl) => { + const { user } = await client.apiFetch<{ user: User }>( + 'POST', + 'v1/user', + apiKey, + { name: username, email, displayName, pictureUrl }, + ); + + return user; + }, + + deleteUser: async (id) => { + await client.apiFetch('DELETE', `v1/user/${id}`, apiKey); + }, + + renameUser: async (oldId, newName) => { + await client.apiFetch( + 'POST', + `v1/user/${oldId}/rename/${newName}`, + apiKey, + ); + }, +})); diff --git a/app/server/headscale/api/error.ts b/app/server/headscale/api/error.ts new file mode 100644 index 0000000..af62288 --- /dev/null +++ b/app/server/headscale/api/error.ts @@ -0,0 +1,143 @@ +import { data } from 'react-router'; +import { errors } from 'undici'; + +// Represents an error that occurred during a response +// Thrown when status codes are >= 400 +export default class ResponseError extends Error { + status: number; + response: string; + responseObject?: Record; + + constructor(status: number, response: string, requestUrl: string) { + super(`${requestUrl}: status ${status} - ${response}`); + this.name = 'ResponseError'; + this.status = status; + this.response = response; + + try { + // Try to parse the response as JSON to get a response object + this.responseObject = JSON.parse(response); + } catch {} + } +} + +function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException { + if (typeof error !== 'object' || error === null) { + return false; + } + + const keys = Object.keys(error as Record); + return keys.includes('code') && keys.includes('errno'); +} + +export function friendlyError(givenError: unknown) { + let error: unknown = givenError; + if (error instanceof AggregateError) { + error = error.errors[0]; + } + + switch (true) { + case error instanceof errors.BodyTimeoutError: + case error instanceof errors.ConnectTimeoutError: + case error instanceof errors.HeadersTimeoutError: + return data('Timed out waiting for a response from the Headscale API', { + statusText: 'Request Timeout', + status: 408, + }); + + case error instanceof errors.SocketError: + case error instanceof errors.SecureProxyConnectionError: + case error instanceof errors.ClientClosedError: + case error instanceof errors.ClientDestroyedError: + case error instanceof errors.RequestAbortedError: + return data('The Headscale API is not reachable', { + statusText: 'Service Unavailable', + status: 503, + }); + + case error instanceof errors.InvalidArgumentError: + case error instanceof errors.InvalidReturnValueError: + case error instanceof errors.NotSupportedError: + return data('Unable to make a request (this is most likely a bug)', { + statusText: 'Internal Server Error', + status: 500, + }); + + case error instanceof errors.HeadersOverflowError: + case error instanceof errors.RequestContentLengthMismatchError: + case error instanceof errors.ResponseContentLengthMismatchError: + case error instanceof errors.ResponseExceededMaxSizeError: + return data('The Headscale API returned a malformed response', { + statusText: 'Bad Gateway', + status: 502, + }); + + case isNodeNetworkError(error): + if (error.code === 'ECONNREFUSED') { + return data('The Headscale API is not reachable', { + statusText: 'Service Unavailable', + status: 503, + }); + } + + if (error.code === 'ENOTFOUND') { + return data('The Headscale API is not reachable', { + statusText: 'Service Unavailable', + status: 503, + }); + } + + if (error.code === 'EAI_AGAIN') { + return data('The Headscale API is not reachable', { + statusText: 'Service Unavailable', + status: 503, + }); + } + + if (error.code === 'ETIMEDOUT') { + return data('Timed out waiting for a response from the Headscale API', { + statusText: 'Request Timeout', + status: 408, + }); + } + + if (error.code === 'ECONNRESET') { + return data('The Headscale API is not reachable', { + statusText: 'Service Unavailable', + status: 503, + }); + } + + if (error.code === 'EPIPE') { + return data('The Headscale API is not reachable', { + statusText: 'Service Unavailable', + status: 503, + }); + } + + if (error.code === 'ENETUNREACH') { + return data('The Headscale API is not reachable', { + statusText: 'Service Unavailable', + status: 503, + }); + } + + if (error.code === 'ENETRESET') { + return data('The Headscale API is not reachable', { + statusText: 'Service Unavailable', + status: 503, + }); + } + + return data('The Headscale API is not reachable', { + statusText: 'Service Unavailable', + status: 503, + }); + + default: + return data((error as Error).message ?? 'An unknown error occurred', { + statusText: 'Internal Server Error', + status: 500, + }); + } +} diff --git a/app/server/headscale/api/factory.ts b/app/server/headscale/api/factory.ts new file mode 100644 index 0000000..1bba6f4 --- /dev/null +++ b/app/server/headscale/api/factory.ts @@ -0,0 +1,45 @@ +import { HeadscaleApiInterface } from '../api'; + +/** + * Creates a strongly-typed group factory for a given endpoint interface. + * + * Example: + * export const apiKeyGroup = defineGroup({...}) + */ + +export interface EndpointFactory { + __type?: T; + (client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T; +} + +export function defineApiEndpoints( + factories: ( + client: HeadscaleApiInterface['clientHelpers'], + apiKey: string, + ) => T, +): EndpointFactory { + return factories; +} + +export type ExtractApiEndpoints = F extends EndpointFactory + ? T + : never; +export type UnionToIntersection = ( + U extends any + ? (k: U) => void + : never +) extends (k: infer I) => void + ? I + : never; + +/** + * Compose multiple endpoint sets into a single typed runtime client + */ +export function composeEndpoints[]>( + factories: T, + clientHelpers: any, + apiKey: string, +): UnionToIntersection> { + const instances = factories.map((f) => f(clientHelpers, apiKey)); + return Object.assign({}, ...instances) as any; +} diff --git a/app/server/headscale/api/hasher.ts b/app/server/headscale/api/hasher.ts new file mode 100644 index 0000000..5c553fe --- /dev/null +++ b/app/server/headscale/api/hasher.ts @@ -0,0 +1,52 @@ +import { createHash } from 'node:crypto'; +import { dereference } from '@readme/openapi-parser'; +import { OpenAPIV2 } from 'openapi-types'; + +/** + * A map of operation IDs to their hashes. + */ +export interface DocumentHash { + [operationId: string]: string; +} + +/** + * Given an OpenAPI v2 document, generate a map of operations with hashes. + * This gives us deterministic identifers to determine the version of Headscale + * that is being used at runtime. + * + * @param doc The OpenAPI v2 document to hash. + * @returns A map of operation IDs to their hashes. + */ +export async function hashOpenApiDocument( + doc: OpenAPIV2.Document, +): Promise { + const spec = await dereference(doc); + const hashes: DocumentHash = {}; + const seen = new Set(); + + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(item)) { + if (typeof operation !== 'object') { + continue; + } + + const { parameters, responses } = operation as OpenAPIV2.OperationObject; + const raw = JSON.stringify( + { + path, + method: method.toUpperCase(), + parameters, + responses, + }, + Object.keys({ path, method, parameters, responses }).sort(), + ); + + const hash = createHash('md5').update(raw).digest('hex').slice(0, 16); + const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; + seen.add(final); + hashes[`${method.toUpperCase()} ${path}`] = final; + } + } + + return hashes; +} diff --git a/app/server/headscale/api/index.ts b/app/server/headscale/api/index.ts new file mode 100644 index 0000000..d8a7547 --- /dev/null +++ b/app/server/headscale/api/index.ts @@ -0,0 +1,317 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { dereference } from '@readme/openapi-parser'; +import type { OpenAPIV2 } from 'openapi-types'; +import { Agent, type Dispatcher, request } from 'undici'; +import type { Key, Machine, PreAuthKey, User } from '~/types'; +import log from '~/utils/log'; +import endpointSets, { RuntimeApiClient } from './endpoints'; +import ResponseError, { friendlyError } from './error'; +import { detectApiVersion, isAtLeast, type Version } from './version'; + +export type RuntimeApiClient2 = {}; + +/** + * A low-level composed interface for interacting with the Headscale API. + * This interface provides direct access to the underlying Undici agent + * and methods for making API requests. + * + * It is also responsible for handling OpenAPI spec polling and hashing to + * determine the implementations of API methods when requested for use. + */ +export interface HeadscaleApiInterface { + /** + * The underlying Undici agent used for making requests. + */ + undiciAgent: Agent; + + /** + * The base URL of the Headscale API. + */ + baseUrl: string; + + /** + * The OpenAPI hashes retrieved from the Headscale instance at runtime. + * This is used to determine which implementations of API methods to use. + */ + openapiHashes: Record | null; + + /** + * The detected API version of the connected Headscale instance. + */ + apiVersion: Version; + + /** + * Retrieves a runtime API client for the given API key. + * + * @param apiKey The API key to use for authentication. + * @returns A `RuntimeApiClient` instance for interacting with the API. + */ + getRuntimeClient(apiKey: string): RuntimeApiClient; + + /** + * A set of helper methods made available to API method implementations. + * The idea is to make interacting with the API easier by providing + * common functionality that can be reused across multiple methods. + */ + clientHelpers: { + /** + * Checks if the connected Headscale instance's API version + * is at least the specified version. + * + * @param version The version to check against. + * @returns `true` if the API version is at least the specified version, `false` otherwise. + */ + isAtleast(version: Version): boolean; + + /** + * Makes a raw fetch request to the Headscale API via the Undici agent. + * This method is used internally by API method implementations + * to make requests to the Headscale API. + * + * @param path The API path to request. + * @param options Optional request options. + * @returns A promise that resolves to the response data. + */ + rawFetch( + path: string, + options?: Partial, + ): Promise; + + /** + * Makes a typed API fetch request to the Headscale API. + * This method is used internally by API method implementations + * to make requests to the Headscale API and parse the response. + * + * @param method The HTTP method to use. + * @param apiPath The API path to request. + * @param apiKey The API key to use for authentication. + * @param bodyOrQuery Optional body or query parameters. + * @returns A promise that resolves to the typed response data. + */ + apiFetch( + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + apiPath: `v1/${string}`, + apiKey: string, + bodyOrQuery?: Record, + ): Promise; + }; +} + +/** + * Creates a new Headscale API client interface. + * + * @param baseUrl The base URL of the Headscale API. + * @param certPath Optional path to a custom TLS certificate for secure connections. + * @returns A promise that resolves to a `HeadscaleApiClient` instance. + */ +export async function createHeadscaleInterface( + baseUrl: string, + certPath?: string, +): Promise { + const undiciAgent = await createUndiciAgent(certPath); + let openapiHashes: Record | null = null; + let apiVersion: Version; + + const rawFetch = async ( + url: string, + options?: Partial, + ): Promise => { + const method = options?.method ?? 'GET'; + log.debug('api', '%s %s', method, url); + + try { + const res = await request(new URL(url, baseUrl), { + dispatcher: undiciAgent, + headers: { + ...options?.headers, + Accept: 'application/json', + 'User-Agent': `Headplane/${__VERSION__}`, + }, + + body: options?.body, + method, + }); + + return res; + } catch (error) { + throw friendlyError(error); + } + }; + + const apiFetch = async ( + method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH', + apiPath: `v1/${string}`, + apiKey: string, + bodyOrQuery?: Record, + ): Promise => { + let url = `/api/${apiPath}`; + const options: Partial = { + method: method, + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }; + + if (bodyOrQuery) { + if (method === 'GET' || method === 'DELETE') { + // Filter out undefined values + + const params = new URLSearchParams(); + for (const [key, value] of Object.entries(bodyOrQuery)) { + if (value !== undefined) { + params.append(key, String(value)); + } + } + + if ([...params.keys()].length > 0) { + url += `?${params.toString()}`; + } + } else { + options.body = JSON.stringify(bodyOrQuery); + options.headers = { + ...options.headers, + 'Content-Type': 'application/json', + }; + } + } + + const res = await rawFetch(url, options); + if (res.statusCode >= 400) { + log.debug( + 'api', + '%s %s failed with status %d', + method, + apiPath, + res.statusCode, + ); + throw new ResponseError( + res.statusCode, + await res.body.text(), + `${method} ${apiPath}`, + ); + } + + return res.body.json() as Promise; + }; + + /** + * Polls the OpenAPI spec endpoint and generates operation hashes. + * This is used to determine which implementations of API methods to use. + * + * @returns A promise that resolves to the OpenAPI operation hashes. + */ + async function fetchAndHashOpenapi(): Promise | null> { + try { + const res = await rawFetch('/swagger/v1/openapiv2.json'); + if (res.statusCode !== 200) { + log.error('api', 'Failed to fetch OpenAPI spec: %d', res.statusCode); + return null; + } + + const body = await res.body.json(); + const spec = await dereference(body as OpenAPIV2.Document); + const hashes = generateSpecHashes(spec); + log.debug( + 'api', + 'OpenAPI hashes updated (%d endpoints)', + Object.keys(hashes).length, + ); + return hashes; + } catch (err) { + log.warn('api', 'Error during OpenAPI polling: %o', err); + return null; + } + } + + const isAtleast = (version: Version): boolean => { + return isAtLeast(apiVersion, version); + }; + + openapiHashes = await fetchAndHashOpenapi(); + apiVersion = detectApiVersion(openapiHashes); + + setInterval(async () => { + const hashes = await fetchAndHashOpenapi(); + if (hashes) { + openapiHashes = hashes; + apiVersion = detectApiVersion(openapiHashes); + } + }, 60_000); // every 60 seconds + + return { + undiciAgent, + baseUrl, + openapiHashes, + apiVersion, + getRuntimeClient: (apiKey: string) => + endpointSets( + { + rawFetch, + apiFetch, + isAtleast, + }, + apiKey, + ), + clientHelpers: { + rawFetch, + apiFetch, + isAtleast, + }, + }; +} + +/** + * Creates a new Undici agent for making HTTP requests. + * + * @param certPath Optional path to a custom TLS certificate for secure connections. + * @returns A promise that resolves to an `Agent` instance. + */ +async function createUndiciAgent(certPath?: string): Promise { + if (!certPath) { + return new Agent(); + } + + try { + log.debug('config', 'Loading certificate from %s', certPath); + const data = await readFile(certPath, 'utf8'); + + log.info('config', 'Using certificate from %s', certPath); + return new Agent({ connect: { ca: data.trim() } }); + } catch (error) { + log.error('config', 'Failed to load Headscale TLS cert: %s', error); + log.debug('config', 'Error Details: %o', error); + return new Agent(); + } +} + +function generateSpecHashes(spec: OpenAPIV2.Document) { + const hashes: Record = {}; + const seen = new Set(); + + for (const [path, item] of Object.entries(spec.paths)) { + for (const [method, operation] of Object.entries(item)) { + if (typeof operation !== 'object') { + continue; + } + + const { parameters, responses } = operation as OpenAPIV2.OperationObject; + const raw = JSON.stringify( + { + path, + method: method.toUpperCase(), + parameters, + responses, + }, + Object.keys({ path, method, parameters, responses }).sort(), + ); + + const hash = createHash('md5').update(raw).digest('hex').slice(0, 16); + const final = seen.has(hash) ? `${hash}_${seen.size}` : hash; + seen.add(final); + hashes[`${method.toUpperCase()} ${path}`] = final; + } + } + + return hashes; +} diff --git a/app/server/headscale/api/version.ts b/app/server/headscale/api/version.ts new file mode 100644 index 0000000..9f45bc5 --- /dev/null +++ b/app/server/headscale/api/version.ts @@ -0,0 +1,79 @@ +import hashes from '~/openapi-operation-hashes.json'; +import log from '~/utils/log'; + +/** + * The known API versions based on operation hashes. + */ +export type Version = keyof typeof hashes; +const VERSIONS = Object.keys(hashes) as Version[]; + +/** + * Detects the closest matching API version using operation hashes. + * Falls back to the latest known version and emits a warning if unknown. + * + * @param observed - A mapping of operation identifiers to their hashes. + * @returns The detected API version. + */ +export function detectApiVersion( + observed: Record | null, +): Version { + if (!observed) { + const latest = VERSIONS.at(-1)!; + log.warn( + 'api', + 'No operation hashes observed, defaulting to version %s', + latest, + ); + return latest; + } + + let bestVersion: Version | null = null; + let bestScore = -1; + + for (const [version, known] of Object.entries(hashes) as [ + Version, + Record, + ][]) { + let score = 0; + for (const [op, hash] of Object.entries(observed)) { + if (known[op] === hash) score++; + } + if (score > bestScore) { + bestVersion = version; + bestScore = score; + } + } + + if (!bestVersion || bestScore === 0) { + const latest = VERSIONS.at(-1)!; + log.warn( + 'api', + 'Could not determine API version, defaulting to %s', + latest, + ); + return latest; + } + + if (bestScore < Object.keys(observed).length) { + log.warn( + 'api', + 'Partial version match: %d/%d endpoints for version %s', + bestScore, + Object.keys(observed).length, + bestVersion, + ); + } + + return bestVersion; +} + +/** + * Checks if the current version is at least the baseline version. + * + * @param current - The current API version. + * @param baseline - The baseline API version to compare against. + * @returns True if current is at least baseline, false otherwise. + */ +export function isAtLeast(current: Version, baseline: Version) { + return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline); +} diff --git a/app/server/index.ts b/app/server/index.ts index cc681ec..3d33336 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -6,6 +6,7 @@ import { configureConfig, configureLogger, envVariables } from './config/env'; import { loadIntegration } from './config/integration'; import { loadConfig } from './config/loader'; import { createDbClient } from './db/client.server'; +import { createHeadscaleInterface } from './headscale/api'; import { createApiClient } from './headscale/api-client'; import { loadHeadscaleConfig } from './headscale/config-loader'; import { createHeadplaneAgent } from './hp-agent'; @@ -67,6 +68,11 @@ const appLoadContext = { }, }), + hsApi: await createHeadscaleInterface( + config.headscale.url, + config.headscale.tls_cert_path, + ), + client: await createApiClient( config.headscale.url, config.headscale.tls_cert_path, diff --git a/compose.yaml b/compose.yaml index b20d969..85605b7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -24,7 +24,7 @@ services: - "./test/caddy/config:/config" - "./test/caddy/certs:/certs" headscale: - image: "headscale/headscale:0.26.0-debug" + image: "headscale/headscale:0.27.0-debug" container_name: "headscale" labels: me.tale.headplane.target: headscale diff --git a/mise.toml b/mise.toml index e58ca4f..e927b85 100644 --- a/mise.toml +++ b/mise.toml @@ -1,7 +1,7 @@ [tools] go = "1.25.1" pnpm = "10.4.0" -node = "22.16" +node = "22.18" [tasks.build-nixos-docs] alias = ["nixos-docs"] diff --git a/package.json b/package.json index 9b2de49..824a478 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@react-router/dev": "^7.8.1", "@react-router/node": "^7.8.1", "@react-stately/toast": "3.1.2", + "@readme/openapi-parser": "^5.2.0", "@shopify/lang-jsonc": "^1.0.1", "@tailwindcss/vite": "^4.1.12", "@types/node": "^24.3.0", @@ -57,6 +58,7 @@ "lefthook": "^1.12.3", "lucide-react": "^0.540.0", "mime": "^4.0.7", + "openapi-types": "^12.1.3", "openid-client": "6.7.0", "postcss": "^8.5.6", "react": "19.2.0", @@ -74,6 +76,7 @@ "tailwindcss": "^4.1.12", "tailwindcss-animate": "^1.0.7", "tailwindcss-react-aria-components": "^2.0.0", + "tsx": "^4.20.6", "typescript": "^5.9.2", "ulidx": "2.4.1", "undici": "7.14.0", @@ -86,7 +89,7 @@ }, "packageManager": "pnpm@10.4.0", "engines": { - "node": ">=22.16 <23", + "node": ">=22.18 <23", "pnpm": ">=10.4 <11" }, "pnpm": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index abdb4ed..3064637 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,19 +49,22 @@ importers: version: 3.0.8(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@react-router/dev': specifier: ^7.8.1 - version: 7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1) + version: 7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) '@react-router/node': specifier: ^7.8.1 version: 7.8.1(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2) '@react-stately/toast': specifier: 3.1.2 version: 3.1.2(react@19.2.0) + '@readme/openapi-parser': + specifier: ^5.2.0 + version: 5.2.0(openapi-types@12.1.3) '@shopify/lang-jsonc': specifier: ^1.0.1 version: 1.0.1 '@tailwindcss/vite': specifier: ^4.1.12 - version: 4.1.12(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1)) + version: 4.1.12(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1)) '@types/node': specifier: ^24.3.0 version: 24.3.0 @@ -131,6 +134,9 @@ importers: mime: specifier: ^4.0.7 version: 4.0.7 + openapi-types: + specifier: ^12.1.3 + version: 12.1.3 openid-client: specifier: 6.7.0 version: 6.7.0 @@ -160,7 +166,7 @@ importers: version: 7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router-hono-server: specifier: 2.21.0 - version: 2.21.0(patch_hash=b68723a36649e2c3bd9e9edcfff3a8bc0fdb48e1cf6d64eba37c04f7bfbb1417)(@hono/node-server@1.19.0(hono@4.7.10))(@react-router/dev@7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1))(@types/react@19.2.2)(bufferutil@4.0.9)(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(utf-8-validate@5.0.10) + version: 2.21.0(patch_hash=b68723a36649e2c3bd9e9edcfff3a8bc0fdb48e1cf6d64eba37c04f7bfbb1417)(@hono/node-server@1.19.0(hono@4.7.10))(@react-router/dev@7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1))(@types/react@19.2.2)(bufferutil@4.0.9)(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(utf-8-validate@5.0.10) react-scan: specifier: ^0.4.3 version: 0.4.3(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react-router-dom@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rollup@4.46.3) @@ -182,6 +188,9 @@ importers: tailwindcss-react-aria-components: specifier: ^2.0.0 version: 2.0.0(tailwindcss@4.1.12) + tsx: + specifier: ^4.20.6 + version: 4.20.6 typescript: specifier: ^5.9.2 version: 5.9.2 @@ -196,16 +205,16 @@ importers: version: 3.1.1(react@19.2.0) vite: specifier: npm:rolldown-vite@7.1.4 - version: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + version: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(typescript@5.9.2) + version: 5.1.4(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(typescript@5.9.2) vitepress: specifier: next - version: 2.0.0-alpha.12(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(postcss@8.5.6)(terser@5.39.0)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1) + version: 2.0.0-alpha.12(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(postcss@8.5.6)(terser@5.39.0)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) vitest: specifier: ^3.1.3 - version: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + version: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) yaml: specifier: 2.8.1 version: 2.8.1 @@ -216,6 +225,12 @@ packages: resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} engines: {node: '>=6.0.0'} + '@apidevtools/json-schema-ref-parser@14.2.1': + resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==} + engines: {node: '>= 20'} + peerDependencies: + '@types/json-schema': ^7.0.15 + '@ark/schema@0.46.0': resolution: {integrity: sha512-c2UQdKgP2eqqDArfBqQIJppxJHvNNXuQPeuSPlDML4rjw+f1cu0qAlzOG4b8ujgm9ctIDWwhpyw6gjG5ledIVQ==} @@ -839,6 +854,10 @@ packages: wrangler: optional: true + '@humanwhocodes/momoa@2.0.4': + resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} + engines: {node: '>=10.10.0'} + '@iconify-json/simple-icons@1.2.48': resolution: {integrity: sha512-EACOtZMoPJtERiAbX1De0asrrCtlwI27+03c9OJlYWsly9w1O5vcD8rTzh+kDPjo+K8FOVnq2Qy+h/CzljSKDA==} @@ -1607,6 +1626,22 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + '@readme/better-ajv-errors@2.4.0': + resolution: {integrity: sha512-9WODaOAKSl/mU+MYNZ2aHCrkoRSvmQ+1YkLj589OEqqjOAhbn8j7Z+ilYoiTu/he6X63/clsxxAB4qny9/dDzg==} + engines: {node: '>=18'} + peerDependencies: + ajv: 4.11.8 - 8 + + '@readme/openapi-parser@5.2.0': + resolution: {integrity: sha512-ye9adgWXtksbitgUHLABpZBP9xSUkxhLaBx2r7xzDXUikgpMk+LF96pmZhQvqD4PVo2tNk7XV+BQ+RYlcUJvkQ==} + engines: {node: '>=20'} + peerDependencies: + openapi-types: '>=7' + + '@readme/openapi-schemas@3.1.0': + resolution: {integrity: sha512-9FC/6ho8uFa8fV50+FPy/ngWN53jaUu4GRXlAjcxIRrzhltJnpKkBG2Tp0IDraFJeWrOpk84RJ9EMEEYzaI1Bw==} + engines: {node: '>=18'} + '@rolldown/binding-android-arm64@1.0.0-beta.33': resolution: {integrity: sha512-xhDQXKftRkEULIxCddrKMR8y0YO/Y+6BKk/XrQP2B29YjV2wr8DByoEz+AHX9BfLHb2srfpdN46UquBW2QXWpQ==} cpu: [arm64] @@ -1933,6 +1968,9 @@ packages: '@types/js-yaml@4.0.9': resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/linkify-it@5.0.0': resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} @@ -2235,6 +2273,17 @@ packages: resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} engines: {node: '>= 14'} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2673,9 +2722,15 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2873,6 +2928,9 @@ packages: resolution: {integrity: sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2883,6 +2941,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + kleur@4.1.5: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} @@ -2944,6 +3006,10 @@ packages: resolution: {integrity: sha512-huMg+mGp6wHPjkaLdchuOvxVRMzvz6OVdhivatiH2Qn47O5Zm46jwzbVPYIanX6N/8ZTjGLBxv8tZ0KYmKt/Jg==} hasBin: true + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + libsql@0.5.17: resolution: {integrity: sha512-RRlj5XQI9+Wq+/5UY8EnugSWfRmHEw4hn3DKlPrkUgZONsge1PwTtHcpStP6MSNi8ohcbsRgEHJaymA33a8cBw==} os: [darwin, linux, win32] @@ -3187,6 +3253,9 @@ packages: oniguruma-to-es@4.3.3: resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openid-client@6.7.0: resolution: {integrity: sha512-mA8Cex9wGK6Jaqq4vd1sPDK/a+cQ6G+lExVJ9IRjwInN4NcEEntYqLAZsBhZOyHwDx7V1nmPKOvnxOcdLtSpoA==} @@ -3434,6 +3503,10 @@ packages: zod: optional: true + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -3724,8 +3797,8 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsx@4.20.4: - resolution: {integrity: sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==} + tsx@4.20.6: + resolution: {integrity: sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==} engines: {node: '>=18.0.0'} hasBin: true @@ -4013,6 +4086,11 @@ snapshots: '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.30 + '@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)': + dependencies: + '@types/json-schema': 7.0.15 + js-yaml: 4.1.0 + '@ark/schema@0.46.0': dependencies: '@ark/util': 0.46.0 @@ -4572,6 +4650,8 @@ snapshots: hono: 4.7.10 minimatch: 9.0.5 + '@humanwhocodes/momoa@2.0.4': {} + '@iconify-json/simple-icons@1.2.48': dependencies: '@iconify/types': 2.0.0 @@ -5414,7 +5494,7 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@react-router/dev@7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1)': + '@react-router/dev@7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1)': dependencies: '@babel/core': 7.28.3 '@babel/generator': 7.28.3 @@ -5425,7 +5505,7 @@ snapshots: '@babel/types': 7.28.2 '@npmcli/package-json': 4.0.1 '@react-router/node': 7.8.1(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(typescript@5.9.2) - '@vitejs/plugin-rsc': 0.4.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1)) + '@vitejs/plugin-rsc': 0.4.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1)) arg: 5.0.2 babel-dead-code-elimination: 1.0.10 chokidar: 4.0.3 @@ -5444,8 +5524,8 @@ snapshots: set-cookie-parser: 2.7.1 tinyglobby: 0.2.14 valibot: 0.41.0(typescript@5.9.2) - vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: @@ -5845,6 +5925,28 @@ snapshots: '@react-types/shared': 3.32.1(react@19.2.0) react: 19.2.0 + '@readme/better-ajv-errors@2.4.0(ajv@8.17.1)': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/runtime': 7.27.3 + '@humanwhocodes/momoa': 2.0.4 + ajv: 8.17.1 + jsonpointer: 5.0.1 + leven: 3.1.0 + picocolors: 1.1.1 + + '@readme/openapi-parser@5.2.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15) + '@readme/better-ajv-errors': 2.4.0(ajv@8.17.1) + '@readme/openapi-schemas': 3.1.0 + '@types/json-schema': 7.0.15 + ajv: 8.17.1 + ajv-draft-04: 1.0.0(ajv@8.17.1) + openapi-types: 12.1.3 + + '@readme/openapi-schemas@3.1.0': {} + '@rolldown/binding-android-arm64@1.0.0-beta.33': optional: true @@ -6072,12 +6174,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.12 '@tailwindcss/oxide-win32-x64-msvc': 4.1.12 - '@tailwindcss/vite@4.1.12(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))': + '@tailwindcss/vite@4.1.12(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@tailwindcss/node': 4.1.12 '@tailwindcss/oxide': 4.1.12 tailwindcss: 4.1.12 - vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) '@tybys/wasm-util@0.10.0': dependencies: @@ -6103,6 +6205,8 @@ snapshots: '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} + '@types/linkify-it@5.0.0': {} '@types/markdown-it@14.1.2': @@ -6239,7 +6343,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-rsc@0.4.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))': + '@vitejs/plugin-rsc@0.4.11(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@mjackson/node-fetch-server': 0.7.0 es-module-lexer: 1.7.0 @@ -6249,13 +6353,13 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) turbo-stream: 3.1.0 - vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) - vitefu: 1.1.1(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1)) + vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) + vitefu: 1.1.1(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1)) - '@vitejs/plugin-vue@6.0.1(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(vue@3.5.18(typescript@5.9.2))': + '@vitejs/plugin-vue@6.0.1(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.18(typescript@5.9.2))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.29 - vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) vue: 3.5.18(typescript@5.9.2) '@vitest/expect@3.2.4': @@ -6266,13 +6370,13 @@ snapshots: chai: 5.2.1 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -6417,6 +6521,17 @@ snapshots: agent-base@7.1.3: {} + ajv-draft-04@1.0.0(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-regex@5.0.1: {} ansi-regex@6.0.1: {} @@ -6784,8 +6899,12 @@ snapshots: expect-type@1.2.2: {} + fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-uri@3.1.0: {} + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -6980,6 +7099,8 @@ snapshots: json-parse-even-better-errors@3.0.2: {} + json-schema-traverse@1.0.0: {} + json5@2.2.3: {} jsonpath-plus@10.3.0: @@ -6988,6 +7109,8 @@ snapshots: '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) jsep: 1.4.0 + jsonpointer@5.0.1: {} + kleur@4.1.5: {} layerr@3.0.0: {} @@ -7035,6 +7158,8 @@ snapshots: lefthook-windows-arm64: 1.12.3 lefthook-windows-x64: 1.12.3 + leven@3.1.0: {} + libsql@0.5.17: dependencies: '@neon-rs/load': 0.0.4 @@ -7255,6 +7380,8 @@ snapshots: regex: 6.0.1 regex-recursion: 6.0.2 + openapi-types@12.1.3: {} + openid-client@6.7.0: dependencies: jose: 6.1.0 @@ -7429,17 +7556,17 @@ snapshots: react-dom: 19.2.0(react@19.2.0) react-router: 7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react-router-hono-server@2.21.0(patch_hash=b68723a36649e2c3bd9e9edcfff3a8bc0fdb48e1cf6d64eba37c04f7bfbb1417)(@hono/node-server@1.19.0(hono@4.7.10))(@react-router/dev@7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1))(@types/react@19.2.2)(bufferutil@4.0.9)(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(utf-8-validate@5.0.10): + react-router-hono-server@2.21.0(patch_hash=b68723a36649e2c3bd9e9edcfff3a8bc0fdb48e1cf6d64eba37c04f7bfbb1417)(@hono/node-server@1.19.0(hono@4.7.10))(@react-router/dev@7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1))(@types/react@19.2.2)(bufferutil@4.0.9)(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(utf-8-validate@5.0.10): dependencies: '@drizzle-team/brocli': 0.11.0 '@hono/node-server': 1.19.0(hono@4.7.10) '@hono/node-ws': 1.2.0(@hono/node-server@1.19.0(hono@4.7.10))(bufferutil@4.0.9)(hono@4.7.10)(utf-8-validate@5.0.10) '@hono/vite-dev-server': 0.20.1(hono@4.7.10) - '@react-router/dev': 7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1) + '@react-router/dev': 7.8.1(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(react-dom@19.2.0(react@19.2.0))(react-router@7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(terser@5.39.0)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1) '@types/react': 19.2.2 hono: 4.7.10 react-router: 7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - bufferutil - utf-8-validate @@ -7472,7 +7599,7 @@ snapshots: preact: 10.27.1 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - tsx: 4.20.4 + tsx: 4.20.6 optionalDependencies: react-router: 7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react-router-dom: 7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -7542,6 +7669,8 @@ snapshots: react: 19.2.0 react-router: 7.8.1(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} retry@0.12.0: {} @@ -7550,7 +7679,7 @@ snapshots: rfdc@1.4.1: {} - rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1): + rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: fdir: 6.5.0(picomatch@4.0.3) lightningcss: 1.30.1 @@ -7564,7 +7693,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.5.1 terser: 5.39.0 - tsx: 4.20.4 + tsx: 4.20.6 yaml: 2.8.1 rolldown@1.0.0-beta.33: @@ -7854,7 +7983,7 @@ snapshots: tslib@2.8.1: {} - tsx@4.20.4: + tsx@4.20.6: dependencies: esbuild: 0.25.9 get-tsconfig: 4.10.1 @@ -7955,13 +8084,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1): + vite-node@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: cac: 6.7.14 debug: 4.4.1 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - '@types/node' - jiti @@ -7976,18 +8105,18 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(typescript@5.9.2): + vite-tsconfig-paths@5.1.4(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(typescript@5.9.2): dependencies: debug: 4.4.1 globrex: 0.1.2 tsconfck: 3.1.4(typescript@5.9.2) optionalDependencies: - vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color - typescript - vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1): + vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -8001,14 +8130,14 @@ snapshots: jiti: 2.5.1 lightningcss: 1.30.1 terser: 5.39.0 - tsx: 4.20.4 + tsx: 4.20.6 yaml: 2.8.1 - vitefu@1.1.1(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1)): + vitefu@1.1.1(rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1)): optionalDependencies: - vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: rolldown-vite@7.1.4(@types/node@24.3.0)(esbuild@0.25.9)(jiti@2.5.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) - vitepress@2.0.0-alpha.12(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(postcss@8.5.6)(terser@5.39.0)(tsx@4.20.4)(typescript@5.9.2)(yaml@2.8.1): + vitepress@2.0.0-alpha.12(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(postcss@8.5.6)(terser@5.39.0)(tsx@4.20.6)(typescript@5.9.2)(yaml@2.8.1): dependencies: '@docsearch/css': 4.0.0-beta.7 '@docsearch/js': 4.0.0-beta.7 @@ -8017,7 +8146,7 @@ snapshots: '@shikijs/transformers': 3.9.2 '@shikijs/types': 3.9.2 '@types/markdown-it': 14.1.2 - '@vitejs/plugin-vue': 6.0.1(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1))(vue@3.5.18(typescript@5.9.2)) + '@vitejs/plugin-vue': 6.0.1(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1))(vue@3.5.18(typescript@5.9.2)) '@vue/devtools-api': 8.0.0 '@vue/shared': 3.5.18 '@vueuse/core': 13.7.0(vue@3.5.18(typescript@5.9.2)) @@ -8026,7 +8155,7 @@ snapshots: mark.js: 8.11.1 minisearch: 7.1.2 shiki: 3.9.2 - vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) vue: 3.5.18(typescript@5.9.2) optionalDependencies: postcss: 8.5.6 @@ -8055,11 +8184,11 @@ snapshots: - universal-cookie - yaml - vitest@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1): + vitest@3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(vite@7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -8077,8 +8206,8 @@ snapshots: tinyglobby: 0.2.14 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) - vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.4)(yaml@2.8.1) + vite: 7.1.2(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.3.0)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.39.0)(tsx@4.20.6)(yaml@2.8.1) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.3.0 diff --git a/tests/generate-openapi-hashes.ts b/tests/generate-openapi-hashes.ts new file mode 100644 index 0000000..1c00e27 --- /dev/null +++ b/tests/generate-openapi-hashes.ts @@ -0,0 +1,42 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { cwd } from 'node:process'; +import type { OpenAPIV2 } from 'openapi-types'; +import { request } from 'undici'; +import { hashOpenApiDocument } from '~/server/headscale/api/hasher'; + +const HASH_FILE_LOCATION = 'app/openapi-operation-hashes.json'; +const SPEC_MAP = { + // '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json', + // '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json', + '0.26.0': '/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json', + '0.26.1': '/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json', + '0.27.0': '/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json', +} as const; + +async function hashOpenApiOperations(specUrl: string) { + const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`; + const res = await request(url); + if (res.statusCode !== 200) { + console.error('Failed to fetch OpenAPI spec:', res.statusCode); + process.exit(1); + } + + const body = (await res.body.json()) as OpenAPIV2.Document; + return hashOpenApiDocument(body); +} + +async function writeHashes(hashes: Record>) { + const path = resolve(cwd(), HASH_FILE_LOCATION); + await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, 'utf-8'); +} + +const newHashes = await Promise.all( + Object.entries(SPEC_MAP).map(async ([version, specUrl]) => { + const hashes = await hashOpenApiOperations(specUrl); + return [version, hashes] as const; + }), +); + +console.log('Writing new OpenAPI operation hashes to file'); +await writeHashes(Object.fromEntries(newHashes));