Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/__tests__/seoFallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React from "react";
import ReactDOMServer from "react-dom/server";

import SeoFallback from "@/components/ui/seo-fallback";
import { buildJsonLd, DEFAULT_DESCRIPTION, INDEXABLE_ROUTES } from "@/lib/seo";
import { buildLlmsTxt } from "@/pages/llms.txt";

/**
* These guard the "even a no-JS fetcher sees real content" behaviour: the SPA
* renders an empty body server-side, so SeoFallback (in <noscript>) and the
* server-rendered JSON-LD are what crawlers / LLM fetchers actually read.
*/
describe("SeoFallback — no-JS crawler/LLM content fallback", () => {
const html = ReactDOMServer.renderToStaticMarkup(
React.createElement(SeoFallback),
);

it("is wrapped in <noscript> so JS-enabled visitors never render it", () => {
expect(html.startsWith("<noscript>")).toBe(true);
expect(html.endsWith("</noscript>")).toBe(true);
});

it("exposes the product headline and description as readable text", () => {
expect(html).toContain("Multisig Security");
expect(html).toContain(DEFAULT_DESCRIPTION);
});

it("links to every public surface", () => {
for (const path of [
"/features",
"/governance",
"/governance/drep",
"/api-docs",
"/dapps",
"/wallets/import-wallet",
]) {
// Absolute canonical URLs, so each path appears as an href suffix.
expect(html).toContain(`${path}"`);
}
});

it("surfaces the bot-API entry points for AI agents", () => {
for (const path of ["/llms.txt", "/api/swagger", "/api/skill"]) {
expect(html).toContain(`${path}"`);
}
});
});

describe("buildLlmsTxt — /llms.txt for AI agents", () => {
const txt = buildLlmsTxt();

it("starts with an llms.txt H1 + summary blockquote", () => {
expect(txt.startsWith("# ")).toBe(true);
expect(txt).toContain(`> ${DEFAULT_DESCRIPTION}`);
});

it("points to the machine-readable spec and downloadable skill", () => {
expect(txt).toContain("/api/swagger");
expect(txt).toContain("/api/skill");
});

it("documents the 4-step bot onboarding and bearer auth", () => {
expect(txt).toContain("Authorization: Bearer");
for (const ep of [
"/api/v1/botRegister",
"/api/v1/botClaim",
"/api/v1/botPickupSecret",
"/api/v1/botAuth",
]) {
expect(txt).toContain(ep);
}
});

it("is listed in the sitemap route set", () => {
expect(INDEXABLE_ROUTES.map((r) => r.path)).toContain("/llms.txt");
});
});

describe("buildJsonLd — structured data for the initial HTML", () => {
const types = (pathname: string) =>
buildJsonLd(pathname).map((entry) => entry["@type"] as string);

it("emits Organization + WebSite site-wide", () => {
expect(types("/features")).toEqual(["Organization", "WebSite"]);
});

it("adds SoftwareApplication only on the home page", () => {
expect(types("/")).toContain("SoftwareApplication");
expect(types("/features")).not.toContain("SoftwareApplication");
});
});
25 changes: 0 additions & 25 deletions src/components/ui/json-ld.tsx

This file was deleted.

13 changes: 9 additions & 4 deletions src/components/ui/metatags.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import Head from "next/head";
import JsonLd from "@/components/ui/json-ld";
import {
SITE_NAME,
TWITTER_HANDLE,
Expand Down Expand Up @@ -127,10 +126,16 @@ export default function Metatags({
name="apple-mobile-web-app-status-bar-style"
content="black-translucent"
/>
</Head>

{/* Structured data (injected safely into <head> on the client). */}
<JsonLd json={jsonLd} />
{/* Structured data, server-rendered so non-JS crawlers and LLM fetchers
(which never execute our client bundle) read it in the initial HTML.
The content is app-controlled JSON.stringify output; escaping "<" to
its "<" JSON form keeps it valid JSON while making a "</script>"
breakout impossible, so no raw-HTML injection is involved. */}
<script type="application/ld+json">
{jsonLd.replace(/</g, "\\u003c")}
</script>
</Head>
</>
);
}
121 changes: 121 additions & 0 deletions src/components/ui/seo-fallback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
SITE_NAME,
DEFAULT_DESCRIPTION,
routeSeo,
absoluteUrl,
} from "@/lib/seo";

