@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/dist/app.d.ts ADDED
@@ -0,0 +1,87 @@
1
+ import { type AuthorizationServerMetadata } from './auth.js';
2
+ import { type ResourceClient } from './client.js';
3
+ import { type AppManifest, type MockData } from './preview.js';
4
+ import type { DmpRecord } from './types.js';
5
+ /**
6
+ * `cube-app.config.json`, which the DMP writes next to the app only when it publishes an approved
7
+ * release. Never part of what the maker or an AI sends: its presence is what makes an app live.
8
+ */
9
+ export interface CubeAppConfig {
10
+ /** The DMP's origin, `https://dmp.example.nl`. Also the OAuth issuer unless `issuer` says otherwise. */
11
+ dmpUrl: string;
12
+ /** The app's own public OAuth client. */
13
+ clientId: string;
14
+ /** Absolute, or relative to the page. Must equal the client's registered redirect URI. */
15
+ redirectUri: string;
16
+ /** Exactly what the reviewer approved, e.g. `['api:read', 'api:write']`. */
17
+ scopes: string | readonly string[];
18
+ /** The REST face's base URL. Default `{dmpUrl}/apps/api/v1`. */
19
+ apiUrl?: string;
20
+ /** Default `dmpUrl`. */
21
+ issuer?: string;
22
+ /** Passed to `createOAuth`: endpoints to use instead of (or on top of) discovery. */
23
+ metadata?: Partial<AuthorizationServerMetadata>;
24
+ }
25
+ export interface ConnectOptions {
26
+ /** Preview data: `cube.mock_data`'s output. Default: what `CubeApp.mock()` registered, else `./cube-mock.json`. */
27
+ mock?: MockData;
28
+ /**
29
+ * The manifest the preview holds calls against, like the live ceiling does. Default
30
+ * `./cube-app.json`; `false` checks nothing.
31
+ */
32
+ manifest?: AppManifest | false;
33
+ /** Where `./cube-app.config.json` and the other files are resolved. Default: the page's own URL. */
34
+ pageUrl?: string;
35
+ fetch?: typeof fetch;
36
+ /**
37
+ * Live only. `'session'` (default): the tokens survive a reload in this tab, in
38
+ * `sessionStorage`, and are gone when the tab closes. `'memory'`: every reload signs in again.
39
+ */
40
+ tokenStorage?: 'session' | 'memory';
41
+ /** Live only: go to the DMP's sign-in right away when nobody is signed in. Default false. */
42
+ autoSignIn?: boolean;
43
+ }
44
+ export interface CubeAppUser {
45
+ /** The DMP's user id (the token's `sub`), or `'preview'`. Null when the token does not say. */
46
+ id: string | null;
47
+ /** Only the preview knows a name: an OAuth token from the DMP carries none. */
48
+ name: string | null;
49
+ /** What the token may do: the approved scopes. Empty in the preview. */
50
+ scopes: string[];
51
+ }
52
+ export interface CubeAppSession {
53
+ /** `'preview'`: mock data in this browser, no DMP. `'live'`: the real DMP, as the signed-in person. */
54
+ readonly mode: 'preview' | 'live';
55
+ /** Who is signed in, or null. In the preview a stand-in user is signed in from the start. */
56
+ user(): CubeAppUser | null;
57
+ /** Live: navigates to the DMP's sign-in (the page is gone after this). Preview: signs the stand-in in again. */
58
+ signIn(): Promise<void>;
59
+ /** Forgets the tokens in this browser. Preview: the stand-in signs out, and calls answer 401. */
60
+ signOut(): Promise<void>;
61
+ /** The same client `createClient().resources(name)` returns: list, get, create, update, delete, ... */
62
+ resource<T extends DmpRecord = DmpRecord>(name: string): ResourceClient<T>;
63
+ /** Why the last sign-in came back without a session (consent refused, say); null otherwise. */
64
+ readonly signInError: Error | null;
65
+ }
66
+ /**
67
+ * Starts the app. Reads `./cube-app.config.json` next to the page: absent, the app runs in the
68
+ * preview on mock data and makes no request to any DMP; present, it signs the end user in with
69
+ * OAuth (authorization code + PKCE) and talks to the DMP's REST face. The app's code is the same.
70
+ */
71
+ declare function connect(options?: ConnectOptions): Promise<CubeAppSession>;
72
+ /**
73
+ * Registers preview data before `connect()`, for an app that embeds its mock data in a script tag
74
+ * instead of shipping `cube-mock.json`. Has no effect on a live app. `null` forgets it again.
75
+ */
76
+ declare function mock(data: MockData | null): void;
77
+ /** What `cube-sdk.js` puts on `window`, and what this package exports as `CubeApp`. */
78
+ export declare const CubeApp: Readonly<{
79
+ connect: typeof connect;
80
+ mock: typeof mock;
81
+ /** This SDK's version, e.g. `'1.0.0-rc.4'`. */
82
+ version: "1.0.0-rc.4";
83
+ /** The manifest `"sdk"` value this build serves. */
84
+ sdk: "1";
85
+ }>;
86
+ export type CubeAppStatic = typeof CubeApp;
87
+ export {};
package/dist/app.js ADDED
@@ -0,0 +1,245 @@
1
+ import { createOAuth, memoryTokenStore, } from './auth.js';
2
+ import { createClient } from './client.js';
3
+ import { DmpError } from './errors.js';
4
+ import { PreviewStore } from './preview.js';
5
+ import { MANIFEST_SDK, VERSION } from './version.js';
6
+ const CONFIG_FILE = './cube-app.config.json';
7
+ const MOCK_FILE = './cube-mock.json';
8
+ const MANIFEST_FILE = './cube-app.json';
9
+ const PREVIEW_USER = { id: 'preview', name: 'Voorbeeldgebruiker', scopes: [] };
10
+ let registeredMock = null;
11
+ /**
12
+ * Starts the app. Reads `./cube-app.config.json` next to the page: absent, the app runs in the
13
+ * preview on mock data and makes no request to any DMP; present, it signs the end user in with
14
+ * OAuth (authorization code + PKCE) and talks to the DMP's REST face. The app's code is the same.
15
+ */
16
+ async function connect(options = {}) {
17
+ const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis);
18
+ const pageUrl = options.pageUrl ?? currentPage();
19
+ const config = await readJson(doFetch, resolve(CONFIG_FILE, pageUrl));
20
+ if (config === null) {
21
+ return preview(options, doFetch, pageUrl);
22
+ }
23
+ return live(asConfig(config), options, doFetch, pageUrl);
24
+ }
25
+ async function preview(options, doFetch, pageUrl) {
26
+ const mock = options.mock ?? registeredMock ?? (await readJson(doFetch, resolve(MOCK_FILE, pageUrl))) ?? {};
27
+ const manifest = options.manifest === false
28
+ ? null
29
+ : options.manifest ?? (await readJson(doFetch, resolve(MANIFEST_FILE, pageUrl)));
30
+ let signedIn = true;
31
+ // Signed out, the preview refuses as the API refuses a request without a token.
32
+ const store = new PreviewStore(mock, manifest, () => {
33
+ if (!signedIn) {
34
+ throw new DmpError(401, new Headers({ 'Content-Type': 'application/problem+json' }), {
35
+ type: 'https://docs.cube.nl/dmp-api/problems/token_invalid',
36
+ title: 'token invalid',
37
+ status: 401,
38
+ code: 'token_invalid',
39
+ detail: 'Unauthenticated.',
40
+ });
41
+ }
42
+ });
43
+ return {
44
+ mode: 'preview',
45
+ signInError: null,
46
+ user: () => (signedIn ? { ...PREVIEW_USER } : null),
47
+ async signIn() {
48
+ signedIn = true;
49
+ },
50
+ async signOut() {
51
+ signedIn = false;
52
+ },
53
+ resource: (name) => store.resource(name),
54
+ };
55
+ }
56
+ async function live(config, options, doFetch, pageUrl) {
57
+ const dmpUrl = config.dmpUrl.replace(/\/+$/, '');
58
+ let tokens = null;
59
+ const store = tracking(options.tokenStorage === 'memory' ? memoryTokenStore() : sessionTokenStore(`cube-app.tokens.${config.clientId}`), (current) => {
60
+ tokens = current;
61
+ });
62
+ const oauth = createOAuth({
63
+ issuer: config.issuer ?? dmpUrl,
64
+ clientId: config.clientId,
65
+ redirectUri: resolve(config.redirectUri, pageUrl),
66
+ scopes: config.scopes,
67
+ fetch: doFetch,
68
+ tokenStore: store,
69
+ ...(config.metadata !== undefined ? { metadata: config.metadata } : {}),
70
+ });
71
+ const client = createClient({ baseUrl: config.apiUrl ?? `${dmpUrl}/apps/api/v1`, auth: oauth, fetch: doFetch });
72
+ let signInError = null;
73
+ tokens = await store.get();
74
+ try {
75
+ await oauth.handleCallback();
76
+ }
77
+ catch (error) {
78
+ // A refused consent or a stale callback link is something to show, not a crash at start-up.
79
+ signInError = error instanceof Error ? error : new Error(String(error));
80
+ }
81
+ if (tokens === null && signInError === null && options.autoSignIn === true) {
82
+ await oauth.signIn();
83
+ }
84
+ return {
85
+ mode: 'live',
86
+ get signInError() {
87
+ return signInError;
88
+ },
89
+ user: () => (tokens === null ? null : userFrom(tokens)),
90
+ async signIn() {
91
+ signInError = null;
92
+ await oauth.signIn();
93
+ },
94
+ async signOut() {
95
+ await oauth.signOut();
96
+ },
97
+ resource: (name) => client.resources(name),
98
+ };
99
+ }
100
+ /**
101
+ * Who the token belongs to, read from the access token itself (cube/oauth-server issues JWTs).
102
+ * For display only: the DMP checks the token on every request, this SDK does not verify it.
103
+ */
104
+ function userFrom(tokens) {
105
+ const claims = jwtClaims(tokens.accessToken);
106
+ const sub = claims?.sub;
107
+ const fromClaims = Array.isArray(claims?.scopes) ? claims.scopes.map(String) : null;
108
+ return {
109
+ id: typeof sub === 'string' || typeof sub === 'number' ? String(sub) : null,
110
+ name: null,
111
+ scopes: fromClaims ?? (tokens.scope ?? '').split(' ').filter((scope) => scope !== ''),
112
+ };
113
+ }
114
+ function jwtClaims(token) {
115
+ const payload = token.split('.')[1];
116
+ if (payload === undefined) {
117
+ return null;
118
+ }
119
+ try {
120
+ const base64 = payload.replace(/-/g, '+').replace(/_/g, '/');
121
+ const binary = atob(base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '='));
122
+ const json = new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0)));
123
+ const claims = JSON.parse(json);
124
+ return claims !== null && typeof claims === 'object' ? claims : null;
125
+ }
126
+ catch {
127
+ return null;
128
+ }
129
+ }
130
+ function asConfig(value) {
131
+ const missing = ['dmpUrl', 'clientId', 'redirectUri'].filter((key) => typeof value[key] !== 'string' || value[key] === '');
132
+ const scopes = value.scopes;
133
+ if (!(typeof scopes === 'string' || (Array.isArray(scopes) && scopes.every((scope) => typeof scope === 'string')))) {
134
+ missing.push('scopes');
135
+ }
136
+ if (missing.length > 0) {
137
+ // Present but broken is a failed publish, not a preview: say so instead of showing mock data live.
138
+ throw new TypeError(`cube-app.config.json is incomplete: ${missing.join(', ')} missing or not a string.`);
139
+ }
140
+ return value;
141
+ }
142
+ /**
143
+ * A JSON object from the app's own origin, or null when there is none. A 404, a network failure
144
+ * and a page that is not a JSON object (a dev server answering every path with index.html) all
145
+ * mean "absent". Any other refusal is an error: a broken server must not quietly become a preview.
146
+ */
147
+ async function readJson(doFetch, url) {
148
+ let response;
149
+ try {
150
+ response = await doFetch(url, {
151
+ headers: { Accept: 'application/json' },
152
+ ...(typeof document !== 'undefined' ? { cache: 'no-store' } : {}),
153
+ });
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ if (response.status === 404 || response.status === 410) {
159
+ return null;
160
+ }
161
+ if (!response.ok) {
162
+ throw new Error(`${url} answered ${response.status}.`);
163
+ }
164
+ try {
165
+ const value = JSON.parse(await response.text());
166
+ return value !== null && typeof value === 'object' && !Array.isArray(value) ? value : null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ function tracking(inner, onChange) {
173
+ return {
174
+ async get() {
175
+ const tokens = await inner.get();
176
+ onChange(tokens);
177
+ return tokens;
178
+ },
179
+ async set(tokens) {
180
+ await inner.set(tokens);
181
+ onChange(tokens);
182
+ },
183
+ };
184
+ }
185
+ /** sessionStorage, falling back to memory where storage is blocked (a sandboxed iframe). */
186
+ function sessionTokenStore(key) {
187
+ const storage = (() => {
188
+ try {
189
+ return globalThis.sessionStorage ?? null;
190
+ }
191
+ catch {
192
+ return null;
193
+ }
194
+ })();
195
+ if (storage === null) {
196
+ return memoryTokenStore();
197
+ }
198
+ return {
199
+ get() {
200
+ try {
201
+ const raw = storage.getItem(key);
202
+ return raw === null ? null : JSON.parse(raw);
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ },
208
+ set(tokens) {
209
+ try {
210
+ if (tokens === null) {
211
+ storage.removeItem(key);
212
+ }
213
+ else {
214
+ storage.setItem(key, JSON.stringify(tokens));
215
+ }
216
+ }
217
+ catch {
218
+ // Storage full or blocked: the session lasts as long as the page.
219
+ }
220
+ },
221
+ };
222
+ }
223
+ function currentPage() {
224
+ const page = globalThis;
225
+ return page.document?.baseURI ?? page.location?.href ?? 'http://localhost/';
226
+ }
227
+ function resolve(path, pageUrl) {
228
+ return new URL(path, pageUrl).href;
229
+ }
230
+ /**
231
+ * Registers preview data before `connect()`, for an app that embeds its mock data in a script tag
232
+ * instead of shipping `cube-mock.json`. Has no effect on a live app. `null` forgets it again.
233
+ */
234
+ function mock(data) {
235
+ registeredMock = data;
236
+ }
237
+ /** What `cube-sdk.js` puts on `window`, and what this package exports as `CubeApp`. */
238
+ export const CubeApp = Object.freeze({
239
+ connect,
240
+ mock,
241
+ /** This SDK's version, e.g. `'1.0.0-rc.4'`. */
242
+ version: VERSION,
243
+ /** The manifest `"sdk"` value this build serves. */
244
+ sdk: MANIFEST_SDK,
245
+ });
package/dist/auth.d.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * How the client gets a token. `bearer()` and `createOAuth()` both implement it; so can your own
3
+ * object, for a token that comes from somewhere else entirely.
4
+ */
5
+ export interface AuthStrategy {
6
+ /** The bearer token for the next request, or null to send none. Read on every request. */
7
+ getAccessToken(): string | null | Promise<string | null>;
8
+ /** Called once on a 401. Resolve true after getting a new token to have the request retried once. */
9
+ onUnauthorized?(): boolean | Promise<boolean>;
10
+ }
11
+ export type TokenSource = string | (() => string | null | undefined | Promise<string | null | undefined>);
12
+ /**
13
+ * A fixed token: a personal token (`$user->createToken(...)`) or an API-client token. Pass a
14
+ * function to read it fresh on every call. Never hardcode one in code that reaches a browser bundle.
15
+ */
16
+ export declare function bearer(token: TokenSource): AuthStrategy;
17
+ /** RFC 8414, the fields this SDK reads. */
18
+ export interface AuthorizationServerMetadata {
19
+ issuer: string;
20
+ authorization_endpoint: string;
21
+ token_endpoint: string;
22
+ registration_endpoint?: string;
23
+ scopes_supported?: string[];
24
+ code_challenge_methods_supported?: string[];
25
+ grant_types_supported?: string[];
26
+ token_endpoint_auth_methods_supported?: string[];
27
+ [key: string]: unknown;
28
+ }
29
+ export interface Tokens {
30
+ accessToken: string;
31
+ refreshToken: string | null;
32
+ /** Epoch milliseconds; null when the server gave no `expires_in`. */
33
+ expiresAt: number | null;
34
+ scope: string | null;
35
+ }
36
+ /** Where the tokens live. Memory by default; bring your own only as a deliberate choice. */
37
+ export interface TokenStore {
38
+ get(): Tokens | null | Promise<Tokens | null>;
39
+ set(tokens: Tokens | null): void | Promise<void>;
40
+ }
41
+ /** Where the PKCE verifier and state survive the redirect. sessionStorage in a browser by default. */
42
+ export interface KeyValueStore {
43
+ getItem(key: string): string | null;
44
+ setItem(key: string, value: string): void;
45
+ removeItem(key: string): void;
46
+ }
47
+ export interface OAuthOptions {
48
+ /** The DMP's origin, e.g. `https://dmp.example.nl`. Discovery is read from `{issuer}/.well-known/...`. */
49
+ issuer: string;
50
+ clientId: string;
51
+ /** Must equal a redirect URI registered for the client exactly: scheme, host, port and path. */
52
+ redirectUri: string;
53
+ /** `api:read`, `api:write`. `api:write` does not include `api:read`; ask for both when you need both. */
54
+ scopes: string | readonly string[];
55
+ /**
56
+ * Only for a confidential client on a server (Node). Never in a browser: anything in the bundle
57
+ * is public. A browser app is a public client, and PKCE is its proof.
58
+ */
59
+ clientSecret?: string;
60
+ /**
61
+ * The whole document (it must name `authorization_endpoint` and `token_endpoint`) to skip
62
+ * discovery, or only the members you want to override on top of what discovery returns: for
63
+ * example `{ token_endpoint: '/oauth/token' }` to send the code exchange through a dev-server
64
+ * proxy, because the DMP's token endpoint answers no CORS.
65
+ */
66
+ metadata?: Partial<AuthorizationServerMetadata>;
67
+ fetch?: typeof fetch;
68
+ tokenStore?: TokenStore;
69
+ pendingStore?: KeyValueStore;
70
+ /** Refresh this many seconds before the access token expires. Default 30. */
71
+ refreshLeewaySeconds?: number;
72
+ }
73
+ export interface OAuthSession extends AuthStrategy {
74
+ discover(): Promise<AuthorizationServerMetadata>;
75
+ /** The URL to send the person to. Stores the verifier and state for `handleCallback()`. */
76
+ authorizationUrl(options?: {
77
+ state?: string;
78
+ extraParams?: Record<string, string>;
79
+ }): Promise<string>;
80
+ /** Browser only: navigates to `authorizationUrl()`. The page is gone after this. */
81
+ signIn(options?: {
82
+ extraParams?: Record<string, string>;
83
+ }): Promise<void>;
84
+ /**
85
+ * On the redirect URI. Returns null when the URL is not a callback, so it can run on every
86
+ * page load. In a browser it removes `code` and `state` from the address bar.
87
+ */
88
+ handleCallback(url?: string | URL): Promise<Tokens | null>;
89
+ refresh(): Promise<Tokens>;
90
+ getTokens(): Promise<Tokens | null>;
91
+ setTokens(tokens: Tokens | null): Promise<void>;
92
+ signOut(): Promise<void>;
93
+ }
94
+ export interface RegistrationRequest {
95
+ clientName: string;
96
+ redirectUris: readonly string[];
97
+ [extra: string]: unknown;
98
+ }
99
+ /**
100
+ * OAuth 2.1 authorization code with PKCE (S256) against cube/oauth-server.
101
+ *
102
+ * The access token lives in memory unless you pass a `tokenStore`. What survives the redirect is
103
+ * the verifier and the state, in sessionStorage: useless without the code, and removed as soon as
104
+ * the callback is handled.
105
+ */
106
+ export declare function createOAuth(options: OAuthOptions): OAuthSession;
107
+ /**
108
+ * RFC 7591 dynamic client registration, when the DMP turned it on (`registration_endpoint` is then
109
+ * in discovery). Returns the registration response, with `client_id`.
110
+ */
111
+ export declare function registerClient(server: AuthorizationServerMetadata, request: RegistrationRequest, doFetch?: typeof fetch): Promise<Record<string, unknown> & {
112
+ client_id: string;
113
+ }>;
114
+ export declare function discoverMetadata(issuer: string, doFetch?: typeof fetch): Promise<AuthorizationServerMetadata>;
115
+ export declare function memoryTokenStore(initial?: Tokens | null): TokenStore;
116
+ export declare function memoryKeyValueStore(): KeyValueStore;
117
+ export declare function base64url(buffer: ArrayBuffer | Uint8Array): string;