feat: create type-safe API object for Headscale

This commit is contained in:
Aarnav Tale
2025-11-04 21:05:22 -05:00
parent 1c0561edb8
commit a68aedc297
27 changed files with 1378 additions and 80 deletions
+1 -1
View File
@@ -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';
+2 -1
View File
@@ -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(
+1 -1
View File
@@ -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({
+81
View File
@@ -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"
}
}
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+2 -5
View File
@@ -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()) {
+2 -3
View File
@@ -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<string, unknown>);
-19
View File
@@ -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<string, unknown>;
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 {}
}
}
@@ -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<Key[]>;
}
export default defineApiEndpoints<ApiKeyEndpoints>((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;
},
}));
@@ -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);
+150
View File
@@ -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<Machine[]>;
/**
* 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<Machine>;
/**
* Deletes a specific node (machine) by its ID.
*
* @param id The ID of the node to delete.
*/
deleteNode(id: string): Promise<void>;
/**
* 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<Machine>;
/**
* 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<void>;
/**
* Expires a specific node (machine) by its ID.
*
* @param id The ID of the node to expire.
*/
expireNode(id: string): Promise<void>;
/**
* 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<void>;
/**
* 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<void>;
/**
* 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<void>;
}
export default defineApiEndpoints<NodeEndpoints>((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<void>('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<void>(
'POST',
`v1/node/${nodeId}/approve_routes`,
apiKey,
{ routes },
);
},
expireNode: async (nodeId) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/expire`, apiKey);
},
renameNode: async (nodeId, newName) => {
await client.apiFetch<void>(
'POST',
`v1/node/${nodeId}/rename/${newName}`,
apiKey,
);
},
setNodeTags: async (nodeId, tags) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/tags`, apiKey, {
tags,
});
},
setNodeUser: async (nodeId, user) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/user`, apiKey, {
user,
});
},
}));
@@ -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<string>;
/**
* 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<Date>;
}
export default defineApiEndpoints<PolicyEndpoints>((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);
},
}));
@@ -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<PreAuthKey[]>;
/**
* 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<PreAuthKey>;
/**
* 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<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((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<void>('POST', 'v1/preauthkey/expire', apiKey, {
user,
key,
});
},
}));
@@ -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<User[]>;
/**
* 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<User>;
/**
* Deletes a specific user by its ID.
*
* @param id The ID of the user to delete.
*/
deleteUser(id: string): Promise<void>;
/**
* 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<void>;
}
export default defineApiEndpoints<UserEndpoints>((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<void>('DELETE', `v1/user/${id}`, apiKey);
},
renameUser: async (oldId, newName) => {
await client.apiFetch<void>(
'POST',
`v1/user/${oldId}/rename/${newName}`,
apiKey,
);
},
}));
+143
View File
@@ -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<string, unknown>;
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<string, unknown>);
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,
});
}
}
+45
View File
@@ -0,0 +1,45 @@
import { HeadscaleApiInterface } from '../api';
/**
* Creates a strongly-typed group factory for a given endpoint interface.
*
* Example:
* export const apiKeyGroup = defineGroup<ApiKeyEndpoints>({...})
*/
export interface EndpointFactory<T extends object> {
__type?: T;
(client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T;
}
export function defineApiEndpoints<T extends object>(
factories: (
client: HeadscaleApiInterface['clientHelpers'],
apiKey: string,
) => T,
): EndpointFactory<T> {
return factories;
}
export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T>
? T
: never;
export type UnionToIntersection<U> = (
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<T extends readonly EndpointFactory<any>[]>(
factories: T,
clientHelpers: any,
apiKey: string,
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any;
}
+52
View File
@@ -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<DocumentHash> {
const spec = await dereference(doc);
const hashes: DocumentHash = {};
const seen = new Set<string>();
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;
}
+317
View File
@@ -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<string, string> | 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<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* 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<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
}
/**
* 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<HeadscaleApiInterface> {
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
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 <T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
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<T>;
};
/**
* 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<Record<string, string> | 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<Agent> {
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<string, string> = {};
const seen = new Set<string>();
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;
}
+79
View File
@@ -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<string, string> | 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<string, string>,
][]) {
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);
}
+6
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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"]
+4 -1
View File
@@ -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": {
+173 -44
View File
@@ -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
+42
View File
@@ -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<string, Record<string, string>>) {
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));