feat(api): publish the v1.43 contract with routes that answer 501 until filled

Every new endpoint of the contract exists, wrapped in apiHandler, with its
authentication, module gate and request validation, and answers
501 not_implemented until its behaviour lands. The OpenAPI document carries
the new operations and schemas and the additive response fields on the
changed routes. The four server feature flags are published as false.

contract-stub-inventory.test.ts registers the stubbed routes; each one
leaves the register with the change that fills it. The route, sharing,
session, security, module-gate and outcome inventories name the new routes
deliberately, and the error-code catalogue lists the codes the routes emit
today.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 21:49:02 +02:00
1 parent 6e72de1c35
commit 3786deb503
74 files changed
+6614 -282

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
/**
* v1.43 (#1217) — AI usage in three levels.
*
* The wire spells a level in lower case; the database enum (`AiUsageLevel`)
* in upper case. The order of {@link AI_USAGE_LEVELS} is the ceiling order:
* a level is allowed when it is at or below the operator's `maxLevel`.
*/
import { z } from "zod/v4";
export const AI_USAGE_LEVELS = ["frugal", "standard", "full"] as const;
export type AiUsageLevelWire = (typeof AI_USAGE_LEVELS)[number];
export const aiUsageLevelSchema = z.enum(AI_USAGE_LEVELS);
/** `PUT /api/auth/me/ai-usage`: the account's own choice; `null` follows the instance default. */
export const aiUsagePutSchema = z.strictObject({
level: aiUsageLevelSchema.nullable(),
});
export type AiUsagePutInput = z.infer<typeof aiUsagePutSchema>;
/** The `aiUsage` block of `PUT /api/admin/settings/assistant-flags`. */
export const adminAiUsageSchema = z.strictObject({
maxLevel: aiUsageLevelSchema,
defaultLevel: aiUsageLevelSchema,
syncRegenGapHours: z.number().int().min(1).max(48).nullable(),
});
export type AdminAiUsageInput = z.infer<typeof adminAiUsageSchema>;
+45
View File
@@ -0,0 +1,45 @@
/**
* v1.43 (D1223) — merging biomarkers. `{id}` in the path is the marker that
* stays; `sourceIds` are folded into it. That the target is not among the
* sources is checked by the route, which knows the path id.
*/
import { z } from "zod/v4";
export const BIOMARKER_MERGE_MAX_SOURCES = 10;
export const biomarkerMergeSchema = z
.strictObject({
sourceIds: z
.array(z.string().trim().min(1).max(64))
.min(1)
.max(BIOMARKER_MERGE_MAX_SOURCES),
dryRun: z.boolean().optional().default(false),
})
.superRefine((value, ctx) => {
if (new Set(value.sourceIds).size !== value.sourceIds.length) {
ctx.addIssue({
code: "custom",
path: ["sourceIds"],
message: "sourceIds must be unique",
});
}
});
export type BiomarkerMergeInput = z.infer<typeof biomarkerMergeSchema>;
export const BIOMARKER_MERGE_CONVERSIONS = [
"none",
"converted",
"refused",
] as const;
export const BIOMARKER_MERGE_REFUSALS = [
"unknown_analyte",
"unknown_unit",
"not_finite",
] as const;
/** Where an alias came from (`biomarker_aliases.source`). */
export const BIOMARKER_ALIAS_SOURCES = [
"MERGE",
"IMPORT_CHOICE",
"MANUAL",
] as const;
+17
View File
@@ -0,0 +1,17 @@
/**
* v1.43 (#959) — claiming a managed profile through single sign-on. The
* handover token and the chosen username ride a sealed cookie into the OIDC
* round trip; the token never rides a URL.
*/
import { z } from "zod/v4";
import { accountUsernameSchema } from "@/lib/validations/auth";
export const claimSsoSchema = z.strictObject({
token: z.string().min(1).max(80),
username: accountUsernameSchema,
});
export type ClaimSsoInput = z.infer<typeof claimSsoSchema>;
/** How a handover link can be redeemed on this server. */
export const HANDOVER_METHODS = ["password", "sso"] as const;
+48
View File
@@ -0,0 +1,48 @@
/**
* v1.43 — Dawarich as movement context (daily values only). Part of the
* `environment` module; every route is the caller's own record.
*/
import { z } from "zod/v4";
const dayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD");
export const dawarichConnectSchema = z.strictObject({
url: z
.string()
.trim()
.max(2048)
.url()
.refine((value) => /^https?:\/\//i.test(value), "Use http or https"),
apiKey: z.string().trim().min(1).max(512),
});
export type DawarichConnectInput = z.infer<typeof dawarichConnectSchema>;
export const dawarichPlacesQuerySchema = z.strictObject({
q: z.string().trim().max(80).optional(),
});
export const dawarichSettingsSchema = z
.strictObject({
homePlaceId: z.number().int().positive().nullable().optional(),
homeCountry: z
.string()
.regex(/^[A-Z]{2}$/, "ISO 3166-1 alpha-2")
.nullable()
.optional(),
})
.refine(
(value) =>
value.homePlaceId !== undefined || value.homeCountry !== undefined,
"Send homePlaceId, homeCountry or both",
);
export type DawarichSettingsInput = z.infer<typeof dawarichSettingsSchema>;
/** At most 365 days back, checked by the route against the account's today. */
export const DAWARICH_SYNC_MAX_DAYS = 365;
export const dawarichSyncSchema = z.strictObject({
fromDate: dayKey.optional(),
});
export const dawarichDisconnectSchema = z.strictObject({
deleteData: z.boolean().optional().default(false),
});
+221
View File
@@ -0,0 +1,221 @@
/**
* v1.43 (#614) — life circumstances: six sections of dated, sealed,
* self-reported context, and AUDIT-C for the alcohol section.
*
* Each new fact kind stores a JSON object with `schema: 1`; the kinds are
* deliberately NOT in `HEALTH_PROFILE_FACT_KINDS` (health-profile-facts.ts),
* which is what `GET /api/anamnesis/facts` lists: older app builds decode that
* enum closed. They are read through `GET /api/anamnesis/life-circumstances`.
* Every question allows "no answer" (`null`, or an empty list).
*/
import { z } from "zod/v4";
import { assertStableExternalId } from "@/lib/validations/external-id";
// ── Fact kinds and their values ──────────────────────────────────────────────
export const LIFE_FACT_KINDS = [
"TOBACCO_DETAIL",
"ACTIVITY_HABIT",
"WORK_CONTEXT",
"FREE_WEEKDAYS",
"HABITS",
"HEALTH_GOALS",
] as const;
export type LifeFactKind = (typeof LIFE_FACT_KINDS)[number];
const schemaVersion = z.literal(1);
const currentYear = () => new Date().getUTCFullYear();
export const TOBACCO_PRODUCTS = [
"cigarettes",
"cigars_pipe",
"e_cigarette",
"heated_tobacco",
"snus_pouches",
] as const;
export const tobaccoDetailValueSchema = z
.strictObject({
schema: schemaVersion,
products: z.array(z.enum(TOBACCO_PRODUCTS)).max(TOBACCO_PRODUCTS.length),
cigarettesPerDay: z.number().int().min(1).max(100).nullable(),
startYear: z
.number()
.int()
.min(1900)
.refine((year) => year <= currentYear(), "Not in the future")
.nullable(),
quitMonth: z
.string()
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Use YYYY-MM")
.nullable(),
secondhandSmoke: z.boolean().nullable(),
})
.refine(
(value) =>
value.cigarettesPerDay === null || value.products.includes("cigarettes"),
{
path: ["cigarettesPerDay"],
message: "Only with cigarettes",
},
);
export const activityHabitValueSchema = z.strictObject({
schema: schemaVersion,
moderateDaysPerWeek: z.number().int().min(0).max(7).nullable(),
moderateMinutesPerDay: z.number().int().min(0).max(600).nullable(),
sittingHoursWorkday: z.number().min(0).max(24).multipleOf(0.5).nullable(),
});
export const WORK_SITUATIONS = ["none", "indoor", "outdoor", "mixed"] as const;
export const WORK_PHYSICAL_NATURE = [
"mostly_sitting",
"standing_walking",
"heavy_physical",
] as const;
export const WORK_EXPOSURES = [
"heat",
"cold",
"dust",
"fumes_solvents",
"noise",
] as const;
export const workContextValueSchema = z.strictObject({
schema: schemaVersion,
situation: z.enum(WORK_SITUATIONS).nullable(),
physicalNature: z.enum(WORK_PHYSICAL_NATURE).nullable(),
nightWork: z.boolean().nullable(),
exposures: z.array(z.enum(WORK_EXPOSURES)).max(WORK_EXPOSURES.length),
});
export const freeWeekdaysValueSchema = z.strictObject({
schema: schemaVersion,
/** ISO weekdays, 1 = Monday. */
days: z.array(z.number().int().min(1).max(7)).max(7),
});
export const DIET_PATTERNS = [
"mixed",
"vegetarian",
"vegan",
"pescetarian",
"other",
] as const;
export const DIET_SPECIFICS = [
"intermittent_fasting",
"low_carb",
"prescribed_diet",
] as const;
export const LATEST_CAFFEINE = [
"before_12",
"12_15",
"15_18",
"after_18",
] as const;
export const habitsValueSchema = z.strictObject({
schema: schemaVersion,
napRegularly: z.boolean().nullable(),
snoringReported: z.boolean().nullable(),
dietPattern: z.enum(DIET_PATTERNS).nullable(),
dietSpecifics: z.array(z.enum(DIET_SPECIFICS)).max(DIET_SPECIFICS.length),
lateMealsPerWeek: z.number().int().min(0).max(7).nullable(),
fruitVegPortionsPerDay: z.number().int().min(0).max(15).nullable(),
caffeineDrinksPerDay: z.number().int().min(0).max(20).nullable(),
latestCaffeine: z.enum(LATEST_CAFFEINE).nullable(),
});
export const HEALTH_GOALS = [
"weight",
"blood_pressure",
"blood_sugar",
"sleep",
"fitness",
"stress_recovery",
"medication_routine",
"quit_smoking",
"less_alcohol",
] as const;
export const healthGoalsValueSchema = z.strictObject({
schema: schemaVersion,
goals: z.array(z.enum(HEALTH_GOALS)).max(3),
});
/** The value schema per kind, the one map every writer and reader uses. */
export const LIFE_FACT_VALUE_SCHEMAS = {
TOBACCO_DETAIL: tobaccoDetailValueSchema,
ACTIVITY_HABIT: activityHabitValueSchema,
WORK_CONTEXT: workContextValueSchema,
FREE_WEEKDAYS: freeWeekdaysValueSchema,
HABITS: habitsValueSchema,
HEALTH_GOALS: healthGoalsValueSchema,
} as const satisfies Record<LifeFactKind, z.ZodType>;
// ── Sections ─────────────────────────────────────────────────────────────────
export const LIFE_SECTION_KEYS = [
"tobacco",
"alcohol",
"activity",
"work",
"habits",
"goals",
] as const;
export type LifeSectionKey = (typeof LIFE_SECTION_KEYS)[number];
/** The AI sections the life-circumstances surface switches. */
export const LIFE_AI_SECTIONS = [
"SMOKING_STATUS",
"ALCOHOL_PATTERN",
"SHIFT_SCHEDULE",
"ACTIVITY",
"WORK",
"HABITS",
"GOALS",
] as const;
export type LifeAiSection = (typeof LIFE_AI_SECTIONS)[number];
/** `PATCH /api/anamnesis/life-circumstances/ai-sections`. */
export const lifeAiSectionsPatchSchema = z
.strictObject({
sections: z.partialRecord(z.enum(LIFE_AI_SECTIONS), z.boolean()),
baseUpdatedAt: z.iso.datetime({ offset: true }).optional(),
})
.refine((value) => Object.keys(value.sections).length > 0, {
path: ["sections"],
message: "At least one section",
});
export type LifeAiSectionsPatchInput = z.infer<
typeof lifeAiSectionsPatchSchema
>;
/** `POST /api/anamnesis/life-circumstances/confirm`. */
export const lifeConfirmSchema = z.strictObject({
section: z.enum(LIFE_SECTION_KEYS),
});
// ── AUDIT-C ──────────────────────────────────────────────────────────────────
const auditCItem = z.number().int().min(0).max(4);
/** `POST /api/anamnesis/audit-c`. */
export const auditCRequestSchema = z
.strictObject({
items: z.tuple([auditCItem, auditCItem, auditCItem]),
takenAt: z.iso.datetime({ offset: true }).optional(),
tz: z.string().min(1).max(64).optional(),
locale: z.string().min(2).max(16).optional(),
source: z.enum(["WEB", "IOS"]),
externalId: z.string().min(1).max(128).optional(),
})
.superRefine(assertStableExternalId);
export type AuditCRequestInput = z.infer<typeof auditCRequestSchema>;
export const AUDIT_C_CUTOFF_SOURCES = [
"S3_ALCOHOL_DE",
"KNHANES_KR",
"BRADLEY_2007",
] as const;
@@ -0,0 +1,52 @@
/**
* v1.43 — a supplement's composition per unit (tablet, capsule).
*
* The shape is checked here; whether `nutrient` is a catalogue code and
* `unit` one of its input units is checked against the nutrient catalogue by
* the route (422 `medications.ingredients.unitMismatch` carries the index and
* the allowed units), so the catalogue stays the one place units live.
*/
import { z } from "zod/v4";
export const MEDICATION_INGREDIENTS_MAX = 40;
export const MEDICATION_INGREDIENT_SOURCES = ["MANUAL", "LABEL_AI"] as const;
export const medicationIngredientInputSchema = z.strictObject({
nutrient: z.string().trim().min(1).max(40),
amount: z.number().finite().positive(),
unit: z.string().trim().min(1).max(8),
});
export const medicationIngredientsPutSchema = z
.strictObject({
ingredients: z
.array(medicationIngredientInputSchema)
.max(MEDICATION_INGREDIENTS_MAX),
source: z.enum(MEDICATION_INGREDIENT_SOURCES).optional().default("MANUAL"),
})
.superRefine((value, ctx) => {
const seen = new Set<string>();
value.ingredients.forEach((ingredient, index) => {
if (seen.has(ingredient.nutrient)) {
ctx.addIssue({
code: "custom",
path: ["ingredients", index, "nutrient"],
message: "Each nutrient may appear once",
});
}
seen.add(ingredient.nutrient);
});
});
export type MedicationIngredientsPutInput = z.infer<
typeof medicationIngredientsPutSchema
>;
/** Label photo upload limits for `POST /api/medications/{id}/ingredients/extract`. */
export const SUPPLEMENT_LABEL_MAX_BYTES = 10 * 1024 * 1024;
export const SUPPLEMENT_LABEL_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/heic",
"image/webp",
"application/pdf",
] as const;
+64 -1
View File
@@ -88,6 +88,36 @@ export const nutrientOverviewQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(14),
});
/**
* v1.43 — the tolerable upper intake level of a nutrient (EFSA), applied by
* the server to the day's total or to the supplemental share as `scope` says.
*/
export const nutrientUpperLimitSchema = z
.object({
value: z.number(),
unit: z.string(),
scope: z.enum(["total", "supplemental"]),
source: z.string(),
})
.meta({
id: "NutrientUpperLimit",
description:
"The tolerable upper intake level, with the unit, whether it applies to the total intake or to the supplemental share only, and the cited source. `null` where no level is established.",
});
/** v1.43 — a day total split by where it came from. */
export const nutrientBySourceSchema = z
.object({
APPLE_HEALTH: z.number().optional(),
MANUAL: z.number().optional(),
SUPPLEMENT: z.number().optional(),
})
.meta({
id: "NutrientAmountBySource",
description:
"The amount split by source: synced from Apple Health, entered by hand, and counted from taken supplement doses. A source with nothing is absent.",
});
export const nutrientOverviewSchema = z
.object({
windowDays: z.number().int().min(1),
@@ -98,6 +128,20 @@ export const nutrientOverviewSchema = z
latestDay: z.string(),
latestAmount: z.number(),
daysWithData: z.number().int().min(1),
latestBySource: nutrientBySourceSchema
.optional()
.describe("v1.43 — `latestAmount` split by source."),
upperLimit: nutrientUpperLimitSchema
.nullable()
.optional()
.describe("v1.43 — the nutrient's upper intake level, or null."),
latestAboveUpperLimit: z
.boolean()
.nullable()
.optional()
.describe(
"v1.43 — whether the latest day is above the upper limit; null without one.",
),
}),
),
lastAttempt: z
@@ -177,8 +221,27 @@ export const nutrientDailySeriesSchema = z
nutrient: nutrientCodeEnum,
unit: z.string(),
windowDays: z.number().int().min(1),
days: z.array(z.object({ day: z.string(), amount: z.number() })),
days: z.array(
z.object({
day: z.string(),
amount: z.number(),
bySource: nutrientBySourceSchema
.optional()
.describe("v1.43 — the day's amount split by source."),
aboveUpperLimit: z
.boolean()
.nullable()
.optional()
.describe(
"v1.43 — whether the day is above the upper limit; null without one.",
),
}),
),
reference: resolvedNutrientReferenceSchema.nullable(),
upperLimit: nutrientUpperLimitSchema
.nullable()
.optional()
.describe("v1.43 — the nutrient's upper intake level, or null."),
})
.meta({
id: "NutrientDailySeries",
+14
View File
@@ -0,0 +1,14 @@
/**
* v1.43 (#1107 follow-up) — the admin pulse day-mean dry run and apply step.
*/
import { z } from "zod/v4";
export const PULSE_DAY_MEAN_METHODS = [
"hour_mean_v1",
"weighted_hour_mean_v2",
] as const;
export const pulseDayMeanActionSchema = z.strictObject({
action: z.enum(["dry_run", "apply"]),
});
export type PulseDayMeanActionInput = z.infer<typeof pulseDayMeanActionSchema>;
+39
View File
@@ -0,0 +1,39 @@
/**
* v1.43 — the relation links that close the relation matrix. Same body and
* answer as the existing link routes (`{ targetKind, targetIds }` in,
* `{ changed, unknown }` out).
*/
import { z } from "zod/v4";
const targetIds = z.array(z.string().trim().min(1).max(64)).min(1).max(100);
/** `POST/DELETE /api/medications/{id}/links`. */
export const MEDICATION_LINK_TARGETS = [
"conditionEpisode",
"document",
"encounter",
] as const;
export const medicationLinkSchema = z.strictObject({
targetKind: z.enum(MEDICATION_LINK_TARGETS),
targetIds,
});
export type MedicationLinkInput = z.infer<typeof medicationLinkSchema>;
/** `POST/DELETE /api/labs/{id}/links`. */
export const LAB_RESULT_LINK_TARGETS = ["document", "encounter"] as const;
export const labResultLinkSchema = z.strictObject({
targetKind: z.enum(LAB_RESULT_LINK_TARGETS),
targetIds,
});
export type LabResultLinkInput = z.infer<typeof labResultLinkSchema>;
/** `related[]` on the six detail responses. */
export const RELATED_KINDS = [
"labResult",
"encounter",
"document",
"conditionEpisode",
"medication",
"vaccination",
] as const;
export const RELATED_VIA = ["direct", "encounter", "efficacyTarget"] as const;