Let an invitation carry the level it offers

A grant has always had an access column and `inviteGrant` has always written
READ into it. That was the right way to ship the first version: "this release
never grants write" was a property of the file rather than a promise in a
comment. Remove it deliberately now, so somebody sharing their record can offer
reading or reading plus adding.

The level is a required argument to `inviteGrant` and is defaulted in exactly
one place, the invite schema, where an omitted field becomes READ. An older
client's payload therefore stays valid and stays narrow, and the domain module
never guesses a level nobody named.

There is no upgrade path and that is the decision, not a gap. A grant is READ
or WRITE from the moment it is offered and stays that way until somebody ends
it. Widening a live one would change what the delegate agreed to without asking
them again, and their consent is half of what makes a write grant legitimate.
The way up is a new invitation, accepted again.

The audit row and the wide event carry the level: "who was given what, and by
whom" is the question that row exists to answer.
This commit is contained in:
Marc-André Bombeck committed 2026-08-03 10:12:08 +02:00
1 parent 1efd337f53
commit 533400ce60
11 files changed
+408 -47

No files matched your search

@@ -0,0 +1,155 @@
/**
* `POST /api/account/grants` — the level a request carries is the level the row
* gets.
*
* The assertion is deliberately on the WRITE the route performs rather than on
* the JSON it answers with: a response echoing the request would pass either
* way, and the failure this pins is the one where a field is validated,
* annotated on, and then dropped on the way to the database. So every case
* reads the `data` object handed to `accountGrant.create`.
*
* Where the default lives matters and is asserted here rather than assumed: an
* omitted `access` becomes READ in the invite schema and nowhere else —
* `inviteGrant` takes the level as a required argument. Move the default and
* the third case below goes red.
*
* What this file cannot see: the browser. That the level a person picks on the
* invitation form reaches this route at all is proven in
* `e2e/account-sharing.spec.ts`, which reads the posted body — the component
* test renders the control and stays green with the control wired to a
* constant.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: never[]) => Promise<Response>>(h: T): T =>
h,
requireAuth: vi.fn(),
};
});
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
accountGrant: { create: vi.fn() },
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
import { POST } from "../route";
import { requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
const OWNER = { id: "owner-1" };
const INVITEE = { id: "invitee-1", username: "housemate", displayName: null };
function request(body: Record<string, unknown>): NextRequest {
return new NextRequest("http://localhost/api/account/grants", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
/** The `data` object the route's write reached the client with. */
function writtenRow(): Record<string, unknown> {
const calls = vi.mocked(prisma.accountGrant.create).mock.calls;
expect(calls).toHaveLength(1);
return (calls[0][0] as { data: Record<string, unknown> }).data;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(requireAuth).mockResolvedValue({
user: OWNER,
} as unknown as Awaited<ReturnType<typeof requireAuth>>);
vi.mocked(prisma.user.findFirst).mockResolvedValue(
INVITEE as never as Awaited<ReturnType<typeof prisma.user.findFirst>>,
);
vi.mocked(prisma.accountGrant.create).mockImplementation((async (args: {
data: Record<string, unknown>;
}) => ({
id: "grant-1",
grantorId: OWNER.id,
granteeId: INVITEE.id,
acceptedAt: null,
revokedAt: null,
revokedBy: null,
lastUsedAt: null,
expiresAt: null,
...args.data,
})) as never);
});
describe("invitation access level", () => {
it("stores WRITE when the invitation offers it", async () => {
const res = await POST(
request({ identifier: "housemate", access: "WRITE" }),
);
expect(res.status).toBe(201);
expect(writtenRow().access).toBe("WRITE");
const body = (await res.json()) as { data: { access: string } };
expect(body.data.access).toBe("WRITE");
});
it("stores READ when the invitation offers it", async () => {
const res = await POST(
request({ identifier: "housemate", access: "READ" }),
);
expect(res.status).toBe(201);
expect(writtenRow().access).toBe("READ");
});
it("mints READ for a client that does not send the field", async () => {
// An older client's payload stays valid, and it stays NARROW. This is the
// case that must never widen by accident.
const res = await POST(request({ identifier: "housemate" }));
expect(res.status).toBe(201);
expect(writtenRow().access).toBe("READ");
});
it("refuses a level that is not one of the two, without writing a row", async () => {
const res = await POST(
request({ identifier: "housemate", access: "ADMIN" }),
);
expect(res.status).toBe(422);
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
});
it("records the level on the audit row", async () => {
// "Who was given what, and by whom" is the question this row answers. An
// entry naming the invitation without its level answers half of it.
await POST(request({ identifier: "housemate", access: "WRITE" }));
expect(auditLog).toHaveBeenCalledWith(
"sharing.grant.invited",
expect.objectContaining({
userId: OWNER.id,
details: expect.objectContaining({ access: "WRITE" }),
}),
);
});
});
+21 -5
View File
@@ -6,7 +6,10 @@
* one question — "who can see my record, and whose can I see" — and because a
* client that has to call twice will eventually render half an answer.
*
* `POST` offers a grant to somebody already registered on this instance.
* `POST` offers a grant to somebody already registered on this instance, at
* the level the owner chose. There is no route that raises a live grant: the
* level is settled when the invitation is written and the only way to a wider
* one is a fresh invitation the delegate accepts again.
*
* **Neither is delegable, and that is structural.** Both resolve through bare
* `requireAuth()`, which refuses outright while the caller is acting on
@@ -111,7 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { identifier, expiresAt } = parsed.data;
const { identifier, expiresAt, access } = parsed.data;
// Either column, case-insensitively — the same lookup the login form does,
// so the name somebody signs in with is the name they can be invited by.
@@ -129,18 +132,30 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
try {
// `inviteGrant` decides everything about the row: READ and only READ, the
// `inviteGrant` decides everything about the row except the level: the
// self-grant refusal, and the one-live-grant-per-pair rule the partial
// unique index backs. None of it is re-decided here.
// unique index backs. Neither is re-decided here. The level travels from
// the parsed body, where the schema is the one place an omitted field
// becomes READ, and it is fixed for the life of the row — the domain
// module has no transition that raises it.
const grant = await inviteGrant({
grantorId: user.id,
granteeId: invitee.id,
access,
expiresAt: expiresAt ? new Date(expiresAt) : null,
});
// The level is on the audit row, not only in the response: "who was given
// what, and by whom" is the question this row exists to answer, and an
// entry that recorded the invitation without its level would answer half
// of it.
await auditLog("sharing.grant.invited", {
userId: user.id,
details: { grantId: grant.id, granteeId: invitee.id },
details: {
grantId: grant.id,
granteeId: invitee.id,
access: grant.access,
},
ipAddress: getClientIp(request),
}).catch(() => {});
@@ -148,6 +163,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "sharing.grant.invited" },
meta: {
grant_id: grant.id,
access: grant.access,
expires: expiresAt !== null && expiresAt !== undefined,
},
});