gdc-common-utils-ts 2.3.29 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/models/device-license.d.ts +26 -0
- package/dist/models/fhir-r5-subscription.d.ts +123 -0
- package/dist/models/fhir-r5-subscription.js +138 -0
- package/dist/models/index.d.ts +1 -0
- package/dist/models/index.js +1 -0
- package/dist/models/openid-device.d.ts +19 -4
- package/dist/utils/actor-identifier.d.ts +36 -0
- package/dist/utils/actor-identifier.js +58 -0
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.js +1 -0
- package/dist/utils/license.d.ts +12 -0
- package/dist/utils/license.js +35 -0
- package/package.json +1 -1
|
@@ -17,6 +17,19 @@ export interface DeviceInfo {
|
|
|
17
17
|
/** The model of the device (e.g., "iPhone14,6"). */
|
|
18
18
|
model?: string;
|
|
19
19
|
}
|
|
20
|
+
/** One concrete DCR client/application installation bound to a licensed seat. */
|
|
21
|
+
export interface DeviceBinding {
|
|
22
|
+
/** Authorization-server assigned DCR client identifier. */
|
|
23
|
+
clientId: string;
|
|
24
|
+
/** Stable application installation fingerprint supplied by the client. */
|
|
25
|
+
clientInstanceId: string;
|
|
26
|
+
/** Current relationship between this installation and the seat. */
|
|
27
|
+
status: 'active' | 'revoked';
|
|
28
|
+
/** Device/application metadata captured during DCR. */
|
|
29
|
+
deviceInfo: DeviceInfo;
|
|
30
|
+
activatedAt: number;
|
|
31
|
+
revokedAt?: number;
|
|
32
|
+
}
|
|
20
33
|
/**
|
|
21
34
|
* A set of rules that a device must match to be eligible to activate a license.
|
|
22
35
|
*/
|
|
@@ -141,6 +154,19 @@ export interface DeviceLicense {
|
|
|
141
154
|
* Populated when the status becomes 'active'.
|
|
142
155
|
*/
|
|
143
156
|
deviceId?: string;
|
|
157
|
+
/**
|
|
158
|
+
* Maximum number of simultaneously active DCR installations for this seat.
|
|
159
|
+
* Defaults to two when omitted for legacy records.
|
|
160
|
+
*/
|
|
161
|
+
maxDevices?: number;
|
|
162
|
+
/** All DCR installations bound to this user/member seat. */
|
|
163
|
+
deviceBindings?: DeviceBinding[];
|
|
164
|
+
/**
|
|
165
|
+
* Stable private actor identifier:
|
|
166
|
+
* `urn:multibase:<hash(normalized email or phone)>:<professional|personal>`.
|
|
167
|
+
* Portal DIDs, IdP subjects, wallets and DCR clients bind to this value but do not define it.
|
|
168
|
+
*/
|
|
169
|
+
activatedBy?: string;
|
|
144
170
|
/**
|
|
145
171
|
* Optional, pre-defined restrictions on which devices are allowed to activate this license.
|
|
146
172
|
* Set at the time of license creation.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** Standard FHIR R5 Subscription channel coding. */
|
|
2
|
+
export declare const FhirR5SubscriptionChannelTypes: Readonly<{
|
|
3
|
+
readonly RestHook: "rest-hook";
|
|
4
|
+
}>;
|
|
5
|
+
export declare const FhirR5SubscriptionScopes: Readonly<{
|
|
6
|
+
readonly Individual: "individual";
|
|
7
|
+
readonly Tenant: "tenant";
|
|
8
|
+
}>;
|
|
9
|
+
export type FhirR5SubscriptionScope = typeof FhirR5SubscriptionScopes[keyof typeof FhirR5SubscriptionScopes];
|
|
10
|
+
export interface FhirR5SubscriptionFilter {
|
|
11
|
+
resourceType?: string;
|
|
12
|
+
filterParameter: string;
|
|
13
|
+
comparator?: 'eq' | 'ne';
|
|
14
|
+
modifier?: string;
|
|
15
|
+
value: string;
|
|
16
|
+
}
|
|
17
|
+
export interface FhirR5Subscription {
|
|
18
|
+
resourceType: 'Subscription';
|
|
19
|
+
id: string;
|
|
20
|
+
status: 'requested' | 'active' | 'error' | 'off' | 'entered-in-error';
|
|
21
|
+
topic: string;
|
|
22
|
+
reason?: string;
|
|
23
|
+
filterBy?: FhirR5SubscriptionFilter[];
|
|
24
|
+
channelType: {
|
|
25
|
+
system?: string;
|
|
26
|
+
code: string;
|
|
27
|
+
};
|
|
28
|
+
endpoint?: string;
|
|
29
|
+
parameter?: Array<{
|
|
30
|
+
name: string;
|
|
31
|
+
value: string;
|
|
32
|
+
}>;
|
|
33
|
+
contentType?: string;
|
|
34
|
+
content?: 'empty' | 'id-only' | 'full-resource';
|
|
35
|
+
heartbeatPeriod?: number;
|
|
36
|
+
timeout?: number;
|
|
37
|
+
maxCount?: number;
|
|
38
|
+
}
|
|
39
|
+
export interface FhirR5SubscriptionTopicFilterDefinition {
|
|
40
|
+
resourceType?: string;
|
|
41
|
+
filterParameter: string;
|
|
42
|
+
comparator?: Array<'eq' | 'ne'>;
|
|
43
|
+
modifier?: string[];
|
|
44
|
+
}
|
|
45
|
+
export interface FhirR5SubscriptionTopic {
|
|
46
|
+
resourceType: 'SubscriptionTopic';
|
|
47
|
+
id: string;
|
|
48
|
+
url: string;
|
|
49
|
+
status: 'draft' | 'active' | 'retired' | 'unknown';
|
|
50
|
+
title?: string;
|
|
51
|
+
description?: string;
|
|
52
|
+
resourceTrigger: Array<{
|
|
53
|
+
resource: string;
|
|
54
|
+
}>;
|
|
55
|
+
canFilterBy?: FhirR5SubscriptionTopicFilterDefinition[];
|
|
56
|
+
}
|
|
57
|
+
export interface BuildFhirR5RestHookSubscriptionInput {
|
|
58
|
+
id: string;
|
|
59
|
+
scope: FhirR5SubscriptionScope;
|
|
60
|
+
topic: string;
|
|
61
|
+
endpoint: string;
|
|
62
|
+
filters?: readonly FhirR5SubscriptionFilter[];
|
|
63
|
+
reason?: string;
|
|
64
|
+
contentType?: string;
|
|
65
|
+
heartbeatPeriod?: number;
|
|
66
|
+
timeout?: number;
|
|
67
|
+
}
|
|
68
|
+
/** Builds a reusable standards-shaped FHIR R5 rest-hook Subscription. */
|
|
69
|
+
export declare function buildFhirR5RestHookSubscription(input: BuildFhirR5RestHookSubscriptionInput): FhirR5Subscription;
|
|
70
|
+
/** Builds the relative gateway `_batch` request for a Subscription resource. */
|
|
71
|
+
export declare function buildFhirR5SubscriptionBatch(subscription: FhirR5Subscription, scope: FhirR5SubscriptionScope): {
|
|
72
|
+
path: string;
|
|
73
|
+
body: {
|
|
74
|
+
resourceType: "Bundle";
|
|
75
|
+
type: "batch";
|
|
76
|
+
entry: {
|
|
77
|
+
request: {
|
|
78
|
+
method: "POST";
|
|
79
|
+
url: "Subscription";
|
|
80
|
+
};
|
|
81
|
+
resource: FhirR5Subscription;
|
|
82
|
+
}[];
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
/** Builds the standard R5 notification Bundle delivered to a subscriber. */
|
|
86
|
+
export declare function buildFhirR5SubscriptionNotification(input: {
|
|
87
|
+
subscriptionReference: string;
|
|
88
|
+
topic?: string;
|
|
89
|
+
eventNumber: number;
|
|
90
|
+
focusReference: string;
|
|
91
|
+
eventsSinceSubscriptionStart: number;
|
|
92
|
+
timestamp?: string;
|
|
93
|
+
additionalContextReferences?: readonly string[];
|
|
94
|
+
}): {
|
|
95
|
+
resourceType: "Bundle";
|
|
96
|
+
type: "subscription-notification";
|
|
97
|
+
timestamp: string;
|
|
98
|
+
entry: {
|
|
99
|
+
fullUrl: string;
|
|
100
|
+
resource: {
|
|
101
|
+
eventsSinceSubscriptionStart: string;
|
|
102
|
+
notificationEvent: {
|
|
103
|
+
additionalContext?: {
|
|
104
|
+
reference: string;
|
|
105
|
+
}[] | undefined;
|
|
106
|
+
eventNumber: string;
|
|
107
|
+
timestamp: string;
|
|
108
|
+
focus: {
|
|
109
|
+
reference: string;
|
|
110
|
+
};
|
|
111
|
+
}[];
|
|
112
|
+
topic?: string | undefined;
|
|
113
|
+
resourceType: "SubscriptionStatus";
|
|
114
|
+
status: "active";
|
|
115
|
+
type: "event-notification";
|
|
116
|
+
subscription: {
|
|
117
|
+
reference: string;
|
|
118
|
+
};
|
|
119
|
+
};
|
|
120
|
+
}[];
|
|
121
|
+
};
|
|
122
|
+
/** Evaluates the initial portable `eq`/`ne` SubscriptionTopic filter profile. */
|
|
123
|
+
export declare function matchesFhirR5SubscriptionEvent(subscription: FhirR5Subscription, topic: FhirR5SubscriptionTopic, resource: Record<string, any>): boolean;
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/** Standard FHIR R5 Subscription channel coding. */
|
|
2
|
+
export const FhirR5SubscriptionChannelTypes = Object.freeze({ RestHook: 'rest-hook' });
|
|
3
|
+
export const FhirR5SubscriptionScopes = Object.freeze({
|
|
4
|
+
Individual: 'individual',
|
|
5
|
+
Tenant: 'tenant',
|
|
6
|
+
});
|
|
7
|
+
function requiredText(value, label) {
|
|
8
|
+
const normalized = String(value || '').trim();
|
|
9
|
+
if (!normalized)
|
|
10
|
+
throw new Error(`${label} is required.`);
|
|
11
|
+
return normalized;
|
|
12
|
+
}
|
|
13
|
+
function absoluteHttpsUrl(value, label) {
|
|
14
|
+
const normalized = requiredText(value, label);
|
|
15
|
+
let parsed;
|
|
16
|
+
try {
|
|
17
|
+
parsed = new URL(normalized);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new Error(`${label} must be an absolute HTTPS URL.`);
|
|
21
|
+
}
|
|
22
|
+
if (parsed.protocol !== 'https:')
|
|
23
|
+
throw new Error(`${label} must be an absolute HTTPS URL.`);
|
|
24
|
+
return parsed.href;
|
|
25
|
+
}
|
|
26
|
+
/** Builds a reusable standards-shaped FHIR R5 rest-hook Subscription. */
|
|
27
|
+
export function buildFhirR5RestHookSubscription(input) {
|
|
28
|
+
const filters = (input.filters || []).map((filter) => ({
|
|
29
|
+
...(filter.resourceType ? { resourceType: requiredText(filter.resourceType, 'Subscription filter resourceType') } : {}),
|
|
30
|
+
filterParameter: requiredText(filter.filterParameter, 'Subscription filter parameter'),
|
|
31
|
+
...(filter.comparator ? { comparator: filter.comparator } : {}),
|
|
32
|
+
...(filter.modifier ? { modifier: filter.modifier } : {}),
|
|
33
|
+
value: requiredText(filter.value, 'Subscription filter value'),
|
|
34
|
+
}));
|
|
35
|
+
if (input.scope === FhirR5SubscriptionScopes.Individual) {
|
|
36
|
+
const exactSubject = filters.some((filter) => ['patient', 'subject'].includes(filter.filterParameter.toLowerCase())
|
|
37
|
+
&& !filter.value.includes('*') && !filter.value.includes(','));
|
|
38
|
+
if (!exactSubject)
|
|
39
|
+
throw new Error('An individual-scoped Subscription requires an exact patient or subject filter.');
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
resourceType: 'Subscription',
|
|
43
|
+
id: requiredText(input.id, 'Subscription id'),
|
|
44
|
+
status: 'requested',
|
|
45
|
+
topic: absoluteHttpsUrl(input.topic, 'Subscription topic'),
|
|
46
|
+
reason: input.reason?.trim() || 'Notify the registered BFF when matching data changes.',
|
|
47
|
+
...(filters.length ? { filterBy: filters } : {}),
|
|
48
|
+
channelType: {
|
|
49
|
+
system: 'http://terminology.hl7.org/CodeSystem/subscription-channel-type',
|
|
50
|
+
code: FhirR5SubscriptionChannelTypes.RestHook,
|
|
51
|
+
},
|
|
52
|
+
endpoint: absoluteHttpsUrl(input.endpoint, 'Subscription endpoint'),
|
|
53
|
+
contentType: input.contentType?.trim() || 'application/fhir+json',
|
|
54
|
+
content: 'id-only',
|
|
55
|
+
...(input.heartbeatPeriod !== undefined ? { heartbeatPeriod: input.heartbeatPeriod } : {}),
|
|
56
|
+
...(input.timeout !== undefined ? { timeout: input.timeout } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** Builds the relative gateway `_batch` request for a Subscription resource. */
|
|
60
|
+
export function buildFhirR5SubscriptionBatch(subscription, scope) {
|
|
61
|
+
const section = scope === FhirR5SubscriptionScopes.Individual ? 'individual' : 'entity';
|
|
62
|
+
return {
|
|
63
|
+
path: `${section}/org.hl7.fhir.r5/Subscription/_batch`,
|
|
64
|
+
body: {
|
|
65
|
+
resourceType: 'Bundle',
|
|
66
|
+
type: 'batch',
|
|
67
|
+
entry: [{ request: { method: 'POST', url: 'Subscription' }, resource: subscription }],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/** Builds the standard R5 notification Bundle delivered to a subscriber. */
|
|
72
|
+
export function buildFhirR5SubscriptionNotification(input) {
|
|
73
|
+
const timestamp = input.timestamp || new Date().toISOString();
|
|
74
|
+
return {
|
|
75
|
+
resourceType: 'Bundle',
|
|
76
|
+
type: 'subscription-notification',
|
|
77
|
+
timestamp,
|
|
78
|
+
entry: [{
|
|
79
|
+
fullUrl: 'urn:uuid:subscription-status',
|
|
80
|
+
resource: {
|
|
81
|
+
resourceType: 'SubscriptionStatus',
|
|
82
|
+
status: 'active',
|
|
83
|
+
type: 'event-notification',
|
|
84
|
+
subscription: { reference: requiredText(input.subscriptionReference, 'Subscription reference') },
|
|
85
|
+
...(input.topic ? { topic: input.topic } : {}),
|
|
86
|
+
eventsSinceSubscriptionStart: String(input.eventsSinceSubscriptionStart),
|
|
87
|
+
notificationEvent: [{
|
|
88
|
+
eventNumber: String(input.eventNumber),
|
|
89
|
+
timestamp,
|
|
90
|
+
focus: { reference: requiredText(input.focusReference, 'Notification focus reference') },
|
|
91
|
+
...(input.additionalContextReferences?.length ? {
|
|
92
|
+
additionalContext: input.additionalContextReferences.map((reference) => ({ reference })),
|
|
93
|
+
} : {}),
|
|
94
|
+
}],
|
|
95
|
+
},
|
|
96
|
+
}],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function readPath(resource, path) {
|
|
100
|
+
const aliases = {
|
|
101
|
+
patient: ['patient.reference', 'subject.reference'],
|
|
102
|
+
subject: ['subject.reference', 'patient.reference'],
|
|
103
|
+
};
|
|
104
|
+
const paths = aliases[path.toLowerCase()] || [path];
|
|
105
|
+
const values = [];
|
|
106
|
+
for (const candidate of paths) {
|
|
107
|
+
let current = [resource];
|
|
108
|
+
for (const part of candidate.split('.')) {
|
|
109
|
+
current = current.flatMap((value) => {
|
|
110
|
+
const next = value?.[part];
|
|
111
|
+
return Array.isArray(next) ? next : next === undefined ? [] : [next];
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
values.push(...current.map((value) => String(value)).filter(Boolean));
|
|
115
|
+
}
|
|
116
|
+
return values;
|
|
117
|
+
}
|
|
118
|
+
/** Evaluates the initial portable `eq`/`ne` SubscriptionTopic filter profile. */
|
|
119
|
+
export function matchesFhirR5SubscriptionEvent(subscription, topic, resource) {
|
|
120
|
+
if (subscription.status !== 'active' || topic.status !== 'active' || subscription.topic !== topic.url)
|
|
121
|
+
return false;
|
|
122
|
+
const resourceType = String(resource.resourceType || '');
|
|
123
|
+
if (!topic.resourceTrigger.some((trigger) => trigger.resource === resourceType))
|
|
124
|
+
return false;
|
|
125
|
+
return (subscription.filterBy || []).every((filter) => {
|
|
126
|
+
if (filter.resourceType && filter.resourceType !== resourceType)
|
|
127
|
+
return true;
|
|
128
|
+
const allowed = (topic.canFilterBy || []).find((candidate) => candidate.filterParameter === filter.filterParameter
|
|
129
|
+
&& (!candidate.resourceType || candidate.resourceType === resourceType));
|
|
130
|
+
if (!allowed)
|
|
131
|
+
return false;
|
|
132
|
+
const comparator = filter.comparator || 'eq';
|
|
133
|
+
if (allowed.comparator?.length && !allowed.comparator.includes(comparator))
|
|
134
|
+
return false;
|
|
135
|
+
const match = readPath(resource, filter.filterParameter).includes(filter.value);
|
|
136
|
+
return comparator === 'ne' ? !match : match;
|
|
137
|
+
});
|
|
138
|
+
}
|
package/dist/models/index.d.ts
CHANGED
|
@@ -41,6 +41,7 @@ export * from './oidc4ida.document.model';
|
|
|
41
41
|
export * from './oidc4ida.electronicRecord.model';
|
|
42
42
|
export * from './oidc4ida.evidence.model';
|
|
43
43
|
export * from './openid-device';
|
|
44
|
+
export * from './fhir-r5-subscription';
|
|
44
45
|
export * from './permission-templates';
|
|
45
46
|
export * from './profile-manager';
|
|
46
47
|
export * from './operation-outcome';
|
package/dist/models/index.js
CHANGED
|
@@ -41,6 +41,7 @@ export * from './oidc4ida.document.model.js';
|
|
|
41
41
|
export * from './oidc4ida.electronicRecord.model.js';
|
|
42
42
|
export * from './oidc4ida.evidence.model.js';
|
|
43
43
|
export * from './openid-device.js';
|
|
44
|
+
export * from './fhir-r5-subscription.js';
|
|
44
45
|
export * from './permission-templates.js';
|
|
45
46
|
export * from './profile-manager.js';
|
|
46
47
|
export * from './operation-outcome.js';
|
|
@@ -13,12 +13,12 @@ export interface OpenIdDeviceInfo {
|
|
|
13
13
|
* The push notification token for the device.
|
|
14
14
|
* @example "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]"
|
|
15
15
|
*/
|
|
16
|
-
push_token
|
|
16
|
+
push_token?: string;
|
|
17
17
|
/**
|
|
18
18
|
* The push notification provider.
|
|
19
19
|
* @example "expo"
|
|
20
20
|
*/
|
|
21
|
-
push_provider
|
|
21
|
+
push_provider?: string;
|
|
22
22
|
/**
|
|
23
23
|
* A unique identifier for the device, such as the OS internal build ID.
|
|
24
24
|
* @example "19.6.0"
|
|
@@ -44,9 +44,19 @@ export interface DcrRegistrationRequest {
|
|
|
44
44
|
*/
|
|
45
45
|
redirect_uris: string[];
|
|
46
46
|
/**
|
|
47
|
-
* Kind of the application.
|
|
47
|
+
* Kind of the application. Browser/BFF clients use `web`; installed apps use
|
|
48
|
+
* `native`. This does not identify a concrete browser session or push token.
|
|
48
49
|
*/
|
|
49
|
-
application_type?: 'native';
|
|
50
|
+
application_type?: 'native' | 'web';
|
|
51
|
+
/**
|
|
52
|
+
* Stable identifier assigned by the software publisher. The GDC profile uses
|
|
53
|
+
* a reverse-DNS value such as `es.globaldatacare.portal`; the authorization
|
|
54
|
+
* server still assigns a distinct `client_id` to the registration instance.
|
|
55
|
+
* @see https://www.rfc-editor.org/rfc/rfc7591.html#section-2
|
|
56
|
+
*/
|
|
57
|
+
software_id?: string;
|
|
58
|
+
/** Version of the client software identified by `software_id`. */
|
|
59
|
+
software_version?: string;
|
|
50
60
|
/**
|
|
51
61
|
* Human-readable name of the client to be presented to the end-user.
|
|
52
62
|
* @example "My Awesome App"
|
|
@@ -78,6 +88,11 @@ export interface DcrRegistrationRequest {
|
|
|
78
88
|
jwks?: JwkSet;
|
|
79
89
|
/**
|
|
80
90
|
* Custom data about the specific device instance being registered.
|
|
91
|
+
*
|
|
92
|
+
* `push_token` and `push_provider` may bootstrap one delivery endpoint, but
|
|
93
|
+
* push subscriptions have their own lifecycle and are not the device/client
|
|
94
|
+
* identity. A client can later maintain several Web Push, APNs or FCM
|
|
95
|
+
* subscriptions without issuing another professional seat.
|
|
81
96
|
* This is prefixed to avoid collision with standard fields.
|
|
82
97
|
*/
|
|
83
98
|
ext_device_info?: OpenIdDeviceInfo;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Cross-portal actor classes. These are intentionally broader than job roles. */
|
|
2
|
+
export declare const StableActorRoles: Readonly<{
|
|
3
|
+
readonly Professional: "professional";
|
|
4
|
+
readonly Personal: "personal";
|
|
5
|
+
}>;
|
|
6
|
+
export type StableActorRole = typeof StableActorRoles[keyof typeof StableActorRoles];
|
|
7
|
+
export type StableActorContactKind = 'email' | 'phone';
|
|
8
|
+
/**
|
|
9
|
+
* Builds the private, stable actor identifier shared by independent portals.
|
|
10
|
+
*
|
|
11
|
+
* Wire format: `urn:multibase:<multibase(multihash(SHA3-256, normalized-contact))>:<role>`.
|
|
12
|
+
* Examples end in `:professional` or `:personal`; the clear email/phone is
|
|
13
|
+
* never embedded. A portal-specific `did:web`, IdP `sub`, wallet and DCR
|
|
14
|
+
* `client_id` are bindings to this identifier, not replacements for it.
|
|
15
|
+
*
|
|
16
|
+
* Email is trimmed, stripped of `mailto:` and lower-cased. Phone is stripped
|
|
17
|
+
* of `tel:` and formatting while preserving an E.164 leading `+`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildStableActorIdentifier(input: {
|
|
20
|
+
contactKind: StableActorContactKind;
|
|
21
|
+
contact: string;
|
|
22
|
+
role: StableActorRole;
|
|
23
|
+
}): string;
|
|
24
|
+
/**
|
|
25
|
+
* Builds a portal-specific DID binding that carries the stable actor payload.
|
|
26
|
+
* The portal root can change while the final actor payload remains identical.
|
|
27
|
+
*/
|
|
28
|
+
export declare function buildPortalActorDidWeb(input: {
|
|
29
|
+
portalDidWeb: string;
|
|
30
|
+
actorIdentifier: string;
|
|
31
|
+
}): string;
|
|
32
|
+
/**
|
|
33
|
+
* Extracts the stable actor URN from a canonical GDC portal actor DID.
|
|
34
|
+
* This is extraction, not reversal of the contact hash.
|
|
35
|
+
*/
|
|
36
|
+
export declare function stableActorIdentifierFromDidWeb(didWeb: string): string;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { normalizePhone } from './consent.js';
|
|
2
|
+
import { multibase58MultihashSha3_256 } from './same-as.js';
|
|
3
|
+
/** Cross-portal actor classes. These are intentionally broader than job roles. */
|
|
4
|
+
export const StableActorRoles = Object.freeze({
|
|
5
|
+
Professional: 'professional',
|
|
6
|
+
Personal: 'personal',
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Builds the private, stable actor identifier shared by independent portals.
|
|
10
|
+
*
|
|
11
|
+
* Wire format: `urn:multibase:<multibase(multihash(SHA3-256, normalized-contact))>:<role>`.
|
|
12
|
+
* Examples end in `:professional` or `:personal`; the clear email/phone is
|
|
13
|
+
* never embedded. A portal-specific `did:web`, IdP `sub`, wallet and DCR
|
|
14
|
+
* `client_id` are bindings to this identifier, not replacements for it.
|
|
15
|
+
*
|
|
16
|
+
* Email is trimmed, stripped of `mailto:` and lower-cased. Phone is stripped
|
|
17
|
+
* of `tel:` and formatting while preserving an E.164 leading `+`.
|
|
18
|
+
*/
|
|
19
|
+
export function buildStableActorIdentifier(input) {
|
|
20
|
+
const role = String(input.role || '').trim();
|
|
21
|
+
if (!Object.values(StableActorRoles).includes(role)) {
|
|
22
|
+
throw new Error('Stable actor role must be professional or personal.');
|
|
23
|
+
}
|
|
24
|
+
const rawContact = String(input.contact || '').trim();
|
|
25
|
+
const normalizedContact = input.contactKind === 'email'
|
|
26
|
+
? rawContact.replace(/^mailto:/i, '').replace(/\s+/g, '').toLowerCase()
|
|
27
|
+
: normalizePhone(rawContact.replace(/^tel:/i, ''));
|
|
28
|
+
if (!normalizedContact) {
|
|
29
|
+
throw new Error(`Stable actor ${input.contactKind} is required.`);
|
|
30
|
+
}
|
|
31
|
+
return `urn:multibase:${multibase58MultihashSha3_256(normalizedContact)}:${role}`;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Builds a portal-specific DID binding that carries the stable actor payload.
|
|
35
|
+
* The portal root can change while the final actor payload remains identical.
|
|
36
|
+
*/
|
|
37
|
+
export function buildPortalActorDidWeb(input) {
|
|
38
|
+
const portalDidWeb = String(input.portalDidWeb || '').trim();
|
|
39
|
+
const match = /^urn:multibase:(z[^:]+):(professional|personal)$/.exec(String(input.actorIdentifier || '').trim());
|
|
40
|
+
if (!portalDidWeb.startsWith('did:web:')) {
|
|
41
|
+
throw new Error('Portal actor binding requires a did:web root.');
|
|
42
|
+
}
|
|
43
|
+
if (!match) {
|
|
44
|
+
throw new Error('Portal actor binding requires a stable actor URN.');
|
|
45
|
+
}
|
|
46
|
+
return `${portalDidWeb}:actor:multibase:${match[1]}:${match[2]}`;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Extracts the stable actor URN from a canonical GDC portal actor DID.
|
|
50
|
+
* This is extraction, not reversal of the contact hash.
|
|
51
|
+
*/
|
|
52
|
+
export function stableActorIdentifierFromDidWeb(didWeb) {
|
|
53
|
+
const match = /:actor:multibase:(z[^:]+):(professional|personal)(?::|$)/.exec(String(didWeb || '').trim());
|
|
54
|
+
if (!match) {
|
|
55
|
+
throw new Error('DID does not contain a canonical GDC actor binding.');
|
|
56
|
+
}
|
|
57
|
+
return `urn:multibase:${match[1]}:${match[2]}`;
|
|
58
|
+
}
|
package/dist/utils/index.d.ts
CHANGED
|
@@ -104,6 +104,7 @@ export * from './service-act-reasons';
|
|
|
104
104
|
export * from './same-as';
|
|
105
105
|
export * from './subject-identity-binding';
|
|
106
106
|
export * from './activation-request';
|
|
107
|
+
export * from './actor-identifier';
|
|
107
108
|
export * from './vp-token';
|
|
108
109
|
export * from './vital-sign-day-batch';
|
|
109
110
|
export * from './wallet-mem';
|
package/dist/utils/index.js
CHANGED
|
@@ -104,6 +104,7 @@ export * from './service-act-reasons.js';
|
|
|
104
104
|
export * from './same-as.js';
|
|
105
105
|
export * from './subject-identity-binding.js';
|
|
106
106
|
export * from './activation-request.js';
|
|
107
|
+
export * from './actor-identifier.js';
|
|
107
108
|
export * from './vp-token.js';
|
|
108
109
|
export * from './vital-sign-day-batch.js';
|
|
109
110
|
export * from './wallet-mem.js';
|
package/dist/utils/license.d.ts
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { DeviceAppType, DeviceUserClass } from '../constants/device';
|
|
2
|
+
import type { DeviceBinding, DeviceLicense } from '../models/device-license';
|
|
3
|
+
/** Default simultaneous installation allowance for one professional/member seat. */
|
|
4
|
+
export declare const DEFAULT_LICENSE_DEVICE_ALLOWANCE = 2;
|
|
5
|
+
/** Resolves an explicit positive allowance or the backwards-compatible default. */
|
|
6
|
+
export declare function resolveLicenseDeviceAllowance(license: unknown): number;
|
|
7
|
+
/**
|
|
8
|
+
* Reads active bindings while projecting the old singular `deviceId` shape as
|
|
9
|
+
* one binding. This permits a rolling migration without invalidating seats.
|
|
10
|
+
*/
|
|
11
|
+
export declare function listActiveLicenseDeviceBindings(license: Pick<DeviceLicense, 'deviceBindings' | 'deviceId' | 'deviceInfo' | 'activatedAt'>): DeviceBinding[];
|
|
12
|
+
/** True for an idempotent installation or while the seat still has capacity. */
|
|
13
|
+
export declare function canRegisterLicenseDevice(license: Pick<DeviceLicense, 'maxDevices' | 'deviceBindings' | 'deviceId' | 'deviceInfo' | 'activatedAt'>, clientInstanceId: string): boolean;
|
|
2
14
|
export type LicenseClaims = Record<string, unknown>;
|
|
3
15
|
export declare const LicenseClaimContext: Readonly<{
|
|
4
16
|
readonly SchemaOrg: "org.schema";
|
package/dist/utils/license.js
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
import { DeviceAppTypes, DeviceUserClasses } from '../constants/device.js';
|
|
2
2
|
import { ClaimsIndividualProductSchemaorg, ClaimsOfferSchemaorg, ClaimsPersonSchemaorg, } from '../constants/schemaorg.js';
|
|
3
|
+
/** Default simultaneous installation allowance for one professional/member seat. */
|
|
4
|
+
export const DEFAULT_LICENSE_DEVICE_ALLOWANCE = 2;
|
|
5
|
+
/** Resolves an explicit positive allowance or the backwards-compatible default. */
|
|
6
|
+
export function resolveLicenseDeviceAllowance(license) {
|
|
7
|
+
const value = Number(license?.maxDevices);
|
|
8
|
+
return Number.isInteger(value) && value > 0 ? value : DEFAULT_LICENSE_DEVICE_ALLOWANCE;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Reads active bindings while projecting the old singular `deviceId` shape as
|
|
12
|
+
* one binding. This permits a rolling migration without invalidating seats.
|
|
13
|
+
*/
|
|
14
|
+
export function listActiveLicenseDeviceBindings(license) {
|
|
15
|
+
if (Array.isArray(license.deviceBindings)) {
|
|
16
|
+
return license.deviceBindings.filter((binding) => binding.status === 'active');
|
|
17
|
+
}
|
|
18
|
+
const clientId = String(license.deviceId || '').trim();
|
|
19
|
+
if (!clientId)
|
|
20
|
+
return [];
|
|
21
|
+
const deviceInfo = license.deviceInfo || { clientInstanceId: clientId };
|
|
22
|
+
return [{
|
|
23
|
+
clientId,
|
|
24
|
+
clientInstanceId: deviceInfo.clientInstanceId || clientId,
|
|
25
|
+
status: 'active',
|
|
26
|
+
deviceInfo,
|
|
27
|
+
activatedAt: Number(license.activatedAt || 0),
|
|
28
|
+
}];
|
|
29
|
+
}
|
|
30
|
+
/** True for an idempotent installation or while the seat still has capacity. */
|
|
31
|
+
export function canRegisterLicenseDevice(license, clientInstanceId) {
|
|
32
|
+
const installationId = String(clientInstanceId || '').trim();
|
|
33
|
+
const active = listActiveLicenseDeviceBindings(license);
|
|
34
|
+
if (installationId && active.some((binding) => binding.clientInstanceId === installationId))
|
|
35
|
+
return true;
|
|
36
|
+
return active.length < resolveLicenseDeviceAllowance(license);
|
|
37
|
+
}
|
|
3
38
|
export const LicenseClaimContext = Object.freeze({
|
|
4
39
|
SchemaOrg: 'org.schema',
|
|
5
40
|
});
|