sos-sdk-node-ts 0.1.14 → 0.1.16

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 CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.16 - 2026-10-22
6
+
7
+ - Recover checksum-valid historical animal cards through the provider's exact
8
+ stored alias and explicit taxonomy claim, while returning only the current
9
+ species-bound card identity to portals. New writes still reject the retired
10
+ identifier form.
11
+
12
+ ## 0.1.15 - 2026-10-22
13
+
14
+ - Add product-neutral verification and resolution for an existing professional
15
+ DID, employee-authorization URN or signed professional SHC. Invitation and
16
+ device registration remain separate host-managed operations.
17
+
5
18
  ## 0.1.14 - 2026-10-21
6
19
 
7
20
  - Add a product-neutral server-profile owner assertion for personal passkey
package/dist/index.d.ts CHANGED
@@ -10,3 +10,4 @@ export * from './subject-card-directory-client.js';
10
10
  export * from './server-passkey-profile.js';
11
11
  export * from './host-organization-directory-client.js';
12
12
  export * from './secure-organization-registration.js';
13
+ export * from './portable-professional-evidence.js';
package/dist/index.js CHANGED
@@ -10,3 +10,4 @@ export * from './subject-card-directory-client.js';
10
10
  export * from './server-passkey-profile.js';
11
11
  export * from './host-organization-directory-client.js';
12
12
  export * from './secure-organization-registration.js';