/**
* Server-rendered, no-JavaScript content fallback for crawlers and LLM fetchers.
*
* The app is a client-only SPA: the whole UI lives behind an `ssr:false`
* MeshProvider boundary (the wallet SDK assumes a browser), so the server HTML
* body is just an empty `<div id="__next">`. A consumer that doesn't execute
* JavaScript — search-engine crawlers, social unfurlers, ChatGPT's URL fetcher /
* GPTBot, `curl` — therefore can't tell what the site is.
*
* This block renders inside <noscript>, so real (JS-enabled) visitors never see
* it, while no-JS consumers get a readable description of the product plus links
* to every public surface. Copy and links derive from @/lib/seo so they stay in
* sync with the page metadata and sitemap. Absolute URLs (via {@link absoluteUrl})
* point at the canonical host, consolidating link equity there.
*/

// Public, indexable surfaces, in nav order. Labels mirror the routeSeo titles;
// the longer per-link blurb is pulled from routeSeo at render time.
const FALLBACK_LINKS = [
{ path: "/features", label: "Features" },
{ path: "/governance", label: "Cardano Governance" },
{ path: "/governance/drep", label: "DRep Explorer" },
{ path: "/api-docs", label: "API & Bot Documentation" },
{ path: "/dapps", label: "DApps" },
{ path: "/wallets/import-wallet", label: "Import a Multisig Wallet" },
] as const;

// Machine-readable entry points, so an AI agent that lands on the homepage HTML
// (not just /llms.txt) can discover how to drive the bot API.
const BOT_API_LINKS = [
{
path: "/llms.txt",
label: "llms.txt",
blurb: "AI-oriented overview of the site and bot API, with a quickstart.",
},
{
path: "/api/swagger",
label: "OpenAPI 3.0 spec (JSON)",
blurb: "Machine-readable definition of every bot endpoint.",
},
{
path: "/api/skill",
label: "Agent skill (Markdown)",
blurb: "Downloadable skill describing the full bot workflow.",
},
{
path: "/api-docs",
label: "Interactive API docs",
blurb: "Human Swagger UI with a wallet-based token generator.",
},
] as const;

const EXTERNAL_LINKS = [
{ href: "https://github.com/MeshJS/multisig", label: "Source code (GitHub)" },
{ href: "https://meshjs.dev", label: "Mesh SDK" },
{ href: "https://discord.gg/dH48jH3BKa", label: "Community (Discord)" },
] as const;

