@cube-nl/dmp-sdk 0.0.0-stage → 1.0.0-rc.4

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 CHANGED
@@ -1,3 +1,721 @@
1
- # Temporary Holding Version
1
+ # @cube-nl/dmp-sdk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Nederlands.** De TypeScript-SDK voor het DMP: één client waarmee een app de REST-API van
4
+ `cube/api` aanroept, in de browser of in Node 20+. Zonder runtime-dependencies: alleen `fetch`,
5
+ `URL` en Web Crypto. Hij spreekt precies de wire-format van `cube/api` op `5.0.x` —
6
+ `page[size]`/`page[number]`, `filter[veld][operator]`, `If-Match` met `X-Dmp-Record-Version`,
7
+ `Idempotency-Key` — en gooit elke weigering als een `DmpError` met de stabiele `code` en de
8
+ `reason` van de API. Inloggen gaat met OAuth 2.1 + PKCE (S256) tegen `cube/oauth-server`, of met
9
+ een persoonlijk token voor een PoC. Het token blijft standaard alleen in het geheugen.
10
+
11
+ Dit pakket vervangt `dmp.js` en `oauth.js` uit de hackathon-starter van `cube/resources`
12
+ (`docs/hackathon/starter/public/`): dezelfde logica, getypt en getest. Het staat openbaar op npm:
13
+ `npm install @cube-nl/dmp-sdk@next` (zolang het een release candidate is), of als losse
14
+ `cube-sdk.js` via jsDelivr of unpkg.
15
+
16
+ Besluit OP-05: **één client, resources als eigenschap** —
17
+ `createClient(...).resources('projects').list()`, geen ingang per soort.
18
+
19
+ ---
20
+
21
+ The rest of this document is in English.
22
+
23
+ - [Install](#install)
24
+ - [Quick start](#quick-start)
25
+ - [Authentication](#authentication)
26
+ - [Reading](#reading)
27
+ - [Writing](#writing)
28
+ - [Custom operations](#custom-operations)
29
+ - [Errors](#errors)
30
+ - [Voor AI-tools](#voor-ai-tools)
31
+ - [Route B: apps without a build step](#route-b-apps-without-a-build-step)
32
+ - [What this SDK deliberately does not do](#what-this-sdk-deliberately-does-not-do)
33
+ - [Development](#development)
34
+ - [Integration tests](#integration-tests)
35
+
36
+ ## Install
37
+
38
+ Published on the public npm registry as [`@cube-nl/dmp-sdk`](https://www.npmjs.com/package/@cube-nl/dmp-sdk):
39
+
40
+ ```bash
41
+ npm install @cube-nl/dmp-sdk@next # while 1.0.0 is a release candidate
42
+ npm install @cube-nl/dmp-sdk # once a stable version is out
43
+ ```
44
+
45
+ Release candidates go to the `next` dist-tag, stable versions to `latest`. The package is an ES
46
+ module with its own `.d.ts`; there is no CommonJS build.
47
+
48
+ For an app without a build step, the single-file browser build is on the npm CDNs. Pin an exact
49
+ version:
50
+
51
+ ```html
52
+ <script src="https://cdn.jsdelivr.net/npm/@cube-nl/dmp-sdk@1.0.0-rc.4/dist/cube-sdk.js"></script>
53
+ <!-- or -->
54
+ <script src="https://unpkg.com/@cube-nl/dmp-sdk@1.0.0-rc.4/dist/cube-sdk.js"></script>
55
+ ```
56
+
57
+ Fallback, for an unreleased commit: `npm install
58
+ git+ssh://git@gitlab.cube.nl/cube/packages/laravel/filament/dmp/sdk-js.git#5.0.x` (needs access to
59
+ our GitLab; `prepare` builds `dist/`).
60
+
61
+ ## Quick start
62
+
63
+ These ten lines ran as they are (24-09-2026, Node 24, installed from `npm pack`) against the
64
+ hackathon demo DMP on `127.0.0.1:8000`, with a personal token in `DMP_TOKEN`:
65
+
66
+ ```js
67
+ import { createClient } from '@cube-nl/dmp-sdk';
68
+
69
+ const dmp = createClient({ baseUrl: 'http://127.0.0.1:8000/apps/api/v1', auth: process.env.DMP_TOKEN });
70
+
71
+ const { resources } = await dmp.capabilities(); // always first
72
+ console.log(resources.map((resource) => resource.name)); // [ 'tasks', 'projects' ]
73
+
74
+ const projects = await dmp.resources('projects').list({
75
+ fields: ['id', 'name', 'budget'], filter: { status: 'actief' }, sort: '-budget', page: { size: 3 },
76
+ });
77
+ console.log(projects.data, projects.meta.page); // rows, { size, number, total, pages }
78
+ ```
79
+
80
+ ```text
81
+ [ 'tasks', 'projects' ]
82
+ [ { id: 2, name: 'App', budget: 30000 }, { id: 1, name: 'Website', budget: 12000 }, … ] { size: 3, number: 1, total: 8, pages: 3 }
83
+ ```
84
+
85
+ In a browser app the token comes from the OAuth session instead (see
86
+ [Authentication](#authentication)), and `baseUrl` is the "Base URL" on the panel's API → Connect
87
+ screen. Besides `data` and `meta.page`, show `meta.omitted` (relations you asked for and may not
88
+ see) as missing, not as empty.
89
+
90
+ `baseUrl` includes the API prefix (`apps/api/v1` unless the host changed `cube-apps.faces.rest.prefix`). A relative
91
+ one (`/apps/api/v1`) works in a browser behind a dev-server proxy.
92
+
93
+ ### `createClient(options)`
94
+
95
+ | Option | Meaning |
96
+ |---|---|
97
+ | `baseUrl` | Required. The API base URL, prefix included |
98
+ | `auth` | A token string, a function returning one (read on every call), `bearer(...)`, or an OAuth session from `createOAuth()`. Omitted: no `Authorization` header |
99
+ | `fetch` | Your own `fetch` (a proxy wrapper, a test double). Default: the global one |
100
+ | `headers` | Sent on every request. `X-Dmp-Act-As` goes here — on a server, never in a browser |
101
+ | `cache` | The fetch cache mode. Default `'no-cache'` in a browser (revalidate with the ETag instead of serving a stale list after a write), unset in Node |
102
+ | `onResponse` | `({ method, url, status, ms }) => void` for every call. Never receives the token |
103
+
104
+ It returns `{ capabilities(), resources(name), request(method, path, options) }`.
105
+
106
+ ## Authentication
107
+
108
+ Every request carries `Authorization: Bearer <token>`. The API refuses a browser session cookie.
109
+
110
+ ### OAuth 2.1 + PKCE (the right way for an app)
111
+
112
+ ```ts
113
+ import { createClient, createOAuth } from '@cube-nl/dmp-sdk';
114
+
115
+ const oauth = createOAuth({
116
+ issuer: 'https://dmp.example.nl', // discovery: {issuer}/.well-known/oauth-authorization-server
117
+ clientId: import.meta.env.VITE_DMP_CLIENT_ID,
118
+ redirectUri: 'http://localhost:5173/callback',// exactly as registered: scheme, host, port, path
119
+ scopes: ['api:read', 'api:write'], // api:write does not include api:read
120
+ });
121
+
122
+ const dmp = createClient({ baseUrl: 'https://dmp.example.nl/apps/api/v1', auth: oauth });
123
+
124
+ // On every page load: null when this is not the callback.
125
+ await oauth.handleCallback();
126
+
127
+ if ((await oauth.getTokens()) === null) {
128
+ await oauth.signIn(); // navigates away to the DMP's consent screen
129
+ }
130
+ ```
131
+
132
+ What it does, following `cube/oauth-server` and the starter's `oauth.js`:
133
+
134
+ - **Discovery** (RFC 8414), once, cached; a failed discovery is not cached. The endpoints are read
135
+ from there, never hardcoded. Pass `metadata` to skip it.
136
+ - **S256 or nothing.** A server that does not list `S256` in `code_challenge_methods_supported`
137
+ is refused before the browser leaves.
138
+ - **Only the `redirectUri` is a callback.** On any other page `handleCallback()`
139
+ answers `null` and touches nothing, so an app route with a `?code=` or `?error=` of its own keeps
140
+ working and a pending sign-in survives it.
141
+ - **The verifier and state** go to `sessionStorage` (or `pendingStore`), are used once and removed
142
+ the moment a matching callback is handled. A callback whose `state` was not sent from here is
143
+ refused without a token request, and an `?error=` is checked against the state too before it is
144
+ reported; neither cancels the pending sign-in. In a browser, `handleCallback()` also removes
145
+ `code` and `state` from the address bar.
146
+ - **The code exchange and refresh** are form-encoded `POST`s to `token_endpoint`.
147
+ - **`metadata` overrides, it does not have to replace.** Pass the whole document (with
148
+ `authorization_endpoint` and `token_endpoint`) to skip discovery, or only the members you want
149
+ changed on top of what discovery returns.
150
+ - **Tokens stay in memory** unless you pass a `tokenStore`. Reloading the page means signing in
151
+ again (usually without a prompt, since the person is still logged in at the DMP).
152
+ - **Refresh** happens 30 seconds before expiry (`refreshLeewaySeconds`), and once on a `401`,
153
+ after which the request is retried once. Concurrent callers share one refresh. A refused refresh
154
+ token is forgotten.
155
+
156
+ | Session method | |
157
+ |---|---|
158
+ | `discover()` | The authorization server metadata |
159
+ | `authorizationUrl({ state?, extraParams? })` | Build the URL yourself (Node, or a custom redirect) |
160
+ | `signIn({ extraParams? })` | Browser: go to that URL |
161
+ | `handleCallback(url?)` | Exchange the code; `null` when `url` is not the `redirectUri` with a `code` or `error` |
162
+ | `refresh()` | Force a refresh |
163
+ | `getTokens()` / `setTokens(tokens)` | Read or seed the store |
164
+ | `signOut()` | Forget the tokens (this does not revoke them at the DMP; the person does that under *Verbonden apps*) |
165
+
166
+ `registerClient(metadata, { clientName, redirectUris })` does RFC 7591 self-registration when the
167
+ DMP turned it on (`registration_endpoint` is in discovery), and returns a public `client_id`.
168
+
169
+ A **confidential** client (`clientSecret`) is for a server only. Anything in a browser bundle is
170
+ public; a browser app is a public client and PKCE is its proof.
171
+
172
+ #### In a browser on another origin: CORS
173
+
174
+ `cube/api` answers CORS on its own prefix when the host turns it on (`DMP_API_CORS`,
175
+ `DMP_API_CORS_ORIGINS`), and exposes every header this SDK reads. The OAuth endpoints do not:
176
+ `/.well-known/oauth-authorization-server` and `/oauth/token` send no CORS headers (checked against
177
+ `cube/oauth-server` 5.0.0-rc.1). A page on `localhost:5173` can therefore not discover or exchange a
178
+ code directly; the browser refuses it with a `TypeError`. Let the dev server forward those two
179
+ paths to the DMP (the hackathon starter does exactly that) and point the SDK at its own origin:
180
+
181
+ ```ts
182
+ const oauth = createOAuth({
183
+ issuer: location.origin, // discovery through the proxy
184
+ metadata: { token_endpoint: '/oauth/token' }, // the exchange and refresh through the proxy too
185
+ clientId, redirectUri, scopes: ['api:read'],
186
+ });
187
+ ```
188
+
189
+ The `authorization_endpoint` stays the DMP's own: that one is a navigation, not a `fetch`.
190
+
191
+ Token limits the API applies to an OAuth token: it reaches **no custom operation**, not even with
192
+ `*`; `X-Dmp-Act-As` is ignored; the scope never opens more than the person may.
193
+
194
+ ### A personal token or an API-client token
195
+
196
+ ```ts
197
+ import { bearer, createClient } from '@cube-nl/dmp-sdk';
198
+
199
+ createClient({ baseUrl, auth: process.env.DMP_TOKEN }); // a string
200
+ createClient({ baseUrl, auth: () => tokenFromSomewhere() }); // read on every call
201
+ createClient({ baseUrl, auth: bearer(() => vault.get('dmp')) }); // the same, explicitly
202
+ ```
203
+
204
+ A personal token (`$user->createToken('poc', ['projects.list'])->plainTextToken`) in a browser app
205
+ ends up in the bundle: PoC only. An API-client token that holds `_api.act_as` must never reach a
206
+ browser at all.
207
+
208
+ Your own strategy is any object with `getAccessToken()` and, optionally, `onUnauthorized()`
209
+ (resolve `true` to have the request retried once).
210
+
211
+ ## Reading
212
+
213
+ `dmp.resources<T>(name)` returns a client for one resource. The name must match
214
+ `[a-z][a-z0-9_]*`, as the API's does. Pass your own record type as `T` if you have one; the default
215
+ is `Record<string, unknown>`.
216
+
217
+ ### `list(query?)` — `GET /{resource}`
218
+
219
+ | Query | Wire | Notes |
220
+ |---|---|---|
221
+ | `fields: ['id', 'name']` | `fields[projects]=id,name` | Default: the resource's `summary`. An object writes one entry per resource, but `cube/api` reads only the queried resource's: an included relation always comes with its own summary |
222
+ | `filter: { active: true }` | `filter[active]=1` | Plain equality; booleans as `1`/`0` |
223
+ | `filter: { hours: '>=8' }` | `filter[hours]=>=8` | The six prefixes `=`, `<>`, `>`, `>=`, `<`, `<=` |
224
+ | `filter: { hours: { gte: 8, lte: 10 } }` | `filter[hours][gte]=8&filter[hours][lte]=10` | Named operators: `eq neq gt gte lt lte in like ilike is between` |
225
+ | `filter: { status: { in: ['a', 'b'] } }` | `filter[status][in]=a,b` | `in` and `between` take an array |
226
+ | `filter: { left_at: { is: 'not_null' } }` | `filter[left_at][is]=not_null` | `is` takes `'null'`, `'not_null'` or `null` |
227
+ | `sort: ['-name', 'id']` | `sort=-name,id` | `-` for descending |
228
+ | `page: { size: 25, number: 2 }` | `page[size]=25&page[number]=2` | Default 50, max 200. Above the max is a 422, never a clamp |
229
+ | `include: 'customer'` | `include=customer` | One level deep, scoped by the target's rules |
230
+ | `withCount: 'tasks'` | `with_count=tasks` | Adds `tasks_aantal` to each row; sortable |
231
+ | `has: { tasks: true }` / `{ tasks: 2 }` / `{ tasks: { min, max } }` | `has[tasks]=1` / `has[tasks][min]=2` / … | Rows whose relation holds at least/at most |
232
+ | `params: { 'x': '1' }` | `x=1` | Raw, appended last, for anything newer than this SDK |
233
+
234
+ Filters combine with AND; there is no OR, `in` stands in for it. Two mistakes are thrown as a
235
+ `TypeError` before any request: a bare array (`filter: { status: ['a', 'b'] }` — the API refuses
236
+ `filter[status][]=` because it names no operator), and an array under an operator that takes one
237
+ value.
238
+
239
+ The answer is a `ListResponse<T>`:
240
+
241
+ | Field | |
242
+ |---|---|
243
+ | `data` | The rows |
244
+ | `meta.page` | `{ size, number, total, pages }` — `total` is the whole filtered set, not this page |
245
+ | `meta.omitted` | Requested relations that came back with no data: show them as missing, not empty |
246
+ | `meta.pruned` | Relations delivered but not guaranteed complete: "not everything visible", not a total |
247
+ | `meta.forbidden_fields` | Fields withheld from an otherwise normal 200 |
248
+ | `etag` | Weak representation validator (`If-None-Match`), **never** for `If-Match` |
249
+ | `rateLimit` | `{ limit, remaining, reset }` from the `X-RateLimit-*` headers |
250
+ | `status`, `headers` | As received |
251
+
252
+ ### `pages(query?)` and `all(query?)`
253
+
254
+ Async generators that walk `meta.page` with the same query, one request per page, stopping at the
255
+ last page (or an empty one). Pagination on the API is mandatory and has no "fetch everything"; these
256
+ make it explicit that you are asking for many requests. Mind the rate limit.
257
+
258
+ ```ts
259
+ for await (const project of dmp.resources('projects').all({ page: { size: 200 } })) { … }
260
+ ```
261
+
262
+ ### `get(id, query?)` — `GET /{resource}/{id}`
263
+
264
+ Accepts only `fields`, `include` and `withCount`. The answer's `recordVersion` is the strong
265
+ validator for a later `update`/`delete`. A row outside your scope is a `404 not_found`, exactly
266
+ like one that does not exist.
267
+
268
+ An empty id, `.` or `..` is refused before a request is made: the URL parser would resolve the
269
+ last two as path steps and send the request to another endpoint. The same holds for operation names.
270
+
271
+ ### `aggregate({ fn, group?, filter?, has? })` — `GET /{resource}/_aggregate`
272
+
273
+ ```ts
274
+ const { data, meta } = await dmp.resources('employees').aggregate({
275
+ fn: ['count', 'sum(hours)'],
276
+ group: ['department_id', 'started_on.month'],
277
+ filter: { active: true },
278
+ });
279
+ data[0]['sum(hours)']; // keyed by exactly the strings you sent
280
+ meta.groups; // meta.page is null: aggregates do not paginate
281
+ ```
282
+
283
+ Functions: `count` (bare, or on a field), `sum`, `avg`, `min`, `max`, per field as
284
+ `_capabilities.resources[].aggregatable` allows. Truncations: `.day`, `.week` (ISO), `.month`,
285
+ `.quarter`, `.year`. A field you may not read is a `403 field_forbidden` here, not withheld.
286
+
287
+ ### `capabilities()` — `GET /_capabilities`
288
+
289
+ Returns `Capabilities`: `contract`, the `resources` this token and this user may reach (fields
290
+ with type/nullable/readonly, actions, operations, summary, filterable, sortable, groupable,
291
+ aggregatable, relations, countable), plus `operators`, `operator_symbols`, `aggregations` and
292
+ `pagination`. A resource you hold no ability for is simply absent. Build UIs and queries from this,
293
+ not from assumptions.
294
+
295
+ ## Writing
296
+
297
+ ```ts
298
+ import { newIdempotencyKey } from '@cube-nl/dmp-sdk';
299
+
300
+ const projects = dmp.resources('projects');
301
+
302
+ const key = newIdempotencyKey(); // one per logical create
303
+ const created = await projects.create({ name: 'X' }, { idempotencyKey: key });
304
+ created.status; // 201
305
+ created.location; // https://dmp.example.nl/apps/api/v1/projects/7
306
+ created.recordVersion; // for the next conditional write
307
+ created.replayed; // true when this is the stored answer to an earlier attempt with this key
308
+
309
+ const updated = await projects.update(7, { name: 'Y' }, { ifMatch: created.recordVersion });
310
+ await projects.delete(7, { ifMatch: updated.recordVersion }); // 204 → data: null
311
+ ```
312
+
313
+ | Method | Wire | Options |
314
+ |---|---|---|
315
+ | `create(body, opts?)` | `POST /{resource}` → 201 | `idempotencyKey`, `fields` |
316
+ | `update(id, body, opts?)` | `PATCH /{resource}/{id}` → 200, partial | `ifMatch`, `fields` |
317
+ | `delete(id, opts?)` | `DELETE /{resource}/{id}` → 204 | `ifMatch` |
318
+
319
+ - **`ifMatch` is `recordVersion` (`X-Dmp-Record-Version`), never the `etag`.** A weak tag (`W/"…"`)
320
+ is thrown as a `TypeError` before sending: the API would refuse it with a 409 anyway. `'*'` means
321
+ "must still exist". A stale version is a `409 conflict`, and the error carries the current
322
+ `recordVersion` so you can re-read or retry.
323
+ - **`idempotencyKey`**: retrying with the same key and the same body replays the first 201
324
+ (`replayed: true`) instead of writing twice. The same key with a different body is a
325
+ `422 validation_failed`. `fields` is part of what "the same request" means — repeat it. A
326
+ replay's `recordVersion` is the version at creation time; re-read before using it in `ifMatch`.
327
+ - The SDK never retries a write by itself, except once after a `401` when the auth strategy got a
328
+ new token (a 401 wrote nothing).
329
+ - `DELETE` is off on the server unless the host set `allow_delete`, whatever the schema says.
330
+
331
+ ## Custom operations
332
+
333
+ ```ts
334
+ // POST /articles/42/publish
335
+ const result = await dmp.resources('articles').operation(42, 'publish',
336
+ { publish_at: '2026-10-01T09:00:00Z' },
337
+ { idempotencyKey: newIdempotencyKey(), ifMatch: version });
338
+
339
+ // POST /articles/_operations/import
340
+ const job = await dmp.resources('articles').collectionOperation('import', { url }, { idempotencyKey: key });
341
+ job.accepted; // true on 202: queued, not done
342
+ ```
343
+
344
+ | Status | `data` | `accepted` |
345
+ |---|---|---|
346
+ | 200 | the `output` resource's record | false |
347
+ | 202 | `null` | true — queued, not done |
348
+ | 204 | `null` | false — done, nothing to return |
349
+
350
+ An operation `_capabilities` marks `idempotent: false` **requires** `idempotencyKey` (422 without
351
+ it). A wildcard `*` token does not reach operations; the token needs `{resource}.{operation}` by
352
+ name, and an OAuth token reaches none. Names starting with `_` are reserved and thrown as a
353
+ `TypeError`.
354
+
355
+ ## Errors
356
+
357
+ Every non-2xx answer throws a `DmpError`:
358
+
359
+ ```ts
360
+ import { isDmpError } from '@cube-nl/dmp-sdk';
361
+
362
+ try {
363
+ await dmp.resources('projects').list({ sort: 'nope' });
364
+ } catch (error) {
365
+ if (isDmpError(error) && error.code === 'query_invalid' && error.reason === 'not_sortable') {
366
+ error.errors?.sort; // ['Allowed values: …']
367
+ }
368
+ }
369
+ ```
370
+
371
+ | Field | |
372
+ |---|---|
373
+ | `status` | HTTP status |
374
+ | `code` | The stable code (below), an OAuth `error` from the token endpoint, or `null` when the body was not a problem (a proxy's 502 page). **Decide on this** |
375
+ | `reason` | The finer classification under `code` (`not_sortable`, `not_filterable`, `limit_too_high`, …), or `null` for "none finer" |
376
+ | `type`, `title`, `detail` | RFC 9457 members. `detail` is for people; it is also `message` |
377
+ | `errors` | Per field or parameter, e.g. `errors.sort`, `errors['Idempotency-Key']` |
378
+ | `recordVersion` | On a 409 `conflict`: the record's current version |
379
+ | `retryAfter` | Seconds, on a 429 `rate_limited` |
380
+ | `allow` | On a 405 `unsupported_action`: the actions the resource does offer (`list`, `get`, …; `cube/api` names actions here, not HTTP methods). `null` in a browser on another origin: `Allow` is not a CORS-exposed header |
381
+ | `headers`, `body`, `isProblem` | The raw material |
382
+
383
+ The codes, exactly as `cube/api` emits them:
384
+
385
+ | Status | `code` | Repair |
386
+ |---|---|---|
387
+ | 401 | `token_invalid` | Sign in again / present a valid token (the SDK already tried one refresh) |
388
+ | 403 | `resource_forbidden` | The person's own rights say no — ask an administrator |
389
+ | 403 | `client_scope_exceeded` | The token's abilities or scopes say no — reissue or ask consent for more |
390
+ | 403 | `field_forbidden` | `_aggregate` on a field you may not read |
391
+ | 403 | `client_inactive`, `client_without_user` | The `ApiClient` is off or unlinked |
392
+ | 403 | `act_as_forbidden`, `act_as_unknown` | Act-as not allowed for this token, or unknown subject |
393
+ | 404 | `not_found` | Does not exist, or is outside your scope — the API does not say which, on purpose |
394
+ | 405 | `unsupported_action` | The resource does not do this; see `allow` |
395
+ | 409 | `conflict` | Stale `ifMatch`: re-read (or use `error.recordVersion`) and retry |
396
+ | 422 | `query_invalid` | A name the resource does not have; `errors` lists what it does have |
397
+ | 422 | `validation_failed` | A value is wrong; `errors` per field. Also: an idempotency key reused with another body |
398
+ | 422 | `cardinality_exceeded` | `page[size]` over the max, or too many groups |
399
+ | 429 | `rate_limited` | Wait `retryAfter` seconds |
400
+
401
+ A network failure (DNS, offline, CORS refused by the browser) is not a `DmpError`: it is the
402
+ `TypeError` `fetch` itself throws, untouched, so it cannot be mistaken for an answer from the API.
403
+
404
+ ## Voor AI-tools
405
+
406
+ **Nederlands.** Bouwt een AI-tool (Claude Code, Cursor, …) een app tegen een DMP, dan begint hij bij
407
+ de MCP-server van dat DMP (`cube/apps`, `POST {dmp}/apps/mcp`) met de tool **`dmp.instructions`**. Die zegt
408
+ hoe een app bij dit DMP inlogt (`authenticatie.oauth`, met het discovery-adres), wat de basis-URL
409
+ van de API is (`de_api.basis_url`), welke regels en foutcodes er gelden, en wat de AI-tool zelf
410
+ níét doet: de API aanroepen, of om rijen of een token vragen. Daarna `dmp.capabilities` en
411
+ `dmp.describe` voor de vorm van de gegevens, en dan code schrijven die deze SDK gebruikt.
412
+
413
+ The DMP's MCP server (`cube/mcp`) does not hand an AI agent any rows. It gives the **shape** —
414
+ `dmp.capabilities`, `dmp.describe` per resource — and `dmp.instructions`: how an app built against
415
+ this DMP signs in and calls the REST API. The agent's job is to **write code that uses this SDK**,
416
+ running in the user's browser with the user's token; not to call the API itself.
417
+
418
+ If you are an AI coding agent reading this:
419
+
420
+ 1. Call `dmp.instructions` first. Take `de_api.basis_url` as `baseUrl`, and
421
+ `authenticatie.oauth.discovery`'s origin as `issuer` when `authenticatie.oauth.beschikbaar` is
422
+ true. Otherwise use a personal token from an env variable (`VITE_DMP_TOKEN`), never inlined.
423
+ In a browser on another origin, discovery and the token endpoint go through the dev server's
424
+ proxy: see [CORS](#in-a-browser-on-another-origin-cors).
425
+ 2. Use `dmp.describe` for field names, types and filters. Use only what it (and, at runtime,
426
+ `dmp.capabilities()`) lists: an invented field or filter is a `422 query_invalid`, not a guess
427
+ the API will humour.
428
+ 3. Write:
429
+ ```ts
430
+ const oauth = createOAuth({ issuer, clientId, redirectUri, scopes: ['api:read'] });
431
+ const dmp = createClient({ baseUrl, auth: oauth });
432
+ await oauth.handleCallback();
433
+ const caps = await dmp.capabilities();
434
+ ```
435
+ and build the app's queries with `dmp.resources(name).list/get/aggregate(...)`.
436
+ 4. Handle `DmpError` by `code` using the table above. Show `meta.omitted`, `meta.pruned` and
437
+ `meta.forbidden_fields` to the user as "not visible to you", never as empty.
438
+ 5. Do not run the app's requests yourself, do not ask the user to paste rows or their token into
439
+ the conversation, and do not put a token in code or in the repo. The data does not go to the AI
440
+ vendor; that is the point of the design (ADR 0005).
441
+ 6. Ask only for the scopes the app needs: `api:read`, and `api:write` for writes (it does not
442
+ include `api:read`). `mcp:use` is for the MCP connection and opens nothing here.
443
+
444
+ ## Route B: apps without a build step
445
+
446
+ **Nederlands.** Route B is de route voor niet-ontwikkelaars (cube/apps#38): Claude bouwt in
447
+ Claude.ai of Claude Desktop een app van gewone HTML en JavaScript-modules, zonder `npm install` en
448
+ zonder build. Zo'n app laadt `cube-sdk.js` met een script-tag en praat alleen via `CubeApp`. Zolang
449
+ het DMP de app niet heeft gepubliceerd draait hij in de **previewstand**: nep-data in de browser,
450
+ geen enkel verzoek naar een DMP. Na goedkeuring en publicatie schrijft het DMP
451
+ `cube-app.config.json` naast de app, en dezelfde code draait **live**: de eindgebruiker logt in met
452
+ OAuth (PKCE) en de app leest en schrijft echte gegevens, begrensd tot wat de app in zijn manifest
453
+ vraagt en wat die persoon mag.
454
+
455
+ ### The page
456
+
457
+ ```html
458
+ <!doctype html>
459
+ <html lang="nl">
460
+ <head>
461
+ <meta charset="utf-8">
462
+ <title>Uren invullen</title>
463
+ <script src="./cube-sdk.js"></script>
464
+ <script type="module" src="./app.js"></script>
465
+ </head>
466
+ <body>
467
+ <main id="app"></main>
468
+ </body>
469
+ </html>
470
+ ```
471
+
472
+ ```js
473
+ // app.js — the same file in the preview and live
474
+ const app = await CubeApp.connect();
475
+
476
+ if (app.user() === null) {
477
+ document.querySelector('#app').innerHTML = '<button id="login">Inloggen</button>';
478
+ document.querySelector('#login').onclick = () => app.signIn();
479
+ } else {
480
+ const { data, meta } = await app.resource('urenregel').list({
481
+ filter: { datum: { gte: '2026-09-28' } }, sort: '-datum', page: { size: 20 },
482
+ });
483
+ // render data; meta.page.total is the number of rows in the whole filtered set
484
+ }
485
+ ```
486
+
487
+ `cube-sdk.js` is a single classic script (an IIFE, no imports) that sets `window.CubeApp`. Do not
488
+ send it with the app: the DMP adds the version it pins to every `dist/` it assembles. During the
489
+ preview the page loads it from the same folder, wherever the preview runs.
490
+
491
+ ### The manifest: `cube-app.json`
492
+
493
+ Required in the app's root. It declares what the app uses; the reviewer approves exactly that, and
494
+ live the app's OAuth client is capped to it.
495
+
496
+ ```json
497
+ {
498
+ "name": "Uren invullen",
499
+ "description": "Medewerkers vullen hun uren in voor de week.",
500
+ "uses": { "urenregel": ["list", "get", "create", "update"], "project": ["list"] },
501
+ "sdk": "1"
502
+ }
503
+ ```
504
+
505
+ `uses` keys are REST resource names from `_capabilities`, the values actions. `"sdk"` is
506
+ `CubeApp.sdk` of the build the app was written against (`"1"`).
507
+
508
+ ### Preview and live
509
+
510
+ `CubeApp.connect()` fetches `./cube-app.config.json` relative to the page.
511
+
512
+ | | Preview | Live |
513
+ |---|---|---|
514
+ | When | `cube-app.config.json` is absent: a 404, a network failure, or a page that is not a JSON object (a dev server answering every path with `index.html`) | The file is there. Any other error status (a 500) rejects `connect()`, and a file missing a member rejects it with a `TypeError` naming it: a broken publish never turns into a quiet preview |
515
+ | Data | Mock data: the `mock` option, else what `CubeApp.mock(data)` registered, else `./cube-mock.json`, else none (every resource is then a 404) | The DMP's REST face through `createClient`, as the signed-in person |
516
+ | Network | Only the page's own folder: `cube-app.config.json`, `cube-mock.json`, `cube-app.json`. **Never a DMP** | Discovery, the authorize redirect, the token endpoint and the REST face |
517
+ | User | A stand-in, `{ id: 'preview', name: 'Voorbeeldgebruiker', scopes: [] }`, signed in from the start | `null` until signed in, then `{ id, name: null, scopes }` from the access token |
518
+ | Writes | Create, update and delete change the data in memory until the page reloads; the mock data passed in is never changed | Real writes |
519
+
520
+ `cube-app.config.json` is written by the DMP when the Hub publishes an approved release, and only
521
+ then. The maker and the AI never send it (the DMP refuses a submitted file with that name), so a
522
+ preview cannot reach real data.
523
+
524
+ ```json
525
+ {
526
+ "dmpUrl": "https://dmp.example.nl",
527
+ "clientId": "9f6c…",
528
+ "redirectUri": "https://apps.example.nl/uren-invullen/",
529
+ "scopes": ["api:read", "api:write"]
530
+ }
531
+ ```
532
+
533
+ | Member | |
534
+ |---|---|
535
+ | `dmpUrl` | Required. The DMP's origin; the OAuth issuer unless `issuer` is set |
536
+ | `clientId` | Required. The app's public OAuth client |
537
+ | `redirectUri` | Required. Absolute, or relative to the page; must equal the registered one exactly |
538
+ | `scopes` | Required. A string or a list |
539
+ | `apiUrl` | The REST base URL. Default `{dmpUrl}/apps/api/v1` |
540
+ | `issuer` | Default `dmpUrl` |
541
+ | `metadata` | Passed to `createOAuth`: endpoints on top of (or, complete, instead of) discovery; for a token endpoint behind a proxy, see [CORS](#in-a-browser-on-another-origin-cors) |
542
+
543
+ #### The preview answers as the REST face does
544
+
545
+ `app.resource(name)` returns the same `ResourceClient` as `createClient().resources(name)`, and in
546
+ the preview it answers with the same envelope (`data`, `meta.page`, `recordVersion`, a header
547
+ `X-Dmp-Preview: true`) and the same refusals as `DmpError`, so error handling written in the
548
+ preview is the code that runs live. The query goes through `buildQuery` and is read back the way
549
+ the REST face reads it:
550
+
551
+ - `filter` with every operator (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `ilike`,
552
+ `is`, `between`) and the prefix form (`'>=4'`), values typed after the field; `like` is a
553
+ case-insensitive substring match where `%` is a wildcard and `_` a literal, as in the engine.
554
+ A field outside the schema's `filters` is `422 query_invalid` / `not_filterable`.
555
+ - `sort` (several, `-` for descending; an unknown field is `not_sortable`), `page[size]` (default
556
+ 50, above 200 `422 cardinality_exceeded`, never clamped) and `page[number]`.
557
+ - `fields`; without it a row carries the schema's `summary_fields`, as on the REST face.
558
+ - `get` of an id that is not there is `404 not_found`; an unknown resource `404 not_found`.
559
+ - `create` and `update`: a key that is not writable (`id`, the schema's `readonly`) is
560
+ `422 query_invalid` per field; a value of the wrong declared type `422 validation_failed` with
561
+ Laravel's sentence per field; an empty body `422 validation_failed`. `ifMatch` with a stale
562
+ `recordVersion` is `409 conflict`; `idempotencyKey` replays the first answer.
563
+ - `aggregate` with `count`, `count(f)`, `sum(f)`, `avg(f)`, `min(f)`, `max(f)`, grouped on fields
564
+ and on `.day`, `.week` (ISO, `2026-W23`), `.month`, `.quarter` (`2026-Q2`) and `.year`; a date-time is
565
+ bucketed by the date as written. A call needs `aggregate` in the manifest's `uses` (`list` alone no longer
566
+ suffices). Grouping is limited to the schema's `groupable` fields and a function to what `aggregatable` allows
567
+ (absent: every field, every function; a bare `count` is always allowed). Groups come ascending by their values,
568
+ nulls first; a `sum` of nothing is `null`; a whole-set aggregate over no rows answers one row. At most 5
569
+ dimensions and 1000 groups; a repeated entry is refused.
570
+ - A field with `choices` in the schema: a value outside `values` (else the field's own `enum`) is
571
+ `422 validation_failed` ("The selected status is invalid."), and a choice that is not `writable` is read-only.
572
+ - With a `cube-app.json` next to the page (or the `manifest` option), a call outside its `uses` is
573
+ `403 client_scope_exceeded`, as the live client is capped to it.
574
+ - Signed out (`signOut()`), every call is `401 token_invalid`.
575
+
576
+ What the preview cannot do: mock data names no relations, so an `include` or `withCount` comes back
577
+ in `meta.omitted` (show it as "not visible", as live) and `has` is not applied; custom operations
578
+ are `404 not_found`; `location` and `etag` are null.
579
+
580
+ ### Mock data
581
+
582
+ The shape is `cube.mock_data`'s output as is: per REST resource the `dmp.describe` payload and the
583
+ synthetic rows.
584
+
585
+ ```json
586
+ {
587
+ "urenregel": {
588
+ "schema": {
589
+ "name": "urenregel",
590
+ "filters": ["project_id", "datum"],
591
+ "readonly": ["id"],
592
+ "summary_fields": ["id", "project_id", "datum", "uren"],
593
+ "fields": {
594
+ "id": { "type": "integer" },
595
+ "project_id": { "type": "integer" },
596
+ "datum": { "type": "string", "format": "date" },
597
+ "uren": { "type": "number" },
598
+ "omschrijving": { "type": ["string", "null"] }
599
+ }
600
+ },
601
+ "rows": [{ "id": 1, "project_id": 1, "datum": "2026-09-28", "uren": 8, "omschrijving": "Ontwerp" }]
602
+ }
603
+ }
604
+ ```
605
+
606
+ Ship it as `cube-mock.json` next to `index.html`, or register it in a script before the app's
607
+ module runs: `CubeApp.mock({ ... })`. A resource without a `schema` takes its fields from its rows.
608
+
609
+ The schema may also carry three keys that `cube.mock_data` writes: `groupable` (fields an aggregate may group on),
610
+ `aggregatable` (per field, the functions allowed) and `choices` (per field, `{ "values": [...], "writable": true }`).
611
+ Without them the preview is permissive: every field, every function; only a field's own `enum` is checked on writes.
612
+
613
+ ### `CubeApp` API
614
+
615
+ | | |
616
+ |---|---|
617
+ | `CubeApp.connect(options?)` | `Promise<CubeAppSession>`. Options: `mock`, `manifest` (an object, or `false` to check nothing), `pageUrl` (default the page's URL), `fetch`, `tokenStorage` (`'session'`, the default, or `'memory'`), `autoSignIn` (default `false`) |
618
+ | `CubeApp.mock(data)` | Registers preview data for the next `connect()`; `null` forgets it |
619
+ | `CubeApp.version` | This SDK's version, e.g. `'1.0.0-rc.4'` |
620
+ | `CubeApp.sdk` | The manifest `"sdk"` this build serves, `'1'` |
621
+ | `session.mode` | `'preview'` or `'live'` |
622
+ | `session.user()` | `{ id, name, scopes }` or `null` |
623
+ | `session.signIn()` | Live: navigates to the DMP's sign-in. Preview: signs the stand-in in |
624
+ | `session.signOut()` | Forgets the tokens in this browser (not a revocation). Preview: signs the stand-in out |
625
+ | `session.resource(name)` | A `ResourceClient`: `list`, `pages`, `all`, `get`, `aggregate`, `create`, `update`, `delete`, `operation`, `collectionOperation` |
626
+ | `session.signInError` | The error the last sign-in came back with (a refused consent: `code` `access_denied`), else `null`; `connect()` does not reject for it |
627
+
628
+ Live, `connect()` handles the OAuth callback itself (on the `redirectUri`), so the redirect URI can
629
+ be the app's own `index.html`. The tokens go to `sessionStorage` by default, unlike `createOAuth`'s
630
+ memory default: a no-build app reloads often, and a redirect round trip on every reload is worse for
631
+ its users than a token that lives as long as the tab. `tokenStorage: 'memory'` opts out. `user()` reads
632
+ the id and scopes from the access token for display; the DMP checks the token on every request.
633
+
634
+ `CubeApp` is also exported from the ES module (`import { CubeApp } from '@cube-nl/dmp-sdk'`) for an
635
+ app with a build (route A).
636
+
637
+ ### For the host: serving `cube-sdk.js`
638
+
639
+ | | |
640
+ |---|---|
641
+ | File | `dist/cube-sdk.js` in the package, `@cube-nl/dmp-sdk/cube-sdk.js` as an export; on jsDelivr/unpkg as `@cube-nl/dmp-sdk@<version>/dist/cube-sdk.js`; the CI's `pack` job also keeps it as an artifact |
642
+ | Size | 31.4 KiB minified, 11.3 KiB gzipped (1.0.0-rc.4) |
643
+ | Version | The banner's first line, `/*! @cube-nl/dmp-sdk 1.0.0-rc.4 cube-sdk.js … */`, and `CubeApp.version` |
644
+
645
+ Pin it by copying the built file of a known version into the host, rather than fetching it at
646
+ runtime: `npm pack @cube-nl/dmp-sdk@<version>` downloads the tarball, `package/dist/cube-sdk.js` is
647
+ the file. Nothing from a CDN, nothing at request time.
648
+
649
+ ## What this SDK deliberately does not do
650
+
651
+ - **No per-resource generated types.** `_capabilities` is per token and at runtime, and OpenAPI
652
+ generation does not exist yet (api#6). Pass your own `T` to `resources<T>()`.
653
+ - **No cursor pagination.** The API's wire has `page[size]`/`page[number]` only; the engine's cursor
654
+ is not exposed.
655
+ - **No automatic retries**, of reads or writes, apart from the single retry after a refreshed 401.
656
+ A 429 is thrown with `retryAfter`; what to do is the app's call.
657
+ - **No token persistence** by default. Pass a `tokenStore` if you decide otherwise, knowingly.
658
+ `CubeApp` is the one deliberate exception (`sessionStorage`, see [Route B](#route-b-apps-without-a-build-step)).
659
+ - **No token revocation call**: `cube/oauth-server` publishes no revocation endpoint. `signOut()`
660
+ forgets locally; the person revokes the connection in the DMP under *Verbonden apps*.
661
+ - **No `_history`**: the API has not built it. `request()` reaches any path once it exists.
662
+
663
+ ## Development
664
+
665
+ ```bash
666
+ npm ci
667
+ npm run lint # tsc --noEmit, strict
668
+ npm test # tsc + cube-sdk.js build, then node:test against a mock fetch — no network
669
+ npm pack
670
+ ```
671
+
672
+ `test/fixtures/api-wire.json` holds responses as `cube/api`'s own tests pin them (each case names
673
+ its source test). When the API's wire format changes, update the fixture; `test/contract.test.js`
674
+ then says what the SDK still reads wrong.
675
+
676
+ TypeScript and esbuild (which bundles `dist/cube-sdk.js`, see `scripts/build-browser.mjs`) are the only
677
+ dev dependencies; there are no runtime dependencies, and there should not be.
678
+
679
+ ### Releasing
680
+
681
+ Bump `version` in `package.json` and `src/version.ts` (a test holds them equal), add the CHANGELOG
682
+ entry, merge, then push a tag that equals the version (e.g. `1.0.0-rc.4`). The tag pipeline's
683
+ `publish` job checks that, builds, and runs `npm publish`: a version with a `-` goes to the `next`
684
+ dist-tag, any other to `latest`. It needs the masked, protected CI variable `NPM_TOKEN` (an npm
685
+ granular access token with write access to the `cube-nl` org).
686
+
687
+ ## Integration tests
688
+
689
+ `test/integration.test.js` runs the SDK against a real DMP. `npm test` includes it, and it skips
690
+ itself unless `DMP_SDK_IT_URL` and `DMP_SDK_IT_TOKEN` are set, so the CI (which has no DMP) only
691
+ runs the mock-fetch tests. It is written against the hackathon demo app (`projects`, `tasks`) and
692
+ **writes**: every run leaves a few `SDK-IT …` projects behind (`php artisan demo:reset` clears them).
693
+
694
+ ```bash
695
+ php artisan serve --host=127.0.0.1 --port=8000 # in the demo app
696
+ DMP_SDK_IT_URL=http://127.0.0.1:8000 DMP_SDK_IT_TOKEN=… npm run test:integration
697
+ ```
698
+
699
+ | Variable | |
700
+ |---|---|
701
+ | `DMP_SDK_IT_URL` | The DMP's origin, also the OAuth issuer. Required |
702
+ | `DMP_SDK_IT_TOKEN` | A token with list, get, aggregate, create and update on the resource (the demo's `api_client`). Required |
703
+ | `DMP_SDK_IT_BASE_URL` | Default `{DMP_SDK_IT_URL}/apps/api/v1` |
704
+ | `DMP_SDK_IT_RESOURCE` | Default `projects` |
705
+ | `DMP_SDK_IT_CREATE` | JSON merged into every created record next to a unique `name`. Default `{"status":"actief","budget":1}` |
706
+ | `DMP_SDK_IT_READ_TOKEN` | A token with `api:read` only: a `POST` must be `403 client_scope_exceeded` |
707
+ | `DMP_SDK_IT_PERSONAL_TOKEN` | A personal token (`$user->createToken(...)`) |
708
+ | `DMP_SDK_IT_CLIENT_ID` | A public OAuth client: discovery and the authorization URL |
709
+ | `DMP_SDK_IT_REDIRECT_URI` | Its redirect URI. Default `http://localhost:5173/callback` |
710
+ | `DMP_SDK_IT_AUTHORIZE_CMD` | A shell command that signs in and allows the consent for the URL in `DMP_SDK_IT_AUTHORIZE_URL`, and prints the code. With it, the code exchange, the scope refusal on the OAuth token, the refresh and the refusal of a spent code and a rotated refresh token run as well |
711
+
712
+ For the demo, the authorize command is its own Playwright script, reading everything from the
713
+ environment so no password ends up in a process list:
714
+
715
+ ```bash
716
+ export DMP_SDK_IT_AUTHORIZE_CMD='PLAYWRIGHT_DIR=… node scripts/authorize-code.mjs \
717
+ "$DMP_SDK_IT_AUTHORIZE_URL" "$DMP_SDK_IT_EMAIL" "$DMP_SDK_IT_PASSWORD" "$DMP_SDK_IT_REDIRECT_URI"'
718
+ ```
719
+
720
+ Filament slows down a login after a few attempts a minute; run the OAuth test a few times in a
721
+ row and it waits for that (or `demo:reset`, which also clears the counter).