wiki-formant 0.28.0 → 0.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/rola.js DELETED
@@ -1,150 +0,0 @@
1
- // rola.ts — Radix On-Ledger Authentication: challenge, proof, session.
2
- //
3
- // Parameterised on the two things a second wiki would differ in: the cookie
4
- // name and its dApp identity. It verifies persona (identity_*) proofs as well
5
- // as account proofs, and logs a failed verification so it is diagnosable.
6
- //
7
- // **One consumer.** caper's wallet auth never used this stack and acuiq gates
8
- // only its wiki editor, on a shared secret. Keep it — the
9
- // persona-proof fix and the error logging are worth not losing, and the ports
10
- // below are what make it reusable at all — but do not mistake it for a proven
11
- // shared abstraction. It is radix-wiki's auth that happens to live in a package.
12
- //
13
- // Storage and cookies are ports rather than imports. A Prisma client is
14
- // generated per repo and cannot be shared, and taking `next/headers` here would
15
- // bind the package to a framework it otherwise knows nothing about. Both ports
16
- // are shaped so the caller passes what it already has — the delegate and the
17
- // cookie jar — with no adapter to write.
18
- //
19
- // `jose` and `@radixdlt/rola` are optional peers: only a repo importing this
20
- // subpath needs them installed.
21
- import { SignJWT, jwtVerify } from 'jose';
22
- import { Rola } from '@radixdlt/rola';
23
- const DEFAULT_SESSION_MS = 7 * 24 * 60 * 60 * 1000;
24
- const DEFAULT_CHALLENGE_TTL_SEC = 300;
25
- const bytesToHex = (bytes) => Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
26
- /**
27
- * A wallet address tells you which proof type it is, and getting it wrong is a
28
- * silent rejection: the ROLA library verifies an `identity_*` proof only under
29
- * `type: 'persona'`. One of the two copies this replaces hardcoded `'account'`
30
- * and so could never accept a persona login at all.
31
- */
32
- export function proofType(address) {
33
- if (address.startsWith('account_'))
34
- return 'account';
35
- if (address.startsWith('identity_'))
36
- return 'persona';
37
- return null;
38
- }
39
- export function createRolaAuth(config) {
40
- const { cookieName, jwtSecret, store, cookies, sessionDurationMs = DEFAULT_SESSION_MS, challengeTtlSec = DEFAULT_CHALLENGE_TTL_SEC, secureCookies = process.env.NODE_ENV === 'production', } = config;
41
- const rola = Rola(config.rola);
42
- async function verifyToken(token) {
43
- try {
44
- const { payload } = await jwtVerify(token, jwtSecret);
45
- const session = await store.session.findUnique({ where: { token } });
46
- if (!session || session.expiresAt < new Date()) {
47
- if (session)
48
- await store.session.delete({ where: { id: session.id } }).catch(() => { });
49
- return null;
50
- }
51
- return {
52
- userId: payload.userId,
53
- radixAddress: payload.radixAddress,
54
- personaAddress: payload.personaAddress,
55
- displayName: payload.displayName,
56
- expiresAt: new Date(payload.exp * 1000),
57
- };
58
- }
59
- catch {
60
- return null;
61
- }
62
- }
63
- /** Consumes the challenge: a replayed one must not verify twice. */
64
- async function validateChallenge(challenge) {
65
- const stored = await store.challenge.findUnique({ where: { challenge } });
66
- if (!stored || stored.expiresAt < new Date()) {
67
- if (stored)
68
- await store.challenge.delete({ where: { id: stored.id } });
69
- return false;
70
- }
71
- await store.challenge.delete({ where: { id: stored.id } });
72
- return true;
73
- }
74
- return {
75
- async createSession(userId, radixAddress, personaAddress, displayName) {
76
- const expiresAt = new Date(Date.now() + sessionDurationMs);
77
- const token = await new SignJWT({ userId, radixAddress, personaAddress, displayName })
78
- .setProtectedHeader({ alg: 'HS256' })
79
- .setIssuedAt()
80
- .setExpirationTime(expiresAt)
81
- .setJti(crypto.randomUUID())
82
- .sign(jwtSecret);
83
- await store.session.create({ data: { userId, token, expiresAt } });
84
- const jar = await cookies();
85
- jar.set(cookieName, token, {
86
- httpOnly: true,
87
- secure: secureCookies,
88
- sameSite: 'lax',
89
- expires: expiresAt,
90
- path: '/',
91
- });
92
- return token;
93
- },
94
- async getSession(request) {
95
- const jar = await cookies();
96
- const cookieToken = jar.get(cookieName)?.value;
97
- if (cookieToken) {
98
- const session = await verifyToken(cookieToken);
99
- if (session)
100
- return session;
101
- }
102
- // Agents authenticate per-request with a Bearer token rather than a
103
- // cookie, so the header is a second, equal route in — not a fallback.
104
- if (request) {
105
- const authHeader = request.headers.get('Authorization');
106
- const bearerToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
107
- if (bearerToken)
108
- return verifyToken(bearerToken);
109
- }
110
- return null;
111
- },
112
- async destroySession() {
113
- const jar = await cookies();
114
- const token = jar.get(cookieName)?.value;
115
- if (token)
116
- await store.session.deleteMany({ where: { token } });
117
- jar.delete(cookieName);
118
- },
119
- async generateChallenge() {
120
- const array = new Uint8Array(32);
121
- crypto.getRandomValues(array);
122
- const challenge = bytesToHex(array);
123
- const expiresAt = new Date(Date.now() + challengeTtlSec * 1000);
124
- await store.challenge.create({ data: { challenge, expiresAt } });
125
- return { challenge, expiresAt };
126
- },
127
- async verifySignedChallenge(signedChallenge) {
128
- try {
129
- if (!(await validateChallenge(signedChallenge.challenge))) {
130
- console.error('ROLA: challenge validation failed', signedChallenge.challenge.slice(0, 16) + '...');
131
- return { isValid: false, error: 'Invalid or expired challenge' };
132
- }
133
- const type = proofType(signedChallenge.address);
134
- if (!type) {
135
- throw new Error(`ROLA: unrecognized address prefix (expected account_* or identity_*): ${signedChallenge.address}`);
136
- }
137
- const result = await rola.verifySignedChallenge({ ...signedChallenge, type });
138
- if (result.isErr()) {
139
- console.error('ROLA: verification failed', result.error.reason, result.error);
140
- return { isValid: false, error: result.error.reason };
141
- }
142
- return { isValid: true };
143
- }
144
- catch (error) {
145
- console.error('ROLA verification error:', error);
146
- return { isValid: false, error: 'Verification failed' };
147
- }
148
- },
149
- };
150
- }
package/dist/x402.d.ts DELETED
@@ -1,95 +0,0 @@
1
- import type { EnvelopeGate, RpcRequest } from './mcp.js';
2
- /**
3
- * The slice of `@x402/core`'s `x402ResourceServer` this module calls.
4
- *
5
- * Method syntax, not properties: the real methods carry the library's own
6
- * `PaymentPayload` / `PaymentRequirements` types, and bivariant method
7
- * parameters are what lets a real server satisfy a port written in `unknown`.
8
- */
9
- export interface X402ServerPort {
10
- buildPaymentRequirements(spec: {
11
- scheme: 'exact';
12
- network: string;
13
- payTo: string;
14
- price: string;
15
- maxTimeoutSeconds: number;
16
- }): Promise<unknown>;
17
- createPaymentRequiredResponse(accepts: unknown, resource: {
18
- url: string;
19
- description: string;
20
- mimeType: string;
21
- serviceName: string;
22
- tags: string[];
23
- }, error: string, extensions: unknown, context: unknown, payload?: unknown): Promise<unknown>;
24
- findMatchingRequirements(accepts: unknown, payload: unknown): unknown;
25
- verifyPayment(payload: unknown, requirements: unknown, extensions: unknown, context: unknown): Promise<{
26
- isValid: boolean;
27
- invalidReason?: string;
28
- }>;
29
- settlePayment(payload: unknown, requirements: unknown, extensions: unknown, context: unknown): Promise<{
30
- success?: boolean;
31
- }>;
32
- }
33
- /** One priced surface, as the caller describes it. All of this is policy. */
34
- export interface PaidSurface {
35
- /**
36
- * Which envelope slot the challenge can travel in. A tool result carries
37
- * `structuredContent`; a `resources/read` has no such shape and takes a
38
- * JSON-RPC error instead.
39
- */
40
- kind: 'tool' | 'resource';
41
- /** Canonical URL of the thing being sold, for the 402 body and the catalogue. */
42
- resource: string;
43
- price: string;
44
- mimeType: string;
45
- description: string;
46
- /** The sentence an unpaid caller reads. Say what else is free. */
47
- unpaidMessage: string;
48
- /**
49
- * Bazaar's MCP discovery shape, so an agent that would pay can find this in
50
- * the catalogue instead of finding it by being refused. The shape is defined
51
- * for tools, not resources — a `resources/read` has no tool name or input
52
- * schema to catalogue — so leave it undefined there rather than describing it
53
- * as something it is not. Declared through `GateOptions.declareDiscovery`,
54
- * and only for an entry that has actually been asked for.
55
- */
56
- discovery?: Record<string, unknown>;
57
- /** Passed through to the facilitator as call context, verbatim. */
58
- context: unknown;
59
- }
60
- export interface GateOptions {
61
- /**
62
- * Built lazily, and allowed to throw. An unreachable facilitator throws out
63
- * of `initialize()` rather than returning anything, and the gate turns that
64
- * into a withheld paid entry instead of a 500 that would take the free tools
65
- * in the same batch down with it.
66
- */
67
- server: () => Promise<X402ServerPort>;
68
- /** Which paid surface, if any, an envelope entry addresses. */
69
- resolve: (entry: RpcRequest) => PaidSurface | null;
70
- network: string;
71
- payTo: string;
72
- maxTimeoutSeconds: number;
73
- serviceName: string;
74
- tags: string[];
75
- /** Must match the cap `handleMcp` will apply. See the passthrough below. */
76
- maxBatch: number;
77
- /** False when this deployment charges for nothing. Everything passes through. */
78
- enabled?: boolean;
79
- /**
80
- * `@x402/extensions/bazaar`'s `declareDiscoveryExtension`, reached however
81
- * the caller reaches it — this package does not import @x402 and will not
82
- * start. Keep it a lazy `import()` on that side: it is only ever called for a
83
- * surface somebody has actually requested, so the free path never loads it.
84
- */
85
- declareDiscovery?: (discovery: Record<string, unknown>) => Promise<unknown>;
86
- }
87
- /**
88
- * Verify payment for every paid entry, withhold the unpaid ones, and return a
89
- * `finish` that reassembles the response.
90
- *
91
- * The ordering mirrors `withX402`: verify BEFORE dispatch, settle only AFTER
92
- * the handler produced something that is not an error. A tool that raises
93
- * cancels rather than settles — the caller pays for an answer, not an attempt.
94
- */
95
- export declare function gatePaidCalls(raw: unknown, options: GateOptions): Promise<EnvelopeGate>;
package/dist/x402.js DELETED
@@ -1,212 +0,0 @@
1
- // x402.ts — per-call payment gating for an MCP envelope.
2
- //
3
- // THE LEAK THIS CLOSES. Paywalling a bulk HTTP export while the MCP tool that
4
- // returns the same bytes stays free reroutes a scraper one endpoint over. A
5
- // price one surface honours and its twin does not is not a price.
6
- //
7
- // WHY NOT AN HTTP 402 ON THE MCP ROUTE. One status covers one POST, and an MCP
8
- // server accepts batches — a batch of [search, get_catalogue, create_checkout]
9
- // would have to 402 all three or bill none. A blanket 402 would also block the
10
- // free tools that share the URL. And no MCP client would understand it: a
11
- // non-2xx from the transport reads as "server error", which is why the x402
12
- // Foundation's own MCP integration does not use HTTP 402 either.
13
- //
14
- // SO PAYMENT TRAVELS IN-BAND, in the JSON-RPC envelope, in the places @x402/mcp
15
- // puts it:
16
- //
17
- // client -> server params._meta["x402/payment"] PaymentPayload
18
- // unpaid tool -> client tool result, isError + structuredContent
19
- // unpaid resource -> client JSON-RPC error -32042, data: PaymentRequired
20
- // paid -> client result._meta["x402/payment-response"] SettleResponse
21
- //
22
- // (resources/read has no isError result shape, hence the two forms.)
23
- //
24
- // WHY THIS IS ITS OWN SUBPATH, AND WHY IT IMPORTS NOTHING.
25
- // `wiki-formant` has no runtime dependencies and no framework peer, and x402 is
26
- // a stack of them — @x402/core, @x402/evm, @x402/extensions and viem behind
27
- // those. They are declared here as OPTIONAL peers, because a caller genuinely
28
- // cannot build an `x402ResourceServer` without them, and a wiki that never
29
- // imports `wiki-formant/x402` never resolves, installs or loads any of it.
30
- //
31
- // But this file imports none of them either. The resource server arrives as a
32
- // PORT, for the same reason the Prisma delegates in `rola.ts` do: the caller
33
- // already holds a configured one, its construction is deployment policy (which
34
- // chain, which facilitator, whose gas key, whose money), and typing it
35
- // structurally is what keeps a viem-sized dependency tree out of a package
36
- // three other wikis install. `declareDiscoveryExtension` arrives the same way,
37
- // so the lazy `import()` that keeps it off the free path stays lazy and stays
38
- // the caller's.
39
- //
40
- // WHAT STAYS WITH THE CALLER, deliberately: which surfaces cost what, the
41
- // prices, the terms text, the service name and the sentence that tells an agent
42
- // how to pay. Those are policy. What is here is the envelope surgery —
43
- // verify before dispatch, settle only after a non-error answer, splice the
44
- // challenges back into the caller's original order — which is the part that is
45
- // the same wherever it is written and the part that is easy to get subtly
46
- // wrong.
47
- import { toolText } from './mcp.js';
48
- /** Structural check only — `verifyPayment` does the real one, as @x402/mcp does. */
49
- function metaPayment(req) {
50
- const meta = (req.params ?? {})._meta;
51
- const payment = meta?.['x402/payment'];
52
- return payment && typeof payment === 'object' && 'x402Version' in payment && 'payload' in payment
53
- ? payment
54
- : null;
55
- }
56
- const passthrough = (body) => ({
57
- body: body,
58
- empty: false,
59
- finish: async (r) => r,
60
- });
61
- /**
62
- * Verify payment for every paid entry, withhold the unpaid ones, and return a
63
- * `finish` that reassembles the response.
64
- *
65
- * The ordering mirrors `withX402`: verify BEFORE dispatch, settle only AFTER
66
- * the handler produced something that is not an error. A tool that raises
67
- * cancels rather than settles — the caller pays for an answer, not an attempt.
68
- */
69
- export async function gatePaidCalls(raw, options) {
70
- const { resolve, network, payTo, maxTimeoutSeconds, serviceName, tags, maxBatch } = options;
71
- if (options.enabled === false)
72
- return passthrough(raw);
73
- const isBatch = Array.isArray(raw);
74
- const entries = (isBatch ? raw : [raw]);
75
- // Malformed or oversized bodies go straight through: handleMcp owns those
76
- // errors and says them better. The batch cap especially — it is enforced
77
- // downstream, after this function would have removed entries, so a 500-entry
78
- // batch must not be allowed to shrink past it here.
79
- if (!entries.length || entries.length > maxBatch)
80
- return passthrough(raw);
81
- if (entries.some(e => !e || typeof e !== 'object'))
82
- return passthrough(raw);
83
- const targets = entries
84
- .map((entry, index) => ({ entry, index, spec: resolve(entry) }))
85
- .filter((t) => t.spec !== null);
86
- // The overwhelmingly common case: nothing paid in this envelope, so the
87
- // facilitator is never contacted and the free surface pays no latency for
88
- // the existence of the paid one.
89
- if (!targets.length)
90
- return passthrough(raw);
91
- const blocked = new Map();
92
- const settlers = new Map();
93
- /** A withheld entry, in whichever form this method can carry. */
94
- const refuse = (entry, kind, message, data) => kind === 'tool'
95
- ? toolText(entry.id, JSON.stringify(data ?? { error: message }), true, data ? { structuredContent: data } : {})
96
- : { jsonrpc: '2.0', id: entry.id, error: { code: -32042, message, data } };
97
- // An unreachable facilitator THROWS out of initialize() rather than returning
98
- // anything. Uncaught here it would 500 the entire POST — taking down the free
99
- // tools in the same batch, which is precisely the blast radius this per-entry
100
- // design exists to avoid. Withhold only the paid entries and let everything
101
- // else through.
102
- let server;
103
- try {
104
- server = await options.server();
105
- }
106
- catch {
107
- for (const { entry, index, spec } of targets) {
108
- blocked.set(index, refuse(entry, spec.kind, 'Payment verification is temporarily unavailable. Retry shortly.'));
109
- }
110
- return finishWith(entries, isBatch, blocked, new Map());
111
- }
112
- for (const { entry, index, spec } of targets) {
113
- const accepts = await server.buildPaymentRequirements({
114
- scheme: 'exact',
115
- network,
116
- payTo,
117
- price: spec.price,
118
- maxTimeoutSeconds,
119
- });
120
- // The full PaymentRequired object, in whichever envelope slot this method
121
- // has: a tool result can carry structuredContent, resources/read cannot.
122
- const extensions = spec.discovery && options.declareDiscovery
123
- ? await options.declareDiscovery(spec.discovery)
124
- : undefined;
125
- const challenge = async (error, payload) => refuse(entry, spec.kind, error, await server.createPaymentRequiredResponse(accepts, {
126
- url: spec.resource,
127
- description: spec.description,
128
- mimeType: spec.mimeType,
129
- serviceName,
130
- tags,
131
- }, error, extensions, spec.context, payload));
132
- const payload = metaPayment(entry);
133
- if (!payload) {
134
- blocked.set(index, await challenge(spec.unpaidMessage));
135
- continue;
136
- }
137
- const requirements = server.findMatchingRequirements(accepts, payload);
138
- if (!requirements) {
139
- blocked.set(index, await challenge('No matching payment requirements found.', payload));
140
- continue;
141
- }
142
- const verify = await server.verifyPayment(payload, requirements, extensions, spec.context);
143
- if (!verify.isValid) {
144
- blocked.set(index, await challenge(verify.invalidReason || 'Payment verification failed.', payload));
145
- continue;
146
- }
147
- settlers.set(index, () => server.settlePayment(payload, requirements, extensions, spec.context));
148
- }
149
- return finishWith(entries, isBatch, blocked, settlers);
150
- }
151
- /**
152
- * Assemble the EnvelopeGate: what to dispatch, and how to put the answer back together.
153
- *
154
- * Shared by the normal path and the facilitator-unreachable one, which differ
155
- * only in whether anything is left to settle.
156
- */
157
- function finishWith(entries, isBatch, blocked, settlers) {
158
- const kept = entries.filter((_, i) => !blocked.has(i));
159
- const failedResult = (id, message) => toolText(id, JSON.stringify({ error: message }), true);
160
- return {
161
- body: isBatch ? kept : kept[0],
162
- empty: kept.length === 0,
163
- async finish(dispatched) {
164
- // handleMcp returns a bare object for a single request and an array for a
165
- // batch, and drops notification entries — so positions do not survive it.
166
- // Rejoin on the JSON-RPC id, the only stable handle either side has.
167
- //
168
- // Its one hazard, stated rather than hidden: a batch that repeats an id
169
- // collapses onto one entry here, so the duplicates lose their answers.
170
- // That envelope is malformed under JSON-RPC 2.0 and no client sends it,
171
- // but it is the reason the obvious "pass a per-call context to handlers
172
- // and rejoin by position" refactor is not the improvement it looks like:
173
- // McpTool.handler is (args) => Promise<unknown> and widening it cannot
174
- // carry a settlement receipt back out, so the rejoin would still be here.
175
- const answers = dispatched == null ? [] : Array.isArray(dispatched) ? dispatched : [dispatched];
176
- const byId = new Map(answers.map(a => [a.id, a]));
177
- await Promise.all([...settlers].map(async ([index, settle]) => {
178
- const id = entries[index].id;
179
- const answer = byId.get(id);
180
- // No answer, a JSON-RPC error, or a handler that raised: the caller is
181
- // not getting the data, so nothing is charged for it.
182
- const failed = !answer
183
- || 'error' in answer
184
- || answer.result?.isError === true;
185
- if (failed)
186
- return;
187
- try {
188
- const receipt = await settle();
189
- if (receipt.success === false) {
190
- // Settlement failed after the handler ran. The bytes exist but must
191
- // not ship — nobody was charged for them.
192
- byId.set(id, failedResult(id, 'Payment settlement failed.'));
193
- return;
194
- }
195
- const result = answer.result;
196
- result._meta = { ...result._meta, 'x402/payment-response': receipt };
197
- }
198
- catch {
199
- // Facilitator unreachable at settle time. Fail CLOSED: withholding a
200
- // bulk export costs a retry, and handing one over uncharged is what a
201
- // scraper waits for.
202
- byId.set(id, failedResult(id, 'Payment settlement is temporarily unavailable. Retry shortly.'));
203
- }
204
- }));
205
- // Rebuild in the caller's original order, challenges spliced back in.
206
- const merged = entries
207
- .map((e, i) => blocked.get(i) ?? byId.get(e.id))
208
- .filter(Boolean);
209
- return isBatch ? merged : (merged[0] ?? null);
210
- },
211
- };
212
- }