@lanes-sh/link 0.3.2 → 0.4.1
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/README.md +11 -3
- package/instructions/agents/lanes-link-scout.md +2 -2
- package/instructions/skills/lanes-link/SKILL.md +135 -11
- package/package.json +3 -1
- package/src/cli/argv.ts +52 -0
- package/src/cli/commands/connect/authorise.ts +5 -0
- package/src/cli/commands/connect/custom/ask.ts +167 -0
- package/src/cli/commands/connect/custom/credential.ts +143 -0
- package/src/cli/commands/connect/custom/derive.ts +229 -0
- package/src/cli/commands/connect/custom/index.ts +285 -0
- package/src/cli/commands/connect/custom/prompts.ts +160 -0
- package/src/cli/commands/connect/custom/spec.ts +293 -0
- package/src/cli/commands/connect/custom/values.ts +53 -0
- package/src/cli/commands/connect/custom/write.ts +166 -0
- package/src/cli/commands/connect/grant.ts +27 -0
- package/src/cli/commands/connect/index.ts +24 -27
- package/src/cli/commands/connect/outcome.ts +3 -1
- package/src/cli/commands/connect/requirements.ts +11 -1
- package/src/cli/commands/connect/settle.ts +17 -0
- package/src/cli/commands/connect/setup.ts +9 -1
- package/src/cli/commands/connect/strategy.ts +87 -0
- package/src/cli/commands/connect/unknown.ts +41 -0
- package/src/cli/commands/mcp/list.ts +123 -29
- package/src/cli/identity.ts +29 -5
- package/src/cli/main.ts +42 -14
- package/src/cli/oauth.ts +89 -36
- package/src/cli/runtime/open.ts +13 -1
- package/src/cli/runtime/registry.ts +12 -0
- package/src/cli/selection.ts +12 -0
- package/src/cli/usage.ts +9 -1
- package/src/connectivity/auth/README.md +8 -1
- package/src/connectivity/auth/strategy/index.ts +128 -4
- package/src/connectivity/connector.ts +11 -0
- package/src/connectivity/index.ts +11 -1
- package/src/connectivity/manifest/auth.ts +19 -0
- package/src/connectivity/manifest/connector.ts +21 -0
- package/src/connectivity/manifest/primitives.ts +5 -1
- package/src/connectivity/manifest/provider.ts +30 -12
- package/src/connectivity/provider.ts +55 -0
- package/src/connectivity/transports/factory.ts +1 -0
- package/src/connectivity/transports/http/index.ts +73 -2
- package/src/dispatch/dispatch.ts +44 -5
- package/src/providers/bunq/hints.ts +45 -0
- package/src/providers/bunq/index.ts +87 -0
- package/src/providers/bunq/redact.ts +75 -0
- package/src/providers/bunq/specs/bunq.v1.json +883 -0
- package/src/providers/bunq/specs/vendor.ts +396 -0
- package/src/providers/bunq/strategy/handshake.ts +211 -0
- package/src/providers/bunq/strategy/index.ts +298 -0
- package/src/providers/bunq/strategy/keys.ts +72 -0
- package/src/providers/custom/index.ts +1 -6
- package/src/providers/custom/load.ts +56 -14
- package/src/providers/custom/template.ts +1 -1
- package/src/providers/discord/hints.ts +195 -0
- package/src/providers/discord/index.ts +121 -0
- package/src/providers/discord/redact.ts +99 -0
- package/src/providers/discord/specs/discord.v10.json +2333 -0
- package/src/providers/discord/specs/vendor.ts +164 -0
- package/src/providers/google/specs/vendor.ts +32 -317
- package/src/providers/index.ts +9 -0
- package/src/providers/reddit/index.ts +113 -0
- package/src/providers/reddit/oauth.ts +77 -0
- package/src/providers/reddit/redact.ts +33 -0
- package/src/providers/reddit/scopes.ts +27 -0
- package/src/providers/reddit/specs/reddit.v1.json +700 -0
- package/src/providers/scopes.ts +2 -0
- package/src/providers/shared/openapi.ts +155 -0
- package/src/providers/shared/vendor-operations.ts +179 -0
- package/src/providers/shared/vendor-spec.ts +309 -0
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vendor a trimmed OpenAPI spec for bunq.
|
|
3
|
+
*
|
|
4
|
+
* The output is **committed**, for the reason the Google script gives and then
|
|
5
|
+
* some: a spec decides which paths get called with the operator's credential,
|
|
6
|
+
* and this credential moves money. `connect` grants everything a provider
|
|
7
|
+
* discovers, so the reviewable surface has to be a file in the repository
|
|
8
|
+
* rather than a document fetched from GitHub at connect time.
|
|
9
|
+
*
|
|
10
|
+
* bunq's published spec cannot be used as it stands, and not marginally. It is
|
|
11
|
+
* 1.37 MB across 271 paths, and generating tools from it fails on **55
|
|
12
|
+
* operations with `Maximum call stack size exceeded`** — including every single
|
|
13
|
+
* payment endpoint, because `Payment` recurses through `RequestInquiry` and
|
|
14
|
+
* `RequestResponse` and `mcp-from-openapi` inlines `$ref`s. `cutCycles` is what
|
|
15
|
+
* makes this provider possible at all; without it there is nothing here worth
|
|
16
|
+
* shipping.
|
|
17
|
+
*
|
|
18
|
+
* bun run vendor:bunq
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { OpenAPIToolGenerator, type McpOpenAPITool } from 'mcp-from-openapi';
|
|
24
|
+
import { cutCycles, referenced, type Spec } from '../../shared/openapi.ts';
|
|
25
|
+
import { projectRequestBody } from '../../shared/vendor-operations.ts';
|
|
26
|
+
|
|
27
|
+
const SOURCE = 'https://raw.githubusercontent.com/bunq/doc/master/swagger.json';
|
|
28
|
+
const OUT = 'bunq.v1.json';
|
|
29
|
+
|
|
30
|
+
/** Kept in step with `BUDGET_KB` in `src/cli/tools.test.ts`, which enforces it. */
|
|
31
|
+
const BUDGET_KB = 64;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Everything this provider can reach, and nothing else.
|
|
35
|
+
*
|
|
36
|
+
* The list *is* the security boundary, ahead of policy and ahead of the
|
|
37
|
+
* connection's bundles: an operation absent here has no tool, so no rule can
|
|
38
|
+
* allow it and no agent can find it. Adding a line is the decision worth
|
|
39
|
+
* arguing about, which is why each one says why it is here.
|
|
40
|
+
*
|
|
41
|
+
* Reading: what an agent needs to know before it can pay anything — which
|
|
42
|
+
* accounts exist, what is in them, and what has already gone out.
|
|
43
|
+
*
|
|
44
|
+
* Writing: the three shapes bunq offers for sending money. `Payment` executes
|
|
45
|
+
* immediately. `DraftPayment` does not — it waits for approval in the bunq app,
|
|
46
|
+
* which is the human checkpoint, and `UPDATE_DraftPayment` is how one is
|
|
47
|
+
* cancelled or accepted. `PaymentBatch` is up to 350 payments in one call,
|
|
48
|
+
* which is the whole point of automating a payment run rather than a payment.
|
|
49
|
+
*/
|
|
50
|
+
const OPERATIONS = [
|
|
51
|
+
// Read. `List_all_User` first because every other path is addressed under a
|
|
52
|
+
// userID, and nothing reports it but this.
|
|
53
|
+
'List_all_User',
|
|
54
|
+
'List_all_MonetaryAccount_for_User',
|
|
55
|
+
'List_all_Payment_for_User_MonetaryAccount',
|
|
56
|
+
'READ_Payment_for_User_MonetaryAccount',
|
|
57
|
+
'List_all_DraftPayment_for_User_MonetaryAccount',
|
|
58
|
+
'READ_DraftPayment_for_User_MonetaryAccount',
|
|
59
|
+
'List_all_PaymentBatch_for_User_MonetaryAccount',
|
|
60
|
+
// Write.
|
|
61
|
+
'CREATE_Payment_for_User_MonetaryAccount',
|
|
62
|
+
'CREATE_DraftPayment_for_User_MonetaryAccount',
|
|
63
|
+
'UPDATE_DraftPayment_for_User_MonetaryAccount',
|
|
64
|
+
'CREATE_PaymentBatch_for_User_MonetaryAccount',
|
|
65
|
+
//
|
|
66
|
+
// Not vendored, deliberately:
|
|
67
|
+
//
|
|
68
|
+
// `CREATE_RequestInquiry_*` — 355 KB generated, 5.5x the per-tool budget on
|
|
69
|
+
// its own, and it asks *for* money rather than sending it. Neither half of
|
|
70
|
+
// that is worth solving for a capability nobody asked for.
|
|
71
|
+
//
|
|
72
|
+
// `schedule-payment` and `schedule` — recurring payments. They fail cycle
|
|
73
|
+
// cutting as well, and a standing order that an agent can create is a
|
|
74
|
+
// different risk from a payment it can make: one is a decision taken once,
|
|
75
|
+
// the other repeats without anybody looking. The bunq app does this well.
|
|
76
|
+
//
|
|
77
|
+
// Every `monetary-account-*` creation path — opening and closing accounts is
|
|
78
|
+
// not paying bills, and `CREATE_MonetaryAccountBank` is a 15 KB body whose
|
|
79
|
+
// only purpose here would be to widen what a compromised session can do.
|
|
80
|
+
//
|
|
81
|
+
// `card-*` — ordering and freezing cards, same argument.
|
|
82
|
+
] as const;
|
|
83
|
+
|
|
84
|
+
const METHODS = ['get', 'post', 'put', 'patch', 'delete', 'head'];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Drop every referenced parameter, which for bunq means every protocol header.
|
|
88
|
+
*
|
|
89
|
+
* bunq declares seven headers on almost every operation — `Cache-Control`,
|
|
90
|
+
* `User-Agent`, `X-Bunq-Language`, `X-Bunq-Region`, `X-Bunq-Geolocation`,
|
|
91
|
+
* `X-Bunq-Client-Request-Id` and `X-Bunq-Client-Authentication` — and the
|
|
92
|
+
* generator turns each into a tool argument. That is wrong twice over. They are
|
|
93
|
+
* noise on every schema, and the last one is the **session token**: leaving it
|
|
94
|
+
* as an argument invites a model to fill it in, and means a tool call could
|
|
95
|
+
* carry a credential the strategy is supposed to own.
|
|
96
|
+
*
|
|
97
|
+
* Every parameter bunq puts in `components.parameters` is one of these, so
|
|
98
|
+
* dropping referenced parameters wholesale is exact rather than a heuristic —
|
|
99
|
+
* the path parameters that must survive are declared inline. Google's script
|
|
100
|
+
* needs a name list for the same job because Google mixes the two.
|
|
101
|
+
*/
|
|
102
|
+
function dropProtocolParameters(item: Record<string, unknown>): number {
|
|
103
|
+
let dropped = 0;
|
|
104
|
+
|
|
105
|
+
const filter = (holder: Record<string, unknown>): void => {
|
|
106
|
+
const parameters = holder['parameters'];
|
|
107
|
+
if (!Array.isArray(parameters)) return;
|
|
108
|
+
|
|
109
|
+
const kept = parameters.filter((parameter) => {
|
|
110
|
+
const reference = (parameter as { $ref?: string }).$ref;
|
|
111
|
+
if (typeof reference !== 'string' || !reference.startsWith('#/components/parameters/')) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
dropped++;
|
|
115
|
+
return false;
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (kept.length > 0) holder['parameters'] = kept;
|
|
119
|
+
else delete holder['parameters'];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
filter(item);
|
|
123
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
124
|
+
if (!METHODS.includes(method)) continue;
|
|
125
|
+
filter(operation as Record<string, unknown>);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return dropped;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Strip the properties OpenAPI already marks as response-only.
|
|
133
|
+
*
|
|
134
|
+
* bunq describes a request body by pointing at the *whole* resource, so the
|
|
135
|
+
* body schema for creating a payment carries `id`, `created`, `updated`,
|
|
136
|
+
* `balance_after_mutation` and two dozen more — fields bunq computes and
|
|
137
|
+
* ignores on the way in. The document says so itself: they carry
|
|
138
|
+
* `readOnly: true`, which the specification defines as "MAY be sent as part of
|
|
139
|
+
* a response and SHOULD NOT be sent as part of the request".
|
|
140
|
+
*
|
|
141
|
+
* So this is the document's own judgement applied rather than ours invented,
|
|
142
|
+
* which is the difference between this and a hand-written field list that would
|
|
143
|
+
* go stale. It is safe only because responses have already been replaced above:
|
|
144
|
+
* these schemas are now reachable from request bodies alone.
|
|
145
|
+
*
|
|
146
|
+
* **A schema left with nothing is opened rather than emptied.** Nine of bunq's
|
|
147
|
+
* are, `LabelMonetaryAccount` among them — and that one is `counterparty_alias`
|
|
148
|
+
* on a payment, which is to say the single field that decides who gets the
|
|
149
|
+
* money. Every property of it is marked read-only because the document points
|
|
150
|
+
* the *request* at the response type; what a payment actually takes there is a
|
|
151
|
+
* `Pointer`, `{ type, value, name }`, as bunq's own guide says and as this
|
|
152
|
+
* provider's `hints` repeat. Neither shape is worth asserting from here. What
|
|
153
|
+
* matters is the difference between `{}` — which reads as "this field takes
|
|
154
|
+
* nothing", and is the one thing that is certainly false — and an open object,
|
|
155
|
+
* which reads as "send what the description says".
|
|
156
|
+
*/
|
|
157
|
+
function dropReadOnly(node: unknown): number {
|
|
158
|
+
if (node === null || typeof node !== 'object') return 0;
|
|
159
|
+
if (Array.isArray(node)) return node.reduce<number>((total, item) => total + dropReadOnly(item), 0);
|
|
160
|
+
|
|
161
|
+
let dropped = 0;
|
|
162
|
+
const record = node as Record<string, unknown>;
|
|
163
|
+
const properties = record['properties'];
|
|
164
|
+
|
|
165
|
+
if (properties && typeof properties === 'object') {
|
|
166
|
+
const fields = properties as Record<string, unknown>;
|
|
167
|
+
const writable = Object.entries(fields).filter(
|
|
168
|
+
([, schema]) => (schema as { readOnly?: boolean })?.readOnly !== true,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
if (writable.length === 0 && Object.keys(fields).length > 0) {
|
|
172
|
+
delete record['properties'];
|
|
173
|
+
record['additionalProperties'] = true;
|
|
174
|
+
record['description'] =
|
|
175
|
+
`${record['description'] ?? ''} Every field bunq documents here is read-only, so its request shape is not described by the specification — send the object the tool description names.`.trim();
|
|
176
|
+
return dropped;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const [name, schema] of Object.entries(fields)) {
|
|
180
|
+
if ((schema as { readOnly?: boolean })?.readOnly === true) {
|
|
181
|
+
delete fields[name];
|
|
182
|
+
dropped++;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const value of Object.values(record)) dropped += dropReadOnly(value);
|
|
188
|
+
return dropped;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The operations bunq describes with the schema of a *different* operation.
|
|
193
|
+
*
|
|
194
|
+
* `PUT .../draft-payment/{itemId}` points at `DraftPayment`, the same schema as
|
|
195
|
+
* the `POST` that creates one, which requires `entries` and
|
|
196
|
+
* `number_of_required_accepts`. For a create those two *are* the payment. For an
|
|
197
|
+
* update bunq refuses both as superfluous — its own generated SDK sends only
|
|
198
|
+
* `status`, `previous_updated_timestamp` and `schedule` here — so the tool asked
|
|
199
|
+
* for exactly what the bank rejects and every accept failed. Worse than the
|
|
200
|
+
* error: made to send `entries` for a draft that has them, a model reaches for
|
|
201
|
+
* the array echoed back or for `[]`, and both ask bunq to rewrite what the draft
|
|
202
|
+
* pays on the way to approving it. A hint cannot fix a required argument, which
|
|
203
|
+
* is the difference between this and the `payments`-is-really-an-array note
|
|
204
|
+
* above: nothing stops an agent sending an array, and nothing lets it omit a
|
|
205
|
+
* required field. `required` is asserted here rather than projected because
|
|
206
|
+
* bunq's own is the one written for the create.
|
|
207
|
+
*
|
|
208
|
+
* `schedule` is deliberately not projected: it carries `recurrence_unit` and
|
|
209
|
+
* `recurrence_size`, so approving a one-off draft would be a place to acquire a
|
|
210
|
+
* standing order — the risk `OPERATIONS` gives for leaving the scheduling
|
|
211
|
+
* *endpoints* out. Not the whole of that risk, though: `CREATE_DraftPayment`
|
|
212
|
+
* still offers the same field, because it comes with bunq's create schema.
|
|
213
|
+
* Closing that changes what the provider can do rather than whether a call
|
|
214
|
+
* works, and is not done here.
|
|
215
|
+
*/
|
|
216
|
+
const UPDATE_BODIES: Record<string, readonly string[]> = {
|
|
217
|
+
UPDATE_DraftPayment_for_User_MonetaryAccount: ['status', 'previous_updated_timestamp'],
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
async function vendor(): Promise<void> {
|
|
221
|
+
const response = await fetch(SOURCE);
|
|
222
|
+
if (!response.ok) throw new Error(`${SOURCE}: HTTP ${response.status}`);
|
|
223
|
+
const spec = (await response.json()) as Spec;
|
|
224
|
+
|
|
225
|
+
const wanted = new Set<string>(OPERATIONS);
|
|
226
|
+
const paths: Spec['paths'] = {};
|
|
227
|
+
const seen = new Set<string>();
|
|
228
|
+
|
|
229
|
+
for (const [path, item] of Object.entries(spec.paths)) {
|
|
230
|
+
const kept: Record<string, unknown> = {};
|
|
231
|
+
|
|
232
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
233
|
+
// Path-level keys are not operations and must survive — `parameters`
|
|
234
|
+
// here holds what every method on the path shares.
|
|
235
|
+
if (!METHODS.includes(method)) {
|
|
236
|
+
kept[method] = operation;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const operationId = operation.operationId;
|
|
240
|
+
if (!operationId || !wanted.has(operationId)) continue;
|
|
241
|
+
kept[method] = operation;
|
|
242
|
+
seen.add(operationId);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!Object.keys(kept).some((key) => METHODS.includes(key))) continue;
|
|
246
|
+
paths[path] = kept as Spec['paths'][string];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Drop response schemas, before `referenced` so nothing they reach is kept.
|
|
250
|
+
//
|
|
251
|
+
// The same two reasons as Google's, in the same order of importance. Nothing
|
|
252
|
+
// reads them — the connector hands the body back as text. And bunq's response
|
|
253
|
+
// schemas are where most of the recursion lives, so keeping them would drag
|
|
254
|
+
// the whole `Payment`/`RequestInquiry` cycle into a document that does not
|
|
255
|
+
// otherwise need it.
|
|
256
|
+
for (const item of Object.values(paths)) {
|
|
257
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
258
|
+
if (!METHODS.includes(method)) continue;
|
|
259
|
+
(operation as { responses?: unknown }).responses = {
|
|
260
|
+
'200': { description: 'Success. The response body is returned verbatim.' },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let headers = 0;
|
|
266
|
+
for (const item of Object.values(paths)) {
|
|
267
|
+
headers += dropProtocolParameters(item as unknown as Record<string, unknown>);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const missing = OPERATIONS.filter((operationId) => !seen.has(operationId));
|
|
271
|
+
if (missing.length > 0) {
|
|
272
|
+
// Loudly: an upstream rename should fail the refresh rather than quietly
|
|
273
|
+
// shrinking what this provider can do.
|
|
274
|
+
throw new Error(`these operations are not in the spec — ${missing.join(', ')}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const schemas = spec.components?.schemas ?? {};
|
|
278
|
+
|
|
279
|
+
const projected = new Set<string>();
|
|
280
|
+
for (const item of Object.values(paths)) {
|
|
281
|
+
for (const [method, operation] of Object.entries(item)) {
|
|
282
|
+
if (!METHODS.includes(method)) continue;
|
|
283
|
+
const fields = operation.operationId ? UPDATE_BODIES[operation.operationId] : undefined;
|
|
284
|
+
if (!fields || !operation.operationId) continue;
|
|
285
|
+
|
|
286
|
+
projectRequestBody(
|
|
287
|
+
operation as unknown as Record<string, unknown>,
|
|
288
|
+
operation.operationId,
|
|
289
|
+
schemas,
|
|
290
|
+
fields,
|
|
291
|
+
'The whole of what this call takes. bunq refuses any other field here as superfluous — the rest ' +
|
|
292
|
+
'of the schema it shares belongs to the call that creates one.',
|
|
293
|
+
);
|
|
294
|
+
projected.add(operation.operationId);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The same refusal as `missing` above, and for a sharper reason: an entry that
|
|
299
|
+
// matches nothing narrows nothing, and what is left is the wide body this
|
|
300
|
+
// exists to remove — printed as a success.
|
|
301
|
+
const unprojected = Object.keys(UPDATE_BODIES).filter((id) => !projected.has(id));
|
|
302
|
+
if (unprojected.length > 0) {
|
|
303
|
+
throw new Error(`UPDATE_BODIES names operations the spec does not have — ${unprojected.join(', ')}`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const keep = referenced(paths, schemas);
|
|
307
|
+
const trimmedSchemas = Object.fromEntries(
|
|
308
|
+
Object.entries(schemas).filter(([name]) => keep.has(name)),
|
|
309
|
+
);
|
|
310
|
+
const cuts = cutCycles(trimmedSchemas);
|
|
311
|
+
const readOnly = dropReadOnly(trimmedSchemas);
|
|
312
|
+
|
|
313
|
+
const trimmed: Spec = {
|
|
314
|
+
openapi: spec.openapi,
|
|
315
|
+
info: {
|
|
316
|
+
...spec.info,
|
|
317
|
+
// Replaced, not carried. bunq's own `description` is 73 KB of product
|
|
318
|
+
// tour — 78% of what this file would otherwise weigh — and it is
|
|
319
|
+
// documentation of the bank rather than of the API. It also carries
|
|
320
|
+
// example addresses at bunq's own registrable domain, which
|
|
321
|
+
// `architecture.test.ts` refuses anywhere a reader of this repository can
|
|
322
|
+
// see. Both problems are the same field, and dropping it is better than
|
|
323
|
+
// exempting the file: an exemption would also cover whatever the next
|
|
324
|
+
// refresh brings in.
|
|
325
|
+
description:
|
|
326
|
+
'Trimmed from the published bunq specification. See x-vendored-from for the original, and https://doc.bunq.com for the reference.',
|
|
327
|
+
'x-vendored-from': SOURCE,
|
|
328
|
+
'x-vendored-note':
|
|
329
|
+
'Trimmed by src/providers/bunq/specs/vendor.ts. Committed deliberately: a spec decides which paths are called with the operator credential, and this one can move money.',
|
|
330
|
+
},
|
|
331
|
+
// Rewritten rather than carried over. bunq's document declares both the
|
|
332
|
+
// sandbox and production servers with a templated `{basePath}`, and the
|
|
333
|
+
// manifest names one concrete `base_url` — leaving two in the spec invites
|
|
334
|
+
// a reader to think the choice is made here. It is made by the strategy.
|
|
335
|
+
servers: [{ url: 'https://api.bunq.com/v1' }],
|
|
336
|
+
paths,
|
|
337
|
+
// Schemas only. Carrying `...spec.components` kept bunq's `parameters`,
|
|
338
|
+
// `responses` and `headers` — none of them referenced by a surviving path,
|
|
339
|
+
// and among them the definition of `X-Bunq-Client-Authentication`, which
|
|
340
|
+
// `dropProtocolParameters` above exists to make unreachable. A committed
|
|
341
|
+
// spec is committed so it can be read; dead definitions of the session
|
|
342
|
+
// header are the opposite of that.
|
|
343
|
+
components: { schemas: trimmedSchemas },
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const directory = import.meta.dir;
|
|
347
|
+
await mkdir(directory, { recursive: true });
|
|
348
|
+
await writeFile(join(directory, OUT), `${JSON.stringify(trimmed, null, 2)}\n`);
|
|
349
|
+
|
|
350
|
+
const size = Math.round(JSON.stringify(trimmed).length / 1024);
|
|
351
|
+
console.log(
|
|
352
|
+
` bunq ${String(Object.keys(paths).length).padStart(2)} paths, ` +
|
|
353
|
+
`${seen.size} operations, ${Object.keys(trimmedSchemas).length} schemas, ` +
|
|
354
|
+
`${cuts} cycle${cuts === 1 ? '' : 's'} cut, ${headers} protocol params dropped, ` +
|
|
355
|
+
`${readOnly} read-only fields dropped, ${projected.size} update ` +
|
|
356
|
+
`bod${projected.size === 1 ? 'y' : 'ies'} projected, ${size}KB`,
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
await report(trimmed);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* What the budget is actually about: the **generated** input schema.
|
|
364
|
+
*
|
|
365
|
+
* Orders of magnitude away from the spec size on the line above, because
|
|
366
|
+
* `$ref`s are inlined. Printed so a refresh that adds an operation shows what
|
|
367
|
+
* it costs, rather than leaving `cli/tools.test.ts` to say so afterwards.
|
|
368
|
+
*/
|
|
369
|
+
async function report(trimmed: Spec): Promise<void> {
|
|
370
|
+
try {
|
|
371
|
+
const generator = await OpenAPIToolGenerator.fromJSON(trimmed);
|
|
372
|
+
const tools = await generator.generateTools();
|
|
373
|
+
|
|
374
|
+
const measured = tools
|
|
375
|
+
.map((tool: McpOpenAPITool) => ({
|
|
376
|
+
name: tool.metadata.operationId ?? tool.name,
|
|
377
|
+
kb: JSON.stringify(tool.inputSchema).length / 1024,
|
|
378
|
+
}))
|
|
379
|
+
.sort((a, b) => b.kb - a.kb);
|
|
380
|
+
|
|
381
|
+
for (const { name, kb } of measured.slice(0, 4)) {
|
|
382
|
+
const over = kb > BUDGET_KB ? ` ✗ over the ${BUDGET_KB}KB budget` : '';
|
|
383
|
+
console.log(` ${name.padEnd(46)} ${kb.toFixed(1).padStart(7)}KB${over}`);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const surface = tools.reduce(
|
|
387
|
+
(total, tool) => total + JSON.stringify({ description: tool.description, inputSchema: tool.inputSchema }).length,
|
|
388
|
+
0,
|
|
389
|
+
);
|
|
390
|
+
console.log(` ${'whole advertised surface'.padEnd(46)} ${(surface / 1024).toFixed(1).padStart(7)}KB`);
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.log(` (could not measure generated schemas: ${String(error)})`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
await vendor();
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { signBody } from './keys.ts';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* bunq's three-step API context, which is why this provider needs code at all.
|
|
5
|
+
*
|
|
6
|
+
* Most APIs take a key and are done. bunq takes a key and then wants an
|
|
7
|
+
* *installation* (here is my public key), a *device* (this key may be used, from
|
|
8
|
+
* these addresses), and a *session* (and now let me in) — three round trips
|
|
9
|
+
* producing three different tokens, of which only the last one authenticates an
|
|
10
|
+
* ordinary call. No manifest field describes that, which is the whole argument
|
|
11
|
+
* for `AuthStrategy` in ADR-008.
|
|
12
|
+
*
|
|
13
|
+
* Nothing in this file names an operation. It knows how to open a session and
|
|
14
|
+
* nothing about what the session is then used for.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export const PRODUCTION = 'https://api.bunq.com/v1';
|
|
18
|
+
export const SANDBOX = 'https://public-api.sandbox.bunq.com/v1';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Which bunq this connection talks to: whatever its manifest says.
|
|
22
|
+
*
|
|
23
|
+
* One provider serves both environments, because they are the same API and the
|
|
24
|
+
* same tool list — a second provider id would duplicate the manifest, the
|
|
25
|
+
* vendored spec, and every policy rule written against it. The built-in names
|
|
26
|
+
* production; a workspace manifest in `providers.d/` naming the sandbox gets
|
|
27
|
+
* the sandbox, and borrows this strategy through `strategyFor`.
|
|
28
|
+
*
|
|
29
|
+
* This used to be a `sandbox: true` option on the strategy, with `authorize`
|
|
30
|
+
* rewriting the request origin to match. That was a second source of truth for
|
|
31
|
+
* something `base_url` already states, and the two could disagree — a manifest
|
|
32
|
+
* pointed at the sandbox but missing the flag (or carrying `sandbox: "true"`,
|
|
33
|
+
* which is not `true`) would have spent against production while its own
|
|
34
|
+
* `base_url` said otherwise. Reading the manifest makes the disagreement
|
|
35
|
+
* impossible rather than documented, and the transport and the handshake now
|
|
36
|
+
* cannot end up on different hosts because neither chooses.
|
|
37
|
+
*/
|
|
38
|
+
export function hostFor(manifest: { connector: { kind: string } }): string {
|
|
39
|
+
const connector = manifest.connector as { kind: string; base_url?: string };
|
|
40
|
+
if (connector.kind !== 'http' || !connector.base_url) {
|
|
41
|
+
throw new Error('The bunq strategy needs an http connector with a base_url.');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return connector.base_url.replace(/\/$/, '');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface Installation {
|
|
48
|
+
/** Authenticates device-server and session-server, and nothing else. */
|
|
49
|
+
readonly token: string;
|
|
50
|
+
/** bunq's half, for checking that a reply is bunq's. */
|
|
51
|
+
readonly serverPublicKey: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Everything bunq requires on a request that is not the signature.
|
|
56
|
+
*
|
|
57
|
+
* `X-Bunq-Client-Request-Id` must differ per request; bunq uses it to
|
|
58
|
+
* de-duplicate, so reusing one would make a retried payment silently a no-op —
|
|
59
|
+
* which is the behaviour you want, but only if the id is genuinely fresh when
|
|
60
|
+
* the payment is genuinely new.
|
|
61
|
+
*/
|
|
62
|
+
export function baseHeaders(): Record<string, string> {
|
|
63
|
+
return {
|
|
64
|
+
// No `content-type`. Every handshake call below adds it because every one
|
|
65
|
+
// of them posts JSON, but `authorize` applies this set to reads too, and a
|
|
66
|
+
// GET that carries no body should not claim one.
|
|
67
|
+
'cache-control': 'no-cache',
|
|
68
|
+
'user-agent': 'lanes-link/1.0',
|
|
69
|
+
'x-bunq-language': 'en_US',
|
|
70
|
+
'x-bunq-region': 'en_US',
|
|
71
|
+
'x-bunq-geolocation': '0 0 0 0 000',
|
|
72
|
+
'x-bunq-client-request-id': crypto.randomUUID(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* bunq answers everything as `{ Response: [ { Key: value }, … ] }`.
|
|
78
|
+
*
|
|
79
|
+
* An array of single-key objects rather than one object, so reading a field
|
|
80
|
+
* means searching for the wrapper that holds it.
|
|
81
|
+
*/
|
|
82
|
+
function pick(payload: unknown, wrapper: string): Record<string, unknown> | undefined {
|
|
83
|
+
const entries = (payload as { Response?: unknown[] } | null)?.Response;
|
|
84
|
+
if (!Array.isArray(entries)) return undefined;
|
|
85
|
+
|
|
86
|
+
for (const entry of entries) {
|
|
87
|
+
const found = (entry as Record<string, unknown>)?.[wrapper];
|
|
88
|
+
if (found && typeof found === 'object') return found as Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function call(
|
|
95
|
+
url: string,
|
|
96
|
+
body: unknown,
|
|
97
|
+
headers: Record<string, string>,
|
|
98
|
+
fetcher: typeof globalThis.fetch,
|
|
99
|
+
): Promise<unknown> {
|
|
100
|
+
const payload = JSON.stringify(body);
|
|
101
|
+
const response = await fetcher(url, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
104
|
+
body: payload,
|
|
105
|
+
});
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
|
|
108
|
+
if (!response.ok) {
|
|
109
|
+
// bunq's errors are readable and specific — a wrong key, an IP that is not
|
|
110
|
+
// permitted, a session created too soon. Passing the text through is what
|
|
111
|
+
// makes the difference visible; swallowing it leaves "connect failed".
|
|
112
|
+
throw new Error(`bunq ${new URL(url).pathname} answered ${response.status}: ${text}`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return JSON.parse(text);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Step one: hand bunq the public key. The only call needing no authentication
|
|
120
|
+
* and no signature, because bunq has nothing to check one against yet.
|
|
121
|
+
*/
|
|
122
|
+
export async function createInstallation(
|
|
123
|
+
host: string,
|
|
124
|
+
publicKey: string,
|
|
125
|
+
fetcher: typeof globalThis.fetch,
|
|
126
|
+
): Promise<Installation> {
|
|
127
|
+
const payload = await call(
|
|
128
|
+
`${host}/installation`,
|
|
129
|
+
{ client_public_key: publicKey },
|
|
130
|
+
baseHeaders(),
|
|
131
|
+
fetcher,
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const token = pick(payload, 'Token')?.['token'];
|
|
135
|
+
const serverPublicKey = pick(payload, 'ServerPublicKey')?.['server_public_key'];
|
|
136
|
+
|
|
137
|
+
if (typeof token !== 'string' || typeof serverPublicKey !== 'string') {
|
|
138
|
+
throw new Error('bunq /installation returned no token or no server public key.');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { token, serverPublicKey };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Step two: register the key against the addresses it may be used from.
|
|
146
|
+
*
|
|
147
|
+
* `permitted_ips` is deliberately not passed. bunq refuses the wildcard `*` over
|
|
148
|
+
* the API — it can only be set in the app — and any list written here would be
|
|
149
|
+
* this machine's address at connect time, which is wrong the moment the
|
|
150
|
+
* endpoint is deployed or the operator's ISP renumbers. Omitting it lets bunq
|
|
151
|
+
* default to the calling address, and the setup doc says plainly that a
|
|
152
|
+
* deployment needs a key marked wildcard in the app.
|
|
153
|
+
*
|
|
154
|
+
* Idempotent in practice: re-registering an existing device answers 200.
|
|
155
|
+
*/
|
|
156
|
+
export async function registerDevice(
|
|
157
|
+
host: string,
|
|
158
|
+
installationToken: string,
|
|
159
|
+
apiKey: string,
|
|
160
|
+
description: string,
|
|
161
|
+
privateKey: string,
|
|
162
|
+
fetcher: typeof globalThis.fetch,
|
|
163
|
+
): Promise<void> {
|
|
164
|
+
const body = { secret: apiKey, description };
|
|
165
|
+
|
|
166
|
+
await call(
|
|
167
|
+
`${host}/device-server`,
|
|
168
|
+
body,
|
|
169
|
+
{
|
|
170
|
+
...baseHeaders(),
|
|
171
|
+
'x-bunq-client-authentication': installationToken,
|
|
172
|
+
'x-bunq-client-signature': signBody(JSON.stringify(body), privateKey),
|
|
173
|
+
},
|
|
174
|
+
fetcher,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Step three, and the only one that runs again later.
|
|
180
|
+
*
|
|
181
|
+
* A session lasts as long as the account's auto-logout setting — a week by
|
|
182
|
+
* default — and `/session-server` is rate-limited to **one call per thirty
|
|
183
|
+
* seconds**. That limit is the reason the token is persisted in shared state
|
|
184
|
+
* rather than held per instance: a deployed endpoint that opened a session per
|
|
185
|
+
* cold start would spend most of its life being refused.
|
|
186
|
+
*/
|
|
187
|
+
export async function createSession(
|
|
188
|
+
host: string,
|
|
189
|
+
installationToken: string,
|
|
190
|
+
apiKey: string,
|
|
191
|
+
privateKey: string,
|
|
192
|
+
fetcher: typeof globalThis.fetch,
|
|
193
|
+
): Promise<string> {
|
|
194
|
+
const body = { secret: apiKey };
|
|
195
|
+
|
|
196
|
+
const payload = await call(
|
|
197
|
+
`${host}/session-server`,
|
|
198
|
+
body,
|
|
199
|
+
{
|
|
200
|
+
...baseHeaders(),
|
|
201
|
+
'x-bunq-client-authentication': installationToken,
|
|
202
|
+
'x-bunq-client-signature': signBody(JSON.stringify(body), privateKey),
|
|
203
|
+
},
|
|
204
|
+
fetcher,
|
|
205
|
+
);
|
|
206
|
+
|
|
207
|
+
const token = pick(payload, 'Token')?.['token'];
|
|
208
|
+
if (typeof token !== 'string') throw new Error('bunq /session-server returned no token.');
|
|
209
|
+
|
|
210
|
+
return token;
|
|
211
|
+
}
|