export default function SeoFallback() {
return (
<noscript>
<main>
<h1>Manage Cardano Treasuries with Multisig Security</h1>
<p>{DEFAULT_DESCRIPTION}</p>
<p>
{SITE_NAME} is a free, open-source, Cardano-native wallet built by Mesh.
Secure treasuries, participate in governance, and collaborate with M-of-N
multi-signature approvals — every transaction requires a quorum of
signers, so no single key can move funds alone.
</p>

<h2>What you can do</h2>
<ul>
<li>Create an M-of-N multi-signature wallet and invite co-signers.</li>
<li>Review and co-sign transactions with a required approval threshold.</li>
<li>Vote on Cardano governance proposals and register as a DRep.</li>
<li>Delegate stake and earn rewards from the treasury.</li>
<li>Automate signing with the REST API and bot integrations.</li>
<li>Import an existing multisig wallet and keep collaborating.</li>
</ul>

<h2>Explore</h2>
<ul>
{FALLBACK_LINKS.map((link) => (
<li key={link.path}>
<a href={absoluteUrl(link.path)}>{link.label}</a>
{routeSeo[link.path]?.description
? ` — ${routeSeo[link.path]?.description}`
: null}
</li>
))}
</ul>

<h2>Bot API (for AI agents)</h2>
<ul>
{BOT_API_LINKS.map((link) => (
<li key={link.path}>
<a href={absoluteUrl(link.path)}>{link.label}</a> — {link.blurb}
</li>
))}
</ul>

<h2>Resources</h2>
<ul>
{EXTERNAL_LINKS.map((link) => (
<li key={link.href}>
<a href={link.href}>{link.label}</a>
</li>
))}
</ul>
</main>
</noscript>
);
}
1 change: 1 addition & 0 deletions src/lib/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export const INDEXABLE_ROUTES: SitemapRoute[] = [
{ path: "/governance", changefreq: "daily", priority: 0.8 },
{ path: "/governance/drep", changefreq: "daily", priority: 0.7 },
{ path: "/api-docs", changefreq: "monthly", priority: 0.6 },
{ path: "/llms.txt", changefreq: "monthly", priority: 0.5 },
{ path: "/wallets/import-wallet", changefreq: "monthly", priority: 0.5 },
];

Expand Down
5 changes: 5 additions & 0 deletions src/pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import "swagger-ui-react/swagger-ui.css";
import "@/styles/swagger-overrides.css";
import { Toaster } from "@/components/ui/toaster";
import Metatags from "@/components/ui/metatags";
import SeoFallback from "@/components/ui/seo-fallback";
import RootLayout from "@/components/common/overall-layout/layout";

