mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
Harden consent receipts and document iOS series + ai-provider contract
Document GET /api/measurements/series and GET /api/user/ai-provider in the OpenAPI registry — both are consumed by the iOS client (charts and Coach visibility gating) but were absent from the spec. Break migration 0159's active-receipt dedup on (created_at, id) so an exact created_at tie still leaves one survivor, keeping the unique index build from aborting 23505 on affected tenants. Catch P2002 in createReceipt: two concurrent first grants both reach create, the loser re-reads the winning active receipt and returns it instead of a 500.
This commit is contained in:
1 parent
555240cd28
commit
32d275b4aa
6 files changed
+519
-17
No files matched your search
@@ -1436,6 +1436,57 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurements/series:
|
||||
get:
|
||||
tags:
|
||||
- Measurements
|
||||
summary: Per-kind time series (iOS chart source)
|
||||
description: Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an
|
||||
explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`)
|
||||
within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night
|
||||
point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to
|
||||
365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Auth via cookie or Bearer.
|
||||
parameters:
|
||||
- in: query
|
||||
name: kind
|
||||
schema:
|
||||
type: string
|
||||
enum:
|
||||
- weight
|
||||
- bloodPressure
|
||||
- pulse
|
||||
- bodyFat
|
||||
- glucose
|
||||
- sleep
|
||||
- steps
|
||||
- totalBodyWater
|
||||
- boneMass
|
||||
- oxygenSaturation
|
||||
- restingHeartRate
|
||||
- heartRateVariability
|
||||
- vo2Max
|
||||
required: true
|
||||
- in: query
|
||||
name: days
|
||||
schema:
|
||||
default: 30
|
||||
description: Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the
|
||||
requested range.
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 3650
|
||||
description: Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the
|
||||
requested range.
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved series.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetMeasurementsSeriesResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurements:
|
||||
get:
|
||||
tags:
|
||||
@@ -2832,6 +2883,25 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/user/ai-provider:
|
||||
get:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Read the calling user's AI-provider configuration
|
||||
description: "Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true
|
||||
when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy`
|
||||
reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char
|
||||
masked preview only."
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved AI-provider configuration.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/GetAiProviderResponse"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/me/coach-prefs:
|
||||
get:
|
||||
tags:
|
||||
@@ -8286,6 +8356,113 @@ components:
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: A soft-deleted medication intake, keyed on server `id`. Apply before upserts within the domain page.
|
||||
GetMeasurementsSeriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MeasurementsSeriesResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementsSeriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- weight
|
||||
- bloodPressure
|
||||
- pulse
|
||||
- bodyFat
|
||||
- glucose
|
||||
- sleep
|
||||
- steps
|
||||
- totalBodyWater
|
||||
- boneMass
|
||||
- oxygenSaturation
|
||||
- restingHeartRate
|
||||
- heartRateVariability
|
||||
- vo2Max
|
||||
unit:
|
||||
type: string
|
||||
description: Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h`
|
||||
(per-night time-asleep in hours).
|
||||
points:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Measurement row id, or `sleep:<wake-day>` for a sleep night.
|
||||
at:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
description: Point timestamp (ISO-8601).
|
||||
value:
|
||||
type: number
|
||||
description: Primary value in the top-level `unit`.
|
||||
secondary:
|
||||
anyOf:
|
||||
- type: number
|
||||
- type: "null"
|
||||
description: Diastolic value for `kind=bloodPressure` (paired within ±5 min); null for every other kind.
|
||||
sleepStages:
|
||||
description: Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.
|
||||
anyOf:
|
||||
- type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties:
|
||||
type: number
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- at
|
||||
- value
|
||||
- secondary
|
||||
additionalProperties: false
|
||||
stats:
|
||||
type: object
|
||||
properties:
|
||||
mean:
|
||||
type: number
|
||||
min:
|
||||
type: number
|
||||
max:
|
||||
type: number
|
||||
stdDev:
|
||||
type: number
|
||||
count:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
required:
|
||||
- mean
|
||||
- min
|
||||
- max
|
||||
- stdDev
|
||||
- count
|
||||
additionalProperties: false
|
||||
description: Summary over the returned points; all-zero when empty.
|
||||
required:
|
||||
- kind
|
||||
- unit
|
||||
- points
|
||||
- stats
|
||||
additionalProperties: false
|
||||
description: "iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an
|
||||
explicit `unit` token, and a summary `stats` block."
|
||||
ListMeasurementsResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -10695,6 +10872,89 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
GetAiProviderResponse:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/AiProviderResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
AiProviderResponse:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- OPENAI
|
||||
- ANTHROPIC
|
||||
- LOCAL
|
||||
- CHATGPT_OAUTH
|
||||
- type: "null"
|
||||
description: The user's selected provider, or null when none is set.
|
||||
model:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
baseUrl:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Custom base URL (LOCAL provider only); null otherwise.
|
||||
aiAvailable:
|
||||
type: boolean
|
||||
description: True when ANY provider can serve this user — including the operator's server-managed key when the user set
|
||||
none. iOS keys Coach visibility off this.
|
||||
managedBy:
|
||||
anyOf:
|
||||
- type: string
|
||||
enum:
|
||||
- user
|
||||
- local
|
||||
- server
|
||||
- type: "null"
|
||||
description: "Resolved provider origin: `user` (BYOK), `local` (self-hosted), `server` (operator key), or null when no
|
||||
provider is available."
|
||||
hasAnthropicKey:
|
||||
type: boolean
|
||||
anthropicKeyPreview:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: "`...` + last 4 chars of the stored Anthropic key, or null."
|
||||
hasLocalKey:
|
||||
type: boolean
|
||||
hasOpenaiKey:
|
||||
type: boolean
|
||||
openaiKeyPreview:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: "`...` + last 4 chars of the stored OpenAI key, or null."
|
||||
required:
|
||||
- provider
|
||||
- model
|
||||
- baseUrl
|
||||
- aiAvailable
|
||||
- managedBy
|
||||
- hasAnthropicKey
|
||||
- anthropicKeyPreview
|
||||
- hasLocalKey
|
||||
- hasOpenaiKey
|
||||
- openaiKeyPreview
|
||||
additionalProperties: false
|
||||
description: The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`).
|
||||
Reports key presence + a masked preview only — never a plaintext key.
|
||||
GetCoachPrefsResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
-- (latest `created_at`) is the one `latestActiveReceipt` already returns, so
|
||||
-- the surviving active grant matches current read behaviour; the superseded
|
||||
-- duplicates get a `revoked_at` marker and stay in the audit history.
|
||||
--
|
||||
-- The sibling predicate breaks on `(created_at, id)`, not `created_at` alone:
|
||||
-- two active rows with an EXACT `created_at` tie would otherwise leave each
|
||||
-- without a strictly-greater sibling, so neither is revoked and the
|
||||
-- `CREATE UNIQUE INDEX` below aborts (23505), failing the deploy on exactly
|
||||
-- the tenants this migration heals. The `id` tie-break is total (PK), so each
|
||||
-- duplicate set keeps precisely one survivor — the row with the greatest
|
||||
-- `(created_at, id)`, preserving the "newest survives" intent.
|
||||
UPDATE "consent_receipts" c
|
||||
SET "revoked_at" = now()
|
||||
WHERE "revoked_at" IS NULL
|
||||
@@ -28,7 +36,10 @@ UPDATE "consent_receipts" c
|
||||
WHERE n."user_id" = c."user_id"
|
||||
AND n."kind" = c."kind"
|
||||
AND n."revoked_at" IS NULL
|
||||
AND n."created_at" > c."created_at"
|
||||
AND (
|
||||
n."created_at" > c."created_at"
|
||||
OR (n."created_at" = c."created_at" AND n."id" > c."id")
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "consent_receipts_user_id_kind_active_key"
|
||||
|
||||
@@ -84,6 +84,50 @@ describe("createReceipt", () => {
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resolves a concurrent first-grant P2002 to the winning active row (no 500)", async () => {
|
||||
// Two concurrent FIRST grants: both `updateMany` calls match 0 rows (no
|
||||
// prior active receipt, no lock under Read Committed), both reach
|
||||
// `create`, and the second trips the partial unique index with P2002.
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const p2002 = Object.assign(new Error("Unique constraint failed"), {
|
||||
code: "P2002",
|
||||
name: "PrismaClientKnownRequestError",
|
||||
clientVersion: "x",
|
||||
});
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue(p2002 as never);
|
||||
// The concurrent grant that won is now the single active receipt.
|
||||
const winner = row({ id: "winner-row" });
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
winner as never,
|
||||
);
|
||||
|
||||
const signedAt = new Date("2026-05-18T10:00:00.000Z");
|
||||
const result = await createReceipt("user-1", "ai_full", "PDF…", signedAt);
|
||||
|
||||
// Success-shaped: returns the row the concurrent grant produced, not a
|
||||
// propagated P2002 (which the route would surface as a generic 500).
|
||||
expect(result.id).toBe("winner-row");
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
it("re-throws a non-P2002 create failure", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.create).mockRejectedValue(
|
||||
new Error("connection reset") as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
createReceipt("user-1", "ai_full", "PDF…", new Date()),
|
||||
).rejects.toThrow("connection reset");
|
||||
});
|
||||
|
||||
it("uses a server clock (not a backdated client signedAt) for the supersede marker", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
|
||||
+32
-16
@@ -15,6 +15,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ConsentReceiptModel } from "@/generated/prisma/models/ConsentReceipt";
|
||||
import type { ConsentKind } from "@/lib/validations/consent";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
|
||||
export type ConsentReceipt = ConsentReceiptModel;
|
||||
|
||||
@@ -44,23 +45,38 @@ export async function createReceipt(
|
||||
// old row's `revoked_at` earlier than its own `signed_at`, inverting the
|
||||
// audit chain. Use a server clock for the revocation instead.
|
||||
const supersededAt = new Date();
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Supersede any currently-active receipt of this kind so the fresh
|
||||
// grant doesn't collide with the partial unique index. `updateMany`
|
||||
// over the active predicate is a no-op when none is active.
|
||||
await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: supersededAt },
|
||||
try {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
// Supersede any currently-active receipt of this kind so the fresh
|
||||
// grant doesn't collide with the partial unique index. `updateMany`
|
||||
// over the active predicate is a no-op when none is active.
|
||||
await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: supersededAt },
|
||||
});
|
||||
return tx.consentReceipt.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
artefact,
|
||||
signedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
return tx.consentReceipt.create({
|
||||
data: {
|
||||
userId,
|
||||
kind,
|
||||
artefact,
|
||||
signedAt,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
// Two concurrent FIRST grants (no prior active row) both see
|
||||
// `updateMany` match zero rows under Read Committed — neither takes a
|
||||
// lock — so both reach `create` and the second trips the partial unique
|
||||
// index (migration 0159, `WHERE revoked_at IS NULL`) with P2002. That is
|
||||
// success-shaped: the concurrent grant already produced the single active
|
||||
// receipt the caller wants. Re-read it and return it rather than letting
|
||||
// the violation surface as a generic 500.
|
||||
if (isP2002(err)) {
|
||||
const winner = await latestActiveReceipt(userId, kind);
|
||||
if (winner) return winner;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -216,7 +216,112 @@ const sleepNightResponse = z
|
||||
"One reconstructed sleep night: the main session's hypnogram segments + breakdown, plus same-wake-day naps surfaced separately.",
|
||||
});
|
||||
|
||||
// ── Time-series adapter (iOS chart source) ───────────────────────────
|
||||
//
|
||||
// v1.16.16 — documents the long-shipped `GET /api/measurements/series`
|
||||
// the iOS charts read off. The response is described AS-IS: `points`
|
||||
// carry `id`/`at`/`value`/`secondary` (the route's wire field names),
|
||||
// not the canonical `measuredAt`. `secondary` pairs the diastolic value
|
||||
// for `bloodPressure`; `sleepStages` is per-night stage hours for `sleep`
|
||||
// and null otherwise. `unit` is the resolved per-kind token (glucose +
|
||||
// sleep resolve at request time to the user's display unit / hours).
|
||||
const seriesKindEnum = z.enum([
|
||||
"weight",
|
||||
"bloodPressure",
|
||||
"pulse",
|
||||
"bodyFat",
|
||||
"glucose",
|
||||
"sleep",
|
||||
"steps",
|
||||
"totalBodyWater",
|
||||
"boneMass",
|
||||
"oxygenSaturation",
|
||||
"restingHeartRate",
|
||||
"heartRateVariability",
|
||||
"vo2Max",
|
||||
]);
|
||||
|
||||
const seriesQuerySchema = z.object({
|
||||
kind: seriesKindEnum,
|
||||
days: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(3650)
|
||||
.optional()
|
||||
.default(30)
|
||||
.describe(
|
||||
"Look-back window in days (1..3650, default 30). `sleep` is internally capped to 365 regardless of the requested range.",
|
||||
),
|
||||
});
|
||||
|
||||
const seriesPointSchema = z.object({
|
||||
id: z.string().describe("Measurement row id, or `sleep:<wake-day>` for a sleep night."),
|
||||
at: z.iso.datetime({ offset: true }).describe("Point timestamp (ISO-8601)."),
|
||||
value: z.number().describe("Primary value in the top-level `unit`."),
|
||||
secondary: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Diastolic value for `kind=bloodPressure` (paired within ±5 min); null for every other kind.",
|
||||
),
|
||||
sleepStages: z
|
||||
.record(z.string(), z.number())
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-stage hours for a `kind=sleep` night (CORE/DEEP/REM/…); null/absent for non-sleep kinds.",
|
||||
),
|
||||
});
|
||||
|
||||
const seriesResponse = z
|
||||
.object({
|
||||
kind: seriesKindEnum,
|
||||
unit: z
|
||||
.string()
|
||||
.describe(
|
||||
"Resolved unit token for `value`. `glucose` follows the user's mg/dL|mmol/L preference; `sleep` is `h` (per-night time-asleep in hours).",
|
||||
),
|
||||
points: z.array(seriesPointSchema),
|
||||
stats: z
|
||||
.object({
|
||||
mean: z.number(),
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
stdDev: z.number(),
|
||||
count: z.number().int().nonnegative(),
|
||||
})
|
||||
.describe("Summary over the returned points; all-zero when empty."),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementsSeriesResponse",
|
||||
description:
|
||||
"iOS-friendly per-kind time series: one point per reading (or per reconstructed night for `sleep`), an explicit `unit` token, and a summary `stats` block.",
|
||||
});
|
||||
|
||||
export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/measurements/series": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
summary: "Per-kind time series (iOS chart source)",
|
||||
description:
|
||||
"Maps a camelCase `kind` to the canonical MeasurementType(s) and returns an ordered point series with an explicit `unit` token and a summary `stats` block. `bloodPressure` pairs systolic + diastolic (`secondary`) within ±5 min so one fetch renders the dual-line chart; `sleep` collapses per-stage rows into one per-night point carrying time-asleep in hours (`sleepStages` holds the per-stage breakdown) and is internally capped to 365 days. `glucose` values + `unit` resolve to the user's mg/dL|mmol/L preference. Auth via cookie or Bearer.",
|
||||
requestParams: {
|
||||
query: seriesQuerySchema,
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Resolved series.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(seriesResponse, "GetMeasurementsSeriesResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/measurements": {
|
||||
get: {
|
||||
tags: ["Measurements"],
|
||||
|
||||
@@ -215,7 +215,73 @@ const profileUpdateResponse = z
|
||||
"Profile echo returned by the PATCH/PUT update path. Reports KVNR presence as a boolean rather than re-emitting the decrypted value.",
|
||||
});
|
||||
|
||||
// v1.16.16 — AI-provider read surface. iOS gates Coach visibility off
|
||||
// `aiAvailable` + `managedBy` so a server-managed (operator-key) provider
|
||||
// is no longer invisible to the client. The response reports key PRESENCE
|
||||
// + a 4-char masked preview only; no plaintext key or admin endpoint is
|
||||
// ever surfaced. `managedBy` reports the resolved provider origin: `user`
|
||||
// (BYOK), `local` (self-hosted Ollama/LM Studio), `server` (operator's
|
||||
// shared key), or null when no provider can serve the caller.
|
||||
const aiProviderResponse = z
|
||||
.object({
|
||||
provider: z
|
||||
.enum(["OPENAI", "ANTHROPIC", "LOCAL", "CHATGPT_OAUTH"])
|
||||
.nullable()
|
||||
.describe("The user's selected provider, or null when none is set."),
|
||||
model: z.string().nullable(),
|
||||
baseUrl: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Custom base URL (LOCAL provider only); null otherwise."),
|
||||
aiAvailable: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when ANY provider can serve this user — including the operator's server-managed key when the user set none. iOS keys Coach visibility off this.",
|
||||
),
|
||||
managedBy: z
|
||||
.enum(["user", "local", "server"])
|
||||
.nullable()
|
||||
.describe(
|
||||
"Resolved provider origin: `user` (BYOK), `local` (self-hosted), `server` (operator key), or null when no provider is available.",
|
||||
),
|
||||
hasAnthropicKey: z.boolean(),
|
||||
anthropicKeyPreview: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("`...` + last 4 chars of the stored Anthropic key, or null."),
|
||||
hasLocalKey: z.boolean(),
|
||||
hasOpenaiKey: z.boolean(),
|
||||
openaiKeyPreview: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("`...` + last 4 chars of the stored OpenAI key, or null."),
|
||||
})
|
||||
.meta({
|
||||
id: "AiProviderResponse",
|
||||
description:
|
||||
"The calling user's AI-provider configuration plus the effective availability (`aiAvailable` + `managedBy`). Reports key presence + a masked preview only — never a plaintext key.",
|
||||
});
|
||||
|
||||
export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/user/ai-provider": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
summary: "Read the calling user's AI-provider configuration",
|
||||
description:
|
||||
"Returns the user's selected provider/model/baseUrl plus the effective availability: `aiAvailable` is true when any provider can serve the user (BYOK, self-hosted, or the operator's server-managed key), and `managedBy` reports which. iOS gates Coach visibility off these two fields. Key material is reported as presence + a 4-char masked preview only.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Resolved AI-provider configuration.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(aiProviderResponse, "GetAiProviderResponse"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/me/coach-prefs": {
|
||||
get: {
|
||||
tags: ["Auth"],
|
||||
|
||||
Reference in new issue
Block a user