fix(oauth): allow generic RFC 7591 DCR + expand install snippets
All checks were successful
Deploy to Production / deploy (push) Successful in 1m28s
All checks were successful
Deploy to Production / deploy (push) Successful in 1m28s
- /oauth/register: drop resource_required check, accept generic registrations (Claude Desktop omits resource in DCR body per spec). serverId stored as NULL; /authorize still enforces org-ownership + access-token aud claim still pinned to resource. Fixes Claude Desktop DCR failure (ofid_d7e39530c109fa7f). - /oauth/authorize: skip strict server.id check when client.serverId is NULL (generic client); org check remains the security boundary. - schema: oauth_clients.server_id no longer NOT NULL. - migration 0002: ALTER COLUMN server_id DROP NOT NULL (already applied on prod). - install-snippets: add Claude Code (CLI), VS Code, Codex, raw URL tabs. Claude Desktop now shows form-field values (Name / Remote MCP Server URL / OAuth Client ID / Secret) matching the new Custom Connector UI instead of the obsolete JSON config. - types: InstallTarget enum extended. - hero-video: clicking the audio toggle restarts the video from frame 0 so unmute aligns with the spoken opening. - marketing: drop em-dashes from rendered copy.
This commit is contained in:
parent
e75f9ad4fe
commit
3a05766f88
@ -103,13 +103,20 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
const parsed = Body.safeParse(req.body);
|
const parsed = Body.safeParse(req.body);
|
||||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
|
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
|
||||||
|
|
||||||
|
// RFC 7591 makes `resource` optional in the registration request body.
|
||||||
|
// Claude Desktop and several other MCP clients perform a generic
|
||||||
|
// registration first and only declare the resource later during the
|
||||||
|
// authorization request (RFC 8707). When a resource is provided we
|
||||||
|
// bind the client to that server; otherwise we accept a generic
|
||||||
|
// registration and let /oauth/authorize enforce the resource → org
|
||||||
|
// check on every authorization. The token endpoint additionally
|
||||||
|
// pins the audience claim to the resource, so a generic client still
|
||||||
|
// can't mint a token usable against a server the user does not own.
|
||||||
let serverId: string | null = null;
|
let serverId: string | null = null;
|
||||||
if (parsed.data.resource) {
|
if (parsed.data.resource) {
|
||||||
const server = await resolveServerByResource(parsed.data.resource);
|
const server = await resolveServerByResource(parsed.data.resource);
|
||||||
if (!server) return reply.code(400).send({ error: 'invalid_resource' });
|
if (!server) return reply.code(400).send({ error: 'invalid_resource' });
|
||||||
serverId = server.id;
|
serverId = server.id;
|
||||||
} else {
|
|
||||||
return reply.code(400).send({ error: 'resource_required' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const clientId = `bmm_${crypto.randomBytes(12).toString('hex')}`;
|
const clientId = `bmm_${crypto.randomBytes(12).toString('hex')}`;
|
||||||
@ -166,7 +173,16 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
if (!redirectOk) return reply.code(400).send({ error: 'invalid_redirect_uri' });
|
if (!redirectOk) return reply.code(400).send({ error: 'invalid_redirect_uri' });
|
||||||
|
|
||||||
const server = await resolveServerByResource(parsed.data.resource);
|
const server = await resolveServerByResource(parsed.data.resource);
|
||||||
if (!server || server.id !== client.serverId) {
|
if (!server) {
|
||||||
|
return reply.code(400).send({ error: 'invalid_resource' });
|
||||||
|
}
|
||||||
|
// Clients that registered against a specific server (`client.serverId`
|
||||||
|
// set) must keep authorizing against the same one. Clients that
|
||||||
|
// registered generically (`client.serverId === null`, e.g. Claude
|
||||||
|
// Desktop after RFC 7591 DCR) can authorize against any server the
|
||||||
|
// logged-in user actually owns — the org check below is the real
|
||||||
|
// boundary.
|
||||||
|
if (client.serverId !== null && server.id !== client.serverId) {
|
||||||
return reply.code(400).send({ error: 'invalid_resource' });
|
return reply.code(400).send({ error: 'invalid_resource' });
|
||||||
}
|
}
|
||||||
if (server.orgId !== user.orgId) {
|
if (server.orgId !== user.orgId) {
|
||||||
|
|||||||
@ -53,7 +53,7 @@ const EXAMPLES: ExampleEntry[] = [
|
|||||||
{ title: 'PostgreSQL', desc: 'Read-only access to your tables with schema introspection.', Icon: PostgresIcon, bg: '#336791', fg: '#ffffff' },
|
{ title: 'PostgreSQL', desc: 'Read-only access to your tables with schema introspection.', Icon: PostgresIcon, bg: '#336791', fg: '#ffffff' },
|
||||||
{ title: 'Salesforce', desc: 'Query opportunities, accounts and leads from Claude.', Icon: SalesforceCloudIcon, bg: '#00a1e0', fg: '#ffffff' },
|
{ title: 'Salesforce', desc: 'Query opportunities, accounts and leads from Claude.', Icon: SalesforceCloudIcon, bg: '#00a1e0', fg: '#ffffff' },
|
||||||
{ title: 'Notion', desc: 'Search pages, read content, append blocks.', Icon: NotionIcon, bg: '#ffffff', fg: '#0a0a0b' },
|
{ title: 'Notion', desc: 'Search pages, read content, append blocks.', Icon: NotionIcon, bg: '#ffffff', fg: '#0a0a0b' },
|
||||||
{ title: 'GitHub', desc: 'List issues, search code, post comments — scoped to one repo.', Icon: GitHubIcon, bg: '#181717', fg: '#ffffff' },
|
{ title: 'GitHub', desc: 'List issues, search code, post comments. Scoped to one repo.', Icon: GitHubIcon, bg: '#181717', fg: '#ffffff' },
|
||||||
{ title: 'Stripe', desc: 'Look up charges, customers, refunds (read-only by default).', Icon: StripeIcon, bg: '#635bff', fg: '#ffffff' },
|
{ title: 'Stripe', desc: 'Look up charges, customers, refunds (read-only by default).', Icon: StripeIcon, bg: '#635bff', fg: '#ffffff' },
|
||||||
{ title: 'Custom REST',desc: 'Wrap any HTTP API behind one prompt-defined tool surface.', Icon: RestIcon, bg: '#6366f1', fg: '#ffffff' },
|
{ title: 'Custom REST',desc: 'Wrap any HTTP API behind one prompt-defined tool surface.', Icon: RestIcon, bg: '#6366f1', fg: '#ffffff' },
|
||||||
];
|
];
|
||||||
@ -90,7 +90,7 @@ const MOCK_TEMPLATES: MockTemplate[] = [
|
|||||||
const MARKETPLACE_POINTS: { t: string; d: string }[] = [
|
const MARKETPLACE_POINTS: { t: string; d: string }[] = [
|
||||||
{
|
{
|
||||||
t: 'Fork and own',
|
t: 'Fork and own',
|
||||||
d: 'Start from a server someone already shipped. Fork it, paste your own credentials, deploy — no prompt required.',
|
d: 'Start from a server someone already shipped. Fork it, paste your own credentials, deploy. No prompt required.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
t: 'Secrets never travel',
|
t: 'Secrets never travel',
|
||||||
@ -98,7 +98,7 @@ const MARKETPLACE_POINTS: { t: string; d: string }[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
t: 'Ranked by real usage',
|
t: 'Ranked by real usage',
|
||||||
d: 'Templates rise on fork count and active deploys — not vanity stars. The useful ones surface themselves.',
|
d: 'Templates rise on fork count and active deploys, not vanity stars. The useful ones surface themselves.',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -160,7 +160,7 @@ export default function Landing() {
|
|||||||
<div className="relative z-10 mx-auto grid w-full max-w-6xl gap-10 px-6 py-14 sm:py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12">
|
<div className="relative z-10 mx-auto grid w-full max-w-6xl gap-10 px-6 py-14 sm:py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<span className="mono inline-block rounded-full border border-[--color-border] bg-[--color-bg-elevated] px-2.5 py-0.5 text-[11px] tracking-wide text-[--color-fg-muted]">
|
<span className="mono inline-block rounded-full border border-[--color-border] bg-[--color-bg-elevated] px-2.5 py-0.5 text-[11px] tracking-wide text-[--color-fg-muted]">
|
||||||
v0.1 — updated 2026-05-20
|
v0.1 · updated 2026-05-20
|
||||||
</span>
|
</span>
|
||||||
<h1 className="mt-6 text-balance text-[30px] font-semibold leading-[1.06] tracking-tight sm:text-[40px] md:text-[52px]">
|
<h1 className="mt-6 text-balance text-[30px] font-semibold leading-[1.06] tracking-tight sm:text-[40px] md:text-[52px]">
|
||||||
Describe your tool.
|
Describe your tool.
|
||||||
@ -337,7 +337,7 @@ export default function Landing() {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<p className="max-w-xs text-[14px] leading-relaxed text-[--color-fg-muted]">
|
<p className="max-w-xs text-[14px] leading-relaxed text-[--color-fg-muted]">
|
||||||
Real integrations our customers ship today — built from one prompt each.
|
Real integrations our customers ship today. Built from one prompt each.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ const COLORS = {
|
|||||||
|
|
||||||
const SNIPPETS: ReadonlyArray<ReadonlyArray<string>> = [
|
const SNIPPETS: ReadonlyArray<ReadonlyArray<string>> = [
|
||||||
[
|
[
|
||||||
'// MCP server — auto-generated',
|
'// MCP server (auto-generated)',
|
||||||
'import { Server } from "@bmm/mcp";',
|
'import { Server } from "@bmm/mcp";',
|
||||||
'',
|
'',
|
||||||
'const server = new Server({',
|
'const server = new Server({',
|
||||||
|
|||||||
@ -85,7 +85,12 @@ export function HeroVideo() {
|
|||||||
const next = !v.muted;
|
const next = !v.muted;
|
||||||
v.muted = next;
|
v.muted = next;
|
||||||
setMuted(next);
|
setMuted(next);
|
||||||
if (!next && v.paused) {
|
// Restart from frame 0 whenever the audio state toggles. Visitors who
|
||||||
|
// unmute want to hear the opening, not whatever moment the video was
|
||||||
|
// already at; visitors who re-mute also expect a clean restart so the
|
||||||
|
// animation lines up with the silent loop again.
|
||||||
|
v.currentTime = 0;
|
||||||
|
if (v.paused) {
|
||||||
v.play().catch(() => undefined);
|
v.play().catch(() => undefined);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
@ -155,7 +160,7 @@ export function HeroVideo() {
|
|||||||
color: 'var(--color-fg-muted)',
|
color: 'var(--color-fg-muted)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
your browser blocked playback — open the video directly
|
your browser blocked playback. open the video directly
|
||||||
<ExternalLink size={12} />
|
<ExternalLink size={12} />
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -8,8 +8,12 @@ import type { InstallTarget } from '@bmm/types';
|
|||||||
|
|
||||||
const TABS: { id: InstallTarget; label: string }[] = [
|
const TABS: { id: InstallTarget; label: string }[] = [
|
||||||
{ id: 'claude-desktop', label: 'Claude Desktop' },
|
{ id: 'claude-desktop', label: 'Claude Desktop' },
|
||||||
|
{ id: 'claude-code', label: 'Claude Code' },
|
||||||
{ id: 'cursor', label: 'Cursor' },
|
{ id: 'cursor', label: 'Cursor' },
|
||||||
|
{ id: 'vscode', label: 'VS Code' },
|
||||||
{ id: 'chatgpt', label: 'ChatGPT' },
|
{ id: 'chatgpt', label: 'ChatGPT' },
|
||||||
|
{ id: 'codex', label: 'Codex' },
|
||||||
|
{ id: 'raw-url', label: 'Other' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function InstallSnippets({ input }: { input: SnippetInput }) {
|
export function InstallSnippets({ input }: { input: SnippetInput }) {
|
||||||
@ -17,7 +21,7 @@ export function InstallSnippets({ input }: { input: SnippetInput }) {
|
|||||||
const snippet = buildSnippet(tab, input);
|
const snippet = buildSnippet(tab, input);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-1 border-b border-[--color-border]">
|
<div className="flex flex-wrap gap-1 border-b border-[--color-border]">
|
||||||
{TABS.map((t) => (
|
{TABS.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.id}
|
key={t.id}
|
||||||
|
|||||||
@ -4,29 +4,69 @@ export interface SnippetInput {
|
|||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
publicUrl: string;
|
publicUrl: string;
|
||||||
|
/**
|
||||||
|
* Optional pre-issued OAuth client credentials. When present, Claude
|
||||||
|
* Desktop / ChatGPT users can paste them into the "OAuth Client ID /
|
||||||
|
* Secret" form fields as a fallback if Dynamic Client Registration
|
||||||
|
* (DCR) is blocked by their network or the client refuses it.
|
||||||
|
*/
|
||||||
|
oauthClientId?: string;
|
||||||
|
oauthClientSecret?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildSnippet(target: InstallTarget, input: SnippetInput): { label: string; code: string; note?: string } {
|
export interface SnippetOutput {
|
||||||
|
label: string;
|
||||||
|
code: string;
|
||||||
|
/** Optional short hint shown below the snippet. */
|
||||||
|
note?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripTrailingSlash = (u: string) => u.replace(/\/$/, '');
|
||||||
|
|
||||||
|
export function buildSnippet(target: InstallTarget, input: SnippetInput): SnippetOutput {
|
||||||
const key = input.slug.replace(/[^a-zA-Z0-9_-]/g, '_');
|
const key = input.slug.replace(/[^a-zA-Z0-9_-]/g, '_');
|
||||||
const mcpUrl = `${input.publicUrl.replace(/\/$/, '')}/mcp`;
|
const baseUrl = stripTrailingSlash(input.publicUrl);
|
||||||
|
const mcpUrl = `${baseUrl}/mcp`;
|
||||||
|
const hasCreds = !!(input.oauthClientId && input.oauthClientSecret);
|
||||||
|
|
||||||
switch (target) {
|
switch (target) {
|
||||||
case 'claude-desktop':
|
case 'claude-desktop': {
|
||||||
|
// Claude Desktop now uses a "Custom Connector" UI with form fields:
|
||||||
|
// Name / Remote MCP Server URL / OAuth Client ID / OAuth Client Secret.
|
||||||
|
// We show the exact values to paste so the JSON-config dance is gone.
|
||||||
|
// OAuth fields are blank by default (DCR registers them automatically)
|
||||||
|
// and only filled when the org has pre-issued credentials as a fallback.
|
||||||
|
const lines = [
|
||||||
|
`Name: ${input.name}`,
|
||||||
|
`Remote MCP Server URL: ${mcpUrl}`,
|
||||||
|
];
|
||||||
|
if (hasCreds) {
|
||||||
|
lines.push(
|
||||||
|
`OAuth Client ID: ${input.oauthClientId}`,
|
||||||
|
`OAuth Client Secret: ${input.oauthClientSecret}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
lines.push(
|
||||||
|
`OAuth Client ID: (leave blank, registers automatically)`,
|
||||||
|
`OAuth Client Secret: (leave blank, registers automatically)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
label: 'claude_desktop_config.json',
|
label: 'Claude Desktop · Custom Connector',
|
||||||
code: JSON.stringify(
|
code: lines.join('\n'),
|
||||||
{
|
note: hasCreds
|
||||||
mcpServers: {
|
? 'Settings → Connectors → Add custom connector. Paste each value into the matching field. OAuth handshake runs on first use.'
|
||||||
[key]: {
|
: 'Settings → Connectors → Add custom connector. Paste Name + URL, leave the OAuth fields blank. If your network blocks Dynamic Client Registration, generate a fixed Client ID / Secret in the server settings and paste them too.',
|
||||||
url: mcpUrl,
|
|
||||||
auth: 'oauth2',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
note: '~/Library/Application Support/Claude/claude_desktop_config.json (macOS) — Settings → Developer (Windows). Restart Claude after saving.',
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'claude-code':
|
||||||
|
return {
|
||||||
|
label: 'Claude Code (CLI)',
|
||||||
|
code: `claude mcp add ${key} ${mcpUrl} --transport http`,
|
||||||
|
note: `Run in any project. Use \`claude mcp list\` to confirm and \`claude mcp remove ${key}\` to undo.`,
|
||||||
|
};
|
||||||
|
|
||||||
case 'cursor':
|
case 'cursor':
|
||||||
return {
|
return {
|
||||||
label: '.cursor/mcp.json',
|
label: '.cursor/mcp.json',
|
||||||
@ -42,13 +82,62 @@ export function buildSnippet(target: InstallTarget, input: SnippetInput): { labe
|
|||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
note: 'Place at the project root or in ~/.cursor/mcp.json for global use.',
|
note: 'Place at the project root or in ~/.cursor/mcp.json for global use. Restart Cursor after saving.',
|
||||||
};
|
};
|
||||||
case 'chatgpt':
|
|
||||||
|
case 'vscode':
|
||||||
return {
|
return {
|
||||||
label: 'ChatGPT — Custom Connector',
|
label: '.vscode/mcp.json',
|
||||||
code: `Name: ${input.name}\nURL: ${mcpUrl}\nAuth: OAuth 2.1 (Dynamic Client Registration)`,
|
code: JSON.stringify(
|
||||||
note: 'ChatGPT → Settings → Connectors → Add custom connector. Paste the values above. OAuth handshake runs on first use.',
|
{
|
||||||
|
servers: {
|
||||||
|
[key]: {
|
||||||
|
type: 'http',
|
||||||
|
url: mcpUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
note: 'VS Code → Copilot Chat → MCP servers. Save the file at the workspace root, then reload the window. OAuth runs on first call.',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'chatgpt': {
|
||||||
|
const lines = [
|
||||||
|
`Name: ${input.name}`,
|
||||||
|
`URL: ${mcpUrl}`,
|
||||||
|
`Auth: OAuth 2.1`,
|
||||||
|
];
|
||||||
|
if (hasCreds) {
|
||||||
|
lines.push(
|
||||||
|
`Client ID: ${input.oauthClientId}`,
|
||||||
|
`Client Secret: ${input.oauthClientSecret}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: 'ChatGPT · Custom Connector',
|
||||||
|
code: lines.join('\n'),
|
||||||
|
note: hasCreds
|
||||||
|
? 'ChatGPT → Settings → Connectors → Add custom connector. Paste the values above.'
|
||||||
|
: 'ChatGPT → Settings → Connectors → Add custom connector. Paste Name + URL. OAuth handshake registers a client automatically on first use.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'codex':
|
||||||
|
return {
|
||||||
|
label: '~/.codex/config.toml',
|
||||||
|
code: `[mcp_servers.${key}]
|
||||||
|
type = "http"
|
||||||
|
url = "${mcpUrl}"`,
|
||||||
|
note: 'Append to your ~/.codex/config.toml. Restart the Codex CLI. OAuth runs on first tool call.',
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'raw-url':
|
||||||
|
return {
|
||||||
|
label: 'Server URL',
|
||||||
|
code: mcpUrl,
|
||||||
|
note: 'Any MCP-compatible client. Add it as a "Remote MCP server" (HTTP transport) and approve OAuth on first use.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
-- Allow generic RFC 7591 Dynamic Client Registration:
|
||||||
|
-- a client may register without binding to a specific MCP server.
|
||||||
|
-- /oauth/authorize still enforces the org-ownership check on every
|
||||||
|
-- authorization, and the access-token `aud` claim is pinned to the
|
||||||
|
-- resource declared at /token, so a generic client cannot mint a
|
||||||
|
-- token usable against a server outside the user's org.
|
||||||
|
ALTER TABLE oauth_clients
|
||||||
|
ALTER COLUMN server_id DROP NOT NULL;
|
||||||
@ -260,9 +260,13 @@ export const secrets = pgTable('secrets', {
|
|||||||
|
|
||||||
export const oauthClients = pgTable('oauth_clients', {
|
export const oauthClients = pgTable('oauth_clients', {
|
||||||
id: uuid('id').defaultRandom().primaryKey(),
|
id: uuid('id').defaultRandom().primaryKey(),
|
||||||
serverId: uuid('server_id')
|
// Nullable: RFC 7591 Dynamic Client Registration treats the `resource`
|
||||||
.references(() => mcpServers.id, { onDelete: 'cascade' })
|
// claim as optional, so a client may register generically and only bind
|
||||||
.notNull(),
|
// to a specific server at /oauth/authorize. /authorize enforces the
|
||||||
|
// org-ownership check on every authorization either way.
|
||||||
|
serverId: uuid('server_id').references(() => mcpServers.id, {
|
||||||
|
onDelete: 'cascade',
|
||||||
|
}),
|
||||||
clientId: varchar('client_id', { length: 128 }).notNull().unique(),
|
clientId: varchar('client_id', { length: 128 }).notNull().unique(),
|
||||||
clientSecretHash: text('client_secret_hash'),
|
clientSecretHash: text('client_secret_hash'),
|
||||||
redirectUris: jsonb('redirect_uris').notNull(),
|
redirectUris: jsonb('redirect_uris').notNull(),
|
||||||
|
|||||||
@ -175,5 +175,13 @@ export type IterateServerInput = z.infer<typeof IterateServerInput>;
|
|||||||
|
|
||||||
// ---- Install snippets ----
|
// ---- Install snippets ----
|
||||||
|
|
||||||
export const InstallTarget = z.enum(['claude-desktop', 'cursor', 'chatgpt']);
|
export const InstallTarget = z.enum([
|
||||||
|
'claude-desktop',
|
||||||
|
'claude-code',
|
||||||
|
'cursor',
|
||||||
|
'vscode',
|
||||||
|
'chatgpt',
|
||||||
|
'codex',
|
||||||
|
'raw-url',
|
||||||
|
]);
|
||||||
export type InstallTarget = z.infer<typeof InstallTarget>;
|
export type InstallTarget = z.infer<typeof InstallTarget>;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user