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
15 changes: 15 additions & 0 deletions .agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,22 @@ Specs live in the vault, not this repo. The maintainer keeps a document-driven d
- **OpenAPI**: `GET /api/swagger` (JSON).
- **Bot auth**: `POST /api/v1/botAuth` with body `{ "botKeyId", "secret", "paymentAddress" }` → `{ "token", "botId" }`. Use token as Bearer for `walletIds`, `pendingTransactions`, `freeUtxos`, `addTransaction`, `signTransaction`, etc. Reference client: `scripts/bot-ref/` (see README there).

## MCP (AI agents)

- **Endpoint**: `POST /api/mcp` — a stateless Model Context Protocol server built on
`@modelcontextprotocol/server` v2. Docs: `src/pages/api/mcp/README.md`.
- **Surface**: read-only plus governance ballot drafts. It cannot sign, spend or
broadcast, and that boundary is enforced by a test (`src/__tests__/mcpTools.test.ts`) —
adding a write tool must be a deliberate decision, not a registry addition.
- **Tools wrap the existing v1 handlers in-process** via `src/lib/mcp/invokeV1.ts`, so
authorization and validation stay defined once. Handler imports in
`src/lib/mcp/tools.ts` must stay **lazy** or the Mesh/whisky WASM lands in the route's
cold path.
- **Auth**: an OAuth 2.1 access token, or an existing v1 bearer token. The authorization
server lives under `src/pages/api/oauth/` — see `src/pages/api/oauth/README.md`.

## Docs to keep in sync

