Skip to content
Open
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
70 changes: 15 additions & 55 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion crates/rest/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,6 @@ hmac = "0.12" # A*CBC-HS* authenticat
sha2 = "0.10" # Concat KDF, A*CBC-HS*
p256 = { version = "0.13", features = ["ecdh", "jwk", "pem", "pkcs8"] } # ECDH-ES (P-256)
p384 = { version = "0.13", features = ["ecdh", "jwk", "pem", "pkcs8"] } # ECDH-ES (P-384)
rsa = { version = "0.9", features = ["pem"] } # RS384 public JWK derivation (#529)
flate2 = "1" # JWE `zip: "DEF"` payloads

[dev-dependencies]
Expand Down
26 changes: 6 additions & 20 deletions crates/rest/src/bulk_submit_oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,26 +183,12 @@ pub(crate) fn derive_public_jwk(pem: &str, alg: &str) -> Option<Value> {
"alg": "ES384",
}))
}
"RS384" => {
use rsa::pkcs8::DecodePrivateKey;
use rsa::traits::PublicKeyParts;

let private_key = rsa::RsaPrivateKey::from_pkcs8_pem(pem).ok()?;
let pub_key = private_key.to_public_key();
let n = URL_SAFE_NO_PAD.encode(pub_key.n().to_bytes_be());
let e = URL_SAFE_NO_PAD.encode(pub_key.e().to_bytes_be());
// RFC 7638 §3.3: required RSA members in lexicographic order.
let canonical = format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#);
let kid = URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes()));
Some(serde_json::json!({
"kty": "RSA",
"n": n,
"e": e,
"kid": kid,
"use": "sig",
"alg": "RS384",
}))
}
// RS384 derivation is deliberately not offered: the only pure-Rust
// RSA implementation carries RUSTSEC-2023-0071 (Marvin Attack timing
// sidechannel) with no fixed release — the same reason jwe.rs rejects
// RSA-OAEP. An RS384 deployment registers its public key with the
// recipient out-of-band; the JWKS endpoint serves an empty set, as
// its handler documents.
_ => None,
}
}
Expand Down
4 changes: 3 additions & 1 deletion crates/ui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,11 @@ chrono.workspace = true
# Bulk Import workspace (#527).
uuid = { version = "1", features = ["v4"] }
jsonwebtoken = "9"
# Hand-parsing the export form: repeated checkbox fields need more than
# serde_urlencoded offers (#537).
form_urlencoded = "1"
# RFC 7638 thumbprint derivation for the bulk-submit signing key kid (#529).
p384 = { version = "0.13", features = ["pem", "pkcs8"] }
rsa = { version = "0.9", features = ["pem"] }
sha2 = "0.10"
base64 = "0.22"

Expand Down
29 changes: 29 additions & 0 deletions crates/ui/assets/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -4173,3 +4173,32 @@ body.has-nav-panel {
color: var(--muted);
font-size: 9.5px;
}

/* Bulk Export (#537): the resource-type checkbox grid and job-status chips. */
.typegrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 6px 18px;
padding: 6px 0;
}

.typegrid__item {
font-size: 14px;
display: flex;
gap: 8px;
align-items: center;
}

.chip {
display: inline-block;
font-size: 12px;
font-weight: 600;
border-radius: 999px;
padding: 2px 12px;
white-space: nowrap;
}

.chip--in-progress { color: #B45309; background: rgb(180 83 9 / 12%); }
.chip--complete { color: var(--accent, #0E7C6B); background: rgb(14 124 107 / 12%); }
.chip--failed { color: #C0362C; background: rgb(192 54 44 / 12%); }
.chip--cancelled { color: var(--muted, #667); background: rgb(120 130 130 / 14%); }
7 changes: 7 additions & 0 deletions crates/ui/e2e/pages/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@ export class Editor {
get addPanel(): Locator {
return this.root.locator(".editor-add").first();
}
/** Opens the add panel if it isn't already — the root picker auto-opens on
* an empty document (#547), and a blind summary click would close it. */
async openAddPanel(): Promise<void> {
if ((await this.addPanel.getAttribute("open")) === null) {
await this.addPanel.locator("summary").first().click();
}
}
addFilter(): Locator {
return this.root.locator(".editor-add__filter").first();
}
Expand Down
6 changes: 3 additions & 3 deletions crates/ui/e2e/tests/chrome.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ test("there is no expand/collapse toggle", async ({ page }) => {
test("the Batch & Data section lists Import and Export", async ({ page, chrome }) => {
await page.goto("/ui", { waitUntil: "networkidle" });
await chrome.sidebar.hover();
// Import went live with the Bulk Import workspace (#527); Export and
// SQL-on-FHIR are still placeholders.
// Import (#527) and Export (#537) are live workspaces; SQL-on-FHIR is
// still a placeholder.
await expect(chrome.navLink("/ui/bulk-import")).toBeVisible();
await expect(chrome.soonItem("Export")).toBeVisible();
await expect(chrome.navLink("/ui/bulk-export")).toBeVisible();
await expect(chrome.soonItem("SQL-on-FHIR")).toBeVisible();
});
12 changes: 7 additions & 5 deletions crates/ui/e2e/tests/editor-controls.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ test("add-node adds a top-level field to the document", async ({ resources }) =>
const ed = resources.modal.editor;
expect(await ed.currentDoc()).not.toHaveProperty("gender");

await ed.addPanel.locator("summary").first().click();
await ed.openAddPanel();
await ed.addFilter().fill("gender");
await ed.addItem("gender").click();

Expand All @@ -55,12 +55,14 @@ test("the value[x] choice select adds the chosen variant", async ({ resources, p
await resources.openCreate("Observation");
const ed = resources.modal.editor;

// The value[x] choice select lives inside a collapsed add-node <details>;
// open it first, then pick a concrete arm.
// The value[x] choice select lives inside the add-node <details>, which
// auto-opens on an empty document (#547) -- open it only when closed.
const panel = ed.root.locator(".editor-add", {
has: page.locator("select[data-declarer='value']"),
});
await panel.locator("summary").click();
if ((await panel.getAttribute("open")) === null) {
await panel.locator("summary").click();
}
const choose = panel.locator("select[data-declarer='value']");
const arms = await choose.locator("option").allInnerTexts();
const arm = arms.find((a) => /string/i.test(a)) ?? arms[1];
Expand All @@ -76,7 +78,7 @@ test("an ad-hoc extension can be attached by URL", async ({ resources }) => {
await resources.openCreate();
const ed = resources.modal.editor;

await ed.addPanel.locator("summary").first().click();
await ed.openAddPanel();
const ext = ed.root.locator(".editor-add__ext").first();
await ext.locator(".editor-add__ext-url").fill("http://example.org/fhir/StructureDefinition/e2e");
// The ad-hoc button is the plain .btn; profiled-extension entries carry
Expand Down
Loading
Loading