mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
feat(tokens): mint a workouts:write token for headless workout ingest
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
This commit is contained in:
1 parent
ae6a5ac693
commit
e7166cb3c6
25 files changed
+1340
-32
No files matched your search
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Who may call `POST /api/workouts/batch` (#1054).
|
||||
*
|
||||
* The route names `workouts:write`, so the set of credentials it admits is:
|
||||
* a cookie session, a wildcard (`["*"]`) token, and a narrow token carrying
|
||||
* exactly that scope. Every other narrow token — `measurements:write`
|
||||
* included, however adjacent it looks — is refused 403 by the fail-closed
|
||||
* default.
|
||||
*
|
||||
* The real `requireAuth` and the real `resolveBearerToken` run here; only the
|
||||
* token row lookup is stubbed, so the admission decision under test is the
|
||||
* production one rather than a re-implementation of it.
|
||||
*
|
||||
* The second half pins what a scoped caller writes: its rows are `EXTERNAL`,
|
||||
* resolved from the credential, and a body naming any source is refused 422
|
||||
* rather than relabelled. A session and a wildcard token keep the source they
|
||||
* send.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
apiToken: { findUnique: vi.fn(), update: vi.fn() },
|
||||
user: { findUnique: vi.fn() },
|
||||
workout: {
|
||||
findMany: vi.fn(),
|
||||
createManyAndReturn: vi.fn(),
|
||||
},
|
||||
workoutRoute: { createMany: vi.fn() },
|
||||
workoutSamples: { createManyAndReturn: vi.fn() },
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
if (typeof fn === "function") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (fn as any)(prisma as unknown as { workout: unknown });
|
||||
}
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
|
||||
vi.mock("@/lib/jobs/pr-detection", () => ({
|
||||
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/arrivals/emit-shared", () => ({
|
||||
emitDataArrival: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../batch/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { headers } from "next/headers";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { DOCUMENTS_WRITE_SCOPE } from "@/lib/documents/scopes";
|
||||
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
|
||||
|
||||
const USER = {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
sourcePriorityJson: null,
|
||||
};
|
||||
const TOKEN = "hlk_" + "c".repeat(64);
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/workouts/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function workout(
|
||||
externalId: string,
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
sportType: "running",
|
||||
startedAt: "2026-09-20T06:30:00.000Z",
|
||||
endedAt: "2026-09-20T07:15:00.000Z",
|
||||
externalId,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Present `TOKEN` as a Bearer whose row carries `permissions`. */
|
||||
function armToken(permissions: string[]) {
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
vi.mocked(headers).mockResolvedValue({
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "authorization" ? `Bearer ${TOKEN}` : null,
|
||||
} as never);
|
||||
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
|
||||
id: "tok-1",
|
||||
userId: USER.id,
|
||||
permissions,
|
||||
revoked: false,
|
||||
expiresAt: new Date(Date.now() + 86_400_000),
|
||||
} as never);
|
||||
}
|
||||
|
||||
function armSession() {
|
||||
vi.mocked(getSession).mockResolvedValue({
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: USER,
|
||||
} as never);
|
||||
}
|
||||
|
||||
/** The rows the route handed to `INSERT ... RETURNING`. */
|
||||
function insertedRows(): Array<Record<string, unknown>> {
|
||||
return vi
|
||||
.mocked(prisma.workout.createManyAndReturn)
|
||||
.mock.calls.flatMap(([args]) => {
|
||||
const data = (args as { data: unknown }).data;
|
||||
return (Array.isArray(data) ? data : [data]) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.stubEnv("API_TOKEN_HMAC_KEY", "k".repeat(64));
|
||||
vi.mocked(headers).mockResolvedValue({ get: () => null } as never);
|
||||
vi.mocked(getSession).mockResolvedValue(null as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
limit: 60,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
|
||||
// The refusal path chains `.catch` on the audit write, so the reset mock
|
||||
// has to hand back a promise again.
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
// One row serves both lookups: the resolver's user fetch and the route's
|
||||
// source-priority read.
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(USER as never);
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.workout.createManyAndReturn).mockImplementation(
|
||||
(async (args: { data: Array<Record<string, unknown>> }) =>
|
||||
args.data.map((row, index) => ({
|
||||
id: `w-${index}`,
|
||||
source: row.source,
|
||||
externalId: row.externalId,
|
||||
}))) as never,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — which credentials it admits", () => {
|
||||
it("admits a narrow workouts:write token", async () => {
|
||||
armToken([WORKOUTS_WRITE_SCOPE]);
|
||||
const res = await POST(makeRequest({ workouts: [workout("s-1")] }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.data.inserted).toBe(1);
|
||||
});
|
||||
|
||||
it("still admits a cookie session", async () => {
|
||||
armSession();
|
||||
const res = await POST(
|
||||
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it("still admits a wildcard token", async () => {
|
||||
armToken(["*"]);
|
||||
const res = await POST(
|
||||
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["measurements:write", [MEASUREMENTS_WRITE_SCOPE]],
|
||||
["documents:write", [DOCUMENTS_WRITE_SCOPE]],
|
||||
["medication:ingest", ["medication:ingest", "medication:m-1:ingest"]],
|
||||
["health:read", ["health:read"]],
|
||||
])("refuses a %s token with 403 and writes nothing", async (_, perms) => {
|
||||
armToken(perms);
|
||||
const res = await POST(makeRequest({ workouts: [workout("s-1")] }));
|
||||
expect(res.status).toBe(403);
|
||||
expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — what a scoped caller writes", () => {
|
||||
it("attributes its rows to EXTERNAL", async () => {
|
||||
armToken([WORKOUTS_WRITE_SCOPE]);
|
||||
const res = await POST(
|
||||
makeRequest({ workouts: [workout("s-1"), workout("s-2")] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const rows = insertedRows();
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((r) => r.source)).toEqual(["EXTERNAL", "EXTERNAL"]);
|
||||
});
|
||||
|
||||
it("probes for duplicates in the EXTERNAL namespace", async () => {
|
||||
armToken([WORKOUTS_WRITE_SCOPE]);
|
||||
await POST(makeRequest({ workouts: [workout("s-1")] }));
|
||||
const probe = vi
|
||||
.mocked(prisma.workout.findMany)
|
||||
.mock.calls.map(([args]) => args as { where?: { OR?: unknown } })
|
||||
.find((args) => args.where?.OR !== undefined);
|
||||
expect(probe?.where?.OR).toEqual([
|
||||
{ source: "EXTERNAL", externalId: "s-1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each(["APPLE_HEALTH", "MANUAL"])(
|
||||
"refuses a body naming %s with 422 and writes nothing",
|
||||
async (source) => {
|
||||
armToken([WORKOUTS_WRITE_SCOPE]);
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [workout("s-1"), workout("s-2", { source })],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const body = await res.json();
|
||||
expect(body.meta?.errorCode).toBe("workout.batch.source_not_permitted");
|
||||
expect(prisma.workout.createManyAndReturn).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves the source a wildcard caller sends alone", async () => {
|
||||
armToken(["*"]);
|
||||
await POST(
|
||||
makeRequest({ workouts: [workout("s-1", { source: "APPLE_HEALTH" })] }),
|
||||
);
|
||||
expect(insertedRows().map((r) => r.source)).toEqual(["APPLE_HEALTH"]);
|
||||
});
|
||||
|
||||
it("leaves the source a session sends alone", async () => {
|
||||
armSession();
|
||||
await POST(
|
||||
makeRequest({ workouts: [workout("s-1", { source: "MANUAL" })] }),
|
||||
);
|
||||
expect(insertedRows().map((r) => r.source)).toEqual(["MANUAL"]);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* `POST /api/workouts/batch` — typed workout ingest with nested route.
|
||||
*
|
||||
* Consumers (v1.5):
|
||||
* Consumers:
|
||||
* 1. The native iOS app draining its HealthKit observer queue —
|
||||
* maps `HKWorkout` + `HKWorkoutRoute` samples into the request
|
||||
* shape locked by `createBatchWorkoutSchema` in
|
||||
@@ -15,12 +15,18 @@
|
||||
* (MANUAL / APPLE_HEALTH), so this server path must write its
|
||||
* `WITHINGS`-sourced rows directly via `prisma.workout.upsert`
|
||||
* (as the WHOOP / Fitbit syncs already do), not through this route.
|
||||
* 3. A third-party bridge holding a narrow `workouts:write` token
|
||||
* (#1054) — a watch vendor's sync relayed by a small service. Its
|
||||
* rows are attributed `EXTERNAL`, resolved from the credential; see
|
||||
* the source guard in the handler.
|
||||
*
|
||||
* Cross-cutting concerns mirror `/api/measurements/batch` so the iOS
|
||||
* sync engine can re-use the same retry / cursor plumbing:
|
||||
* - `requireAuth()` — cookie + Bearer (narrow-scope-token safe per
|
||||
* v1.4.25 W10 fix-C: when the route declares no scope, any
|
||||
* authenticated token passes).
|
||||
* - `requireAuth` naming `WORKOUTS_WRITE_SCOPE` — a cookie session, a
|
||||
* wildcard (`["*"]`) token, or a narrow `workouts:write` token. Every
|
||||
* other narrow token is refused 403: under the fail-closed default a
|
||||
* route admits exactly the scope it names, so a `measurements:write`
|
||||
* credential does not reach this route.
|
||||
* - `withIdempotency` — `Idempotency-Key` header replays the cached
|
||||
* response on retry (24h window).
|
||||
* - `checkRateLimit("workouts:batch:${user.id}", 60, 60s)` — same
|
||||
@@ -59,7 +65,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -85,6 +91,8 @@ import {
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { dedupeWorkoutBatch } from "@/lib/workouts/canonical-rows";
|
||||
import { WORKOUTS_WRITE_SCOPE } from "@/lib/workouts/scopes";
|
||||
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
|
||||
import { encryptRouteGeometry } from "@/lib/workouts/route-geometry-cipher";
|
||||
import { Prisma, type MeasurementSource } from "@/generated/prisma/client";
|
||||
|
||||
@@ -169,7 +177,17 @@ async function emitWorkoutArrivals(
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
|
||||
async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// Declares the workout-ingest scope, so a narrow `workouts:write` token
|
||||
// reaches this route alongside cookie sessions and the wildcard tokens a
|
||||
// native login mints. The only route that names it. `requireAuth` refuses
|
||||
// any acting-account carrier for every credential shape, so the scoped token
|
||||
// is confined to its owner's own record without an arm of its own here.
|
||||
const auth = await requireAuth(WORKOUTS_WRITE_SCOPE);
|
||||
const { user } = auth;
|
||||
|
||||
// A scoped credential is a bridge, not the native client: the source of
|
||||
// its rows is resolved from the credential below rather than from the body.
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// v1.4.43 W9 — resolve the per-user source-priority blob once at
|
||||
// the start of the handler so the write-time canonical-row picker
|
||||
@@ -268,6 +286,27 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
});
|
||||
}
|
||||
|
||||
// A scoped credential may attribute nothing: its rows are `EXTERNAL`,
|
||||
// resolved from the credential. Refused loudly rather than relabelled, the
|
||||
// measurement batch's rule and for its reasons. `APPLE_HEALTH` is half the
|
||||
// `(userId, source, externalId)` dedup key the phone writes into and ranks
|
||||
// first in the cross-source picker, so a bridge naming it would land in the
|
||||
// phone's namespace and could shadow the phone's own copy of a session;
|
||||
// `MANUAL` would make a bridged workout indistinguishable from one entered
|
||||
// by hand. Checked on the raw body because the schema defaults an absent
|
||||
// `source` to `MANUAL`, which would make "named" and "omitted" look alike.
|
||||
if (scoped && namesAnySource(rawBody)) {
|
||||
annotate({
|
||||
action: { name: "workout.batch.ingest" },
|
||||
meta: { outcome: "source_not_permitted" },
|
||||
});
|
||||
return apiError(
|
||||
"This credential resolves the source itself; omit the field",
|
||||
422,
|
||||
{ errorCode: "workout.batch.source_not_permitted" },
|
||||
);
|
||||
}
|
||||
|
||||
const { workouts } = parsed.data;
|
||||
const refusedSampleIndices = new Set(parsed.refusedSampleIndices);
|
||||
|
||||
@@ -359,7 +398,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
stepCount: w.stepCount ?? null,
|
||||
elevationM: w.elevationM ?? null,
|
||||
pauseDurationSec: w.pauseDurationSec ?? null,
|
||||
source: w.source,
|
||||
// `EXTERNAL` under a scoped credential, and nothing else: the guard
|
||||
// above refused any body that named a source, so nothing asserted is
|
||||
// discarded here. It also moves the dedup key into its own namespace,
|
||||
// so a bridge's ids can never collide with the phone's.
|
||||
source: scoped ? EXTERNAL_SOURCE : w.source,
|
||||
externalId: w.externalId ?? null,
|
||||
externalSourceVersion: w.externalSourceVersion ?? null,
|
||||
metadata: w.metadata
|
||||
@@ -373,7 +416,10 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
route,
|
||||
samples,
|
||||
dedupKey: w.externalId
|
||||
? { source: w.source, externalId: w.externalId }
|
||||
? {
|
||||
source: scoped ? EXTERNAL_SOURCE : w.source,
|
||||
externalId: w.externalId,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
];
|
||||
@@ -719,3 +765,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
entries: results,
|
||||
});
|
||||
}
|
||||
|
||||
/** Whether any entry of a raw batch body names a `source` at all. */
|
||||
function namesAnySource(rawBody: unknown): boolean {
|
||||
if (typeof rawBody !== "object" || rawBody === null) return false;
|
||||
const workouts = (rawBody as { workouts?: unknown }).workouts;
|
||||
if (!Array.isArray(workouts)) return false;
|
||||
return workouts.some(
|
||||
(w) =>
|
||||
typeof w === "object" &&
|
||||
w !== null &&
|
||||
(w as { source?: unknown }).source !== undefined,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user