- Landing “Developers & Bots” section: `src/components/pages/homepage/index.tsx` (id `#developers-and-bots`).
- API/bot docs: `src/utils/swagger.ts`, `scripts/bot-ref/README.md`.
- MCP/OAuth: `src/pages/api/mcp/README.md`, `src/pages/api/oauth/README.md`.
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,10 @@ NEXT_PUBLIC_BLOCKFROST_API_KEY_PREPROD="your-blockfrost-preprod-api-key"
# NOTIFICATION_LINK_BASE_URL="http://localhost:3000"
# NOTIFICATION_DRAIN_SECRET="your-notification-drain-secret"
# NOTIFICATIONS_EMAIL_ENABLED="false"
# Optional: OAuth 2.1 issuer for the MCP endpoint (/api/mcp)
# Canonical origin of the authorization server. Access tokens carry it as `iss`,
# and the /.well-known discovery documents are built from it. Falls back to
# NEXT_PUBLIC_SITE_URL; set this only if the issuer must differ from the site
# origin. In non-production it falls back to the request host, so a local
# `next start` on any port works without setting anything.
# OAUTH_ISSUER_URL="https://multisig.meshjs.dev"
7 changes: 7 additions & 0 deletions jest.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { shared, ESM_TESTS, INTEGRATION_GLOB } from './jest.shared.mjs';
*/
export default {
...shared,
// Own cache directory. The two projects share one transform config but run
// under different module systems (this one without --experimental-vm-modules),
// so a cache entry produced by one is not valid for the other. Sharing jest's
// default cache dir let them clobber each other's entries for ESM packages in
// node_modules — surfacing as an intermittent "Cannot use import statement
// outside a module" from superjson, in whichever suite happened to lose.
cacheDirectory: '<rootDir>/node_modules/.cache/jest-cjs',
testMatch: [
'**/__tests__/**/*.(test|spec).+(ts|tsx|js)',
'**/*.(test|spec).+(ts|tsx|js)',
Expand Down
2 changes: 2 additions & 0 deletions jest.esm.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,7 @@ import { shared, ESM_TESTS } from './jest.shared.mjs';
*/
export default {
...shared,
// Separate from the CJS project's cache — see the note in jest.config.mjs.
cacheDirectory: '<rootDir>/node_modules/.cache/jest-esm',
testMatch: ESM_TESTS.map((name) => `<rootDir>/src/__tests__/${name}.test.ts`),
};
21 changes: 21 additions & 0 deletions jest.shared.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,27 @@
* The two modes are mutually exclusive per file, so they run as separate jest
* invocations (see jest.config.mjs for CJS and jest.esm.config.mjs for ESM),
* both built from this shared base.
*
* ## Never pass `{ virtual: true }` to `jest.mock()` for a module that exists
*
* `transform` below matches TypeScript only, so **no transformer applies to
* `.js` files** — including ESM-only packages in node_modules that
* `transformIgnorePatterns` permits. `superjson` v2 is the live example:
* `"type": "module"`, a single ESM export, no CommonJS build. Loading it in the
* CJS project throws "Cannot use import statement outside a module", every
* time, with no cache or ordering involved.
*
* CJS tests therefore depend on their mocks actually applying, so that the real
* module (and its unloadable dependencies) is never reached. `virtual: true` is
* for modules with *no file on disk*; used on a real one it registers the mock
* under the bare specifier instead of the resolved path, and whether a given
* importer gets the mock then varies with resolution order — which shifts with
* test count and load. That was the cause of a ~1-in-8 intermittent failure in
* walletIds.bot.test.ts, whose `@/server/api/root` mock would occasionally miss
* and pull the real tRPC root, and superjson with it.
*
* `src/__tests__/jestMockHygiene.test.ts` fails the build if the pattern
* returns.
*/
export const ESM_TESTS = [
'apiSecurity',
Expand Down
28 changes: 28 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,34 @@ const config = {
"@sidan-lab/whisky-js-nodejs",
],

// OAuth discovery documents must live under /.well-known/, but Next ignores
// dot-directories inside pages/, so they cannot be files. Rewrites map the
// well-known paths onto real API routes.
//
// RFC 9728 defines a path-aware form for protected-resource metadata
// (/.well-known/oauth-protected-resource + the resource's path). Clients probe
// that first and fall back to the root form, so both are served.
async rewrites() {
return [
{
source: '/.well-known/oauth-authorization-server',
destination: '/api/oauth/metadata/authorization-server',
},
{
source: '/.well-known/oauth-authorization-server/:path*',
destination: '/api/oauth/metadata/authorization-server',
},
{
source: '/.well-known/oauth-protected-resource',
destination: '/api/oauth/metadata/protected-resource',
},
{
source: '/.well-known/oauth-protected-resource/:path*',
destination: '/api/oauth/metadata/protected-resource',
},
];
},

// Basic security headers applied to all routes.
// NOTE: Content-Security-Policy and Strict-Transport-Security are intentionally
// omitted — CSP would break inline scripts/styles and HSTS locks browsers to
Expand Down
44 changes: 44 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"test": "node scripts/run-tests.mjs",
"test:cjs": "jest",
"test:esm": "node --experimental-vm-modules node_modules/jest/bin/jest.js -c jest.esm.config.mjs --passWithNoTests",
"test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts",
"test:bot:unit": "node scripts/run-tests.mjs src/__tests__/botAuth.test.ts src/__tests__/botMe.test.ts src/__tests__/createWallet.bot.test.ts src/__tests__/walletIds.bot.test.ts src/__tests__/pendingTransactions.bot.test.ts src/__tests__/freeUtxos.bot.test.ts src/__tests__/addTransaction.bot.test.ts src/__tests__/nativeScript.bot.test.ts src/__tests__/governanceActiveProposals.test.ts src/__tests__/botBallotsUpsert.test.ts src/__tests__/signTransaction.bot.test.ts src/__tests__/submitDatum.bot.test.ts src/__tests__/resolveUtxoRefsFromChain.test.ts src/__tests__/resolveDRepAnchorFromUrl.test.ts src/__tests__/normalizePoolId.test.ts src/__tests__/createPendingMultisigTransaction.test.ts src/__tests__/proxyUtxos.test.ts src/__tests__/proxyTxBuilders.test.ts src/__tests__/proxySetup.bot.test.ts src/__tests__/proxyCleanup.bot.test.ts src/__tests__/proxyAccess.test.ts src/__tests__/proxySetupFinalization.test.ts src/__tests__/proxyCleanupFinalization.test.ts src/__tests__/proxyCiPreflight.test.ts src/__tests__/proxyCiOrphanAdoption.test.ts src/__tests__/proxyCiChainRecovery.test.ts src/__tests__/proxyBotSelection.test.ts src/__tests__/proxyCleanupRuntime.test.ts src/__tests__/ciSigningSelection.test.ts src/__tests__/ciScenarioManifest.test.ts src/__tests__/mcpTools.test.ts src/__tests__/mcpRoute.test.ts src/__tests__/oauthTokens.test.ts src/__tests__/oauthFlow.test.ts src/__tests__/oauthRefreshStore.test.ts src/__tests__/jestMockHygiene.test.ts",
"test:bot:integration": "jest src/__tests__/botApi.integration.test.ts --runInBand",
"test:bot": "npm run test:bot:unit && npm run test:bot:integration",
"test:watch": "jest --watch",
Expand All @@ -40,6 +40,7 @@
"@meshsdk/core-cst": "^1.9.0",
"@meshsdk/provider": "^1.9.0-beta.101",
"@meshsdk/react": "^2.0.0-beta.2",
"@modelcontextprotocol/server": "^2.0.0",
"@octokit/core": "^6.1.2",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
Expand Down
134 changes: 134 additions & 0 deletions prisma/migrations/20260812090000_add_oauth_server_models/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
-- OAuth 2.1 authorization server tables, backing the MCP endpoint's
-- resource-server role. Codes, refresh tokens and client secrets are stored as
-- SHA-256 hashes only.

-- CreateTable
CREATE TABLE "OAuthClient" (
"id" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"isMetadataUrl" BOOLEAN NOT NULL DEFAULT false,
"clientName" TEXT NOT NULL,
"clientUri" TEXT,
"redirectUris" TEXT[],
"tokenEndpointAuthMethod" TEXT NOT NULL DEFAULT 'none',
"secretHash" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"lastSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "OAuthClient_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "OAuthAuthorizationCode" (
"id" TEXT NOT NULL,
"codeHash" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"subjectAddress" TEXT NOT NULL,
"grantedAddresses" TEXT[],
"scopes" TEXT[],
"resource" TEXT NOT NULL,
"redirectUri" TEXT NOT NULL,
"codeChallenge" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"consumedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "OAuthAuthorizationCode_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "OAuthRefreshToken" (
"id" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"subjectAddress" TEXT NOT NULL,
"grantedAddresses" TEXT[],
"scopes" TEXT[],
"resource" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"revokedAt" TIMESTAMP(3),
"replacedById" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "OAuthRefreshToken_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "OAuthGrant" (
"id" TEXT NOT NULL,
"subjectAddress" TEXT NOT NULL,
"clientId" TEXT NOT NULL,
"scopes" TEXT[],
"grantedAddresses" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "OAuthGrant_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE UNIQUE INDEX "OAuthClient_clientId_key" ON "OAuthClient"("clientId");

-- CreateIndex
CREATE INDEX "OAuthClient_clientId_idx" ON "OAuthClient"("clientId");

-- CreateIndex
CREATE UNIQUE INDEX "OAuthAuthorizationCode_codeHash_key" ON "OAuthAuthorizationCode"("codeHash");

-- CreateIndex
CREATE INDEX "OAuthAuthorizationCode_clientId_idx" ON "OAuthAuthorizationCode"("clientId");

-- CreateIndex
CREATE INDEX "OAuthAuthorizationCode_expiresAt_idx" ON "OAuthAuthorizationCode"("expiresAt");

-- CreateIndex
CREATE UNIQUE INDEX "OAuthRefreshToken_tokenHash_key" ON "OAuthRefreshToken"("tokenHash");

-- CreateIndex
CREATE INDEX "OAuthRefreshToken_clientId_idx" ON "OAuthRefreshToken"("clientId");

-- CreateIndex
CREATE INDEX "OAuthRefreshToken_subjectAddress_idx" ON "OAuthRefreshToken"("subjectAddress");

-- CreateIndex
CREATE INDEX "OAuthRefreshToken_expiresAt_idx" ON "OAuthRefreshToken"("expiresAt");

-- CreateIndex
CREATE UNIQUE INDEX "OAuthGrant_subjectAddress_clientId_key" ON "OAuthGrant"("subjectAddress", "clientId");

-- CreateIndex
CREATE INDEX "OAuthGrant_subjectAddress_idx" ON "OAuthGrant"("subjectAddress");

-- Row Level Security.
--
-- The 20251215090000 migration enabled RLS over a hardcoded table list, and
-- every table added since was silently left out of it. These four hold
-- authorization codes and client secrets, so they do not repeat that: RLS is
-- enabled here, at creation. Prisma connects as the service role and continues
-- to bypass RLS; this only denies the PostgREST roles.
DO $$
DECLARE
tbl TEXT;
BEGIN
FOR tbl IN
SELECT unnest(ARRAY[
'OAuthClient', 'OAuthAuthorizationCode', 'OAuthRefreshToken', 'OAuthGrant'
])
LOOP
EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl);

IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') THEN
EXECUTE format(
'CREATE POLICY "deny_all_anon_%s" ON %I FOR ALL TO anon USING (false) WITH CHECK (false)',
tbl, tbl
);
END IF;

IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') THEN
EXECUTE format(
'CREATE POLICY "deny_all_authenticated_%s" ON %I FOR ALL TO authenticated USING (false) WITH CHECK (false)',
tbl, tbl
);
END IF;
END LOOP;
END $$;
Loading
Loading