Skip to content

Commit e39f602

Browse files
Merge pull request #1025 from Max-Health-Inc/develop
🧪 Auto-PR: Merge `develop` → `test`
2 parents e4544b5 + e4f26ef commit e39f602

17 files changed

Lines changed: 145 additions & 30 deletions

File tree

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "proxy-smart-backend",
33
"displayName": "Proxy Smart Backend",
4-
"version": "0.3.15-beta.202608142040.36af3ff76",
4+
"version": "0.3.16-alpha.202608161203.54bbcfc20",
55
"type": "module",
66
"scripts": {
77
"test": "bun test --isolate",

backend/src/lib/shl-scope.ts

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -196,22 +196,42 @@ interface FhirBundleLike {
196196
[key: string]: unknown
197197
}
198198

199+
/**
200+
* The `query` hints for this share — the SHL spec's optional field on
201+
* `application/smart-api-access`, "hints to the client, indicating queries it
202+
* might want to make".
203+
*
204+
* A study-scoped link has no other way to say what it is about, so a recipient
205+
* fires its usual sweep and the default-deny proxy rejects nearly all of it. The
206+
* hint carries the same identifier filter `scopeFhirRequest` forces, so what the
207+
* recipient is told to run is exactly what will be allowed.
208+
*
209+
* Whole-patient shares get no hints on purpose. Listing the reachable types would
210+
* name the withheld ones by omission, which is more than the patient agreed to
211+
* disclose.
212+
*/
213+
export function shareQueryHints(narrowing: { studyInstanceUID?: string }): string[] | undefined {
214+
if (!narrowing.studyInstanceUID) return undefined
215+
return [`ImagingStudy?identifier=urn:oid:${narrowing.studyInstanceUID}`]
216+
}
217+
199218
/**
200219
* True only when NOTHING narrows the share.
201220
*
202-
* The recipient is told "complete summary — the patient shared their full health
203-
* record" on the strength of this, so every dimension that narrows a share has to
204-
* be counted here. It lives beside the scope rules rather than inline at the mint
205-
* site because that is how it drifted: it knew about selective de-selection and
206-
* not about study scoping, so a single-study link claimed to carry everything
207-
* while the proxy answered almost every query with 403 — which the viewer drew as
208-
* "no allergies, no medications, no conditions".
221+
* @deprecated Conflates the two narrowings it counts: a study-scoped link and a
222+
* de-selected record both report `false`, which reads as a warning for one and as
223+
* the definition of the link for the other. Recipients should use the `query`
224+
* hints for what the share covers and `maxhealth_records_withheld` for what was
225+
* held back. Kept until both viewers ship those.
209226
*/
210227
export function isCompleteShare(narrowing: {
211-
selectiveScope?: unknown
228+
selectiveScope?: SelectiveScope
212229
studyInstanceUID?: string
213230
}): boolean {
214-
return !narrowing.selectiveScope && !narrowing.studyInstanceUID
231+
const narrowed = narrowing.selectiveScope
232+
? isSelectiveScopeActive(narrowing.selectiveScope)
233+
: false
234+
return !narrowed && !narrowing.studyInstanceUID
215235
}
216236

217237
/** True when the scope actually narrows anything (else all helpers are no-ops). */

backend/src/routes/api/shl.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
scopeFhirRequest,
3737
isCompleteShare,
3838
isSelectiveScopeActive,
39+
shareQueryHints,
3940
preScreenSelectiveRequest,
4041
applySelectiveFilter,
4142
emptySearchBundle,
@@ -96,10 +97,11 @@ function isJsonContentType(contentType: string | null): boolean {
9697
* The smart-api-access document the recipient decrypts (SHL spec §3.2).
9798
*
9899
* Derived from the session on every manifest fetch rather than frozen at mint,
99-
* because what it asserts — how long the token is good for, whether the share is
100-
* complete — are properties of the share as it stands now. Freezing them meant a
101-
* link kept telling recipients it carried the full record after the rule deciding
102-
* that was corrected, since the claim sat inside ciphertext minted days earlier.
100+
* because what it asserts — how long the token is good for, what the share covers
101+
* — are properties of the share as it stands now. Freezing them meant a link kept
102+
* telling recipients it carried the full record after the rule deciding that was
103+
* corrected, since the claim sat inside ciphertext minted days earlier. It is also
104+
* why links already in circulation pick up `query` without being re-minted.
103105
*/
104106
export function buildSmartApiAccess(session: {
105107
sessionToken: string
@@ -108,6 +110,7 @@ export function buildSmartApiAccess(session: {
108110
shareScope?: ShareScope
109111
studyInstanceUID?: string
110112
}): string {
113+
const selectiveScope = sessionSelectiveScope(session)
111114
return JSON.stringify({
112115
access_token: session.sessionToken,
113116
token_type: 'Bearer',
@@ -116,8 +119,13 @@ export function buildSmartApiAccess(session: {
116119
patient: session.patientId,
117120
// aud points to our FHIR proxy — the viewer never talks to the real FHIR server.
118121
aud: `${config.baseUrl}/api/shl/fhir`,
122+
// Spec field. Undefined drops out of the JSON, which is the "no hints" case.
123+
query: shareQueryHints({ studyInstanceUID: session.studyInstanceUID }),
124+
// Ours, and named so: nothing in the SHL spec describes a withheld record.
125+
maxhealth_records_withheld: isSelectiveScopeActive(selectiveScope),
126+
// Deprecated — see isCompleteShare. Emitted until both viewers read the above.
119127
complete: isCompleteShare({
120-
selectiveScope: session.shareScope,
128+
selectiveScope,
121129
studyInstanceUID: session.studyInstanceUID,
122130
}),
123131
})

backend/test/shl-access-document.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,43 @@ const base = {
2323

2424
const parse = (json: string) => JSON.parse(json) as Record<string, unknown>
2525

26+
/**
27+
* `query` is the SHL spec's own field — "hints to the client, indicating queries it
28+
* might want to make". A scoped link has no other standard way to say what it holds,
29+
* which is the gap `complete` was invented to paper over.
30+
*/
31+
describe('buildSmartApiAccess — query hints', () => {
32+
it('points a study-scoped share at that study', () => {
33+
const doc = parse(buildSmartApiAccess({ ...base, studyInstanceUID: '1.2.840.113619.2.55.3' }))
34+
expect(doc.query).toEqual(['ImagingStudy?identifier=urn:oid:1.2.840.113619.2.55.3'])
35+
})
36+
37+
/** Optional in the spec, and an omitted key is how "no hints" is expressed. */
38+
it('omits the key entirely for a whole-patient share', () => {
39+
expect('query' in parse(buildSmartApiAccess(base))).toBe(false)
40+
})
41+
})
42+
43+
describe('buildSmartApiAccess — withheld records', () => {
44+
it('reports nothing withheld from a whole-patient share', () => {
45+
expect(parse(buildSmartApiAccess(base)).maxhealth_records_withheld).toBe(false)
46+
})
47+
48+
it('reports records withheld when the patient de-selected some', () => {
49+
const doc = parse(buildSmartApiAccess({
50+
...base,
51+
shareScope: { excludedTypes: ['Condition'], excludedIds: [], excludedObservationCategories: [] },
52+
}))
53+
expect(doc.maxhealth_records_withheld).toBe(true)
54+
})
55+
56+
/** Study scoping is not withholding — it is what the link is for. */
57+
it('reports nothing withheld from a study-scoped share', () => {
58+
const doc = parse(buildSmartApiAccess({ ...base, studyInstanceUID: '1.2.840.113619.2.55.3' }))
59+
expect(doc.maxhealth_records_withheld).toBe(false)
60+
})
61+
})
62+
2663
describe('buildSmartApiAccess', () => {
2764
it('reports a whole-patient share as complete', () => {
2865
expect(parse(buildSmartApiAccess(base)).complete).toBe(true)

backend/test/shl-scope.test.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
scopeFhirRequest,
1616
isCompleteShare,
1717
isSelectiveScopeActive,
18+
shareQueryHints,
1819
preScreenSelectiveRequest,
1920
applySelectiveFilter,
2021
isResourceExcluded,
@@ -291,14 +292,52 @@ describe('emptySearchBundle', () => {
291292
})
292293
})
293294

294-
describe('isCompleteShare — what the recipient is told the link carries', () => {
295+
const scopeOf = (partial: Partial<SelectiveScope>): SelectiveScope => ({
296+
excludedTypes: [],
297+
excludedIds: [],
298+
excludedObservationCategories: [],
299+
...partial,
300+
})
301+
302+
/**
303+
* The spec's answer to what `complete` was invented for: a scoped link saying what
304+
* it IS rather than what it is not. The hint has to match the identifier filter
305+
* isFhirPathAllowed forces, or the recipient is told to run a query the proxy denies.
306+
*/
307+
describe('shareQueryHints — telling the recipient what the share covers', () => {
308+
it('points a study-scoped share at exactly that study', () => {
309+
expect(shareQueryHints({ studyInstanceUID: STUDY })).toEqual([
310+
`ImagingStudy?identifier=urn:oid:${STUDY}`,
311+
])
312+
})
313+
314+
it('agrees with the identifier the FHIR proxy forces', () => {
315+
const [hint] = shareQueryHints({ studyInstanceUID: STUDY }) ?? []
316+
const [path, search] = hint.split('?')
317+
const decision = scopeFhirRequest(path, `?${search}`, {
318+
patientId: PATIENT,
319+
studyInstanceUID: STUDY,
320+
})
321+
expect(decision.allowed).toBe(true)
322+
// Already filtered, so the proxy has nothing to rewrite.
323+
expect(decision.rewrittenSearch).toBeUndefined()
324+
})
325+
326+
/** Naming the reachable types would name the withheld ones by omission. */
327+
it('offers no hints for a whole-patient share', () => {
328+
expect(shareQueryHints({})).toBeUndefined()
329+
expect(shareQueryHints({ studyInstanceUID: undefined })).toBeUndefined()
330+
})
331+
})
332+
333+
describe('isCompleteShare — deprecated, kept until both viewers move off it', () => {
295334
it('is complete when nothing narrows the share', () => {
296335
expect(isCompleteShare({})).toBe(true)
297336
expect(isCompleteShare({ selectiveScope: undefined, studyInstanceUID: undefined })).toBe(true)
298337
})
299338

300339
it('is NOT complete when the patient de-selected records', () => {
301-
expect(isCompleteShare({ selectiveScope: { excludedTypes: ['Condition'] } })).toBe(false)
340+
expect(isCompleteShare({ selectiveScope: scopeOf({ excludedTypes: ['Condition'] }) })).toBe(false)
302341
})
303342

304343
/**
@@ -311,6 +350,17 @@ describe('isCompleteShare — what the recipient is told the link carries', () =
311350
})
312351

313352
it('is NOT complete when both narrowings apply', () => {
314-
expect(isCompleteShare({ selectiveScope: { excludedTypes: ['Condition'] }, studyInstanceUID: STUDY })).toBe(false)
353+
expect(
354+
isCompleteShare({ selectiveScope: scopeOf({ excludedTypes: ['Condition'] }), studyInstanceUID: STUDY }),
355+
).toBe(false)
356+
})
357+
358+
/**
359+
* Truthiness on the object read a present-but-empty scope as narrowing. The mint
360+
* site normalises that to undefined, so it never fired — but the invariant lived
361+
* at the call site rather than here, where the question is asked.
362+
*/
363+
it('is complete when a scope is present but excludes nothing', () => {
364+
expect(isCompleteShare({ selectiveScope: scopeOf({}) })).toBe(true)
315365
})
316366
})

config/eslint/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@proxy-smart/eslint-config",
3-
"version": "0.3.15-beta.202608141759.af17725a9",
3+
"version": "0.3.15-beta.202608142040.36af3ff76",
44
"private": true,
55
"type": "module",
66
"exports": {

deploy/infra/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "proxy-smart-infra",
33
"displayName": "Proxy Smart Infrastructure",
44
"description": "AWS CDK infrastructure for Proxy Smart production deployment",
5-
"version": "0.3.15-beta.202608142040.36af3ff76",
5+
"version": "0.3.16-alpha.202608161203.54bbcfc20",
66
"private": true,
77
"type": "module",
88
"scripts": {

frontend/smart-dicom-template/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "SMART DICOM Algorithm Template",
44
"description": "Starter kit for building SMART on FHIR imaging algorithm apps. Clone, implement your algorithm in src/algorithm.ts, and deploy as a SMART app.",
55
"private": true,
6-
"version": "0.3.15-beta.202608141759.af17725a9",
6+
"version": "0.3.15-beta.202608142040.36af3ff76",
77
"type": "module",
88
"scripts": {
99
"dev": "vite --port 5180",

frontend/ui/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "Proxy Smart Admin UI",
44
"description": "A web-based administration interface for managing healthcare applications and resources via Proxy Smart.",
55
"private": true,
6-
"version": "0.3.15-beta.202608141759.af17725a9",
6+
"version": "0.3.15-beta.202608142040.36af3ff76",
77
"type": "module",
88
"scripts": {
99
"dev": "vite",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "proxy-smart",
3-
"version": "0.3.15-beta.202608142040.36af3ff76",
3+
"version": "0.3.16-alpha.202608161203.54bbcfc20",
44
"repository": {
55
"type": "git",
66
"url": "git+https://github.com/Max-Health-Inc/proxy-smart.git"

0 commit comments

Comments
 (0)