13
+ export * from './portable-professional-evidence.js';
@@ -0,0 +1,55 @@
1
+ export type PortableProfessionalProvider = Readonly<{
2
+ baseUrl: string;
3
+ tenantId: string;
4
+ jurisdiction: string;
5
+ sector: string;
6
+ }>;
7
+ export type PortableProfessionalEnrollment = Readonly<{
8
+ sector: string;
9
+ employeeAuthorizationUrn: string;
10
+ professionalDid: string;
11
+ providerDid: string;
12
+ gatewayBaseUrl: string;
13
+ tenantId: string;
14
+ jurisdiction: string;
15
+ role: string;
16
+ }>;
17
+ /**
18
+ * Derives routing candidates only from evidence shape plus a server-owned host
19
+ * and sector allowlist. The returned candidate grants no authority: the signed
20
+ * credential or authoritative DID documents are still verified by the
21
+ * resolution functions below.
22
+ */
23
+ export declare function derivePortableProfessionalProviderCandidates(input: Readonly<{
24
+ professionalCredential?: unknown;
25
+ professionalIdentifier?: unknown;
26
+ sectorHint?: unknown;
27
+ trustedGatewayBaseUrls: readonly string[];
28
+ allowedSectors: readonly string[];
29
+ }>): PortableProfessionalProvider[];
30
+ /**
31
+ * Verifies one signed professional SHC and returns only its existing
32
+ * organization-issued authorization reference. It creates neither an
33
+ * invitation nor a device registration.
34
+ */
35
+ export declare function resolveProfessionalShcEnrollment(input: Readonly<{
36
+ credential: unknown;
37
+ providers: readonly PortableProfessionalProvider[];
38
+ fetchImpl?: typeof fetch;
39
+ nowSeconds?: number;
40
+ }>): Promise<Readonly<{
41
+ professionalIdentifier: string;
42
+ sectorHint: string;
43
+ }>>;
44
+ /**
45
+ * Resolves one organization-issued DID or authorization URN through
46
+ * server-owned provider configuration. The result still requires the host
47
+ * profile manager to perform licence, contact and device-registration checks.
48
+ */
49
+ export declare function resolvePortableProfessionalEnrollment(input: Readonly<{
50
+ professionalIdentifier: unknown;
51
+ sectorHint: unknown;
52
+ verifiedEmail: unknown;
53
+ providers: readonly PortableProfessionalProvider[];
54
+ fetchImpl?: typeof fetch;
55
+ }>): Promise<PortableProfessionalEnrollment>;
@@ -0,0 +1,260 @@
1
+ import { createPublicKey, verify } from 'node:crypto';
2
+ import { inflateRawSync } from 'node:zlib';
3
+ import { buildProfessionalDidWeb } from 'gdc-common-utils-ts/utils/did';
4
+ const EMPLOYEE_AUTHORIZATION_URN_PATTERN = /^urn:cds-([a-z]{2}(?:-[a-z0-9]{2,3})?):v[1-9][0-9]*:organization:([a-z][a-z0-9-]*):([^:]+):member:(z[1-9A-HJ-NP-Za-km-z]+):([0-9]+)$/i;
5
+ function record(value) {
6
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
7
+ }
8
+ function text(value) {
9
+ return typeof value === 'string' ? value.trim() : '';
10
+ }
11
+ function identifiers(resource) {
12
+ return (Array.isArray(resource.identifier) ? resource.identifier : [])
13
+ .map(item => text(record(item).value))
14
+ .filter(Boolean);
15
+ }
16
+ function providerIssuer(provider) {
17
+ return `${new URL(provider.baseUrl).origin}/${encodeURIComponent(provider.tenantId)}/cds-${encodeURIComponent(provider.jurisdiction)}/v1/${encodeURIComponent(provider.sector)}`;
18
+ }
19
+ function decodeShcQr(value) {
20
+ const normalized = text(value);
21
+ if (!/^shc:\/\d+$/.test(normalized))
22
+ throw new TypeError('professional_credential_shc_required');
23
+ const digits = normalized.slice('shc:/'.length);
24
+ if (!digits || digits.length % 2 !== 0)
25
+ throw new TypeError('professional_credential_shc_invalid');
26
+ let compact = '';
27
+ for (let index = 0; index < digits.length; index += 2) {
28
+ const pair = Number(digits.slice(index, index + 2));
29
+ if (pair > 77)
30
+ throw new TypeError('professional_credential_shc_invalid');
31
+ compact += String.fromCharCode(pair + 45);
32
+ }
33
+ return compact;
34
+ }
35
+ /**
36
+ * Derives routing candidates only from evidence shape plus a server-owned host
37
+ * and sector allowlist. The returned candidate grants no authority: the signed
38
+ * credential or authoritative DID documents are still verified by the
39
+ * resolution functions below.
40
+ */
41
+ export function derivePortableProfessionalProviderCandidates(input) {
42
+ const hasCredential = Boolean(text(input.professionalCredential));
43
+ const professionalIdentifier = text(input.professionalIdentifier);
44
+ if (hasCredential === Boolean(professionalIdentifier))
45
+ throw new TypeError('professional_evidence_path_invalid');
46
+ let tenantId = '';
47
+ let jurisdiction = '';
48
+ let sector = text(input.sectorHint).toLowerCase();
49
+ let issuerOrigin = '';
50
+ if (hasCredential) {
51
+ const parts = decodeShcQr(input.professionalCredential).split('.');
52
+ if (parts.length !== 3)
53
+ throw new TypeError('professional_credential_shc_invalid');
54
+ let payload;
55
+ try {
56
+ payload = record(JSON.parse(inflateRawSync(Buffer.from(parts[1], 'base64url')).toString('utf8')));
57
+ }
58
+ catch {
59
+ throw new TypeError('professional_credential_shc_invalid');
60
+ }
61
+ const issuer = new URL(text(payload.iss));
62
+ const match = issuer.pathname.match(/^\/([^/]+)\/cds-([^/]+)\/v[1-9][0-9]*\/([a-z][a-z0-9-]*)\/?$/i);
63
+ if (!match)
64
+ throw new TypeError('professional_credential_issuer_invalid');
65
+ tenantId = decodeURIComponent(match[1]);
66
+ jurisdiction = decodeURIComponent(match[2]);
67
+ sector = decodeURIComponent(match[3]).toLowerCase();
68
+ issuerOrigin = issuer.origin;
69
+ }
70
+ else {
71
+ const authorization = EMPLOYEE_AUTHORIZATION_URN_PATTERN.exec(professionalIdentifier);
72
+ const did = professionalIdentifier.match(/:([^:]+):cds-([^:]+):v[1-9][0-9]*:([a-z][a-z0-9-]*):employee:/i);
73
+ if (authorization) {
74
+ jurisdiction = authorization[1];
75
+ tenantId = authorization[3];
76
+ }
77
+ else if (did) {
78
+ tenantId = did[1];
79
+ jurisdiction = did[2];
80
+ if (sector && sector !== did[3].toLowerCase())
81
+ throw new TypeError('professional_sector_mismatch');
82
+ sector = did[3].toLowerCase();
83
+ }
84
+ else {
85
+ throw new TypeError('professional_credential_invalid');
86
+ }
87
+ }
88
+ const allowedSectors = new Set(input.allowedSectors.map(value => text(value).toLowerCase()).filter(Boolean));
89
+ if (!sector || !allowedSectors.has(sector))
90
+ throw new TypeError('professional_sector_not_allowed');
91
+ const origins = [...new Set(input.trustedGatewayBaseUrls.map(value => new URL(value).origin))]
92
+ .filter(origin => !issuerOrigin || origin === issuerOrigin);
93
+ if (origins.length === 0)
94
+ throw new TypeError('professional_provider_not_allowed');
95
+ return origins.map(baseUrl => Object.freeze({ baseUrl, tenantId, jurisdiction: jurisdiction.toUpperCase(), sector }));
96
+ }
97
+ /**
98
+ * Verifies one signed professional SHC and returns only its existing
99
+ * organization-issued authorization reference. It creates neither an
100
+ * invitation nor a device registration.
101
+ */
102
+ export async function resolveProfessionalShcEnrollment(input) {
103
+ const compact = decodeShcQr(input.credential);
104
+ const parts = compact.split('.');
105
+ if (parts.length !== 3)
106
+ throw new TypeError('professional_credential_shc_invalid');
107
+ let header;
108
+ let payload;
109
+ try {
110
+ header = record(JSON.parse(Buffer.from(parts[0], 'base64url').toString('utf8')));
111
+ payload = record(JSON.parse(inflateRawSync(Buffer.from(parts[1], 'base64url')).toString('utf8')));
112
+ }
113
+ catch {
114
+ throw new TypeError('professional_credential_shc_invalid');
115
+ }
116
+ if (header.alg !== 'ES256' || header.zip !== 'DEF' || !text(header.kid)) {
117
+ throw new TypeError('professional_credential_header_invalid');
118
+ }
119
+ const issuer = text(payload.iss);
120
+ const notBefore = Number(payload.nbf);
121
+ if (!issuer.startsWith('https://') || !Number.isInteger(notBefore)
122
+ || notBefore > (input.nowSeconds ?? Math.floor(Date.now() / 1000))) {
123
+ throw new TypeError('professional_credential_time_or_issuer_invalid');
124
+ }
125
+ const provider = input.providers.find(candidate => providerIssuer(candidate) === issuer);
126
+ if (!provider)
127
+ throw new TypeError('professional_credential_provider_not_allowed');
128
+ const jwksResponse = await (input.fetchImpl ?? fetch)(`${issuer}/.well-known/jwks.json`, {
129
+ headers: { accept: 'application/jwk-set+json, application/json' },
130
+ });
131
+ if (!jwksResponse.ok)
132
+ throw new TypeError('professional_credential_jwks_unavailable');
133
+ const jwks = record(await jwksResponse.json());
134
+ const key = (Array.isArray(jwks.keys) ? jwks.keys : []).map(record)
135
+ .find(candidate => candidate.kid === header.kid && candidate.kty === 'EC' && candidate.crv === 'P-256');
136
+ if (!key)
137
+ throw new TypeError('professional_credential_key_not_found');
138
+ let valid = false;
139
+ try {
140
+ valid = verify('sha256', Buffer.from(`${parts[0]}.${parts[1]}`), {
141
+ key: createPublicKey({ key: key, format: 'jwk' }),
142
+ dsaEncoding: 'ieee-p1363',
143
+ }, Buffer.from(parts[2], 'base64url'));
144
+ }
145
+ catch {
146
+ valid = false;
147
+ }
148
+ if (!valid)
149
+ throw new TypeError('professional_credential_signature_invalid');
150
+ const credentialSubject = record(record(payload.vc).credentialSubject);
151
+ const bundle = record(credentialSubject.fhirBundle);
152
+ if (bundle.resourceType !== 'Bundle')
153
+ throw new TypeError('professional_credential_bundle_invalid');
154
+ const resources = (Array.isArray(bundle.entry) ? bundle.entry : [])
155
+ .map(entry => record(record(entry).resource));
156
+ const organization = resources.find(resource => resource.resourceType === 'Organization');
157
+ const practitioner = resources.find(resource => resource.resourceType === 'Practitioner');
158
+ const practitionerRole = resources.find(resource => resource.resourceType === 'PractitionerRole');
159
+ if (!organization || !practitioner || !practitionerRole || practitionerRole.active === false) {
160
+ throw new TypeError('professional_credential_resources_required');
161
+ }
162
+ const authorizationUrn = identifiers(practitionerRole)
163
+ .find(value => EMPLOYEE_AUTHORIZATION_URN_PATTERN.test(value));
164
+ const authorization = authorizationUrn
165
+ ? EMPLOYEE_AUTHORIZATION_URN_PATTERN.exec(authorizationUrn)
166
+ : undefined;
167
+ if (!authorization || !authorizationUrn)
168
+ throw new TypeError('professional_credential_authorization_missing');
169
+ const [, jurisdiction, , organizationOfficialId] = authorization;
170
+ if (jurisdiction.toLowerCase() !== provider.jurisdiction.toLowerCase()
171
+ || organizationOfficialId !== provider.tenantId
172
+ || !identifiers(organization).includes(provider.tenantId)
173
+ || identifiers(practitioner).length === 0
174
+ || text(record(practitionerRole.organization).reference) !== 'resource:0'
175
+ || text(record(practitionerRole.practitioner).reference) !== 'resource:1') {
176
+ throw new TypeError('professional_credential_binding_invalid');
177
+ }
178
+ return Object.freeze({ professionalIdentifier: authorizationUrn, sectorHint: provider.sector });
179
+ }
180
+ /**
181
+ * Resolves one organization-issued DID or authorization URN through
182
+ * server-owned provider configuration. The result still requires the host
183
+ * profile manager to perform licence, contact and device-registration checks.
184
+ */
185
+ export async function resolvePortableProfessionalEnrollment(input) {
186
+ const professionalIdentifier = text(input.professionalIdentifier);
187
+ const sectorHint = text(input.sectorHint).toLowerCase();
188
+ const verifiedEmail = text(input.verifiedEmail).toLowerCase();
189
+ if (!/^[a-z][a-z0-9-]*$/.test(sectorHint))
190
+ throw new TypeError('professional_sector_hint_required');
191
+ if (!verifiedEmail)
192
+ throw new TypeError('verified_email_required');
193
+ const fetchImpl = input.fetchImpl ?? fetch;
194
+ let employeeAuthorizationUrn = professionalIdentifier;
195
+ let authorization = EMPLOYEE_AUTHORIZATION_URN_PATTERN.exec(employeeAuthorizationUrn);
196
+ if (!authorization && professionalIdentifier.startsWith('did:web:')) {
197
+ const didMatches = input.providers.flatMap(provider => {
198
+ const context = `:${provider.tenantId}:cds-${provider.jurisdiction}:v1:${provider.sector}:employee:`;
199
+ if (provider.sector.toLowerCase() !== sectorHint || !professionalIdentifier.includes(context))
200
+ return [];
201
+ const employeePath = professionalIdentifier.slice(professionalIdentifier.indexOf(context) + context.length);
202
+ const [memberId, ...roleParts] = employeePath.split(':');
203
+ const role = roleParts.join(':');
204
+ return memberId && role ? [{ provider, memberId, role }] : [];
205
+ });
206
+ if (didMatches.length === 0)
207
+ throw new TypeError('professional_provider_not_found');
208
+ if (didMatches.length > 1)
209
+ throw new TypeError('multiple_professional_providers_found');
210
+ const { provider, memberId, role } = didMatches[0];
211
+ const didUrl = `${providerIssuer(provider)}/employee/${encodeURIComponent(memberId)}/${encodeURIComponent(role)}/did.json`;
212
+ const didResponse = await fetchImpl(didUrl, { headers: { accept: 'application/did+json, application/json' } });
213
+ if (!didResponse.ok)
214
+ throw new TypeError('professional_did_unavailable');
215
+ const didDocument = record(await didResponse.json());
216
+ if (text(didDocument.id) !== professionalIdentifier)
217
+ throw new TypeError('professional_did_invalid');
218
+ employeeAuthorizationUrn = (Array.isArray(didDocument.alsoKnownAs) ? didDocument.alsoKnownAs : [])
219
+ .map(String).find(value => EMPLOYEE_AUTHORIZATION_URN_PATTERN.test(value)) || '';
220
+ authorization = EMPLOYEE_AUTHORIZATION_URN_PATTERN.exec(employeeAuthorizationUrn);
221
+ }
222
+ if (!authorization)
223
+ throw new TypeError('professional_credential_invalid');
224
+ const [, authorizationJurisdiction, , organizationOfficialId, , authorizationRoleCode] = authorization;
225
+ const matches = input.providers.filter(provider => provider.tenantId === organizationOfficialId
226
+ && provider.jurisdiction.toLowerCase() === authorizationJurisdiction.toLowerCase()
227
+ && provider.sector.toLowerCase() === sectorHint);
228
+ if (matches.length === 0)
229
+ throw new TypeError('professional_provider_not_found');
230
+ if (matches.length > 1)
231
+ throw new TypeError('multiple_professional_providers_found');
232
+ const provider = matches[0];
233
+ const gatewayBaseUrl = new URL(provider.baseUrl).origin;
234
+ const response = await fetchImpl(`${providerIssuer(provider)}/.well-known/did.json`, {
235
+ headers: { accept: 'application/did+json, application/json' },
236
+ });
237
+ if (!response.ok)
238
+ throw new TypeError('professional_tenant_did_unavailable');
239
+ const document = record(await response.json());
240
+ const providerDid = text(document.id);
241
+ const expectedContext = `:${provider.tenantId}:cds-${provider.jurisdiction}:v1:${provider.sector}`;
242
+ const services = Array.isArray(document.service) ? document.service : [];
243
+ const hasJwks = services.some(service => text(record(service).type) === 'JsonWebKeyService2020'
244
+ && /^https:\/\//i.test(text(record(service).serviceEndpoint)));
245
+ if (!providerDid.startsWith('did:web:') || !providerDid.endsWith(expectedContext) || !hasJwks) {
246
+ throw new TypeError('professional_tenant_did_invalid');
247
+ }
248
+ const role = `ISCO-08|${authorizationRoleCode}`;
249
+ const professionalDid = buildProfessionalDidWeb({ organizationDidWeb: providerDid, email: verifiedEmail, role });
250
+ return Object.freeze({
251
+ sector: provider.sector,
252
+ employeeAuthorizationUrn,
253
+ professionalDid,
254
+ providerDid,
255
+ gatewayBaseUrl,
256
+ tenantId: provider.tenantId,
257
+ jurisdiction: provider.jurisdiction.toUpperCase(),
258
+ role,
259
+ });
260
+ }
@@ -17,6 +17,8 @@ export type AuthorizedIndividualSubjectDirectoryInput = Readonly<{
17
17
  }>;
18
18
  export type AuthorizedIndividualSubject = Readonly<{
19
19
  subjectDid: string;
20
+ /** Historical provider lookup alias retained only while its record is migrated. */
21
+ sourceSubjectDid?: string;
20
22
  organizationId?: string;
21
23
  role?: string;
22
24
  grantClaims: Readonly<Record<string, unknown>>;
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { parseAnimalCardDidWeb } from 'sos-data-utils-ts/animal-card-interoperability';
2
+ import { migrateHistoricalAnimalCardDidWeb, parseAnimalCardDidWeb, parseNcbiTaxonomyUri, } from 'sos-data-utils-ts/animal-card-interoperability';
3
3
  import { buildGatewayApplicationHeaders } from 'sos-data-utils-ts/gateway-application-identity';
4
4
  const CLAIM_AUDIENCE_TYPE = 'org.schema.IndividualProduct.audience.audienceType';
5
5
  const CLAIM_ITEM_CONDITION = 'org.schema.IndividualProduct.itemCondition';
@@ -10,6 +10,7 @@ const CLAIM_ORGANIZATION_OWNER_EMAIL = 'org.schema.Organization.owner.email';
10
10
  const CLAIM_ORGANIZATION_OWNER_TELEPHONE = 'org.schema.Organization.owner.telephone';
11
11
  const CLAIM_ORGANIZATION_OWNER_IDENTIFIER = 'org.schema.Organization.owner.identifier.value';
12
12
  const CLAIM_ORGANIZATION_MEMBER_ROLE = 'org.schema.Organization.member.role';
13
+ const CLAIM_ORGANIZATION_MEMBER_ADDITIONAL_TYPE = 'org.schema.Organization.member.additionalType';
13
14
  const CLAIM_RELATED_PERSON_ROLE = 'RelatedPerson.role';
14
15
  const CLAIM_OCCUPATION_IDENTIFIER = 'org.schema.Person.hasOccupation.identifier.value';
15
16
  /**
@@ -93,8 +94,10 @@ export class SosGatewaySubjectDirectoryClient {
93
94
  const organization = readFirstOrganization(organizationResult.body);
94
95
  if (!publicAliases(organization.claims[CLAIM_ORGANIZATION_SAME_AS]).includes(license.subjectDid))
95
96
  continue;
96
- subjectsByDid.set(license.subjectDid, Object.freeze({
97
- subjectDid: license.subjectDid,
97
+ const resolvedDid = resolveSubjectCardDid(license.subjectDid, context, organization.claims);
98
+ subjectsByDid.set(resolvedDid.subjectDid, Object.freeze({
99
+ subjectDid: resolvedDid.subjectDid,
100
+ ...(resolvedDid.sourceSubjectDid ? { sourceSubjectDid: resolvedDid.sourceSubjectDid } : {}),
98
101
  ...(organization.organizationId ? { organizationId: organization.organizationId } : {}),
99
102
  ...(license.role ? { role: license.role } : {}),
100
103
  grantClaims: license.grantClaims,
@@ -167,15 +170,17 @@ function readOwnedSubjects(body, context, verified) {
167
170
  const ownerTelephone = String(claims[CLAIM_ORGANIZATION_OWNER_TELEPHONE] || '').trim();
168
171
  if (!((verified.email && ownerEmail === verified.email) || (verified.telephone && ownerTelephone === verified.telephone)))
169
172
  return [];
170
- const subjectDid = selectSubjectCardAlias(claims[CLAIM_ORGANIZATION_SAME_AS], context);
171
- if (!subjectDid)
173
+ const sourceSubjectDid = selectSubjectCardAlias(claims[CLAIM_ORGANIZATION_SAME_AS], context);
174
+ if (!sourceSubjectDid)
172
175
  return [];
176
+ const resolvedDid = resolveSubjectCardDid(sourceSubjectDid, context, claims);
173
177
  const role = isAnimalSector(context.sector)
174
178
  ? 'RESPRSN'
175
179
  : String(claims[CLAIM_ORGANIZATION_MEMBER_ROLE] || '').trim();
176
180
  const relatedPersonId = String(claims[CLAIM_ORGANIZATION_OWNER_IDENTIFIER] || '').trim();
177
181
  return [Object.freeze({
178
- subjectDid,
182
+ subjectDid: resolvedDid.subjectDid,
183
+ ...(resolvedDid.sourceSubjectDid ? { sourceSubjectDid: resolvedDid.sourceSubjectDid } : {}),
179
184
  ...(String(resource?.id || '').trim() ? { organizationId: String(resource.id).trim() } : {}),
180
185
  ...(role ? { role } : {}),
181
186
  ...(relatedPersonId ? { relatedPersonId } : {}),
@@ -195,7 +200,6 @@ function readAcceptedLicenses(body, context) {
195
200
  return [];
196
201
  if (!isSubjectCardCandidate(subjectDid, context))
197
202
  return [];
198
- requireCanonicalSubjectDid(subjectDid, context);
199
203
  const claims = objectRecord(meta.claims);
200
204
  const relatedRoles = String(claims[CLAIM_RELATED_PERSON_ROLE] || '')
201
205
  .split(',').map((value) => value.trim().toUpperCase()).filter(Boolean);
@@ -229,9 +233,29 @@ function selectSubjectCardAlias(value, context) {
229
233
  : alias.includes(':card:uhc:'));
230
234
  if (!candidate)
231
235
  return undefined;
232
- requireCanonicalSubjectDid(candidate, context);
236
+ if (!isAnimalSector(context.sector))
237
+ requireCanonicalSubjectDid(candidate, context);
233
238
  return candidate;
234
239
  }
240
+ function resolveSubjectCardDid(sourceSubjectDid, context, claims) {
241
+ if (!isAnimalSector(context.sector)) {
242
+ requireCanonicalSubjectDid(sourceSubjectDid, context);
243
+ return Object.freeze({ subjectDid: sourceSubjectDid });
244
+ }
245
+ if (parseAnimalCardDidWeb(sourceSubjectDid)) {
246
+ return Object.freeze({ subjectDid: sourceSubjectDid });
247
+ }
248
+ const taxonomyId = parseNcbiTaxonomyUri(claims[CLAIM_ORGANIZATION_MEMBER_ADDITIONAL_TYPE]);
249
+ const migrated = taxonomyId
250
+ ? migrateHistoricalAnimalCardDidWeb(sourceSubjectDid, taxonomyId)
251
+ : undefined;
252
+ if (!migrated)
253
+ throw new Error('animal_card_did_invalid');
254
+ return Object.freeze({
255
+ subjectDid: migrated.cardDid,
256
+ sourceSubjectDid: migrated.sourceCardDid,
257
+ });
258
+ }
235
259
  function requireCanonicalSubjectDid(subjectDid, context) {
236
260
  if (isAnimalSector(context.sector) && !parseAnimalCardDidWeb(subjectDid)) {
237
261
  throw new Error('animal_card_did_invalid');
@@ -15,10 +15,14 @@ The client submits and polls these SOS GW routes:
15
15
  confidential resource. SOS GW indexes every normalized non-empty alias as its
16
16
  own protected indexed attribute. Exact resolution therefore searches one
17
17
  alias; a returned CSV is accepted only when it contains that exact alias.
18
- Animal entries accept only the current species-bound card profile, in which a
19
- seven-digit taxonomy context precedes the complete 21-digit card number. The
20
- removed split `jurisdiction5:animalId16` form fails closed. The exact profile
21
- literal remains owned and documented by the gateway that issues the card.
18
+ Animal entries returned to consumers use only the current species-bound card
19
+ profile, in which a seven-digit taxonomy context precedes the complete
20
+ 21-digit card number. A retired split `jurisdiction5:animalId16` alias can be
21
+ used only to find an existing provider record: its historical checksum and the
22
+ record's explicit animal taxonomy claim must validate before the client derives
23
+ the current identifier. The client retains the old alias separately for exact
24
+ provider lookup and never accepts it for a new write. The exact profile literal
25
+ remains owned and documented by the gateway that issues the card.
22
26
 
23
27
  Discovery is not authorization. A signed OpenID token is verified by the BFF;
24
28
  the SDK receives only the verified contact and uses the token as server-side
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sos-sdk-node-ts",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Product-neutral Node adapters shared by SOSChain, Vet and UHC runtimes",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Connecting Solution & Applications Ltd",
@@ -55,6 +55,10 @@
55
55
  "./secure-organization-registration": {
56
56
  "types": "./dist/secure-organization-registration.d.ts",
57
57
  "default": "./dist/secure-organization-registration.js"
58
+ },
59
+ "./portable-professional-evidence": {
60
+ "types": "./dist/portable-professional-evidence.d.ts",
61
+ "default": "./dist/portable-professional-evidence.js"
58
62
  }
59
63
  },
60
64
  "files": [
@@ -76,7 +80,7 @@
76
80
  "@simplewebauthn/server": "13.3.2",
77
81
  "gdc-common-utils-ts": "2.9.30",
78
82
  "gdc-sdk-node-ts": "2.9.32",
79
- "sos-data-utils-ts": "0.6.13",
83
+ "sos-data-utils-ts": "0.6.16",
80
84
  "sos-sdk-core-ts": "0.5.2"
81
85
  },
82
86
  "devDependencies": {