// MeshProvider pulls in dependencies that assume a browser/webpack env.
Expand Down Expand Up @@ -94,6 +95,10 @@ const MyApp: AppType<{ session: Session | null }> = ({
type={seo.type}
extraJsonLd={seo.jsonLd}
/>
{/* Also outside the ssr:false boundary: a <noscript> content fallback so
crawlers and LLM fetchers that don't run our client bundle still get
readable body content (the app itself renders an empty shell for them). */}
<SeoFallback />
<MeshProviderNoSSR>
{umamiWebsiteId && (
<Script
Expand Down
95 changes: 95 additions & 0 deletions src/pages/llms.txt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import type { GetServerSideProps } from "next";
import { SITE_NAME, DEFAULT_DESCRIPTION, absoluteUrl } from "@/lib/seo";

/**
* Serves /llms.txt — a concise, machine-readable orientation for AI agents and
* LLM fetchers (see llmstxt.org). The whole app is a client-only SPA, so a
* no-JS agent can't discover the bot API by crawling the UI; this file gives it
* the entry points (OpenAPI spec, downloadable skill, human docs) plus a
* self-contained bot quickstart so it can start calling the API immediately.
*
* Content is built from @/lib/seo so the URLs point at the canonical host of
* whatever deployment serves this. Keep in sync with src/pages/api/v1/README.md,
* which is the authoritative endpoint reference.
*/
export function buildLlmsTxt(): string {
const spec = absoluteUrl("/api/swagger");
const skill = absoluteUrl("/api/skill");
const docs = absoluteUrl("/api-docs");

return `# ${SITE_NAME}

> ${DEFAULT_DESCRIPTION}

${SITE_NAME} exposes a REST bot API (base path \`/api/v1\`, JSON over HTTPS) so
autonomous agents can create wallets, co-sign transactions, delegate stake, and
vote on Cardano governance under M-of-N approval. Every mutating call needs a bot
JWT, and the wallet's signing threshold still applies: a bot can propose and sign,
but funds move only once the required quorum of signatures is collected.

## Start here (machine-readable)

- [OpenAPI 3.0 spec](${spec}): complete, machine-readable definition of every
endpoint, with an absolute \`servers\` base URL. Feed this to your tooling / codegen.
- [Agent skill (Markdown)](${skill}): a ready-to-load skill describing the full bot
workflow end to end — download and give it to your agent.
- [Interactive API docs](${docs}): human Swagger UI with a wallet-based bearer-token generator.

## Authentication

Every bot request sends the header \`Authorization: Bearer <jwt>\`. A bot JWT is
minted once through the onboarding flow, then refreshed via botAuth:

1. POST /api/v1/botRegister — bot self-registers with \`requestedScopes\`; returns \`{ pendingBotId, claimCode }\`. Register **without** a \`paymentAddress\` — a fresh bot has no wallet yet; the address is bound later at first botAuth.
2. POST /api/v1/botClaim — the human owner approves the \`claimCode\` using their own JWT.
3. GET /api/v1/botPickupSecret?pendingBotId=... — bot retrieves \`{ botKeyId, secret }\` exactly once (the secret then stays valid for repeated botAuth).
4. POST /api/v1/botAuth — bot exchanges \`botKeyId\` + \`secret\` for a bot JWT \`{ token, botId }\`. Supply \`paymentAddress\` on the **first** botAuth to bind the bot's identity; the JWT always carries that server-bound address.

Refresh by repeating step 4 (tokens expire after ~1 hour; there is no separate
refresh endpoint). Rotate a leaked secret with POST /api/v1/botRotateSecret
(\`{ botKeyId, secret }\` -> a new secret, returned once). Scopes:
\`multisig:read\`, \`multisig:create\`, \`multisig:sign\`, \`governance:read\`, \`ballot:write\`.
The \`address\` in the JWT must match the \`address\` sent in each request.

Human owners authenticate differently: GET /api/v1/getNonce -> sign the nonce ->
POST /api/v1/authSigner -> JWT.

## Common endpoints

Read:
- GET /api/v1/botMe — the bot's own identity, owner address, and wallet grants.
- GET /api/v1/walletIds?address=... — wallets the caller can access.
- GET /api/v1/freeUtxos?walletId=...&address=... — spendable UTxOs; pass these refs (\`{ txHash, outputIndex }\`) when building transactions.
- GET /api/v1/pendingTransactions?walletId=...&address=... — transactions awaiting signatures.
- GET /api/v1/governanceActiveProposals — active governance proposals (scope \`governance:read\`).
- GET /api/v1/botBallots?walletId=... — the bot's governance ballot drafts (\`ballot:write\`).

Write (required scope in parentheses):
- POST /api/v1/createWallet — create a multisig wallet (\`multisig:create\`).
- POST /api/v1/addTransaction — submit a built tx (CBOR/JSON) for signing or queuing (\`multisig:sign\`).
- POST /api/v1/signTransaction — add a witness; auto-submits when the threshold is met (\`multisig:sign\`).
- POST /api/v1/botStakeCertificate — server-build a stake register/delegate/deregister tx (\`multisig:sign\`).
- POST /api/v1/botDRepCertificate — server-build a DRep register/retire tx (\`multisig:sign\`).
- POST /api/v1/botBallotsUpsert — record governance vote decisions + draft rationale (\`ballot:write\`; an observer grant is enough).

Plutus proxy endpoints (\`/api/v1/proxy*\`) follow the same request pattern; see the OpenAPI spec.

## Notes

- Status: alpha — endpoints may change. The OpenAPI spec at ${spec} is the source of truth.
- UTxO-consuming builders take references only (\`{ txHash, outputIndex }\`) from freeUtxos — never raw UTxO JSON.
- Errors use standard HTTP status codes: 400 validation, 401 auth, 403 scope/access, 409 conflict, 429 rate limit.
`;
}

export const getServerSideProps: GetServerSideProps = async ({ res }) => {
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.setHeader("Cache-Control", "public, max-age=3600, s-maxage=3600");
res.write(buildLlmsTxt());
res.end();
return { props: {} };
};

export default function LlmsTxt() {
return null;
}
Loading
Loading