Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "proxy-smart-backend",
"displayName": "Proxy Smart Backend",
"version": "0.3.15-beta.202608142040.36af3ff76",
"version": "0.3.16-alpha.202608161203.54bbcfc20",
"type": "module",
"scripts": {
"test": "bun test --isolate",
Expand Down
38 changes: 29 additions & 9 deletions backend/src/lib/shl-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,22 +196,42 @@ interface FhirBundleLike {
[key: string]: unknown
}

/**
* The `query` hints for this share — the SHL spec's optional field on
* `application/smart-api-access`, "hints to the client, indicating queries it
* might want to make".
*
* A study-scoped link has no other way to say what it is about, so a recipient
* fires its usual sweep and the default-deny proxy rejects nearly all of it. The
* hint carries the same identifier filter `scopeFhirRequest` forces, so what the
* recipient is told to run is exactly what will be allowed.
*
* Whole-patient shares get no hints on purpose. Listing the reachable types would
* name the withheld ones by omission, which is more than the patient agreed to
* disclose.
*/
export function shareQueryHints(narrowing: { studyInstanceUID?: string }): string[] | undefined {
if (!narrowing.studyInstanceUID) return undefined
return [`ImagingStudy?identifier=urn:oid:${narrowing.studyInstanceUID}`]
}

/**
* True only when NOTHING narrows the share.
*
* The recipient is told "complete summary — the patient shared their full health
* record" on the strength of this, so every dimension that narrows a share has to
* be counted here. It lives beside the scope rules rather than inline at the mint
* site because that is how it drifted: it knew about selective de-selection and
* not about study scoping, so a single-study link claimed to carry everything
* while the proxy answered almost every query with 403 — which the viewer drew as
* "no allergies, no medications, no conditions".
* @deprecated Conflates the two narrowings it counts: a study-scoped link and a
* de-selected record both report `false`, which reads as a warning for one and as
* the definition of the link for the other. Recipients should use the `query`
* hints for what the share covers and `maxhealth_records_withheld` for what was
* held back. Kept until both viewers ship those.
*/
export function isCompleteShare(narrowing: {
selectiveScope?: unknown
selectiveScope?: SelectiveScope
studyInstanceUID?: string
}): boolean {
return !narrowing.selectiveScope && !narrowing.studyInstanceUID
const narrowed = narrowing.selectiveScope
? isSelectiveScopeActive(narrowing.selectiveScope)
: false
return !narrowed && !narrowing.studyInstanceUID
}

/** True when the scope actually narrows anything (else all helpers are no-ops). */
Expand Down
18 changes: 13 additions & 5 deletions backend/src/routes/api/shl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
scopeFhirRequest,
isCompleteShare,
isSelectiveScopeActive,
shareQueryHints,
preScreenSelectiveRequest,
applySelectiveFilter,
emptySearchBundle,
Expand Down Expand Up @@ -96,10 +97,11 @@ function isJsonContentType(contentType: string | null): boolean {
* The smart-api-access document the recipient decrypts (SHL spec §3.2).
*
* Derived from the session on every manifest fetch rather than frozen at mint,
* because what it asserts — how long the token is good for, whether the share is
* complete — are properties of the share as it stands now. Freezing them meant a
* link kept telling recipients it carried the full record after the rule deciding
* that was corrected, since the claim sat inside ciphertext minted days earlier.
* because what it asserts — how long the token is good for, what the share covers
* — are properties of the share as it stands now. Freezing them meant a link kept
* telling recipients it carried the full record after the rule deciding that was
* corrected, since the claim sat inside ciphertext minted days earlier. It is also
* why links already in circulation pick up `query` without being re-minted.
*/
export function buildSmartApiAccess(session: {
sessionToken: string
Expand All @@ -108,6 +110,7 @@ export function buildSmartApiAccess(session: {
shareScope?: ShareScope
studyInstanceUID?: string
}): string {
const selectiveScope = sessionSelectiveScope(session)
return JSON.stringify({
access_token: session.sessionToken,
token_type: 'Bearer',
Expand All @@ -116,8 +119,13 @@ export function buildSmartApiAccess(session: {
patient: session.patientId,
// aud points to our FHIR proxy — the viewer never talks to the real FHIR server.
aud: `${config.baseUrl}/api/shl/fhir`,
// Spec field. Undefined drops out of the JSON, which is the "no hints" case.
query: shareQueryHints({ studyInstanceUID: session.studyInstanceUID }),
// Ours, and named so: nothing in the SHL spec describes a withheld record.
maxhealth_records_withheld: isSelectiveScopeActive(selectiveScope),
// Deprecated — see isCompleteShare. Emitted until both viewers read the above.
complete: isCompleteShare({
selectiveScope: session.shareScope,
selectiveScope,
studyInstanceUID: session.studyInstanceUID,
}),
})
Expand Down
37 changes: 37 additions & 0 deletions backend/test/shl-access-document.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,43 @@ const base = {

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

/**
* `query` is the SHL spec's own field — "hints to the client, indicating queries it
* might want to make". A scoped link has no other standard way to say what it holds,
* which is the gap `complete` was invented to paper over.
*/
describe('buildSmartApiAccess — query hints', () => {
it('points a study-scoped share at that study', () => {
const doc = parse(buildSmartApiAccess({ ...base, studyInstanceUID: '1.2.840.113619.2.55.3' }))
expect(doc.query).toEqual(['ImagingStudy?identifier=urn:oid:1.2.840.113619.2.55.3'])
})

/** Optional in the spec, and an omitted key is how "no hints" is expressed. */
it('omits the key entirely for a whole-patient share', () => {
expect('query' in parse(buildSmartApiAccess(base))).toBe(false)
})
})

describe('buildSmartApiAccess — withheld records', () => {
it('reports nothing withheld from a whole-patient share', () => {
expect(parse(buildSmartApiAccess(base)).maxhealth_records_withheld).toBe(false)
})

it('reports records withheld when the patient de-selected some', () => {
const doc = parse(buildSmartApiAccess({
...base,
shareScope: { excludedTypes: ['Condition'], excludedIds: [], excludedObservationCategories: [] },
}))
expect(doc.maxhealth_records_withheld).toBe(true)
})

/** Study scoping is not withholding — it is what the link is for. */
it('reports nothing withheld from a study-scoped share', () => {
const doc = parse(buildSmartApiAccess({ ...base, studyInstanceUID: '1.2.840.113619.2.55.3' }))
expect(doc.maxhealth_records_withheld).toBe(false)
})
})

describe('buildSmartApiAccess', () => {
it('reports a whole-patient share as complete', () => {
expect(parse(buildSmartApiAccess(base)).complete).toBe(true)
Expand Down
56 changes: 53 additions & 3 deletions backend/test/shl-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
scopeFhirRequest,
isCompleteShare,
isSelectiveScopeActive,
shareQueryHints,
preScreenSelectiveRequest,
applySelectiveFilter,
isResourceExcluded,
Expand Down Expand Up @@ -291,14 +292,52 @@ describe('emptySearchBundle', () => {
})
})

describe('isCompleteShare — what the recipient is told the link carries', () => {
const scopeOf = (partial: Partial<SelectiveScope>): SelectiveScope => ({
excludedTypes: [],
excludedIds: [],
excludedObservationCategories: [],
...partial,
})

/**
* The spec's answer to what `complete` was invented for: a scoped link saying what
* it IS rather than what it is not. The hint has to match the identifier filter
* isFhirPathAllowed forces, or the recipient is told to run a query the proxy denies.
*/
describe('shareQueryHints — telling the recipient what the share covers', () => {
it('points a study-scoped share at exactly that study', () => {
expect(shareQueryHints({ studyInstanceUID: STUDY })).toEqual([
`ImagingStudy?identifier=urn:oid:${STUDY}`,
])
})

it('agrees with the identifier the FHIR proxy forces', () => {
const [hint] = shareQueryHints({ studyInstanceUID: STUDY }) ?? []
const [path, search] = hint.split('?')
const decision = scopeFhirRequest(path, `?${search}`, {
patientId: PATIENT,
studyInstanceUID: STUDY,
})
expect(decision.allowed).toBe(true)
// Already filtered, so the proxy has nothing to rewrite.
expect(decision.rewrittenSearch).toBeUndefined()
})

/** Naming the reachable types would name the withheld ones by omission. */
it('offers no hints for a whole-patient share', () => {
expect(shareQueryHints({})).toBeUndefined()
expect(shareQueryHints({ studyInstanceUID: undefined })).toBeUndefined()
})
})

describe('isCompleteShare — deprecated, kept until both viewers move off it', () => {
it('is complete when nothing narrows the share', () => {
expect(isCompleteShare({})).toBe(true)
expect(isCompleteShare({ selectiveScope: undefined, studyInstanceUID: undefined })).toBe(true)
})

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

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

it('is NOT complete when both narrowings apply', () => {
expect(isCompleteShare({ selectiveScope: { excludedTypes: ['Condition'] }, studyInstanceUID: STUDY })).toBe(false)
expect(
isCompleteShare({ selectiveScope: scopeOf({ excludedTypes: ['Condition'] }), studyInstanceUID: STUDY }),
).toBe(false)
})

/**
* Truthiness on the object read a present-but-empty scope as narrowing. The mint
* site normalises that to undefined, so it never fired — but the invariant lived
* at the call site rather than here, where the question is asked.
*/
it('is complete when a scope is present but excludes nothing', () => {
expect(isCompleteShare({ selectiveScope: scopeOf({}) })).toBe(true)
})
})
2 changes: 1 addition & 1 deletion config/eslint/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proxy-smart/eslint-config",
"version": "0.3.15-beta.202608141759.af17725a9",
"version": "0.3.15-beta.202608142040.36af3ff76",
"private": true,
"type": "module",
"exports": {
Expand Down
2 changes: 1 addition & 1 deletion deploy/infra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "proxy-smart-infra",
"displayName": "Proxy Smart Infrastructure",
"description": "AWS CDK infrastructure for Proxy Smart production deployment",
"version": "0.3.15-beta.202608142040.36af3ff76",
"version": "0.3.16-alpha.202608161203.54bbcfc20",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion frontend/smart-dicom-template/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "SMART DICOM Algorithm Template",
"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.",
"private": true,
"version": "0.3.15-beta.202608141759.af17725a9",
"version": "0.3.15-beta.202608142040.36af3ff76",
"type": "module",
"scripts": {
"dev": "vite --port 5180",
Expand Down
2 changes: 1 addition & 1 deletion frontend/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "Proxy Smart Admin UI",
"description": "A web-based administration interface for managing healthcare applications and resources via Proxy Smart.",
"private": true,
"version": "0.3.15-beta.202608141759.af17725a9",
"version": "0.3.15-beta.202608142040.36af3ff76",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "proxy-smart",
"version": "0.3.15-beta.202608142040.36af3ff76",
"version": "0.3.16-alpha.202608161203.54bbcfc20",
"repository": {
"type": "git",
"url": "git+https://github.com/Max-Health-Inc/proxy-smart.git"
Expand Down
2 changes: 1 addition & 1 deletion packages/app-store/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proxy-smart/app-store",
"version": "0.3.15-beta.202608141759.af17725a9",
"version": "0.3.15-beta.202608142040.36af3ff76",
"private": false,
"type": "module",
"description": "SMART on FHIR app store — manifest discovery, visibility configuration, and registry CRUD. Framework-agnostic.",
Expand Down
2 changes: 1 addition & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proxy-smart/auth",
"version": "0.3.15-beta.202608141759.af17725a9",
"version": "0.3.15-beta.202608142040.36af3ff76",
"private": false,
"type": "module",
"description": "SMART on FHIR STU 2.2.0 server-side authorization proxy — launch context, session management, token enrichment. Framework-agnostic, IdP-pluggable.",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proxy-smart/cli",
"version": "0.3.15-beta.202608141759.af17725a9",
"version": "0.3.15-beta.202608142040.36af3ff76",
"private": false,
"type": "module",
"description": "Admin CLI for the proxy-smart SMART on FHIR authorization proxy. Authenticates via Keycloak OAuth (device flow or client_credentials) and drives the admin REST API.",
Expand Down
2 changes: 1 addition & 1 deletion packages/elysia-mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@max-health-inc/elysia-mcp",
"version": "0.3.15-beta.202608141759.af17725a9",
"version": "0.3.15-beta.202608142040.36af3ff76",
"private": false,
"type": "module",
"description": "Auto-generate MCP tools and resources from Elysia routes via introspection. TypeBox-to-Standard-Schema bridge, Streamable HTTP transport, session management.",
Expand Down
2 changes: 1 addition & 1 deletion packages/patient-picker/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"displayName": "Proxy Smart Patient Picker",
"description": "Patient selection UI shown during SMART standalone launch when patient context is needed.",
"private": true,
"version": "0.3.15-beta.202608141759.af17725a9",
"version": "0.3.15-beta.202608142040.36af3ff76",
"type": "module",
"scripts": {
"dev": "vite --port 5176",
Expand Down
2 changes: 1 addition & 1 deletion scripts/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "proxy-smart-scripts",
"version": "0.3.15-beta.202608142040.36af3ff76",
"version": "0.3.16-alpha.202608161203.54bbcfc20",
"description": "CI/CD and development scripts for Proxy Smart",
"private": true,
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion testing/e2e/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@proxy-smart/e2e",
"version": "0.3.15-beta.202608141759.af17725a9",
"version": "0.3.15-beta.202608142040.36af3ff76",
"private": true,
"description": "End-to-end Playwright tests for Proxy Smart apps",
"scripts": {
Expand Down