mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 15:02:04 +00:00
Add a self-service measurements:write API token scope
Since v1.30.17 there has been no self-service path to a token that can write a measurement. That release made the Bearer scope default fail-closed, which closed a real privilege-escalation hole, and removed the generic mint at POST /api/tokens along with it. The generic mint had been the only way to get a token that could reach /api/measurements, so a local sensor pushed through Home Assistant or Node-RED lost its door while the OAuth integrations kept theirs. Add a narrow scope accepted by exactly two routes, both writes, both on the holder's own record. The scope constant is a leaf module beside the surface that consumes it, matching FHIR_READ_SCOPE and the MCP scopes. requireRecordAuth grows an options object rather than a sibling function, because the sharing surface guard keys every leg on that one identifier and a second name would mean two copies of the delegable declaration. AuthContext carries the admitting scope as a single nullable string rather than the token's permission array, so nothing downstream can re-derive an authorisation decision from it. A scoped credential is refused a record selector before any grant is read. Placing the refusal above the sharing check rather than beside it means no future widening of what a grant permits can reach one of these tokens. Rows are attributed MANUAL. An entry that explicitly names APPLE_HEALTH is refused 422 rather than relabelled: that source is half the (userId, type, source, externalId) dedup key the iOS client writes into, it participates in the cross-source merge, and it gates the HealthKit sync fields. For the same reason a scoped write no longer advances User.lastSyncedAt, which the phone reads to size its undelivered window. Also closes a narrower gap found while building this. The idempotency wrapper resolved a replayed delegated response against a live grant without considering which credential was presenting it, so a cached response about a shared record could be returned to a token the handler itself would refuse. No write was reachable that way; the response body was. Guards: T3 gains the mint site, T4's vocabulary gains the scope and learns to read the requireRecordAuth form (without which this change would open a way to widen a route that the closed vocabulary could not see), and a new T5 freezes the set of delegable routes naming a scope. The sharing surface guard's AST matcher reads only the first two arguments, so it needed prose but no code.
This commit is contained in:
1 parent
4308114c5b
commit
cba7a0fce5
26 files changed
+1704
-34
No files matched your search
@@ -38,7 +38,8 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
@@ -218,7 +219,18 @@ interface EntryResult {
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
|
||||
async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
// Declares the third-party ingest scope, so a narrow `measurements:write`
|
||||
// token reaches this route alongside cookie sessions and the wildcard tokens
|
||||
// a native login mints. `requireAuth` refuses any acting-account carrier for
|
||||
// every credential shape, so the scoped token is confined to its owner's own
|
||||
// record here without this route adding an arm of its own.
|
||||
const auth = await requireAuth(MEASUREMENTS_WRITE_SCOPE);
|
||||
const { user } = auth;
|
||||
|
||||
// A scoped credential is a bridge — a scale, a home-automation rule — and not
|
||||
// the native client. Two things below turn on that distinction: what `source`
|
||||
// its rows may carry, and whether it may move the phone's sync checkpoint.
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
// v1.4.25 W10 reconcile (security H-2): per-user rate limit. Without
|
||||
// it, a leaked wildcard iOS token can sustain unbounded writes
|
||||
@@ -265,6 +277,27 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
|
||||
}
|
||||
|
||||
// A scoped credential may attribute MANUAL and nothing else. Refused loudly
|
||||
// rather than rewritten quietly: silently relabelling a client's explicit
|
||||
// assertion hands it rows it did not ask for and no way to notice, while a
|
||||
// 422 is fixable on the first call.
|
||||
//
|
||||
// `APPLE_HEALTH` is the label that matters, and forging it is not merely
|
||||
// mislabelling. It is half the `(userId, type, source, externalId)` dedup key,
|
||||
// so a bridge's rows would land in the phone's namespace; it is a mergeable
|
||||
// source, so a forged row joins the cross-source merge and can suppress a
|
||||
// genuine one; and it is what `healthKitSyncSucceeded` reads to decide the
|
||||
// Apple Health card may claim the phone synced.
|
||||
if (scoped && parsed.data.entries.some((e) => e.source === "APPLE_HEALTH")) {
|
||||
annotate({
|
||||
action: { name: "measurement.batch.ingest" },
|
||||
meta: { outcome: "source_not_permitted" },
|
||||
});
|
||||
return apiError("This credential may only attribute MANUAL readings", 422, {
|
||||
errorCode: "measurement.batch.source_not_permitted",
|
||||
});
|
||||
}
|
||||
|
||||
const { entries, syncTrigger } = parsed.data;
|
||||
|
||||
// First pass — map each inbound entry; record skipped entries.
|
||||
@@ -406,7 +439,13 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
unit: mapped.unit,
|
||||
// v1.8.6 W6 — honour the per-entry source tag, defaulting to
|
||||
// `APPLE_HEALTH` when absent so legacy callers are unchanged.
|
||||
source: entry.source ?? "APPLE_HEALTH",
|
||||
//
|
||||
// A scoped credential defaults to `MANUAL` instead, and can hold no
|
||||
// other value: the guard above already refused an explicit
|
||||
// `APPLE_HEALTH`, so this is the only remaining arm for it. The default
|
||||
// has to move with the credential rather than with the route, because
|
||||
// the route serves both the phone and a third-party bridge.
|
||||
source: scoped ? "MANUAL" : (entry.source ?? "APPLE_HEALTH"),
|
||||
measuredAt: mapped.takenAt,
|
||||
externalId: entry.externalId,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
@@ -671,7 +710,16 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
// trigger writes null over the previous value rather than leaving a stale one
|
||||
// standing: the field describes THIS sync, and an older build's silence is
|
||||
// not evidence about it.
|
||||
if (failedCount === 0) {
|
||||
//
|
||||
// None of that is true of a scoped credential, which is why it is excluded
|
||||
// from the whole block rather than only from the HealthKit half. The
|
||||
// checkpoint's meaning is "the client synced what it was holding", and a
|
||||
// third-party bridge is not the client and holds nothing on its behalf.
|
||||
// `lastSyncedAt` is what `GET /api/sync/state` reports and what the phone
|
||||
// uses to size its undelivered window, so letting a scale advance it can make
|
||||
// the phone skip a window of its own samples — a silent data loss whose cause
|
||||
// would be nowhere near the code that caused it.
|
||||
if (failedCount === 0 && !scoped) {
|
||||
const syncedAt = new Date();
|
||||
const deliveredWithoutTheApp =
|
||||
syncTrigger === "background" || syncTrigger === "push";
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
isScopedCredential,
|
||||
requireRecordAuth,
|
||||
} from "@/lib/api-handler";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
@@ -744,6 +749,23 @@ async function sleepListResponse(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this body attribute any reading to `APPLE_HEALTH`?
|
||||
*
|
||||
* Read off the raw body rather than the parsed result because the answer has to
|
||||
* be the same for the object arm and the array arm, and those parse against
|
||||
* different schemas further down. Anything that is not a recognisable
|
||||
* measurement shape answers false and falls through to the validation error it
|
||||
* has coming — this function decides attribution, not validity.
|
||||
*/
|
||||
function namesAppleHealthSource(body: unknown): boolean {
|
||||
const names = (entry: unknown): boolean =>
|
||||
typeof entry === "object" &&
|
||||
entry !== null &&
|
||||
(entry as { source?: unknown }).source === "APPLE_HEALTH";
|
||||
return Array.isArray(body) ? body.some(names) : names(body);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
|
||||
|
||||
async function postMeasurement(request: NextRequest) {
|
||||
@@ -752,7 +774,17 @@ async function postMeasurement(request: NextRequest) {
|
||||
// who may or may not be that person. Nothing else in this handler reads the
|
||||
// caller: the safety-floor check, the reminder satisfaction, the rollup
|
||||
// recompute and the cache eviction are all statements about the record.
|
||||
const { user } = await requireRecordAuth("write", "measurements");
|
||||
//
|
||||
// v1.38.x — it also declares the third-party ingest scope, so a narrow
|
||||
// `measurements:write` token reaches this arm. The two declarations do not
|
||||
// interact: a scoped credential is refused the moment the request names
|
||||
// another record, before any grant is read, so "delegable" and "reachable by
|
||||
// a bridge token" stay separate properties of the same route.
|
||||
const auth = await requireRecordAuth("write", "measurements", {
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
});
|
||||
const { user } = auth;
|
||||
const scoped = isScopedCredential(auth);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
@@ -760,6 +792,18 @@ async function postMeasurement(request: NextRequest) {
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// A scoped credential may attribute MANUAL and nothing else — the schema's
|
||||
// own default, so only an explicit `APPLE_HEALTH` needs refusing. Checked
|
||||
// before validation branches so the object and array arms share one answer,
|
||||
// and refused rather than rewritten for the reason the batch route gives:
|
||||
// relabelling a client's explicit assertion hands it rows it cannot see.
|
||||
if (scoped && namesAppleHealthSource(body)) {
|
||||
annotate({ action: { name: "measurements.create.source-not-permitted" } });
|
||||
return apiError("This credential may only attribute MANUAL readings", 422, {
|
||||
errorCode: "measurement.create.source_not_permitted",
|
||||
});
|
||||
}
|
||||
|
||||
// Batch mode (array of measurements, e.g. combined BP + Pulse)
|
||||
if (Array.isArray(body)) {
|
||||
const parsed = createBatchMeasurementSchema.safeParse({
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* The measurement-ingest mint.
|
||||
*
|
||||
* The assertion that matters most is not the 201 — it is the exact
|
||||
* `permissions` array handed to `issueApiToken`. That helper defaults to
|
||||
* `["*"]` when the property is absent, so an edit that drops one line turns a
|
||||
* user-facing endpoint into a cookie-equivalent credential factory, and a test
|
||||
* that only checked the status code would go on passing. The structural guard
|
||||
* catches a literal `permissions: ["*"` and would not catch the omission, so
|
||||
* this is where that hole is closed.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: (fn: unknown) => fn,
|
||||
requireAuth: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/issue-token", () => ({
|
||||
issueApiToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/app-settings", () => ({
|
||||
isApiGloballyEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { requireAuth } 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 { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
const USER = { id: "user-1", role: "USER" as const };
|
||||
const EXPIRES = new Date("2027-08-31T00:00:00.000Z");
|
||||
|
||||
function req(body: unknown): Request {
|
||||
return new Request("https://health.example/api/tokens/measurements", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(requireAuth).mockResolvedValue({ user: USER } as never);
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never);
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined as never);
|
||||
vi.mocked(issueApiToken).mockResolvedValue({
|
||||
token: "hlk_" + "a".repeat(64),
|
||||
expiresAt: EXPIRES,
|
||||
tokenId: "token-1",
|
||||
name: "Home Assistant",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("what it mints", () => {
|
||||
it("names exactly the ingest scope, and no wildcard", async () => {
|
||||
const res = await POST(req({ name: "Home Assistant" }) as never);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
|
||||
// Exact equality, not `toContain`: an extra scope is a widening, and this
|
||||
// is the assertion standing between the endpoint and one.
|
||||
expect(opts.permissions).toEqual([MEASUREMENTS_WRITE_SCOPE]);
|
||||
expect(opts.userId).toBe(USER.id);
|
||||
});
|
||||
|
||||
it("returns the raw token once, with its name and expiry", async () => {
|
||||
const res = await POST(req({ name: "Home Assistant" }) as never);
|
||||
const json = await res.json();
|
||||
expect(json.data.token).toMatch(/^hlk_[0-9a-f]{64}$/);
|
||||
expect(json.data.name).toBe("Home Assistant");
|
||||
expect(json.data.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("expires by default rather than living forever", async () => {
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
const opts = vi.mocked(issueApiToken).mock.calls[0][0];
|
||||
expect(opts.expiresInDays).toBe(365);
|
||||
});
|
||||
|
||||
it("honours an explicit lifetime", async () => {
|
||||
await POST(req({ name: "Scale", expiresInDays: 30 }) as never);
|
||||
expect(vi.mocked(issueApiToken).mock.calls[0][0].expiresInDays).toBe(30);
|
||||
});
|
||||
|
||||
it("audits the mint with the token id and the scope", async () => {
|
||||
await POST(req({ name: "Scale" }) as never);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"tokens.measurements.create",
|
||||
expect.objectContaining({
|
||||
userId: USER.id,
|
||||
details: expect.objectContaining({
|
||||
tokenId: "token-1",
|
||||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when it refuses", () => {
|
||||
it("403s while the operator has the API switched off", async () => {
|
||||
vi.mocked(isApiGloballyEnabled).mockResolvedValue(false);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
expect(res.status).toBe(403);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("429s past the mint rate limit, without minting", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never);
|
||||
const res = await POST(req({ name: "Scale" }) as never);
|
||||
expect(res.status).toBe(429);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a missing name", async () => {
|
||||
const res = await POST(req({}) as never);
|
||||
expect(res.status).toBe(422);
|
||||
expect(issueApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on a lifetime outside the permitted band", async () => {
|
||||
const res = await POST(
|
||||
req({ name: "Scale", expiresInDays: 4000 }) as never,
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(issueApiToken).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
|
||||
// "the field does not exist" is the security property, and a schema change
|
||||
// that added one would silently make this body meaningful.
|
||||
await POST(
|
||||
req({ name: "Scale", scope: "read_write", permissions: ["*"] }) as never,
|
||||
);
|
||||
expect(vi.mocked(issueApiToken).mock.calls[0][0].permissions).toEqual([
|
||||
MEASUREMENTS_WRITE_SCOPE,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* `POST /api/tokens/measurements` — mint a Bearer for third-party measurement
|
||||
* ingest.
|
||||
*
|
||||
* The self-service mint that v1.30.17 left missing. That release made the
|
||||
* Bearer-scope default fail-closed, which was right — a medication-intake token
|
||||
* had been reaching every authenticated route, the full backup export included
|
||||
* — and removed the generic mint at the parent path, which was also right,
|
||||
* since the token it issued could not do its advertised job. What went with them
|
||||
* was any way at all to push readings in programmatically: the OAuth
|
||||
* integrations cover Withings and WHOOP, and cover nothing for a local sensor
|
||||
* behind a home-automation bridge.
|
||||
*
|
||||
* What this mints is narrow by construction. `permissions` is a literal, so no
|
||||
* request shape reaches it; the scope it names is accepted by exactly two
|
||||
* routes, both writes, both on the holder's own record. It cannot read a
|
||||
* reading back, cannot edit or delete one, cannot reach the export, and cannot
|
||||
* mint another token — the `requireAuth()` below names no scope, so a
|
||||
* measurements token presenting itself here is refused like any other narrow
|
||||
* credential. That last property is why the mint is worth its own file: the
|
||||
* blast radius of a credential pasted into a container the user does not
|
||||
* operate should not include making more credentials.
|
||||
*
|
||||
* No listing leg. `/api/tokens` already returns every token with its
|
||||
* `permissions`, so the settings card filters that one query rather than
|
||||
* duplicating it — the MCP module needs its own list only because it must hide
|
||||
* transient OAuth access rows, and there is no such row here. No revoke-all
|
||||
* either: `DELETE /api/tokens/[id]` is per-row and the card shows the rows. The
|
||||
* per-medication toggle's bulk revoke exists because that surface is a boolean
|
||||
* with no list behind it, which is not this surface.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
|
||||
|
||||
/**
|
||||
* Days a token lives when the caller names no lifetime.
|
||||
*
|
||||
* Longer than the MCP mint's 90, and the difference is the deployment rather
|
||||
* than the risk appetite. A connector token is used from a session somebody is
|
||||
* sitting in front of; this one is pasted into a rule that runs unattended for
|
||||
* months, where a quiet expiry surfaces as "my weight stopped syncing sometime
|
||||
* in the spring". A year is long enough to not be that, short enough to still
|
||||
* be a rotation.
|
||||
*/
|
||||
const DEFAULT_EXPIRY_DAYS = 365;
|
||||
|
||||
/** Mints per user per minute. A credential mint is worth its own bucket. */
|
||||
const MINT_RATE_LIMIT_MAX = 10;
|
||||
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// No scope argument, and that is load-bearing rather than incidental: it
|
||||
// keeps the fail-closed default, so the only credentials that reach the mint
|
||||
// are a cookie session and a cookie-equivalent token. It also refuses any
|
||||
// acting-account carrier, so a delegate cannot mint a credential against the
|
||||
// record they are helping with.
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "tokens.measurements.create" } });
|
||||
|
||||
if (!(await isApiGloballyEnabled())) {
|
||||
return apiError("API is globally disabled", 403);
|
||||
}
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`tokens:measurements:mint:${user.id}`,
|
||||
MINT_RATE_LIMIT_MAX,
|
||||
MINT_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many token mints, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 16 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createMeasurementTokenSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
const issued = await issueApiToken({
|
||||
userId: user.id,
|
||||
name: parsed.data.name,
|
||||
// A literal, never spread and never derived from the body. `issueApiToken`
|
||||
// defaults to `["*"]` when this property is absent, so an edit that drops
|
||||
// it mints a cookie-equivalent credential from a user-facing endpoint —
|
||||
// which is why the unit suite asserts this array and not merely the 201.
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS,
|
||||
});
|
||||
|
||||
await auditLog("tokens.measurements.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { tokenId: issued.tokenId, scope: MEASUREMENTS_WRITE_SCOPE },
|
||||
});
|
||||
|
||||
// The raw token, once. It is stored as an HMAC and no path re-reveals it.
|
||||
return apiSuccess(
|
||||
{ token: issued.token, name: issued.name, expiresAt: issued.expiresAt },
|
||||
201,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user