Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 42 additions & 7 deletions src/lib/public-commitment.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,61 @@
import { describe, it, expect } from 'vitest';
import { withoutServerOnlyHashes } from '@/lib/public-commitment';
import { getTableColumns } from 'drizzle-orm';
import { commitments, type Commitment } from '@/db/schema/commitments';
import { PUBLIC_COMMITMENT_FIELDS, toPublicCommitment } from '@/lib/public-commitment';

const row = {
const row: Commitment = {
id: 'com_1',
slotId: 'slot_1',
signupId: 'sig_1',
workspaceId: 'ws_1',
participantId: 'par_1',
position: 1,
status: 'confirmed',
quantity: 1,
notes: '',
notesVisibility: 'public',
customFieldValues: {},
paymentId: null,
editTokenHash: 'deadbeef',
idempotencyKeyHash: 'cafebabe',
createdAt: new Date('2026-01-01T00:00:00Z'),
updatedAt: new Date('2026-01-01T00:00:00Z'),
cancelledAt: null,
};

describe('withoutServerOnlyHashes', () => {
it('drops server-only hashes and keeps public fields', () => {
const publicRow = withoutServerOnlyHashes(row);
describe('toPublicCommitment', () => {
it('keeps exactly the listed fields, so the hashes stay on the server', () => {
const publicRow = toPublicCommitment(row);

expect(publicRow).toStrictEqual({ id: 'com_1', status: 'confirmed' });
expect(Object.keys(publicRow)).toStrictEqual([...PUBLIC_COMMITMENT_FIELDS]);
expect(publicRow).not.toHaveProperty('editTokenHash');
expect(publicRow).not.toHaveProperty('idempotencyKeyHash');
});

it('drops a field nobody has listed, as a new column would be', () => {
const withNewColumn = { ...row, newServerOnlyColumn: 'secret' } as Commitment;

expect(toPublicCommitment(withNewColumn)).not.toHaveProperty('newServerOnlyColumn');
});

it('does not mutate the row it was given', () => {
withoutServerOnlyHashes(row);
toPublicCommitment(row);

expect(row.editTokenHash).toBe('deadbeef');
expect(row.idempotencyKeyHash).toBe('cafebabe');
});
});

describe('PUBLIC_COMMITMENT_FIELDS', () => {
// Fails when a column is added to `commitments`: decide whether participants
// see it, then add it to PUBLIC_COMMITMENT_FIELDS or to this list.
const SERVER_ONLY_FIELDS = ['editTokenHash', 'idempotencyKeyHash'];

it('covers every commitments column but the server-only ones', () => {
const columns = Object.keys(getTableColumns(commitments));

expect([...PUBLIC_COMMITMENT_FIELDS, ...SERVER_ONLY_FIELDS].sort()).toStrictEqual(
[...columns].sort(),
);
});
});
46 changes: 34 additions & 12 deletions src/lib/public-commitment.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,35 @@
/** Server-only commitment check values never leave the server. */
export function withoutServerOnlyHashes<
T extends { editTokenHash: string; idempotencyKeyHash: string | null },
>(
row: T,
): Omit<T, 'editTokenHash' | 'idempotencyKeyHash'> {
const {
editTokenHash: _editTokenHash,
idempotencyKeyHash: _idempotencyKeyHash,
...publicRow
} = row;
return publicRow;
import type { Commitment } from '@/db/schema/commitments';

/**
* The commitment fields a participant's own responses carry (sign up, view,
* edit, move). A list of what may go out rather than of what may not, so a
* column added to `commitments` stays on the server until someone adds it
* here. The edit-token and idempotency-key hashes are left out: they are read
* only where they are checked.
*/
export const PUBLIC_COMMITMENT_FIELDS = [
'id',
'slotId',
'signupId',
'workspaceId',
'participantId',
'position',
'status',
'quantity',
'notes',
'notesVisibility',
'customFieldValues',
'paymentId',
'createdAt',
'updatedAt',
'cancelledAt',
] as const satisfies readonly (keyof Commitment)[];

export type PublicCommitment = Pick<Commitment, (typeof PUBLIC_COMMITMENT_FIELDS)[number]>;

/** A copy of `row` with only the fields in `PUBLIC_COMMITMENT_FIELDS`. */
export function toPublicCommitment(row: Commitment): PublicCommitment {
return Object.fromEntries(
PUBLIC_COMMITMENT_FIELDS.map((field) => [field, row[field]]),
) as PublicCommitment;
}
19 changes: 9 additions & 10 deletions src/services/commitments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { summarizeSlotValues } from '@/lib/slot-summary';
import { signupShutReason } from '@/lib/signup-closing';
import { parseInputSafe } from '@/lib/parse';
import { requireWorkspaceWrite, type Actor } from '@/lib/policy';
import { withoutServerOnlyHashes } from '@/lib/public-commitment';
import { toPublicCommitment, type PublicCommitment } from '@/lib/public-commitment';
import { err, ok, type Result } from '@/lib/result';
import { editTokenFor, hashToken, idempotencyKeyHash, verifyHash } from '@/lib/token';
import {
Expand All @@ -31,9 +31,8 @@ import { inWorkspace, readLiveSignup } from './locks';
import { listFieldsForSignup } from './slot-fields';

type CommitmentRow = typeof commitments.$inferSelect;
type PublicCommitmentRow = ReturnType<typeof withoutServerOnlyHashes<CommitmentRow>>;
/** A commitment with who made it. `participantEmail` is null when they gave none. */
type OwnCommitment = PublicCommitmentRow & {
type OwnCommitment = PublicCommitment & {
participantName: string;
participantEmail: string | null;
};
Expand Down Expand Up @@ -156,7 +155,7 @@ function whyNotTakingPlaces(
}

export interface CommitResult {
commitment: PublicCommitmentRow;
commitment: PublicCommitment;
editToken: string;
signupSlug: string;
/** The slot's instant, read under its lock, so the route can keep the
Expand Down Expand Up @@ -277,7 +276,7 @@ async function commitParticipantToSlot(
.limit(1);
if (earlier) {
return ok({
commitment: withoutServerOnlyHashes(earlier),
commitment: toPublicCommitment(earlier),
editToken: editTokenFor(earlier.id),
signupSlug: signupRow.slug,
slotAt: slot.slotAt,
Expand Down Expand Up @@ -474,7 +473,7 @@ async function commitParticipantToSlot(
});

return ok({
commitment: withoutServerOnlyHashes(row),
commitment: toPublicCommitment(row),
editToken,
signupSlug: signupRow.slug,
slotAt: slot.slotAt,
Expand Down Expand Up @@ -515,7 +514,7 @@ export async function getOwnCommitment(
return err(serviceError('forbidden', 'invalid edit token'));
}
return ok({
...withoutServerOnlyHashes(found.c),
...toPublicCommitment(found.c),
participantName: found.pname,
participantEmail: found.pemail,
signupSlug: found.slug,
Expand Down Expand Up @@ -555,7 +554,7 @@ export async function getOwnCommitmentsForSignup(
if (!token) continue;
if (!verifyHash(token, row.c.editTokenHash)) continue;
out.push({
...withoutServerOnlyHashes(row.c),
...toPublicCommitment(row.c),
participantName: row.pname,
participantEmail: row.pemail,
});
Expand All @@ -572,7 +571,7 @@ const MoveNameSchema = CommitmentCreateInputSchema.pick({ name: true });
* commitment, whose id and edit token are new, with `moved` holding what its
* edit link needs.
*/
type EditedCommitment = PublicCommitmentRow & {
type EditedCommitment = PublicCommitment & {
moved?: Pick<CommitResult, 'editToken' | 'signupSlug' | 'slotAt'>;
};

Expand Down Expand Up @@ -849,7 +848,7 @@ export async function updateOwnCommitment(
eventType: 'commitment.updated',
payload: { commitmentId, changed: Object.keys(data) },
});
return ok(withoutServerOnlyHashes(updated));
return ok(toPublicCommitment(updated));
});
}

Expand Down
Loading