mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
Merge remote-tracking branch 'origin/main' into pr899
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
e961be81a2
7 files changed
+239
-3
No files matched your search
@@ -37,12 +37,17 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: { apiToken: { count: vi.fn() } },
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { requireCookieAuth } from "@/lib/api-handler";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
const USER = { id: "user-1", role: "USER" as const };
|
||||
@@ -62,6 +67,8 @@ beforeEach(() => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
// Below the ceiling by default; the cases that care set their own count.
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(issueApiToken).mockResolvedValue({
|
||||
token: "hlk_" + "a".repeat(64),
|
||||
expiresAt: EXPIRES,
|
||||
@@ -114,6 +121,25 @@ describe("what it mints", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("counts only live tokens carrying this scope", async () => {
|
||||
// The whole shape of the query, asserted rather than assumed. Counting
|
||||
// every row would let the `["*"]` tokens a login mints consume the budget
|
||||
// and refuse a mint for a reason the person cannot see; counting revoked
|
||||
// or expired rows would mean revoking never frees a slot.
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
|
||||
const where = vi.mocked(prisma.apiToken.count).mock.calls[0][0]?.where;
|
||||
expect(where).toMatchObject({
|
||||
userId: USER.id,
|
||||
revoked: false,
|
||||
permissions: { has: MEASUREMENTS_WRITE_SCOPE },
|
||||
});
|
||||
expect(where?.OR).toEqual([
|
||||
{ expiresAt: null },
|
||||
{ expiresAt: { gt: expect.any(Date) } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when it refuses", () => {
|
||||
@@ -145,6 +171,36 @@ describe("when it refuses", () => {
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("409s at the live-token ceiling, without minting", async () => {
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
|
||||
// 409, not 429: a conflict with the account's current state, whose remedy
|
||||
// is revoking rather than waiting. A 429 would say the opposite.
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).meta?.errorCode).toBe(
|
||||
"tokens.measurements.ceiling_reached",
|
||||
);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still mints on the last free slot", async () => {
|
||||
// The off-by-one that would make the ceiling nine. Worth pinning, because
|
||||
// `>=` versus `>` is invisible in review and only one of them is right.
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(9 as never);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
expect(res.status).toBe(201);
|
||||
});
|
||||
|
||||
it("checks the ceiling only after the body validates", async () => {
|
||||
// A malformed request should hear about the malformation, not the
|
||||
// ceiling — and should not spend a query establishing it.
|
||||
vi.mocked(prisma.apiToken.count).mockResolvedValue(10 as never);
|
||||
const res = await POST(req({}) as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.apiToken.count).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a scope the body tries to smuggle in", async () => {
|
||||
// There is no `scope` field to validate, so an unknown key is simply not
|
||||
// read — the permission array is a literal either way. Asserted because
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
@@ -62,6 +63,23 @@ const DEFAULT_EXPIRY_DAYS = 365;
|
||||
const MINT_RATE_LIMIT_MAX = 10;
|
||||
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* How many ingest tokens an account may hold at once.
|
||||
*
|
||||
* The rate limit above and this are different guards and neither substitutes
|
||||
* for the other. The bucket bounds a burst; it does nothing about
|
||||
* accumulation, and a stuck automation retrying politely inside its allowance
|
||||
* still reaches several hundred live credentials in an afternoon. This bounds
|
||||
* the standing set, so a runaway script meets a refusal rather than leaving a
|
||||
* thousand rows for somebody to revoke by hand.
|
||||
*
|
||||
* Ten is meant to be a number nobody legitimately reaches: a household running
|
||||
* Home Assistant, a scale bridge and a couple of scripts sits at three or four.
|
||||
* It is not a security boundary — the person can revoke and mint freely — so
|
||||
* it is set to catch a loop, not to ration.
|
||||
*/
|
||||
const MAX_LIVE_TOKENS = 10;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// Cookie-only, and the reason is lifetime rather than reach.
|
||||
//
|
||||
@@ -102,6 +120,56 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Counted LIVE, not minted-ever, so revoking frees a slot and an expired
|
||||
// token stops occupying one — the ceiling bounds what currently exists
|
||||
// rather than what the account has ever done. Scoped to this grant for the
|
||||
// same reason: a login mints a `["*"]` access token per session and those
|
||||
// accumulate on their own, so counting every row would let ordinary browser
|
||||
// use exhaust the budget and then refuse a mint for a reason invisible to
|
||||
// the person hitting it.
|
||||
//
|
||||
// Placed after validation and before the create: a request that is going to
|
||||
// be refused should not cost a credential, and one that is malformed should
|
||||
// hear about the malformation rather than the ceiling.
|
||||
//
|
||||
// Count-then-create is NOT atomic, and deliberately so. Two mints racing at
|
||||
// nine both read nine, both pass, and the account lands eleven. Nothing here
|
||||
// prevents that; what bounds it is the rate limit above, which is a single
|
||||
// `INSERT … ON CONFLICT DO UPDATE … RETURNING` (`lib/rate-limit.ts`) and so
|
||||
// admits at most `MINT_RATE_LIMIT_MAX` mints per user per window. The race
|
||||
// can overshoot the ceiling; it cannot run away from it.
|
||||
//
|
||||
// Left unserialised because of what this limit is for: catching a runaway
|
||||
// automation, not metering a quota anybody is billed against. The holder can
|
||||
// revoke and mint freely, so eleven tokens instead of ten costs nothing,
|
||||
// while a lock on a credential mint is a contention point on a write that
|
||||
// must not hang. If it ever does need to be exact, the house pattern is
|
||||
// `pg_advisory_xact_lock(hashtextextended(<key>, 0))` inside the
|
||||
// transaction — see `lib/managed-profiles/lifecycle.ts:47` or
|
||||
// `lib/integrations/oauth-refresh.ts:89`.
|
||||
const live = await prisma.apiToken.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
revoked: false,
|
||||
permissions: { has: MEASUREMENTS_WRITE_SCOPE },
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: new Date() } }],
|
||||
},
|
||||
});
|
||||
if (live >= MAX_LIVE_TOKENS) {
|
||||
annotate({
|
||||
action: { name: "tokens.measurements.create" },
|
||||
meta: { outcome: "ceiling_reached", live_token_count: live },
|
||||
});
|
||||
// 409 rather than 429: this is a conflict with the account's current
|
||||
// state, not a rate, and the remedy is to revoke one rather than to wait.
|
||||
// A 429 would tell the caller the opposite of what is true.
|
||||
return apiError(
|
||||
`You already have ${MAX_LIVE_TOKENS} measurement tokens. Revoke one before creating another.`,
|
||||
409,
|
||||
{ errorCode: "tokens.measurements.ceiling_reached" },
|
||||
);
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: parsed.data.name,
|
||||
|
||||
@@ -1620,7 +1620,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it.\n\n" +
|
||||
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record, attributing `source: MANUAL`. An entry naming `APPLE_HEALTH` is refused 422 rather than relabelled: that source is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint.\n\n" +
|
||||
"**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" +
|
||||
"Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other.",
|
||||
"Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -1671,6 +1671,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"More than 10 mints from this account in a minute. Nothing was minted.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The account already holds 10 live measurement tokens (`meta.errorCode` = `tokens.measurements.ceiling_reached`). Nothing was minted. Distinct from the 429 above: that one is a rate and clears by waiting, this one is a conflict with the account's current state and clears by revoking a token at `DELETE /api/tokens/{id}`. Only LIVE tokens count — revoked and expired rows do not occupy a slot — and only those carrying `measurements:write`, so the wildcard tokens a sign-in mints never consume the budget.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user