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

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