mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(consent): mint AI consent receipts only on an explicit agreement
The AI settings page posted to /api/consent/ai/web on every mount and the server minted an ai_full receipt for it, so an account that had agreed to nothing sent its health data to the operator's provider from background jobs. Native builds synced a receipt the same way on every launch. - Both grant routes now require `confirm: true`; anything else writes nothing and answers 400 consent.ai.confirmRequired. - Remove the mount-time heal. A consent card names the operator's service and the data that would go there, with one Agree button, in Settings > AI and on the Insights overview while AI is closed by the missing consent alone. - Publish the standing consent as `ai.consent` on /api/auth/me (status, since, recipients); the settings card renders active-since and the withdraw control from it. - Existing receipts stay valid. - Guard that no client grants outside a click handler, route tests for the confirmation, and an integration test that a fresh operator-key account gets no background AI text until it agrees.
This commit is contained in:
1 parent
bc5ea5a67b
commit
e2b7e7350e
45 files changed
+1707
-678
No files matched your search
+111
-44
@@ -14473,8 +14473,16 @@ paths:
|
||||
tags:
|
||||
- Consent
|
||||
summary: Grant AI consent (signed artefact)
|
||||
description: Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM
|
||||
key); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated. Append-only audit trail.
|
||||
description: >-
|
||||
Persist a fresh AI-consent receipt. Required before server-managed AI egress (the operator's global LLM key or
|
||||
shared ChatGPT account); BYOK / local / ChatGPT-OAuth chains are the user's own egress and are not gated.
|
||||
Append-only audit trail.
|
||||
|
||||
|
||||
v1.43.2: the body must carry `confirm: true`, sent only when the person taps an agree control that named the
|
||||
service and the data. A request without it (a launch-time sync from an older app build) writes nothing and
|
||||
answers 400 with `meta.errorCode: consent.ai.confirmRequired`; such a client reads the standing consent from
|
||||
`ai.consent` on GET /api/auth/me instead of re-minting it.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -14489,7 +14497,8 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ConsentReceiptResponseEnvelope"
|
||||
"400":
|
||||
description: Request validation failed (consent routes use 400).
|
||||
description: "Request validation failed (consent routes use 400), or `confirm` was not `true` (`meta.errorCode:
|
||||
consent.ai.confirmRequired`, nothing written)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -14501,14 +14510,14 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Consent
|
||||
summary: Grant web AI consent (idempotent ai_full)
|
||||
description: "Mint an `ai_full` consent receipt for the calling web user if none is active; a no-op when one already
|
||||
exists. Mirrors the iOS master grant. Without a body (the mount heal) it grants only to an account with no
|
||||
consent history, so a revocation stands until the user grants again; with `intent: \"affirmative\"` (the
|
||||
explicit grant control) it records the user's own consent act, which may supersede an earlier revocation.
|
||||
Revocation flows through DELETE /api/consent/ai/latest."
|
||||
summary: Grant web AI consent (explicit ai_full)
|
||||
description: "Record the calling web user's explicit `ai_full` consent: they pressed the agree control on a card that
|
||||
named the service and the data. Mints a receipt if none is active; a no-op when one already exists. As the
|
||||
person's own consent act it may follow an earlier withdrawal. The body must be `{ \"confirm\": true }`; anything
|
||||
else mints nothing and answers 400 `consent.ai.confirmRequired`. Revocation flows through DELETE
|
||||
/api/consent/ai/latest."
|
||||
requestBody:
|
||||
required: false
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -14520,6 +14529,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/WebConsentGrantResponseEnvelope"
|
||||
"400":
|
||||
description: "`confirm` was not `true` (`meta.errorCode: consent.ai.confirmRequired`). Nothing was written."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"401": *a1
|
||||
"422": *a4
|
||||
"429": *a5
|
||||
@@ -31265,6 +31280,8 @@ components:
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
confirm:
|
||||
type: boolean
|
||||
artefact:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -31276,21 +31293,19 @@ components:
|
||||
- kind
|
||||
- artefact
|
||||
- signedAt
|
||||
description: Explicit AI-consent grant. `artefact` is an opaque signed receipt (base64 PDF or JWT, ≤ 64 KB UTF-8 bytes);
|
||||
`signedAt` is an ISO-8601 instant. Always appends a fresh row — re-granting after a revoke mints a new receipt.
|
||||
description: "Explicit AI-consent grant. `artefact` is an opaque signed receipt (base64 PDF or JWT, ≤ 64 KB UTF-8
|
||||
bytes); `signedAt` is an ISO-8601 instant. `confirm` must be `true` (v1.43.2): send this body only from the
|
||||
person's own tap on an agree control, never from a launch or sync path. Without it nothing is written and the
|
||||
route answers 400 `consent.ai.confirmRequired`. Always appends a fresh row — re-granting after a revoke mints a
|
||||
new receipt."
|
||||
WebConsentGrantBody:
|
||||
type: object
|
||||
properties:
|
||||
intent:
|
||||
default: heal
|
||||
type: string
|
||||
enum:
|
||||
- heal
|
||||
- affirmative
|
||||
description: "Optional. Omitting the body (or sending `intent: \"heal\"`) is the AI-settings mount heal: it mints only
|
||||
for an account with no `ai_full` consent history at all, so a standing revocation is never resurrected. `intent:
|
||||
\"affirmative\"` is the user's own grant action and may supersede an earlier revocation, exactly as a first
|
||||
grant would."
|
||||
confirm:
|
||||
type: boolean
|
||||
description: "`confirm` must be `true`: the person pressed the agree control. Any other body, or none, mints nothing and
|
||||
answers 400 `consent.ai.confirmRequired` (v1.43.2; before, an empty body was a silent heal sent on every
|
||||
AI-settings visit)."
|
||||
JsonImportRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -36088,26 +36103,27 @@ components:
|
||||
coach.fenced.attachmentLimit, coach.fenced.attachmentNotFound, coach.fenced.attachmentNotIndexed,
|
||||
coach.fenced.attachmentRequired, coach.fenced.attachmentUnavailable, coach.fenced.invalid,
|
||||
coach.request.invalid; coach-prefs — coach-prefs.body.invalid_json, coach-prefs.body.invalid_shape;
|
||||
consent — consent.ai.required; cycle — cycle.bulk.invalid, cycle.bulk.too_large, cycle.calendar.invalid,
|
||||
cycle.calendar.range, cycle.cycles.invalid, cycle.day-log.conflict, cycle.day-log.invalid,
|
||||
cycle.disabled, cycle.period.invalid, cycle.symptom.custom.invalid, cycle.symptom.custom.limit;
|
||||
cycle-prefs — cycle-prefs.body.invalid_json, cycle-prefs.invalid; disable-coach —
|
||||
disable-coach.body.invalid_json; dawarich — dawarich.credentials_rejected, dawarich.home_place_unknown,
|
||||
dawarich.invalid, dawarich.not_connected, dawarich.rate_limited, dawarich.unreachable; day —
|
||||
day.invalid; demo — demo.readOnly; documents — documents.chat.conversationNotFound,
|
||||
documents.chat.invalid, documents.inbound.alreadyConfirmed, documents.inbound.alreadyPartlyConfirmed,
|
||||
documents.inbound.budgetExceeded, documents.inbound.decryptFailed, documents.inbound.encounterNotFound,
|
||||
documents.inbound.episodeNotFound, documents.inbound.extractFailed, documents.inbound.factNotFound,
|
||||
documents.inbound.factNotPending, documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge,
|
||||
documents.inbound.fileType, documents.inbound.invalidMetadata, documents.inbound.invalidQuery,
|
||||
documents.inbound.invalidUpdate, documents.inbound.localOcrDisabled, documents.inbound.notFound,
|
||||
documents.inbound.notIndexed, documents.inbound.pdfNeedsAnthropic,
|
||||
documents.inbound.providerUnsupported, documents.inbound.quotaExceeded, documents.inbound.rateLimited,
|
||||
documents.inbound.restoreDuplicate, documents.inbound.restoreGone, documents.inbound.sourceAliasLimit,
|
||||
documents.inbound.uploadBusy, documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound,
|
||||
documents.sources.authRefused, documents.sources.badResponse, documents.sources.browserOnly,
|
||||
documents.sources.invalidAddress, documents.sources.linkTargetNotFound, documents.sources.notConnected,
|
||||
documents.sources.notFound, documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
consent — consent.ai.confirmRequired, consent.ai.required; cycle — cycle.bulk.invalid,
|
||||
cycle.bulk.too_large, cycle.calendar.invalid, cycle.calendar.range, cycle.cycles.invalid,
|
||||
cycle.day-log.conflict, cycle.day-log.invalid, cycle.disabled, cycle.period.invalid,
|
||||
cycle.symptom.custom.invalid, cycle.symptom.custom.limit; cycle-prefs — cycle-prefs.body.invalid_json,
|
||||
cycle-prefs.invalid; disable-coach — disable-coach.body.invalid_json; dawarich —
|
||||
dawarich.credentials_rejected, dawarich.home_place_unknown, dawarich.invalid, dawarich.not_connected,
|
||||
dawarich.rate_limited, dawarich.unreachable; day — day.invalid; demo — demo.readOnly; documents —
|
||||
documents.chat.conversationNotFound, documents.chat.invalid, documents.inbound.alreadyConfirmed,
|
||||
documents.inbound.alreadyPartlyConfirmed, documents.inbound.budgetExceeded,
|
||||
documents.inbound.decryptFailed, documents.inbound.encounterNotFound, documents.inbound.episodeNotFound,
|
||||
documents.inbound.extractFailed, documents.inbound.factNotFound, documents.inbound.factNotPending,
|
||||
documents.inbound.factTypeMismatch, documents.inbound.fileTooLarge, documents.inbound.fileType,
|
||||
documents.inbound.invalidMetadata, documents.inbound.invalidQuery, documents.inbound.invalidUpdate,
|
||||
documents.inbound.localOcrDisabled, documents.inbound.notFound, documents.inbound.notIndexed,
|
||||
documents.inbound.pdfNeedsAnthropic, documents.inbound.providerUnsupported,
|
||||
documents.inbound.quotaExceeded, documents.inbound.rateLimited, documents.inbound.restoreDuplicate,
|
||||
documents.inbound.restoreGone, documents.inbound.sourceAliasLimit, documents.inbound.uploadBusy,
|
||||
documents.inbound.uploadTimeout, documents.inbound.vaccinationNotFound, documents.sources.authRefused,
|
||||
documents.sources.badResponse, documents.sources.browserOnly, documents.sources.invalidAddress,
|
||||
documents.sources.linkTargetNotFound, documents.sources.notConnected, documents.sources.notFound,
|
||||
documents.sources.organizationRequired, documents.sources.originNotAllowed,
|
||||
documents.sources.permissionMissing, documents.sources.rateLimited, documents.sources.redirected,
|
||||
documents.sources.tokenRequired, documents.sources.unavailable, documents.sources.unreachable,
|
||||
documents.sources.versionTooOld; encounter — encounter.invalid, encounter.link.invalid,
|
||||
@@ -68816,7 +68832,8 @@ components:
|
||||
properties:
|
||||
minted:
|
||||
type: boolean
|
||||
description: True when a new `ai_full` receipt was minted; false when an active one already existed (idempotent no-op).
|
||||
description: True when a new `ai_full` receipt was minted; false when an active one already existed (a double tap is a
|
||||
no-op).
|
||||
kind:
|
||||
type: string
|
||||
const: ai_full
|
||||
@@ -68824,7 +68841,7 @@ components:
|
||||
- minted
|
||||
- kind
|
||||
additionalProperties: false
|
||||
description: Outcome of the idempotent web `ai_full` grant. Safe to call on every AI-settings mount.
|
||||
description: Outcome of the explicit web `ai_full` grant.
|
||||
ConsentLatestForKindEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -80860,12 +80877,15 @@ components:
|
||||
$ref: "#/components/schemas/AiCapabilities"
|
||||
provider:
|
||||
$ref: "#/components/schemas/AiProviderState"
|
||||
consent:
|
||||
$ref: "#/components/schemas/AiConsentState"
|
||||
usage:
|
||||
description: v1.43 — the account's AI usage level. Absent on older servers; hide the setting then.
|
||||
$ref: "#/components/schemas/AiUsageState"
|
||||
required:
|
||||
- capabilities
|
||||
- provider
|
||||
- consent
|
||||
additionalProperties: false
|
||||
description: Which AI capabilities the record this session is inside has, and why not when it has none. Resolved on the
|
||||
server for the ACTIVE RECORD and masked to the sections the active grant opens, exactly like `modules` and
|
||||
@@ -80964,6 +80984,53 @@ components:
|
||||
- responseTimeoutMs
|
||||
additionalProperties: false
|
||||
description: The account-level companion to the capability map.
|
||||
AiConsentState:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- given
|
||||
- needed
|
||||
- not_needed
|
||||
description: "`given` — an `ai_full` consent receipt is active (`since` says when); show it with a withdraw control.
|
||||
`needed` — the provider chain runs through a credential the operator holds and at least one capability is
|
||||
closed by the missing receipt alone (its reason is `consent_required`); ask, naming `recipients`, and grant
|
||||
only from the person's tap. `not_needed` — nothing to ask and nothing to withdraw."
|
||||
since:
|
||||
description: When the active `ai_full` consent was given, ISO-8601. `null` unless `status` is `given`.
|
||||
type:
|
||||
- string
|
||||
- "null"
|
||||
recipients:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
via:
|
||||
type: string
|
||||
enum:
|
||||
- operator_key
|
||||
- operator_chatgpt
|
||||
description: "`operator_key` — the operator's API key. `operator_chatgpt` — the operator's shared ChatGPT account."
|
||||
name:
|
||||
type: string
|
||||
description: "The service, named for a person: `OpenAI`, `Anthropic`, `ChatGPT (OpenAI)`, or the host name of the
|
||||
endpoint the operator set."
|
||||
required:
|
||||
- via
|
||||
- name
|
||||
additionalProperties: false
|
||||
description: The operator-held services in the record's provider chain, in chain order. Empty when the chain holds none.
|
||||
required:
|
||||
- status
|
||||
- since
|
||||
- recipients
|
||||
additionalProperties: false
|
||||
description: "v1.43.2 — the standing AI consent for the record. Consent is asked only when data would go through a
|
||||
credential the operator holds; a person's own key, ChatGPT account or local model needs none. Render from it; a
|
||||
client never mints a receipt on its own (launch, sync or mount): POST /api/consent/ai and /api/consent/ai/web
|
||||
require `confirm: true`."
|
||||
AiUsageState:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
Reference in new issue
Block a user