@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.
Files changed (69) hide show
  1. package/README.md +11 -3
  2. package/instructions/agents/lanes-link-scout.md +2 -2
  3. package/instructions/skills/lanes-link/SKILL.md +135 -11
  4. package/package.json +3 -1
  5. package/src/cli/argv.ts +52 -0
  6. package/src/cli/commands/connect/authorise.ts +5 -0
  7. package/src/cli/commands/connect/custom/ask.ts +167 -0
  8. package/src/cli/commands/connect/custom/credential.ts +143 -0
  9. package/src/cli/commands/connect/custom/derive.ts +229 -0
  10. package/src/cli/commands/connect/custom/index.ts +285 -0
  11. package/src/cli/commands/connect/custom/prompts.ts +160 -0
  12. package/src/cli/commands/connect/custom/spec.ts +293 -0
  13. package/src/cli/commands/connect/custom/values.ts +53 -0
  14. package/src/cli/commands/connect/custom/write.ts +166 -0
  15. package/src/cli/commands/connect/grant.ts +27 -0
  16. package/src/cli/commands/connect/index.ts +24 -27
  17. package/src/cli/commands/connect/outcome.ts +3 -1
  18. package/src/cli/commands/connect/requirements.ts +11 -1
  19. package/src/cli/commands/connect/settle.ts +17 -0
  20. package/src/cli/commands/connect/setup.ts +9 -1
  21. package/src/cli/commands/connect/strategy.ts +87 -0
  22. package/src/cli/commands/connect/unknown.ts +41 -0
  23. package/src/cli/commands/mcp/list.ts +123 -29
  24. package/src/cli/identity.ts +29 -5
  25. package/src/cli/main.ts +42 -14
  26. package/src/cli/oauth.ts +89 -36
  27. package/src/cli/runtime/open.ts +13 -1
  28. package/src/cli/runtime/registry.ts +12 -0
  29. package/src/cli/selection.ts +12 -0
  30. package/src/cli/usage.ts +9 -1
  31. package/src/connectivity/auth/README.md +8 -1
  32. package/src/connectivity/auth/strategy/index.ts +128 -4
  33. package/src/connectivity/connector.ts +11 -0
  34. package/src/connectivity/index.ts +11 -1
  35. package/src/connectivity/manifest/auth.ts +19 -0
  36. package/src/connectivity/manifest/connector.ts +21 -0
  37. package/src/connectivity/manifest/primitives.ts +5 -1
  38. package/src/connectivity/manifest/provider.ts +30 -12
  39. package/src/connectivity/provider.ts +55 -0
  40. package/src/connectivity/transports/factory.ts +1 -0
  41. package/src/connectivity/transports/http/index.ts +73 -2
  42. package/src/dispatch/dispatch.ts +44 -5
  43. package/src/providers/bunq/hints.ts +45 -0
  44. package/src/providers/bunq/index.ts +87 -0
  45. package/src/providers/bunq/redact.ts +75 -0
  46. package/src/providers/bunq/specs/bunq.v1.json +883 -0
  47. package/src/providers/bunq/specs/vendor.ts +396 -0
  48. package/src/providers/bunq/strategy/handshake.ts +211 -0
  49. package/src/providers/bunq/strategy/index.ts +298 -0
  50. package/src/providers/bunq/strategy/keys.ts +72 -0
  51. package/src/providers/custom/index.ts +1 -6
  52. package/src/providers/custom/load.ts +56 -14
  53. package/src/providers/custom/template.ts +1 -1
  54. package/src/providers/discord/hints.ts +195 -0
  55. package/src/providers/discord/index.ts +121 -0
  56. package/src/providers/discord/redact.ts +99 -0
  57. package/src/providers/discord/specs/discord.v10.json +2333 -0
  58. package/src/providers/discord/specs/vendor.ts +164 -0
  59. package/src/providers/google/specs/vendor.ts +32 -317
  60. package/src/providers/index.ts +9 -0
  61. package/src/providers/reddit/index.ts +113 -0
  62. package/src/providers/reddit/oauth.ts +77 -0
  63. package/src/providers/reddit/redact.ts +33 -0
  64. package/src/providers/reddit/scopes.ts +27 -0
  65. package/src/providers/reddit/specs/reddit.v1.json +700 -0
  66. package/src/providers/scopes.ts +2 -0
  67. package/src/providers/shared/openapi.ts +155 -0
  68. package/src/providers/shared/vendor-operations.ts +179 -0
  69. 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
+ }