chore(security): MED cluster — Coolify URL scrub + isCurrent device-id + SLEEP_DURATION migration note

- Coolify runbook + audit briefs scrub the live deploy URL to a
  placeholder (Sec-MED-2). Real values live in repo secrets.
- /api/auth/me/devices isCurrent now compares against the session's
  device id, not the unauthenticated X-Device-Id header (Sr-MED-5).
- Migration 0036 sleep-duration unit shift documented + verification
  command added to the coolify-auto-deploy howto.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-11 02:44:12 +02:00
1 parent 650f150353
commit 1a46bfe78a
5 files changed
+136 -10

No files matched your search

+20 -1
View File
@@ -23,10 +23,11 @@ its image-cache on every workflow-triggered deploy.
Set these once. Both required. Set these once. Both required.
1. **`COOLIFY_WEBHOOK`** 1. **`COOLIFY_WEBHOOK`**
- Value: `https://apps-01.bombeck.io/api/v1/deploy?uuid=pg8wggwogo8c4gc4ks0kk4ss&force=false` - Value: `https://<COOLIFY_INSTANCE>/api/v1/deploy?uuid=<APPLICATION_UUID>&force=false`
- Find: Coolify UI → Application → **Webhooks** tab → "Deploy" URL. - Find: Coolify UI → Application → **Webhooks** tab → "Deploy" URL.
The workflow appends `&force=true` if your stored URL doesn't The workflow appends `&force=true` if your stored URL doesn't
already carry a `force=` parameter, so either value is fine. already carry a `force=` parameter, so either value is fine.
- The actual values live in repo secrets only.
2. **`COOLIFY_TOKEN`** 2. **`COOLIFY_TOKEN`**
- Value: a Bearer token from Coolify UI → **Keys & Tokens** → - Value: a Bearer token from Coolify UI → **Keys & Tokens** →
@@ -52,6 +53,24 @@ The toggle's exact wording shifts between Coolify v4 point-releases;
look for "image registry", "digest auto-deploy", or look for "image registry", "digest auto-deploy", or
"auto-update" in the same tab. "auto-update" in the same tab.
## Pre-deploy data check (v1.4.23 onwards)
Migration `0036_apple_health_measurement_types` documents a unit
semantics shift for `SLEEP_DURATION` (hours → minutes) and explicitly
relies on **zero** pre-existing rows of that type. Before tagging
v1.4.23 (or any future release that re-applies migration 0036 against
a fresh database):
```
psql "$DATABASE_URL" -c "select count(*) from measurements where type = 'SLEEP_DURATION'"
```
If the count is non-zero, write a one-shot data-migration multiplying
every existing `SLEEP_DURATION` row's `value` column by 60 (hours →
minutes) BEFORE running `prisma migrate deploy`. Skipping the check
will silently shrink displayed sleep duration by 60× without
rewriting the stored numeric.
## Verification ## Verification
Push a tag `vX.Y.Z` to main → wait for the GHCR build to finish → Push a tag `vX.Y.Z` to main → wait for the GHCR build to finish →
+2 -1
View File
@@ -117,7 +117,8 @@ auto-deploy code ships:
Token format: `n_<base62>`. Token format: `n_<base62>`.
2. **Add two GitHub repository secrets**: 2. **Add two GitHub repository secrets**:
- `COOLIFY_WEBHOOK` = - `COOLIFY_WEBHOOK` =
`https://apps-01.bombeck.io/api/v1/deploy?uuid=pg8wggwogo8c4gc4ks0kk4ss&force=false` `https://<COOLIFY_INSTANCE>/api/v1/deploy?uuid=<APPLICATION_UUID>&force=false`
(the actual values live in repo secrets only).
- `COOLIFY_TOKEN` = the token from step 1. - `COOLIFY_TOKEN` = the token from step 1.
3. **Configure Coolify outgoing notifications**: 3. **Configure Coolify outgoing notifications**:
- Settings → Notifications → Webhook - Settings → Notifications → Webhook
@@ -15,6 +15,18 @@
-- this enum value yet (per the v1.4.23 W1 research), so the change is -- this enum value yet (per the v1.4.23 W1 research), so the change is
-- effectively schema-only — the application enforces the new unit going -- effectively schema-only — the application enforces the new unit going
-- forward via `getUnitForType()`. -- forward via `getUnitForType()`.
--
-- Pre-deploy verification (v1.4.23 W6 reconcile, Sec MED cluster):
-- Before tagging v1.4.23 the maintainer MUST run
-- psql -c "select count(*) from measurements where type = 'SLEEP_DURATION'"
-- and confirm the result is `0`. If the count is non-zero, this
-- migration's "schema-only" assumption no longer holds and the operator
-- has to ship a one-shot data-migration multiplying every existing
-- SLEEP_DURATION row's `value` column by 60 (hours → minutes) BEFORE
-- this migration is applied. Skipping the check would silently rewrite
-- the unit semantics on rows that were ingested under the old contract,
-- shrinking displayed sleep duration by 60× without rewriting the
-- stored numeric.
-- ── MeasurementType — append 7 Apple-Health-shaped enum values ────── -- ── MeasurementType — append 7 Apple-Health-shaped enum values ──────
@@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({
device: { device: {
findMany: vi.fn(), findMany: vi.fn(),
}, },
refreshToken: {
findFirst: vi.fn(),
},
}, },
})); }));
@@ -47,6 +50,7 @@ function req(deviceIdHeader?: string): NextRequest {
beforeEach(() => { beforeEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
vi.mocked(prisma.device.findMany).mockResolvedValue([]); vi.mocked(prisma.device.findMany).mockResolvedValue([]);
vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null);
}); });
describe("GET /api/auth/me/devices", () => { describe("GET /api/auth/me/devices", () => {
@@ -100,8 +104,11 @@ describe("GET /api/auth/me/devices", () => {
expect(body.data.devices[1].channels).toEqual(["web_push"]); expect(body.data.devices[1].channels).toEqual(["web_push"]);
}); });
it("marks the matching device as isCurrent when X-Device-Id is present", async () => { it("marks the matching device as isCurrent when X-Device-Id is anchored by an unrevoked refresh token", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue({
id: "rt-1",
} as never);
vi.mocked(prisma.device.findMany).mockResolvedValue([ vi.mocked(prisma.device.findMany).mockResolvedValue([
{ {
id: "dev-1", id: "dev-1",
@@ -137,7 +144,9 @@ describe("GET /api/auth/me/devices", () => {
const res = await GET(req("dev-2")); const res = await GET(req("dev-2"));
const body = await res.json(); const body = await res.json();
const current = body.data.devices.find((d: { isCurrent: boolean; id: string }) => d.isCurrent); const current = body.data.devices.find(
(d: { isCurrent: boolean; id: string }) => d.isCurrent,
);
expect(current?.id).toBe("dev-2"); expect(current?.id).toBe("dev-2");
}); });
@@ -147,4 +156,66 @@ describe("GET /api/auth/me/devices", () => {
const where = vi.mocked(prisma.device.findMany).mock.calls[0]?.[0]?.where; const where = vi.mocked(prisma.device.findMany).mock.calls[0]?.[0]?.where;
expect(where).toEqual({ userId: "user-1" }); expect(where).toEqual({ userId: "user-1" });
}); });
// v1.4.23 W6 reconcile (Sr-MED-5) — a forged X-Device-Id header
// pointing at a device the caller doesn't actually hold a credential
// for must NOT flip `isCurrent: true`. The route now cross-checks the
// header against an unrevoked RefreshToken row owned by the same
// user; when no such row exists, every device returns
// `isCurrent: false`.
it("ignores a forged X-Device-Id header when no refresh token anchors it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
userId: "user-1",
platform: "ios",
token: "tok-1",
bundleId: "io.healthlog.app",
locale: null,
appVersion: null,
model: "iPhone",
apnsToken: null,
apnsEnvironment: null,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: "dev-2",
userId: "user-1",
platform: "ios",
token: "tok-2",
bundleId: "io.healthlog.app",
locale: null,
appVersion: null,
model: "iPad",
apnsToken: null,
apnsEnvironment: null,
lastSeen: new Date(),
createdAt: new Date(),
updatedAt: new Date(),
},
] as never);
// Caller forges a header pointing at dev-2 but has no live
// refresh-token bound to that device. Expectation: every device is
// `isCurrent: false`.
const res = await GET(req("dev-2"));
const body = await res.json();
expect(
body.data.devices.every((d: { isCurrent: boolean }) => !d.isCurrent),
).toBe(true);
// The refresh-token lookup must scope by userId AND deviceId AND
// revokedAt: null — never trust the header without all three.
const lookupWhere = vi.mocked(prisma.refreshToken.findFirst).mock
.calls[0]?.[0]?.where;
expect(lookupWhere).toEqual({
userId: "user-1",
deviceId: "dev-2",
revokedAt: null,
});
});
}); });
+29 -6
View File
@@ -7,10 +7,16 @@
* device-identifying metadata only (label, lastSeen, channels) — no * device-identifying metadata only (label, lastSeen, channels) — no
* tokens, no APNs identifiers, no IPs. * tokens, no APNs identifiers, no IPs.
* *
* The "current device" marker is best-effort: the route inspects the * v1.4.23 W6 reconcile (Sr-MED-5) — the "current device" marker is no
* X-Device-Id header (which the native client always sends) and marks * longer derived from the unauthenticated `X-Device-Id` request header
* the matching row with `isCurrent: true`. Browser callers don't have a * alone. The header value is now cross-checked against an unrevoked
* device row and therefore see `isCurrent: false` across the board. * RefreshToken row owned by the authenticated user; only when both
* agree do we mark the matching row with `isCurrent: true`. This stops
* a forged `X-Device-Id` header from flipping the badge for a device
* the caller doesn't actually hold a credential for. Browser callers
* (cookie sessions, no refresh-token row) continue to see
* `isCurrent: false` across the board — there is no server-side anchor
* to validate the header against.
*/ */
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler"; import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -32,7 +38,23 @@ interface DeviceDTO {
export const GET = apiHandler(async (request: NextRequest) => { export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(); const { user } = await requireAuth();
const currentDeviceId = request.headers.get("x-device-id"); const headerDeviceId = request.headers.get("x-device-id");
// Sr-MED-5 — anchor `isCurrent` against a server-trusted credential.
// The header is only honoured when the user actually holds a live
// refresh-token issued for that device. Otherwise, ignore it.
let trustedCurrentDeviceId: string | null = null;
if (headerDeviceId) {
const tokenForDevice = await prisma.refreshToken.findFirst({
where: {
userId: user.id,
deviceId: headerDeviceId,
revokedAt: null,
},
select: { id: true },
});
if (tokenForDevice) trustedCurrentDeviceId = headerDeviceId;
}
const devices = await prisma.device.findMany({ const devices = await prisma.device.findMany({
where: { userId: user.id }, where: { userId: user.id },
@@ -57,7 +79,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
channels, channels,
lastSeenAt: d.lastSeen.toISOString(), lastSeenAt: d.lastSeen.toISOString(),
createdAt: d.createdAt.toISOString(), createdAt: d.createdAt.toISOString(),
isCurrent: currentDeviceId !== null && d.id === currentDeviceId, isCurrent:
trustedCurrentDeviceId !== null && d.id === trustedCurrentDeviceId,
}; };
}); });