gdc-common-utils-ts 2.3.29 → 2.4.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.
@@ -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,15 @@ 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
+ /** Identity-provider subject that first activated this seat. */
165
+ activatedBy?: string;
144
166
  /**
145
167
  * Optional, pre-defined restrictions on which devices are allowed to activate this license.
146
168
  * 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
+ }
@@ -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';
@@ -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: string;
16
+ push_token?: string;
17
17
  /**
18
18
  * The push notification provider.
19
19
  * @example "expo"
20
20
  */
21
- push_provider: string;
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. The only supported value is 'native'.
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;
@@ -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";
@@ -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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdc-common-utils-ts",
3
- "version": "2.3.29",
3
+ "version": "2.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },