From e7a9df9e36f3f0beb1c1dc3904660079ab27364d Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sat, 6 Jun 2026 15:17:16 +1000 Subject: [PATCH 1/2] Add sharing grant management flow --- CHANGELOG.md | 2 + README.md | 4 +- docs/api-client.md | 22 ++ docs/security-model.md | 3 + src/App.test.tsx | 386 ++++++++++++++++++++++++++- src/api/client.test.ts | 115 ++++++++ src/api/client.ts | 115 +++++++- src/api/schemas.test.ts | 49 ++++ src/api/schemas.ts | 2 + src/routes/incidents/$incidentId.tsx | 377 ++++++++++++++++++++++++-- 10 files changed, 1052 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11b3f6e..919338c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Added incident-scoped sharing-grant management for active contact keys, + optional stream scope, optional expiry, and revocation. - Added account-level contact public-key management for create, update, and revoke flows. - Added owner incident deletion status and request UI for the authenticated diff --git a/README.md b/README.md index b2faca7..90e6518 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ The current bootstrap includes: - incident detail metadata UI - stream and chunk metadata review - contact public-key metadata views and account-level management -- sharing-grant metadata views +- sharing-grant metadata views and incident-scoped management - wrapped-key metadata views - owner incident deletion status and request UI - safe loading, empty, and error states @@ -149,8 +149,6 @@ The web client must not expose private admin/operator behavior or route `/v1/adm Planned sharing/contact work includes: -- sharing-grant creation and revocation -- incident-scoped and stream-scoped grant management - wrapped-key metadata review and delivery status - clear warnings that wrapped-key metadata is access-enabling metadata - trusted-contact access design, once separately scoped and threat-modeled diff --git a/docs/api-client.md b/docs/api-client.md index 3d4c59e..86916ad 100644 --- a/docs/api-client.md +++ b/docs/api-client.md @@ -47,8 +47,10 @@ From current `open-proofline/server` docs and route registration: - `GET /v1/contact-public-keys/{public_key_id}` - `PATCH /v1/contact-public-keys/{public_key_id}` - `POST /v1/contact-public-keys/{public_key_id}/revoke` +- `POST /v1/incidents/{incident_id}/sharing-grants` - `GET /v1/incidents/{incident_id}/sharing-grants` - `GET /v1/sharing-grants/{grant_id}` +- `POST /v1/sharing-grants/{grant_id}/revoke` - `GET /v1/incidents/{incident_id}/wrapped-keys` - `GET /v1/wrapped-keys/{wrapped_key_id}` @@ -125,6 +127,26 @@ revoked keys visibly ineligible and does not offer reactivation controls for revoked records; the server also rejects revoked-key reactivation with `409 invalid_contact_key_state`. +Sharing-grant management uses the server's authenticated owner-scoped routes: + +- `POST /v1/incidents/{incident_id}/sharing-grants` +- `GET /v1/incidents/{incident_id}/sharing-grants` +- `GET /v1/sharing-grants/{grant_id}` +- `POST /v1/sharing-grants/{grant_id}/revoke` + +The client creates grants only from active contact public keys already returned +for the active session. It sends tightly shaped request bodies for `contact_id`, +optional `contact_public_key_id`, optional `stream_id`, `data_class`, and +optional future `expires_at`. Missing incident, stream, or active contact-key +dependencies stay generic in the UI as `sharing_grant_dependency_not_found`. + +Sharing grants authorize metadata and/or encrypted evidence access. They do not +decrypt media, create trusted-contact sessions, notify emergency services, or +guarantee emergency response. The UI marks expired or revoked grants as inactive +delivery paths and does not retain wrapped-key ciphertext, raw media keys, +contact private keys, plaintext, request bodies, stored paths, object keys, or +private deployment details from grant responses. + ## Public Registration Contracts The API client includes typed public calls for `POST /v1/auth/register` and diff --git a/docs/security-model.md b/docs/security-model.md index 566718f..f3dfd86 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -19,6 +19,9 @@ This web client is experimental and not production-ready. plaintext, raw keys, wrapped-key ciphertext, stored paths, or object keys. - Contact public-key management sends only reviewed public-key metadata fields and keeps revoked keys visibly ineligible for new sharing grants. +- Sharing-grant management uses active contact public keys only, keeps expired + or revoked grants out of active delivery paths, and does not add decryption, + notification, or emergency-response behavior. - Registration responses use the server's generic verification-required success message and do not create a browser session. - The email-verification route reads the token from the URL fragment, submits diff --git a/src/App.test.tsx b/src/App.test.tsx index ee8c430..8c4159d 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -595,7 +595,7 @@ test("renders authenticated mock incident detail metadata sections", async () => expect( screen.getByRole("heading", { name: "Key delivery" }), ).toBeInTheDocument(); - expect(screen.getByText("str_audio_001")).toBeInTheDocument(); + expect(screen.getAllByText("str_audio_001")).not.toHaveLength(0); expect(screen.getByText("No shared access")).toBeInTheDocument(); expect(screen.getByText("No key delivery")).toBeInTheDocument(); }); @@ -944,6 +944,385 @@ test("does not show stale contact-key metadata after switching sessions", async expect(screen.queryByText("Alice trusted contact")).toBeNull(); }); +test("creates and revokes live sharing grants from active contact keys", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + let grants: Record[] = []; + server.use( + http.get("*/v1/incidents/inc_live", () => + HttpResponse.json({ + incident: { + id: "inc_live", + status: "closed", + sharing_state: "private", + deletion_state: "active", + }, + streams: [ + { + id: "str_audio", + incident_id: "inc_live", + media_type: "audio", + status: "complete", + }, + ], + chunks: [], + checkins: [], + }), + ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "incident_deletion_not_found", + message: "incident deletion was not found", + }, + }, + { status: 404 }, + ), + ), + http.get("*/v1/contact-public-keys", () => + HttpResponse.json({ + contact_public_keys: [ + { + public_key_id: "cpk_active", + owner_account_id: "acct_live", + contact_id: "ctc_active", + version: 1, + display_label: "Trusted contact", + wrapping_algorithm: "age-v1-x25519", + public_key_fingerprint: "fingerprint-active", + key_state: "active", + }, + { + public_key_id: "cpk_pending", + owner_account_id: "acct_live", + contact_id: "ctc_pending", + version: 1, + display_label: "Pending contact", + wrapping_algorithm: "age-v1-x25519", + public_key_fingerprint: "fingerprint-pending", + key_state: "pending_verification", + }, + ], + }), + ), + http.get("*/v1/incidents/inc_live/sharing-grants", () => + HttpResponse.json({ sharing_grants: grants }), + ), + http.post( + "*/v1/incidents/inc_live/sharing-grants", + async ({ request }) => { + await expect(request.json()).resolves.toEqual({ + stream_id: "str_audio", + contact_id: "ctc_active", + contact_public_key_id: "cpk_active", + data_class: "metadata_ciphertext", + expires_at: expiresAt, + }); + const grant = { + grant_id: "sgr_live", + owner_account_id: "acct_live", + incident_id: "inc_live", + stream_id: "str_audio", + recipient_type: "trusted_contact", + contact_id: "ctc_active", + contact_public_key_id: "cpk_active", + contact_public_key_version: 1, + data_class: "metadata_ciphertext", + grant_state: "active", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + expires_at: expiresAt, + wrapped_key_ciphertext: "wrapped-ciphertext", + raw_media_key: "raw-media-key", + request_body: "private request", + stored_path: "incidents/inc_live/private.enc", + object_key: "private/object/key", + }; + grants = [grant]; + return HttpResponse.json({ sharing_grant: grant }, { status: 201 }); + }, + ), + http.post("*/v1/sharing-grants/sgr_live/revoke", () => { + grants = grants.map((grant) => + grant.grant_id === "sgr_live" + ? { + ...grant, + grant_state: "revoked", + revoked_at: "2026-06-01T00:20:00Z", + updated_at: "2026-06-01T00:20:00Z", + } + : grant, + ); + return HttpResponse.json({ sharing_grant: grants[0] }); + }), + http.get("*/v1/incidents/inc_live/wrapped-keys", () => + HttpResponse.json({ wrapped_keys: [] }), + ), + ); + + renderRoute("/incidents/inc_live"); + + expect( + await screen.findByRole("heading", { name: "inc_live" }), + ).toBeInTheDocument(); + const contactKeySelect = (await screen.findByLabelText( + "Active contact key", + )) as HTMLSelectElement; + fireEvent.change(contactKeySelect, { + target: { value: "cpk_active" }, + }); + expect( + Array.from(contactKeySelect.options).some((option) => + option.text.includes("Pending contact"), + ), + ).toBe(false); + fireEvent.change(screen.getByLabelText("Scope"), { + target: { value: "str_audio" }, + }); + fireEvent.change(screen.getByLabelText("Expires at"), { + target: { value: expiresAt }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create sharing grant" })); + + expect(await screen.findByText("Sharing grant created.")).toBeInTheDocument(); + expect(await screen.findByText("sgr_live")).toBeInTheDocument(); + expect(screen.getByText("Active delivery path")).toBeInTheDocument(); + expect(screen.getByText("Yes")).toBeInTheDocument(); + expect(screen.queryByText("wrapped-ciphertext")).toBeNull(); + expect(screen.queryByText("raw-media-key")).toBeNull(); + expect(screen.queryByText("private request")).toBeNull(); + expect(screen.queryByText("incidents/inc_live/private.enc")).toBeNull(); + expect(screen.queryByText("private/object/key")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Revoke" })); + + expect(await screen.findByText("Sharing grant revoked.")).toBeInTheDocument(); + expect(await screen.findByText("revoked")).toBeInTheDocument(); + expect(screen.getAllByText("No").length).toBeGreaterThan(0); + expect(screen.getByRole("button", { name: "Revoke" })).toBeDisabled(); +}); + +test("shows safe sharing-grant empty state without active contact keys", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/incidents/inc_live", () => + HttpResponse.json({ + incident: { + id: "inc_live", + status: "closed", + deletion_state: "active", + }, + streams: [], + chunks: [], + checkins: [], + }), + ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "incident_deletion_not_found", + message: "incident deletion was not found", + }, + }, + { status: 404 }, + ), + ), + http.get("*/v1/contact-public-keys", () => + HttpResponse.json({ + contact_public_keys: [ + { + public_key_id: "cpk_pending", + owner_account_id: "acct_live", + contact_id: "ctc_pending", + version: 1, + display_label: "Pending contact", + wrapping_algorithm: "age-v1-x25519", + public_key_fingerprint: "fingerprint-pending", + key_state: "pending_verification", + }, + ], + }), + ), + http.get("*/v1/incidents/inc_live/sharing-grants", () => + HttpResponse.json({ sharing_grants: [] }), + ), + http.get("*/v1/incidents/inc_live/wrapped-keys", () => + HttpResponse.json({ wrapped_keys: [] }), + ), + ); + + renderRoute("/incidents/inc_live"); + + expect( + await screen.findByRole("heading", { name: "inc_live" }), + ).toBeInTheDocument(); + expect( + await screen.findByText( + "No active contact keys are eligible for new sharing grants.", + ), + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Create sharing grant" }), + ).toBeNull(); +}); + +test("validates sharing-grant expiry before submission", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + const createGrant = vi.fn(); + server.use( + http.get("*/v1/incidents/inc_live", () => + HttpResponse.json({ + incident: { + id: "inc_live", + status: "closed", + deletion_state: "active", + }, + streams: [], + chunks: [], + checkins: [], + }), + ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "incident_deletion_not_found", + message: "incident deletion was not found", + }, + }, + { status: 404 }, + ), + ), + http.get("*/v1/contact-public-keys", () => + HttpResponse.json({ + contact_public_keys: [ + { + public_key_id: "cpk_active", + owner_account_id: "acct_live", + contact_id: "ctc_active", + version: 1, + display_label: "Trusted contact", + wrapping_algorithm: "age-v1-x25519", + public_key_fingerprint: "fingerprint-active", + key_state: "active", + }, + ], + }), + ), + http.get("*/v1/incidents/inc_live/sharing-grants", () => + HttpResponse.json({ sharing_grants: [] }), + ), + http.post("*/v1/incidents/inc_live/sharing-grants", () => { + createGrant(); + return HttpResponse.json({ sharing_grant: {} }, { status: 201 }); + }), + http.get("*/v1/incidents/inc_live/wrapped-keys", () => + HttpResponse.json({ wrapped_keys: [] }), + ), + ); + + renderRoute("/incidents/inc_live"); + + expect( + await screen.findByRole("heading", { name: "inc_live" }), + ).toBeInTheDocument(); + fireEvent.change(await screen.findByLabelText("Active contact key"), { + target: { value: "cpk_active" }, + }); + fireEvent.change(screen.getByLabelText("Expires at"), { + target: { value: "2020-01-01T00:00:00Z" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create sharing grant" })); + + expect( + await screen.findByText("Expiry must be in the future."), + ).toBeInTheDocument(); + expect(createGrant).toHaveBeenCalledTimes(0); +}); + +test("keeps sharing-grant dependency errors generic", async () => { + vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); + saveLiveSession(); + server.use( + http.get("*/v1/incidents/inc_live", () => + HttpResponse.json({ + incident: { + id: "inc_live", + status: "closed", + deletion_state: "active", + }, + streams: [], + chunks: [], + checkins: [], + }), + ), + http.get("*/v1/incidents/inc_live/deletion", () => + HttpResponse.json( + { + error: { + code: "incident_deletion_not_found", + message: "incident deletion was not found", + }, + }, + { status: 404 }, + ), + ), + http.get("*/v1/contact-public-keys", () => + HttpResponse.json({ + contact_public_keys: [ + { + public_key_id: "cpk_active", + owner_account_id: "acct_live", + contact_id: "ctc_active", + version: 1, + display_label: "Trusted contact", + wrapping_algorithm: "age-v1-x25519", + public_key_fingerprint: "fingerprint-active", + key_state: "active", + }, + ], + }), + ), + http.get("*/v1/incidents/inc_live/sharing-grants", () => + HttpResponse.json({ sharing_grants: [] }), + ), + http.post("*/v1/incidents/inc_live/sharing-grants", () => + HttpResponse.json( + { + error: { + code: "sharing_grant_dependency_not_found", + message: "backend private dependency detail", + }, + }, + { status: 404 }, + ), + ), + http.get("*/v1/incidents/inc_live/wrapped-keys", () => + HttpResponse.json({ wrapped_keys: [] }), + ), + ); + + renderRoute("/incidents/inc_live"); + + expect( + await screen.findByRole("heading", { name: "inc_live" }), + ).toBeInTheDocument(); + fireEvent.change(await screen.findByLabelText("Active contact key"), { + target: { value: "cpk_active" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Create sharing grant" })); + + expect( + await screen.findByText("Sharing grant dependency was not available."), + ).toBeInTheDocument(); + expect(screen.queryByText("backend private dependency detail")).toBeNull(); +}); + test("renders existing incident deletion status without private deletion internals", async () => { vi.stubEnv("VITE_PROOFLINE_API_MODE", "live"); saveLiveSession(); @@ -1651,13 +2030,16 @@ test("shows generic dependent metadata errors on incident detail", async () => { expect( await screen.findByRole("heading", { name: "inc_live" }), ).toBeInTheDocument(); - expect(await screen.findAllByRole("alert")).toHaveLength(3); + expect(await screen.findAllByRole("alert")).toHaveLength(4); expect( screen.getByText("Contact details could not be loaded."), ).toBeInTheDocument(); expect( screen.getByText("Shared access details could not be loaded."), ).toBeInTheDocument(); + expect( + screen.getByText("Eligible contact keys could not be loaded."), + ).toBeInTheDocument(); expect( screen.getByText("Key delivery details could not be loaded."), ).toBeInTheDocument(); diff --git a/src/api/client.test.ts b/src/api/client.test.ts index 7e45631..2fa27b4 100644 --- a/src/api/client.test.ts +++ b/src/api/client.test.ts @@ -806,3 +806,118 @@ test("revokes live contact public keys through the revoke route", async () => { }, ); }); + +test("creates live sharing grants with only grant metadata fields", async () => { + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + server.use( + http.post( + "*/v1/incidents/inc_live/sharing-grants", + async ({ request }) => { + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + await expect(request.json()).resolves.toEqual({ + stream_id: "str_live", + contact_id: "ctc_live", + contact_public_key_id: "cpk_live", + data_class: "metadata_ciphertext", + expires_at: expiresAt, + }); + return HttpResponse.json( + { + sharing_grant: { + grant_id: "sgr_live", + owner_account_id: "acct_live", + incident_id: "inc_live", + stream_id: "str_live", + recipient_type: "trusted_contact", + contact_id: "ctc_live", + contact_public_key_id: "cpk_live", + contact_public_key_version: 1, + data_class: "metadata_ciphertext", + grant_state: "active", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:00:00Z", + expires_at: expiresAt, + wrapped_key_ciphertext: "wrapped-ciphertext", + raw_media_key: "raw-media-key", + contact_private_key: "contact-private-key", + plaintext: "private plaintext", + request_body: "private request", + stored_path: "incidents/inc_live/private.enc", + object_key: "private/object/key", + }, + }, + { status: 201 }, + ); + }, + ), + ); + + const client = createProoflineApiClient({ + mode: "live", + getToken: () => "test-session-token", + }); + + const grant = await client.createSharingGrant("inc_live", { + streamId: "str_live", + contactId: "ctc_live", + contactPublicKeyId: "cpk_live", + dataClass: "metadata_ciphertext", + expiresAt, + }); + + expect(grant).toMatchObject({ + grant_id: "sgr_live", + incident_id: "inc_live", + stream_id: "str_live", + contact_public_key_id: "cpk_live", + grant_state: "active", + }); + expect("wrapped_key_ciphertext" in grant).toBe(false); + expect("raw_media_key" in grant).toBe(false); + expect("contact_private_key" in grant).toBe(false); + expect("plaintext" in grant).toBe(false); + expect("request_body" in grant).toBe(false); + expect("stored_path" in grant).toBe(false); + expect("object_key" in grant).toBe(false); +}); + +test("revokes live sharing grants through the revoke route", async () => { + server.use( + http.post("*/v1/sharing-grants/sgr_live/revoke", ({ request }) => { + expect(request.headers.get("authorization")).toBe( + "Bearer test-session-token", + ); + return HttpResponse.json({ + sharing_grant: { + grant_id: "sgr_live", + owner_account_id: "acct_live", + incident_id: "inc_live", + recipient_type: "trusted_contact", + contact_id: "ctc_live", + contact_public_key_id: "cpk_live", + contact_public_key_version: 1, + data_class: "metadata_ciphertext", + grant_state: "revoked", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:10:00Z", + revoked_at: "2026-06-01T00:10:00Z", + revoked_by_account_id: "acct_live", + }, + }); + }), + ); + + const client = createProoflineApiClient({ + mode: "live", + getToken: () => "test-session-token", + }); + + await expect(client.revokeSharingGrant("sgr_live")).resolves.toMatchObject({ + grant_id: "sgr_live", + grant_state: "revoked", + revoked_at: "2026-06-01T00:10:00Z", + revoked_by_account_id: "acct_live", + }); +}); diff --git a/src/api/client.ts b/src/api/client.ts index 2fa2b04..edd7136 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -73,6 +73,15 @@ export type UpdateContactPublicKeyRequest = { keyState?: string; }; +export type CreateSharingGrantRequest = { + streamId?: string; + recipientType?: string; + contactId: string; + contactPublicKeyId?: string; + dataClass?: string; + expiresAt?: string; +}; + type RequestOptions = { includeAuth?: boolean; includeCredentials?: boolean; @@ -102,9 +111,10 @@ export const prooflineQueryKeys = { ["incident-deletion", incidentId] as const, contactPublicKeys: (sessionId: string) => ["contact-public-keys", sessionId] as const, - sharingGrants: (incidentId: string) => - ["sharing-grants", incidentId] as const, - wrappedKeys: (incidentId: string) => ["wrapped-keys", incidentId] as const, + sharingGrants: (sessionId: string, incidentId: string) => + ["sharing-grants", sessionId, incidentId] as const, + wrappedKeys: (sessionId: string, incidentId: string) => + ["wrapped-keys", sessionId, incidentId] as const, }; const defaultBaseUrl = @@ -647,6 +657,74 @@ export class ProoflineApiClient { ).sharing_grants; } + async createSharingGrant( + incidentId: string, + request: CreateSharingGrantRequest, + ): Promise { + if (this.mode === "mock") { + const now = new Date().toISOString(); + const activeContactKey = mockContactPublicKeys.find( + (contactKey) => + contactKey.contact_id === request.contactId && + contactKey.key_state === "active" && + (!request.contactPublicKeyId || + contactKey.public_key_id === request.contactPublicKeyId), + ); + if (!activeContactKey) { + throw new ApiError("Sharing grant dependency was not found.", { + status: 404, + code: "sharing_grant_dependency_not_found", + }); + } + const grant: SharingGrant = { + grant_id: `sgr_prototype_${Date.now()}`, + owner_account_id: mockAccount.id, + incident_id: incidentId, + recipient_type: request.recipientType ?? "trusted_contact", + contact_id: activeContactKey.contact_id, + contact_public_key_id: activeContactKey.public_key_id, + contact_public_key_version: activeContactKey.version ?? 1, + data_class: request.dataClass ?? "metadata_ciphertext", + grant_state: "active", + created_at: now, + updated_at: now, + ...(request.streamId ? { stream_id: request.streamId } : {}), + ...(request.expiresAt ? { expires_at: request.expiresAt } : {}), + }; + mockSharingGrants.push(grant); + return grant; + } + + const body: Record = { + contact_id: request.contactId, + }; + if (request.streamId) { + body.stream_id = request.streamId; + } + if (request.recipientType) { + body.recipient_type = request.recipientType; + } + if (request.contactPublicKeyId) { + body.contact_public_key_id = request.contactPublicKeyId; + } + if (request.dataClass) { + body.data_class = request.dataClass; + } + if (request.expiresAt) { + body.expires_at = request.expiresAt; + } + + return sharingGrantResponseSchema.parse( + await this.request( + `/v1/incidents/${encodeURIComponent(incidentId)}/sharing-grants`, + { + method: "POST", + body: JSON.stringify(body), + }, + ), + ).sharing_grant; + } + async readSharingGrant(grantId: string): Promise { if (this.mode === "mock") { return mockSharingGrant; @@ -656,6 +734,37 @@ export class ProoflineApiClient { ).sharing_grant; } + async revokeSharingGrant(grantId: string): Promise { + if (this.mode === "mock") { + const updatedAt = new Date().toISOString(); + const grant = mockSharingGrants.find( + (record) => record.grant_id === grantId, + ); + const nextGrant: SharingGrant = { + ...(grant ?? mockSharingGrant), + grant_id: grantId, + grant_state: "revoked", + updated_at: updatedAt, + revoked_at: updatedAt, + revoked_by_account_id: mockAccount.id, + }; + const index = mockSharingGrants.findIndex( + (record) => record.grant_id === grantId, + ); + if (index >= 0) { + mockSharingGrants[index] = nextGrant; + } + return nextGrant; + } + + return sharingGrantResponseSchema.parse( + await this.request( + `/v1/sharing-grants/${encodeURIComponent(grantId)}/revoke`, + { method: "POST" }, + ), + ).sharing_grant; + } + async listWrappedKeys(incidentId: string): Promise { if (this.mode === "mock") { return mockWrappedKeys.filter( diff --git a/src/api/schemas.test.ts b/src/api/schemas.test.ts index 8b11aa9..98c3bf6 100644 --- a/src/api/schemas.test.ts +++ b/src/api/schemas.test.ts @@ -4,6 +4,7 @@ import { incidentDeletionResponseSchema, incidentDetailSchema, incidentsResponseSchema, + sharingGrantResponseSchema, wrappedKeyResponseSchema, wrappedKeysResponseSchema, } from "./schemas"; @@ -200,6 +201,54 @@ test("contact public-key parsing drops private key material and request internal expect("private_deployment_detail" in parsed.contact_public_key).toBe(false); }); +test("sharing-grant parsing drops request internals and key material", () => { + const parsed = sharingGrantResponseSchema.parse({ + sharing_grant: { + grant_id: "sgr_test", + owner_account_id: "acct_test", + incident_id: "inc_test", + stream_id: "str_test", + recipient_type: "trusted_contact", + contact_id: "ctc_test", + contact_public_key_id: "cpk_test", + contact_public_key_version: 1, + data_class: "metadata_ciphertext", + grant_state: "revoked", + created_at: "2026-06-01T00:00:00Z", + updated_at: "2026-06-01T00:10:00Z", + expires_at: "2026-06-08T00:00:00Z", + revoked_at: "2026-06-01T00:10:00Z", + revoked_by_account_id: "acct_test", + wrapped_key_ciphertext: "wrapped-ciphertext", + raw_media_key: "raw-media-key", + contact_private_key: "contact-private-key", + plaintext: "private plaintext", + request_body: "private request", + stored_path: "incidents/inc_test/private.enc", + object_key: "private/object/key", + browser_fragment_secret: "fragment-secret", + private_deployment_detail: "private deployment", + }, + }); + + expect(parsed.sharing_grant).toMatchObject({ + grant_id: "sgr_test", + contact_id: "ctc_test", + contact_public_key_id: "cpk_test", + grant_state: "revoked", + revoked_at: "2026-06-01T00:10:00Z", + }); + expect("wrapped_key_ciphertext" in parsed.sharing_grant).toBe(false); + expect("raw_media_key" in parsed.sharing_grant).toBe(false); + expect("contact_private_key" in parsed.sharing_grant).toBe(false); + expect("plaintext" in parsed.sharing_grant).toBe(false); + expect("request_body" in parsed.sharing_grant).toBe(false); + expect("stored_path" in parsed.sharing_grant).toBe(false); + expect("object_key" in parsed.sharing_grant).toBe(false); + expect("browser_fragment_secret" in parsed.sharing_grant).toBe(false); + expect("private_deployment_detail" in parsed.sharing_grant).toBe(false); +}); + const wrappedKeyFixture = { wrapped_key_id: "wkey_test", owner_account_id: "acct_test", diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 25bfd9b..da84f61 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -190,6 +190,8 @@ export const sharingGrantSchema = z.object({ created_at: z.string().optional(), updated_at: z.string().optional(), expires_at: z.string().nullable().optional(), + revoked_at: z.string().optional(), + revoked_by_account_id: z.string().optional(), }); export const sharingGrantsResponseSchema = z.object({ diff --git a/src/routes/incidents/$incidentId.tsx b/src/routes/incidents/$incidentId.tsx index 65cd242..6157a4d 100644 --- a/src/routes/incidents/$incidentId.tsx +++ b/src/routes/incidents/$incidentId.tsx @@ -1,10 +1,25 @@ -import { useState } from "react"; +import { useState, type FormEvent } from "react"; import { Navigate, createRoute, useParams } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { prooflineQueryKeys } from "../../api/client"; -import type { IncidentDeletionStatus } from "../../api/schemas"; +import { + prooflineQueryKeys, + type CreateSharingGrantRequest, +} from "../../api/client"; +import type { + ContactPublicKey, + IncidentDeletionStatus, + SharingGrant, +} from "../../api/schemas"; +import { ApiError } from "../../api/errors"; import { useAuth } from "../../auth/use-auth"; import { Button } from "../../components/catalyst/button"; +import { + ErrorMessage, + Field, + Label, +} from "../../components/catalyst/fieldset"; +import { Input } from "../../components/catalyst/input"; +import { Select } from "../../components/catalyst/select"; import { EmptyState } from "../../components/proofline/EmptyState"; import { ContentSection, @@ -16,6 +31,70 @@ import { MetadataGrid } from "../../components/proofline/MetadataGrid"; import { StatusBadge } from "../../components/proofline/StatusBadge"; import { rootRoute } from "../__root"; +type ResultState = + | { state: "idle" } + | { state: "success"; message: string } + | { state: "error"; message: string }; + +type SharingGrantFormErrors = Partial< + Record<"contactPublicKeyId" | "expiresAt", string> +>; + +const defaultGrantDataClass = "metadata_ciphertext"; + +function isActiveContactKey(contactKey: ContactPublicKey): boolean { + return contactKey.key_state === "active"; +} + +function isExpiredGrant(grant: SharingGrant, now = new Date()): boolean { + if (!grant.expires_at) { + return false; + } + const expiresAt = Date.parse(grant.expires_at); + return Number.isFinite(expiresAt) && expiresAt <= now.getTime(); +} + +function grantDisplayState(grant: SharingGrant): string { + if (grant.grant_state === "active" && isExpiredGrant(grant)) { + return "expired"; + } + return grant.grant_state; +} + +function isActiveDeliveryGrant(grant: SharingGrant): boolean { + return grant.grant_state === "active" && !isExpiredGrant(grant); +} + +function normalizeFutureExpiry(value: string): { + expiresAt?: string; + error?: string; +} { + const trimmed = value.trim(); + if (!trimmed) { + return {}; + } + const parsed = Date.parse(trimmed); + if (!Number.isFinite(parsed)) { + return { error: "Enter a valid expiry timestamp." }; + } + if (parsed <= Date.now()) { + return { error: "Expiry must be in the future." }; + } + return { expiresAt: new Date(parsed).toISOString() }; +} + +function sharingGrantCreateErrorMessage(error: unknown): string { + if (error instanceof ApiError) { + if (error.code === "sharing_grant_dependency_not_found") { + return "Sharing grant dependency was not available."; + } + if (error.code === "invalid_expires_at") { + return "Expiry must be in the future."; + } + } + return "Sharing grant could not be created."; +} + function IncidentDetailPage() { const { incidentId } = useParams({ from: "/incidents/$incidentId" }); const { isAuthenticated, apiClient, session } = useAuth(); @@ -24,6 +103,22 @@ function IncidentDetailPage() { const contactPublicKeysQueryKey = prooflineQueryKeys.contactPublicKeys( session?.sessionId ?? "signed-out", ); + const sharingGrantsQueryKey = prooflineQueryKeys.sharingGrants( + session?.sessionId ?? "signed-out", + incidentId, + ); + const wrappedKeysQueryKey = prooflineQueryKeys.wrappedKeys( + session?.sessionId ?? "signed-out", + incidentId, + ); + const [grantContactPublicKeyId, setGrantContactPublicKeyId] = useState(""); + const [grantStreamId, setGrantStreamId] = useState(""); + const [grantDataClass, setGrantDataClass] = useState(defaultGrantDataClass); + const [grantExpiresAt, setGrantExpiresAt] = useState(""); + const [grantErrors, setGrantErrors] = useState({}); + const [grantResult, setGrantResult] = useState({ + state: "idle", + }); const incident = useQuery({ queryKey: prooflineQueryKeys.incident(incidentId), @@ -41,14 +136,39 @@ function IncidentDetailPage() { enabled: isAuthenticated && session !== null, }); const grants = useQuery({ - queryKey: prooflineQueryKeys.sharingGrants(incidentId), + queryKey: sharingGrantsQueryKey, queryFn: () => apiClient.listSharingGrants(incidentId), - enabled: isAuthenticated, + enabled: isAuthenticated && session !== null, }); const wrappedKeys = useQuery({ - queryKey: prooflineQueryKeys.wrappedKeys(incidentId), + queryKey: wrappedKeysQueryKey, queryFn: () => apiClient.listWrappedKeys(incidentId), - enabled: isAuthenticated, + enabled: isAuthenticated && session !== null, + }); + const createSharingGrant = useMutation({ + mutationFn: (request: CreateSharingGrantRequest) => + apiClient.createSharingGrant(incidentId, request), + onSuccess: (grant) => { + queryClient.setQueryData( + sharingGrantsQueryKey, + (current) => [...(current ?? []), grant], + ); + void queryClient.invalidateQueries({ queryKey: sharingGrantsQueryKey }); + }, + }); + const revokeSharingGrant = useMutation({ + mutationFn: (grantId: string) => apiClient.revokeSharingGrant(grantId), + onSuccess: (grant) => { + queryClient.setQueryData( + sharingGrantsQueryKey, + (current) => + (current ?? []).map((record) => + record.grant_id === grant.grant_id ? grant : record, + ), + ); + void queryClient.invalidateQueries({ queryKey: sharingGrantsQueryKey }); + void queryClient.invalidateQueries({ queryKey: wrappedKeysQueryKey }); + }, }); const requestDeletion = useMutation({ mutationFn: () => { @@ -74,6 +194,67 @@ function IncidentDetailPage() { }); }, }); + const activeContactKeys = (contacts.data ?? []).filter(isActiveContactKey); + + async function handleCreateSharingGrant(event: FormEvent) { + event.preventDefault(); + setGrantResult({ state: "idle" }); + const nextErrors: SharingGrantFormErrors = {}; + const selectedContactKey = activeContactKeys.find( + (contactKey) => contactKey.public_key_id === grantContactPublicKeyId, + ); + if (!selectedContactKey) { + nextErrors.contactPublicKeyId = "Select an active contact key."; + } + const expiry = normalizeFutureExpiry(grantExpiresAt); + if (expiry.error) { + nextErrors.expiresAt = expiry.error; + } + setGrantErrors(nextErrors); + if (Object.values(nextErrors).some(Boolean) || !selectedContactKey) { + return; + } + + const request: CreateSharingGrantRequest = { + contactId: selectedContactKey.contact_id, + contactPublicKeyId: selectedContactKey.public_key_id, + dataClass: grantDataClass, + ...(grantStreamId ? { streamId: grantStreamId } : {}), + ...(expiry.expiresAt ? { expiresAt: expiry.expiresAt } : {}), + }; + try { + await createSharingGrant.mutateAsync(request); + setGrantContactPublicKeyId(""); + setGrantStreamId(""); + setGrantDataClass(defaultGrantDataClass); + setGrantExpiresAt(""); + setGrantResult({ + state: "success", + message: "Sharing grant created.", + }); + } catch (error) { + setGrantResult({ + state: "error", + message: sharingGrantCreateErrorMessage(error), + }); + } + } + + async function handleRevokeSharingGrant(grantId: string) { + setGrantResult({ state: "idle" }); + try { + await revokeSharingGrant.mutateAsync(grantId); + setGrantResult({ + state: "success", + message: "Sharing grant revoked.", + }); + } catch { + setGrantResult({ + state: "error", + message: "Sharing grant could not be revoked.", + }); + } + } if (!isAuthenticated) { return ; @@ -294,6 +475,131 @@ function IncidentDetailPage() { +
+ + Sharing grants authorize metadata or encrypted evidence only. They + do not decrypt media, notify emergency services, or guarantee + emergency response. + + + {contacts.isError ? ( + Eligible contact keys could not be loaded. + ) : contacts.isLoading ? ( +

+ Loading eligible contact keys. +

+ ) : activeContactKeys.length ? ( +
+
+ + + + {grantErrors.contactPublicKeyId ? ( + + {grantErrors.contactPublicKeyId} + + ) : null} + + + + + + + + + + + + { + setGrantExpiresAt(event.target.value); + if (grantErrors.expiresAt !== undefined) { + setGrantErrors((current) => { + const next = { ...current }; + delete next.expiresAt; + return next; + }); + } + }} + /> + {grantErrors.expiresAt ? ( + {grantErrors.expiresAt} + ) : null} + +
+ +
+ ) : ( + + No active contact keys are eligible for new sharing grants. + + )} + + {grantResult.state === "success" ? ( + {grantResult.message} + ) : null} + {grantResult.state === "error" ? ( + + {grantResult.message} + + ) : null} +
+ {grants.isError ? ( Shared access details could not be loaded. @@ -305,15 +611,14 @@ function IncidentDetailPage() { ) : grants.data?.length ? (
{grants.data.map((grant) => ( - } + grant={grant} + isRevoking={ + revokeSharingGrant.isPending && + revokeSharingGrant.variables === grant.grant_id + } + onRevoke={handleRevokeSharingGrant} /> ))}
@@ -400,6 +705,48 @@ function MetadataError({ children }: { children: React.ReactNode }) { ); } +function SharingGrantRow({ + grant, + isRevoking, + onRevoke, +}: { + grant: SharingGrant; + isRevoking: boolean; + onRevoke: (grantId: string) => void; +}) { + const isRevoked = grant.grant_state === "revoked"; + return ( +
+ } + /> +
+ +
+
+ ); +} + function MetadataSection({ title, count, From eda0fc8cb8c2b1641938a60ec1aa30fe8cd4cb44 Mon Sep 17 00:00:00 2001 From: Ellie Melton Date: Sat, 6 Jun 2026 15:33:01 +1000 Subject: [PATCH 2/2] Refresh incident detail after sharing grant changes --- src/App.test.tsx | 9 ++++++++- src/routes/incidents/$incidentId.tsx | 6 ++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/App.test.tsx b/src/App.test.tsx index 8c4159d..5163547 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -949,13 +949,14 @@ test("creates and revokes live sharing grants from active contact keys", async ( saveLiveSession(); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); let grants: Record[] = []; + let sharingState = "private"; server.use( http.get("*/v1/incidents/inc_live", () => HttpResponse.json({ incident: { id: "inc_live", status: "closed", - sharing_state: "private", + sharing_state: sharingState, deletion_state: "active", }, streams: [ @@ -1041,6 +1042,7 @@ test("creates and revokes live sharing grants from active contact keys", async ( object_key: "private/object/key", }; grants = [grant]; + sharingState = "shared"; return HttpResponse.json({ sharing_grant: grant }, { status: 201 }); }, ), @@ -1055,6 +1057,7 @@ test("creates and revokes live sharing grants from active contact keys", async ( } : grant, ); + sharingState = "private"; return HttpResponse.json({ sharing_grant: grants[0] }); }), http.get("*/v1/incidents/inc_live/wrapped-keys", () => @@ -1088,6 +1091,8 @@ test("creates and revokes live sharing grants from active contact keys", async ( expect(await screen.findByText("Sharing grant created.")).toBeInTheDocument(); expect(await screen.findByText("sgr_live")).toBeInTheDocument(); + expect(await screen.findByText("shared")).toBeInTheDocument(); + expect(screen.queryByText("private")).toBeNull(); expect(screen.getByText("Active delivery path")).toBeInTheDocument(); expect(screen.getByText("Yes")).toBeInTheDocument(); expect(screen.queryByText("wrapped-ciphertext")).toBeNull(); @@ -1100,6 +1105,8 @@ test("creates and revokes live sharing grants from active contact keys", async ( expect(await screen.findByText("Sharing grant revoked.")).toBeInTheDocument(); expect(await screen.findByText("revoked")).toBeInTheDocument(); + expect(await screen.findByText("private")).toBeInTheDocument(); + expect(screen.queryByText("shared")).toBeNull(); expect(screen.getAllByText("No").length).toBeGreaterThan(0); expect(screen.getByRole("button", { name: "Revoke" })).toBeDisabled(); }); diff --git a/src/routes/incidents/$incidentId.tsx b/src/routes/incidents/$incidentId.tsx index 6157a4d..38b9b53 100644 --- a/src/routes/incidents/$incidentId.tsx +++ b/src/routes/incidents/$incidentId.tsx @@ -154,6 +154,9 @@ function IncidentDetailPage() { (current) => [...(current ?? []), grant], ); void queryClient.invalidateQueries({ queryKey: sharingGrantsQueryKey }); + void queryClient.invalidateQueries({ + queryKey: prooflineQueryKeys.incident(incidentId), + }); }, }); const revokeSharingGrant = useMutation({ @@ -168,6 +171,9 @@ function IncidentDetailPage() { ); void queryClient.invalidateQueries({ queryKey: sharingGrantsQueryKey }); void queryClient.invalidateQueries({ queryKey: wrappedKeysQueryKey }); + void queryClient.invalidateQueries({ + queryKey: prooflineQueryKeys.incident(incidentId), + }); }, }); const requestDeletion = useMutation({