dataspace-client-sdk-node 0.1.2 → 0.2.0
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/CHANGELOG.md +4 -1
- package/README.md +4 -1
- package/dist/client.d.ts +40 -2
- package/dist/client.js +156 -82
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/types.d.ts +20 -1
- package/dist/vp-token.d.ts +37 -0
- package/dist/vp-token.js +56 -0
- package/docs/API.md +19 -4
- package/docs/BACKEND_NODE_INTEGRATION.md +129 -2
- package/docs/CONTROLLER_FLOW_STEP_BY_STEP.md +283 -0
- package/docs/DEVELOPER_USE_CASES.md +10 -2
- package/docs/E2E_LOCAL_GW_UC5.md +49 -0
- package/docs/ENDPOINT_ID_CATALOG.md +90 -0
- package/docs/LEGAL_ORGANIZATION_FLOW_STEP_BY_STEP.md +84 -0
- package/docs/PERSONAL_FLOW_STEP_BY_STEP.md +70 -0
- package/docs/PORTAL_BACKEND_INTEGRATION_HANDOVER.md +11 -3
- package/docs/PRACTITIONER_FLOW_STEP_BY_STEP.md +182 -0
- package/examples/e2e-bootstrap-tenant.mjs +3 -2
- package/examples/e2e-individual-flow.mjs +13 -8
- package/examples/host-activate-and-employee.mjs +3 -2
- package/package.json +4 -3
- package/src/client.ts +175 -85
- package/src/index.ts +1 -0
- package/src/types.ts +22 -1
- package/src/vp-token.ts +91 -0
- package/tests/fixtures/ica-vp-minimal.json +67 -0
- package/tests/helpers/vp-token-fixture.mjs +23 -0
- package/tests/live-gw-uc5.e2e.test.mjs +108 -0
- package/TODO_PROMPT_NEXT_STEPS.md +0 -185
- package/artifacts/update-smart-wallet.js +0 -1016
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export type VpTokenHeader = {
|
|
2
|
+
alg: string;
|
|
3
|
+
typ?: string;
|
|
4
|
+
kid?: string;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
};
|
|
7
|
+
export type VpTokenPayload = {
|
|
8
|
+
iss: string;
|
|
9
|
+
sub?: string;
|
|
10
|
+
aud?: string;
|
|
11
|
+
jti?: string;
|
|
12
|
+
iat?: number;
|
|
13
|
+
exp?: number;
|
|
14
|
+
nonce?: string;
|
|
15
|
+
vp: {
|
|
16
|
+
'@context'?: unknown;
|
|
17
|
+
type?: unknown;
|
|
18
|
+
holder?: string;
|
|
19
|
+
verifiableCredential: string[];
|
|
20
|
+
[key: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
[key: string]: unknown;
|
|
23
|
+
};
|
|
24
|
+
export declare function generateUuidLike(): string;
|
|
25
|
+
export declare function buildEpochWindow(ttlSeconds?: number): {
|
|
26
|
+
iat: number;
|
|
27
|
+
exp: number;
|
|
28
|
+
};
|
|
29
|
+
export declare function createVP(input?: Partial<VpTokenPayload>): VpTokenPayload;
|
|
30
|
+
export declare function addVC(vpPayload: VpTokenPayload, vcJwt: string): VpTokenPayload;
|
|
31
|
+
export declare function prepareForSignature(header: VpTokenHeader, payload: VpTokenPayload): {
|
|
32
|
+
encodedHeader: string;
|
|
33
|
+
encodedPayload: string;
|
|
34
|
+
signingInput: string;
|
|
35
|
+
};
|
|
36
|
+
export declare function prepareBytesForSignature(header: VpTokenHeader, payload: VpTokenPayload): Uint8Array;
|
|
37
|
+
export declare function buildVpTokenCompact(encodedHeader: string, encodedPayload: string, signatureBase64Url: string): string;
|
package/dist/vp-token.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
function toB64UrlJson(input) {
|
|
2
|
+
return Buffer.from(JSON.stringify(input), 'utf-8').toString('base64url');
|
|
3
|
+
}
|
|
4
|
+
function fallbackId() {
|
|
5
|
+
const rand = Math.random().toString(36).slice(2, 10);
|
|
6
|
+
return `id-${Date.now()}-${rand}`;
|
|
7
|
+
}
|
|
8
|
+
export function generateUuidLike() {
|
|
9
|
+
const fn = globalThis?.crypto?.randomUUID;
|
|
10
|
+
if (typeof fn === 'function')
|
|
11
|
+
return fn.call(globalThis.crypto);
|
|
12
|
+
return fallbackId();
|
|
13
|
+
}
|
|
14
|
+
export function buildEpochWindow(ttlSeconds = 300) {
|
|
15
|
+
const iat = Math.floor(Date.now() / 1000);
|
|
16
|
+
return { iat, exp: iat + Math.max(1, Math.floor(ttlSeconds)) };
|
|
17
|
+
}
|
|
18
|
+
export function createVP(input) {
|
|
19
|
+
const ttl = input?.exp && input?.iat ? undefined : buildEpochWindow(300);
|
|
20
|
+
const jti = input?.jti || generateUuidLike();
|
|
21
|
+
const nonce = input?.nonce || generateUuidLike();
|
|
22
|
+
return {
|
|
23
|
+
iss: String(input?.iss || ''),
|
|
24
|
+
sub: input?.sub,
|
|
25
|
+
aud: input?.aud,
|
|
26
|
+
jti,
|
|
27
|
+
iat: input?.iat ?? ttl?.iat,
|
|
28
|
+
exp: input?.exp ?? ttl?.exp,
|
|
29
|
+
nonce,
|
|
30
|
+
vp: {
|
|
31
|
+
'@context': ['https://www.w3.org/2018/credentials/v1'],
|
|
32
|
+
type: ['VerifiablePresentation'],
|
|
33
|
+
holder: input?.vp?.holder || input?.iss || '',
|
|
34
|
+
verifiableCredential: [],
|
|
35
|
+
...(input?.vp || {}),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function addVC(vpPayload, vcJwt) {
|
|
40
|
+
const v = String(vcJwt || '').trim();
|
|
41
|
+
if (v)
|
|
42
|
+
vpPayload.vp.verifiableCredential.push(v);
|
|
43
|
+
return vpPayload;
|
|
44
|
+
}
|
|
45
|
+
export function prepareForSignature(header, payload) {
|
|
46
|
+
const encodedHeader = toB64UrlJson(header);
|
|
47
|
+
const encodedPayload = toB64UrlJson(payload);
|
|
48
|
+
return { encodedHeader, encodedPayload, signingInput: `${encodedHeader}.${encodedPayload}` };
|
|
49
|
+
}
|
|
50
|
+
export function prepareBytesForSignature(header, payload) {
|
|
51
|
+
const { signingInput } = prepareForSignature(header, payload);
|
|
52
|
+
return new TextEncoder().encode(signingInput);
|
|
53
|
+
}
|
|
54
|
+
export function buildVpTokenCompact(encodedHeader, encodedPayload, signatureBase64Url) {
|
|
55
|
+
return `${encodedHeader}.${encodedPayload}.${String(signatureBase64Url || '').trim()}`;
|
|
56
|
+
}
|
package/docs/API.md
CHANGED
|
@@ -478,22 +478,33 @@ const token = client.getCachedBearerToken('pkce:my-api-key');
|
|
|
478
478
|
|
|
479
479
|
## Transport methods
|
|
480
480
|
|
|
481
|
-
### `
|
|
481
|
+
### `submitBundle`
|
|
482
482
|
|
|
483
|
-
POST a DIDComm
|
|
483
|
+
POST a DIDComm bundle payload. Preferred API for FHIR/API bundle operations.
|
|
484
484
|
|
|
485
485
|
```ts
|
|
486
|
-
|
|
486
|
+
submitBundle(
|
|
487
|
+
path: string,
|
|
488
|
+
payload: { thid?: string } & Record<string, unknown>,
|
|
489
|
+
options?: {
|
|
490
|
+
mode?: 'plain' | 'strict';
|
|
491
|
+
recipientEncryptionJwk?: PublicJwk;
|
|
492
|
+
walletContext?: WalletContext;
|
|
493
|
+
},
|
|
494
|
+
): Promise<SubmitResponse>
|
|
487
495
|
```
|
|
488
496
|
|
|
489
497
|
```ts
|
|
490
|
-
const { status, location } = await client.
|
|
498
|
+
const { status, location } = await client.submitBundle(
|
|
491
499
|
client.individualTaskBatchPath(ctx),
|
|
492
500
|
payload,
|
|
501
|
+
{ mode: 'plain' },
|
|
493
502
|
);
|
|
494
503
|
// location header contains the poll URL
|
|
495
504
|
```
|
|
496
505
|
|
|
506
|
+
`submitBatch(...)` remains available as a legacy alias for compatibility.
|
|
507
|
+
|
|
497
508
|
---
|
|
498
509
|
|
|
499
510
|
### `submitBatchEncrypted`
|
|
@@ -531,6 +542,10 @@ POST a plain JSON body (`application/json`). Use for token endpoints and API key
|
|
|
531
542
|
postJson(path: string, payload: unknown): Promise<SubmitResponse>
|
|
532
543
|
```
|
|
533
544
|
|
|
545
|
+
### `submitLegacyJson`
|
|
546
|
+
|
|
547
|
+
Alias of `postJson(...)` for explicit non-bundle JSON submits (openid/token/resource payloads).
|
|
548
|
+
|
|
534
549
|
```ts
|
|
535
550
|
const response = await client.postJson('/host/admin/api-keys', {
|
|
536
551
|
agent: { email: 'service@example.com' },
|
|
@@ -2,12 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
This guide is backend-only and uses `dataspace-client-sdk-node`.
|
|
4
4
|
|
|
5
|
+
## Secure communications intent
|
|
6
|
+
|
|
7
|
+
- `backendWallet` is used for secure API communications in the dataspace.
|
|
8
|
+
- User authentication and authorization still apply (for example `client_assertion` for SMART token issuance, scopes, consent).
|
|
9
|
+
- Message protection can run in addition to HTTPS:
|
|
10
|
+
- signature (embedded JWS)
|
|
11
|
+
- encryption (JWE with nested JWS)
|
|
12
|
+
- Apply by environment policy (`plain` / `strict` / `auto-detect`).
|
|
13
|
+
- This is layered security (transport + message), not a literal VPN.
|
|
14
|
+
|
|
15
|
+
## Credential map (do not mix)
|
|
16
|
+
|
|
17
|
+
| Credential | Plane | Purpose | Not used for |
|
|
18
|
+
|---|---|---|---|
|
|
19
|
+
| transport bearer / mTLS / API-gateway credential | Transport | Allow backend HTTP access to GW deployment | User identity, `_exchange`, SMART scopes |
|
|
20
|
+
| `vp_token` | Identity/business | Onboarding proof for activation | Catalog discovery auth |
|
|
21
|
+
| `idToken` (user session) | Identity/business | User session proof | Transport gateway access policy |
|
|
22
|
+
| `_exchange` token flow | Identity/business | Activation code exchange for initial access | Multi-ICA/operator catalog traversal |
|
|
23
|
+
| SMART access token | Identity/business | Authorized protected operations via scopes | Infrastructure/operator-plane control |
|
|
24
|
+
|
|
25
|
+
If your deployment does not require a transport credential, omit `bearerToken` entirely.
|
|
26
|
+
|
|
5
27
|
Recommended initialization:
|
|
6
28
|
|
|
7
29
|
```ts
|
|
30
|
+
import { randomBytes } from 'node:crypto';
|
|
31
|
+
import { DataspaceNodeClient, SeedWalletProvider } from 'dataspace-client-sdk-node';
|
|
32
|
+
|
|
33
|
+
const backendWalletSeedBytes = process.env.WALLET_SEED_BASE64URL
|
|
34
|
+
? Buffer.from(process.env.WALLET_SEED_BASE64URL, 'base64url')
|
|
35
|
+
: randomBytes(32); // if not provided, generate random 32 bytes for this runtime
|
|
36
|
+
|
|
37
|
+
const backendWallet = new SeedWalletProvider(
|
|
38
|
+
Buffer.from(backendWalletSeedBytes).toString('base64url'),
|
|
39
|
+
);
|
|
40
|
+
|
|
8
41
|
const client = new DataspaceNodeClient({
|
|
9
42
|
baseUrl,
|
|
10
|
-
bearerToken,
|
|
43
|
+
bearerToken, // optional transport-plane credential if deployment requires it
|
|
44
|
+
wallet: backendWallet,
|
|
11
45
|
ctx: { tenantId, jurisdiction, sector },
|
|
12
46
|
});
|
|
13
47
|
```
|
|
@@ -15,12 +49,54 @@ const client = new DataspaceNodeClient({
|
|
|
15
49
|
Alternative (mutable context):
|
|
16
50
|
|
|
17
51
|
```ts
|
|
18
|
-
const client = new DataspaceNodeClient({ baseUrl, bearerToken });
|
|
52
|
+
const client = new DataspaceNodeClient({ baseUrl, bearerToken, wallet: backendWallet });
|
|
19
53
|
client.setContextOrg({ tenantId, jurisdiction, sector });
|
|
20
54
|
client.setDefaultTimeoutSeconds(12);
|
|
21
55
|
client.setDefaultIntervalSeconds(2);
|
|
22
56
|
```
|
|
23
57
|
|
|
58
|
+
### Communication mode (integration-level policy)
|
|
59
|
+
|
|
60
|
+
SDK does not currently expose a single constructor flag for communication mode.
|
|
61
|
+
Use an integration policy variable and choose the submit method explicitly:
|
|
62
|
+
|
|
63
|
+
- `plain`: always `submitBundle(..., { mode: 'plain' })` (or `submitBatch(...)` for legacy compatibility)
|
|
64
|
+
- `strict`: always `submitBatchEncrypted(...)`
|
|
65
|
+
- `auto-detect` (default): use encrypted when backend wallet + recipient encryption JWK are available, otherwise plaintext
|
|
66
|
+
|
|
67
|
+
GW compatibility (current):
|
|
68
|
+
- supported request envelopes:
|
|
69
|
+
- `application/didcomm-plaintext+json` (demo/compat only)
|
|
70
|
+
- `application/json` (legacy/demo paths only)
|
|
71
|
+
- `application/x-www-form-urlencoded` with `request=<jwe>` (secure/FAPI style)
|
|
72
|
+
- no standalone `didcomm-signed+json` HTTP submit mode is currently exposed as a first-class request content type.
|
|
73
|
+
- signed-only JWS exists as an internal layer pattern, but secure mode entrypoint is form-encoded JWE.
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
type CommunicationMode = 'plain' | 'strict' | 'auto-detect';
|
|
77
|
+
const communicationMode: CommunicationMode = (process.env.GW_COMMUNICATION_MODE as CommunicationMode) || 'auto-detect';
|
|
78
|
+
|
|
79
|
+
async function submitDidcomm(
|
|
80
|
+
client: DataspaceNodeClient,
|
|
81
|
+
path: string,
|
|
82
|
+
payload: Record<string, unknown>,
|
|
83
|
+
opts: {
|
|
84
|
+
mode: CommunicationMode;
|
|
85
|
+
walletContext: { tenantId: string; jurisdiction: string; sector: string; walletId?: string };
|
|
86
|
+
recipientEncryptionJwk?: any;
|
|
87
|
+
},
|
|
88
|
+
) {
|
|
89
|
+
const canEncrypt = !!opts.recipientEncryptionJwk;
|
|
90
|
+
if (opts.mode === 'strict' && !canEncrypt) {
|
|
91
|
+
throw new Error('strict mode requires recipientEncryptionJwk');
|
|
92
|
+
}
|
|
93
|
+
if (opts.mode === 'strict' || (opts.mode === 'auto-detect' && canEncrypt)) {
|
|
94
|
+
return client.submitBatchEncrypted(path, payload as any, opts.recipientEncryptionJwk, opts.walletContext as any);
|
|
95
|
+
}
|
|
96
|
+
return client.submitBundle(path, payload, { mode: 'plain' });
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
24
100
|
## Flow A. Legal Organization Onboarding (B2B)
|
|
25
101
|
|
|
26
102
|
1. Receive from frontend: `jurisdiction`, `sector`, `vpToken`.
|
|
@@ -120,3 +196,54 @@ app.post('/api/onboarding/personal/register', async (req, res) => {
|
|
|
120
196
|
- `examples/e2e-individual-flow.mjs`
|
|
121
197
|
- `tests/uc5-org-onboarding.flow.test.mjs`
|
|
122
198
|
- `tests/uc5-subject-data.flow.test.mjs`
|
|
199
|
+
|
|
200
|
+
## Deterministic Wallet Profile (seed-based, integrator-managed)
|
|
201
|
+
|
|
202
|
+
Use this section for all flows (controller/professional/personal) when the backend signs/encrypts DIDComm messages without external per-operation KMS signing.
|
|
203
|
+
|
|
204
|
+
Core rule:
|
|
205
|
+
- key custody and seed storage are integrator responsibility
|
|
206
|
+
- SDK requires signing/encryption capability, not local private-key assumptions
|
|
207
|
+
|
|
208
|
+
Recommended derivation contract:
|
|
209
|
+
|
|
210
|
+
```ts
|
|
211
|
+
type DeriveKeyMaterialInput = {
|
|
212
|
+
seed: Uint8Array; // decrypted by integrator runtime
|
|
213
|
+
tenantId: string;
|
|
214
|
+
jurisdiction: string;
|
|
215
|
+
sector: string;
|
|
216
|
+
walletId?: string;
|
|
217
|
+
purpose: 'signing' | 'encryption';
|
|
218
|
+
algorithm: 'ES384' | 'ES256K' | 'ML-DSA-65' | 'ML-KEM-768';
|
|
219
|
+
kdfVersion: 'v1';
|
|
220
|
+
};
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Domain separation (mandatory):
|
|
224
|
+
- derive per `purpose` and `algorithm`
|
|
225
|
+
- never reuse derived bytes across different purposes or algorithms
|
|
226
|
+
|
|
227
|
+
Canonical context string:
|
|
228
|
+
- `tenantId|jurisdiction|sector|walletId|purpose|algorithm|kdfVersion`
|
|
229
|
+
|
|
230
|
+
KDF profile (example):
|
|
231
|
+
- `scrypt(seed, salt=context, N=2^15, r=8, p=1, dkLen=64)`
|
|
232
|
+
- split derived bytes by algorithm requirements
|
|
233
|
+
- keep parameters versioned and immutable per `kdfVersion`
|
|
234
|
+
|
|
235
|
+
Why:
|
|
236
|
+
- deterministic reproducibility across runs/devices
|
|
237
|
+
- safe algorithm migration (ES384 now, ML-DSA later) without collisions
|
|
238
|
+
- portable across Node backend and mobile/web SDK profiles
|
|
239
|
+
|
|
240
|
+
Strict DIDComm mode:
|
|
241
|
+
- sign payload as embedded JWS
|
|
242
|
+
- encrypt as JWE for recipient
|
|
243
|
+
- use the derived keypair for the active `purpose+algorithm`
|
|
244
|
+
|
|
245
|
+
Flow links:
|
|
246
|
+
- `CONTROLLER_FLOW_STEP_BY_STEP.md`
|
|
247
|
+
- `LEGAL_ORGANIZATION_FLOW_STEP_BY_STEP.md`
|
|
248
|
+
- `PRACTITIONER_FLOW_STEP_BY_STEP.md`
|
|
249
|
+
- `PERSONAL_FLOW_STEP_BY_STEP.md`
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# CONTROLLER_FLOW_STEP_BY_STEP
|
|
2
|
+
|
|
3
|
+
Strict step-by-step flow for organization onboarding by controller, then controller runtime activation, then employee provisioning.
|
|
4
|
+
|
|
5
|
+
## Security model (read first)
|
|
6
|
+
|
|
7
|
+
Security planes:
|
|
8
|
+
- Transport plane (deployment-specific): backend ↔ gateway channel protection (optional bearer, mTLS, API gateway policy).
|
|
9
|
+
- Identity/business plane (functional flow): controller/member credentials and tokens (`vp_token`, `idToken`, DCR, SMART).
|
|
10
|
+
- Operator/hosting plane: infrastructure tenancy and node-operator lifecycle.
|
|
11
|
+
|
|
12
|
+
Do not mix these planes in implementation or documentation.
|
|
13
|
+
|
|
14
|
+
Secure communication intent:
|
|
15
|
+
- controller/member auth tokens are identity-plane credentials
|
|
16
|
+
- in addition, backend P2P messages can be protected with embedded JWS/JWE via backend wallet
|
|
17
|
+
- use `plain` / `strict` / `auto-detect` communication policy per deployment
|
|
18
|
+
|
|
19
|
+
- `initialOrder` = first order linked to onboarding `offerId` from `_activate`.
|
|
20
|
+
- `licenseOrder` = later order(s) for additional employee licenses.
|
|
21
|
+
- `initialOrder` is authorized by onboarding proof (`vp_token`) + gateway onboarding policy.
|
|
22
|
+
- `licenseOrder` is a runtime business operation and must use controller runtime auth (DCR + SMART token).
|
|
23
|
+
- DCR for controller and DCR for employee are different operations and different identities.
|
|
24
|
+
|
|
25
|
+
Current GW behavior for `Employee/_batch` (important):
|
|
26
|
+
- if employee license pool exists and has `available` seats: employee is created (`201`) and one seat is consumed
|
|
27
|
+
- if employee license pool exists but has no `available` seats: response is `Employee-license-offer-v1.0` (no employee created)
|
|
28
|
+
- if employee license pool does not exist: employee is created without license gating (legacy/non-strict mode)
|
|
29
|
+
|
|
30
|
+
## 0) Runtime context (not all from `.env`)
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
// optional transport security context (deployment-specific, not user identity)
|
|
34
|
+
const transportSecurity = {
|
|
35
|
+
gwBearerToken: process.env.GW_BEARER_TOKEN, // optional
|
|
36
|
+
};
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// profile-level runtime context for this authenticated role in this organization
|
|
41
|
+
const profileContext = {
|
|
42
|
+
baseUrl: process.env.BASE_URL!,
|
|
43
|
+
jurisdiction: process.env.JURISDICTION!, // REQUIRED
|
|
44
|
+
sector: process.env.SECTOR || 'health-care',
|
|
45
|
+
hostRegistrySector: process.env.HOST_REGISTRY_SECTOR || 'test-network',
|
|
46
|
+
gwDidWeb: process.env.GW_DID_WEB!,
|
|
47
|
+
credentialExpSeconds: Number(process.env.CREDENTIAL_EXP_SECONDS || 300),
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// user/session context (from authenticated portal user + selected tenant/org)
|
|
51
|
+
const sessionContext = {
|
|
52
|
+
tenantId: currentSession.tenantId,
|
|
53
|
+
controllerDidWeb: currentSession.controller.didWeb,
|
|
54
|
+
controllerSignKid: currentSession.controller.signKid, // RFC7638 thumbprint / key id
|
|
55
|
+
controllerIdToken: currentSession.idToken,
|
|
56
|
+
controllerPublicJwk: currentSession.controller.publicJwk,
|
|
57
|
+
controllerEmail: currentSession.controller.email,
|
|
58
|
+
newMemberEmailToInvite: uiFormNewMember.email,
|
|
59
|
+
newMemberRoleCode: uiFormNewMember.hasOccupation || 'ISCO-08|2211',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
// credentials issued previously by ICA
|
|
63
|
+
const onboardingProof = {
|
|
64
|
+
organizationVcJwt: currentSession.ica.organizationVcJwt,
|
|
65
|
+
legalRepresentativeVcJwt: currentSession.ica.legalRepresentativeVcJwt, // optional by policy
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## 1) Build and externally sign `vp_token` (controller identity proof)
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import {
|
|
73
|
+
DataspaceNodeClient,
|
|
74
|
+
createVP, addVC, prepareForSignature, prepareBytesForSignature, buildVpTokenCompact,
|
|
75
|
+
buildEpochWindow, generateUuidLike,
|
|
76
|
+
} from 'dataspace-client-sdk-node';
|
|
77
|
+
import { ClaimsPersonSchemaorg } from 'gdc-common-utils-ts/constants/schemaorg';
|
|
78
|
+
|
|
79
|
+
const { iat, exp } = buildEpochWindow(profileContext.credentialExpSeconds);
|
|
80
|
+
const header = {
|
|
81
|
+
alg: 'ES256',
|
|
82
|
+
typ: 'JWT',
|
|
83
|
+
kid: `${sessionContext.controllerDidWeb}#${sessionContext.controllerSignKid}`,
|
|
84
|
+
};
|
|
85
|
+
const uniquePresentationNonce = generateUuidLike();
|
|
86
|
+
const vp = createVP({
|
|
87
|
+
iss: sessionContext.controllerDidWeb,
|
|
88
|
+
sub: sessionContext.controllerDidWeb,
|
|
89
|
+
aud: profileContext.gwDidWeb,
|
|
90
|
+
iat,
|
|
91
|
+
exp,
|
|
92
|
+
nonce: uniquePresentationNonce,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
addVC(vp, onboardingProof.organizationVcJwt);
|
|
96
|
+
if (onboardingProof.legalRepresentativeVcJwt) addVC(vp, onboardingProof.legalRepresentativeVcJwt);
|
|
97
|
+
|
|
98
|
+
const { encodedHeader, encodedPayload } = prepareForSignature(header, vp);
|
|
99
|
+
const bytesToSign = prepareBytesForSignature(header, vp);
|
|
100
|
+
const signatureBase64Url = await externalSigner(bytesToSign); // wallet/HSM
|
|
101
|
+
const vpToken = buildVpTokenCompact(encodedHeader, encodedPayload, signatureBase64Url);
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Signing responsibility (integrator):
|
|
105
|
+
- SDK provides `prepareForSignature(...)` and `prepareBytesForSignature(...)` to produce the JWT signing input.
|
|
106
|
+
- Signing input is exactly: `base64url(header) + "." + base64url(payload)`.
|
|
107
|
+
- The integrator signs that input with the current user's key material (wallet, secure storage, KMS, HSM, etc.).
|
|
108
|
+
- SDK does not require local custody of private keys.
|
|
109
|
+
|
|
110
|
+
## 2) Activate organization in GW (`_activate`)
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
const client = new DataspaceNodeClient({
|
|
114
|
+
baseUrl: profileContext.baseUrl,
|
|
115
|
+
...(transportSecurity.gwBearerToken ? { bearerToken: transportSecurity.gwBearerToken } : {}),
|
|
116
|
+
ctx: { tenantId: sessionContext.tenantId, jurisdiction: profileContext.jurisdiction, sector: profileContext.sector },
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const activation = await client.activateOrganizationInGatewayFromIcaProof(
|
|
120
|
+
{ jurisdiction: profileContext.jurisdiction, sector: profileContext.hostRegistrySector },
|
|
121
|
+
{
|
|
122
|
+
vpToken,
|
|
123
|
+
organizationVc: onboardingProof.organizationVcJwt,
|
|
124
|
+
legalRepresentativeVc: onboardingProof.legalRepresentativeVcJwt,
|
|
125
|
+
numberOfMembers: 2,
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Jurisdiction is mandatory in all flows:
|
|
131
|
+
- Professional: the organization/tenant is registered in one jurisdiction (country) and routes/scopes resolve against it.
|
|
132
|
+
- Personal: user selects jurisdiction first, then service providers are filtered for that jurisdiction.
|
|
133
|
+
- Integrator rule: reject onboarding/session setup when `jurisdiction` is missing.
|
|
134
|
+
|
|
135
|
+
Auth model note:
|
|
136
|
+
- user auth tokens (`idToken`, SMART access token) belong to the identity/business plane (authenticated user session).
|
|
137
|
+
- `transportSecurity.gwBearerToken` belongs to the transport plane only and is optional by deployment.
|
|
138
|
+
- `transportSecurity.gwBearerToken` is not an ICA exchange token and not a controller API key.
|
|
139
|
+
|
|
140
|
+
## 3) Read Offer and show explicit acceptance UI
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
const offerId = client.getOfferIdFromResponse(activation);
|
|
144
|
+
const offer = client.getOfferInfoFromResponse(activation);
|
|
145
|
+
if (!offerId) throw new Error('Offer id missing in activation response');
|
|
146
|
+
// show offer in UI and wait for user acceptance
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
## 4) Send Order (always, even if amount is 0)
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// This is the INITIAL ORDER (onboarding order).
|
|
153
|
+
const initialOrder = await client.confirmLegalOrganizationOrderSimple({ offerId });
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## 5) Controller runtime activation (DCR) to operate organization's APIs
|
|
157
|
+
|
|
158
|
+
This is separate from onboarding proof. It creates controller device runtime identity.
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
const controllerActivationCode =
|
|
162
|
+
client.getActivationCodeFromResponse(initialOrder) ||
|
|
163
|
+
client.getActivationCodeFromResponse(activation);
|
|
164
|
+
if (!controllerActivationCode) throw new Error('Controller activation code not found');
|
|
165
|
+
|
|
166
|
+
const controllerDevice = await client.activateEmployeeDeviceWithActivationCodeSimple({
|
|
167
|
+
tenantId: sessionContext.tenantId,
|
|
168
|
+
jurisdiction: profileContext.jurisdiction,
|
|
169
|
+
sector: profileContext.sector,
|
|
170
|
+
activationCode: controllerActivationCode,
|
|
171
|
+
idToken: sessionContext.controllerIdToken,
|
|
172
|
+
dcrPayload: {
|
|
173
|
+
application_type: 'web',
|
|
174
|
+
token_endpoint_auth_method: 'private_key_jwt',
|
|
175
|
+
jwks: { keys: [sessionContext.controllerPublicJwk] },
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
## 6) Controller identity token vs entity token (separate concerns)
|
|
181
|
+
|
|
182
|
+
- Identity token request endpoint is tenant-scoped auth route:
|
|
183
|
+
`/host/cds-{jurisdiction}/v1/{sector}/{tenantId}/identity/auth/_token`
|
|
184
|
+
- The returned access token is then used to call entity/business endpoints like:
|
|
185
|
+
`/{tenantId}/cds-{jurisdiction}/v1/{sector}/entity/org.schema/Employee/_batch`
|
|
186
|
+
|
|
187
|
+
Use explicit cache keys by intent to avoid confusion:
|
|
188
|
+
- `controller_identity_token:*` for auth/token operations
|
|
189
|
+
- `controller_entity_token:*` for entity/business operations
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
const controllerSmart = await client.requestSmartTokenSimple({
|
|
193
|
+
tenantId: sessionContext.tenantId,
|
|
194
|
+
jurisdiction: profileContext.jurisdiction,
|
|
195
|
+
sector: profileContext.sector,
|
|
196
|
+
idToken: sessionContext.controllerIdToken,
|
|
197
|
+
targetEndpoint: client.getEndpointId(
|
|
198
|
+
{ section: 'organization', format: 'org.hl7.fhir.r4', resourceType: 'Person', action: '_batch' },
|
|
199
|
+
sessionContext.controllerDidWeb,
|
|
200
|
+
),
|
|
201
|
+
scopes: [
|
|
202
|
+
'organization/Person.cruds',
|
|
203
|
+
'organization/Organization.cruds',
|
|
204
|
+
'organization/Consent.cruds',
|
|
205
|
+
],
|
|
206
|
+
});
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## 7) Create newMember/practitioner employee (runtime, uses controller token)
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const employee = await client.createOrganizationEmployee(
|
|
213
|
+
{ tenantId: sessionContext.tenantId, jurisdiction: profileContext.jurisdiction, sector: profileContext.sector },
|
|
214
|
+
{
|
|
215
|
+
employeeClaims: {
|
|
216
|
+
'@context': 'org.schema',
|
|
217
|
+
[ClaimsPersonSchemaorg.email]: sessionContext.newMemberEmailToInvite,
|
|
218
|
+
[ClaimsPersonSchemaorg.hasOccupation]: sessionContext.newMemberRoleCode,
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
## 8) Extract newMember activation code and hand off to practitioner flow
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const newMemberActivationCode = client.getActivationCodeFromResponse(employee);
|
|
228
|
+
if (!newMemberActivationCode) throw new Error('newMember activation code not found');
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
## 9) If no license is available: create `licenseOrder` (separate from `initialOrder`)
|
|
232
|
+
|
|
233
|
+
When employee creation returns license-offer/gating instead of activation code:
|
|
234
|
+
|
|
235
|
+
1. Extract `licenseOfferId` from employee response (same `getOfferIdFromResponse(...)` helper).
|
|
236
|
+
2. Show offer in UI and request explicit user acceptance.
|
|
237
|
+
3. Submit a new order as `licenseOrder`.
|
|
238
|
+
4. Retry employee creation after successful `licenseOrder`.
|
|
239
|
+
|
|
240
|
+
```ts
|
|
241
|
+
const licenseOfferId = client.getOfferIdFromResponse(employee);
|
|
242
|
+
if (!licenseOfferId) throw new Error('License offer id missing');
|
|
243
|
+
|
|
244
|
+
// This is a LICENSE ORDER (runtime order), not the onboarding order.
|
|
245
|
+
const licenseOrder = await client.confirmLegalOrganizationOrderSimple({
|
|
246
|
+
offerId: licenseOfferId,
|
|
247
|
+
});
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Authentication rules for this step:
|
|
251
|
+
- never reuse onboarding `vp_token` for runtime ordering
|
|
252
|
+
- use controller runtime identity (DCR already completed)
|
|
253
|
+
- use controller SMART token for protected runtime routes
|
|
254
|
+
|
|
255
|
+
Route intent:
|
|
256
|
+
- controller's onboarding activation and onboarding order for the legal organization: `/host/.../registry/...`
|
|
257
|
+
- controller's identity DCR/token flows are tenant-scoped for the legal organization but exposed as `/host/.../{tenantId}/identity/auth/...`
|
|
258
|
+
- employee business operations use tenant-scoped data routes: `/{tenantId}/.../entity/...` (or corresponding section)
|
|
259
|
+
|
|
260
|
+
Complete path examples:
|
|
261
|
+
(using `test-network` but `test` can be used for local development)
|
|
262
|
+
- activate organization:
|
|
263
|
+
`/host/cds-ES/v1/test-network/registry/org.schema/Organization/_activate`
|
|
264
|
+
- poll activate:
|
|
265
|
+
`/host/cds-ES/v1/test-network/registry/org.schema/Organization/_activate-response`
|
|
266
|
+
- initial onboarding order:
|
|
267
|
+
`/host/cds-ES/v1/test-network/registry/org.schema/Order/_batch`
|
|
268
|
+
- poll onboarding order:
|
|
269
|
+
`/host/cds-ES/v1/test-network/registry/org.schema/Order/_batch-response`
|
|
270
|
+
- controller/employee identity exchange (activation code -> initial token):
|
|
271
|
+
`/host/cds-ES/v1/health-care/{tenantId}/identity/auth/_exchange`
|
|
272
|
+
- controller's DCR (consumes the initial access token with scope 'dcr' as per OpenID DCR):
|
|
273
|
+
`/host/cds-ES/v1/health-care/{tenantId}/identity/auth/_dcr`
|
|
274
|
+
- controller's smart token for entity management:
|
|
275
|
+
`/host/cds-ES/v1/health-care/{tenantId}/identity/auth/_token`
|
|
276
|
+
- employee creation:
|
|
277
|
+
`/{tenantId}/cds-ES/v1/health-care/entity/org.schema/Employee/_batch`
|
|
278
|
+
|
|
279
|
+
Continue with:
|
|
280
|
+
- `PRACTITIONER_FLOW_STEP_BY_STEP.md`
|
|
281
|
+
|
|
282
|
+
Shared wallet derivation profile:
|
|
283
|
+
- `BACKEND_NODE_INTEGRATION.md` ("Deterministic Wallet Profile")
|
|
@@ -11,6 +11,7 @@ SDK: `dataspace-client-sdk-node` (`DataspaceNodeClient`)
|
|
|
11
11
|
|
|
12
12
|
```ts
|
|
13
13
|
import { DataspaceNodeClient } from 'dataspace-client-sdk-node';
|
|
14
|
+
import { ClaimsPersonSchemaorg } from 'gdc-common-utils-ts/constants/schemaorg';
|
|
14
15
|
|
|
15
16
|
const client = new DataspaceNodeClient({
|
|
16
17
|
baseUrl: process.env.GW_BASE_URL!,
|
|
@@ -29,6 +30,7 @@ const ctx = {
|
|
|
29
30
|
Terminology note: `subject` names in methods/claims refer to the member (person/patient) orchestrated by a personal organization with controller/manager and associated members.
|
|
30
31
|
|
|
31
32
|
Method: `bootstrapSubjectOrganizationIndex(ctx, input)`
|
|
33
|
+
Test: [tests/client.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/client.test.mjs), [tests/uc5-subject-data.flow.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/uc5-subject-data.flow.test.mjs)
|
|
32
34
|
|
|
33
35
|
```ts
|
|
34
36
|
const result = await client.bootstrapSubjectOrganizationIndex(ctx, {
|
|
@@ -48,6 +50,7 @@ const result = await client.bootstrapSubjectOrganizationIndex(ctx, {
|
|
|
48
50
|
## UC5.2 Legal organization activation in GW (from ICA proof)
|
|
49
51
|
|
|
50
52
|
Method: `activateOrganizationInGatewayFromIcaProof(hostCtx, input)`
|
|
53
|
+
Test: [tests/client.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/client.test.mjs), [tests/uc5-org-onboarding.flow.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/uc5-org-onboarding.flow.test.mjs)
|
|
51
54
|
|
|
52
55
|
```ts
|
|
53
56
|
const activation = await client.activateOrganizationInGatewayFromIcaProof(
|
|
@@ -64,13 +67,14 @@ const activation = await client.activateOrganizationInGatewayFromIcaProof(
|
|
|
64
67
|
## UC5.3 Create employee / professional license
|
|
65
68
|
|
|
66
69
|
Method: `createOrganizationEmployee(ctx, input)`
|
|
70
|
+
Test: [tests/client.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/client.test.mjs), [tests/uc5-org-onboarding.flow.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/uc5-org-onboarding.flow.test.mjs)
|
|
67
71
|
|
|
68
72
|
```ts
|
|
69
73
|
await client.createOrganizationEmployee(ctx, {
|
|
70
74
|
employeeClaims: {
|
|
71
75
|
'@context': 'org.schema',
|
|
72
|
-
|
|
73
|
-
|
|
76
|
+
[ClaimsPersonSchemaorg.email]: 'doctor@example.com',
|
|
77
|
+
[ClaimsPersonSchemaorg.hasOccupation]: 'ISCO-08|2211',
|
|
74
78
|
},
|
|
75
79
|
});
|
|
76
80
|
```
|
|
@@ -78,6 +82,7 @@ await client.createOrganizationEmployee(ctx, {
|
|
|
78
82
|
## UC5.4 Activate employee device
|
|
79
83
|
|
|
80
84
|
Method: `activateEmployeeDeviceWithActivationCode(ctx, input)`
|
|
85
|
+
Test: [tests/client.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/client.test.mjs), [tests/uc5-org-onboarding.flow.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/uc5-org-onboarding.flow.test.mjs)
|
|
81
86
|
|
|
82
87
|
```ts
|
|
83
88
|
const device = await client.activateEmployeeDeviceWithActivationCode(ctx, {
|
|
@@ -94,6 +99,7 @@ const device = await client.activateEmployeeDeviceWithActivationCode(ctx, {
|
|
|
94
99
|
## UC5.5 Import IPS/FHIR and update index
|
|
95
100
|
|
|
96
101
|
Method: `importIpsOrFhirAndUpdateIndex(ctx, input)`
|
|
102
|
+
Test: [tests/client.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/client.test.mjs), [tests/uc5-subject-data.flow.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/uc5-subject-data.flow.test.mjs)
|
|
97
103
|
|
|
98
104
|
```ts
|
|
99
105
|
await client.importIpsOrFhirAndUpdateIndex(ctx, {
|
|
@@ -110,6 +116,7 @@ await client.importIpsOrFhirAndUpdateIndex(ctx, {
|
|
|
110
116
|
|
|
111
117
|
Step 1 method: `grantProfessionalAccessSimple(ctx, input)`
|
|
112
118
|
Step 2 method: `requestSmartToken(input)`
|
|
119
|
+
Test: [tests/client.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/client.test.mjs), [tests/uc5-subject-data.flow.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/uc5-subject-data.flow.test.mjs)
|
|
113
120
|
|
|
114
121
|
Domain note: the protected context is modeled as organization (including personal organizations with controller + subject/person/patient members).
|
|
115
122
|
|
|
@@ -138,6 +145,7 @@ const token = await client.requestSmartToken({
|
|
|
138
145
|
## UC5.7 Generate digital twin
|
|
139
146
|
|
|
140
147
|
Method: `generateDigitalTwinFromSubjectData(ctx, input)`
|
|
148
|
+
Test: [tests/client.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/client.test.mjs), [tests/uc5-subject-data.flow.test.mjs](/Users/fernando/GITS/gdc-workspace/dataspace-client-sdk-node/tests/uc5-subject-data.flow.test.mjs)
|
|
141
149
|
|
|
142
150
|
```ts
|
|
143
151
|
await client.generateDigitalTwinFromSubjectData(ctx, {
|