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.
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.
The workflow appends `&force=true` if your stored URL doesn't
already carry a `force=` parameter, so either value is fine.
- The actual values live in repo secrets only.
2. **`COOLIFY_TOKEN`**
- 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
"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
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>`.
2. **Add two GitHub repository secrets**:
- `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.
3. **Configure Coolify outgoing notifications**:
- Settings → Notifications → Webhook
@@ -15,6 +15,18 @@
-- 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
-- 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 ──────
@@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({
device: {
findMany: vi.fn(),
},
refreshToken: {
findFirst: vi.fn(),
},
},
}));
@@ -47,6 +50,7 @@ function req(deviceIdHeader?: string): NextRequest {
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.device.findMany).mockResolvedValue([]);
vi.mocked(prisma.refreshToken.findFirst).mockResolvedValue(null);
});
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"]);
});
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(prisma.refreshToken.findFirst).mockResolvedValue({
id: "rt-1",
} as never);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
@@ -137,7 +144,9 @@ describe("GET /api/auth/me/devices", () => {
const res = await GET(req("dev-2"));
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");
});
@@ -147,4 +156,66 @@ describe("GET /api/auth/me/devices", () => {
const where = vi.mocked(prisma.device.findMany).mock.calls[0]?.[0]?.where;
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
* tokens, no APNs identifiers, no IPs.
*
* The "current device" marker is best-effort: the route inspects the
* X-Device-Id header (which the native client always sends) and marks
* the matching row with `isCurrent: true`. Browser callers don't have a
* device row and therefore see `isCurrent: false` across the board.
* v1.4.23 W6 reconcile (Sr-MED-5) — the "current device" marker is no
* longer derived from the unauthenticated `X-Device-Id` request header
* alone. The header value is now cross-checked against an unrevoked
* 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 { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -32,7 +38,23 @@ interface DeviceDTO {
export const GET = apiHandler(async (request: NextRequest) => {
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({
where: { userId: user.id },
@@ -57,7 +79,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
channels,
lastSeenAt: d.lastSeen.toISOString(),
createdAt: d.createdAt.toISOString(),
isCurrent: currentDeviceId !== null && d.id === currentDeviceId,
isCurrent:
trustedCurrentDeviceId !== null && d.id === trustedCurrentDeviceId,
};
});