Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/openstatusHQ/openstatus. ๐ซ Status page with uptime monitoring & API monitoring as code ๐ซ openstatus.dev
bun drizzle-orm monitoring monitoring-as-code nextjs observability on-call open-source shadcn-ui status-page statuspage synthetic-monitoring tinybird turso uptime uptime-checker uptime-monitor
Something went wrong. Try again.
4.8 kB ยท 132 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133import { and, eq, gte, isNull } from "@openstatus/db";import { invitation, selectWorkspaceSchema, usersToWorkspaces,} from "@openstatus/db/src/schema";
import { emitAudit } from "../audit";import { requireScope } from "../auth";import { type ServiceContext, tryGetActorUserId, withTransaction,} from "../context";import { ConflictError, NotFoundError, UnauthorizedError } from "../errors";import type { Workspace } from "../types";import { AcceptInvitationInput } from "./schemas";
/** * Accept an invitation: stamp `acceptedAt`, add the accepting user to the * target workspace with the invitation's role, and return the workspace * row. * * The lookup is scoped by id + email + unexpired + not-yet-accepted to * prevent token-sharing. To stay race-safe, the acceptance write is a * conditional UPDATE that re-asserts `acceptedAt IS NULL` โ two concurrent * callers that both pass the initial read will see exactly one row * returned from the update; the loser throws `ConflictError` and the * transaction aborts before the membership insert runs. * * The membership is inserted under `ctx.actor`'s user id โ taking it * from input would let any caller that knows the token+email create * a membership for an arbitrary user. Same defense pattern as * `createApiKey.createdById`. */export async function acceptInvitation(args: { ctx: ServiceContext; input: AcceptInvitationInput;}): Promise<Workspace> { const { ctx } = args; requireScope(ctx, "write"); const input = AcceptInvitationInput.parse(args.input);
const userId = tryGetActorUserId(ctx.actor); if (userId == null) { throw new UnauthorizedError( "Invitations must be accepted by a known user actor.", ); }
return withTransaction(ctx, async (tx) => { // Load the invitation with its workspace in one round-trip โ we use // `existing.workspace` for the service's return value, so there's // no point re-fetching it by id afterwards. Keeping a single read // also avoids a read-skew window where a just-renamed workspace // would appear with one name on the db fetch and another on the // relation join. const existing = await tx.query.invitation.findFirst({ where: and( eq(invitation.id, input.id), eq(invitation.email, input.email), isNull(invitation.acceptedAt), gte(invitation.expiresAt, new Date()), ), with: { workspace: true }, });
if (!existing) throw new NotFoundError("invitation", input.id);
// Conditional UPDATE โ the `isNull(acceptedAt)` predicate makes the // write a no-op for any row another caller has already claimed, // even if both callers passed the read check above. Rowcount == 0 // means we lost the race. The `gte(expiresAt, now)` condition is // re-asserted here to close a TOCTOU gap: without it, an invitation // whose expiry is exactly the moment between the read and the write // can be silently accepted past the deadline. const acceptedAt = new Date(); const claimed = await tx .update(invitation) .set({ acceptedAt }) .where( and( eq(invitation.id, input.id), isNull(invitation.acceptedAt), gte(invitation.expiresAt, new Date()), ), ) .returning({ id: invitation.id });
if (claimed.length === 0) { throw new ConflictError("Invitation already accepted."); }
// `onConflictDoNothing` keyed on the `(userId, workspaceId)` unique // constraint โ idempotent for an invitee who's already a member of // the target workspace (e.g. they were added directly before the // invitation token was redeemed). Without this the final insert // explodes on the unique violation after the invitation row was // already stamped accepted, leaving the token consumed with no // membership change the caller can distinguish from success. await tx .insert(usersToWorkspaces) .values({ userId, workspaceId: existing.workspaceId, role: existing.role, }) .onConflictDoNothing();
if (!existing.workspace) { throw new NotFoundError("workspace", existing.workspaceId); } const workspace = selectWorkspaceSchema.parse(existing.workspace);
// Strip `token` (email-link capability) from the snapshot; construct the snapshots from the pre-fetched row // to avoid a second SELECT after the UPDATE. const { token: _token, ...before } = existing; await emitAudit( tx, { ...ctx, workspace }, { action: "invitation.update", entityType: "invitation", entityId: input.id, before, after: { ...before, acceptedAt }, }, );
return workspace; });}