mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
A bridge that pushes workouts from a watch vendor had no credential that reached POST /api/workouts/batch: the route called requireAuth() with no scope, so under the fail-closed default only a cookie session or a wildcard token got through, and a measurements:write token was refused 403. The route's header still described the pre-fail-closed behaviour. Add a separate workouts:write scope rather than widening measurements:write, so a token already handed to a scale does not start accepting workouts. - POST /api/tokens/workouts mints it, mirroring the measurement and document mints: cookie session plus a fresh proof, a literal permission array, 10 mints a minute, at most 10 live tokens (409 tokens.workouts.ceiling_reached), a one-year default lifetime, audited. - POST /api/workouts/batch names the scope. Sessions and wildcard tokens behave as before. A scoped caller's workouts are stored as EXTERNAL, resolved from the credential, and a batch in which any entry names a source is refused 422 (workout.batch.source_not_permitted), the same rule the measurement batch applies. EXTERNAL is on no source ladder, so when the phone recorded the same session the phone's copy is shown. - Settings > API offers a workout token card next to the measurement one, and the endpoint list shows the batch route. - Guards: the mint site, the declared scope vocabulary, the recent-proof routes, and a new check that only the batch declares the scope. - OpenAPI documents the mint, the batch's narrow-token behaviour and both error codes; docs/api/README.md lists the workout and document mints. Refs #1054
47 lines
1.9 KiB
TypeScript
47 lines
1.9 KiB
TypeScript
import { z } from "zod/v4";
|
|
|
|
/**
|
|
* Body of `POST /api/tokens/measurements` — mint a Bearer for third-party
|
|
* measurement ingest.
|
|
*
|
|
* There is deliberately no `scope` field. The MCP mint next door offers a
|
|
* closed two-value choice because its two shapes mean different things; this
|
|
* endpoint mints one shape and only one, so the request cannot express a scope
|
|
* at all. That is the strongest form of the no-mass-assignment rule: not a
|
|
* field validated against an allowlist, but a field that does not exist.
|
|
*
|
|
* `expiresInDays` is bounded rather than optional-and-unbounded because the
|
|
* credential is pasted into something that runs unattended. The route's default
|
|
* is deliberately long for the same reason — an automation that stops working
|
|
* silently is worse than one that stops working loudly — but a caller who wants
|
|
* a short-lived token can always ask for one.
|
|
*/
|
|
export const createMeasurementTokenSchema = z.object({
|
|
name: z.string().min(1, "Name required").max(100),
|
|
expiresInDays: z.number().int().min(1).max(365).optional(),
|
|
});
|
|
|
|
export type CreateMeasurementTokenInput = z.infer<
|
|
typeof createMeasurementTokenSchema
|
|
>;
|
|
|
|
/**
|
|
* Body of `POST /api/tokens/documents` — mint a Bearer that can upload
|
|
* documents and nothing else. The same shape as the measurement mint and for
|
|
* the same reasons: no scope field, a bounded lifetime.
|
|
*/
|
|
export const createDocumentTokenSchema = z.object({
|
|
name: z.string().min(1, "Name required").max(100),
|
|
expiresInDays: z.number().int().min(1).max(365).optional(),
|
|
});
|
|
|
|
/**
|
|
* Body of `POST /api/tokens/workouts` — mint a Bearer that can push workouts
|
|
* and nothing else (#1054). The same shape as the other two mints and for the
|
|
* same reasons: no scope field, a bounded lifetime.
|
|
*/
|
|
export const createWorkoutTokenSchema = z.object({
|
|
name: z.string().min(1, "Name required").max(100),
|
|
expiresInDays: z.number().int().min(1).max(365).optional(),
|
|
});
|