mirror of
https://github.com/documenso/documenso.git
synced 2026-07-14 14:57:12 +10:00
Merge remote-tracking branch 'origin/main' into pr-2711
# Conflicts: # apps/remix/app/components/tables/envelopes-table-bulk-action-bar.tsx # apps/remix/app/routes/_authenticated+/t.$teamUrl+/documents._index.tsx # packages/app-tests/e2e/documents/bulk-document-actions.spec.ts
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { getServerLimits } from '@documenso/ee/server-only/limits/server';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { DATE_FORMATS, DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats';
|
||||
import { DocumentDataType, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { DocumentDataType, DocumentStatus, EnvelopeType, SigningStatus } from '@prisma/client';
|
||||
import { tsr } from '@ts-rest/serverless/fetch';
|
||||
import { match } from 'ts-pattern';
|
||||
import '@documenso/lib/constants/time-zones';
|
||||
@@ -240,7 +240,12 @@ export const ApiContractV1Implementation = tsr.router(ApiContractV1, {
|
||||
};
|
||||
}
|
||||
|
||||
if (!downloadOriginalDocument && !isDocumentCompleted(envelope.status)) {
|
||||
// A cancelled document was never sealed, so its data is the unsigned original.
|
||||
// Treat it as not-completed here so a "signed" version is never served for it.
|
||||
// REJECTED and COMPLETED keep their prior behavior.
|
||||
const hasSignedArtifact = isDocumentCompleted(envelope.status) && envelope.status !== DocumentStatus.CANCELLED;
|
||||
|
||||
if (!downloadOriginalDocument && !hasSignedArtifact) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
|
||||
@@ -95,13 +95,22 @@ export const authenticatedMiddleware = <
|
||||
{ metadata, logger: apiLogger },
|
||||
);
|
||||
} catch (err) {
|
||||
console.log({ err });
|
||||
|
||||
apiLogger.info(infoToLog);
|
||||
apiLogger.info({
|
||||
...infoToLog,
|
||||
error: err,
|
||||
});
|
||||
|
||||
let message = 'Unauthorized';
|
||||
|
||||
if (err instanceof AppError) {
|
||||
if (err.code === AppErrorCode.TOO_MANY_REQUESTS) {
|
||||
return {
|
||||
status: 429,
|
||||
body: { message: err.message },
|
||||
headers: err.headers,
|
||||
} as const;
|
||||
}
|
||||
|
||||
message = err.message;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { encryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
|
||||
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Locator, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Transports seeded by the current test, deleted afterwards. Deleting a transport
|
||||
// referenced by a claim is safe: the FK is `onDelete: SetNull`.
|
||||
const transportIdsToCleanup: string[] = [];
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (transportIdsToCleanup.length > 0) {
|
||||
await prisma.emailTransport.deleteMany({ where: { id: { in: transportIdsToCleanup } } });
|
||||
transportIdsToCleanup.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const seedTransport = async (label: string) => {
|
||||
const transport = await prisma.emailTransport.create({
|
||||
data: {
|
||||
id: generateDatabaseId('email_transport'),
|
||||
name: `e2e-transport-${label}-${nanoid()}`,
|
||||
type: 'RESEND',
|
||||
fromName: 'Seeded Transport',
|
||||
fromAddress: 'seeded@example.com',
|
||||
config: encryptEmailTransportConfig({ type: 'RESEND', apiKey: `re_${nanoid()}` }),
|
||||
},
|
||||
});
|
||||
|
||||
transportIdsToCleanup.push(transport.id);
|
||||
|
||||
return transport;
|
||||
};
|
||||
|
||||
const seedSubscriptionClaim = (name: string) =>
|
||||
prisma.subscriptionClaim.create({
|
||||
data: {
|
||||
name,
|
||||
teamCount: 1,
|
||||
memberCount: 1,
|
||||
envelopeItemCount: 10,
|
||||
recipientCount: 10,
|
||||
flags: {},
|
||||
documentRateLimits: [],
|
||||
emailRateLimits: [],
|
||||
apiRateLimits: [],
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Seeds an organisation whose `OrganisationClaim` is descended (via
|
||||
* `originalSubscriptionClaimId`) from the supplied subscription claim. This is
|
||||
* the relationship the backport `updateMany` keys on.
|
||||
*/
|
||||
const seedOrgForClaim = async (subscriptionClaimId: string) => {
|
||||
const { organisation } = await seedUser();
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: {
|
||||
originalSubscriptionClaimId: subscriptionClaimId,
|
||||
emailTransportId: null,
|
||||
},
|
||||
});
|
||||
|
||||
return organisation;
|
||||
};
|
||||
|
||||
const openClaimUpdateDialog = async (page: Page, claimName: string) => {
|
||||
// The update dialog lives inside the table row. Wait for the debounced search
|
||||
// refetch to land BEFORE opening it, otherwise the table re-renders mid-flow
|
||||
// and unmounts the dialog.
|
||||
const searchSettled = page
|
||||
.waitForResponse((r) => r.url().includes('claims.find') && r.url().includes(claimName), { timeout: 15_000 })
|
||||
.catch(() => undefined);
|
||||
|
||||
await page.getByPlaceholder('Search by claim ID or name').fill(claimName);
|
||||
await searchSettled;
|
||||
|
||||
const row = page.getByRole('row', { name: claimName });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// The actions dropdown trigger is the last button in the row (the first is the
|
||||
// ID copy button).
|
||||
await row.getByRole('button').last().click();
|
||||
await page.getByRole('menuitem', { name: 'Update' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Update Subscription Claim' })).toBeVisible();
|
||||
|
||||
return dialog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Picks an option from an open Radix Select listbox. The email-transport list is
|
||||
* populated by a `find` query that can keep re-rendering (it loads up to 100
|
||||
* transports), so the target option's box may still be shifting — wait for it,
|
||||
* best-effort scroll it into view, then force the click.
|
||||
*/
|
||||
const chooseOption = async (page: Page, name: string) => {
|
||||
const option = page.getByRole('option', { name });
|
||||
await option.waitFor({ state: 'visible' });
|
||||
await option.scrollIntoViewIfNeeded().catch(() => undefined);
|
||||
await option.click({ force: true });
|
||||
};
|
||||
|
||||
const selectEmailTransport = async (page: Page, dialog: Locator, transportName: string) => {
|
||||
await dialog.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
|
||||
await chooseOption(page, transportName);
|
||||
};
|
||||
|
||||
// ─── Subscription claim: NO backport ─────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITHOUT backport does not touch organisation claims', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('no-backport');
|
||||
const claimName = `e2e-claim-no-backport-${nanoid()}`;
|
||||
const claim = await seedSubscriptionClaim(claimName);
|
||||
const organisation = await seedOrgForClaim(claim.id);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
|
||||
|
||||
const dialog = await openClaimUpdateDialog(page, claimName);
|
||||
|
||||
await selectEmailTransport(page, dialog, transport.name);
|
||||
|
||||
// Backport checkbox left UNCHECKED.
|
||||
await expect(dialog.getByRole('checkbox', { name: 'Backport email transport' })).not.toBeChecked();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Update Claim' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// The subscription claim itself was updated (proves the mutation ran).
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
|
||||
return updated.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
|
||||
// The organisation claim was NOT backported.
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
expect(orgClaim.emailTransportId).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Subscription claim: WITH backport ───────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating a subscription claim WITH backport propagates to organisation claims', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('backport');
|
||||
const claimName = `e2e-claim-backport-${nanoid()}`;
|
||||
const claim = await seedSubscriptionClaim(claimName);
|
||||
const organisation = await seedOrgForClaim(claim.id);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/claims' });
|
||||
|
||||
const dialog = await openClaimUpdateDialog(page, claimName);
|
||||
|
||||
await selectEmailTransport(page, dialog, transport.name);
|
||||
|
||||
// Enable backporting.
|
||||
const backportCheckbox = dialog.getByRole('checkbox', { name: 'Backport email transport' });
|
||||
await backportCheckbox.click();
|
||||
await expect(backportCheckbox).toBeChecked();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Update Claim' }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// Both the subscription claim AND the descendant organisation claim are updated.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const updated = await prisma.subscriptionClaim.findUniqueOrThrow({ where: { id: claim.id } });
|
||||
return updated.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
return orgClaim.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
});
|
||||
|
||||
// ─── Organisation claim transport (set directly on the org page) ─────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: setting the email transport on an organisation claim persists', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('org-claim');
|
||||
const { organisation } = await seedUser();
|
||||
|
||||
// Ensure a known starting point.
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: { emailTransportId: null },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
|
||||
|
||||
// Scope to the billing/claims form (the one containing the "Email transport" field);
|
||||
// the page has a second form (name/url) with its own "Update" button.
|
||||
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
|
||||
|
||||
await billingForm.getByRole('combobox').filter({ hasText: 'Default (system mailer)' }).click();
|
||||
await chooseOption(page, transport.name);
|
||||
|
||||
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
return orgClaim.emailTransportId;
|
||||
})
|
||||
.toBe(transport.id);
|
||||
});
|
||||
|
||||
// ─── Organisation claim transport can be reset to the system mailer ──────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: clearing an organisation claim transport resets it to the system mailer', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
const transport = await seedTransport('org-clear');
|
||||
const { organisation } = await seedUser();
|
||||
|
||||
// Start with the transport already assigned.
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
data: { emailTransportId: transport.id },
|
||||
});
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: `/admin/organisations/${organisation.id}` });
|
||||
|
||||
const billingForm = page.locator('form', { has: page.getByText('Email transport', { exact: true }) });
|
||||
|
||||
// The select currently shows the transport name; switch back to the default.
|
||||
await billingForm.getByRole('combobox').filter({ hasText: transport.name }).click();
|
||||
await chooseOption(page, 'Default (system mailer)');
|
||||
|
||||
await billingForm.getByRole('button', { name: 'Update', exact: true }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const orgClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { id: organisation.organisationClaim.id },
|
||||
});
|
||||
return orgClaim.emailTransportId;
|
||||
})
|
||||
.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
import { decryptEmailTransportConfig } from '@documenso/lib/server-only/email/email-transport-config';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Locator, type Page, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../../fixtures/authentication';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// Transport names created by the current test, deleted afterwards so the global
|
||||
// email-transports table doesn't accumulate rows across runs.
|
||||
const transportNamesToCleanup: string[] = [];
|
||||
|
||||
const trackTransport = (name: string) => {
|
||||
transportNamesToCleanup.push(name);
|
||||
return name;
|
||||
};
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (transportNamesToCleanup.length > 0) {
|
||||
await prisma.emailTransport.deleteMany({ where: { name: { in: transportNamesToCleanup } } });
|
||||
transportNamesToCleanup.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const getTransportFromDbOrThrow = async (name: string) => {
|
||||
await expect
|
||||
.poll(async () => prisma.emailTransport.findFirst({ where: { name }, select: { id: true } }), {
|
||||
message: `transport "${name}" was not persisted in time`,
|
||||
timeout: 10_000,
|
||||
intervals: [200, 400, 800],
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
return prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
};
|
||||
|
||||
const openCreateDialog = async (page: Page) => {
|
||||
await page.getByRole('button', { name: 'Add transport' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog.getByRole('heading', { name: 'Add Email Transport' })).toBeVisible();
|
||||
|
||||
return dialog;
|
||||
};
|
||||
|
||||
const selectTransportType = async (page: Page, dialog: Locator, optionName: string) => {
|
||||
// The transport-type Select is the only combobox inside the create/edit dialog.
|
||||
await dialog.getByRole('combobox').click();
|
||||
await page.getByRole('option', { name: optionName, exact: true }).click();
|
||||
};
|
||||
|
||||
const searchForTransport = async (page: Page, name: string) => {
|
||||
// The row-level Edit/Delete dialogs live inside the table row. Wait for the
|
||||
// debounced search refetch to land before interacting, otherwise a late
|
||||
// re-render can unmount a freshly-opened dialog.
|
||||
const searchSettled = page
|
||||
.waitForResponse((r) => r.url().includes('emailTransport.find') && r.url().includes(name), { timeout: 15_000 })
|
||||
.catch(() => undefined);
|
||||
|
||||
await page.getByPlaceholder('Search by name or from address').fill(name);
|
||||
await searchSettled;
|
||||
|
||||
await expect(page.getByRole('row', { name })).toBeVisible();
|
||||
};
|
||||
|
||||
const openRowAction = async (page: Page, name: string, action: 'Edit' | 'Send test' | 'Delete') => {
|
||||
await searchForTransport(page, name);
|
||||
// The transports table row has exactly one button: the actions dropdown trigger.
|
||||
await page.getByRole('row', { name }).getByRole('button').click();
|
||||
await page.getByRole('menuitem', { name: action }).click();
|
||||
};
|
||||
|
||||
// ─── Create: RESEND (round-trips the secret through encrypt/decrypt) ─────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: create a RESEND transport encrypts the secret and round-trips correctly', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-resend-${nanoid()}`);
|
||||
const apiKey = `re_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const dialog = await openCreateDialog(page);
|
||||
|
||||
await dialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await dialog.getByLabel('From name', { exact: true }).fill('Acme Mailer');
|
||||
await dialog.getByLabel('From address', { exact: true }).fill('sender@example.com');
|
||||
await selectTransportType(page, dialog, 'Resend');
|
||||
await dialog.getByLabel('API key', { exact: true }).fill(apiKey);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
const row = await getTransportFromDbOrThrow(name);
|
||||
|
||||
// The stored blob must NOT contain the plaintext secret.
|
||||
expect(row.config).not.toContain(apiKey);
|
||||
expect(row.type).toBe('RESEND');
|
||||
expect(row.fromName).toBe('Acme Mailer');
|
||||
expect(row.fromAddress).toBe('sender@example.com');
|
||||
|
||||
// Decrypting yields the original config (proves encrypt → store → decrypt works).
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
expect(config).toEqual({ type: 'RESEND', apiKey });
|
||||
});
|
||||
|
||||
// ─── Create: SMTP_AUTH (non-secret + secret fields) ─────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: create an SMTP_AUTH transport stores host/port/username and encrypts the password', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-smtp-${nanoid()}`);
|
||||
const password = `pw_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const dialog = await openCreateDialog(page);
|
||||
|
||||
await dialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await dialog.getByLabel('From name', { exact: true }).fill('SMTP Sender');
|
||||
await dialog.getByLabel('From address', { exact: true }).fill('smtp-sender@example.com');
|
||||
// Default type is SMTP_AUTH, so the host/port/username/password fields are already shown.
|
||||
await dialog.getByLabel('Host', { exact: true }).fill('smtp.example.com');
|
||||
await dialog.getByLabel('Port', { exact: true }).fill('587');
|
||||
await dialog.getByLabel('Username', { exact: true }).fill('smtp-user');
|
||||
await dialog.getByLabel('Password', { exact: true }).fill(password);
|
||||
|
||||
await dialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
const row = await getTransportFromDbOrThrow(name);
|
||||
|
||||
expect(row.config).not.toContain(password);
|
||||
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
expect(config).toEqual({
|
||||
type: 'SMTP_AUTH',
|
||||
host: 'smtp.example.com',
|
||||
port: 587,
|
||||
secure: false,
|
||||
ignoreTLS: false,
|
||||
username: 'smtp-user',
|
||||
password,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Update without a secret preserves the existing secret ───────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating without a secret keeps the existing secret intact', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-keep-${nanoid()}`);
|
||||
const originalApiKey = `re_keep_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
// Create the transport with a secret.
|
||||
const createDialog = await openCreateDialog(page);
|
||||
await createDialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await createDialog.getByLabel('From name', { exact: true }).fill('Keep Original');
|
||||
await createDialog.getByLabel('From address', { exact: true }).fill('keep@example.com');
|
||||
await selectTransportType(page, createDialog, 'Resend');
|
||||
await createDialog.getByLabel('API key', { exact: true }).fill(originalApiKey);
|
||||
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(createDialog).not.toBeVisible();
|
||||
|
||||
await getTransportFromDbOrThrow(name);
|
||||
|
||||
// Edit: change a non-secret field, leave the API key blank.
|
||||
await openRowAction(page, name, 'Edit');
|
||||
|
||||
const editDialog = page.getByRole('dialog');
|
||||
await expect(editDialog.getByRole('heading', { name: 'Edit Email Transport' })).toBeVisible();
|
||||
|
||||
// The secret field stays blank (we never re-enter it).
|
||||
await expect(editDialog.getByLabel('API key', { exact: true })).toHaveValue('');
|
||||
await editDialog.getByLabel('From name', { exact: true }).fill('Renamed Sender');
|
||||
await editDialog.getByRole('button', { name: 'Save changes' }).click();
|
||||
await expect(editDialog).not.toBeVisible();
|
||||
|
||||
// The update ran (fromName changed) but the original secret is preserved.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
return row.fromName;
|
||||
})
|
||||
.toBe('Renamed Sender');
|
||||
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
expect(config).toEqual({ type: 'RESEND', apiKey: originalApiKey });
|
||||
});
|
||||
|
||||
// ─── Update with a new secret correctly replaces it ──────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: updating with a new secret replaces the stored secret', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-replace-${nanoid()}`);
|
||||
const originalApiKey = `re_old_${nanoid()}`;
|
||||
const newApiKey = `re_new_${nanoid()}`;
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const createDialog = await openCreateDialog(page);
|
||||
await createDialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await createDialog.getByLabel('From name', { exact: true }).fill('Replace Secret');
|
||||
await createDialog.getByLabel('From address', { exact: true }).fill('replace@example.com');
|
||||
await selectTransportType(page, createDialog, 'Resend');
|
||||
await createDialog.getByLabel('API key', { exact: true }).fill(originalApiKey);
|
||||
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(createDialog).not.toBeVisible();
|
||||
|
||||
await getTransportFromDbOrThrow(name);
|
||||
|
||||
await openRowAction(page, name, 'Edit');
|
||||
|
||||
const editDialog = page.getByRole('dialog');
|
||||
await editDialog.getByLabel('API key', { exact: true }).fill(newApiKey);
|
||||
await editDialog.getByRole('button', { name: 'Save changes' }).click();
|
||||
await expect(editDialog).not.toBeVisible();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
const config = decryptEmailTransportConfig(row.config);
|
||||
return config.type === 'RESEND' ? config.apiKey : null;
|
||||
})
|
||||
.toBe(newApiKey);
|
||||
|
||||
// And it definitely no longer decrypts to the old secret.
|
||||
const row = await prisma.emailTransport.findFirstOrThrow({ where: { name } });
|
||||
expect(row.config).not.toContain(originalApiKey);
|
||||
});
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: delete removes the transport', async ({ page }) => {
|
||||
const { user: adminUser } = await seedUser({ isAdmin: true });
|
||||
|
||||
const name = trackTransport(`e2e-delete-${nanoid()}`);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
const createDialog = await openCreateDialog(page);
|
||||
await createDialog.getByLabel('Name', { exact: true }).fill(name);
|
||||
await createDialog.getByLabel('From name', { exact: true }).fill('To Delete');
|
||||
await createDialog.getByLabel('From address', { exact: true }).fill('delete@example.com');
|
||||
await selectTransportType(page, createDialog, 'Resend');
|
||||
await createDialog.getByLabel('API key', { exact: true }).fill(`re_${nanoid()}`);
|
||||
await createDialog.getByRole('button', { name: 'Create', exact: true }).click();
|
||||
await expect(createDialog).not.toBeVisible();
|
||||
|
||||
const row = await getTransportFromDbOrThrow(name);
|
||||
|
||||
await openRowAction(page, name, 'Delete');
|
||||
|
||||
const deleteDialog = page.getByRole('dialog');
|
||||
await expect(deleteDialog.getByRole('heading', { name: 'Delete Email Transport' })).toBeVisible();
|
||||
await deleteDialog.getByRole('button', { name: 'Delete', exact: true }).click();
|
||||
await expect(deleteDialog).not.toBeVisible();
|
||||
|
||||
await expect.poll(async () => prisma.emailTransport.findUnique({ where: { id: row.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Access control ──────────────────────────────────────────────────────────
|
||||
|
||||
test('[ADMIN][EMAIL_TRANSPORT]: a non-admin cannot access the email transports page', async ({ page }) => {
|
||||
const { user: nonAdminUser } = await seedUser({ isAdmin: false });
|
||||
|
||||
await apiSignin({ page, email: nonAdminUser.email, redirectPath: '/admin/email-transports' });
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Add transport' })).not.toBeVisible();
|
||||
});
|
||||
@@ -526,7 +526,7 @@ test('[ADMIN]: verify organisation access after ownership change', async ({ page
|
||||
// Should be able to access organisation settings
|
||||
await expect(page.getByText('Organisation Settings')).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Update organisation' })).toBeVisible();
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeEnabled();
|
||||
|
||||
// Should have delete permissions
|
||||
await expect(page.getByRole('button', { name: 'Delete' })).toBeVisible();
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { FieldType } from '@documenso/prisma/client';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Organisation, Team, User } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Dynamic organisation rate-limit & quota tests — API **v1** edition.
|
||||
*
|
||||
* This is the v1 counterpart to `../v2/organisation-rate-limits.spec.ts`. It
|
||||
* covers the feature added in `feat: add dynamic rate limits`:
|
||||
* - Three counters: `api`, `document`, `email`.
|
||||
* - Two enforcement stages per counter:
|
||||
* 1. Windowed rate limits (`*RateLimits`) — a 429 distinguished by its
|
||||
* message. NOTE: in v2 this 429 also carries `X-RateLimit-*` headers, but
|
||||
* v1 does NOT surface them (the ts-rest handler drops the headers the
|
||||
* middleware returns — see the windowed test), so v1 tells the windowed
|
||||
* stage apart from the quota stage by the MESSAGE alone.
|
||||
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
|
||||
* quota means unlimited and a `0` quota is a hard block.
|
||||
*
|
||||
* --- WHAT THIS V1 SUITE COVERS (and what it intentionally does NOT) ---
|
||||
* api -> every authenticated v1 request (get-api-token-by-token). Ported
|
||||
* 1:1 from the v2 suite against `GET /api/v1/documents`.
|
||||
* email -> resend (`POST /api/v1/documents/:id/resend`) consumes
|
||||
* `recipientsToRemind.length` SYNCHRONOUSLY (resend-document), so we
|
||||
* can assert on the HTTP response rather than racing async jobs.
|
||||
* IMPORTANT V1 DIVERGENCE: the v1 `resendDocument` handler catches
|
||||
* EVERY error and returns a generic HTTP 500
|
||||
* (`{ message: 'An error has occured while resending the document' }`)
|
||||
* — it does NOT surface the org limiter's 429 / `X-RateLimit-*`
|
||||
* headers like the v2 `redistribute` endpoint does. These tests
|
||||
* therefore assert the v1 reality: a blocked resend returns 500 and
|
||||
* the monthly counter advances exactly as documented.
|
||||
* document -> INTENTIONALLY OMITTED. v1's `POST /api/v1/documents` create path
|
||||
* requires S3 upload transport (createEnvelope), which the local E2E
|
||||
* environment generally does not provide, so it cannot be exercised
|
||||
* deterministically here. Document-counter enforcement is covered by
|
||||
* the v2 suite (envelope/create).
|
||||
*
|
||||
* --- WHY THIS TEST IS SKIPPED IN CI ---
|
||||
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
|
||||
* the per-org assertion and the global IP limiter, making every assertion here
|
||||
* meaningless. The test therefore skips itself in that mode and is intended to be
|
||||
* run deliberately and locally with the bypass OFF.
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v1/*:
|
||||
* apiV1RateLimit = 100 requests / 1 minute (action `api.v1`, see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
|
||||
* that we hit the org limiter rather than the global one.
|
||||
*/
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v1`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v1 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
// bypass flag, so skip there; run it locally with the bypass turned off.
|
||||
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
|
||||
|
||||
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
|
||||
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
|
||||
|
||||
type ClaimLimits = {
|
||||
apiRateLimits?: RateLimitEntry[];
|
||||
apiQuota?: number | null;
|
||||
documentRateLimits?: RateLimitEntry[];
|
||||
documentQuota?: number | null;
|
||||
emailRateLimits?: RateLimitEntry[];
|
||||
emailQuota?: number | null;
|
||||
};
|
||||
|
||||
const currentMonthlyPeriod = (): string => {
|
||||
const now = new Date();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
|
||||
return `${now.getUTCFullYear()}-${month}`;
|
||||
};
|
||||
|
||||
const getOrganisationClaim = async (team: Team) =>
|
||||
prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { organisation: { id: team.organisationId } },
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply a clean set of limits to the org's claim. Any counter not provided is
|
||||
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
|
||||
* each other.
|
||||
*/
|
||||
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||
const claim = await getOrganisationClaim(team);
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: claim.id },
|
||||
data: {
|
||||
apiRateLimits: limits.apiRateLimits ?? [],
|
||||
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
|
||||
documentRateLimits: limits.documentRateLimits ?? [],
|
||||
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
|
||||
emailRateLimits: limits.emailRateLimits ?? [],
|
||||
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
|
||||
* GLOBAL /api/v1 IP bucket so a fresh scenario starts from zero.
|
||||
*
|
||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV1RateLimit, 100/min
|
||||
* per IP, action `api.v1`) is shared by EVERY v1 request from this test client.
|
||||
* Across the suite (and especially across repeated local runs within the same
|
||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
|
||||
* one. Since this suite runs deliberately in isolation (it skips in CI), we
|
||||
* clear that bucket here so the global limiter never masks the org assertion.
|
||||
*/
|
||||
const resetUsage = async (organisation: Organisation) => {
|
||||
const period = currentMonthlyPeriod();
|
||||
|
||||
await prisma.organisationMonthlyStat.updateMany({
|
||||
where: { organisationId: organisation.id, period },
|
||||
data: {
|
||||
documentCount: 0,
|
||||
emailCount: 0,
|
||||
apiCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.rateLimit.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v1' }],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
|
||||
|
||||
const getMonthlyStat = async (organisation: Organisation) =>
|
||||
prisma.organisationMonthlyStat.findUnique({
|
||||
where: {
|
||||
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
|
||||
},
|
||||
select: { documentCount: true, emailCount: true, apiCount: true },
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert the live OrganisationMonthlyStat counter equals `expected`.
|
||||
*
|
||||
* The DB counter is the source of truth for quota enforcement, so checking its
|
||||
* exact value (not just the HTTP response) proves the documented increment
|
||||
* semantics in check-monthly-quota.ts:
|
||||
* - quota === null -> unlimited: never blocks, but the request is STILL
|
||||
* counted (the upsert now runs before the null return)
|
||||
* - quota === 0 -> throws BEFORE increment (stays 0)
|
||||
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
|
||||
* even the request that gets rejected still advances it
|
||||
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
|
||||
*/
|
||||
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
|
||||
const stat = await getMonthlyStat(organisation);
|
||||
|
||||
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep until just after the next windowed-limit bucket boundary.
|
||||
*
|
||||
* The limiter (createRateLimit -> getBucket) buckets time as
|
||||
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
|
||||
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
|
||||
* boundary the counter resets mid-test and the expected 429 never happens. We
|
||||
* share the server's clock (same host), so aligning to a fresh bucket here makes
|
||||
* the exhaustion deterministic.
|
||||
*/
|
||||
const alignToFreshWindowBucket = async (windowSeconds: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
|
||||
};
|
||||
|
||||
/**
|
||||
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
|
||||
* of MAX+1 requests completes inside ONE window. Without this, a burst that
|
||||
* happens to cross a bucket boundary would have its count reset mid-test and the
|
||||
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
|
||||
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
|
||||
* is almost always a no-op.
|
||||
*/
|
||||
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msLeftInBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
if (msLeftInBucket < requiredHeadroomMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ApiErrorBody = { message?: string; error?: string };
|
||||
|
||||
/**
|
||||
* Non-throwing predicate: true when the response is an ORG-level 429
|
||||
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
|
||||
* which needs a boolean to decide whether to skip rather than fail.
|
||||
*/
|
||||
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
|
||||
if (res.status() !== 429) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
|
||||
|
||||
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
|
||||
return body.message !== undefined && body.error === undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the response is an ORG-level 429 and return its parsed body.
|
||||
*
|
||||
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
|
||||
* "Expected 429, got <status>: <body>" message instead of an opaque
|
||||
* `expected true, received false`. Also asserts the body is the org limiter's
|
||||
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
|
||||
* global-IP 429 can never be mistaken for the org limit under test.
|
||||
*/
|
||||
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
|
||||
const bodyText = await res.text();
|
||||
|
||||
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
|
||||
|
||||
let body: ApiErrorBody = {};
|
||||
|
||||
try {
|
||||
body = JSON.parse(bodyText) as ApiErrorBody;
|
||||
} catch {
|
||||
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
|
||||
}
|
||||
|
||||
expect(
|
||||
body.message !== undefined && body.error === undefined,
|
||||
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
|
||||
).toBeTruthy();
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert NO org rate-limit header was surfaced — the GLOBAL /api/v1 middleware
|
||||
* still stamps a single `X-RateLimit-Limit: 100`, so "no org header" means the
|
||||
* value is either absent or exactly the lone global `100` (i.e. it does not
|
||||
* contain a second, org-specific entry).
|
||||
*
|
||||
* In v1 this holds for BOTH stages: quota rejections intentionally omit
|
||||
* rate-limit headers, AND windowed rejections lose theirs because the ts-rest
|
||||
* handler ignores the `headers` the middleware returns (see the windowed test).
|
||||
*/
|
||||
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
|
||||
const header = res.headers()['x-ratelimit-limit'];
|
||||
|
||||
if (header === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = header.split(',').map((v) => v.trim());
|
||||
|
||||
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
|
||||
};
|
||||
|
||||
/** Guard against the global limiter silently masking an org assertion. */
|
||||
const expectNotGlobalLimited = async (res: APIResponse) => {
|
||||
if (res.status() === 429) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
expect(
|
||||
'error' in body && !('message' in body),
|
||||
'Hit the GLOBAL /api/v1 IP limiter, not the org limiter. Re-run this suite in isolation.',
|
||||
).toBeFalsy();
|
||||
}
|
||||
};
|
||||
|
||||
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
|
||||
const findDocuments = (request: APIRequestContext, token: string): Promise<APIResponse> =>
|
||||
request.get(`${baseUrl}/documents?page=1&perPage=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
/**
|
||||
* Resend (remind) the given recipients. This runs the SYNCHRONOUS email assertion
|
||||
* in resend-document with `count = recipients.length`.
|
||||
*
|
||||
* NOTE: unlike the v2 `redistribute` endpoint, the v1 `resendDocument` handler
|
||||
* wraps everything in a try/catch and returns a generic HTTP 500 on ANY error
|
||||
* (including the org limiter's TOO_MANY_REQUESTS AppError). So when the email
|
||||
* limit/quota is exceeded this resolves to a 500, NOT a 429.
|
||||
*/
|
||||
const resendDocument = (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
documentId: number,
|
||||
recipientIds: number[],
|
||||
): Promise<APIResponse> =>
|
||||
request.post(`${baseUrl}/documents/${documentId}/resend`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { recipients: recipientIds },
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert a resend was blocked by the org email limiter.
|
||||
*
|
||||
* v1's handler masks the limiter's 429 as a generic HTTP 500 (see `resendDocument`
|
||||
* above), so the only signal available on the HTTP layer is the 500 status. The
|
||||
* accompanying `expectMonthlyCounter` assertions in each test prove WHICH stage
|
||||
* blocked it (windowed leaves the counter untouched; quota advances it).
|
||||
*/
|
||||
const expectResendBlocked = async (res: APIResponse) => {
|
||||
const bodyText = await res.text();
|
||||
|
||||
expect(
|
||||
res.status(),
|
||||
`Expected the v1 resend to be blocked (masked as HTTP 500) but got ${res.status()} with body: ${bodyText}`,
|
||||
).toBe(500);
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed a PENDING document with `recipientCount` NOT_SIGNED signer recipients (each
|
||||
* carrying a signature field) created directly via Prisma — so no async signing
|
||||
* emails are fanned out and the monthly email counter starts clean. Returns the
|
||||
* legacy document id (for the resend endpoint) and the recipient ids to remind.
|
||||
*/
|
||||
const seedRemindableDocument = async ({
|
||||
owner,
|
||||
team,
|
||||
recipientCount,
|
||||
}: {
|
||||
owner: User;
|
||||
team: Team;
|
||||
recipientCount: number;
|
||||
}): Promise<{ documentId: number; recipientIds: number[] }> => {
|
||||
const { document, recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner,
|
||||
teamId: team.id,
|
||||
recipients: Array.from(
|
||||
{ length: recipientCount },
|
||||
(_, i) => `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
),
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
return {
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
recipientIds: recipients.map((recipient) => recipient.id),
|
||||
};
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
test.describe('Organisation dynamic rate limits & quotas (API v1)', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let organisation: Organisation;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
const seeded = await seedUser();
|
||||
user = seeded.user;
|
||||
team = seeded.team;
|
||||
organisation = seeded.organisation;
|
||||
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-org-rate-limits',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
|
||||
// value that actually matters is the env the SERVER was started with — if the
|
||||
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
|
||||
// fail confusingly instead of skipping. Prove enforcement is live by setting a
|
||||
// quota of 0 (instant hard block) and confirming the server rejects. If it
|
||||
// doesn't, the server is bypassing limits, so skip with a clear message.
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const preflight = await findDocuments(request, token);
|
||||
const enforced = await isOrgLimited(preflight);
|
||||
|
||||
// Reset back to a clean slate before the real scenario runs.
|
||||
await setClaimLimits(team, {});
|
||||
await resetUsage(organisation);
|
||||
|
||||
test.skip(
|
||||
!enforced,
|
||||
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
|
||||
);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — windowed rate limit
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api rate limit (windowed)', () => {
|
||||
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
|
||||
const MAX = 4;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
// Each request (including these GETs) consumes one api counter.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is over the windowed limit.
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
// The windowed limit uses a message distinct from the global limiter — and
|
||||
// in v1 the MESSAGE is the only signal we get (see note below), so it is how
|
||||
// we tell a windowed rejection apart from a quota one.
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
|
||||
// V1 DIVERGENCE: unlike v2, v1's ts-rest handler does not propagate the org
|
||||
// limiter's `X-RateLimit-*` headers. `authenticatedMiddleware` returns them
|
||||
// on the body object (`headers: err.headers`), which `@ts-rest/serverless`
|
||||
// ignores (custom headers must be written to the `responseHeaders` Headers
|
||||
// object). So only the global middleware's lone `X-RateLimit-Limit: 100`
|
||||
// survives — the org `max` and `Retry-After`/`Remaining` never reach the
|
||||
// client. We therefore assert no org-specific header is surfaced.
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// The windowed stage blocks the (MAX+1)th request before the quota upsert,
|
||||
// but each of the MAX allowed requests still records usage (null quota now
|
||||
// tracks instead of skipping), so the counter equals MAX.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', MAX);
|
||||
});
|
||||
|
||||
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure both requests land inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
const okRes = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
|
||||
// The one allowed request is counted (null quota still tracks); the blocked
|
||||
// request trips the window before the quota upsert, so the counter is 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 1);
|
||||
});
|
||||
|
||||
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
|
||||
const MAX = 2;
|
||||
const WINDOW_SECONDS = 3;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
|
||||
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
|
||||
await alignToFreshWindowBucket(WINDOW_SECONDS);
|
||||
|
||||
// Exhaust the window.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is blocked by the window.
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// Wait out the window using the server-provided Retry-After (plus a small
|
||||
// buffer to be sure we've crossed into the next time bucket). Crucially we
|
||||
// do NOT reset usage here — the limiter must recover on its own as the
|
||||
// bucket rolls over.
|
||||
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
|
||||
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
|
||||
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
|
||||
|
||||
// Window has elapsed: the same org can make requests again without any
|
||||
// manual intervention — the bucket rolled over on its own.
|
||||
const afterReset = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(afterReset);
|
||||
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — monthly quota
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api quota (monthly)', () => {
|
||||
test('null quota allows unlimited requests', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: null });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// A null quota means "unlimited" (never blocks), but every request is now
|
||||
// recorded so usage is visible on unlimited plans — so the counter is 6.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 6);
|
||||
});
|
||||
|
||||
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
|
||||
const QUOTA = 3;
|
||||
await setClaimLimits(team, { apiQuota: QUOTA });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// The atomic increment runs even on the rejected request: QUOTA allowed
|
||||
// requests + the one rejected request = exactly QUOTA + 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
|
||||
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 1 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const okRes = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// One allowed + one rejected, both incremented => exactly 2.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 2);
|
||||
});
|
||||
|
||||
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — windowed rate limit (via synchronous resend)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email rate limit (windowed)', () => {
|
||||
test('resend is allowed when recipient count is within the email window', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
|
||||
|
||||
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
|
||||
// seeding above doesn't count against this window.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The windowed pass is now recorded even though the quota is null, so the
|
||||
// counter advances by the batch size (recipientIds.length).
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('resend is blocked when recipient count exceeds the email window', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
|
||||
|
||||
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
// v1 masks the org 429 as a generic HTTP 500.
|
||||
await expectResendBlocked(res);
|
||||
|
||||
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — monthly quota (via synchronous resend)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email quota (monthly)', () => {
|
||||
test('resend within the remaining email quota succeeds', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 2 });
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 10 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `resend should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('resend that would exceed the email quota is blocked', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 3 });
|
||||
|
||||
// Quota of 2 but reminding 3 recipients in one synchronous call.
|
||||
await setClaimLimits(team, { emailQuota: 2 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
// v1 masks the org 429 as a generic HTTP 500.
|
||||
await expectResendBlocked(res);
|
||||
|
||||
// The count (3) is added BEFORE the over-quota check throws, so the counter
|
||||
// advances by the full batch even though the request was rejected.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
|
||||
const { documentId, recipientIds } = await seedRemindableDocument({ owner: user, team, recipientCount: 1 });
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await resendDocument(request, token, documentId, recipientIds);
|
||||
// v1 masks the org 429 as a generic HTTP 500.
|
||||
await expectResendBlocked(res);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Stage interaction — quota binds before a looser window
|
||||
// =========================================================================
|
||||
|
||||
test.describe('stage interaction', () => {
|
||||
test('the quota trips before a looser windowed limit', async ({ request }) => {
|
||||
const WINDOW_MAX = 50; // generous window
|
||||
const QUOTA = 2; // strict quota — should bind first
|
||||
await setClaimLimits(team, {
|
||||
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
|
||||
apiQuota: QUOTA,
|
||||
});
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findDocuments(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findDocuments(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
|
||||
// It must be the QUOTA that bound, not the window: the message is the quota
|
||||
// one (not the windowed-limit message) and there are no rate-limit headers.
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,917 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EnvelopeType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Organisation, Team, User } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Dynamic organisation rate-limit & quota tests.
|
||||
*
|
||||
* Covers the feature added in `feat: add dynamic rate limits`:
|
||||
* - Three counters: `api`, `document`, `email`.
|
||||
* - Two enforcement stages per counter:
|
||||
* 1. Windowed rate limits (`*RateLimits`) — 429 WITH `X-RateLimit-*` headers.
|
||||
* 2. Monthly quota (`*Quota`) — 429 WITHOUT rate-limit headers; a `null`
|
||||
* quota means unlimited and a `0` quota is a hard block.
|
||||
*
|
||||
* Where each counter is consumed:
|
||||
* api -> every authenticated v2 request (get-api-token-by-token).
|
||||
* document -> envelope create where type === DOCUMENT (count 1).
|
||||
* email -> redistribute/remind consumes `recipientsToRemind.length`
|
||||
* SYNCHRONOUSLY (resend-document), so we can assert on the HTTP
|
||||
* response rather than racing async signing-email jobs.
|
||||
*
|
||||
* --- WHY THIS TEST IS SKIPPED IN CI ---
|
||||
* CI runs E2E with `DANGEROUS_BYPASS_RATE_LIMITS=true`, which short-circuits BOTH
|
||||
* the per-org assertion and the global IP limiter, making every assertion here
|
||||
* meaningless. The test therefore skips itself in that mode and is intended to be
|
||||
* run deliberately and locally with the bypass OFF.
|
||||
*
|
||||
* --- GLOBAL LIMIT AWARENESS ---
|
||||
* apps/remix/server/router.ts applies a GLOBAL per-IP limiter to /api/v2/*:
|
||||
* apiV2RateLimit = 100 requests / 1 minute (see rate-limits.ts).
|
||||
* Every per-org limit/quota configured here is kept FAR below that ceiling (single
|
||||
* digits) and the suite runs serially so the shared-IP global bucket is never the
|
||||
* thing that trips. A global-limit 429 is shaped `{ error }` whereas an org-limit
|
||||
* 429 is shaped `{ message }` — `expectOrgLimited()` asserts the 429 status AND
|
||||
* that we hit the org limiter rather than the global one.
|
||||
*/
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
// Run serially: all workers share one IP, and the global /api/v2 limiter is
|
||||
// per-IP. Serial execution keeps the shared global bucket well under 100/min.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
// This suite is only meaningful with real rate limiting enabled. CI sets the
|
||||
// bypass flag, so skip there; run it locally with the bypass turned off.
|
||||
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Test skipped because bypass rate limits is enabled.');
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
|
||||
const WINDOWED_LIMIT_MESSAGE = /contact support if you require higher limits/i;
|
||||
const NO_QUOTA_MESSAGE = /request could not be completed at this time/i;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Claim / usage control (direct Prisma) — mirrors recipient-count-limit.spec.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type RateLimitEntry = { window: `${number}${'s' | 'm' | 'h' | 'd'}`; max: number };
|
||||
|
||||
type ClaimLimits = {
|
||||
apiRateLimits?: RateLimitEntry[];
|
||||
apiQuota?: number | null;
|
||||
documentRateLimits?: RateLimitEntry[];
|
||||
documentQuota?: number | null;
|
||||
emailRateLimits?: RateLimitEntry[];
|
||||
emailQuota?: number | null;
|
||||
};
|
||||
|
||||
const currentMonthlyPeriod = (): string => {
|
||||
const now = new Date();
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, '0');
|
||||
|
||||
return `${now.getUTCFullYear()}-${month}`;
|
||||
};
|
||||
|
||||
const getOrganisationClaim = async (team: Team) =>
|
||||
prisma.organisationClaim.findFirstOrThrow({
|
||||
where: { organisation: { id: team.organisationId } },
|
||||
});
|
||||
|
||||
/**
|
||||
* Apply a clean set of limits to the org's claim. Any counter not provided is
|
||||
* reset to "unlimited" (empty windows + null quota) so scenarios never leak into
|
||||
* each other.
|
||||
*/
|
||||
const setClaimLimits = async (team: Team, limits: ClaimLimits) => {
|
||||
const claim = await getOrganisationClaim(team);
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: { id: claim.id },
|
||||
data: {
|
||||
apiRateLimits: limits.apiRateLimits ?? [],
|
||||
apiQuota: limits.apiQuota === undefined ? null : limits.apiQuota,
|
||||
documentRateLimits: limits.documentRateLimits ?? [],
|
||||
documentQuota: limits.documentQuota === undefined ? null : limits.documentQuota,
|
||||
emailRateLimits: limits.emailRateLimits ?? [],
|
||||
emailQuota: limits.emailQuota === undefined ? null : limits.emailQuota,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the monthly quota counters, the org windowed rate-limit buckets AND the
|
||||
* GLOBAL /api/v2 IP bucket so a fresh scenario starts from zero.
|
||||
*
|
||||
* - The org windowed limiter keys its rows `ip:org:<id>`.
|
||||
* - The GLOBAL limiter (apps/remix/server/router.ts -> apiV2RateLimit, 100/min
|
||||
* per IP, action `api.v2`) is shared by EVERY v2 request from this test client.
|
||||
* Across the suite (and especially across repeated local runs within the same
|
||||
* minute) that shared bucket would otherwise fill up and trip BEFORE the org
|
||||
* limit under test, producing a `{ error }` 429 instead of the org `{ message }`
|
||||
* one. Since this suite runs deliberately in isolation (it skips in CI), we
|
||||
* clear that bucket here so the global limiter never masks the org assertion.
|
||||
*/
|
||||
const resetUsage = async (organisation: Organisation) => {
|
||||
const period = currentMonthlyPeriod();
|
||||
|
||||
await prisma.organisationMonthlyStat.updateMany({
|
||||
where: { organisationId: organisation.id, period },
|
||||
data: {
|
||||
documentCount: 0,
|
||||
emailCount: 0,
|
||||
apiCount: 0,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.rateLimit.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: `ip:org:${organisation.id}` }, { action: 'api.v2' }],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type MonthlyCounter = 'documentCount' | 'emailCount' | 'apiCount';
|
||||
|
||||
const getMonthlyStat = async (organisation: Organisation) =>
|
||||
prisma.organisationMonthlyStat.findUnique({
|
||||
where: {
|
||||
organisationId_period: { organisationId: organisation.id, period: currentMonthlyPeriod() },
|
||||
},
|
||||
select: { documentCount: true, emailCount: true, apiCount: true },
|
||||
});
|
||||
|
||||
/**
|
||||
* Assert the live OrganisationMonthlyStat counter equals `expected`.
|
||||
*
|
||||
* The DB counter is the source of truth for quota enforcement, so checking its
|
||||
* exact value (not just the HTTP response) proves the documented increment
|
||||
* semantics in check-monthly-quota.ts:
|
||||
* - quota === null -> unlimited: never blocks, but the request is STILL
|
||||
* counted (the upsert now runs before the null return)
|
||||
* - quota === 0 -> throws BEFORE increment (stays 0)
|
||||
* - quota > 0 -> incremented by `count` BEFORE the over-quota check, so
|
||||
* even the request that gets rejected still advances it
|
||||
* - windowed limit -> trips BEFORE the quota stage, so the counter is untouched
|
||||
*/
|
||||
const expectMonthlyCounter = async (organisation: Organisation, counter: MonthlyCounter, expected: number) => {
|
||||
const stat = await getMonthlyStat(organisation);
|
||||
|
||||
expect(stat?.[counter] ?? 0, `${counter} should be exactly ${expected}`).toBe(expected);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wait until a monthly counter reaches `atLeast` and then stops changing.
|
||||
*
|
||||
* `distribute` fans out one async signing-request email job per recipient (the
|
||||
* local job runner fires them via fire-and-forget HTTP, so they complete after
|
||||
* the call returns). Each job increments emailCount. We poll until the counter
|
||||
* has reached the expected floor AND is stable across consecutive reads, which
|
||||
* guarantees no late job will increment the counter after the caller resets
|
||||
* usage — making the subsequent (synchronous) redistribute assertions exact.
|
||||
*/
|
||||
const waitForCounterToSettle = async (
|
||||
organisation: Organisation,
|
||||
counter: MonthlyCounter,
|
||||
atLeast: number,
|
||||
timeoutMs = 20_000,
|
||||
): Promise<number> => {
|
||||
const start = Date.now();
|
||||
let previous = -1;
|
||||
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const stat = await getMonthlyStat(organisation);
|
||||
const current = stat?.[counter] ?? 0;
|
||||
|
||||
if (current >= atLeast && current === previous) {
|
||||
return current;
|
||||
}
|
||||
|
||||
previous = current;
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for ${counter} to settle at >= ${atLeast}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Sleep until just after the next windowed-limit bucket boundary.
|
||||
*
|
||||
* The limiter (createRateLimit -> getBucket) buckets time as
|
||||
* `now - (now % windowMs)` aligned to the epoch. A windowed exhaustion test must
|
||||
* land all of its MAX+1 requests inside ONE bucket; if the requests straddle a
|
||||
* boundary the counter resets mid-test and the expected 429 never happens. We
|
||||
* share the server's clock (same host), so aligning to a fresh bucket here makes
|
||||
* the exhaustion deterministic.
|
||||
*/
|
||||
const alignToFreshWindowBucket = async (windowSeconds: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msUntilNextBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, msUntilNextBucket + 100));
|
||||
};
|
||||
|
||||
/**
|
||||
* Guarantee at least `requiredHeadroomMs` remain in the current bucket so a burst
|
||||
* of MAX+1 requests completes inside ONE window. Without this, a burst that
|
||||
* happens to cross a bucket boundary would have its count reset mid-test and the
|
||||
* expected 429 would never fire. Unlike `alignToFreshWindowBucket`, this only
|
||||
* sleeps when we are actually near a boundary, so for long (e.g. 1m) windows it
|
||||
* is almost always a no-op.
|
||||
*/
|
||||
const ensureWindowHeadroom = async (windowSeconds: number, requiredHeadroomMs: number) => {
|
||||
const windowMs = windowSeconds * 1000;
|
||||
const msLeftInBucket = windowMs - (Date.now() % windowMs);
|
||||
|
||||
if (msLeftInBucket < requiredHeadroomMs) {
|
||||
await new Promise((resolve) => setTimeout(resolve, msLeftInBucket + 100));
|
||||
}
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ApiErrorBody = { message?: string; error?: string };
|
||||
|
||||
/**
|
||||
* Non-throwing predicate: true when the response is an ORG-level 429
|
||||
* (`{ message }`), not the global IP 429 (`{ error }`). Used by the preflight,
|
||||
* which needs a boolean to decide whether to skip rather than fail.
|
||||
*/
|
||||
const isOrgLimited = async (res: APIResponse): Promise<boolean> => {
|
||||
if (res.status() !== 429) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = (await res.json().catch(() => ({}))) as ApiErrorBody;
|
||||
|
||||
// Global limiter returns `{ error }`; org limiter returns `{ message }`.
|
||||
return body.message !== undefined && body.error === undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the response is an ORG-level 429 and return its parsed body.
|
||||
*
|
||||
* Checks the status code EXPLICITLY so a wrong 200/4xx fails with a clear
|
||||
* "Expected 429, got <status>: <body>" message instead of an opaque
|
||||
* `expected true, received false`. Also asserts the body is the org limiter's
|
||||
* `{ message }` shape and not the global limiter's `{ error }` shape, so a
|
||||
* global-IP 429 can never be mistaken for the org limit under test.
|
||||
*/
|
||||
const expectOrgLimited = async (res: APIResponse): Promise<ApiErrorBody> => {
|
||||
const bodyText = await res.text();
|
||||
|
||||
expect(res.status(), `Expected an org 429 but got ${res.status()} with body: ${bodyText}`).toBe(429);
|
||||
|
||||
let body: ApiErrorBody = {};
|
||||
|
||||
try {
|
||||
body = JSON.parse(bodyText) as ApiErrorBody;
|
||||
} catch {
|
||||
throw new Error(`Expected a JSON error body, got: ${bodyText}`);
|
||||
}
|
||||
|
||||
expect(
|
||||
body.message !== undefined && body.error === undefined,
|
||||
`429 should be the ORG limiter ({ message }), not the global limiter ({ error }). Got: ${bodyText}`,
|
||||
).toBeTruthy();
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert the org windowed-limit value is present in `X-RateLimit-Limit`.
|
||||
*
|
||||
* Two limiters set this header: the GLOBAL /api/v2 middleware (max 100) sets it
|
||||
* first, then the org limiter's AppError sets it to the org `max`. Playwright
|
||||
* surfaces duplicate headers joined by ", " (e.g. "100, 4"), so we assert the
|
||||
* org value is one of the comma-separated entries rather than an exact match.
|
||||
*/
|
||||
const expectRateLimitHeaderToInclude = (res: APIResponse, expectedMax: number) => {
|
||||
const header = res.headers()['x-ratelimit-limit'] ?? '';
|
||||
const values = header.split(',').map((v) => v.trim());
|
||||
|
||||
expect(values, `X-RateLimit-Limit "${header}" should include the org max ${expectedMax}`).toContain(
|
||||
String(expectedMax),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Assert NO org rate-limit header was added — used for quota rejections, which
|
||||
* intentionally omit rate-limit headers (a quota isn't a window). The GLOBAL
|
||||
* middleware still stamps a single `X-RateLimit-Limit: 100`, so "no org header"
|
||||
* means the value is either absent or exactly the lone global `100` (i.e. it does
|
||||
* not contain a second, org-specific entry).
|
||||
*/
|
||||
const expectNoOrgRateLimitHeader = (res: APIResponse) => {
|
||||
const header = res.headers()['x-ratelimit-limit'];
|
||||
|
||||
if (header === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const values = header.split(',').map((v) => v.trim());
|
||||
|
||||
expect(values, `Quota rejection should not add an org X-RateLimit-Limit, got "${header}"`).toEqual(['100']);
|
||||
};
|
||||
|
||||
/** Guard against the global limiter silently masking an org assertion. */
|
||||
const expectNotGlobalLimited = async (res: APIResponse) => {
|
||||
if (res.status() === 429) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
|
||||
expect(
|
||||
'error' in body && !('message' in body),
|
||||
'Hit the GLOBAL /api/v2 IP limiter, not the org limiter. Re-run this suite in isolation.',
|
||||
).toBeFalsy();
|
||||
}
|
||||
};
|
||||
|
||||
/** Cheap read endpoint — consumes exactly one `api` counter, no document/email. */
|
||||
const findEnvelopes = (request: APIRequestContext, token: string): Promise<APIResponse> =>
|
||||
request.get(`${baseUrl}/envelope?perPage=1`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a DOCUMENT envelope. Consumes one `api` counter and, when
|
||||
* `type === DOCUMENT`, one `document` counter. Optionally seeds SIGNER recipients
|
||||
* (each with a signature field) so the envelope can later be distributed.
|
||||
*/
|
||||
const createEnvelope = async (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
options: { recipientCount?: number } = {},
|
||||
): Promise<APIResponse> => {
|
||||
const { recipientCount = 0 } = options;
|
||||
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
title: `Rate limit test ${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
recipients:
|
||||
recipientCount > 0
|
||||
? Array.from({ length: recipientCount }, (_, i) => ({
|
||||
email: `rl-${Date.now()}-${i}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
name: `Recipient ${i}`,
|
||||
role: RecipientRole.SIGNER,
|
||||
signingOrder: i + 1,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
fields: [
|
||||
{
|
||||
type: 'SIGNATURE',
|
||||
fieldMeta: { type: 'signature', overflow: 'crop' },
|
||||
identifier: 0,
|
||||
page: 1,
|
||||
positionX: 10,
|
||||
positionY: 80,
|
||||
width: 20,
|
||||
height: 5,
|
||||
},
|
||||
],
|
||||
}))
|
||||
: undefined,
|
||||
meta: {
|
||||
subject: 'Rate limit test',
|
||||
message: 'Automated rate-limit test. Ignore.',
|
||||
distributionMethod: 'EMAIL',
|
||||
},
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
return request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
multipart: formData,
|
||||
});
|
||||
};
|
||||
|
||||
/** Distribute an envelope to all of its recipients via EMAIL. */
|
||||
const distributeEnvelope = (request: APIRequestContext, token: string, envelopeId: string): Promise<APIResponse> =>
|
||||
request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
meta: { distributionMethod: 'EMAIL', subject: 'Rate limit test', message: 'Rate limit test' },
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Redistribute (remind) the given recipients. This runs the SYNCHRONOUS email
|
||||
* assertion in resend-document with `count = recipients.length`, returning a 429
|
||||
* directly when the email limit/quota is exceeded.
|
||||
*/
|
||||
const redistributeEnvelope = (
|
||||
request: APIRequestContext,
|
||||
token: string,
|
||||
envelopeId: string,
|
||||
recipientIds: number[],
|
||||
): Promise<APIResponse> =>
|
||||
request.post(`${baseUrl}/envelope/redistribute`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId, recipients: recipientIds },
|
||||
});
|
||||
|
||||
/**
|
||||
* Build a fully-distributed envelope and return its NOT_SIGNED recipient IDs so a
|
||||
* subsequent redistribute can exercise the synchronous email assertion.
|
||||
*
|
||||
* Setup uses a GENEROUS email quota so the async signing-request emails fanned out
|
||||
* by `distribute` are counted, then waits for that counter to settle. This drains
|
||||
* the background jobs BEFORE the caller resets usage, so they can't pollute
|
||||
* emailCount mid-test. The caller then configures the email limit/quota under test
|
||||
* and resets usage, so only the (synchronous, deterministic) redistribute counts.
|
||||
*/
|
||||
const seedDistributedEnvelope = async ({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount,
|
||||
}: {
|
||||
request: APIRequestContext;
|
||||
token: string;
|
||||
team: Team;
|
||||
organisation: Organisation;
|
||||
recipientCount: number;
|
||||
}): Promise<{ envelopeId: string; recipientIds: number[] }> => {
|
||||
await setClaimLimits(team, { emailQuota: 1000 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const createRes = await createEnvelope(request, token, { recipientCount });
|
||||
expect(createRes.ok(), `create failed: ${await createRes.text()}`).toBeTruthy();
|
||||
const { id: envelopeId } = (await createRes.json()) as TCreateEnvelopeResponse;
|
||||
|
||||
const distributeRes = await distributeEnvelope(request, token, envelopeId);
|
||||
expect(distributeRes.ok(), `distribute failed: ${await distributeRes.text()}`).toBeTruthy();
|
||||
|
||||
const recipients = await prisma.recipient.findMany({
|
||||
where: { envelopeId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// Drain the async signing-request email jobs (one per recipient) so a late job
|
||||
// cannot increment emailCount after the caller's resetUsage.
|
||||
await waitForCounterToSettle(organisation, 'emailCount', recipientCount);
|
||||
|
||||
return { envelopeId, recipientIds: recipients.map((r) => r.id) };
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// Tests
|
||||
// ===========================================================================
|
||||
|
||||
test.describe('Organisation dynamic rate limits & quotas', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let organisation: Organisation;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
const seeded = await seedUser();
|
||||
user = seeded.user;
|
||||
team = seeded.team;
|
||||
organisation = seeded.organisation;
|
||||
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-org-rate-limits',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
// Preflight: the `test.skip` above only sees the PLAYWRIGHT process env. The
|
||||
// value that actually matters is the env the SERVER was started with — if the
|
||||
// server has `DANGEROUS_BYPASS_RATE_LIMITS=true`, every assertion here would
|
||||
// fail confusingly instead of skipping. Prove enforcement is live by setting a
|
||||
// quota of 0 (instant hard block) and confirming the server rejects. If it
|
||||
// doesn't, the server is bypassing limits, so skip with a clear message.
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const preflight = await findEnvelopes(request, token);
|
||||
const enforced = await isOrgLimited(preflight);
|
||||
|
||||
// Reset back to a clean slate before the real scenario runs.
|
||||
await setClaimLimits(team, {});
|
||||
await resetUsage(organisation);
|
||||
|
||||
test.skip(
|
||||
!enforced,
|
||||
'Server is not enforcing organisation rate limits (likely started with DANGEROUS_BYPASS_RATE_LIMITS=true). Restart the server with the flag unset/false to run this suite.',
|
||||
);
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — windowed rate limit
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api rate limit (windowed)', () => {
|
||||
test('allows requests up to the limit then 429s with rate-limit headers', async ({ request }) => {
|
||||
const MAX = 4;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure the MAX+1 request burst lands inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
// Each request (including these GETs) consumes one api counter.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is over the windowed limit.
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
// The windowed limit uses a message distinct from the global limiter.
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectRateLimitHeaderToInclude(limitedRes, MAX);
|
||||
expect(limitedRes.headers()['x-ratelimit-remaining']).toContain('0');
|
||||
expect(limitedRes.headers()['retry-after']).toBeTruthy();
|
||||
|
||||
// The windowed stage blocks the (MAX+1)th request before the quota upsert,
|
||||
// but each of the MAX allowed requests still records usage (null quota now
|
||||
// tracks instead of skipping), so the counter equals MAX.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', MAX);
|
||||
});
|
||||
|
||||
test('a single allowed request succeeds when the limit is 1', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: '1m', max: 1 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure both requests land inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
const okRes = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
|
||||
// The one allowed request is counted (null quota still tracks); the blocked
|
||||
// request trips the window before the quota upsert, so the counter is 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 1);
|
||||
});
|
||||
|
||||
test('the windowed limit RESETS once the window elapses (429 -> wait -> 200)', async ({ request }) => {
|
||||
const MAX = 2;
|
||||
const WINDOW_SECONDS = 3;
|
||||
await setClaimLimits(team, { apiRateLimits: [{ window: `${WINDOW_SECONDS}s`, max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Land at the start of a fresh bucket so all MAX+1 requests below fall in
|
||||
// the SAME window (otherwise a mid-exhaustion boundary would reset the count).
|
||||
await alignToFreshWindowBucket(WINDOW_SECONDS);
|
||||
|
||||
// Exhaust the window.
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be allowed`).toBe(200);
|
||||
}
|
||||
|
||||
// The next request is blocked by the window.
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// Wait out the window using the server-provided Retry-After (plus a small
|
||||
// buffer to be sure we've crossed into the next time bucket). Crucially we
|
||||
// do NOT reset usage here — the limiter must recover on its own as the
|
||||
// bucket rolls over.
|
||||
const retryAfterHeader = limitedRes.headers()['retry-after'] ?? String(WINDOW_SECONDS);
|
||||
const retryAfterSeconds = Number.parseInt(retryAfterHeader.split(',')[0]?.trim() ?? '', 10) || WINDOW_SECONDS;
|
||||
await new Promise((resolve) => setTimeout(resolve, (retryAfterSeconds + 1) * 1000));
|
||||
|
||||
// Window has elapsed: the same org can make requests again without any
|
||||
// manual intervention — the bucket rolled over on its own.
|
||||
const afterReset = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(afterReset);
|
||||
expect(afterReset.status(), 'request after the window elapsed should be allowed').toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// API counter — monthly quota
|
||||
// =========================================================================
|
||||
|
||||
test.describe('api quota (monthly)', () => {
|
||||
test('null quota allows unlimited requests', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: null });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < 6; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
// A null quota means "unlimited" (never blocks), but every request is now
|
||||
// recorded so usage is visible on unlimited plans — so the counter is 6.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 6);
|
||||
});
|
||||
|
||||
test('exhausting the quota 429s without rate-limit headers and keeps counting', async ({ request }) => {
|
||||
const QUOTA = 3;
|
||||
await setClaimLimits(team, { apiQuota: QUOTA });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status(), `request #${i + 1} should be within quota`).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// Quota rejections deliberately omit rate-limit headers (it isn't a window).
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// The atomic increment runs even on the rejected request: QUOTA allowed
|
||||
// requests + the one rejected request = exactly QUOTA + 1.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
|
||||
test('quota of exactly 1 allows one request then blocks', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 1 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const okRes = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(okRes);
|
||||
expect(okRes.status()).toBe(200);
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// One allowed + one rejected, both incremented => exactly 2.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 2);
|
||||
});
|
||||
|
||||
test('quota of 0 is a hard block with a "no quota available" message', async ({ request }) => {
|
||||
await setClaimLimits(team, { apiQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Document counter — windowed rate limit
|
||||
// =========================================================================
|
||||
|
||||
test.describe('document rate limit (windowed)', () => {
|
||||
test('allows creates up to the limit then 429s with headers', async ({ request }) => {
|
||||
const MAX = 3;
|
||||
// Keep api unlimited so only the document stage can trip.
|
||||
await setClaimLimits(team, { documentRateLimits: [{ window: '1m', max: MAX }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
// Make sure the MAX+1 create burst lands inside a single 1m bucket.
|
||||
await ensureWindowHeadroom(60, 10_000);
|
||||
|
||||
for (let i = 0; i < MAX; i += 1) {
|
||||
const res = await createEnvelope(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `create #${i + 1} should succeed`).toBeTruthy();
|
||||
}
|
||||
|
||||
const limitedRes = await createEnvelope(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectRateLimitHeaderToInclude(limitedRes, MAX);
|
||||
expect(limitedRes.headers()['retry-after']).toBeTruthy();
|
||||
|
||||
// The (MAX+1)th create trips the window before the quota upsert, but each of
|
||||
// the MAX allowed creates still records usage (null quota now tracks).
|
||||
await expectMonthlyCounter(organisation, 'documentCount', MAX);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Document counter — monthly quota
|
||||
// =========================================================================
|
||||
|
||||
test.describe('document quota (monthly)', () => {
|
||||
test('exhausting the document quota blocks further creates', async ({ request }) => {
|
||||
const QUOTA = 2;
|
||||
await setClaimLimits(team, { documentQuota: QUOTA });
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await createEnvelope(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `create #${i + 1} should be within quota`).toBeTruthy();
|
||||
}
|
||||
|
||||
const limitedRes = await createEnvelope(request, token);
|
||||
await expectOrgLimited(limitedRes);
|
||||
|
||||
// QUOTA successful creates + the rejected one (incremented before throwing).
|
||||
await expectMonthlyCounter(organisation, 'documentCount', QUOTA + 1);
|
||||
});
|
||||
|
||||
test('document quota of 0 hard-blocks creation', async ({ request }) => {
|
||||
await setClaimLimits(team, { documentQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const limitedRes = await createEnvelope(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'documentCount', 0);
|
||||
});
|
||||
|
||||
test('null document quota allows creation', async ({ request }) => {
|
||||
await setClaimLimits(team, { documentQuota: null });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await createEnvelope(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
// A null quota is unlimited (never blocks) but is now still recorded, so the
|
||||
// single create advances the counter to 1.
|
||||
await expectMonthlyCounter(organisation, 'documentCount', 1);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — windowed rate limit (via synchronous redistribute)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email rate limit (windowed)', () => {
|
||||
test('redistribute is allowed when recipient count is within the email window', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
// Window allows 5/min; reminding 2 recipients is fine. Reset usage so the
|
||||
// create/distribute consumption above doesn't count against this window.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 5 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The windowed pass is now recorded even though the quota is null, so the
|
||||
// counter advances by the batch size (recipientIds.length).
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('redistribute is blocked when recipient count exceeds the email window', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 3,
|
||||
});
|
||||
|
||||
// Window only allows 2 emails per minute; reminding 3 at once exceeds it.
|
||||
await setClaimLimits(team, { emailRateLimits: [{ window: '1m', max: 2 }] });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
const body = await expectOrgLimited(res);
|
||||
expect(String(body.message)).toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectRateLimitHeaderToInclude(res, 2);
|
||||
|
||||
// Windowed limit trips BEFORE the quota stage, so the counter is untouched.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Email counter — monthly quota (via synchronous redistribute)
|
||||
// =========================================================================
|
||||
|
||||
test.describe('email quota (monthly)', () => {
|
||||
test('redistribute within the remaining email quota succeeds', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 10 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
// The synchronous assertion consumed exactly `recipientIds.length` of quota.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('redistribute that would exceed the email quota is blocked', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 3,
|
||||
});
|
||||
|
||||
// Quota of 2 but reminding 3 recipients in one synchronous call.
|
||||
await setClaimLimits(team, { emailQuota: 2 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
const body = await expectOrgLimited(res);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// Quota rejection carries no rate-limit headers.
|
||||
expectNoOrgRateLimitHeader(res);
|
||||
|
||||
// The count (3) is added BEFORE the over-quota check throws, so the counter
|
||||
// advances by the full batch even though the request was rejected.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', recipientIds.length);
|
||||
});
|
||||
|
||||
test('email quota of 0 hard-blocks reminders', async ({ request }) => {
|
||||
const { envelopeId, recipientIds } = await seedDistributedEnvelope({
|
||||
request,
|
||||
token,
|
||||
team,
|
||||
organisation,
|
||||
recipientCount: 1,
|
||||
});
|
||||
|
||||
await setClaimLimits(team, { emailQuota: 0 });
|
||||
await resetUsage(organisation);
|
||||
|
||||
const res = await redistributeEnvelope(request, token, envelopeId, recipientIds);
|
||||
const body = await expectOrgLimited(res);
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
|
||||
// quota === 0 throws before the increment, so the counter stays at zero.
|
||||
await expectMonthlyCounter(organisation, 'emailCount', 0);
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// Stage interaction — quota binds before a looser window
|
||||
// =========================================================================
|
||||
|
||||
test.describe('stage interaction', () => {
|
||||
test('the quota trips before a looser windowed limit', async ({ request }) => {
|
||||
const WINDOW_MAX = 50; // generous window
|
||||
const QUOTA = 2; // strict quota — should bind first
|
||||
await setClaimLimits(team, {
|
||||
apiRateLimits: [{ window: '1m', max: WINDOW_MAX }],
|
||||
apiQuota: QUOTA,
|
||||
});
|
||||
await resetUsage(organisation);
|
||||
|
||||
for (let i = 0; i < QUOTA; i += 1) {
|
||||
const res = await findEnvelopes(request, token);
|
||||
await expectNotGlobalLimited(res);
|
||||
expect(res.status()).toBe(200);
|
||||
}
|
||||
|
||||
const limitedRes = await findEnvelopes(request, token);
|
||||
const body = await expectOrgLimited(limitedRes);
|
||||
|
||||
// It must be the QUOTA that bound, not the window: the message is the quota
|
||||
// one (not the windowed-limit message) and there are no rate-limit headers.
|
||||
expect(String(body.message)).toMatch(NO_QUOTA_MESSAGE);
|
||||
expect(String(body.message)).not.toMatch(WINDOWED_LIMIT_MESSAGE);
|
||||
expectNoOrgRateLimitHeader(limitedRes);
|
||||
|
||||
// Quota bound at QUOTA + 1; the looser window (50) was never the limiter.
|
||||
await expectMonthlyCounter(organisation, 'apiCount', QUOTA + 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, EnvelopeType, FieldType, RecipientRole } from '@documenso/prisma/client';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
TCreateEnvelopePayload,
|
||||
TCreateEnvelopeResponse,
|
||||
} from '@documenso/trpc/server/envelope-router/create-envelope.types';
|
||||
import type { TDistributeEnvelopeRequest } from '@documenso/trpc/server/envelope-router/distribute-envelope.types';
|
||||
import type { TCreateEnvelopeRecipientsRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/create-envelope-recipients.types';
|
||||
import type { TGetEnvelopeResponse } from '@documenso/trpc/server/envelope-router/get-envelope.types';
|
||||
import { type APIRequestContext, type APIResponse, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const examplePdfBuffer = fs.readFileSync(path.join(__dirname, '../../../../../assets/example.pdf'));
|
||||
|
||||
/**
|
||||
* Set the `recipientCount` limit on the organisation that owns the seeded team.
|
||||
*
|
||||
* A value of `0` means unlimited recipients are allowed.
|
||||
*/
|
||||
const setOrganisationRecipientCount = async (team: Team, recipientCount: number) => {
|
||||
const organisationClaim = await prisma.organisationClaim.findFirstOrThrow({
|
||||
where: {
|
||||
organisation: {
|
||||
id: team.organisationId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
recipientCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createEnvelope = async (request: APIRequestContext, authToken: string) => {
|
||||
const payload: TCreateEnvelopePayload = {
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
title: 'Recipient Count Limit Test',
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('payload', JSON.stringify(payload));
|
||||
formData.append('files', new File([examplePdfBuffer], 'example.pdf', { type: 'application/pdf' }));
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/create`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
multipart: formData,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TCreateEnvelopeResponse;
|
||||
};
|
||||
|
||||
const getEnvelope = async (request: APIRequestContext, authToken: string, envelopeId: string) => {
|
||||
const res = await request.get(`${baseUrl}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${authToken}` },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
return (await res.json()) as TGetEnvelopeResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build an envelope with exactly `recipientCount` SIGNER recipients, each with
|
||||
* their own signature field, then attempt to distribute it.
|
||||
*
|
||||
* Returns the raw distribute response so the caller can assert on the status.
|
||||
*/
|
||||
const buildAndDistributeEnvelopeWithRecipients = async ({
|
||||
request,
|
||||
authToken,
|
||||
recipientCount,
|
||||
}: {
|
||||
request: APIRequestContext;
|
||||
authToken: string;
|
||||
recipientCount: number;
|
||||
}): Promise<{ envelopeId: string; distributeRes: APIResponse }> => {
|
||||
const envelope = await createEnvelope(request, authToken);
|
||||
|
||||
// Create N SIGNER recipients in a single request.
|
||||
const recipientData = Array.from({ length: recipientCount }).map((_, index) => ({
|
||||
email: `recipient-${index}-${Date.now()}-${Math.random().toString(36).slice(2)}@test.documenso.com`,
|
||||
name: `Recipient ${index}`,
|
||||
role: RecipientRole.SIGNER,
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
}));
|
||||
|
||||
const recipientsRes = await request.post(`${baseUrl}/envelope/recipient/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: recipientData,
|
||||
} satisfies TCreateEnvelopeRecipientsRequest,
|
||||
});
|
||||
|
||||
expect(recipientsRes.ok()).toBeTruthy();
|
||||
|
||||
const recipients = (await recipientsRes.json()).data;
|
||||
|
||||
// Resolve the envelope item ID to place fields on.
|
||||
const envelopeData = await getEnvelope(request, authToken, envelope.id);
|
||||
const envelopeItemId = envelopeData.envelopeItems[0].id;
|
||||
|
||||
// Each SIGNER must have a signature field, otherwise distribution fails for
|
||||
// a reason unrelated to the recipient count.
|
||||
const fieldData = recipients.map((recipient: { id: number }) => ({
|
||||
recipientId: recipient.id,
|
||||
envelopeItemId,
|
||||
type: FieldType.SIGNATURE,
|
||||
page: 1,
|
||||
positionX: 100,
|
||||
positionY: 100,
|
||||
width: 50,
|
||||
height: 50,
|
||||
}));
|
||||
|
||||
const fieldsRes = await request.post(`${baseUrl}/envelope/field/create-many`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
data: fieldData,
|
||||
},
|
||||
});
|
||||
|
||||
expect(fieldsRes.ok()).toBeTruthy();
|
||||
|
||||
// Attempt to distribute the envelope.
|
||||
const distributeRes = await request.post(`${baseUrl}/envelope/distribute`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId: envelope.id,
|
||||
} satisfies TDistributeEnvelopeRequest,
|
||||
});
|
||||
|
||||
return { envelopeId: envelope.id, distributeRes };
|
||||
};
|
||||
|
||||
const expectEnvelopeStatus = async (envelopeId: string, status: DocumentStatus) => {
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelopeId },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(status);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe('Recipient count limit on distribute', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-recipient-count-limit',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Limit = 3. Edge cases around the boundary: 2 (under), 3 (at), 4 (over).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow distribution when recipient count is below the limit', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 3);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('should allow distribution when recipient count is exactly at the limit', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 3);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 3,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('should deny distribution when recipient count is one over the limit', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 3);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 4,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeFalsy();
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
|
||||
// The envelope must remain a DRAFT — distribution was rejected.
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Limit = 1. The smallest non-unlimited boundary.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow distribution with a single recipient when the limit is 1', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 1);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 1,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('should deny distribution with two recipients when the limit is 1', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 1);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 2,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeFalsy();
|
||||
expect(distributeRes.status()).toBe(400);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Limit = 0 means unlimited recipients are allowed.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('should allow distribution with many recipients when the limit is 0 (unlimited)', async ({ request }) => {
|
||||
await setOrganisationRecipientCount(team, 0);
|
||||
|
||||
const { envelopeId, distributeRes } = await buildAndDistributeEnvelopeWithRecipients({
|
||||
request,
|
||||
authToken: token,
|
||||
recipientCount: 10,
|
||||
});
|
||||
|
||||
expect(distributeRes.ok()).toBeTruthy();
|
||||
expect(distributeRes.status()).toBe(200);
|
||||
|
||||
await expectEnvelopeStatus(envelopeId, DocumentStatus.PENDING);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SendStatus, SigningStatus } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test.describe('Redistribute updates recipient send status', () => {
|
||||
let user: User, team: Team, token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('marks a NOT_SENT signer as SENT after a successful resend', async ({ request }) => {
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const [recipient] = document.recipients;
|
||||
|
||||
// Simulate a recipient that is stuck at NOT_SENT on a pending document
|
||||
// (e.g. the initial send did not dispatch an email for them).
|
||||
await prisma.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.NOT_SENT,
|
||||
signingStatus: SigningStatus.NOT_SIGNED,
|
||||
sentAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request.post(`${baseUrl}/document/redistribute`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
data: {
|
||||
documentId: mapSecondaryIdToDocumentId(document.secondaryId),
|
||||
recipients: [recipient.id],
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.ok(), `redistribute should succeed: ${await res.text()}`).toBeTruthy();
|
||||
|
||||
const updatedRecipient = await prisma.recipient.findFirstOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.sendStatus).toBe(SendStatus.SENT);
|
||||
expect(updatedRecipient.sentAt).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, SigningStatus, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { TRejectEnvelopeRecipientOnBehalfOfRequest } from '@documenso/trpc/server/envelope-router/envelope-recipients/reject-envelope-recipient-on-behalf-of.types';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const rejectRecipient = (
|
||||
request: APIRequestContext,
|
||||
authToken: string,
|
||||
envelopeId: string,
|
||||
recipientId: number,
|
||||
reason: string,
|
||||
actAsEmail?: string,
|
||||
) => {
|
||||
return request.post(`${baseUrl}/envelope/recipient/${recipientId}/reject`, {
|
||||
headers: { Authorization: `Bearer ${authToken}`, 'Content-Type': 'application/json' },
|
||||
data: {
|
||||
envelopeId,
|
||||
recipientId,
|
||||
reason,
|
||||
actAsEmail,
|
||||
} satisfies TRejectEnvelopeRecipientOnBehalfOfRequest,
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Reject recipient on behalf of', () => {
|
||||
let user: User;
|
||||
let team: Team;
|
||||
let token: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user, team } = await seedUser());
|
||||
({ token } = await createApiToken({
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'test-reject-recipient',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('should reject a recipient and record an external rejection audit log', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band');
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.signingStatus).toBe(SigningStatus.REJECTED);
|
||||
expect(updatedRecipient.rejectionReason).toBe('Declined out of band');
|
||||
|
||||
const auditLog = await prisma.documentAuditLog.findFirst({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(auditLog).not.toBeNull();
|
||||
|
||||
const auditData = auditLog!.data as Record<string, unknown>;
|
||||
|
||||
expect(auditData.recipientId).toBe(recipient.id);
|
||||
expect(auditData.recipientEmail).toBe(recipient.email);
|
||||
expect(auditData.reason).toBe('Declined out of band');
|
||||
expect(auditData.isExternal).toBe(true);
|
||||
|
||||
// No actAsEmail supplied - the rejection defaults to the API user.
|
||||
expect(auditLog!.userId).toBe(user.id);
|
||||
expect(auditLog!.email).toBe(user.email);
|
||||
expect(auditData.onBehalfOfUserEmail).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should attribute the rejection to the elected team member when actAsEmail is supplied', async ({ request }) => {
|
||||
const member = await seedTeamMember({ teamId: team.id });
|
||||
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Declined out of band', member.email);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const auditLog = await prisma.documentAuditLog.findFirstOrThrow({
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
type: 'DOCUMENT_RECIPIENT_REJECTED',
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
// The audit log actor must be the elected member, not the API user.
|
||||
expect(auditLog.userId).toBe(member.id);
|
||||
expect(auditLog.email).toBe(member.email);
|
||||
|
||||
const auditData = auditLog.data as Record<string, unknown>;
|
||||
|
||||
expect(auditData.isExternal).toBe(true);
|
||||
expect(auditData.onBehalfOfUserEmail).toBe(member.email);
|
||||
});
|
||||
|
||||
test('should reject when actAsEmail is not a member of the team', async ({ request }) => {
|
||||
// A user that exists but belongs to a different team.
|
||||
const { user: outsider } = await seedUser();
|
||||
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(
|
||||
request,
|
||||
token,
|
||||
envelope.id,
|
||||
recipient.id,
|
||||
'Declined out of band',
|
||||
outsider.email,
|
||||
);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should deny rejecting a recipient that has already actioned the document', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
// Reject once - succeeds.
|
||||
const firstRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'First rejection');
|
||||
expect(firstRes.ok()).toBeTruthy();
|
||||
|
||||
// Reject again - the recipient is no longer NOT_SIGNED.
|
||||
const secondRes = await rejectRecipient(request, token, envelope.id, recipient.id, 'Second rejection');
|
||||
|
||||
expect(secondRes.ok()).toBeFalsy();
|
||||
expect(secondRes.status()).toBe(400);
|
||||
|
||||
// The original rejection reason must remain unchanged.
|
||||
const updatedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(updatedRecipient.rejectionReason).toBe('First rejection');
|
||||
});
|
||||
|
||||
test('should not allow rejecting a recipient in another team', async ({ request }) => {
|
||||
// Seed a separate team/user that owns the document.
|
||||
const { user: otherUser, team: otherTeam } = await seedUser();
|
||||
|
||||
const envelope = await seedPendingDocument(otherUser, otherTeam.id, ['recipient@test.documenso.com']);
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
// Use the original team's token - it must not be able to reject.
|
||||
const res = await rejectRecipient(request, token, envelope.id, recipient.id, 'Should not work');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should return 404 for a non-existent recipient', async ({ request }) => {
|
||||
const envelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await rejectRecipient(request, token, envelope.id, 999999999, 'No such recipient');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should return 404 when the recipient does not belong to the supplied envelope', async ({ request }) => {
|
||||
const targetEnvelope = await seedPendingDocument(user, team.id, ['recipient@test.documenso.com']);
|
||||
const otherEnvelope = await seedPendingDocument(user, team.id, ['other-recipient@test.documenso.com']);
|
||||
|
||||
const recipient = targetEnvelope.recipients[0];
|
||||
|
||||
// Valid recipient ID, but paired with the wrong envelope ID.
|
||||
const res = await rejectRecipient(request, token, otherEnvelope.id, recipient.id, 'Mismatched envelope');
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The recipient must remain untouched.
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
|
||||
test('should enforce document visibility: manager cannot reject on an ADMIN-only document', async ({ request }) => {
|
||||
// The API token belongs to a MANAGER, who cannot see ADMIN-visibility docs.
|
||||
const { team: visTeam, owner } = await seedTeam();
|
||||
const manager = await seedTeamMember({ teamId: visTeam.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const { token: managerToken } = await createApiToken({
|
||||
userId: manager.id,
|
||||
teamId: visTeam.id,
|
||||
tokenName: 'manager-reject-token',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
// ADMIN-visibility document owned by the team owner.
|
||||
const envelope = await seedPendingDocument(owner, visTeam.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
const recipient = envelope.recipients[0];
|
||||
|
||||
const res = await rejectRecipient(
|
||||
request,
|
||||
managerToken,
|
||||
envelope.id,
|
||||
recipient.id,
|
||||
'Should be hidden by visibility',
|
||||
);
|
||||
|
||||
// Visibility failure surfaces as not-found, matching the canonical checks.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const untouchedRecipient = await prisma.recipient.findUniqueOrThrow({
|
||||
where: { id: recipient.id },
|
||||
});
|
||||
|
||||
expect(untouchedRecipient.signingStatus).toBe(SigningStatus.NOT_SIGNED);
|
||||
expect(untouchedRecipient.rejectionReason).toBeNull();
|
||||
});
|
||||
});
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const baseUrl = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const createTokenForUser = async (userId: number, teamId: number, tokenName: string) => {
|
||||
const { token } = await createApiToken({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName,
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
return token;
|
||||
};
|
||||
|
||||
test.describe('Envelope cancel endpoint authorization', () => {
|
||||
test('hides the document from an outsider attempting to cancel it', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const { user: outsider, team: outsiderTeam } = await seedUser();
|
||||
const outsiderToken = await createTokenForUser(outsider.id, outsiderTeam.id, 'outsider');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${outsiderToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
// Outsiders must not be able to determine whether the envelope exists.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
// The document must be untouched.
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
test('hides the document from a recipient attempting to cancel it', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient, team: recipientTeam } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const recipientToken = await createTokenForUser(recipient.id, recipientTeam.id, 'recipient');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${recipientToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
// A recipient is not a member of the document's team, so they must not be
|
||||
// able to determine whether it exists via this endpoint.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
|
||||
// Note: a non-privileged MEMBER cannot obtain an API token at all (token
|
||||
// creation requires the MANAGE_TEAM permission), so the MEMBER cancellation
|
||||
// restriction is covered through the UI tests in cancel-documents.spec.ts
|
||||
// rather than at the API layer.
|
||||
|
||||
test('allows the document owner to cancel a pending document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true, completedAt: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(documentInDb.completedAt).not.toBeNull();
|
||||
expect(documentInDb.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('allows a team ADMIN to cancel a pending document they do not own', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const adminUser = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
role: TeamMemberRole.ADMIN,
|
||||
});
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const adminToken = await createTokenForUser(adminUser.id, team.id, 'admin');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${adminToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test('allows a team MANAGER to cancel a pending document they do not own', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const managerUser = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
role: TeamMemberRole.MANAGER,
|
||||
});
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const managerToken = await createTokenForUser(managerUser.id, team.id, 'manager');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${managerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test('rejects cancelling a draft document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const document = await seedDraftDocument(owner, team.id, []);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-draft');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.DRAFT);
|
||||
});
|
||||
|
||||
test('rejects cancelling a completed document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedCompletedDocument(owner, team.id, [recipient]);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-completed');
|
||||
|
||||
const res = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('rejects double cancellation of an already cancelled document', async ({ request }) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const { user: recipient } = await seedUser();
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient]);
|
||||
|
||||
const ownerToken = await createTokenForUser(owner.id, team.id, 'owner-double');
|
||||
|
||||
const firstRes = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(firstRes.status()).toBe(200);
|
||||
|
||||
const secondRes = await request.post(`${baseUrl}/envelope/cancel`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}` },
|
||||
data: { envelopeId: document.id },
|
||||
});
|
||||
|
||||
expect(secondRes.ok()).toBeFalsy();
|
||||
expect(secondRes.status()).toBe(400);
|
||||
|
||||
const documentInDb = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(documentInDb.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
});
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
import type { Team, User } from '@prisma/client';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
/**
|
||||
* Create an API token directly, bypassing the role check in `createApiToken`.
|
||||
*
|
||||
* This simulates a token that was minted while the user had permission, and which
|
||||
* survives a later downgrade to a lower team role (e.g. MEMBER). Such a token must
|
||||
* still respect document visibility at request time.
|
||||
*/
|
||||
const seedApiTokenForUser = async ({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
tokenName: string;
|
||||
}) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: {
|
||||
name: tokenName,
|
||||
token: hashString(token),
|
||||
expires: null,
|
||||
userId,
|
||||
teamId,
|
||||
},
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const downloadAuditLogPdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => {
|
||||
return request.get(`${API_BASE_URL}/envelope/${envelopeId}/audit-log/download`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
};
|
||||
|
||||
const downloadCertificatePdf = (request: APIRequestContext, envelopeId: string, authToken?: string) => {
|
||||
return request.get(`${API_BASE_URL}/envelope/${envelopeId}/certificate/download`, {
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('Envelope certificate / audit log PDF download API V2 - access control', () => {
|
||||
let userA: User, teamA: Team, userB: User, teamB: Team, tokenA: string, tokenB: string;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
({ user: userA, team: teamA } = await seedUser());
|
||||
({ token: tokenA } = await createApiToken({
|
||||
userId: userA.id,
|
||||
teamId: teamA.id,
|
||||
tokenName: 'userA',
|
||||
expiresIn: null,
|
||||
}));
|
||||
|
||||
({ user: userB, team: teamB } = await seedUser());
|
||||
({ token: tokenB } = await createApiToken({
|
||||
userId: userB.id,
|
||||
teamId: teamB.id,
|
||||
tokenName: 'userB',
|
||||
expiresIn: null,
|
||||
}));
|
||||
});
|
||||
|
||||
test('should reject audit log download without an API token', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject certificate download without an API token', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject audit log download from a user in a different team', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, tokenB);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should reject certificate download from a user in a different team', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, tokenB);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should reject a disabled user downloading the audit log', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userA.id },
|
||||
data: { disabled: true },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, tokenA);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should reject a disabled user downloading the certificate', async ({ request }) => {
|
||||
const document = await seedCompletedDocument(userA, teamA.id, ['recipient@test.documenso.com']);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userA.id },
|
||||
data: { disabled: true },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, tokenA);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('should return 404 for a non-existent envelope id', async ({ request }) => {
|
||||
const auditLogRes = await downloadAuditLogPdf(request, 'envelope_doesnotexist', tokenA);
|
||||
expect(auditLogRes.status()).toBe(404);
|
||||
|
||||
const certificateRes = await downloadCertificatePdf(request, 'envelope_doesnotexist', tokenA);
|
||||
expect(certificateRes.status()).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Envelope certificate / audit log PDF download API V2 - document visibility', () => {
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
test('should hide an ADMIN-only document from a downgraded member (audit log)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-audit-log-token',
|
||||
});
|
||||
|
||||
// ADMIN-visibility document owned by the team owner - a member must not see it.
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, memberToken);
|
||||
|
||||
// Visibility failure surfaces as not-found, matching the canonical access checks.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide an ADMIN-only document from a downgraded member (certificate)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-certificate-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide a MANAGER_AND_ABOVE document from a downgraded member (audit log)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-manager-vis-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide a MANAGER_AND_ABOVE document from a downgraded member (certificate)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-manager-vis-cert-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.MANAGER_AND_ABOVE },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should hide an ADMIN-only document from a downgraded manager (certificate)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const { token: managerToken } = await seedApiTokenForUser({
|
||||
userId: manager.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'manager-admin-vis-cert-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const res = await downloadCertificatePdf(request, document.id, managerToken);
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
|
||||
test('should allow a member to download an EVERYONE-visibility document (audit log)', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
tokenName: 'member-everyone-vis-token',
|
||||
});
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.EVERYONE },
|
||||
});
|
||||
|
||||
const res = await downloadAuditLogPdf(request, document.id, memberToken);
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
expect(res.headers()['content-type']).toContain('application/pdf');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createEmbeddingPresignToken } from '@documenso/lib/server-only/embedding-presign/create-embedding-presign-token';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
const examplePdf = fs.readFileSync(path.join(__dirname, '../../../../../../assets/example.pdf'));
|
||||
|
||||
test.describe.configure({
|
||||
mode: 'parallel',
|
||||
});
|
||||
|
||||
const createPresignTokenForUser = async (userId: number, teamId: number) => {
|
||||
const { token: apiToken } = await createApiToken({
|
||||
userId,
|
||||
teamId,
|
||||
tokenName: 'file-upload-test',
|
||||
expiresIn: null,
|
||||
});
|
||||
|
||||
const { token: presignToken } = await createEmbeddingPresignToken({ apiToken });
|
||||
|
||||
return presignToken;
|
||||
};
|
||||
|
||||
const buildPdfFormData = () => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File([examplePdf], 'test.pdf', { type: 'application/pdf' }));
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
test.describe('File upload endpoint authorization', () => {
|
||||
test('rejects an unauthenticated upload-pdf request', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
|
||||
multipart: buildPdfFormData(),
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects an unauthenticated presigned-post-url request', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with an invalid presign token', async ({ request }) => {
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer not-a-real-token',
|
||||
},
|
||||
data: { fileName: 'test.pdf', contentType: 'application/pdf' },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(401);
|
||||
});
|
||||
|
||||
test('rejects a presigned-post-url request with a disallowed content type', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/presigned-post-url`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${presignToken}`,
|
||||
},
|
||||
data: { fileName: 'malware.exe', contentType: 'application/x-msdownload' },
|
||||
});
|
||||
|
||||
// Authenticated, but the content type is not on the allow-list.
|
||||
expect(res.ok()).toBeFalsy();
|
||||
expect(res.status()).toBe(400);
|
||||
});
|
||||
|
||||
test('allows an upload-pdf request authorized by a valid presign token', async ({ request }) => {
|
||||
const { user, team } = await seedUser();
|
||||
const presignToken = await createPresignTokenForUser(user.id, team.id);
|
||||
|
||||
const res = await request.post(`${WEBAPP_BASE_URL}/api/files/upload-pdf`, {
|
||||
headers: { Authorization: `Bearer ${presignToken}` },
|
||||
multipart: buildPdfFormData(),
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
expect(res.status()).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCompletedDocument, seedDraftDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedBlankFolder } from '@documenso/prisma/seed/folders';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { type Download, expect, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
@@ -287,3 +290,147 @@ test('[BULK_ACTIONS]: can move documents from folder to home (root)', async ({ p
|
||||
await page.goto(`/t/${sender.team.url}/documents/f/${folder.id}`);
|
||||
await expect(page.getByRole('link', { name: 'Bulk Test Doc 1' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Bulk cancel ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('[BULK_ACTIONS]: can cancel multiple pending documents', async ({ page }) => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const [pending1, pending2] = await Promise.all([
|
||||
seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Bulk Cancel Pending 1' },
|
||||
}),
|
||||
seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Bulk Cancel Pending 2' },
|
||||
}),
|
||||
]);
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Bulk Cancel Pending 1' }).getByRole('checkbox').click();
|
||||
await page.locator('tr', { hasText: 'Bulk Cancel Pending 2' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('2 selected')).toBeVisible();
|
||||
|
||||
// The bulk action bar Cancel button (distinct from the dialog's confirm button).
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByRole('heading', { name: 'Cancel Documents' })).toBeVisible();
|
||||
await expect(dialog.getByText('You are about to cancel 2 documents')).toBeVisible();
|
||||
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Documents cancelled');
|
||||
|
||||
// Selection clears after a successful cancel.
|
||||
await expect(page.getByText(/\d+ selected/)).not.toBeVisible();
|
||||
|
||||
// Both documents are now cancelled in the database.
|
||||
for (const document of [pending1, pending2]) {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.deletedAt).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: bulk cancel only affects pending documents', async ({ page }) => {
|
||||
const sender = await seedUser({ setTeamEmailAsOwner: true });
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const pending = await seedPendingDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Pending' },
|
||||
});
|
||||
const draft = await seedDraftDocument(sender.user, sender.team.id, [], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Draft' },
|
||||
});
|
||||
const completed = await seedCompletedDocument(sender.user, sender.team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Mixed Cancel Completed' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await page.locator('thead').getByRole('checkbox').click();
|
||||
await expect(page.getByText('3 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// Only one of the three was pending, so this is a partial result.
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
const pendingEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: pending.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(pendingEnvelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
|
||||
// The draft and completed documents are untouched.
|
||||
const draftEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: draft.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(draftEnvelope.status).toBe(DocumentStatus.DRAFT);
|
||||
|
||||
const completedEnvelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: completed.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(completedEnvelope.status).toBe(DocumentStatus.COMPLETED);
|
||||
});
|
||||
|
||||
test('[BULK_ACTIONS]: a MEMBER cannot bulk cancel documents they do not own', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const ownerDocument = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Member Cannot Cancel This', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await page.locator('tr', { hasText: 'Member Cannot Cancel This' }).getByRole('checkbox').click();
|
||||
await expect(page.getByText('1 selected')).toBeVisible();
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: 'Cancel documents' }).click();
|
||||
|
||||
// The server rejects the cancellation for a document the MEMBER does not own,
|
||||
// so it reports zero cancelled (a partial result with the document in failedIds).
|
||||
await expectToastTextToBeVisible(page, 'Documents partially cancelled');
|
||||
|
||||
// The document remains pending.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: ownerDocument.id },
|
||||
select: { status: true },
|
||||
});
|
||||
expect(envelope.status).toBe(DocumentStatus.PENDING);
|
||||
|
||||
await apiSignout({ page });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedCancelledDocument, seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentStatus, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin, apiSignout } from '../fixtures/authentication';
|
||||
import { checkDocumentTabCount } from '../fixtures/documents';
|
||||
import { expectToastTextToBeVisible, openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const seedCancelDocumentsTestRequirements = async () => {
|
||||
const [sender, recipientA, recipientB] = await Promise.all([
|
||||
seedUser({ setTeamEmailAsOwner: true }),
|
||||
seedUser({ setTeamEmailAsOwner: true }),
|
||||
seedUser({ setTeamEmailAsOwner: true }),
|
||||
]);
|
||||
|
||||
const pendingDocument = await seedPendingDocument(sender.user, sender.team.id, [recipientA.user, recipientB.user], {
|
||||
createDocumentOptions: { title: 'Document 1 - Pending' },
|
||||
});
|
||||
|
||||
return {
|
||||
sender,
|
||||
recipients: [recipientA, recipientB],
|
||||
pendingDocument,
|
||||
};
|
||||
};
|
||||
|
||||
const cancelDocumentViaUi = async (page: Page, documentTitle: string, reason?: string) => {
|
||||
const documentActionBtn = page.locator('tr', { hasText: documentTitle }).getByTestId('document-table-action-btn');
|
||||
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Cancel' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Cancel' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Are you sure?' })).toBeVisible();
|
||||
|
||||
if (reason) {
|
||||
await page.getByPlaceholder('Add an optional reason for cancelling this document').fill(reason);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Cancel document' }).click();
|
||||
};
|
||||
|
||||
test('[DOCUMENTS]: cancelling a pending document keeps it in the owner dashboard as cancelled', async ({ page }) => {
|
||||
const { sender, pendingDocument } = await seedCancelDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending', 'No longer required');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// The document must remain in the dashboard, unlike deleting a pending document.
|
||||
await checkDocumentTabCount(page, 'Inbox', 0);
|
||||
await checkDocumentTabCount(page, 'Pending', 0);
|
||||
await checkDocumentTabCount(page, 'Cancelled', 1);
|
||||
await checkDocumentTabCount(page, 'All', 1);
|
||||
|
||||
// The cancelled document is still listed.
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
// The envelope status is persisted as CANCELLED.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingDocument.id,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
completedAt: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.completedAt).not.toBeNull();
|
||||
expect(envelope.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: cancelling a pending document retains it for recipients', async ({ page }) => {
|
||||
const { sender, recipients } = await seedCancelDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// Recipients should still be able to see the document as a record of distribution.
|
||||
for (const recipient of recipients) {
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipient.user.email,
|
||||
redirectPath: `/t/${recipient.team.url}/documents`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
}
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a cancelled document can be deleted, hiding it from the owner without removing it', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { sender, recipients, pendingDocument } = await seedCancelDocumentsTestRequirements();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: sender.user.email,
|
||||
redirectPath: `/t/${sender.team.url}/documents`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Document 1 - Pending');
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
// Delete the now-cancelled document. Being terminal, it should soft delete (hide).
|
||||
await page.getByRole('tab', { name: 'Cancelled' }).click();
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Document 1 - Pending' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
await expect(page.getByRole('menuitem', { name: 'Delete' })).toBeVisible();
|
||||
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||
await page.getByPlaceholder("Type 'delete' to confirm").fill('delete');
|
||||
await page.getByRole('button', { name: 'Delete' }).click();
|
||||
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
await expect(page.getByRole('row', { name: /Document 1 - Pending/ })).not.toBeVisible();
|
||||
|
||||
// The envelope is soft deleted, not hard deleted.
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: pendingDocument.id,
|
||||
},
|
||||
select: {
|
||||
status: true,
|
||||
deletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
expect(envelope.deletedAt).not.toBeNull();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// Recipients should still retain the document after the owner deletes it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: recipients[0].user.email,
|
||||
redirectPath: `/t/${recipients[0].team.url}/documents`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Document 1 - Pending' })).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── Visibility: a cancelled document must respect team document visibility ───
|
||||
|
||||
test('[DOCUMENTS]: cancelled document with ADMIN visibility is hidden from a MEMBER', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
||||
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedCancelledDocument(owner, team.id, [], {
|
||||
createDocumentOptions: {
|
||||
visibility: 'ADMIN',
|
||||
title: 'Cancelled Admin Only Document',
|
||||
},
|
||||
});
|
||||
|
||||
// The MEMBER must NOT see the ADMIN-visibility cancelled document on any tab.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
// Also confirm it doesn't leak via the ALL tab.
|
||||
await page.goto(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${team.url}/documents`);
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The MANAGER must NOT see an ADMIN-visibility document either.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: managerUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The ADMIN must see it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Only Document', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: cancelled document with MANAGER_AND_ABOVE visibility is hidden from a MEMBER', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const managerUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedCancelledDocument(owner, team.id, [], {
|
||||
createDocumentOptions: {
|
||||
visibility: 'MANAGER_AND_ABOVE',
|
||||
title: 'Cancelled Manager Document',
|
||||
},
|
||||
});
|
||||
|
||||
// The MEMBER must NOT see it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).not.toBeVisible();
|
||||
|
||||
await apiSignout({ page });
|
||||
|
||||
// The MANAGER must see it.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: managerUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Manager Document', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a recipient sees a cancelled document regardless of restricted visibility', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
// A MEMBER who is also a recipient on an ADMIN-visibility document.
|
||||
const memberRecipient = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await seedCancelledDocument(owner, team.id, [memberRecipient], {
|
||||
createDocumentOptions: {
|
||||
visibility: 'ADMIN',
|
||||
title: 'Cancelled Admin Doc With Recipient',
|
||||
},
|
||||
});
|
||||
|
||||
// Even though the document is ADMIN-only, the MEMBER is a recipient, so they
|
||||
// must still see it (proof of distribution), matching completed-document behaviour.
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberRecipient.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=CANCELLED`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('link', { name: 'Cancelled Admin Doc With Recipient', exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
// ─── UI gating: only privileged members see the Cancel action ────────────────
|
||||
|
||||
test('[DOCUMENTS]: a MEMBER does not see the Cancel action on a pending document', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const memberUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Member Gating Pending Document', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: memberUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
const documentActionBtn = page
|
||||
.locator('tr', { hasText: 'Member Gating Pending Document' })
|
||||
.getByTestId('document-table-action-btn');
|
||||
await openDropdownMenu(page, documentActionBtn);
|
||||
|
||||
// The dropdown must render (Edit is always there) but Cancel must be absent.
|
||||
await expect(page.getByRole('menuitem', { name: 'Edit' })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: 'Cancel' })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a team ADMIN sees and can use the Cancel action on a document they do not own', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
|
||||
const adminUser = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.ADMIN });
|
||||
|
||||
const { user: recipient } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(owner, team.id, [recipient], {
|
||||
createDocumentOptions: { title: 'Admin Cancellable Document', visibility: 'EVERYONE' },
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: adminUser.email,
|
||||
redirectPath: `/t/${team.url}/documents?status=PENDING`,
|
||||
});
|
||||
|
||||
await cancelDocumentViaUi(page, 'Admin Cancellable Document');
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document cancelled');
|
||||
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: { id: document.id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
expect(envelope.status).toBe(DocumentStatus.CANCELLED);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { cancelDocument } from '@documenso/lib/server-only/document/cancel-document';
|
||||
import { deleteDocument } from '@documenso/lib/server-only/document/delete-document';
|
||||
import { getEnvelopeWhereInput } from '@documenso/lib/server-only/envelope/get-envelope-by-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const requestMetadata = {
|
||||
auth: null,
|
||||
requestMetadata: {},
|
||||
source: 'app' as const,
|
||||
};
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const canReadEnvelope = async (envelopeId: string, userId: number, teamId: number) => {
|
||||
try {
|
||||
await getEnvelopeWhereInput({
|
||||
id: { type: 'envelopeId', id: envelopeId },
|
||||
userId,
|
||||
teamId,
|
||||
type: null,
|
||||
}).then(({ envelopeWhereInput }) => prisma.envelope.findFirstOrThrow({ where: envelopeWhereInput }));
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
test('[DOCUMENTS]: a member cannot delete a document with restricted visibility', async () => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: {
|
||||
visibility: DocumentVisibility.ADMIN,
|
||||
status: DocumentStatus.DRAFT,
|
||||
},
|
||||
});
|
||||
|
||||
// The member cannot read an ADMIN-visibility document, so they must not be
|
||||
// able to delete it either.
|
||||
expect(await canReadEnvelope(envelope.id, member.id, team.id)).toBe(false);
|
||||
|
||||
await expect(
|
||||
deleteDocument({
|
||||
id: { type: 'envelopeId', id: envelope.id },
|
||||
userId: member.id,
|
||||
teamId: team.id,
|
||||
requestMetadata,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const stillExists = await prisma.envelope.findUnique({ where: { id: envelope.id } });
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('[DOCUMENTS]: a manager cannot cancel a document with restricted visibility', async () => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
const manager = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MANAGER });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: {
|
||||
visibility: DocumentVisibility.ADMIN,
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
// A manager outranks a member but still cannot read an ADMIN-visibility
|
||||
// document, so cancellation must be blocked despite the sufficient role.
|
||||
expect(await canReadEnvelope(envelope.id, manager.id, team.id)).toBe(false);
|
||||
|
||||
await expect(
|
||||
cancelDocument({
|
||||
id: { type: 'envelopeId', id: envelope.id },
|
||||
userId: manager.id,
|
||||
teamId: team.id,
|
||||
reason: 'test-cancel',
|
||||
requestMetadata,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
const after = await prisma.envelope.findUnique({ where: { id: envelope.id } });
|
||||
expect(after?.status).toBe(DocumentStatus.PENDING);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: { name: 'attachment-url-test', token: hashString(token), expires: null, userId, teamId },
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Attachment URLs are rendered as link hrefs, so they must be restricted to
|
||||
* http(s). The API must reject any other scheme.
|
||||
*/
|
||||
const NON_HTTP_URLS = [
|
||||
'javascript:alert(document.cookie)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd',
|
||||
];
|
||||
|
||||
test('[ATTACHMENTS]: rejects attachment URLs with a non-http(s) protocol', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id);
|
||||
|
||||
for (const url of NON_HTTP_URLS) {
|
||||
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'attachment', data: url } },
|
||||
});
|
||||
|
||||
expect(res.ok(), `expected ${url} to be rejected`).toBe(false);
|
||||
}
|
||||
|
||||
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||
expect(attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ATTACHMENTS]: accepts attachment URLs with an http(s) protocol', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const { token } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id);
|
||||
|
||||
const res = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'safe', data: 'https://example.com/file.pdf' } },
|
||||
});
|
||||
|
||||
expect(res.ok()).toBe(true);
|
||||
|
||||
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||
expect(attachments).toHaveLength(1);
|
||||
expect(attachments[0].data).toBe('https://example.com/file.pdf');
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { type APIRequestContext, expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: { name: 'attachment-access-test', token: hashString(token), expires: null, userId, teamId },
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
const canReadEnvelope = async (request: APIRequestContext, token: string, envelopeId: string) => {
|
||||
const res = await request.get(`${API_BASE_URL}/envelope/${envelopeId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
return res.ok();
|
||||
};
|
||||
|
||||
/**
|
||||
* Attachment create/update/delete/list must enforce document visibility, not
|
||||
* just team membership. A member whose visibility tier excludes a restricted
|
||||
* envelope must not be able to read or mutate its attachments.
|
||||
*/
|
||||
test('[ATTACHMENTS]: a member cannot create or delete attachments on a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||
|
||||
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'attachment', data: 'https://example.com' } },
|
||||
});
|
||||
|
||||
expect(createRes.ok()).toBe(false);
|
||||
|
||||
// No attachment should have been created.
|
||||
const attachments = await prisma.envelopeAttachment.findMany({ where: { envelopeId: envelope.id } });
|
||||
expect(attachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('[ATTACHMENTS]: a member cannot update an attachment on a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
// The owner (who can see the document) creates the attachment.
|
||||
const createRes = await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'original', data: 'https://example.com/original' } },
|
||||
});
|
||||
expect(createRes.ok()).toBe(true);
|
||||
const attachment = await createRes.json();
|
||||
|
||||
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||
|
||||
const updateRes = await request.post(`${API_BASE_URL}/envelope/attachment/update`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}`, 'Content-Type': 'application/json' },
|
||||
data: { id: attachment.id, data: { label: 'tampered', data: 'https://example.com/tampered' } },
|
||||
});
|
||||
|
||||
expect(updateRes.ok()).toBe(false);
|
||||
|
||||
const persisted = await prisma.envelopeAttachment.findUnique({ where: { id: attachment.id } });
|
||||
expect(persisted?.label).toBe('original');
|
||||
});
|
||||
|
||||
test('[ATTACHMENTS]: a member cannot list attachments on a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: ownerToken } = await seedApiTokenForUser({ userId: owner.id, teamId: team.id });
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const envelope = await seedBlankDocument(owner, team.id, {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
await request.post(`${API_BASE_URL}/envelope/attachment/create`, {
|
||||
headers: { Authorization: `Bearer ${ownerToken}`, 'Content-Type': 'application/json' },
|
||||
data: { envelopeId: envelope.id, data: { label: 'restricted', data: 'https://example.com/restricted' } },
|
||||
});
|
||||
|
||||
expect(await canReadEnvelope(request, memberToken, envelope.id)).toBe(false);
|
||||
|
||||
const findRes = await request.get(`${API_BASE_URL}/envelope/attachment?envelopeId=${envelope.id}`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}` },
|
||||
});
|
||||
|
||||
expect(findRes.ok()).toBe(false);
|
||||
|
||||
const body = findRes.ok() ? await findRes.json() : null;
|
||||
const attachments = body?.data ?? [];
|
||||
expect(attachments).toHaveLength(0);
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import path from 'node:path';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { createApiToken } from '@documenso/lib/server-only/public-api/create-api-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedDraftDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type {
|
||||
@@ -302,6 +303,95 @@ test.describe('document editor', () => {
|
||||
expect(envelopes.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test('duplicate document without recipients excludes recipients and fields', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Seed a draft document that has a recipient with a field.
|
||||
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
|
||||
key: `dup-exclude-recipients-${Date.now()}`,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Open the duplicate dialog.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
|
||||
|
||||
// Uncheck "Include Recipients" — this also disables and unchecks "Include Fields".
|
||||
await page.getByLabel('Include Recipients').click();
|
||||
await expect(page.getByLabel('Include Fields')).toBeDisabled();
|
||||
|
||||
// Duplicate.
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expectToastTextToBeVisible(page, 'Document Duplicated');
|
||||
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
|
||||
|
||||
// The duplicate should have neither recipients nor fields.
|
||||
const duplicate = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
id: { not: document.id },
|
||||
},
|
||||
include: { recipients: true, fields: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(duplicate.recipients).toHaveLength(0);
|
||||
expect(duplicate.fields).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('duplicate document without fields keeps recipients but excludes fields', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
// Seed a draft document that has a recipient with a field.
|
||||
const document = await seedDraftDocument(user, team.id, ['signer@test.documenso.com'], {
|
||||
key: `dup-exclude-fields-${Date.now()}`,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/documents/${document.id}/edit`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Documents' })).toBeVisible();
|
||||
|
||||
// Open the duplicate dialog.
|
||||
await page.locator('button[title="Duplicate Envelope"]').click();
|
||||
await expect(page.getByRole('heading', { name: 'Duplicate Document' })).toBeVisible();
|
||||
|
||||
// Uncheck only "Include Fields" (recipients stay included).
|
||||
await page.getByLabel('Include Fields').click();
|
||||
|
||||
// Duplicate.
|
||||
await page.getByRole('button', { name: 'Duplicate' }).click();
|
||||
await expectToastTextToBeVisible(page, 'Document Duplicated');
|
||||
await expect(page).toHaveURL(/\/documents\/.*\/edit/);
|
||||
|
||||
// The duplicate should keep the recipient but have no fields.
|
||||
const duplicate = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
id: { not: document.id },
|
||||
},
|
||||
include: { recipients: true, fields: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
expect(duplicate.recipients).toHaveLength(1);
|
||||
expect(duplicate.fields).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('download PDF dialog shows envelope items', async ({ page }) => {
|
||||
await openDocumentEnvelopeEditor(page);
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type TEnvelopeEditorSurface,
|
||||
} from '../fixtures/envelope-editor';
|
||||
import { expectToastTextToBeVisible } from '../fixtures/generic';
|
||||
import { getKonvaElementCountForPage } from '../fixtures/konva';
|
||||
import { getKonvaElementCountForPage, getKonvaTransformerNodeCountForPage } from '../fixtures/konva';
|
||||
|
||||
type TFieldFlowResult = {
|
||||
externalId: string;
|
||||
@@ -46,6 +46,7 @@ const updateExternalId = async (surface: TEnvelopeEditorSurface, externalId: str
|
||||
|
||||
if (!surface.isEmbedded) {
|
||||
await expectToastTextToBeVisible(surface.root, 'Envelope updated');
|
||||
await surface.root.getByTestId('toast-close').click();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -98,6 +99,17 @@ const selectFieldOnCanvas = async (root: Page, position: { x: number; y: number
|
||||
await canvas.click({ position, force: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Shift+click a field on the canvas to toggle it in/out of the current multi-selection.
|
||||
*/
|
||||
const shiftClickFieldOnCanvas = async (root: Page, position: { x: number; y: number }) => {
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
await root.waitForTimeout(300);
|
||||
// Use force:true to bypass any floating action toolbar buttons that may intercept clicks.
|
||||
await canvas.click({ position, modifiers: ['Shift'], force: true });
|
||||
};
|
||||
|
||||
const runAddAndPersistSignatureTextFields = async (surface: TEnvelopeEditorSurface): Promise<TFieldFlowResult> => {
|
||||
const externalId = `e2e-fields-${nanoid()}`;
|
||||
|
||||
@@ -621,9 +633,245 @@ const assertDuplicateDeleteFieldPersistedInDatabase = async ({
|
||||
expect(envelope.fields[0].type).toBe(FieldType.SIGNATURE);
|
||||
};
|
||||
|
||||
// --- Change field type flow ---
|
||||
|
||||
type TChangeFieldTypeFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const FIELD_A_POSITION = { x: 150, y: 150 };
|
||||
const FIELD_B_POSITION = { x: 150, y: 250 };
|
||||
|
||||
const changeFieldTypeViaToolbar = async (root: Page, newTypeLabel: FieldButtonName) => {
|
||||
await expect(root.locator('button[title="Change Field Type"]')).toBeVisible();
|
||||
await root.locator('button[title="Change Field Type"]').click();
|
||||
|
||||
// The CommandDialog uses role="option" for items; sidebar palette buttons use role="button".
|
||||
const option = root.getByRole('option', { name: newTypeLabel, exact: true });
|
||||
await expect(option).toBeVisible();
|
||||
await option.click();
|
||||
|
||||
// Wait for the CommandDialog to close (selection persists so the toolbar remains).
|
||||
await expect(root.getByRole('dialog')).toHaveCount(0);
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-select fields on the konva canvas by drawing a marquee selection rectangle.
|
||||
*
|
||||
* The editor's stage mousedown/mousemove/mouseup handlers create a Konva selection
|
||||
* rectangle when the user drags on empty stage area. All field groups that intersect
|
||||
* the rectangle are selected at once. This is the canonical multi-select gesture.
|
||||
*/
|
||||
const marqueeSelectFieldsOnCanvas = async (
|
||||
root: Page,
|
||||
start: { x: number; y: number },
|
||||
end: { x: number; y: number },
|
||||
) => {
|
||||
const canvas = root.locator('.konva-container canvas').first();
|
||||
await expect(canvas).toBeVisible();
|
||||
|
||||
const box = await canvas.boundingBox();
|
||||
|
||||
if (!box) {
|
||||
throw new Error('Canvas bounding box not available for marquee selection.');
|
||||
}
|
||||
|
||||
// The marquee gesture must start on empty stage (not on a field) and pass through
|
||||
// intermediate points so the editor's mousemove handler can grow the rectangle.
|
||||
await root.mouse.move(box.x + start.x, box.y + start.y);
|
||||
await root.mouse.down();
|
||||
await root.mouse.move(box.x + (start.x + end.x) / 2, box.y + (start.y + end.y) / 2, { steps: 5 });
|
||||
await root.mouse.move(box.x + end.x, box.y + end.y, { steps: 5 });
|
||||
await root.mouse.up();
|
||||
};
|
||||
|
||||
const runChangeFieldTypeFlow = async (surface: TEnvelopeEditorSurface): Promise<TChangeFieldTypeFlowResult> => {
|
||||
const externalId = `e2e-change-type-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place two fields of different types: Signature (A) and Name (B).
|
||||
await placeFieldOnPdf(root, 'Signature', FIELD_A_POSITION);
|
||||
await placeFieldOnPdf(root, 'Name', FIELD_B_POSITION);
|
||||
let fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// --- Phase 1: single field type change ---
|
||||
// Select field A (Signature) and change it to Text via the toolbar.
|
||||
await selectFieldOnCanvas(root, FIELD_A_POSITION);
|
||||
await changeFieldTypeViaToolbar(root, 'Text');
|
||||
|
||||
// Field count must remain stable -- changing type doesn't add/remove fields.
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to verify the change is persisted in local state.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// --- Phase 2: multi-field type change ---
|
||||
// Use a marquee drag-selection rectangle to capture both fields at once.
|
||||
// Fields are at (150, 150) and (150, 250) with default dims ~90x30; drag from
|
||||
// (50, 100) to (260, 290) encloses both with margin.
|
||||
await marqueeSelectFieldsOnCanvas(root, { x: 50, y: 100 }, { x: 260, y: 290 });
|
||||
|
||||
// With mixed-type selection (Text + Name), change both to Date.
|
||||
await changeFieldTypeViaToolbar(root, 'Date');
|
||||
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
fieldCount = await getKonvaElementCountForPage(root, 1, '.field-group');
|
||||
expect(fieldCount).toBe(2);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertChangeFieldTypePersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
// Started with Signature + Name, then both were converted to Date.
|
||||
// Use sorted .map() in the assertion so any failure prints which types were found.
|
||||
const actualTypes = envelope.fields.map((field) => field.type).sort();
|
||||
const expectedTypes = [FieldType.DATE, FieldType.DATE];
|
||||
|
||||
expect(envelope.fields).toHaveLength(2);
|
||||
expect(actualTypes).toEqual(expectedTypes);
|
||||
|
||||
// Each field's meta must have been reset to the new type's defaults.
|
||||
const actualMetaTypes = envelope.fields.map((field) => getFieldMetaType(field.fieldMeta)).sort();
|
||||
expect(actualMetaTypes).toEqual(['date', 'date']);
|
||||
};
|
||||
|
||||
// --- Shift+click multi-select flow ---
|
||||
|
||||
type TShiftClickFlowResult = {
|
||||
externalId: string;
|
||||
};
|
||||
|
||||
const SHIFT_CLICK_FIELD_POSITIONS = {
|
||||
signature: { x: 150, y: 120 },
|
||||
text: { x: 150, y: 260 },
|
||||
name: { x: 150, y: 400 },
|
||||
};
|
||||
|
||||
const runShiftClickMultiSelectFlow = async (surface: TEnvelopeEditorSurface): Promise<TShiftClickFlowResult> => {
|
||||
const externalId = `e2e-shift-click-${nanoid()}`;
|
||||
const root = surface.root;
|
||||
|
||||
if (surface.isEmbedded && !surface.envelopeId) {
|
||||
await addEnvelopeItemPdf(root, 'embedded-fields.pdf');
|
||||
}
|
||||
|
||||
await updateExternalId(surface, externalId);
|
||||
await setupRecipientsForFieldPlacement(surface);
|
||||
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
await expect(root.locator('.konva-container canvas').first()).toBeVisible();
|
||||
|
||||
// Place three fields, spaced far enough apart that their action toolbars don't
|
||||
// overlap a neighbouring field's click target.
|
||||
await placeFieldOnPdf(root, 'Signature', SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await placeFieldOnPdf(root, 'Text', SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||
await placeFieldOnPdf(root, 'Name', SHIFT_CLICK_FIELD_POSITIONS.name);
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(3);
|
||||
|
||||
// A plain click selects exactly one field.
|
||||
await selectFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
|
||||
// Shift+click a second field ADDS it to the selection (the new behaviour).
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.text);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
// Shift+click an already-selected field REMOVES it from the selection.
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(1);
|
||||
|
||||
// Shift+click it again RE-ADDS it, leaving Signature + Text selected and Name excluded.
|
||||
await shiftClickFieldOnCanvas(root, SHIFT_CLICK_FIELD_POSITIONS.signature);
|
||||
await expect.poll(() => getKonvaTransformerNodeCountForPage(root, 1)).toBe(2);
|
||||
|
||||
// Delete the two selected fields via the floating action toolbar. Only the
|
||||
// un-selected Name field should remain -- proving the multi-selection contained
|
||||
// exactly the two Shift-clicked fields.
|
||||
await expect(root.locator('button[title="Remove"]')).toBeVisible();
|
||||
await root.locator('button[title="Remove"]').click();
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||
|
||||
// Navigate away and back to verify persistence.
|
||||
await clickEnvelopeEditorStep(root, 'upload');
|
||||
await clickEnvelopeEditorStep(root, 'addFields');
|
||||
expect(await getKonvaElementCountForPage(root, 1, '.field-group')).toBe(1);
|
||||
|
||||
return { externalId };
|
||||
};
|
||||
|
||||
const assertShiftClickMultiSelectPersistedInDatabase = async ({
|
||||
surface,
|
||||
externalId,
|
||||
}: {
|
||||
surface: TEnvelopeEditorSurface;
|
||||
externalId: string;
|
||||
}) => {
|
||||
const envelope = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
externalId,
|
||||
userId: surface.userId,
|
||||
teamId: surface.teamId,
|
||||
type: surface.envelopeType,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
// Signature + Text were multi-selected via Shift+click and deleted; only Name remains.
|
||||
expect(envelope.fields).toHaveLength(1);
|
||||
expect(envelope.fields[0].type).toBe(FieldType.NAME);
|
||||
};
|
||||
|
||||
// --- Test describe blocks ---
|
||||
|
||||
test.describe('document editor', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
@@ -663,9 +911,29 @@ test.describe('document editor', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openDocumentEnvelopeEditor(page);
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('template editor', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runAddAndPersistSignatureTextFields(surface);
|
||||
@@ -705,9 +973,34 @@ test.describe('template editor', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openTemplateEnvelopeEditor(page);
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded create', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||
});
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
@@ -767,9 +1060,40 @@ test.describe('embedded create', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'DOCUMENT',
|
||||
tokenNamePrefix: 'e2e-embed-change-type',
|
||||
});
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('embedded edit', () => {
|
||||
test('shift+click adds and removes fields from the selection', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-shift-click',
|
||||
});
|
||||
const result = await runShiftClickMultiSelectFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertShiftClickMultiSelectPersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('add and persist signature/text fields', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
@@ -833,4 +1157,20 @@ test.describe('embedded edit', () => {
|
||||
...result,
|
||||
});
|
||||
});
|
||||
|
||||
test('change field type via canvas action toolbar (single and multi-select)', async ({ page }) => {
|
||||
const surface = await openEmbeddedEnvelopeEditor(page, {
|
||||
envelopeType: 'TEMPLATE',
|
||||
mode: 'edit',
|
||||
tokenNamePrefix: 'e2e-embed-change-type',
|
||||
});
|
||||
const result = await runChangeFieldTypeFlow(surface);
|
||||
|
||||
await persistEmbeddedEnvelope(surface);
|
||||
|
||||
await assertChangeFieldTypePersistedInDatabase({
|
||||
surface,
|
||||
...result,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,21 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
|
||||
await root.locator('input[name="externalId"]').fill(externalId);
|
||||
await root.locator('input[name="meta.redirectUrl"]').fill(TEST_SETTINGS_VALUES.redirectUrl);
|
||||
await root.getByRole('button', { name: 'Notifications' }).click();
|
||||
// Fill email-content fields and toggle recipient-facing notification checkboxes
|
||||
// while distributionMethod is still EMAIL. After it flips to NONE below, these
|
||||
// controls are disabled because no email is sent to recipients.
|
||||
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
await root.locator('#recipientRemoved').click();
|
||||
await root.locator('#documentPending').click();
|
||||
await root.locator('#documentCompleted').click();
|
||||
await root.locator('#documentDeleted').click();
|
||||
|
||||
await root.getByRole('button', { name: 'General' }).click();
|
||||
|
||||
await root.locator('[data-testid="documentDistributionMethodSelectValue"]').click();
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.distributionMethod }).click();
|
||||
@@ -190,19 +205,35 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
await root.getByRole('option', { name: TEST_SETTINGS_VALUES.reminderRepeatUnit }).click();
|
||||
await clickSettingsDialogHeader(root);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await root.locator('#recipientSigned').click();
|
||||
await root.locator('#recipientSigningRequest').click();
|
||||
await root.locator('#recipientRemoved').click();
|
||||
await root.locator('#documentPending').click();
|
||||
await root.locator('#documentCompleted').click();
|
||||
await root.locator('#documentDeleted').click();
|
||||
await root.getByRole('button', { name: 'Notifications' }).click();
|
||||
|
||||
// Distribution is NONE: email-content fields stay rendered but disabled,
|
||||
// recipient-facing checkboxes are hidden entirely and replaced by an alert,
|
||||
// owner-facing checkboxes stay editable so we toggle them here.
|
||||
await expect(root.locator('input[name="meta.subject"]')).toBeDisabled();
|
||||
await expect(root.locator('textarea[name="meta.message"]')).toBeDisabled();
|
||||
await expect(root.locator('input[name="meta.emailReplyTo"]')).toBeDisabled();
|
||||
await expect(root.locator('#recipientSigned')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientRemoved')).toHaveCount(0);
|
||||
await expect(root.locator('#documentPending')).toHaveCount(0);
|
||||
await expect(root.locator('#documentCompleted')).toHaveCount(0);
|
||||
await expect(root.locator('#documentDeleted')).toHaveCount(0);
|
||||
await expect(root.getByText(/Email distribution needs to be enabled/)).toBeVisible();
|
||||
|
||||
// Email Sender select only renders when the org has the emailDomains feature
|
||||
// flag plus allowConfigureEmailSender, so the assertion is conditional.
|
||||
const emailSenderSelect = getComboboxByLabel(root, 'Email Sender');
|
||||
const hasEmailSenderSelect = (await emailSenderSelect.count()) > 0;
|
||||
|
||||
if (hasEmailSenderSelect) {
|
||||
await expect(emailSenderSelect).toBeDisabled();
|
||||
}
|
||||
|
||||
await expect(root.locator('#ownerDocumentCompleted')).toBeEnabled();
|
||||
await root.locator('#ownerDocumentCompleted').click();
|
||||
await root.locator('#ownerRecipientExpired').click();
|
||||
await root.locator('#ownerDocumentCreated').click();
|
||||
await root.locator('input[name="meta.emailReplyTo"]').fill(TEST_SETTINGS_VALUES.replyTo);
|
||||
await root.locator('input[name="meta.subject"]').fill(TEST_SETTINGS_VALUES.subject);
|
||||
await root.locator('textarea[name="meta.message"]').fill(TEST_SETTINGS_VALUES.message);
|
||||
|
||||
await root.getByRole('button', { name: 'Security' }).click();
|
||||
await selectMultiSelectOption(root, 'documentAccessSelectValue', TEST_SETTINGS_VALUES.accessAuth);
|
||||
@@ -264,13 +295,17 @@ const runSettingsFlow = async ({ root }: TEnvelopeEditorSurface, { externalId, i
|
||||
TEST_SETTINGS_VALUES.reminderRepeatUnit,
|
||||
);
|
||||
|
||||
await root.getByRole('button', { name: 'Email' }).click();
|
||||
await expect(root.locator('#recipientSigned')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#recipientRemoved')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentPending')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#documentDeleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await root.getByRole('button', { name: 'Notifications' }).click();
|
||||
// Distribution persisted as NONE: recipient-facing checkboxes are hidden, owner-facing
|
||||
// checkboxes remain visible and persist their stored values. Email-content fields are
|
||||
// still rendered (disabled) and persist their stored values.
|
||||
await expect(root.locator('#recipientSigned')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientSigningRequest')).toHaveCount(0);
|
||||
await expect(root.locator('#recipientRemoved')).toHaveCount(0);
|
||||
await expect(root.locator('#documentPending')).toHaveCount(0);
|
||||
await expect(root.locator('#documentCompleted')).toHaveCount(0);
|
||||
await expect(root.locator('#documentDeleted')).toHaveCount(0);
|
||||
await expect(root.getByText(/Email distribution needs to be enabled/)).toBeVisible();
|
||||
await expect(root.locator('#ownerDocumentCompleted')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerRecipientExpired')).toHaveAttribute('aria-checked', 'false');
|
||||
await expect(root.locator('#ownerDocumentCreated')).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
@@ -270,7 +270,7 @@ test('[ENVELOPE_EXPIRATION]: resending refreshes expiresAt', async ({ page }) =>
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expect(page.getByText('Document re-sent', { exact: true })).toBeVisible({
|
||||
await expect(page.getByText('Document resent', { exact: true })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
});
|
||||
|
||||
// Wait for the form to load.
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Change the amount to 2.
|
||||
const amountInput = page.getByTestId('envelope-expiration-amount');
|
||||
@@ -35,7 +35,7 @@ test('[ENVELOPE_EXPIRATION]: set custom expiration period at organisation level'
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Weeks' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
@@ -57,14 +57,14 @@ test('[ENVELOPE_EXPIRATION]: disable expiration at organisation level', async ({
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Find the mode select (shows "Custom duration") and change to "Never expires".
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
await modeTrigger.click();
|
||||
await page.getByRole('option', { name: 'Never expires' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify via database.
|
||||
@@ -109,7 +109,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
redirectPath: `/t/${team.url}/settings/document`,
|
||||
});
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Update' }).first()).toBeVisible();
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// The expiration picker mode select should show "Inherit from organisation" by default.
|
||||
const modeTrigger = page.getByTestId('envelope-expiration-mode');
|
||||
@@ -128,7 +128,7 @@ test('[ENVELOPE_EXPIRATION]: team overrides organisation expiration', async ({ p
|
||||
await unitTrigger.click();
|
||||
await page.getByRole('option', { name: 'Days' }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
// Verify team setting is overridden.
|
||||
|
||||
@@ -324,10 +324,7 @@ test.describe('Signing Certificate Tests', () => {
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
|
||||
await page
|
||||
.getByRole('button', { name: /Update/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
@@ -347,10 +344,7 @@ test.describe('Signing Certificate Tests', () => {
|
||||
.getByRole('combobox')
|
||||
.click();
|
||||
await page.getByRole('option', { name: 'Yes' }).click();
|
||||
await page
|
||||
.getByRole('button', { name: /Update/ })
|
||||
.first()
|
||||
.click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
|
||||
@@ -16,3 +16,35 @@ export const getKonvaElementCountForPage = async (page: Page, pageNumber: number
|
||||
{ pageNumber, elementSelector },
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns how many field groups are currently attached to the page's Konva
|
||||
* transformer, i.e. the size of the active canvas selection. Used to assert
|
||||
* multi-select behaviour (marquee drag and Shift+click).
|
||||
*/
|
||||
export const getKonvaTransformerNodeCountForPage = async (page: Page, pageNumber: number) => {
|
||||
await page.locator('.konva-container canvas').first().waitFor({ state: 'visible' });
|
||||
|
||||
return await page.evaluate(
|
||||
({ pageNumber }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const konva: typeof Konva = (window as unknown as { Konva: typeof Konva }).Konva;
|
||||
|
||||
const stage = konva.stages.find((stage) => stage.attrs.id === `page-${pageNumber}`);
|
||||
|
||||
if (!stage) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const transformer = stage.find('Transformer')[0];
|
||||
|
||||
if (!transformer) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
return (transformer as Konva.Transformer).nodes().length;
|
||||
},
|
||||
{ pageNumber },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ test('[ORGANISATIONS]: manage general settings', async ({ page }) => {
|
||||
await page.getByLabel('Organisation URL*').clear();
|
||||
await page.getByLabel('Organisation URL*').fill(updatedOrganisationId);
|
||||
|
||||
await page.getByRole('button', { name: 'Update organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
// Check we have been redirected to the new organisation URL and the name is updated.
|
||||
await page.waitForURL(`/o/${updatedOrganisationId}/settings/general`);
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateDatabaseId, nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { OrganisationGroupType, type OrganisationMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* Calls a tRPC mutation directly using the cookies of whoever is currently
|
||||
* signed in on the page context. This deliberately bypasses the UI: the
|
||||
* authorisation checks under test live on the server, and the UI may simply
|
||||
* hide a button rather than reject the request, which would mask a backend gap.
|
||||
*/
|
||||
const trpcMutation = async (page: Page, procedure: string, input: Record<string, unknown>) => {
|
||||
return await page.request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
};
|
||||
|
||||
const getOrganisationMember = async (userId: number, organisationId: string) => {
|
||||
return await prisma.organisationMember.findFirstOrThrow({
|
||||
where: {
|
||||
userId,
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createCustomGroup = async (organisationId: string, organisationRole: OrganisationMemberRole) => {
|
||||
return await prisma.organisationGroup.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_group'),
|
||||
organisationId,
|
||||
name: `custom-${organisationRole}-${nanoid()}`,
|
||||
type: OrganisationGroupType.CUSTOM,
|
||||
organisationRole,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createPendingInvite = async (organisationId: string, organisationRole: OrganisationMemberRole) => {
|
||||
return await prisma.organisationMemberInvite.create({
|
||||
data: {
|
||||
id: generateDatabaseId('member_invite'),
|
||||
email: `invite-${nanoid()}@test.documenso.com`,
|
||||
token: nanoid(32),
|
||||
organisationId,
|
||||
organisationRole,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: member deletion', () => {
|
||||
test('a manager cannot delete an admin via member.delete', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser, adminUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{ name: 'Manager', organisationRole: 'MANAGER' },
|
||||
{ name: 'Admin', organisationRole: 'ADMIN' },
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminMember = await getOrganisationMember(adminUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.delete', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberId: adminMember.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
// The admin must still be a member of the organisation.
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: adminMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager cannot delete an admin via member.deleteMany', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser, adminUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{ name: 'Manager', organisationRole: 'MANAGER' },
|
||||
{ name: 'Admin', organisationRole: 'ADMIN' },
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminMember = await getOrganisationMember(adminUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [adminMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: adminMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager cannot delete the organisation owner', async ({ page }) => {
|
||||
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [ownerMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: ownerMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('an admin cannot delete the organisation owner', async ({ page }) => {
|
||||
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [adminUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Admin', organisationRole: 'ADMIN' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: adminUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [ownerMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: ownerMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager can still delete a regular member (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser, memberUser] = await seedOrganisationMembers({
|
||||
members: [
|
||||
{ name: 'Manager', organisationRole: 'MANAGER' },
|
||||
{ name: 'Member', organisationRole: 'MEMBER' },
|
||||
],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const member = await getOrganisationMember(memberUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.deleteMany', {
|
||||
organisationId: organisation.id,
|
||||
organisationMemberIds: [member.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const deleted = await prisma.organisationMember.findFirst({
|
||||
where: { id: member.id },
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group deletion', () => {
|
||||
test('a manager cannot delete an admin-role group', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminGroup = await createCustomGroup(organisation.id, 'ADMIN');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.delete', {
|
||||
organisationId: organisation.id,
|
||||
groupId: adminGroup.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationGroup.findFirst({
|
||||
where: { id: adminGroup.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a manager can delete a member-role group (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const memberGroup = await createCustomGroup(organisation.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.delete', {
|
||||
organisationId: organisation.id,
|
||||
groupId: memberGroup.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const deleted = await prisma.organisationGroup.findFirst({
|
||||
where: { id: memberGroup.id },
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: invite resend', () => {
|
||||
test('a manager cannot resend an admin-role invite', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const adminInvite = await createPendingInvite(organisation.id, 'ADMIN');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.invite.resend', {
|
||||
organisationId: organisation.id,
|
||||
invitationId: adminInvite.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
});
|
||||
|
||||
test('a manager can resend a member-role invite (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [managerUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Manager', organisationRole: 'MANAGER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const memberInvite = await createPendingInvite(organisation.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: managerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.member.invite.resend', {
|
||||
organisationId: organisation.id,
|
||||
invitationId: memberInvite.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: leaving an organisation', () => {
|
||||
test('the owner cannot leave without transferring ownership first', async ({ page }) => {
|
||||
const { user: ownerUser, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const ownerMember = await getOrganisationMember(ownerUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: ownerUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.leave', {
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const stillExists = await prisma.organisationMember.findFirst({
|
||||
where: { id: ownerMember.id },
|
||||
});
|
||||
|
||||
expect(stillExists).not.toBeNull();
|
||||
});
|
||||
|
||||
test('a non-owner member can still leave (positive control)', async ({ page }) => {
|
||||
const { organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [memberUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const member = await getOrganisationMember(memberUser.id, organisation.id);
|
||||
|
||||
await apiSignin({ page, email: memberUser.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.leave', {
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const deleted = await prisma.organisationMember.findFirst({
|
||||
where: { id: member.id },
|
||||
});
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('[ORGANISATION_PERMISSION_HIERARCHY]: group membership scoping', () => {
|
||||
test('cannot add a member from another organisation to a group', async ({ page }) => {
|
||||
// Organisation A, where the actor is the owner/admin.
|
||||
const { user: actor, organisation: organisationA } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// A separate organisation B with a member the actor has no authority over.
|
||||
const { organisation: organisationB } = await seedUser({ isPersonalOrganisation: false });
|
||||
const [foreignUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Foreign', organisationRole: 'MEMBER' }],
|
||||
organisationId: organisationB.id,
|
||||
});
|
||||
|
||||
const foreignMember = await getOrganisationMember(foreignUser.id, organisationB.id);
|
||||
|
||||
// A custom group the actor legitimately controls in organisation A.
|
||||
const groupA = await createCustomGroup(organisationA.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: actor.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.update', {
|
||||
id: groupA.id,
|
||||
memberIds: [foreignMember.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeFalsy();
|
||||
|
||||
const injectedMembership = await prisma.organisationGroupMember.findFirst({
|
||||
where: { groupId: groupA.id, organisationMemberId: foreignMember.id },
|
||||
});
|
||||
|
||||
expect(injectedMembership).toBeNull();
|
||||
});
|
||||
|
||||
test('can add a member from the same organisation to a group (positive control)', async ({ page }) => {
|
||||
const { user: actor, organisation } = await seedUser({ isPersonalOrganisation: false });
|
||||
|
||||
const [memberUser] = await seedOrganisationMembers({
|
||||
members: [{ name: 'Member', organisationRole: 'MEMBER' }],
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
const member = await getOrganisationMember(memberUser.id, organisation.id);
|
||||
|
||||
const group = await createCustomGroup(organisation.id, 'MEMBER');
|
||||
|
||||
await apiSignin({ page, email: actor.email });
|
||||
|
||||
const res = await trpcMutation(page, 'organisation.group.update', {
|
||||
id: group.id,
|
||||
memberIds: [member.id],
|
||||
});
|
||||
|
||||
expect(res.ok()).toBeTruthy();
|
||||
|
||||
const membership = await prisma.organisationGroupMember.findFirst({
|
||||
where: { groupId: group.id, organisationMemberId: member.id },
|
||||
});
|
||||
|
||||
expect(membership).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const BANNER_EXCEEDED_TEXT = 'Your organisation has exceeded a fair use limit';
|
||||
const BANNER_NEARING_TEXT = 'Your organisation is approaching a fair use limit';
|
||||
|
||||
type SeedQuotaStateOptions = {
|
||||
organisationId: string;
|
||||
organisationClaimId: string;
|
||||
/**
|
||||
* The `originalSubscriptionClaimId` to set on the claim. The banner is suppressed
|
||||
* for `INTERNAL_CLAIM_ID.FREE`, so use a non-free value to make it render.
|
||||
*/
|
||||
subscriptionClaimId: string;
|
||||
documentQuota: number | null;
|
||||
documentCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Point the organisation's document quota and current-period usage at a known
|
||||
* state. Only the document counter is touched; email/api quotas stay `null`
|
||||
* (unlimited) so the document counter is the sole driver of the banner.
|
||||
*/
|
||||
const seedQuotaState = async ({
|
||||
organisationId,
|
||||
organisationClaimId,
|
||||
subscriptionClaimId,
|
||||
documentQuota,
|
||||
documentCount,
|
||||
}: SeedQuotaStateOptions) => {
|
||||
await prisma.organisationClaim.update({
|
||||
where: {
|
||||
id: organisationClaimId,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: subscriptionClaimId,
|
||||
documentQuota,
|
||||
},
|
||||
});
|
||||
|
||||
const period = currentMonthlyPeriod();
|
||||
|
||||
await prisma.organisationMonthlyStat.upsert({
|
||||
where: {
|
||||
organisationId_period: {
|
||||
organisationId,
|
||||
period,
|
||||
},
|
||||
},
|
||||
update: {
|
||||
documentCount,
|
||||
},
|
||||
create: {
|
||||
id: generateDatabaseId('org_monthly_stat'),
|
||||
organisationId,
|
||||
period,
|
||||
documentCount,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
test('[QUOTA BANNER]: shows the approaching state when a quota is nearing', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// ceil(10 * 0.8) = 8 → nearing, but not yet exceeded.
|
||||
await seedQuotaState({
|
||||
organisationId: organisation.id,
|
||||
organisationClaimId: organisation.organisationClaim.id,
|
||||
subscriptionClaimId: INTERNAL_CLAIM_ID.TEAM,
|
||||
documentQuota: 10,
|
||||
documentCount: 8,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
await expect(page.getByText(BANNER_NEARING_TEXT)).toBeVisible();
|
||||
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
|
||||
});
|
||||
|
||||
test('[QUOTA BANNER]: shows the exceeded state when a quota is reached', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// usage >= quota → exceeded.
|
||||
await seedQuotaState({
|
||||
organisationId: organisation.id,
|
||||
organisationClaimId: organisation.organisationClaim.id,
|
||||
subscriptionClaimId: INTERNAL_CLAIM_ID.TEAM,
|
||||
documentQuota: 10,
|
||||
documentCount: 10,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeVisible();
|
||||
await expect(page.getByText(BANNER_NEARING_TEXT)).toBeHidden();
|
||||
});
|
||||
|
||||
test('[QUOTA BANNER]: learn more dialog lists the affected counter', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await seedQuotaState({
|
||||
organisationId: organisation.id,
|
||||
organisationClaimId: organisation.organisationClaim.id,
|
||||
subscriptionClaimId: INTERNAL_CLAIM_ID.TEAM,
|
||||
documentQuota: 10,
|
||||
documentCount: 10,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Learn more' }).click();
|
||||
|
||||
const dialog = page.getByRole('dialog');
|
||||
|
||||
await expect(dialog.getByText('Fair use limit exceeded')).toBeVisible();
|
||||
await expect(dialog.getByText('Document creation has been temporarily paused')).toBeVisible();
|
||||
await expect(dialog.getByRole('link', { name: 'support' })).toHaveAttribute('href', /^mailto:/);
|
||||
});
|
||||
|
||||
test('[QUOTA BANNER]: is hidden for free-claim organisations', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
// Usage is exceeded, but a free-claim organisation must never see the banner.
|
||||
await seedQuotaState({
|
||||
organisationId: organisation.id,
|
||||
organisationClaimId: organisation.organisationClaim.id,
|
||||
subscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||
documentQuota: 10,
|
||||
documentCount: 10,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/general`,
|
||||
});
|
||||
|
||||
// Anchor on a stable element so banner-absence is meaningful (page fully loaded).
|
||||
await expect(page.getByLabel('Organisation Name*')).toBeVisible();
|
||||
|
||||
await expect(page.getByText(BANNER_EXCEEDED_TEXT)).toBeHidden();
|
||||
await expect(page.getByRole('button', { name: 'Learn more' })).toBeHidden();
|
||||
});
|
||||
@@ -39,7 +39,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByTestId('include-signing-certificate-trigger').click();
|
||||
await page.getByRole('option', { name: 'No' }).click();
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
@@ -73,7 +73,7 @@ test('[ORGANISATIONS]: manage document preferences', async ({ page }) => {
|
||||
await page.getByTestId('document-date-format-trigger').click();
|
||||
await page.getByRole('option', { name: 'MM/DD/YYYY', exact: true }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your document preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const updatedTeamSettings = await getTeamSettings({
|
||||
@@ -128,7 +128,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://documenso.com');
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).fill('BrandDetails');
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
@@ -150,7 +150,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Brand Website' }).fill('https://example.com');
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).click();
|
||||
await page.getByRole('textbox', { name: 'Brand Details' }).fill('UpdatedBrandDetails');
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const updatedTeamSettings = await getTeamSettings({
|
||||
@@ -165,7 +165,7 @@ test('[ORGANISATIONS]: manage branding preferences', async ({ page }) => {
|
||||
// Test inheritance by setting team back to inherit from organisation
|
||||
await page.getByTestId('enable-branding').click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your branding preferences have been updated').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
@@ -208,7 +208,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('checkbox', { name: 'Email the signer if the document is still pending' }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email recipients when a pending document is deleted' }).uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const teamSettings = await getTeamSettings({
|
||||
@@ -245,7 +245,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('checkbox', { name: 'Email recipients when the document is completed', exact: true }).uncheck();
|
||||
await page.getByRole('checkbox', { name: 'Email the owner when the document is completed' }).uncheck();
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
const updatedTeamSettings = await getTeamSettings({
|
||||
@@ -292,7 +292,7 @@ test('[ORGANISATIONS]: manage email preferences', async ({ page }) => {
|
||||
await page.getByRole('textbox', { name: 'Reply to email' }).fill('');
|
||||
await page.getByRole('combobox').filter({ hasText: 'Override organisation settings' }).click();
|
||||
await page.getByRole('option', { name: 'Inherit from organisation' }).click();
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
await expect(page.getByText('Your email preferences have been updated').first()).toBeVisible();
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { hashString } from '@documenso/lib/server-only/auth/hash';
|
||||
import { alphaid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentVisibility, TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedCompletedDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
const API_BASE_URL = `${WEBAPP_BASE_URL}/api/v2-beta`;
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
const seedApiTokenForUser = async ({ userId, teamId }: { userId: number; teamId: number }) => {
|
||||
const token = `api_${alphaid(16)}`;
|
||||
|
||||
await prisma.apiToken.create({
|
||||
data: { name: 'recipient-access-test', token: hashString(token), expires: null, userId, teamId },
|
||||
});
|
||||
|
||||
return { token };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reading a recipient exposes its signing token (a bearer credential), so the
|
||||
* recipient read must enforce document visibility — a member who cannot read a
|
||||
* restricted document must not be able to read its recipients either. This
|
||||
* mirrors the field read, which is asserted as a control below.
|
||||
*/
|
||||
test('[RECIPIENT]: a member cannot read a recipient of a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirstOrThrow({ where: { envelopeId: document.id } });
|
||||
|
||||
const res = await request.get(`${API_BASE_URL}/envelope/recipient/${recipient.id}`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(404);
|
||||
|
||||
const body = res.ok() ? await res.json() : null;
|
||||
expect(body?.token).toBeUndefined();
|
||||
});
|
||||
|
||||
test('[RECIPIENT]: a member cannot read a field of a restricted document', async ({ request }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
const { token: memberToken } = await seedApiTokenForUser({ userId: member.id, teamId: team.id });
|
||||
|
||||
const document = await seedCompletedDocument(owner, team.id, ['recipient@test.documenso.com'], {
|
||||
createDocumentOptions: { visibility: DocumentVisibility.ADMIN },
|
||||
});
|
||||
|
||||
const field = await prisma.field.findFirst({ where: { envelopeId: document.id } });
|
||||
|
||||
test.skip(!field, 'No field seeded on completed document');
|
||||
|
||||
const res = await request.get(`${API_BASE_URL}/envelope/field/${field!.id}`, {
|
||||
headers: { Authorization: `Bearer ${memberToken}` },
|
||||
});
|
||||
|
||||
expect(res.status()).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { currentMonthlyPeriod } from '@documenso/lib/universal/monthly-period';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
const getEmailReports = async (organisationId: string) => {
|
||||
const stat = await prisma.organisationMonthlyStat.findUnique({
|
||||
where: {
|
||||
organisationId_period: {
|
||||
organisationId,
|
||||
period: currentMonthlyPeriod(),
|
||||
},
|
||||
},
|
||||
select: { emailReports: true },
|
||||
});
|
||||
|
||||
return stat?.emailReports ?? 0;
|
||||
};
|
||||
|
||||
test('[REPORT_SENDER]: only reports the sender after the button is clicked', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
|
||||
const token = document.recipients[0].token;
|
||||
|
||||
expect(await getEmailReports(organisation.id)).toBe(0);
|
||||
|
||||
await page.goto(`/report/${token}`);
|
||||
|
||||
// Visiting the page (GET) must not register a report.
|
||||
await expect(page.getByRole('heading', { name: 'Report this sender?' })).toBeVisible();
|
||||
expect(await getEmailReports(organisation.id)).toBe(0);
|
||||
|
||||
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||
|
||||
expect(await getEmailReports(organisation.id)).toBe(1);
|
||||
});
|
||||
|
||||
test('[REPORT_SENDER]: does not double count within the rate limit window', async ({ page }) => {
|
||||
test.skip(process.env.DANGEROUS_BYPASS_RATE_LIMITS === 'true', 'Rate limits are bypassed');
|
||||
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
const document = await seedPendingDocument(user, team.id, ['recipient@documenso.com']);
|
||||
const token = document.recipients[0].token;
|
||||
|
||||
await page.goto(`/report/${token}`);
|
||||
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||
|
||||
await page.goto(`/report/${token}`);
|
||||
await page.getByRole('button', { name: 'Report sender' }).click();
|
||||
await expect(page.getByRole('heading', { name: 'Sender reported' })).toBeVisible();
|
||||
|
||||
expect(await getEmailReports(organisation.id)).toBe(1);
|
||||
});
|
||||
|
||||
test('[REPORT_SENDER]: returns 404 for an invalid token', async ({ page }) => {
|
||||
const response = await page.goto('/report/not-a-real-token');
|
||||
|
||||
expect(response?.status()).toBe(404);
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
import { formatDirectTemplatePath } from '@documenso/lib/utils/templates';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedDirectTemplate } from '@documenso/prisma/seed/templates';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { DocumentDataType, FieldType } from '@prisma/client';
|
||||
|
||||
const BRANDING_URL = 'https://brand.example/signing?source=documenso';
|
||||
const PDF_PAGE_SELECTOR = 'img[data-page-number]';
|
||||
|
||||
const readBrandingLogo = async () => {
|
||||
const logo = await fs.readFile(path.join(__dirname, '../../assets/logo.png'));
|
||||
|
||||
return JSON.stringify({
|
||||
type: DocumentDataType.BYTES_64,
|
||||
data: logo.toString('base64'),
|
||||
});
|
||||
};
|
||||
|
||||
const enableOrganisationBranding = async ({
|
||||
organisationGlobalSettingsId,
|
||||
brandingUrl = BRANDING_URL,
|
||||
}: {
|
||||
organisationGlobalSettingsId: string;
|
||||
brandingUrl?: string;
|
||||
}) => {
|
||||
await prisma.organisationGlobalSettings.update({
|
||||
where: { id: organisationGlobalSettingsId },
|
||||
data: {
|
||||
brandingEnabled: true,
|
||||
brandingLogo: await readBrandingLogo(),
|
||||
brandingUrl,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* On signing surfaces the custom branding logo must render as a plain image.
|
||||
* It must not be wrapped in any link, and the Brand Website must never appear
|
||||
* as a link on these pages.
|
||||
*/
|
||||
const expectPlainBrandingLogo = async (page: Page, logoName: string) => {
|
||||
const logo = page.getByRole('img', { name: logoName });
|
||||
|
||||
await expect(logo).toBeVisible();
|
||||
|
||||
// The custom logo must not be wrapped in a link.
|
||||
await expect(page.getByRole('link', { name: logoName })).toHaveCount(0);
|
||||
|
||||
// The Brand Website must never be rendered as a link on signing pages.
|
||||
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||
};
|
||||
|
||||
test('[SIGNING_BRANDING]: V1 normal signing renders custom logo as a plain image', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['v1-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: V2 signing renders custom logo as a plain image', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['v2-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
const directTemplate = await seedDirectTemplate({
|
||||
title: 'V2 Branding Direct Template',
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
internalVersion: 2,
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
|
||||
await page.goto(formatDirectTemplatePath(directTemplate.directLink?.token || ''));
|
||||
await expectPlainBrandingLogo(page, `${team.name}'s Logo`);
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: V2 signing keeps internal link for the Documenso fallback logo', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['v2-fallback-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
await page.goto(`/sign/${recipients[0].token}`);
|
||||
|
||||
const fallbackLogoLink = page.locator('a[href="/"]').first();
|
||||
|
||||
await expect(fallbackLogoLink).toBeVisible();
|
||||
});
|
||||
|
||||
test('[SIGNING_BRANDING]: embedded signing does not render custom logo Brand Website links', async ({ page }) => {
|
||||
const { user, team, organisation } = await seedUser();
|
||||
|
||||
await enableOrganisationBranding({
|
||||
organisationGlobalSettingsId: organisation.organisationGlobalSettingsId,
|
||||
});
|
||||
|
||||
const { recipients } = await seedPendingDocumentWithFullFields({
|
||||
owner: user,
|
||||
teamId: team.id,
|
||||
recipients: ['embed-branding-signer@test.documenso.com'],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
updateDocumentOptions: { internalVersion: 2 },
|
||||
});
|
||||
|
||||
await page.goto(`/embed/sign/${recipients[0].token}`);
|
||||
await expect(page.locator(PDF_PAGE_SELECTOR).first()).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await expect(page.locator(`a[href="${BRANDING_URL}"]`)).toHaveCount(0);
|
||||
await expect(page.getByRole('link', { name: `${team.name}'s Logo` })).toHaveCount(0);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ test('[TEAMS]: update team', async ({ page }) => {
|
||||
await page.getByLabel('Team URL*').clear();
|
||||
await page.getByLabel('Team URL*').fill(updatedTeamId);
|
||||
|
||||
await page.getByRole('button', { name: 'Update team' }).click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).click();
|
||||
|
||||
// Check we have been redirected to the new team URL and the name is updated.
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/t/${updatedTeamId}/settings`);
|
||||
|
||||
@@ -238,7 +238,7 @@ test('[TEAMS]: resend pending team document', async ({ page }) => {
|
||||
await page.getByLabel('test.documenso.com').first().click();
|
||||
await page.getByRole('button', { name: 'Send reminder' }).click();
|
||||
|
||||
await expectToastTextToBeVisible(page, 'Document re-sent');
|
||||
await expectToastTextToBeVisible(page, 'Document resent');
|
||||
});
|
||||
|
||||
test('[TEAMS]: delete draft team document', async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, type Page, test } from '@playwright/test';
|
||||
import { OrganisationGroupType, OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
/**
|
||||
* Calls a team-group tRPC mutation directly, bypassing the UI.
|
||||
*
|
||||
* The UI only ever surfaces CUSTOM / INTERNAL_ORGANISATION groups, so these
|
||||
* authorisation rules must be enforced on the server - a crafted request can
|
||||
* target any `teamGroupId`, including the system-managed INTERNAL_TEAM groups.
|
||||
*/
|
||||
const callTeamGroupMutation = (
|
||||
page: Page,
|
||||
procedure: 'team.group.delete' | 'team.group.update',
|
||||
teamId: number,
|
||||
input: Record<string, unknown>,
|
||||
) =>
|
||||
page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/${procedure}`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': teamId.toString() },
|
||||
data: JSON.stringify({ json: input }),
|
||||
});
|
||||
|
||||
/**
|
||||
* Every team is created with three system-managed INTERNAL_TEAM groups
|
||||
* (admin/manager/member). They are the backbone of team-specific access and,
|
||||
* like organisation internal groups, must not be deletable - deleting them
|
||||
* silently strips team members of access while leaving the team row in place.
|
||||
*/
|
||||
test('[TEAMS]: internal team groups cannot be deleted via the API', async ({ page }) => {
|
||||
// Member inheritance OFF: membership is granted exclusively through the team's
|
||||
// INTERNAL_TEAM groups, so removing them is what causes the access loss.
|
||||
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
// A direct team member whose access depends on the INTERNAL_TEAM member group.
|
||||
const directMember = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const internalTeamGroups = await prisma.teamGroup.findMany({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||
},
|
||||
});
|
||||
|
||||
// admin + manager + member.
|
||||
expect(internalTeamGroups).toHaveLength(3);
|
||||
|
||||
for (const group of internalTeamGroups) {
|
||||
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
|
||||
teamId: team.id,
|
||||
teamGroupId: group.id,
|
||||
});
|
||||
|
||||
expect(response.status(), `INTERNAL_TEAM ${group.teamRole} group must not be deletable`).not.toBe(200);
|
||||
}
|
||||
|
||||
// None of the internal groups were removed.
|
||||
const remaining = await prisma.teamGroup.count({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||
},
|
||||
});
|
||||
|
||||
expect(remaining).toBe(3);
|
||||
|
||||
// The direct member therefore keeps their team access.
|
||||
const memberStillHasAccess = await prisma.teamGroup.findFirst({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
organisationGroup: {
|
||||
type: OrganisationGroupType.INTERNAL_TEAM,
|
||||
organisationGroupMembers: {
|
||||
some: { organisationMember: { userId: directMember.id } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(memberStillHasAccess).not.toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* Guards against over-blocking: user-created (CUSTOM) team groups are not
|
||||
* internal and must remain removable by team managers/admins.
|
||||
*/
|
||||
test('[TEAMS]: custom team groups can still be deleted', async ({ page }) => {
|
||||
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
const customGroup = await prisma.organisationGroup.create({
|
||||
data: {
|
||||
id: generateDatabaseId('org_group'),
|
||||
name: `custom-${team.url}`,
|
||||
type: OrganisationGroupType.CUSTOM,
|
||||
organisationRole: OrganisationMemberRole.MEMBER,
|
||||
organisationId: organisation.id,
|
||||
teamGroups: {
|
||||
create: {
|
||||
id: generateDatabaseId('team_group'),
|
||||
teamId: team.id,
|
||||
teamRole: TeamMemberRole.MEMBER,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { teamGroups: true },
|
||||
});
|
||||
|
||||
const customTeamGroup = customGroup.teamGroups[0];
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const response = await callTeamGroupMutation(page, 'team.group.delete', team.id, {
|
||||
teamId: team.id,
|
||||
teamGroupId: customTeamGroup.id,
|
||||
});
|
||||
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const deleted = await prisma.teamGroup.findUnique({ where: { id: customTeamGroup.id } });
|
||||
|
||||
expect(deleted).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The same root cause affects updates: an INTERNAL_TEAM group's role must not be
|
||||
* editable either, otherwise a team admin could rewrite the backbone roles
|
||||
* (e.g. promote the member group to admin).
|
||||
*/
|
||||
test('[TEAMS]: internal team groups cannot be updated via the API', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
await apiSignin({ page, email: owner.email });
|
||||
|
||||
const internalMemberGroup = await prisma.teamGroup.findFirstOrThrow({
|
||||
where: {
|
||||
teamId: team.id,
|
||||
teamRole: TeamMemberRole.MEMBER,
|
||||
organisationGroup: { type: OrganisationGroupType.INTERNAL_TEAM },
|
||||
},
|
||||
});
|
||||
|
||||
const response = await callTeamGroupMutation(page, 'team.group.update', team.id, {
|
||||
id: internalMemberGroup.id,
|
||||
data: { teamRole: TeamMemberRole.ADMIN },
|
||||
});
|
||||
|
||||
expect(response.status()).not.toBe(200);
|
||||
|
||||
const reloaded = await prisma.teamGroup.findUniqueOrThrow({ where: { id: internalMemberGroup.id } });
|
||||
|
||||
expect(reloaded.teamRole).toBe(TeamMemberRole.MEMBER);
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { OrganisationMemberRole, TeamMemberRole } from '@prisma/client';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
import { openDropdownMenu } from '../fixtures/generic';
|
||||
|
||||
/**
|
||||
* Reproduces the "Team has no internal team groups" bug.
|
||||
*
|
||||
* When a team has member inheritance turned OFF, organisation admins/managers are
|
||||
* still inherited into the team as team admins (shown with the "Group" source).
|
||||
* These members are not part of the team's INTERNAL_TEAM group, so they cannot be
|
||||
* removed via the team members page - attempting to do so threw a 500 ("Team has no
|
||||
* internal team groups").
|
||||
*
|
||||
* Instead of crashing, the delete dialog must explain why the inherited member can't
|
||||
* be removed and not offer a confirm button.
|
||||
*/
|
||||
test('[TEAMS]: explains why an inherited organisation member cannot be removed', async ({ page }) => {
|
||||
// Team created with member inheritance OFF.
|
||||
const { user: owner, organisation, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
const inheritedAdminEmail = `inherited-admin-${team.url}@test.documenso.com`;
|
||||
|
||||
// A second organisation admin is inherited into the team as a team admin (source "Group").
|
||||
await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [
|
||||
{
|
||||
name: 'Inherited Admin',
|
||||
email: inheritedAdminEmail,
|
||||
organisationRole: OrganisationMemberRole.ADMIN,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/members`,
|
||||
});
|
||||
|
||||
const inheritedMemberRow = page.getByRole('row').filter({ hasText: inheritedAdminEmail });
|
||||
|
||||
// Sanity check: the member is inherited from a group, not a direct team member.
|
||||
await expect(inheritedMemberRow).toBeVisible();
|
||||
await expect(inheritedMemberRow).toContainText('Group');
|
||||
|
||||
await openDropdownMenu(page, inheritedMemberRow.getByRole('button').last());
|
||||
|
||||
// The action stays enabled - opening it shows a dialog explaining why the inherited
|
||||
// member can't be removed, rather than triggering the 500.
|
||||
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
|
||||
await expect(removeMenuItem).toBeEnabled();
|
||||
await removeMenuItem.click();
|
||||
|
||||
await expect(page.getByText('inherited from a group').first()).toBeVisible();
|
||||
|
||||
// No confirm button is offered, so the broken removal can never be triggered.
|
||||
await expect(page.getByRole('button', { name: 'Remove' })).toHaveCount(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Guards against over-disabling the remove action: a direct team member (one that
|
||||
* belongs to the team's INTERNAL_TEAM group) must still be removable.
|
||||
*/
|
||||
test('[TEAMS]: can remove a direct team member', async ({ page }) => {
|
||||
const { user: owner, team } = await seedUser({ inheritMembers: false });
|
||||
|
||||
const directMember = await seedTeamMember({
|
||||
teamId: team.id,
|
||||
name: 'Direct Member',
|
||||
role: TeamMemberRole.MEMBER,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: owner.email,
|
||||
redirectPath: `/t/${team.url}/settings/members`,
|
||||
});
|
||||
|
||||
const directMemberRow = page.getByRole('row').filter({ hasText: directMember.email });
|
||||
|
||||
await expect(directMemberRow).toBeVisible();
|
||||
|
||||
await openDropdownMenu(page, directMemberRow.getByRole('button').last());
|
||||
|
||||
const removeMenuItem = page.getByRole('menuitem', { name: 'Remove' });
|
||||
|
||||
// The "Remove" action is enabled for direct members and removing them succeeds.
|
||||
await expect(removeMenuItem).toBeEnabled();
|
||||
await removeMenuItem.click();
|
||||
|
||||
await page.getByRole('button', { name: 'Remove' }).click();
|
||||
|
||||
await expect(page.getByText('You have successfully removed this user from the team.').first()).toBeVisible();
|
||||
|
||||
// The member is actually gone after reloading the members list.
|
||||
await page.reload();
|
||||
await expect(page.getByRole('row').filter({ hasText: owner.email })).toBeVisible();
|
||||
await expect(page.getByRole('row').filter({ hasText: directMember.email })).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { TeamMemberRole } from '@documenso/prisma/client';
|
||||
import { seedTeam, seedTeamMember } from '@documenso/prisma/seed/teams';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
/**
|
||||
* Editing the team public profile is a team-management action and must require
|
||||
* MANAGE_TEAM, consistent with renaming the team or changing its URL.
|
||||
*/
|
||||
test('[TEAMS]: a member cannot edit the team public profile', async ({ page }) => {
|
||||
const { team, owner } = await seedTeam();
|
||||
const member = await seedTeamMember({ teamId: team.id, role: TeamMemberRole.MEMBER });
|
||||
|
||||
await apiSignin({ page, email: member.email });
|
||||
|
||||
const profileRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
|
||||
data: JSON.stringify({
|
||||
json: {
|
||||
teamId: team.id,
|
||||
data: { profileEnabled: true, profileBio: 'edited-by-member' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(profileRes.status()).not.toBe(200);
|
||||
|
||||
const profile = await prisma.teamProfile.findUnique({ where: { teamId: team.id } });
|
||||
expect(profile?.enabled ?? false).toBe(false);
|
||||
expect(profile?.bio ?? '').not.toBe('edited-by-member');
|
||||
|
||||
// The name/url path of the same route is also management-gated.
|
||||
const nameRes = await page.context().request.post(`${WEBAPP_BASE_URL}/api/trpc/team.update`, {
|
||||
headers: { 'content-type': 'application/json', 'x-team-id': team.id.toString() },
|
||||
data: JSON.stringify({
|
||||
json: { teamId: team.id, data: { name: 'renamed-by-member' } },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(nameRes.status()).not.toBe(200);
|
||||
|
||||
const reloaded = await prisma.team.findUnique({ where: { id: team.id } });
|
||||
expect(reloaded?.name).not.toBe('renamed-by-member');
|
||||
|
||||
expect(owner.id).toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('[TEAMS]: settings save bar docks at the bottom of the form', async ({ page }) => {
|
||||
const { user, team } = await seedUser();
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/t/${team.url}/settings`,
|
||||
});
|
||||
|
||||
await expect(page.getByLabel('Team Name*')).toBeVisible();
|
||||
|
||||
const saveButton = page.getByRole('button', { name: 'Save changes' });
|
||||
|
||||
// Pristine: the docked Save button is present but disabled; no Undo, no floating notice.
|
||||
await expect(saveButton).toBeVisible();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
|
||||
// Make a change → Save enables and Undo appears.
|
||||
const updatedName = `team-${Date.now()}`;
|
||||
await page.getByLabel('Team Name*').clear();
|
||||
await page.getByLabel('Team Name*').fill(updatedName);
|
||||
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await expect(page.getByRole('button', { name: 'Undo' })).toBeVisible();
|
||||
|
||||
// Undo → value restored, Save disabled again, Undo gone.
|
||||
await page.getByRole('button', { name: 'Undo' }).click();
|
||||
await expect(page.getByLabel('Team Name*')).toHaveValue(team.name);
|
||||
await expect(saveButton).toBeDisabled();
|
||||
await expect(page.getByRole('button', { name: 'Undo' })).toHaveCount(0);
|
||||
|
||||
// Change again → Save → success toast, returns to a pristine (disabled) state.
|
||||
await page.getByLabel('Team Name*').clear();
|
||||
await page.getByLabel('Team Name*').fill(updatedName);
|
||||
await expect(saveButton).toBeEnabled();
|
||||
await saveButton.click();
|
||||
|
||||
await expect(page.getByText('Your team has been successfully updated.').first()).toBeVisible();
|
||||
await expect(saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test('[ORGANISATIONS]: settings save bar floats when the form footer is off-screen', async ({ page }) => {
|
||||
const { user, organisation } = await seedUser({
|
||||
isPersonalOrganisation: false,
|
||||
});
|
||||
|
||||
await apiSignin({
|
||||
page,
|
||||
email: user.email,
|
||||
redirectPath: `/o/${organisation.url}/settings/document`,
|
||||
});
|
||||
|
||||
// Wait for the long document-preferences form to load.
|
||||
await expect(page.getByTestId('document-language-trigger')).toBeVisible();
|
||||
|
||||
// Pristine: no floating notice even though the footer is below the fold.
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
|
||||
// Edit a field near the top → the footer is off-screen, so the floating pill appears.
|
||||
await page.getByTestId('document-language-trigger').click();
|
||||
await page.getByRole('option', { name: 'German' }).click();
|
||||
|
||||
await expect(page.getByText('You have unsaved changes')).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
|
||||
// Scroll to the footer → the floating pill merges into the docked buttons and the
|
||||
// notice disappears.
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
|
||||
await expect(page.getByText('You have unsaved changes')).not.toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Save changes' })).toBeVisible();
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test('[TEAMS]: check signature modes can be disabled', async ({ page }) => {
|
||||
await item.click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
// Wait for the update to complete
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
@@ -140,7 +140,7 @@ test('[TEAMS]: check signature modes work for templates', async ({ page }) => {
|
||||
await item.click();
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Update' }).first().click();
|
||||
await page.getByRole('button', { name: 'Save changes' }).first().click();
|
||||
|
||||
// Wait for finish
|
||||
await expect(page.getByText('Document preferences updated', { exact: true })).toBeVisible();
|
||||
|
||||
@@ -1,23 +1,395 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_USER_ACCOUNT_TYPE } from '@documenso/lib/constants/organisations';
|
||||
import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email';
|
||||
import { nanoid } from '@documenso/lib/universal/id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
import { DocumentStatus, EnvelopeType, SubscriptionStatus } from '@documenso/prisma/client';
|
||||
import { seedBlankDocument } from '@documenso/prisma/seed/documents';
|
||||
import { seedOrganisationMembers } from '@documenso/prisma/seed/organisations';
|
||||
import { seedUser } from '@documenso/prisma/seed/users';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
import { apiSignin } from '../fixtures/authentication';
|
||||
|
||||
test('[USER] delete account', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
test.describe.configure({ mode: 'parallel' });
|
||||
|
||||
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
|
||||
const WEBAPP_BASE_URL = NEXT_PUBLIC_WEBAPP_URL();
|
||||
|
||||
/**
|
||||
* The deleted-account service account is where orphaned DOCUMENT envelopes land
|
||||
* when the team/org they belong to is torn down. It is created by a migration so
|
||||
* it always exists in the test database.
|
||||
*/
|
||||
const getDeletedServiceAccount = async () => {
|
||||
const deletedAccount = await prisma.user.findFirstOrThrow({
|
||||
where: { email: { startsWith: 'deleted-account@' } },
|
||||
select: {
|
||||
id: true,
|
||||
ownedOrganisations: { select: { teams: { select: { id: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: deletedAccount.id,
|
||||
teamId: deletedAccount.ownedOrganisations[0].teams[0].id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Drives the account deletion through the settings UI, exactly as a user would.
|
||||
* Returns once the app has redirected to the sign-in page (deletion is performed
|
||||
* synchronously by the `profile.deleteAccount` mutation before the redirect).
|
||||
*/
|
||||
const deleteAccountViaUi = async (page: Page, email: string) => {
|
||||
await apiSignin({ page, email, redirectPath: '/settings' });
|
||||
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByLabel('Confirm Email').fill(user.email);
|
||||
await page.getByLabel('Confirm Email').fill(email);
|
||||
|
||||
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).not.toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Confirm Deletion' }).click();
|
||||
|
||||
await page.waitForURL(`${NEXT_PUBLIC_WEBAPP_URL()}/signin`);
|
||||
await page.waitForURL(`${WEBAPP_BASE_URL}/signin`);
|
||||
};
|
||||
|
||||
// Verify that the user no longer exists in the database
|
||||
const seedDocumentWithStatus = async (sender: User, teamId: number, key: string, status: DocumentStatus) => {
|
||||
const document = await seedBlankDocument(sender, teamId, { key });
|
||||
|
||||
if (status !== DocumentStatus.DRAFT) {
|
||||
await prisma.envelope.update({
|
||||
where: { id: document.id },
|
||||
data: { status },
|
||||
});
|
||||
}
|
||||
|
||||
return document;
|
||||
};
|
||||
|
||||
const waitForOrganisationToBeGone = async (organisationId: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const org = await prisma.organisation.findUnique({
|
||||
where: { id: organisationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return org === null;
|
||||
},
|
||||
{
|
||||
message: `Organisation ${organisationId} was not removed after account deletion`,
|
||||
timeout: 15_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
};
|
||||
|
||||
// ─── Happy path: the basic flow still works ──────────────────────────────────
|
||||
|
||||
test('[USER] delete account', async ({ page }) => {
|
||||
const { user } = await seedUser();
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
// Verify that the user no longer exists in the database.
|
||||
await expect(getUserByEmail({ email: user.email })).rejects.toThrow();
|
||||
});
|
||||
|
||||
// ─── Owned organisation: documents orphaned to the service account ───────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: owned org docs are orphaned to service account, drafts and templates removed', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
// Inflight/completed DOCUMENT envelopes that must survive as orphans.
|
||||
const completed = await seedDocumentWithStatus(user, team.id, 'owned-completed', DocumentStatus.COMPLETED);
|
||||
const pending = await seedDocumentWithStatus(user, team.id, 'owned-pending', DocumentStatus.PENDING);
|
||||
const rejected = await seedDocumentWithStatus(user, team.id, 'owned-rejected', DocumentStatus.REJECTED);
|
||||
|
||||
// A draft DOCUMENT — orphan only re-parents PENDING/REJECTED/COMPLETED, so it is hard-deleted.
|
||||
const draft = await seedDocumentWithStatus(user, team.id, 'owned-draft', DocumentStatus.DRAFT);
|
||||
|
||||
// A TEMPLATE — orphan only re-parents DOCUMENT envelopes, so it is hard-deleted.
|
||||
const template = await seedBlankDocument(user, team.id, { key: 'owned-template' });
|
||||
await prisma.envelope.update({
|
||||
where: { id: template.id },
|
||||
data: { type: EnvelopeType.TEMPLATE },
|
||||
});
|
||||
|
||||
expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(5);
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const service = await getDeletedServiceAccount();
|
||||
|
||||
// Completed/pending/rejected: re-parented to the service account + soft-deleted.
|
||||
for (const original of [completed, pending, rejected]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(after, `envelope ${original.id} should survive as an orphan`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(service.teamId);
|
||||
expect(after?.userId).toBe(service.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
}
|
||||
|
||||
// Draft + template are hard-deleted.
|
||||
expect(await prisma.envelope.findUnique({ where: { id: draft.id } })).toBeNull();
|
||||
expect(await prisma.envelope.findUnique({ where: { id: template.id } })).toBeNull();
|
||||
|
||||
// The owned org, its team, and the user are gone. Nothing references the old team.
|
||||
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull();
|
||||
expect(await prisma.team.findUnique({ where: { id: team.id } })).toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: user.id } })).toBeNull();
|
||||
expect(await prisma.envelope.count({ where: { teamId: team.id } })).toBe(0);
|
||||
});
|
||||
|
||||
// ─── Member of another org: documents transferred to the OWNER, not deleted ──
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: docs in orgs the user is a member of are transferred to the org owner', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Another org, owned by someone else, that the deleted user is merely a member of.
|
||||
const { user: ownerB, organisation: orgB, team: teamB } = await seedUser();
|
||||
|
||||
// The account being deleted. They own their own (personal) org too.
|
||||
const { user: userA, organisation: orgA, team: teamA } = await seedUser();
|
||||
|
||||
await seedOrganisationMembers({
|
||||
organisationId: orgB.id,
|
||||
members: [{ email: userA.email, name: userA.name ?? 'User A', organisationRole: 'MEMBER' }],
|
||||
});
|
||||
|
||||
// userA authors envelopes inside orgB's team (both completed and draft).
|
||||
const memberCompleted = await seedDocumentWithStatus(userA, teamB.id, 'member-completed', DocumentStatus.COMPLETED);
|
||||
const memberDraft = await seedDocumentWithStatus(userA, teamB.id, 'member-draft', DocumentStatus.DRAFT);
|
||||
|
||||
// userA also has a completed doc in their OWN org (should orphan to service account).
|
||||
const ownedCompleted = await seedDocumentWithStatus(userA, teamA.id, 'owned-completed', DocumentStatus.COMPLETED);
|
||||
|
||||
await deleteAccountViaUi(page, userA.email);
|
||||
|
||||
await waitForOrganisationToBeGone(orgA.id);
|
||||
|
||||
const service = await getDeletedServiceAccount();
|
||||
|
||||
// Member-org envelopes — regardless of status — are reassigned to orgB's owner,
|
||||
// stay in orgB's team, and are NOT soft-deleted.
|
||||
for (const original of [memberCompleted, memberDraft]) {
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: original.id },
|
||||
select: { id: true, teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
|
||||
expect(after, `member envelope ${original.id} should be transferred, not deleted`).not.toBeNull();
|
||||
expect(after?.teamId).toBe(teamB.id);
|
||||
expect(after?.userId).toBe(ownerB.id);
|
||||
expect(after?.deletedAt).toBeNull();
|
||||
}
|
||||
|
||||
// The other org and its owner survive — only the deleted user's own org is removed.
|
||||
expect(await prisma.organisation.findUnique({ where: { id: orgB.id } })).not.toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: ownerB.id } })).not.toBeNull();
|
||||
|
||||
// The deleted user's own completed doc was orphaned to the service account.
|
||||
const ownedAfter = await prisma.envelope.findUnique({
|
||||
where: { id: ownedCompleted.id },
|
||||
select: { teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(ownedAfter, 'owned-org envelope should survive as an orphan').not.toBeNull();
|
||||
expect(ownedAfter?.teamId).toBe(service.teamId);
|
||||
expect(ownedAfter?.userId).toBe(service.id);
|
||||
expect(ownedAfter?.deletedAt).not.toBeNull();
|
||||
|
||||
// userA is gone.
|
||||
expect(await prisma.user.findUnique({ where: { id: userA.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Owned org with members: org torn down, members survive, their docs orphaned ─
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: deleting the owner removes the org but keeps members and orphans their docs', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: owner, organisation, team } = await seedUser();
|
||||
|
||||
const [member] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }],
|
||||
});
|
||||
|
||||
// A member (not the owner) authored a completed doc inside the owned org's team.
|
||||
// The orphan logic filters by teamId only, so this must be orphaned too.
|
||||
const memberCompleted = await seedDocumentWithStatus(member, team.id, 'member-completed', DocumentStatus.COMPLETED);
|
||||
|
||||
await deleteAccountViaUi(page, owner.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const service = await getDeletedServiceAccount();
|
||||
|
||||
const after = await prisma.envelope.findUnique({
|
||||
where: { id: memberCompleted.id },
|
||||
select: { teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(after, 'member-authored envelope should survive as an orphan').not.toBeNull();
|
||||
expect(after?.teamId).toBe(service.teamId);
|
||||
expect(after?.userId).toBe(service.id);
|
||||
expect(after?.deletedAt).not.toBeNull();
|
||||
|
||||
// The member user survives — only the org and its owner are removed.
|
||||
expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull();
|
||||
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Subscription cancellation is scheduled for owned orgs ───────────────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: a cancel-subscription job is enqueued for an owned org that has a subscription', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation } = await seedUser();
|
||||
|
||||
const planId = `sub_e2e_${nanoid()}`;
|
||||
|
||||
await prisma.subscription.create({
|
||||
data: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
planId,
|
||||
priceId: `price_e2e_${nanoid()}`,
|
||||
customerId: `cus_e2e_${nanoid()}`,
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// The deletion must schedule the Stripe subscription cancellation job with the
|
||||
// captured planId (the Subscription row itself cascades away with the org).
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.cancel-organisation-subscription',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (!job) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (job.payload as { stripeSubscriptionId?: string }).stripeSubscriptionId ?? null;
|
||||
},
|
||||
{
|
||||
message: 'cancel-organisation-subscription job was not enqueued',
|
||||
timeout: 15_000,
|
||||
intervals: [250, 500, 1000],
|
||||
},
|
||||
)
|
||||
.toBe(planId);
|
||||
|
||||
// The local Subscription row cascades away with the organisation — which is
|
||||
// exactly why the planId has to be captured into the job payload beforehand.
|
||||
expect(await prisma.subscription.findUnique({ where: { planId } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Owned org account (SSO) rows are cleaned up, members survive ────────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: org-linked account rows are removed when an owned org is torn down', async ({ page }) => {
|
||||
const { user: owner, organisation } = await seedUser();
|
||||
|
||||
const [member] = await seedOrganisationMembers({
|
||||
organisationId: organisation.id,
|
||||
members: [{ organisationRole: 'MEMBER' }],
|
||||
});
|
||||
|
||||
// Simulate a member who linked their login through the organisation's SSO.
|
||||
// These rows are keyed by `provider = organisation.id` and have no foreign key
|
||||
// to the organisation, so they must be deleted explicitly during teardown.
|
||||
const orgAccount = await prisma.account.create({
|
||||
data: {
|
||||
userId: member.id,
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: organisation.id,
|
||||
providerAccountId: `oidc-${nanoid()}`,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteAccountViaUi(page, owner.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
// The org-linked account row is gone...
|
||||
expect(await prisma.account.findUnique({ where: { id: orgAccount.id } })).toBeNull();
|
||||
expect(
|
||||
await prisma.account.count({
|
||||
where: { type: ORGANISATION_USER_ACCOUNT_TYPE, provider: organisation.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
|
||||
// ...but the member user it belonged to survives (only the org + owner are removed).
|
||||
expect(await prisma.user.findUnique({ where: { id: member.id } })).not.toBeNull();
|
||||
expect(await prisma.user.findUnique({ where: { id: owner.id } })).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Sad path: no subscription means no cancel job is enqueued ────────────────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: no cancel-subscription job is enqueued when the owned org has no subscription', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation } = await seedUser();
|
||||
|
||||
await deleteAccountViaUi(page, user.email);
|
||||
|
||||
await waitForOrganisationToBeGone(organisation.id);
|
||||
|
||||
const job = await prisma.backgroundJob.findFirst({
|
||||
where: {
|
||||
jobId: 'internal.cancel-organisation-subscription',
|
||||
payload: { path: ['organisationId'], equals: organisation.id },
|
||||
},
|
||||
});
|
||||
|
||||
expect(job).toBeNull();
|
||||
});
|
||||
|
||||
// ─── Sad path: a mismatched confirmation email leaves everything intact ───────
|
||||
|
||||
test('[USER][DELETE_ACCOUNT]: a wrong confirmation email keeps the account, org and documents intact', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user, organisation, team } = await seedUser();
|
||||
|
||||
const completed = await seedDocumentWithStatus(user, team.id, 'kept-completed', DocumentStatus.COMPLETED);
|
||||
|
||||
await apiSignin({ page, email: user.email, redirectPath: '/settings' });
|
||||
|
||||
await page.getByRole('button', { name: 'Delete Account' }).click();
|
||||
await page.getByLabel('Confirm Email').fill('not-my-email@example.com');
|
||||
|
||||
// The confirm button stays disabled while the email does not match.
|
||||
await expect(page.getByRole('button', { name: 'Confirm Deletion' })).toBeDisabled();
|
||||
|
||||
// Nothing was deleted or orphaned.
|
||||
expect(await prisma.user.findUnique({ where: { id: user.id } })).not.toBeNull();
|
||||
expect(await prisma.organisation.findUnique({ where: { id: organisation.id } })).not.toBeNull();
|
||||
|
||||
const docAfter = await prisma.envelope.findUnique({
|
||||
where: { id: completed.id },
|
||||
select: { teamId: true, userId: true, deletedAt: true },
|
||||
});
|
||||
expect(docAfter?.teamId).toBe(team.id);
|
||||
expect(docAfter?.userId).toBe(user.id);
|
||||
expect(docAfter?.deletedAt).toBeNull();
|
||||
});
|
||||
|
||||
@@ -17,7 +17,9 @@ export const AuthenticationErrorCode = {
|
||||
// TwoFactorMissingSecret: 'TWO_FACTOR_MISSING_SECRET',
|
||||
// TwoFactorMissingCredentials: 'TWO_FACTOR_MISSING_CREDENTIALS',
|
||||
InvalidTwoFactorCode: 'INVALID_TWO_FACTOR_CODE',
|
||||
SigninDisabled: 'SIGNIN_DISABLED',
|
||||
SignupDisabled: 'SIGNUP_DISABLED',
|
||||
SignupDisposableEmail: 'SIGNUP_DISPOSABLE_EMAIL',
|
||||
// IncorrectTwoFactorBackupCode: 'INCORRECT_TWO_FACTOR_BACKUP_CODE',
|
||||
// IncorrectIdentityProvider: 'INCORRECT_IDENTITY_PROVIDER',
|
||||
// IncorrectPassword: 'INCORRECT_PASSWORD',
|
||||
|
||||
@@ -30,7 +30,6 @@ export const sessionCookieOptions = {
|
||||
sameSite: useSecureCookies ? 'none' : 'lax',
|
||||
secure: useSecureCookies,
|
||||
domain: getCookieDomain(),
|
||||
expires: new Date(Date.now() + AUTH_SESSION_LIFETIME),
|
||||
} as const;
|
||||
|
||||
export const extractSessionCookieFromHeaders = (headers: Headers): string | null => {
|
||||
@@ -56,7 +55,10 @@ export const getSessionCookie = async (c: Context): Promise<string | null> => {
|
||||
* @param sessionToken - The session token to set.
|
||||
*/
|
||||
export const setSessionCookie = async (c: Context, sessionToken: string) => {
|
||||
await setSignedCookie(c, sessionCookieName, sessionToken, getAuthSecret(), sessionCookieOptions).catch((err) => {
|
||||
await setSignedCookie(c, sessionCookieName, sessionToken, getAuthSecret(), {
|
||||
...sessionCookieOptions,
|
||||
expires: new Date(Date.now() + AUTH_SESSION_LIFETIME),
|
||||
}).catch((err) => {
|
||||
appLog('SetSessionCookie', `Error setting signed cookie: ${err}`);
|
||||
|
||||
throw err;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { AUTH_SESSION_LIFETIME } from '../../config';
|
||||
*/
|
||||
export type SessionUser = Pick<
|
||||
User,
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature'
|
||||
'id' | 'name' | 'email' | 'emailVerified' | 'avatarImageId' | 'twoFactorEnabled' | 'roles' | 'signature' | 'disabled'
|
||||
>;
|
||||
|
||||
export type SessionValidationResult =
|
||||
@@ -86,6 +86,7 @@ export const validateSessionToken = async (token: string): Promise<SessionValida
|
||||
twoFactorEnabled: true,
|
||||
roles: true,
|
||||
signature: true,
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { assertUserNotDisabledById } from '@documenso/lib/server-only/user/assert-user-not-disabled';
|
||||
import type { Context } from 'hono';
|
||||
|
||||
import type { HonoAuthContext } from '../../types/context';
|
||||
@@ -10,8 +11,15 @@ type AuthorizeUser = {
|
||||
|
||||
/**
|
||||
* Handles creating a session.
|
||||
*
|
||||
* Refuses to issue a session for a disabled account. This is the single
|
||||
* chokepoint shared by every sign-in path (email/password, passkey, OAuth,
|
||||
* OIDC, organisation OIDC), so the guard belongs here rather than in each
|
||||
* caller.
|
||||
*/
|
||||
export const onAuthorize = async (user: AuthorizeUser, c: Context<HonoAuthContext>) => {
|
||||
await assertUserNotDisabledById({ userId: user.userId });
|
||||
|
||||
const metadata = c.get('requestMetadata');
|
||||
|
||||
const sessionToken = generateSessionToken();
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import {
|
||||
isDisposableEmail,
|
||||
isEmailDomainAllowedForSignup,
|
||||
isSignupEnabledForProvider,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { deletedServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/deleted-account';
|
||||
import { legacyServiceAccountEmail } from '@documenso/lib/server-only/user/service-accounts/legacy-service-account';
|
||||
@@ -132,6 +137,17 @@ export const handleOAuthCallbackUrl = async (options: HandleOAuthCallbackUrlOpti
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
// Reject disposable / throwaway email providers for new SSO users.
|
||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||
|
||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||
const errorUrl = new URL('/signin', NEXT_PUBLIC_WEBAPP_URL());
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
||||
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
// Handle new user.
|
||||
const createdUser = await prisma.$transaction(async (tx) => {
|
||||
const user = await tx.user.create({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sendOrganisationAccountLinkConfirmationEmail } from '@documenso/ee/server-only/lib/send-organisation-account-link-confirmation-email';
|
||||
import { isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { isDisposableEmail, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
import { onCreateUserHook } from '@documenso/lib/server-only/user/create-user';
|
||||
import { formatOrganisationLoginUrl } from '@documenso/lib/utils/organisation-authentication-portal';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
@@ -74,6 +75,17 @@ export const handleOAuthOrganisationCallbackUrl = async (options: HandleOAuthOrg
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
// Reject disposable / throwaway email providers for new SSO users.
|
||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||
|
||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||
const errorUrl = new URL(formatOrganisationLoginUrl(orgUrl));
|
||||
|
||||
errorUrl.searchParams.set('error', AuthenticationErrorCode.SignupDisposableEmail);
|
||||
|
||||
return c.redirect(errorUrl.toString(), 302);
|
||||
}
|
||||
|
||||
userToLink = await prisma.user.create({
|
||||
data: {
|
||||
email: email,
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { isEmailDomainAllowedForSignup, isSignupEnabledForProvider } from '@documenso/lib/constants/auth';
|
||||
import {
|
||||
isDisposableEmail,
|
||||
isEmailDomainAllowedForSignup,
|
||||
isSigninEnabledForProvider,
|
||||
isSignupEnabledForProvider,
|
||||
} from '@documenso/lib/constants/auth';
|
||||
import { EMAIL_VERIFICATION_STATE } from '@documenso/lib/constants/email';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { jobsClient } from '@documenso/lib/jobs/client';
|
||||
@@ -18,6 +23,7 @@ import {
|
||||
signupRateLimit,
|
||||
verifyEmailRateLimit,
|
||||
} from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { getEmailBlocklistDomains } from '@documenso/lib/server-only/site-settings/get-email-blocklist-domains';
|
||||
import { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||
import { forgotPassword } from '@documenso/lib/server-only/user/forgot-password';
|
||||
import { getMostRecentEmailVerificationToken } from '@documenso/lib/server-only/user/get-most-recent-email-verification-token';
|
||||
@@ -59,6 +65,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
.post('/authorize', sValidator('json', ZSignInSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const { email, password, totpCode, backupCode, csrfToken, captchaToken } = c.req.valid('json');
|
||||
|
||||
const loginLimitResult = await loginRateLimit.check({
|
||||
@@ -167,12 +179,8 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
});
|
||||
}
|
||||
|
||||
if (user.disabled) {
|
||||
throw new AppError('ACCOUNT_DISABLED', {
|
||||
message: 'Account disabled',
|
||||
});
|
||||
}
|
||||
|
||||
// The disabled check now lives inside `onAuthorize` so every sign-in path
|
||||
// (password, passkey, OAuth, OIDC) shares the same enforcement.
|
||||
await onAuthorize({ userId: user.id }, c);
|
||||
|
||||
return c.text('', 201);
|
||||
@@ -214,6 +222,14 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
});
|
||||
}
|
||||
|
||||
const additionalBlockedDomains = await getEmailBlocklistDomains();
|
||||
|
||||
if (isDisposableEmail(email, additionalBlockedDomains)) {
|
||||
throw new AppError(AuthenticationErrorCode.SignupDisposableEmail, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await createUser({ name, email, password, signature }).catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
@@ -235,6 +251,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
const { password, currentPassword } = c.req.valid('json');
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const { session, user } = await getSession(c);
|
||||
|
||||
await updatePassword({
|
||||
@@ -337,6 +359,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
.post('/forgot-password', sValidator('json', ZForgotPasswordSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const { email } = c.req.valid('json');
|
||||
|
||||
const forgotLimitResult = await forgotPasswordRateLimit.check({
|
||||
@@ -368,6 +396,12 @@ export const emailPasswordRoute = new Hono<HonoAuthContext>()
|
||||
.post('/reset-password', sValidator('json', ZResetPasswordSchema), async (c) => {
|
||||
const requestMetadata = c.get('requestMetadata');
|
||||
|
||||
if (!isSigninEnabledForProvider('email')) {
|
||||
throw new AppError(AuthenticationErrorCode.SigninDisabled, {
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const { token, password } = c.req.valid('json');
|
||||
|
||||
const resetLimitResult = await resetPasswordRateLimit.check({
|
||||
|
||||
@@ -16,6 +16,8 @@
|
||||
"@aws-sdk/client-sesv2": "^3.998.0",
|
||||
"@documenso/lib": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"arctic": "^3.7.0",
|
||||
"hono": "^4.12.14",
|
||||
"luxon": "^3.7.2",
|
||||
"react": "^18",
|
||||
"ts-pattern": "^5.9.0",
|
||||
|
||||
@@ -75,6 +75,13 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({
|
||||
},
|
||||
});
|
||||
|
||||
// We only take `emailLanguage` here and intentionally ignore the resolved
|
||||
// `emailTransport`/`senderEmail`. Unlike other INTERNAL emails, this is an
|
||||
// auth-critical SSO account creation/linking confirmation: it must always be
|
||||
// delivered from trusted Documenso infrastructure (see the `mailer.sendMail`
|
||||
// below). Routing it through the organisation's own (potentially
|
||||
// misconfigured) transport could block account linking and lock users out of
|
||||
// their own SSO setup.
|
||||
const { emailLanguage } = await getEmailContext({
|
||||
emailType: 'INTERNAL',
|
||||
source: {
|
||||
@@ -101,6 +108,10 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({
|
||||
|
||||
const i18n = await getI18nInstance(emailLanguage);
|
||||
|
||||
// Deliberately uses the global Documenso mailer + internal sender (not the
|
||||
// organisation's configured email transport) so auth/SSO confirmation mail is
|
||||
// always sent from trusted, controlled infrastructure. See the note on the
|
||||
// getEmailContext call above.
|
||||
return mailer.sendMail({
|
||||
to: {
|
||||
address: user.email,
|
||||
|
||||
@@ -28,3 +28,10 @@ export const SELFHOSTED_PLAN_LIMITS: TLimitsSchema = {
|
||||
* Used as an initial value for the frontend before values are loaded from the server.
|
||||
*/
|
||||
export const DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT = 5;
|
||||
|
||||
/**
|
||||
* Used as an initial value for the frontend before values are loaded from the server.
|
||||
*
|
||||
* 0 = Unlimited recipients.
|
||||
*/
|
||||
export const DEFAULT_RECIPIENT_COUNT = 20;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSource, EnvelopeType, SubscriptionStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -69,6 +70,15 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
||||
};
|
||||
}
|
||||
|
||||
// Early return for organisations created ahead of a paid checkout that are still awaiting payment.
|
||||
if (isOrganisationPendingPayment(organisation)) {
|
||||
return {
|
||||
quota: INACTIVE_PLAN_LIMITS,
|
||||
remaining: INACTIVE_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Allow unlimited documents for users with an unlimited documents claim.
|
||||
// This also allows "free" claim users without subscriptions if they have this flag.
|
||||
if (organisation.organisationClaim.flags.unlimitedDocuments) {
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import type { TCscCredentialsInfoResponse } from './client/types';
|
||||
|
||||
/**
|
||||
* CSC QES V1 algorithm policy.
|
||||
*
|
||||
* Single OID-to-algorithm map + single helper that:
|
||||
* - validates cert state (status, validity window) → `CSC_CERT_INVALID`,
|
||||
* - validates the credential's key + algorithm against the spec's policy
|
||||
* table (RSA ≥2048, ECDSA P-256/384/521, SHA-256/384/512) →
|
||||
* `CSC_ALGORITHM_REFUSED`,
|
||||
* - resolves a concrete `(signAlgo, hashAlgo)` OID pair for §11.9.
|
||||
*
|
||||
* Called at the service-scope OAuth callback (validation boundary) and
|
||||
* re-called at sign time as a defence-in-depth pre-check. Persisted fields
|
||||
* (`keyType` / `keyLenBits` / `digestAlgorithm` / `signAlgoOid`) round-trip
|
||||
* through `CscCredential`.
|
||||
*/
|
||||
|
||||
export type CscKeyType = 'RSA' | 'ECDSA';
|
||||
|
||||
export type CscDigest = 'SHA-256' | 'SHA-384' | 'SHA-512';
|
||||
|
||||
export type CscEcdsaCurve = 'P-256' | 'P-384' | 'P-521';
|
||||
|
||||
export type CscAlgorithmPolicy = {
|
||||
keyType: CscKeyType;
|
||||
keyLenBits: number;
|
||||
digestAlgorithm: CscDigest;
|
||||
/** OID for `signatures/signHash.signAlgo` + persisted on `CscCredential`. */
|
||||
signAlgoOid: string;
|
||||
/** OID for `signatures/signHash.hashAlgo`. */
|
||||
hashAlgoOid: string;
|
||||
/** ECDSA named curve (informational; not separately persisted). */
|
||||
ecdsaCurve?: CscEcdsaCurve;
|
||||
};
|
||||
|
||||
/**
|
||||
* Default RSA digest when the TSP advertises only hash-agnostic RSA OIDs
|
||||
* (plain `rsaEncryption` / RSASSA-PSS). SHA-256 matches the CSC §11.9
|
||||
* sample and is universally TSP-supported.
|
||||
*/
|
||||
const DEFAULT_RSA_DIGEST: CscDigest = 'SHA-256';
|
||||
|
||||
const HASH_OID_FOR_DIGEST: Record<CscDigest, string> = {
|
||||
'SHA-256': '2.16.840.1.101.3.4.2.1',
|
||||
'SHA-384': '2.16.840.1.101.3.4.2.2',
|
||||
'SHA-512': '2.16.840.1.101.3.4.2.3',
|
||||
};
|
||||
|
||||
/**
|
||||
* Exposed lookup for the `signatures/signHash.hashAlgo` OID corresponding to a
|
||||
* resolved {@link CscDigest}. Useful at sign time when the policy's
|
||||
* `hashAlgoOid` field is not in scope (e.g. when reconstructing a
|
||||
* `LibpdfSignerAlgo` from a persisted `CscCredential` row).
|
||||
*/
|
||||
export const hashOidForDigest = (digest: CscDigest): string => HASH_OID_FOR_DIGEST[digest];
|
||||
|
||||
const DIGEST_STRENGTH: Record<CscDigest, number> = {
|
||||
'SHA-256': 256,
|
||||
'SHA-384': 384,
|
||||
'SHA-512': 512,
|
||||
};
|
||||
|
||||
const STRONG_DIGEST_SET = new Set<string>(['SHA-256', 'SHA-384', 'SHA-512']);
|
||||
|
||||
type AlgoOidInfo = { family: 'RSA' | 'ECDSA'; boundDigest: CscDigest | 'SHA-1' | 'MD5' | null } | { family: 'DSA' };
|
||||
|
||||
/**
|
||||
* Source-of-truth registry for `key.algo` entries (§11.5). Anything not
|
||||
* listed is treated as unknown and skipped at policy evaluation.
|
||||
*/
|
||||
const KEY_ALGO_OID_REGISTRY: Record<string, AlgoOidInfo> = {
|
||||
// Hash-agnostic RSA — caller picks the hash via `hashAlgo`.
|
||||
'1.2.840.113549.1.1.1': { family: 'RSA', boundDigest: null }, // rsaEncryption
|
||||
'1.2.840.113549.1.1.10': { family: 'RSA', boundDigest: null }, // RSASSA-PSS
|
||||
|
||||
// Hash-bound legacy RSA combos.
|
||||
'1.2.840.113549.1.1.4': { family: 'RSA', boundDigest: 'MD5' }, // md5WithRSAEncryption
|
||||
'1.2.840.113549.1.1.5': { family: 'RSA', boundDigest: 'SHA-1' }, // sha1WithRSAEncryption
|
||||
'1.2.840.113549.1.1.11': { family: 'RSA', boundDigest: 'SHA-256' }, // sha256WithRSAEncryption
|
||||
'1.2.840.113549.1.1.12': { family: 'RSA', boundDigest: 'SHA-384' }, // sha384WithRSAEncryption
|
||||
'1.2.840.113549.1.1.13': { family: 'RSA', boundDigest: 'SHA-512' }, // sha512WithRSAEncryption
|
||||
|
||||
// ECDSA with SHA-x (hash is always bound).
|
||||
'1.2.840.10045.4.1': { family: 'ECDSA', boundDigest: 'SHA-1' }, // ecdsa-with-SHA1
|
||||
'1.2.840.10045.4.3.2': { family: 'ECDSA', boundDigest: 'SHA-256' },
|
||||
'1.2.840.10045.4.3.3': { family: 'ECDSA', boundDigest: 'SHA-384' },
|
||||
'1.2.840.10045.4.3.4': { family: 'ECDSA', boundDigest: 'SHA-512' },
|
||||
|
||||
// DSA — refused outright.
|
||||
'1.2.840.10040.4.1': { family: 'DSA' },
|
||||
'1.2.840.10040.4.3': { family: 'DSA' }, // dsa-with-SHA1
|
||||
};
|
||||
|
||||
/**
|
||||
* ECDSA named-curve OID registry. Policy verdict (allow/refuse) is decided
|
||||
* by the resolver from the resolved curve name, not encoded here.
|
||||
*/
|
||||
const CURVE_OID_REGISTRY: Record<string, CscEcdsaCurve | 'P-192' | 'P-224'> = {
|
||||
'1.2.840.10045.3.1.7': 'P-256', // secp256r1
|
||||
'1.3.132.0.34': 'P-384', // secp384r1
|
||||
'1.3.132.0.35': 'P-521', // secp521r1
|
||||
'1.2.840.10045.3.1.1': 'P-192', // secp192r1
|
||||
'1.3.132.0.33': 'P-224', // secp224r1
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a CSC credential's cert + key/algorithm against V1 policy and
|
||||
* resolve the `(signAlgo, hashAlgo)` OID pair used by `signatures/signHash`.
|
||||
*
|
||||
* Caller MUST fetch the credential with `certInfo: true` so `cert.validFrom`
|
||||
* / `cert.validTo` are present.
|
||||
*
|
||||
* Throws:
|
||||
* - `CSC_CERT_INVALID` for cert-state issues (status not `valid`, missing or
|
||||
* malformed validity dates, current time outside the validity window).
|
||||
* - `CSC_ALGORITHM_REFUSED` for key/algorithm policy failures (disabled key,
|
||||
* missing `key.len`, RSA `< 2048`, ECDSA without an allowed curve, DSA, no
|
||||
* acceptable digest advertised in `key.algo`).
|
||||
*/
|
||||
export const resolveCscAlgorithmPolicy = (credentialInfo: TCscCredentialsInfoResponse): CscAlgorithmPolicy => {
|
||||
assertCertValid(credentialInfo.cert);
|
||||
|
||||
if (credentialInfo.key.status !== 'enabled') {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: `CSC credential key status is '${credentialInfo.key.status}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (credentialInfo.key.len === undefined) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: 'CSC credential omits required key.len (REQUIRED per §11.5).',
|
||||
});
|
||||
}
|
||||
|
||||
const choice = pickAlgorithmChoice(credentialInfo.key.algo);
|
||||
|
||||
if (choice.family === 'RSA') {
|
||||
if (credentialInfo.key.len < 2048) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: `CSC RSA credential keyLen ${credentialInfo.key.len} < 2048.`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
keyType: 'RSA',
|
||||
keyLenBits: credentialInfo.key.len,
|
||||
digestAlgorithm: choice.digest,
|
||||
signAlgoOid: choice.signAlgoOid,
|
||||
hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest],
|
||||
};
|
||||
}
|
||||
|
||||
const curve = resolveEcdsaCurve(credentialInfo.key.curve);
|
||||
|
||||
return {
|
||||
keyType: 'ECDSA',
|
||||
keyLenBits: credentialInfo.key.len,
|
||||
digestAlgorithm: choice.digest,
|
||||
signAlgoOid: choice.signAlgoOid,
|
||||
hashAlgoOid: HASH_OID_FOR_DIGEST[choice.digest],
|
||||
ecdsaCurve: curve,
|
||||
};
|
||||
};
|
||||
|
||||
type AlgorithmChoice = {
|
||||
family: 'RSA' | 'ECDSA';
|
||||
signAlgoOid: string;
|
||||
digest: CscDigest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Iterate the TSP's advertised `key.algo` OIDs, drop the policy-refused
|
||||
* entries, and pick the strongest survivor.
|
||||
*
|
||||
* Precedence: ECDSA before RSA (smaller signatures, modern); within each
|
||||
* family, strongest advertised digest first. Hash-agnostic RSA OIDs pair
|
||||
* with {@link DEFAULT_RSA_DIGEST}.
|
||||
*/
|
||||
const pickAlgorithmChoice = (algoOids: readonly string[]): AlgorithmChoice => {
|
||||
const candidates: AlgorithmChoice[] = [];
|
||||
|
||||
for (const oid of algoOids) {
|
||||
const info = KEY_ALGO_OID_REGISTRY[oid];
|
||||
|
||||
if (info === undefined) {
|
||||
// Unknown OID — another entry in `key.algo` may still be acceptable.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.family === 'DSA') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (info.boundDigest === null) {
|
||||
candidates.push({
|
||||
family: info.family,
|
||||
signAlgoOid: oid,
|
||||
digest: DEFAULT_RSA_DIGEST,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (STRONG_DIGEST_SET.has(info.boundDigest)) {
|
||||
candidates.push({
|
||||
family: info.family,
|
||||
signAlgoOid: oid,
|
||||
digest: info.boundDigest as CscDigest,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: `CSC credential advertises no policy-compliant key.algo OIDs (got: ${algoOids.join(', ') || '<empty>'}).`,
|
||||
});
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => {
|
||||
if (a.family !== b.family) {
|
||||
return a.family === 'ECDSA' ? -1 : 1;
|
||||
}
|
||||
|
||||
return DIGEST_STRENGTH[b.digest] - DIGEST_STRENGTH[a.digest];
|
||||
});
|
||||
|
||||
return candidates[0];
|
||||
};
|
||||
|
||||
const resolveEcdsaCurve = (curveOid: string | undefined): CscEcdsaCurve => {
|
||||
if (curveOid === undefined || curveOid === '') {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: 'CSC ECDSA credential omits required key.curve.',
|
||||
});
|
||||
}
|
||||
|
||||
const named = CURVE_OID_REGISTRY[curveOid];
|
||||
|
||||
if (named === 'P-256' || named === 'P-384' || named === 'P-521') {
|
||||
return named;
|
||||
}
|
||||
|
||||
const detail = named ? `, named=${named}` : '';
|
||||
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: `CSC ECDSA credential uses refused curve (oid=${curveOid}${detail}).`,
|
||||
});
|
||||
};
|
||||
|
||||
const assertCertValid = (cert: TCscCredentialsInfoResponse['cert']): void => {
|
||||
if (cert.status !== undefined && cert.status !== 'valid') {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: `CSC credential certificate status is '${cert.status}'.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!cert.validFrom || !cert.validTo) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: 'CSC credential certificate omits validFrom/validTo (malformed).',
|
||||
});
|
||||
}
|
||||
|
||||
const validFromMs = parseGeneralizedTime(cert.validFrom);
|
||||
const validToMs = parseGeneralizedTime(cert.validTo);
|
||||
|
||||
if (validFromMs === null || validToMs === null) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: `CSC credential certificate validity dates are malformed (validFrom=${cert.validFrom}, validTo=${cert.validTo}).`,
|
||||
});
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (now < validFromMs) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: `CSC credential certificate is not yet valid (validFrom=${cert.validFrom}).`,
|
||||
});
|
||||
}
|
||||
|
||||
if (now > validToMs) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: `CSC credential certificate has expired (validTo=${cert.validTo}).`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse an X.509 GeneralizedTime string (`YYYYMMDDHHMMSSZ`) into epoch ms.
|
||||
* Strict — returns null on any deviation from the §11.5 example format.
|
||||
*/
|
||||
const parseGeneralizedTime = (value: string): number | null => {
|
||||
const matched = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(value);
|
||||
|
||||
if (matched === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, y, mo, d, h, mi, s] = matched;
|
||||
|
||||
const ms = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s));
|
||||
|
||||
return Number.isNaN(ms) ? null : ms;
|
||||
};
|
||||
|
||||
/**
|
||||
* Subset of libpdf's `Signer` interface fields derived from a `CscAlgorithmPolicy`.
|
||||
* Used by `CscCaptureSigner` / `CscFifoSigner` to satisfy libpdf's signer
|
||||
* contract without re-deriving the mapping at each call site. `keyLenBits`
|
||||
* is carried through so the capture-signer can size its placeholder output
|
||||
* appropriately for the chosen key.
|
||||
*/
|
||||
export type LibpdfSignerAlgo = {
|
||||
keyType: 'RSA' | 'EC';
|
||||
signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
|
||||
digestAlgorithm: CscDigest;
|
||||
keyLenBits: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Translate a `CscAlgorithmPolicy` (CSC §11.5 OIDs) into libpdf's `Signer`
|
||||
* algorithm tuple. RSASSA-PSS is detected by the `signAlgoOid`; everything
|
||||
* else maps directly from `keyType` + `digestAlgorithm`.
|
||||
*/
|
||||
export const policyToLibpdfSignerAlgo = (policy: CscAlgorithmPolicy): LibpdfSignerAlgo => {
|
||||
if (policy.keyType === 'ECDSA') {
|
||||
return {
|
||||
keyType: 'EC',
|
||||
signatureAlgorithm: 'ECDSA',
|
||||
digestAlgorithm: policy.digestAlgorithm,
|
||||
keyLenBits: policy.keyLenBits,
|
||||
};
|
||||
}
|
||||
|
||||
// RSA — distinguish PKCS1-v1.5 from PSS by the resolved sign-algo OID.
|
||||
// RSASSA-PSS OID: '1.2.840.113549.1.1.10'.
|
||||
const signatureAlgorithm: 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' =
|
||||
policy.signAlgoOid === '1.2.840.113549.1.1.10' ? 'RSA-PSS' : 'RSASSA-PKCS1-v1_5';
|
||||
|
||||
return {
|
||||
keyType: 'RSA',
|
||||
signatureAlgorithm,
|
||||
digestAlgorithm: policy.digestAlgorithm,
|
||||
keyLenBits: policy.keyLenBits,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
/**
|
||||
* Length-prefixed X.509 chain for `CscCredential.certCache`. Schema column is
|
||||
* `Bytes`; this gives a self-describing binary that round-trips without
|
||||
* base64 inflation. Format: u32 BE cert count, then per-cert u32 BE byte
|
||||
* length + raw DER bytes.
|
||||
*
|
||||
* Encoding inputs come from `cscCredentialsInfo.cert.certificates`, which the
|
||||
* CSC §11.5 spec defines as an array of base64-encoded DER X.509 certificates
|
||||
* (leaf-first). The encoder decodes each base64 entry once at persistence
|
||||
* time; the decoder is the symmetric inverse used at sign time.
|
||||
*
|
||||
* Pure functions, no I/O.
|
||||
*/
|
||||
|
||||
const BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||
|
||||
/**
|
||||
* Encode a leaf-first chain of base64-encoded DER certs into the
|
||||
* length-prefixed binary form persisted on `CscCredential.certCache`.
|
||||
*
|
||||
* Throws `INVALID_REQUEST` when the input is empty or any entry is not valid
|
||||
* base64.
|
||||
*/
|
||||
export const encodeCscCertChain = (certs: string[]): Uint8Array => {
|
||||
if (certs.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain encoding requires at least one certificate.',
|
||||
});
|
||||
}
|
||||
|
||||
const derBuffers: Uint8Array[] = [];
|
||||
let totalDerBytes = 0;
|
||||
|
||||
for (const entry of certs) {
|
||||
if (entry.length === 0 || !BASE64_REGEX.test(entry)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain entry is not valid base64.',
|
||||
});
|
||||
}
|
||||
|
||||
const der = Buffer.from(entry, 'base64');
|
||||
|
||||
if (der.length === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain entry decoded to zero bytes.',
|
||||
});
|
||||
}
|
||||
|
||||
derBuffers.push(der);
|
||||
totalDerBytes += der.length;
|
||||
}
|
||||
|
||||
// 4 bytes for the count + 4 bytes per-cert length prefix + raw DER bytes.
|
||||
const totalLength = 4 + derBuffers.length * 4 + totalDerBytes;
|
||||
const out = new Uint8Array(totalLength);
|
||||
const view = new DataView(out.buffer, out.byteOffset, out.byteLength);
|
||||
|
||||
view.setUint32(0, derBuffers.length, false);
|
||||
|
||||
let offset = 4;
|
||||
|
||||
for (const der of derBuffers) {
|
||||
view.setUint32(offset, der.length, false);
|
||||
offset += 4;
|
||||
out.set(der, offset);
|
||||
offset += der.length;
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
/**
|
||||
* Decode a length-prefixed cert chain back into an array of DER cert byte
|
||||
* arrays. Inverse of {@link encodeCscCertChain}.
|
||||
*
|
||||
* Throws `INVALID_REQUEST` when the buffer is truncated or any per-cert
|
||||
* length prefix runs off the end of the buffer.
|
||||
*/
|
||||
export const decodeCscCertChain = (bytes: Uint8Array): Uint8Array[] => {
|
||||
if (bytes.byteLength < 4) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain buffer is too short to contain a count prefix.',
|
||||
});
|
||||
}
|
||||
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const count = view.getUint32(0, false);
|
||||
|
||||
const result: Uint8Array[] = [];
|
||||
let offset = 4;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (offset + 4 > bytes.byteLength) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain buffer truncated at length prefix.',
|
||||
});
|
||||
}
|
||||
|
||||
const length = view.getUint32(offset, false);
|
||||
offset += 4;
|
||||
|
||||
if (length === 0 || offset + length > bytes.byteLength) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain buffer truncated within certificate body.',
|
||||
});
|
||||
}
|
||||
|
||||
// Slice copies the underlying bytes so callers can't mutate the source.
|
||||
result.push(bytes.slice(offset, offset + length));
|
||||
offset += length;
|
||||
}
|
||||
|
||||
if (offset !== bytes.byteLength) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC certificate chain buffer has trailing bytes after declared chain end.',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import { symmetricDecrypt, symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
import { requireEnv } from '@documenso/lib/utils/env';
|
||||
import { bytesToHex, hexToBytes } from '@noble/ciphers/utils';
|
||||
|
||||
/**
|
||||
* Bytes-based wrappers around {@link symmetricEncrypt} / {@link symmetricDecrypt}
|
||||
* for the two CSC secrets stored on Prisma `Bytes` columns:
|
||||
*
|
||||
* - `CscCredential.serviceTokenCiphertext` — service-scope OAuth access token.
|
||||
* - `CscSession.encryptedSad` — credential-scope SAD.
|
||||
*
|
||||
* Both use the primary `DOCUMENSO_ENCRYPTION_KEY` (same key family as 2FA
|
||||
* secrets, OIDC client secrets, DKIM private keys). The underlying cipher
|
||||
* returns hex; we round-trip through `bytesToHex` / `hexToBytes` so the
|
||||
* persisted bytes are the raw XChaCha20-Poly1305 ciphertext (nonce + tag +
|
||||
* payload), not a hex-string-as-bytes inflation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encrypt a CSC plaintext secret (service token or SAD) for persistence.
|
||||
* Throws `MISSING_ENV_VAR` on missing encryption key — encryption can't
|
||||
* otherwise fail.
|
||||
*/
|
||||
export const encryptCscToken = (plaintext: string): Uint8Array => {
|
||||
const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY');
|
||||
|
||||
const hex = symmetricEncrypt({ key, data: plaintext });
|
||||
|
||||
return hexToBytes(hex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decrypt a CSC ciphertext back to its UTF-8 plaintext. Returns `null` on
|
||||
* any cipher-level failure (key rotation, payload tamper, row corruption)
|
||||
* so the caller can map to a domain-appropriate AppError — typically
|
||||
* re-auth for service tokens, `CSC_SAD_EXPIRED_PRE_SIGN` for SADs.
|
||||
*
|
||||
* A missing key throws (config error, must surface loudly) and is *not*
|
||||
* folded into the null return.
|
||||
*/
|
||||
export const decryptCscToken = (ciphertext: Uint8Array): string | null => {
|
||||
const key = requireEnv('NEXT_PRIVATE_ENCRYPTION_KEY');
|
||||
|
||||
try {
|
||||
const buf = symmetricDecrypt({ key, data: bytesToHex(ciphertext) });
|
||||
|
||||
return Buffer.from(buf).toString('utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { cscJsonPost, joinCscUrl } from './http';
|
||||
import {
|
||||
type TCscCredentialsInfoRequest,
|
||||
type TCscCredentialsInfoResponse,
|
||||
type TCscCredentialsListRequest,
|
||||
type TCscCredentialsListResponse,
|
||||
ZCscCredentialsInfoResponseSchema,
|
||||
ZCscCredentialsListResponseSchema,
|
||||
} from './types';
|
||||
|
||||
type CscCredentialsListOptions = TCscCredentialsListRequest & {
|
||||
baseUrl: string;
|
||||
/** Service-scope bearer token (CSC §11.4 + §11.9). */
|
||||
accessToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `credentials/list` (§11.4) — list the credentialIDs the bearer token's user
|
||||
* owns at the TSP.
|
||||
*
|
||||
* Throws `CSC_CREDENTIAL_LIST_EMPTY` when the TSP returns a successful
|
||||
* response with zero credentials — the recipient needs to enrol with the TSP
|
||||
* before they can sign. Other failures throw `CSC_REQUEST_FAILED`.
|
||||
*
|
||||
* `userID` MUST be omitted when the service authorization is user-specific
|
||||
* (true for OAuth `service` scope, which is V1's only flow). The spec rejects
|
||||
* the call with `invalid_request` if both are present.
|
||||
*/
|
||||
export const cscCredentialsList = async (opts: CscCredentialsListOptions): Promise<TCscCredentialsListResponse> => {
|
||||
const { baseUrl, accessToken, signal, userID, maxResults, pageToken, clientData } = opts;
|
||||
|
||||
const body: Record<string, unknown> = {};
|
||||
|
||||
if (userID !== undefined) {
|
||||
body.userID = userID;
|
||||
}
|
||||
|
||||
if (maxResults !== undefined) {
|
||||
body.maxResults = maxResults;
|
||||
}
|
||||
|
||||
if (pageToken !== undefined) {
|
||||
body.pageToken = pageToken;
|
||||
}
|
||||
|
||||
if (clientData !== undefined) {
|
||||
body.clientData = clientData;
|
||||
}
|
||||
|
||||
const response = await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'credentials/list' }),
|
||||
body,
|
||||
accessToken,
|
||||
signal,
|
||||
},
|
||||
ZCscCredentialsListResponseSchema,
|
||||
);
|
||||
|
||||
if (response.credentialIDs.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY, {
|
||||
message:
|
||||
'CSC provider returned no credentials for the authenticated user. Recipient must enrol with the TSP before signing.',
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
type CscCredentialsInfoOptions = TCscCredentialsInfoRequest & {
|
||||
baseUrl: string;
|
||||
/** Service-scope bearer token. */
|
||||
accessToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `credentials/info` (§11.5) — fetch credential metadata: key algorithm tuple,
|
||||
* X.509 certificate chain, authorization mode, multisign capacity.
|
||||
*
|
||||
* Returns the parsed response verbatim. Cert validity, algorithm policy, and
|
||||
* SCAL semantics are enforced by `csc/algorithm-resolver.ts` — that lives
|
||||
* outside the client because it's domain logic, not transport.
|
||||
*/
|
||||
export const cscCredentialsInfo = async (opts: CscCredentialsInfoOptions): Promise<TCscCredentialsInfoResponse> => {
|
||||
const { baseUrl, accessToken, signal, credentialID, certificates, certInfo, authInfo, lang, clientData } = opts;
|
||||
|
||||
const body: Record<string, unknown> = { credentialID };
|
||||
|
||||
if (certificates !== undefined) {
|
||||
body.certificates = certificates;
|
||||
}
|
||||
|
||||
if (certInfo !== undefined) {
|
||||
body.certInfo = certInfo;
|
||||
}
|
||||
|
||||
if (authInfo !== undefined) {
|
||||
body.authInfo = authInfo;
|
||||
}
|
||||
|
||||
if (lang !== undefined) {
|
||||
body.lang = lang;
|
||||
}
|
||||
|
||||
if (clientData !== undefined) {
|
||||
body.clientData = clientData;
|
||||
}
|
||||
|
||||
return await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'credentials/info' }),
|
||||
body,
|
||||
accessToken,
|
||||
signal,
|
||||
},
|
||||
ZCscCredentialsInfoResponseSchema,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,170 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { ZCscErrorResponseSchema } from './types';
|
||||
|
||||
const LEADING_SLASHES_REGEX = /^\/+/;
|
||||
const TRAILING_SLASHES_REGEX = /\/+$/;
|
||||
|
||||
/**
|
||||
* Low-level fetch wrapper for the JSON-bodied CSC API methods (§7.1 mandates
|
||||
* `Content-Type: application/json` for all API requests).
|
||||
*
|
||||
* OAuth 2.0 endpoints (`oauth2/token`, `oauth2/revoke`) use
|
||||
* `application/x-www-form-urlencoded` per RFC 6749 and are handled by the
|
||||
* `arctic` library — see `oauth.ts` in this directory.
|
||||
*
|
||||
* Normalises CSC error responses (§10.1: `{ error, error_description }`)
|
||||
* into {@link AppError}s carrying the upstream HTTP status in
|
||||
* {@link AppError.statusCode}, so callers can discriminate without
|
||||
* re-parsing the body.
|
||||
*/
|
||||
|
||||
type JoinUrlInput = {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Join a CSC base URL with a path segment. Strips trailing/leading slashes so
|
||||
* `joinCscUrl({ baseUrl: 'https://x/csc/v1/', path: '/credentials/list' })`
|
||||
* yields `https://x/csc/v1/credentials/list`.
|
||||
*/
|
||||
export const joinCscUrl = ({ baseUrl, path }: JoinUrlInput): string => {
|
||||
const cleanBaseUrl = baseUrl.replace(TRAILING_SLASHES_REGEX, ''); // Strip trailing slashes from base URL.
|
||||
const cleanPath = path.replace(LEADING_SLASHES_REGEX, ''); // Strip leading slashes from path.
|
||||
|
||||
const url = new URL(cleanPath, `${cleanBaseUrl}/`);
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
type CscRequestErrorOptions = {
|
||||
url: string;
|
||||
status: number;
|
||||
cscError?: { error: string; error_description?: string };
|
||||
cause?: unknown;
|
||||
errorCode?: string;
|
||||
};
|
||||
|
||||
const buildCscRequestError = ({
|
||||
url,
|
||||
status,
|
||||
cscError,
|
||||
cause,
|
||||
errorCode = AppErrorCode.CSC_REQUEST_FAILED,
|
||||
}: CscRequestErrorOptions): AppError => {
|
||||
const causeMessage = cause instanceof Error ? cause.message : undefined;
|
||||
|
||||
const parts: string[] = [`CSC request to ${url} failed (HTTP ${status})`];
|
||||
|
||||
if (cscError) {
|
||||
parts.push(cscError.error_description ? `${cscError.error}: ${cscError.error_description}` : cscError.error);
|
||||
}
|
||||
|
||||
if (causeMessage) {
|
||||
parts.push(causeMessage);
|
||||
}
|
||||
|
||||
return new AppError(errorCode, {
|
||||
message: parts.join(' — '),
|
||||
statusCode: status,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Best-effort parse of a CSC error body. Returns `undefined` on non-JSON or
|
||||
* schema mismatch so the caller still surfaces the HTTP status without
|
||||
* masking it.
|
||||
*/
|
||||
const readCscErrorBody = async (
|
||||
response: Response,
|
||||
): Promise<{ error: string; error_description?: string } | undefined> => {
|
||||
try {
|
||||
const json = await response.json();
|
||||
const parsed = ZCscErrorResponseSchema.safeParse(json);
|
||||
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
type CscJsonPostOptions = {
|
||||
/** Fully-qualified endpoint URL (use {@link joinCscUrl} to build it). */
|
||||
url: string;
|
||||
/** Decoded JSON body; serialised via `JSON.stringify`. */
|
||||
body: Record<string, unknown>;
|
||||
/** Bearer access token. Omit for unauthenticated calls (e.g. `info`). */
|
||||
accessToken?: string;
|
||||
/** Override the AppError code thrown on failure. Defaults to `CSC_REQUEST_FAILED`. */
|
||||
errorCode?: string;
|
||||
/**
|
||||
* Optional `AbortSignal` so callers can enforce their own deadlines
|
||||
* (e.g. the 15s sign-time sync timeout).
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST a JSON body to a CSC API endpoint and parse the response against the
|
||||
* supplied Zod schema.
|
||||
*
|
||||
* Throws {@link AppError} on:
|
||||
* - network/transport error (fetch threw)
|
||||
* - non-2xx HTTP response (with CSC error body folded into the message)
|
||||
* - malformed JSON response
|
||||
* - schema validation failure
|
||||
*/
|
||||
export const cscJsonPost = async <T>(opts: CscJsonPostOptions, responseSchema: z.ZodSchema<T>): Promise<T> => {
|
||||
const { url, body, accessToken, errorCode, signal } = opts;
|
||||
|
||||
let response: Response;
|
||||
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw buildCscRequestError({ url, status: 0, cause, errorCode });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const cscError = await readCscErrorBody(response);
|
||||
|
||||
throw buildCscRequestError({
|
||||
url,
|
||||
status: response.status,
|
||||
cscError,
|
||||
errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
let json: unknown;
|
||||
|
||||
try {
|
||||
json = await response.json();
|
||||
} catch (cause) {
|
||||
throw buildCscRequestError({ url, status: response.status, cause, errorCode });
|
||||
}
|
||||
|
||||
const parsed = responseSchema.safeParse(json);
|
||||
|
||||
if (!parsed.success) {
|
||||
throw buildCscRequestError({
|
||||
url,
|
||||
status: response.status,
|
||||
cause: parsed.error,
|
||||
errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* CSC v1.0.4.0 HTTP client. Stateless function wrappers — one per endpoint,
|
||||
* grouped by spec section. Bring your own base URL(s) and bearer token.
|
||||
*
|
||||
* Endpoint coverage (V1 scope):
|
||||
* - §11.1 info → {@link cscInfo}
|
||||
* - §11.4 credentials/list → {@link cscCredentialsList}
|
||||
* - §11.5 credentials/info → {@link cscCredentialsInfo}
|
||||
* - §11.9 signatures/signHash → {@link cscSignHash}
|
||||
* - §11.10 signatures/timestamp → {@link cscTimestamp}
|
||||
* - §8.3.2 oauth2/authorize → {@link buildCscServiceScopeAuthorizeUrl},
|
||||
* {@link buildCscCredentialScopeAuthorizeUrl}
|
||||
* - §8.3.3 oauth2/token → {@link exchangeCscAuthorizationCode},
|
||||
* {@link refreshCscServiceToken}
|
||||
* - §8.3.4 oauth2/revoke → {@link revokeCscToken}
|
||||
*
|
||||
* Out of scope for V1 (intentionally excluded; we use OAuth + single-sig):
|
||||
* - §11.2 auth/login (HTTP Basic)
|
||||
* - §11.3 auth/revoke (HTTP Basic)
|
||||
* - §11.6 credentials/authorize (alternative to OAuth credential scope)
|
||||
* - §11.7 credentials/extendTransaction
|
||||
* - §11.8 credentials/sendOTP
|
||||
*
|
||||
* OAuth is delegated to `arctic` (same library `packages/auth/` uses).
|
||||
*/
|
||||
|
||||
export * from './credentials';
|
||||
export * from './http';
|
||||
export * from './info';
|
||||
export * from './oauth';
|
||||
export * from './signatures';
|
||||
export * from './types';
|
||||
@@ -0,0 +1,42 @@
|
||||
import { AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
|
||||
import { cscJsonPost, joinCscUrl } from './http';
|
||||
import { type TCscInfoRequest, type TCscInfoResponse, ZCscInfoResponseSchema } from './types';
|
||||
|
||||
type CscInfoOptions = TCscInfoRequest & {
|
||||
/**
|
||||
* Base URI of the CSC service (e.g. `https://service.example.org/csc/v1`).
|
||||
* Per §7.2, `info` is mounted relative to the service base URI; the OAuth
|
||||
* base URI returned in `oauth2` is discovered from this call.
|
||||
*/
|
||||
baseUrl: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `info` (§11.1) — discovery method every CSC-conformant TSP MUST implement.
|
||||
*
|
||||
* Used at startup to:
|
||||
*
|
||||
* 1. Learn the OAuth 2.0 base URI (`oauth2`) for subsequent token / revoke
|
||||
* calls. Per §11.1, this MAY differ from the API base URI.
|
||||
* 2. Enumerate supported methods (`methods`) so the caller can fail fast
|
||||
* when a required endpoint is absent.
|
||||
* 3. Surface `signatures/timestamp` capability for the B-LTA seal step.
|
||||
*
|
||||
* Unauthenticated — `info` requires no bearer token. Failures throw
|
||||
* `CSC_PROVIDER_INFO_FAILED` per the spec's startup-discovery error code.
|
||||
*/
|
||||
export const cscInfo = async (opts: CscInfoOptions): Promise<TCscInfoResponse> => {
|
||||
const { baseUrl, lang, signal } = opts;
|
||||
|
||||
return await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'info' }),
|
||||
body: lang ? { lang } : {},
|
||||
errorCode: AppErrorCode.CSC_PROVIDER_INFO_FAILED,
|
||||
signal,
|
||||
},
|
||||
ZCscInfoResponseSchema,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import {
|
||||
ArcticFetchError,
|
||||
CodeChallengeMethod,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
OAuth2Client,
|
||||
OAuth2RequestError,
|
||||
type OAuth2Tokens,
|
||||
UnexpectedErrorResponseBodyError,
|
||||
UnexpectedResponseError,
|
||||
} from 'arctic';
|
||||
|
||||
import { joinCscUrl } from './http';
|
||||
|
||||
/**
|
||||
* OAuth 2.0 surface for the CSC v1.0.4.0 protocol (§8.3.2 authorize,
|
||||
* §8.3.3 token, §8.3.4 revoke).
|
||||
*
|
||||
* Backed by `arctic` — the same library `packages/auth/` uses for sign-in
|
||||
* OAuth — so PKCE + state generation, token parsing, and revocation share a
|
||||
* proven implementation. CSC-specific extension parameters (`credentialID`,
|
||||
* `numSignatures`, `hash`, `description`, `account_token`, `clientData`,
|
||||
* `lang` — §8.3.2) layer on top of the returned `URL` via
|
||||
* `searchParams.set()`.
|
||||
*
|
||||
* Non-standard CSC bits arctic doesn't model directly:
|
||||
* - `token_type === 'SAD'` for credential-scope responses (§8.3.3). Read from
|
||||
* `tokens.tokenType()` which sources from raw `data`.
|
||||
* - SAD is single-use and short-lived per spec; no refresh_token is issued
|
||||
* for the credential scope. Callers SHOULD NOT call `refreshAccessToken`
|
||||
* with a SAD.
|
||||
*
|
||||
* Re-exports `generateState` and `generateCodeVerifier` for callers that
|
||||
* persist these in the OAuth-flow cookie.
|
||||
*/
|
||||
|
||||
export { generateCodeVerifier, generateState };
|
||||
|
||||
// ─── Client construction ─────────────────────────────────────────────────────
|
||||
|
||||
type CreateCscOAuthClientOptions = {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
redirectUri: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Construct an `OAuth2Client` bound to the CSC TSP's OAuth registration. The
|
||||
* three values come from the env (`NEXT_PRIVATE_SIGNING_CSC_OAUTH_*`).
|
||||
* Stateless — instantiate per request or cache at the transport singleton
|
||||
* level; arctic's client carries no per-call state.
|
||||
*/
|
||||
export const createCscOAuthClient = ({
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
}: CreateCscOAuthClientOptions): OAuth2Client => {
|
||||
return new OAuth2Client(clientId, clientSecret, redirectUri);
|
||||
};
|
||||
|
||||
// ─── Authorize URL builders (§8.3.2) ─────────────────────────────────────────
|
||||
|
||||
type AuthorizeUrlBaseOptions = {
|
||||
client: OAuth2Client;
|
||||
/**
|
||||
* The TSP's OAuth base URI as returned by `info.oauth2` (§11.1). The
|
||||
* `oauth2/authorize` path is joined on; per §8.3.2 NOTE 1 this can live on
|
||||
* a different host from the API base URI.
|
||||
*/
|
||||
oauthBaseUrl: string;
|
||||
/** Opaque CSRF token; see {@link generateState}. Caller persists it. */
|
||||
state: string;
|
||||
/** PKCE verifier; see {@link generateCodeVerifier}. Caller persists it. */
|
||||
codeVerifier: string;
|
||||
/** Preferred response language (§11.1 `lang` parameter). */
|
||||
lang?: string;
|
||||
/**
|
||||
* Arbitrary application-defined string echoed back at callback. WARNING per
|
||||
* §8.3.2: this is forwarded verbatim to the TSP; never put secrets here.
|
||||
*/
|
||||
clientData?: string;
|
||||
};
|
||||
|
||||
const applyCscAuthorizeExtras = (url: URL, opts: { lang?: string; clientData?: string }): URL => {
|
||||
if (opts.lang) {
|
||||
url.searchParams.set('lang', opts.lang);
|
||||
}
|
||||
|
||||
if (opts.clientData) {
|
||||
url.searchParams.set('clientData', opts.clientData);
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the `oauth2/authorize` URL for the **service** scope. Recipient
|
||||
* follows this URL to authenticate at the TSP and grant access to list
|
||||
* credentials + fetch credential info.
|
||||
*/
|
||||
export const buildCscServiceScopeAuthorizeUrl = (opts: AuthorizeUrlBaseOptions): URL => {
|
||||
const { client, oauthBaseUrl, state, codeVerifier, lang, clientData } = opts;
|
||||
|
||||
const url = client.createAuthorizationURLWithPKCE(
|
||||
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }),
|
||||
state,
|
||||
CodeChallengeMethod.S256,
|
||||
codeVerifier,
|
||||
['service'],
|
||||
);
|
||||
|
||||
return applyCscAuthorizeExtras(url, { lang, clientData });
|
||||
};
|
||||
|
||||
type CredentialScopeAuthorizeOptions = AuthorizeUrlBaseOptions & {
|
||||
/** Target credential (§8.3.2 — REQUIRED for credential scope). */
|
||||
credentialId: string;
|
||||
/** Number of signatures this SAD will authorise (§8.3.2 — REQUIRED). */
|
||||
numSignatures: number;
|
||||
/**
|
||||
* Standard-base64-encoded hash values the SAD will be bound to. REQUIRED for
|
||||
* SCAL2 credentials (§8.3.2). The builder converts each value to base64url
|
||||
* before joining with `,` per the spec — §8.3.2 mandates base64url for the
|
||||
* `hash` URL parameter, but the rest of the codebase (and the
|
||||
* `signatures/signHash` JSON body per §11.9) uses standard base64. Callers
|
||||
* pass what `Buffer.from(...).toString('base64')` produces.
|
||||
*/
|
||||
hashes: string[];
|
||||
/** Human-readable transaction description shown on the TSP's SCA page. */
|
||||
description?: string;
|
||||
/** Optional restricted-access token (JWT) some TSPs require (§8.3.2). */
|
||||
accountToken?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a standard-base64 string to base64url (RFC 4648 §5). The CSC §8.3.2
|
||||
* `hash` URL parameter requires base64url; TSPs reject standard base64 even
|
||||
* after percent-decoding because `+`, `/`, and `=` are invalid base64url
|
||||
* characters. JSON-body fields (§11.9 `signatures/signHash`) keep standard
|
||||
* base64.
|
||||
*/
|
||||
const toBase64Url = (standardBase64: string): string =>
|
||||
standardBase64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
|
||||
/**
|
||||
* Build the `oauth2/authorize` URL for the **credential** scope. The TSP
|
||||
* binds the issued SAD to `hashes` so it can only sign those exact digests.
|
||||
*
|
||||
* Hash ordering in the SAD is independent of the order passed to
|
||||
* `signatures/signHash` (§8.3.2) — the TSP matches by hash value, not
|
||||
* position.
|
||||
*/
|
||||
export const buildCscCredentialScopeAuthorizeUrl = (opts: CredentialScopeAuthorizeOptions): URL => {
|
||||
const {
|
||||
client,
|
||||
oauthBaseUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
credentialId,
|
||||
numSignatures,
|
||||
hashes,
|
||||
description,
|
||||
accountToken,
|
||||
lang,
|
||||
clientData,
|
||||
} = opts;
|
||||
|
||||
const url = client.createAuthorizationURLWithPKCE(
|
||||
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/authorize' }),
|
||||
state,
|
||||
CodeChallengeMethod.S256,
|
||||
codeVerifier,
|
||||
['credential'],
|
||||
);
|
||||
|
||||
url.searchParams.set('credentialID', credentialId);
|
||||
url.searchParams.set('numSignatures', String(numSignatures));
|
||||
url.searchParams.set('hash', hashes.map(toBase64Url).join(','));
|
||||
|
||||
if (description) {
|
||||
url.searchParams.set('description', description);
|
||||
}
|
||||
|
||||
if (accountToken) {
|
||||
url.searchParams.set('account_token', accountToken);
|
||||
}
|
||||
|
||||
return applyCscAuthorizeExtras(url, { lang, clientData });
|
||||
};
|
||||
|
||||
// ─── Token exchange (§8.3.3) ─────────────────────────────────────────────────
|
||||
|
||||
type ExchangeCodeOptions = {
|
||||
client: OAuth2Client;
|
||||
/** OAuth base URI from `info.oauth2`. `oauth2/token` is joined on. */
|
||||
oauthBaseUrl: string;
|
||||
/** Authorization code from the callback's `code` query param. */
|
||||
code: string;
|
||||
/** Same PKCE verifier passed to the authorize URL builder. */
|
||||
codeVerifier: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange an authorization code for an access token. Used for both scopes;
|
||||
* the response shape differs only in `token_type`:
|
||||
*
|
||||
* - service scope: `token_type === 'Bearer'`, optional `refresh_token`.
|
||||
* - credential scope: `token_type === 'SAD'`, single-use, no refresh_token.
|
||||
*
|
||||
* Inspect `tokens.tokenType()` (or `tokens.data` for raw access) to
|
||||
* discriminate.
|
||||
*/
|
||||
export const exchangeCscAuthorizationCode = async (opts: ExchangeCodeOptions): Promise<OAuth2Tokens> => {
|
||||
const { client, oauthBaseUrl, code, codeVerifier } = opts;
|
||||
|
||||
try {
|
||||
return await client.validateAuthorizationCode(
|
||||
joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }),
|
||||
code,
|
||||
codeVerifier,
|
||||
);
|
||||
} catch (err) {
|
||||
throw mapArcticError(err, 'oauth2/token');
|
||||
}
|
||||
};
|
||||
|
||||
type RefreshServiceTokenOptions = {
|
||||
client: OAuth2Client;
|
||||
oauthBaseUrl: string;
|
||||
/** Service-scope refresh token from a prior token exchange. */
|
||||
refreshToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Refresh a service-scope access token. Credential-scope SADs are NOT
|
||||
* refreshable per §8.3.3 — only service scope issues refresh tokens.
|
||||
*
|
||||
* Scopes passed as `['service']` to keep the refresh narrow; the TSP may
|
||||
* ignore the scope parameter on refresh per RFC 6749 §6.
|
||||
*/
|
||||
export const refreshCscServiceToken = async (opts: RefreshServiceTokenOptions): Promise<OAuth2Tokens> => {
|
||||
const { client, oauthBaseUrl, refreshToken } = opts;
|
||||
|
||||
try {
|
||||
return await client.refreshAccessToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/token' }), refreshToken, [
|
||||
'service',
|
||||
]);
|
||||
} catch (err) {
|
||||
throw mapArcticError(err, 'oauth2/token');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Revoke (§8.3.4) ─────────────────────────────────────────────────────────
|
||||
|
||||
type RevokeTokenOptions = {
|
||||
client: OAuth2Client;
|
||||
oauthBaseUrl: string;
|
||||
/** Access token or refresh token to revoke. */
|
||||
token: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* Revoke a CSC OAuth token. Per §8.3.4, revoking a refresh token also
|
||||
* invalidates every access token derived from the same grant; revoking an
|
||||
* access token only invalidates that access token.
|
||||
*
|
||||
* `204 No Content` on success; arctic resolves the promise. Failures
|
||||
* surface as `CSC_REQUEST_FAILED` via {@link mapArcticError}.
|
||||
*/
|
||||
export const revokeCscToken = async (opts: RevokeTokenOptions): Promise<void> => {
|
||||
const { client, oauthBaseUrl, token } = opts;
|
||||
|
||||
try {
|
||||
await client.revokeToken(joinCscUrl({ baseUrl: oauthBaseUrl, path: 'oauth2/revoke' }), token);
|
||||
} catch (err) {
|
||||
throw mapArcticError(err, 'oauth2/revoke');
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Error normalisation ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Translate arctic's typed exception hierarchy into AppErrors consistent with
|
||||
* the rest of the CSC client (see http.ts). Preserves the HTTP status when
|
||||
* arctic surfaces it.
|
||||
*/
|
||||
const mapArcticError = (err: unknown, endpoint: string): AppError => {
|
||||
if (err instanceof OAuth2RequestError) {
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} rejected: ${err.code}${err.description ? ` — ${err.description}` : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof ArcticFetchError) {
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} fetch failed: ${err.message}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof UnexpectedResponseError) {
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} returned unexpected HTTP ${err.status}`,
|
||||
statusCode: err.status,
|
||||
});
|
||||
}
|
||||
|
||||
if (err instanceof UnexpectedErrorResponseBodyError) {
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} returned HTTP ${err.status} with unparseable body`,
|
||||
statusCode: err.status,
|
||||
});
|
||||
}
|
||||
|
||||
return new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC ${endpoint} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { cscJsonPost, joinCscUrl } from './http';
|
||||
import {
|
||||
type TCscSignHashRequest,
|
||||
type TCscSignHashResponse,
|
||||
type TCscTimestampRequest,
|
||||
type TCscTimestampResponse,
|
||||
ZCscSignHashResponseSchema,
|
||||
ZCscTimestampResponseSchema,
|
||||
} from './types';
|
||||
|
||||
type CscSignHashOptions = TCscSignHashRequest & {
|
||||
baseUrl: string;
|
||||
/** Service-scope bearer token. The SAD (in the body) is the credential-scope grant. */
|
||||
accessToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `signatures/signHash` (§11.9) — submit one or more pre-computed hashes for
|
||||
* the TSP to sign with the credential identified by `credentialID`.
|
||||
*
|
||||
* Authorisation is two-layered:
|
||||
* - The service-scope bearer token authenticates the API call itself.
|
||||
* - The credential-scope SAD (in the JSON body) authorises the specific
|
||||
* hashes — the TSP rejects with `invalid_request` ("Hash is not authorized
|
||||
* by the SAD") if any hash in the array wasn't bound at SAD issuance.
|
||||
*
|
||||
* The returned `signatures` array is position-ordered with `hash` per §11.9.
|
||||
* Callers SHALL preserve order when mapping responses back to PDF embed
|
||||
* slots (the fifoSigner relies on this).
|
||||
*/
|
||||
export const cscSignHash = async (opts: CscSignHashOptions): Promise<TCscSignHashResponse> => {
|
||||
const { baseUrl, accessToken, signal, credentialID, SAD, hash, hashAlgo, signAlgo, signAlgoParams, clientData } =
|
||||
opts;
|
||||
|
||||
const body: Record<string, unknown> = {
|
||||
credentialID,
|
||||
SAD,
|
||||
hash,
|
||||
signAlgo,
|
||||
};
|
||||
|
||||
if (hashAlgo !== undefined) {
|
||||
body.hashAlgo = hashAlgo;
|
||||
}
|
||||
|
||||
if (signAlgoParams !== undefined) {
|
||||
body.signAlgoParams = signAlgoParams;
|
||||
}
|
||||
|
||||
if (clientData !== undefined) {
|
||||
body.clientData = clientData;
|
||||
}
|
||||
|
||||
return await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'signatures/signHash' }),
|
||||
body,
|
||||
accessToken,
|
||||
signal,
|
||||
},
|
||||
ZCscSignHashResponseSchema,
|
||||
);
|
||||
};
|
||||
|
||||
type CscTimestampOptions = TCscTimestampRequest & {
|
||||
baseUrl: string;
|
||||
/**
|
||||
* Service-scope bearer token. Per §11.10 the timestamp endpoint may or may
|
||||
* not require auth depending on TSP policy; the spec is silent. We send the
|
||||
* token unconditionally because all known TSPs gate this endpoint.
|
||||
*/
|
||||
accessToken: string;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
/**
|
||||
* `signatures/timestamp` (§11.10) — request an RFC 3161 / RFC 5816 time-stamp
|
||||
* token for a pre-computed hash. Driven by {@link CscTspTimestampAuthority}
|
||||
* at sign time, when {@link resolveCscSignTimeTsa} selects the TSP source
|
||||
* (TSP advertises `signatures/timestamp` in `info.methods`). The bearer is
|
||||
* the current recipient's own service-scope token. Seal-time archival
|
||||
* timestamps do not go through this endpoint — they use the env-configured
|
||||
* RFC 3161 TSA directly.
|
||||
*
|
||||
* If `nonce` is supplied, the TSP MUST round-trip it in the token — we leave
|
||||
* verification to LibPDF / our TSA helper, not this client.
|
||||
*/
|
||||
export const cscTimestamp = async (opts: CscTimestampOptions): Promise<TCscTimestampResponse> => {
|
||||
const { baseUrl, accessToken, signal, hash, hashAlgo, nonce, clientData } = opts;
|
||||
|
||||
const body: Record<string, unknown> = { hash, hashAlgo };
|
||||
|
||||
if (nonce !== undefined) {
|
||||
body.nonce = nonce;
|
||||
}
|
||||
|
||||
if (clientData !== undefined) {
|
||||
body.clientData = clientData;
|
||||
}
|
||||
|
||||
return await cscJsonPost(
|
||||
{
|
||||
url: joinCscUrl({ baseUrl, path: 'signatures/timestamp' }),
|
||||
body,
|
||||
accessToken,
|
||||
signal,
|
||||
},
|
||||
ZCscTimestampResponseSchema,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,179 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Zod schemas + types for every CSC v1.0.4.0 request/response shape the V1
|
||||
* client touches. Field names mirror the spec exactly. Unknown fields are
|
||||
* silently dropped (Zod default `.strip()`); we don't `.passthrough()` to
|
||||
* keep parsed objects narrow.
|
||||
*
|
||||
* Out-of-scope endpoints (`auth/login`, `auth/revoke`, `credentials/authorize`,
|
||||
* `credentials/extendTransaction`, `credentials/sendOTP`) intentionally have
|
||||
* no schemas here — V1 uses OAuth + sequential single-signature flows only.
|
||||
*/
|
||||
|
||||
// ─── §10.1 common error envelope ─────────────────────────────────────────────
|
||||
|
||||
export const ZCscErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
error_description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscErrorResponse = z.infer<typeof ZCscErrorResponseSchema>;
|
||||
|
||||
// ─── §11.1 info ──────────────────────────────────────────────────────────────
|
||||
|
||||
export const ZCscInfoRequestSchema = z.object({
|
||||
lang: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscInfoRequest = z.infer<typeof ZCscInfoRequestSchema>;
|
||||
|
||||
export const ZCscInfoResponseSchema = z.object({
|
||||
specs: z.string(),
|
||||
name: z.string(),
|
||||
logo: z.string(),
|
||||
region: z.string(),
|
||||
lang: z.string(),
|
||||
description: z.string(),
|
||||
authType: z.array(z.string()),
|
||||
// REQUIRED Conditional — present when authType includes `oauth2code` /
|
||||
// `oauth2client`, or when any credential supports `oauth2code` authMode.
|
||||
// We always need it for V1, but keeping the schema permissive matches the
|
||||
// spec; absence is detected at the call site.
|
||||
oauth2: z.string().optional(),
|
||||
methods: z.array(z.string()),
|
||||
});
|
||||
|
||||
export type TCscInfoResponse = z.infer<typeof ZCscInfoResponseSchema>;
|
||||
|
||||
// ─── §11.4 credentials/list ──────────────────────────────────────────────────
|
||||
|
||||
export const ZCscCredentialsListRequestSchema = z.object({
|
||||
// OAuth2 user-specific service auth → userID MUST be omitted (§11.4 NOTE 1).
|
||||
userID: z.string().optional(),
|
||||
maxResults: z.number().int().positive().optional(),
|
||||
pageToken: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscCredentialsListRequest = z.infer<typeof ZCscCredentialsListRequestSchema>;
|
||||
|
||||
export const ZCscCredentialsListResponseSchema = z.object({
|
||||
credentialIDs: z.array(z.string()),
|
||||
nextPageToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscCredentialsListResponse = z.infer<typeof ZCscCredentialsListResponseSchema>;
|
||||
|
||||
// ─── §11.5 credentials/info ──────────────────────────────────────────────────
|
||||
|
||||
export const ZCscCredentialsInfoRequestSchema = z.object({
|
||||
credentialID: z.string(),
|
||||
certificates: z.enum(['none', 'single', 'chain']).optional(),
|
||||
certInfo: z.boolean().optional(),
|
||||
authInfo: z.boolean().optional(),
|
||||
lang: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscCredentialsInfoRequest = z.infer<typeof ZCscCredentialsInfoRequestSchema>;
|
||||
|
||||
export const ZCscCredentialsInfoKeySchema = z.object({
|
||||
status: z.enum(['enabled', 'disabled']),
|
||||
algo: z.array(z.string()),
|
||||
// REQUIRED per §11.5 but kept optional here so the algorithm-resolver can
|
||||
// surface absence as a typed `CSC_ALGORITHM_REFUSED` (matching the spec's
|
||||
// policy table) instead of a generic transport schema failure.
|
||||
len: z.number().int().positive().optional(),
|
||||
// REQUIRED Conditional for ECDSA per §11.5; absence handled by the resolver.
|
||||
curve: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCscCredentialsInfoCertSchema = z.object({
|
||||
status: z.enum(['valid', 'expired', 'revoked', 'suspended']).optional(),
|
||||
certificates: z.array(z.string()).optional(),
|
||||
issuerDN: z.string().optional(),
|
||||
serialNumber: z.string().optional(),
|
||||
subjectDN: z.string().optional(),
|
||||
validFrom: z.string().optional(),
|
||||
validTo: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCscCredentialsInfoPinSchema = z.object({
|
||||
presence: z.enum(['true', 'false', 'optional']),
|
||||
format: z.enum(['A', 'N']).optional(),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCscCredentialsInfoOtpSchema = z.object({
|
||||
presence: z.enum(['true', 'false', 'optional']),
|
||||
type: z.enum(['offline', 'online']).optional(),
|
||||
format: z.enum(['A', 'N']).optional(),
|
||||
label: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
ID: z.string().optional(),
|
||||
provider: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZCscCredentialsInfoResponseSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
key: ZCscCredentialsInfoKeySchema,
|
||||
cert: ZCscCredentialsInfoCertSchema,
|
||||
authMode: z.enum(['implicit', 'explicit', 'oauth2code']),
|
||||
SCAL: z.enum(['1', '2']).optional(),
|
||||
PIN: ZCscCredentialsInfoPinSchema.optional(),
|
||||
OTP: ZCscCredentialsInfoOtpSchema.optional(),
|
||||
multisign: z.number().int().min(1),
|
||||
lang: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscCredentialsInfoResponse = z.infer<typeof ZCscCredentialsInfoResponseSchema>;
|
||||
|
||||
// ─── §11.9 signatures/signHash ───────────────────────────────────────────────
|
||||
|
||||
export const ZCscSignHashRequestSchema = z.object({
|
||||
credentialID: z.string(),
|
||||
SAD: z.string(),
|
||||
// Base64-encoded raw message digests.
|
||||
hash: z.array(z.string()).nonempty(),
|
||||
// REQUIRED Conditional — OID of the hash algorithm. Omit only when implied
|
||||
// by signAlgo (per §11.9). The caller decides.
|
||||
hashAlgo: z.string().optional(),
|
||||
signAlgo: z.string(),
|
||||
// REQUIRED Conditional for algorithms like RSASSA-PSS.
|
||||
signAlgoParams: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscSignHashRequest = z.infer<typeof ZCscSignHashRequestSchema>;
|
||||
|
||||
export const ZCscSignHashResponseSchema = z.object({
|
||||
// Position-ordered Base64-encoded signed hashes matching the input order.
|
||||
signatures: z.array(z.string()).nonempty(),
|
||||
});
|
||||
|
||||
export type TCscSignHashResponse = z.infer<typeof ZCscSignHashResponseSchema>;
|
||||
|
||||
// ─── §11.10 signatures/timestamp ─────────────────────────────────────────────
|
||||
|
||||
export const ZCscTimestampRequestSchema = z.object({
|
||||
hash: z.string(),
|
||||
hashAlgo: z.string(),
|
||||
// Hex-encoded random; SHALL round-trip in the timestamp token when supplied.
|
||||
nonce: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
});
|
||||
|
||||
export type TCscTimestampRequest = z.infer<typeof ZCscTimestampRequestSchema>;
|
||||
|
||||
export const ZCscTimestampResponseSchema = z.object({
|
||||
// Base64-encoded RFC 3161 (with RFC 5816 update) time-stamp token.
|
||||
timestamp: z.string(),
|
||||
});
|
||||
|
||||
export type TCscTimestampResponse = z.infer<typeof ZCscTimestampResponseSchema>;
|
||||
|
||||
// OAuth 2.0 token + revoke shapes are handled by the `arctic` library — see
|
||||
// `oauth.ts` in this directory. Arctic exposes `OAuth2Tokens` (with `.data`
|
||||
// available for non-standard CSC fields like `token_type === 'SAD'`).
|
||||
@@ -0,0 +1,120 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
|
||||
import { parseSigned, serialize } from 'hono/utils/cookie';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
|
||||
|
||||
/**
|
||||
* `csc_blocking_error` — one-shot surface for service-scope OAuth callback
|
||||
* failures the recipient can't self-resolve (empty credential list, invalid
|
||||
* cert, refused algorithm, etc.). The `/sign/{token}` loader reads + clears
|
||||
* it on next visit so no error state rides on URL query params.
|
||||
*/
|
||||
|
||||
const CSC_BLOCKING_ERROR_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches the other short-lived CSC cookies.
|
||||
|
||||
export const ZCscBlockingErrorPayloadSchema = z.object({
|
||||
/** `AppErrorCode` value, e.g. `'CSC_CREDENTIAL_LIST_EMPTY'`. */
|
||||
code: z.string().min(1),
|
||||
/** Recipient token from `/sign/{token}`; loader scopes the error to its recipient. */
|
||||
recipientToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export type TCscBlockingErrorPayload = z.infer<typeof ZCscBlockingErrorPayloadSchema>;
|
||||
|
||||
type SetCscBlockingErrorCookieOptions = {
|
||||
c: Context;
|
||||
payload: TCscBlockingErrorPayload;
|
||||
};
|
||||
|
||||
export const setCscBlockingErrorCookie = async (options: SetCscBlockingErrorCookieOptions): Promise<void> => {
|
||||
const { c, payload } = options;
|
||||
|
||||
await setSignedCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), {
|
||||
...cscCookieBaseOptions,
|
||||
maxAge: CSC_BLOCKING_ERROR_MAX_AGE_SECONDS,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Read + validate the blocking-error cookie. Returns `null` when absent or
|
||||
* signature-invalid; throws `INVALID_REQUEST` when signed-but-malformed
|
||||
* (tamper-shaped, mirroring `oauth-flow-cookie.ts`).
|
||||
*/
|
||||
export const getCscBlockingErrorCookie = async (c: Context): Promise<TCscBlockingErrorPayload | null> => {
|
||||
const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedJson: unknown;
|
||||
|
||||
try {
|
||||
parsedJson = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC blocking error cookie payload is not valid JSON.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = ZCscBlockingErrorPayloadSchema.safeParse(parsedJson);
|
||||
|
||||
if (!result.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC blocking error cookie payload failed schema validation.',
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const clearCscBlockingErrorCookie = (c: Context): void => {
|
||||
deleteCookie(c, CSC_BLOCKING_ERROR_COOKIE_NAME, cscCookieBaseOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remix-compatible reader: parses + HMAC-verifies the blocking-error cookie
|
||||
* from a raw `Cookie` header on a standard `Request`. Returns `null` when
|
||||
* absent, signature-invalid, or payload-malformed (no throw — the loader
|
||||
* only uses the cookie advisorily, so a bad cookie shouldn't break the page).
|
||||
*/
|
||||
export const readCscBlockingErrorFromRequest = async (request: Request): Promise<TCscBlockingErrorPayload | null> => {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_BLOCKING_ERROR_COOKIE_NAME);
|
||||
|
||||
const value = parsed[CSC_BLOCKING_ERROR_COOKIE_NAME];
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = JSON.parse(value);
|
||||
|
||||
const result = ZCscBlockingErrorPayloadSchema.safeParse(json);
|
||||
|
||||
return result.success ? result.data : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialised `Set-Cookie` header value that expires the cookie immediately.
|
||||
* Use in a Remix loader's response headers to clear the cookie after the
|
||||
* loader reads it once.
|
||||
*/
|
||||
export const buildClearCscBlockingErrorCookieHeader = (): string => {
|
||||
return serialize(CSC_BLOCKING_ERROR_COOKIE_NAME, '', {
|
||||
...cscCookieBaseOptions,
|
||||
maxAge: 0,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
|
||||
|
||||
/**
|
||||
* `csc_oauth_flow` — single-round-trip carrier across `/api/csc/oauth/authorize`
|
||||
* → TSP → `/api/csc/oauth/callback`. Holds the PKCE verifier + state plus the
|
||||
* Documenso-side context (`recipientToken`, optional `sessionId`) the
|
||||
* callback needs to resume the right signing flow.
|
||||
*
|
||||
* JSON-encoded inside a single signed cookie; structurally validated on read
|
||||
* so a tampered or stale shape can't smuggle bad state into the callback.
|
||||
*/
|
||||
|
||||
const CSC_OAUTH_FLOW_MAX_AGE_SECONDS = 60 * 10; // 10 minutes — matches /api/auth/oauth/* convention.
|
||||
|
||||
export const ZCscOAuthFlowPayloadSchema = z.object({
|
||||
/** `'service'` for the first round-trip, `'credential'` for the SAD round-trip. */
|
||||
scope: z.enum(['service', 'credential']),
|
||||
/** Arctic-generated CSRF token; re-validated against `?state` at callback. */
|
||||
state: z.string().min(1),
|
||||
/** Arctic-generated PKCE verifier (RFC 7636); paired with the URL's `code_challenge`. */
|
||||
codeVerifier: z.string().min(1),
|
||||
/** Recipient signing token from `/sign/{token}`; threads recipient identity through the round-trip. */
|
||||
recipientToken: z.string().min(1),
|
||||
/** CSC session id — present only on `credential`-scope flows (set at prep). */
|
||||
sessionId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export type TCscOAuthFlowPayload = z.infer<typeof ZCscOAuthFlowPayloadSchema>;
|
||||
|
||||
type SetCscOAuthFlowCookieOptions = {
|
||||
c: Context;
|
||||
payload: TCscOAuthFlowPayload;
|
||||
};
|
||||
|
||||
export const setCscOAuthFlowCookie = async (options: SetCscOAuthFlowCookieOptions): Promise<void> => {
|
||||
const { c, payload } = options;
|
||||
|
||||
await setSignedCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, JSON.stringify(payload), getCscCookieSecret(), {
|
||||
...cscCookieBaseOptions,
|
||||
maxAge: CSC_OAUTH_FLOW_MAX_AGE_SECONDS,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Read + validate the OAuth-flow cookie. Returns `null` when the cookie is
|
||||
* absent or the signature is invalid; throws `INVALID_REQUEST` when the
|
||||
* payload is structurally bad (signed but malformed JSON / schema mismatch),
|
||||
* since that's tamper-shaped, not a normal missing-cookie case.
|
||||
*/
|
||||
export const getCscOAuthFlowCookie = async (c: Context): Promise<TCscOAuthFlowPayload | null> => {
|
||||
const raw = await getSignedCookie(c, getCscCookieSecret(), CSC_OAUTH_FLOW_COOKIE_NAME);
|
||||
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedJson: unknown;
|
||||
|
||||
try {
|
||||
parsedJson = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC OAuth flow cookie payload is not valid JSON.',
|
||||
});
|
||||
}
|
||||
|
||||
const result = ZCscOAuthFlowPayloadSchema.safeParse(parsedJson);
|
||||
|
||||
if (!result.success) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC OAuth flow cookie payload failed schema validation.',
|
||||
});
|
||||
}
|
||||
|
||||
return result.data;
|
||||
};
|
||||
|
||||
export const clearCscOAuthFlowCookie = (c: Context): void => {
|
||||
deleteCookie(c, CSC_OAUTH_FLOW_COOKIE_NAME, cscCookieBaseOptions);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
|
||||
import { parseSigned } from 'hono/utils/cookie';
|
||||
|
||||
import { CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
|
||||
|
||||
/**
|
||||
* `csc_sad_session` — HMAC-signed `CscSession` cuid. Set after the
|
||||
* credential-scope OAuth callback exchanges code → SAD; pointed at the
|
||||
* server-side session row that owns the SAD + the prep-time item hashes.
|
||||
*
|
||||
* Lifetime mirrors the TSP-asserted SAD expiry (`sadExpiresAt`) so the cookie
|
||||
* cannot outlive its server-side authorisation. Cleared by the sync sign
|
||||
* mutation on success; otherwise decays naturally with the browser TTL.
|
||||
*/
|
||||
|
||||
type SetCscSadSessionCookieOptions = {
|
||||
c: Context;
|
||||
sessionId: string;
|
||||
/** Mirror of `CscSession.sadExpiresAt`; cookie expires no later than the SAD. */
|
||||
expiresAt: Date;
|
||||
};
|
||||
|
||||
export const setCscSadSessionCookie = async (options: SetCscSadSessionCookieOptions): Promise<void> => {
|
||||
const { c, sessionId, expiresAt } = options;
|
||||
|
||||
await setSignedCookie(c, CSC_SAD_SESSION_COOKIE_NAME, sessionId, getCscCookieSecret(), {
|
||||
...cscCookieBaseOptions,
|
||||
expires: expiresAt,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCscSadSessionCookie = async (c: Context): Promise<string | null> => {
|
||||
const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME);
|
||||
|
||||
// `getSignedCookie` returns `false` on signature mismatch, `undefined` when
|
||||
// the cookie is absent. Both collapse to `null` for the caller's sake.
|
||||
return value ? value : null;
|
||||
};
|
||||
|
||||
export const clearCscSadSessionCookie = (c: Context): void => {
|
||||
deleteCookie(c, CSC_SAD_SESSION_COOKIE_NAME, cscCookieBaseOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remix-compatible reader: parses + HMAC-verifies the SAD-session cookie
|
||||
* from a raw `Cookie` header on a standard `Request`. Mirrors
|
||||
* `getCscSadSessionCookie` but works outside Hono's `Context`.
|
||||
*/
|
||||
export const readCscSadSessionFromRequest = async (request: Request): Promise<string | null> => {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SAD_SESSION_COOKIE_NAME);
|
||||
const value = parsed[CSC_SAD_SESSION_COOKIE_NAME];
|
||||
|
||||
return typeof value === 'string' ? value : null;
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { Context } from 'hono';
|
||||
import { deleteCookie, getSignedCookie, setSignedCookie } from 'hono/cookie';
|
||||
import { parseSigned } from 'hono/utils/cookie';
|
||||
|
||||
import { CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions, getCscCookieSecret } from './shared';
|
||||
|
||||
/**
|
||||
* `csc_service_session` — recipient-scoped attestation that this browser just
|
||||
* completed a service-scope OAuth round-trip for `<recipientToken>`. The
|
||||
* `/sign/{token}` loader compares the cookie value against the path token; on
|
||||
* match it skips re-auth, breaking the redirect loop that would otherwise
|
||||
* occur when the TSP silently re-grants from its cached SCA session.
|
||||
*
|
||||
* Covers the long-lived T1→T3 window (recipient on the signing page filling
|
||||
* fields, before clicking Sign). `csc_sad_session` covers the much shorter
|
||||
* T4→T5 window (active signing transaction); the two are complementary, not
|
||||
* substitutes.
|
||||
*
|
||||
* TTL = TSP-asserted service-scope `expires_in` so the trust window can never
|
||||
* outlive the underlying access token.
|
||||
*/
|
||||
|
||||
type SetCscServiceSessionCookieOptions = {
|
||||
c: Context;
|
||||
recipientToken: string;
|
||||
/** TSP service-scope `expires_in` in seconds. Mirrored as the cookie max-age. */
|
||||
ttlSeconds: number;
|
||||
};
|
||||
|
||||
export const setCscServiceSessionCookie = async (options: SetCscServiceSessionCookieOptions): Promise<void> => {
|
||||
const { c, recipientToken, ttlSeconds } = options;
|
||||
|
||||
await setSignedCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, recipientToken, getCscCookieSecret(), {
|
||||
...cscCookieBaseOptions,
|
||||
maxAge: ttlSeconds,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCscServiceSessionCookie = async (c: Context): Promise<string | null> => {
|
||||
const value = await getSignedCookie(c, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME);
|
||||
|
||||
return value ? value : null;
|
||||
};
|
||||
|
||||
export const clearCscServiceSessionCookie = (c: Context): void => {
|
||||
deleteCookie(c, CSC_SERVICE_SESSION_COOKIE_NAME, cscCookieBaseOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remix-compatible reader: parses + HMAC-verifies the service-session cookie
|
||||
* from a raw `Cookie` header on a standard `Request`. Mirrors
|
||||
* `getCscServiceSessionCookie` but works outside Hono's `Context`.
|
||||
*/
|
||||
export const readCscServiceSessionFromRequest = async (request: Request): Promise<string | null> => {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
|
||||
if (!cookieHeader) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = await parseSigned(cookieHeader, getCscCookieSecret(), CSC_SERVICE_SESSION_COOKIE_NAME);
|
||||
const value = parsed[CSC_SERVICE_SESSION_COOKIE_NAME];
|
||||
|
||||
return typeof value === 'string' ? value : null;
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { formatSecureCookieName, getCookieDomain, useSecureCookies } from '@documenso/lib/constants/auth';
|
||||
import { requireEnv } from '@documenso/lib/utils/env';
|
||||
|
||||
/**
|
||||
* Shared HMAC secret + base attribute set for the CSC cookies.
|
||||
*
|
||||
* `NEXTAUTH_SECRET` is reused so signed-cookie verification stays uniform
|
||||
* across the auth + CSC surfaces. The `sameSite` conditional matches
|
||||
* `sessionCookieOptions` in `@documenso/auth` so a future embedding flow
|
||||
* (CSC inside an `<iframe>` on a partner host) works without a separate
|
||||
* cookie-attribute regime.
|
||||
*/
|
||||
|
||||
/** HMAC secret for hono `setSignedCookie` / `getSignedCookie`. */
|
||||
export const getCscCookieSecret = (): string => requireEnv('NEXTAUTH_SECRET');
|
||||
|
||||
/**
|
||||
* CSC cookie names; prefixed with `__Secure-` in production over HTTPS.
|
||||
*
|
||||
* Naming maps 1:1 to the CSC OAuth scope each cookie attests:
|
||||
* - `csc_service_session` — service-scope grant (long-lived per-browser SCA
|
||||
* attestation; lifetime = TSP `expires_in`).
|
||||
* - `csc_sad_session` — credential-scope grant in progress (in-flight signing
|
||||
* transaction; lifetime = SAD lifetime).
|
||||
* - `csc_oauth_flow` — single-round-trip carrier across authorize → callback
|
||||
* (scope-agnostic; both flows reuse it).
|
||||
* - `csc_blocking_error` — callback failure surface; carries an unresolvable
|
||||
* service-scope error (e.g. empty credential list, refused algorithm) to
|
||||
* the next `/sign/{token}` loader, read-once.
|
||||
*/
|
||||
export const CSC_SERVICE_SESSION_COOKIE_NAME = formatSecureCookieName('csc_service_session');
|
||||
export const CSC_SAD_SESSION_COOKIE_NAME = formatSecureCookieName('csc_sad_session');
|
||||
export const CSC_OAUTH_FLOW_COOKIE_NAME = formatSecureCookieName('csc_oauth_flow');
|
||||
export const CSC_BLOCKING_ERROR_COOKIE_NAME = formatSecureCookieName('csc_blocking_error');
|
||||
|
||||
/**
|
||||
* Base options spread into every CSC cookie. Callers add per-cookie expiry
|
||||
* (`maxAge` or `expires`) on top.
|
||||
*/
|
||||
export const cscCookieBaseOptions = {
|
||||
httpOnly: true,
|
||||
path: '/',
|
||||
sameSite: useSecureCookies ? 'none' : 'lax',
|
||||
secure: useSecureCookies,
|
||||
domain: getCookieDomain(),
|
||||
} as const;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* DB helpers for `CscCredential` — the per-recipient row that holds the
|
||||
* TSP-validated certificate chain, the resolved algorithm policy, and the
|
||||
* encrypted service-scope access token.
|
||||
*
|
||||
* Lifecycle mirrors {@link sign-session.ts} but with a longer-lived row:
|
||||
*
|
||||
* - {@link upsertCscCredential} — service-scope OAuth callback writes the
|
||||
* full credential after `credentials/info` + algorithm validation succeed.
|
||||
* Re-runs replace prior bytes (cert / token rotates as the TSP refreshes).
|
||||
* - {@link loadCscCredential} — sign-time fetches by `recipientId` to recover
|
||||
* the persisted algorithm + encrypted service token; returns `null` when
|
||||
* the recipient never completed service-scope OAuth.
|
||||
*
|
||||
* Encryption is the caller's job — both byte columns hold raw ciphertext
|
||||
* produced by {@link encryptCscToken} so the helpers stay cipher-agnostic.
|
||||
* Cascade cleanup on `Recipient` delete removes the row transitively.
|
||||
*/
|
||||
|
||||
export type CscCredentialRow = {
|
||||
id: string;
|
||||
recipientId: number;
|
||||
providerId: string;
|
||||
credentialId: string;
|
||||
certCache: Uint8Array | null;
|
||||
signatureAlgorithm: string;
|
||||
keyType: string;
|
||||
digestAlgorithm: string;
|
||||
keyLenBits: number | null;
|
||||
signAlgoParams: string | null;
|
||||
serviceTokenCiphertext: Uint8Array | null;
|
||||
serviceTokenExpiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type UpsertCscCredentialInput = {
|
||||
recipientId: number;
|
||||
providerId: string;
|
||||
credentialId: string;
|
||||
/** Length-prefixed X.509 chain — produced from `cscCredentialsInfo.cert.certificates`. */
|
||||
certCache: Uint8Array;
|
||||
/** OID persisted from {@link CscAlgorithmPolicy.signAlgoOid}. */
|
||||
signatureAlgorithm: string;
|
||||
/** `'RSA'` or `'ECDSA'` from the resolved policy. */
|
||||
keyType: string;
|
||||
/** `'SHA-256'` / `'SHA-384'` / `'SHA-512'` from the resolved policy. */
|
||||
digestAlgorithm: string;
|
||||
keyLenBits: number;
|
||||
/** RSASSA-PSS only; omit otherwise. */
|
||||
signAlgoParams?: string;
|
||||
/** Output of {@link encryptCscToken}. */
|
||||
serviceTokenCiphertext: Uint8Array;
|
||||
/** Mirrors the TSP's `expires_in` projected onto wall-clock. */
|
||||
serviceTokenExpiresAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or refresh the per-recipient credential row at service-scope OAuth
|
||||
* callback success. Replaces every prior byte payload — a re-auth always
|
||||
* supersedes the prior cert + token (TSPs may have rotated either).
|
||||
*/
|
||||
export const upsertCscCredential = async (input: UpsertCscCredentialInput): Promise<CscCredentialRow> => {
|
||||
const {
|
||||
recipientId,
|
||||
providerId,
|
||||
credentialId,
|
||||
certCache,
|
||||
signatureAlgorithm,
|
||||
keyType,
|
||||
digestAlgorithm,
|
||||
keyLenBits,
|
||||
signAlgoParams,
|
||||
serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt,
|
||||
} = input;
|
||||
|
||||
const row = await prisma.cscCredential.upsert({
|
||||
where: { recipientId },
|
||||
create: {
|
||||
recipientId,
|
||||
providerId,
|
||||
credentialId,
|
||||
certCache,
|
||||
signatureAlgorithm,
|
||||
keyType,
|
||||
digestAlgorithm,
|
||||
keyLenBits,
|
||||
signAlgoParams: signAlgoParams ?? null,
|
||||
serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt,
|
||||
},
|
||||
update: {
|
||||
providerId,
|
||||
credentialId,
|
||||
certCache,
|
||||
signatureAlgorithm,
|
||||
keyType,
|
||||
digestAlgorithm,
|
||||
keyLenBits,
|
||||
signAlgoParams: signAlgoParams ?? null,
|
||||
serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return toCscCredentialRow(row);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch the credential row for a recipient. Returns `null` when absent — the
|
||||
* recipient hasn't completed service-scope OAuth yet (loader path) or the
|
||||
* recipient cascade fired (cleanup path). Both are normal terminal outcomes.
|
||||
*/
|
||||
export const loadCscCredential = async (recipientId: number): Promise<CscCredentialRow | null> => {
|
||||
const row = await prisma.cscCredential.findUnique({
|
||||
where: { recipientId },
|
||||
});
|
||||
|
||||
return row ? toCscCredentialRow(row) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicit delete by recipient id. Recipient-cascade handles routine cleanup;
|
||||
* this helper is for operator-triggered re-auth flows (force the next visit
|
||||
* to re-do service-scope OAuth even within the trust window).
|
||||
*
|
||||
* Throws `NOT_FOUND` when the row is already gone — semantically distinct
|
||||
* from {@link loadCscCredential}'s nullable return because explicit delete
|
||||
* is a deliberate operation and silent no-op would mask flow-state bugs.
|
||||
*/
|
||||
export const deleteCscCredential = async (recipientId: number): Promise<CscCredentialRow> => {
|
||||
try {
|
||||
const row = await prisma.cscCredential.delete({
|
||||
where: { recipientId },
|
||||
});
|
||||
|
||||
return toCscCredentialRow(row);
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `CSC credential for recipient ${recipientId} not found.`,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const toCscCredentialRow = (row: {
|
||||
id: string;
|
||||
recipientId: number;
|
||||
providerId: string;
|
||||
credentialId: string;
|
||||
certCache: Uint8Array | null;
|
||||
signatureAlgorithm: string;
|
||||
keyType: string;
|
||||
digestAlgorithm: string;
|
||||
keyLenBits: number | null;
|
||||
signAlgoParams: string | null;
|
||||
serviceTokenCiphertext: Uint8Array | null;
|
||||
serviceTokenExpiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): CscCredentialRow => ({
|
||||
id: row.id,
|
||||
recipientId: row.recipientId,
|
||||
providerId: row.providerId,
|
||||
credentialId: row.credentialId,
|
||||
certCache: row.certCache,
|
||||
signatureAlgorithm: row.signatureAlgorithm,
|
||||
keyType: row.keyType,
|
||||
digestAlgorithm: row.digestAlgorithm,
|
||||
keyLenBits: row.keyLenBits,
|
||||
signAlgoParams: row.signAlgoParams,
|
||||
serviceTokenCiphertext: row.serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt: row.serviceTokenExpiresAt,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
@@ -0,0 +1,548 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { jobs } from '@documenso/lib/jobs/client';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { triggerWebhook } from '@documenso/lib/server-only/webhooks/trigger/trigger-webhook';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { mapEnvelopeToWebhookDocumentPayload, ZWebhookDocumentSchema } from '@documenso/lib/types/webhook-payload';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth';
|
||||
import { mapSecondaryIdToDocumentId } from '@documenso/lib/utils/envelope';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { PDF } from '@libpdf/core';
|
||||
import {
|
||||
type DocumentDataType,
|
||||
EnvelopeType,
|
||||
RecipientRole,
|
||||
SendStatus,
|
||||
SigningStatus,
|
||||
WebhookTriggerEvents,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { type CscDigest, hashOidForDigest, policyToLibpdfSignerAlgo } from './algorithm-resolver';
|
||||
import { decodeCscCertChain } from './cert-chain';
|
||||
import { decryptCscToken } from './ciphers';
|
||||
import { cscSignHash } from './client/signatures';
|
||||
import { loadCscCredential } from './credential';
|
||||
import { buildTspAnchorName } from './pdf-names';
|
||||
import { consumeCscSession, loadCscSession } from './sign-session';
|
||||
import { CscCaptureSigner } from './signers/capture-signer';
|
||||
import { CscFifoSigner } from './signers/fifo-signer';
|
||||
import { getCscTransport } from './transport';
|
||||
import { resolveCscSignTimeTsa } from './tsa-resolver';
|
||||
|
||||
/**
|
||||
* CSC TSP sign-time orchestrator.
|
||||
*
|
||||
* Two-pass run, both passes operating on the same prep-time-persisted PDF
|
||||
* bytes (`CscSession.items[i].documentDataId` pins an immutable rendered
|
||||
* orphan row — see `prepare-recipient-signing.ts`):
|
||||
*
|
||||
* 1. Capture re-derives each item's `signedAttrs` digest under the
|
||||
* session-pinned `signingTime` and asserts it matches the prep-time hash
|
||||
* bit-for-bit. Defense in depth — the bytes are identical so a mismatch
|
||||
* means libpdf changed between prep and sign or the row was tampered
|
||||
* with. Throws `CSC_BASE_DOCUMENT_MUTATED` on divergence.
|
||||
* 2. A single batched `signatures/signHash` (§11.9) returns position-ordered
|
||||
* signatures that the embed pass writes back into the same anchors via
|
||||
* `CscFifoSigner`.
|
||||
*
|
||||
* Output bytes are in-place-copied onto `envelopeItem.documentData` (the
|
||||
* row id stays stable; only `type` + `data` change) — same pattern as
|
||||
* `materializeTspAnchorsForEnvelope`. The uploaded rows from
|
||||
* `putPdfFileServerSide` orbit as orphans.
|
||||
*
|
||||
* Persistence is bundled into one outer transaction so document-content
|
||||
* updates, recipient signing-status, audit log, and session consume commit
|
||||
* atomically. Post-tx side effects (webhooks, emails) run after.
|
||||
*/
|
||||
|
||||
export type ExecuteTspSignOptions = {
|
||||
sessionId: string;
|
||||
recipientToken: string;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export type ExecuteTspSignResult = { outcome: 'signed' } | { outcome: 'already_signed' };
|
||||
|
||||
type CapturedItem = {
|
||||
envelopeItemId: string;
|
||||
recapturedDigestB64: string;
|
||||
anchorName: string;
|
||||
pdfBytes: Uint8Array;
|
||||
};
|
||||
|
||||
type SignedItemDataUpdate = {
|
||||
/** Existing `envelopeItem.documentDataId` — receives the in-place data update. */
|
||||
envelopeItemDataId: string;
|
||||
/** Payload to copy onto the existing row. */
|
||||
uploadedType: DocumentDataType;
|
||||
uploadedData: string;
|
||||
};
|
||||
|
||||
export const executeTspSign = async (opts: ExecuteTspSignOptions): Promise<ExecuteTspSignResult> => {
|
||||
const { sessionId, recipientToken, requestMetadata } = opts;
|
||||
|
||||
const session = await loadCscSession(sessionId);
|
||||
|
||||
if (!session) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `CSC session "${sessionId}" not found.`,
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = await getRecipientByToken({ token: recipientToken }).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Recipient with token "${recipientToken}" not found.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient.id !== session.recipientId) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'CSC session does not belong to the recipient identified by token.',
|
||||
});
|
||||
}
|
||||
|
||||
// Idempotency: a 15s tRPC timeout that races with a successful sign can
|
||||
// leave the client retrying after the recipient row already flipped to
|
||||
// SIGNED. Return success rather than re-running.
|
||||
if (recipient.signingStatus === SigningStatus.SIGNED) {
|
||||
return { outcome: 'already_signed' };
|
||||
}
|
||||
|
||||
if (!session.encryptedSad || !session.sadExpiresAt) {
|
||||
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
|
||||
message: 'CSC session has no attached SAD — credential-scope OAuth must complete first.',
|
||||
});
|
||||
}
|
||||
|
||||
if (session.sadExpiresAt.getTime() <= Date.now()) {
|
||||
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
|
||||
message: 'CSC SAD expired before sign-time execution.',
|
||||
});
|
||||
}
|
||||
|
||||
const sad = decryptCscToken(session.encryptedSad);
|
||||
|
||||
if (!sad) {
|
||||
throw new AppError(AppErrorCode.CSC_SAD_EXPIRED_PRE_SIGN, {
|
||||
message: 'CSC SAD decrypt failed — key rotation or row corruption.',
|
||||
});
|
||||
}
|
||||
|
||||
const credential = await loadCscCredential(recipient.id);
|
||||
|
||||
if (!credential) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC credential missing at sign time.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!credential.certCache) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: 'CSC credential has no persisted certificate chain.',
|
||||
});
|
||||
}
|
||||
|
||||
if (credential.keyLenBits === null) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: 'CSC credential omits persisted keyLenBits — service-scope OAuth must re-run.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!credential.serviceTokenCiphertext || !credential.serviceTokenExpiresAt) {
|
||||
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: 'CSC credential has no persisted service token — recipient must re-auth.',
|
||||
});
|
||||
}
|
||||
|
||||
if (credential.serviceTokenExpiresAt.getTime() <= Date.now()) {
|
||||
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: 'CSC service token expired — recipient must re-auth via service-scope OAuth.',
|
||||
});
|
||||
}
|
||||
|
||||
const serviceToken = decryptCscToken(credential.serviceTokenCiphertext);
|
||||
|
||||
if (!serviceToken) {
|
||||
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: 'CSC service token decrypt failed — operator re-auth required.',
|
||||
});
|
||||
}
|
||||
|
||||
const chain = decodeCscCertChain(credential.certCache);
|
||||
|
||||
const algo = policyToLibpdfSignerAlgo({
|
||||
keyType: credential.keyType as 'RSA' | 'ECDSA',
|
||||
digestAlgorithm: credential.digestAlgorithm as CscDigest,
|
||||
signAlgoOid: credential.signatureAlgorithm,
|
||||
keyLenBits: credential.keyLenBits,
|
||||
hashAlgoOid: '',
|
||||
});
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: session.envelopeId },
|
||||
include: {
|
||||
envelopeItems: { include: { documentData: true } },
|
||||
recipients: true,
|
||||
documentMeta: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Capture pass: iterate session.items in order so the resulting hash array
|
||||
// is position-bound to session.items[*].ordinal.
|
||||
const capturedItems: CapturedItem[] = [];
|
||||
|
||||
for (let i = 0; i < session.items.length; i++) {
|
||||
const sessionItem = session.items[i];
|
||||
|
||||
const envelopeItem = envelope.envelopeItems.find((item) => item.id === sessionItem.envelopeItemId);
|
||||
|
||||
if (!envelopeItem) {
|
||||
throw new AppError(AppErrorCode.CSC_BASE_DOCUMENT_MUTATED, {
|
||||
message: `Session references envelope item "${sessionItem.envelopeItemId}" not on envelope.`,
|
||||
});
|
||||
}
|
||||
|
||||
const pinnedDocumentData = await prisma.documentData.findUniqueOrThrow({
|
||||
where: { id: sessionItem.documentDataId },
|
||||
});
|
||||
|
||||
const bytes = await getFileServerSide(pinnedDocumentData);
|
||||
const pdfDoc = await PDF.load(bytes);
|
||||
|
||||
const captureSigner = new CscCaptureSigner({
|
||||
certificate: chain[0],
|
||||
certificateChain: chain.slice(1),
|
||||
algo,
|
||||
});
|
||||
|
||||
const anchorName = buildTspAnchorName(recipient.id, envelopeItem.id);
|
||||
|
||||
// Capture pass stays at B-B even though the embed pass below is B-T:
|
||||
// libpdf's B-T signature timestamp is added as a CMS *unsigned*
|
||||
// attribute *after* `signer.sign()` runs over the signed-attrs digest.
|
||||
// The signed-attrs builder (see CAdESDetachedBuilder.create in
|
||||
// @libpdf/core) takes only (signer, documentHash, digestAlgorithm,
|
||||
// signingTime) — no level-conditional attributes — so B-B and B-T
|
||||
// produce byte-identical signed-attrs for the same inputs. Capturing
|
||||
// at B-B avoids dragging the TSA into the dry-run.
|
||||
await pdfDoc.sign({
|
||||
signer: captureSigner,
|
||||
fieldName: anchorName,
|
||||
signingTime: session.signingTime,
|
||||
level: 'B-B',
|
||||
digestAlgorithm: algo.digestAlgorithm,
|
||||
});
|
||||
|
||||
if (captureSigner.capturedDigest === null) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CscCaptureSigner was not invoked by pdf.sign during sign-time capture.',
|
||||
});
|
||||
}
|
||||
|
||||
const recapturedDigestB64 = Buffer.from(captureSigner.capturedDigest).toString('base64');
|
||||
|
||||
if (recapturedDigestB64 !== sessionItem.hashB64) {
|
||||
throw new AppError(AppErrorCode.CSC_BASE_DOCUMENT_MUTATED, {
|
||||
message: `Re-derived signedAttrs digest at sign time diverged from prep-time hash for envelope item "${envelopeItem.id}".`,
|
||||
});
|
||||
}
|
||||
|
||||
capturedItems.push({
|
||||
envelopeItemId: envelopeItem.id,
|
||||
recapturedDigestB64,
|
||||
anchorName,
|
||||
pdfBytes: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
// Defensive: session-item / captured-item position binding must hold.
|
||||
for (let i = 0; i < capturedItems.length; i++) {
|
||||
if (capturedItems[i].envelopeItemId !== session.items[i].envelopeItemId) {
|
||||
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
||||
message: 'Capture-pass item ordering diverged from session-pinned ordering.',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (capturedItems.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
||||
message: 'CSC session contains no items — nothing to sign.',
|
||||
});
|
||||
}
|
||||
|
||||
const hashes = capturedItems.map((c) => c.recapturedDigestB64);
|
||||
// The cscSignHash request schema requires a non-empty tuple; the explicit
|
||||
// check above narrows the array literal for the type system.
|
||||
const [firstHash, ...restHashes] = hashes;
|
||||
|
||||
const transport = await getCscTransport();
|
||||
|
||||
const signHashResp = await cscSignHash({
|
||||
baseUrl: transport.serviceBaseUrl,
|
||||
accessToken: serviceToken,
|
||||
credentialID: credential.credentialId,
|
||||
SAD: sad,
|
||||
hash: [firstHash, ...restHashes],
|
||||
signAlgo: credential.signatureAlgorithm,
|
||||
hashAlgo: hashOidForDigest(algo.digestAlgorithm),
|
||||
});
|
||||
|
||||
if (signHashResp.signatures.length !== capturedItems.length) {
|
||||
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
||||
message: `CSC signHash returned ${signHashResp.signatures.length} signatures for ${capturedItems.length} hashes.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Embed pass: per-item, reload the same prep-persisted PDF bytes and sign
|
||||
// with a single-signature FIFO signer. No re-render — bytes are exactly
|
||||
// the ones whose digest the TSP just authorised. Level is B-T: each
|
||||
// recipient's CMS gets a TSA-attested signature timestamp embedded as an
|
||||
// unsigned attribute, binding proven time to the signature itself (the
|
||||
// actual eIDAS AES/QES requirement). The TSA is resolved per-recipient
|
||||
// via the sign-time resolver — TSP if advertised (authorised with this
|
||||
// recipient's service-scope bearer), env otherwise.
|
||||
const timestampAuthority = resolveCscSignTimeTsa(transport, serviceToken);
|
||||
|
||||
const signedItemDataUpdates: SignedItemDataUpdate[] = [];
|
||||
|
||||
for (let i = 0; i < capturedItems.length; i++) {
|
||||
const captured = capturedItems[i];
|
||||
const sigBytes = Buffer.from(signHashResp.signatures[i], 'base64');
|
||||
|
||||
const pdfDoc = await PDF.load(captured.pdfBytes);
|
||||
|
||||
const fifoSigner = new CscFifoSigner({
|
||||
certificate: chain[0],
|
||||
certificateChain: chain.slice(1),
|
||||
algo,
|
||||
signatures: [sigBytes],
|
||||
});
|
||||
|
||||
const signResult = await pdfDoc.sign({
|
||||
signer: fifoSigner,
|
||||
fieldName: captured.anchorName,
|
||||
signingTime: session.signingTime,
|
||||
level: 'B-T',
|
||||
timestampAuthority,
|
||||
digestAlgorithm: algo.digestAlgorithm,
|
||||
});
|
||||
|
||||
const envelopeItem = envelope.envelopeItems.find((item) => item.id === captured.envelopeItemId);
|
||||
|
||||
if (!envelopeItem) {
|
||||
throw new AppError(AppErrorCode.CSC_EMBED_FAILED, {
|
||||
message: `Envelope item "${captured.envelopeItemId}" missing during embed pass.`,
|
||||
});
|
||||
}
|
||||
|
||||
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
|
||||
|
||||
const uploaded = await putPdfFileServerSide(
|
||||
{
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(signResult.bytes),
|
||||
},
|
||||
envelopeItem.documentData.initialData ?? undefined,
|
||||
);
|
||||
|
||||
// In-place data update target: the existing envelopeItem.documentDataId
|
||||
// row. `uploaded.documentData` is the freshly-created row whose payload
|
||||
// we'll copy on; that row stays orphan after the copy. Mirrors the
|
||||
// `materializeTspAnchorsForEnvelope` pattern.
|
||||
signedItemDataUpdates.push({
|
||||
envelopeItemDataId: envelopeItem.documentDataId,
|
||||
uploadedType: uploaded.documentData.type,
|
||||
uploadedData: uploaded.documentData.data,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyDocumentId = mapSecondaryIdToDocumentId(envelope.secondaryId);
|
||||
|
||||
// Single tx: per-item in-place data updates + recipient flip + audit log +
|
||||
// session consume. Atomic across items — if any write fails, the recipient
|
||||
// stays unsigned and the session row stays attached. `envelopeItem.
|
||||
// documentDataId` is preserved across the run; only `documentData.{type,
|
||||
// data}` changes. Mirrors `materializeTspAnchorsForEnvelope`.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const { envelopeItemDataId, uploadedType, uploadedData } of signedItemDataUpdates) {
|
||||
await tx.documentData.update({
|
||||
where: { id: envelopeItemDataId },
|
||||
data: { type: uploadedType, data: uploadedData },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.recipient.update({
|
||||
where: { id: recipient.id },
|
||||
data: {
|
||||
signingStatus: SigningStatus.SIGNED,
|
||||
signedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const authOptions = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
envelopeId: envelope.id,
|
||||
user: {
|
||||
name: recipient.name,
|
||||
email: recipient.email,
|
||||
},
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
actionAuth: authOptions.derivedRecipientActionAuth,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGNED,
|
||||
envelopeId: envelope.id,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: credential.providerId,
|
||||
credentialId: credential.credentialId,
|
||||
sessionId,
|
||||
numItemsSigned: signedItemDataUpdates.length,
|
||||
signatureAlgorithm: credential.signatureAlgorithm,
|
||||
digestAlgorithm: credential.digestAlgorithm,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await consumeCscSession(sessionId, tx);
|
||||
});
|
||||
|
||||
// Post-tx side effects (webhooks, emails, next-signer advancement, seal
|
||||
// job dispatch). Inlined rather than shared with the SES completion path —
|
||||
// the in-tx shape diverges enough (TSP swaps documentDataIds + consumes
|
||||
// the CSC session; SES doesn't) that a shared helper would obscure both.
|
||||
const envelopeWithRelations = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: envelope.id },
|
||||
include: { documentMeta: true, recipients: true },
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_RECIPIENT_COMPLETED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(envelopeWithRelations)),
|
||||
userId: envelope.userId,
|
||||
teamId: envelope.teamId,
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.recipient.signed.email',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
const pendingRecipients = await prisma.recipient.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
signingOrder: true,
|
||||
role: true,
|
||||
},
|
||||
where: {
|
||||
envelopeId: envelope.id,
|
||||
signingStatus: { not: SigningStatus.SIGNED },
|
||||
role: { not: RecipientRole.CC },
|
||||
},
|
||||
orderBy: [{ signingOrder: { sort: 'asc', nulls: 'last' } }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
if (pendingRecipients.length > 0) {
|
||||
await jobs.triggerJob({
|
||||
name: 'send.document.pending.email',
|
||||
payload: {
|
||||
envelopeId: envelope.id,
|
||||
recipientId: recipient.id,
|
||||
},
|
||||
});
|
||||
|
||||
// TSP envelopes are forced SEQUENTIAL at send-time; this branch always
|
||||
// fires when pending recipients exist. No `nextSigner` dictation path
|
||||
// — `prepareCscRecipientSigning` doesn't accept one.
|
||||
const [nextRecipient] = pendingRecipients;
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: { id: nextRecipient.id },
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
sentAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await jobs.triggerJob({
|
||||
name: 'send.signing.requested.email',
|
||||
payload: {
|
||||
userId: envelope.userId,
|
||||
documentId: legacyDocumentId,
|
||||
recipientId: nextRecipient.id,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const haveAllRecipientsSigned = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
recipients: {
|
||||
every: {
|
||||
OR: [{ signingStatus: SigningStatus.SIGNED }, { role: RecipientRole.CC }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (haveAllRecipientsSigned) {
|
||||
await jobs.triggerJob({
|
||||
name: 'internal.seal-document',
|
||||
payload: {
|
||||
documentId: legacyDocumentId,
|
||||
requestMetadata,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedDocument = await prisma.envelope.findFirstOrThrow({
|
||||
where: {
|
||||
id: envelope.id,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
include: {
|
||||
documentMeta: true,
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
await triggerWebhook({
|
||||
event: WebhookTriggerEvents.DOCUMENT_SIGNED,
|
||||
data: ZWebhookDocumentSchema.parse(mapEnvelopeToWebhookDocumentPayload(updatedDocument)),
|
||||
userId: updatedDocument.userId,
|
||||
teamId: updatedDocument.teamId ?? undefined,
|
||||
});
|
||||
|
||||
return { outcome: 'signed' };
|
||||
};
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import type { CreateDocumentAuditLogDataResponse } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { HttpTimestampAuthority, PDF, type TimestampAuthority } from '@libpdf/core';
|
||||
import type { DocumentData, DocumentMeta, Envelope, EnvelopeItem, Recipient, User } from '@prisma/client';
|
||||
import { DocumentStatus } from '@prisma/client';
|
||||
|
||||
import { resolveCscSealTimeTsa } from './tsa-resolver';
|
||||
|
||||
/**
|
||||
* TSP envelope finalisation step run from the `seal-document` job.
|
||||
*
|
||||
* Replaces the SES "decorate + p12 sign" pass: recipient bytes are already
|
||||
* PAdES-signed by each recipient's CSC TSP, so the seal step is reduced to
|
||||
* a per-item PAdES B-LTA upgrade — libpdf's `pdf.addArchivalData()` runs
|
||||
* the full archive sequence (DSS for every existing signature + archival
|
||||
* `/DocTimeStamp` + DSS for the timestamp's own chain), and the resulting
|
||||
* bytes are copied in-place onto each `envelopeItem.documentData` row.
|
||||
* `envelopeItem.documentDataId` stays stable across the whole envelope
|
||||
* lifecycle (materialise → per-recipient signs → finalise) — mirrors the
|
||||
* pattern used by `materializeTspAnchorsForEnvelope` and `executeTspSign`.
|
||||
*
|
||||
* Certificate / audit-log sidecar PDFs are intentionally NOT merged into
|
||||
* the signed bytes here — they're rendered on-demand at download time so
|
||||
* the signed PDF stays byte-identical to what each recipient's SAD
|
||||
* authorised. Rejection and resealing are unsupported in V1 and rejected
|
||||
* by the caller before this runs.
|
||||
*/
|
||||
|
||||
export type FinalizeTspEnvelopeCompletionOptions = {
|
||||
envelope: Envelope & {
|
||||
documentMeta: DocumentMeta | null;
|
||||
recipients: Recipient[];
|
||||
envelopeItems: Array<EnvelopeItem & { documentData: DocumentData }>;
|
||||
user: Pick<User, 'name' | 'email'>;
|
||||
};
|
||||
envelopeCompletedAuditLog: CreateDocumentAuditLogDataResponse;
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
type ArchivedItem = {
|
||||
/** Existing `envelopeItem.documentDataId` — target of the in-place update. */
|
||||
envelopeItemDataId: string;
|
||||
uploadedType: DocumentData['type'];
|
||||
uploadedData: string;
|
||||
};
|
||||
|
||||
export const finalizeTspEnvelopeCompletion = async (opts: FinalizeTspEnvelopeCompletionOptions): Promise<void> => {
|
||||
const { envelope, envelopeCompletedAuditLog } = opts;
|
||||
|
||||
// Resolve the TSA up-front — fail fast if the instance is mis-configured
|
||||
// before we start round-tripping PDF bytes through storage.
|
||||
const tsa = resolveCscSealTimeTsa();
|
||||
const timestampAuthority = buildLibpdfTsa(tsa);
|
||||
|
||||
const archivedItems: ArchivedItem[] = [];
|
||||
|
||||
for (const envelopeItem of envelope.envelopeItems) {
|
||||
const pdfBytes = await getFileServerSide(envelopeItem.documentData);
|
||||
const pdfDoc = await PDF.load(pdfBytes);
|
||||
|
||||
// PAdES B-LTA in one call. Internally:
|
||||
// 1. Gather LTV (certs/OCSP/CRL) for every existing signed field and
|
||||
// write a single DSS incremental update.
|
||||
// 2. Add an archival `/DocTimeStamp` over the result.
|
||||
// 3. Gather LTV for the new timestamp's own certificate chain.
|
||||
// All three are append-only incremental updates — every prior recipient
|
||||
// signature's `/ByteRange` stays valid.
|
||||
const archived = await pdfDoc.addArchivalData({ timestampAuthority });
|
||||
|
||||
const { documentData: uploaded } = await putPdfFileServerSide(
|
||||
{
|
||||
name: envelopeItem.title.endsWith('.pdf') ? envelopeItem.title : `${envelopeItem.title}.pdf`,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(archived.bytes),
|
||||
},
|
||||
envelopeItem.documentData.initialData,
|
||||
);
|
||||
|
||||
archivedItems.push({
|
||||
envelopeItemDataId: envelopeItem.documentData.id,
|
||||
uploadedType: uploaded.type,
|
||||
uploadedData: uploaded.data,
|
||||
});
|
||||
}
|
||||
|
||||
// Single tx: per-item in-place data updates + envelope status flip +
|
||||
// completion audit log. `envelopeItem.documentDataId` is preserved; the
|
||||
// freshly-uploaded `DocumentData` rows orbit as orphans.
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (const { envelopeItemDataId, uploadedType, uploadedData } of archivedItems) {
|
||||
await tx.documentData.update({
|
||||
where: { id: envelopeItemDataId },
|
||||
data: { type: uploadedType, data: uploadedData },
|
||||
});
|
||||
}
|
||||
|
||||
await tx.envelope.update({
|
||||
where: { id: envelope.id },
|
||||
data: {
|
||||
status: DocumentStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: envelopeCompletedAuditLog,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrap a resolved seal-time TSA config into a libpdf `TimestampAuthority`.
|
||||
*
|
||||
* Env only at seal time — the archival `/DocTimeStamp` is the operator's
|
||||
* long-term trust anchor and SHOULD point at a dedicated qualified archival
|
||||
* TSA (e.g. DigiCert) that's independent of the per-recipient TSP. We
|
||||
* deliberately don't fall back to the TSP here: doing so would couple the
|
||||
* archive's longevity to a TSP that may revoke or rotate without notice,
|
||||
* and would require keeping a live service-scope bearer around at the
|
||||
* seal-document job which has no recipient context anyway.
|
||||
*
|
||||
* First URL only — multi-URL fallback can layer on later via a composite
|
||||
* wrapper if operators need it.
|
||||
*/
|
||||
const buildLibpdfTsa = (tsa: { urls: string[] }): TimestampAuthority => {
|
||||
return new HttpTimestampAuthority(tsa.urls[0]);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { logger } from '@documenso/lib/utils/logger';
|
||||
|
||||
/**
|
||||
* CSC subapp Hono context. Mirrors the subset of `apps/remix/server/router.ts`
|
||||
* `HonoEnv` that CSC handlers actually read. Duplicated (rather than imported
|
||||
* from `apps/remix/`) to keep the `packages/ee` → `apps/remix` dep direction
|
||||
* unidirectional.
|
||||
*
|
||||
* Runtime contract: the remix host's middleware sets `logger` on every request
|
||||
* before the CSC subapp runs; the CSC subapp does not set it itself.
|
||||
*/
|
||||
export type HonoCscEnv = {
|
||||
Variables: {
|
||||
logger: typeof logger;
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { Hono } from 'hono';
|
||||
import { HTTPException } from 'hono/http-exception';
|
||||
import type { ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
|
||||
import type { HonoCscEnv } from './context';
|
||||
import { cscOAuthAuthorizeRoute } from './oauth-authorize';
|
||||
import { cscOAuthCallbackRoute } from './oauth-callback';
|
||||
|
||||
/**
|
||||
* `@documenso/ee` CSC subapp. Mount under `/api/csc` in the remix host (see
|
||||
* `apps/remix/server/router.ts`). All CSC endpoints — OAuth authorize +
|
||||
* callback — are composed here so the host only has to wire one route.
|
||||
*
|
||||
* Routes throw `AppError` freely; the `.onError` handler below normalises
|
||||
* them into REST responses (mirrors `@documenso/auth/server`'s pattern).
|
||||
*/
|
||||
export const csc = new Hono<HonoCscEnv>()
|
||||
.route('/oauth/authorize', cscOAuthAuthorizeRoute)
|
||||
.route('/oauth/callback', cscOAuthCallbackRoute);
|
||||
|
||||
csc.onError((err, c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
if (err instanceof HTTPException) {
|
||||
return c.json(
|
||||
{
|
||||
code: AppErrorCode.UNKNOWN_ERROR,
|
||||
message: err.message,
|
||||
statusCode: err.status,
|
||||
},
|
||||
err.status,
|
||||
);
|
||||
}
|
||||
|
||||
if (err instanceof AppError) {
|
||||
const { status, body } = AppError.toRestAPIError(err);
|
||||
|
||||
logger.error({
|
||||
event: 'csc.error',
|
||||
code: err.code,
|
||||
message: err.message,
|
||||
});
|
||||
|
||||
return c.json(body, status as ContentfulStatusCode);
|
||||
}
|
||||
|
||||
logger.error({
|
||||
event: 'csc.unknown_error',
|
||||
error: err,
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
code: AppErrorCode.UNKNOWN_ERROR,
|
||||
message: 'Internal Server Error',
|
||||
statusCode: 500,
|
||||
},
|
||||
500,
|
||||
);
|
||||
});
|
||||
|
||||
export type CscAppType = typeof csc;
|
||||
@@ -0,0 +1,154 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
buildCscCredentialScopeAuthorizeUrl,
|
||||
buildCscServiceScopeAuthorizeUrl,
|
||||
generateCodeVerifier,
|
||||
generateState,
|
||||
} from '../client/oauth';
|
||||
import { setCscOAuthFlowCookie } from '../cookies/oauth-flow-cookie';
|
||||
import { loadCscCredential } from '../credential';
|
||||
import { loadCscSession } from '../sign-session';
|
||||
import { getCscTransport } from '../transport';
|
||||
import type { HonoCscEnv } from './context';
|
||||
|
||||
/**
|
||||
* `GET /api/csc/oauth/authorize` — initiates the CSC OAuth round-trip and
|
||||
* 302-redirects to the TSP's authorize URL with a signed `csc_oauth_flow`
|
||||
* cookie carrying the state, PKCE verifier, and recipient context the
|
||||
* callback needs to resume the flow.
|
||||
*
|
||||
* Branches on `?scope=service|credential`:
|
||||
* - `service`: authorised by recipient token; precedes credentials/list.
|
||||
* - `credential`: authorised by an active `CscSession`; binds the issued SAD
|
||||
* to the per-item hashes captured at prep.
|
||||
*
|
||||
* Errors bubble to the parent app's `.onError` handler (see `./index.ts`).
|
||||
*/
|
||||
|
||||
const ZAuthorizeQuerySchema = z.discriminatedUnion('scope', [
|
||||
z.object({
|
||||
scope: z.literal('service'),
|
||||
token: z.string().min(1),
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal('credential'),
|
||||
session: z.string().min(1),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const cscOAuthAuthorizeRoute = new Hono<HonoCscEnv>().get(
|
||||
'/',
|
||||
sValidator('query', ZAuthorizeQuerySchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
const query = c.req.valid('query');
|
||||
|
||||
const transport = await getCscTransport();
|
||||
|
||||
if (query.scope === 'service') {
|
||||
const recipient = await getRecipientByToken({ token: query.token }).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found for the provided token.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'csc.oauth.authorize.start',
|
||||
scope: 'service',
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
|
||||
const authorizeUrl = buildCscServiceScopeAuthorizeUrl({
|
||||
client: transport.oauthClient,
|
||||
oauthBaseUrl: transport.oauthBaseUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
});
|
||||
|
||||
await setCscOAuthFlowCookie({
|
||||
c,
|
||||
payload: {
|
||||
scope: 'service',
|
||||
state,
|
||||
codeVerifier,
|
||||
recipientToken: query.token,
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect(authorizeUrl.toString(), 302);
|
||||
}
|
||||
|
||||
const session = await loadCscSession(query.session);
|
||||
|
||||
if (!session) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC session not found or already consumed.',
|
||||
});
|
||||
}
|
||||
|
||||
const credential = await loadCscCredential(session.recipientId);
|
||||
|
||||
if (!credential) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC credential missing — service-scope OAuth must complete first.',
|
||||
});
|
||||
}
|
||||
|
||||
const recipient = await prisma.recipient.findUnique({
|
||||
where: { id: session.recipientId },
|
||||
select: { token: true },
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found for the CSC session.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({
|
||||
event: 'csc.oauth.authorize.start',
|
||||
scope: 'credential',
|
||||
recipientId: session.recipientId,
|
||||
sessionId: session.id,
|
||||
numSignatures: session.items.length,
|
||||
});
|
||||
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
|
||||
const authorizeUrl = buildCscCredentialScopeAuthorizeUrl({
|
||||
client: transport.oauthClient,
|
||||
oauthBaseUrl: transport.oauthBaseUrl,
|
||||
state,
|
||||
codeVerifier,
|
||||
credentialId: credential.credentialId,
|
||||
numSignatures: session.items.length,
|
||||
hashes: session.items.map((item) => item.hashB64),
|
||||
});
|
||||
|
||||
await setCscOAuthFlowCookie({
|
||||
c,
|
||||
payload: {
|
||||
scope: 'credential',
|
||||
state,
|
||||
codeVerifier,
|
||||
recipientToken: recipient.token,
|
||||
sessionId: session.id,
|
||||
},
|
||||
});
|
||||
|
||||
return c.redirect(authorizeUrl.toString(), 302);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,303 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { extractRequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { formatSigningLink } from '@documenso/lib/utils/recipients';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { sValidator } from '@hono/standard-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { resolveCscAlgorithmPolicy } from '../algorithm-resolver';
|
||||
import { encodeCscCertChain } from '../cert-chain';
|
||||
import { encryptCscToken } from '../ciphers';
|
||||
import { cscCredentialsInfo, cscCredentialsList } from '../client/credentials';
|
||||
import { exchangeCscAuthorizationCode } from '../client/oauth';
|
||||
import { setCscBlockingErrorCookie } from '../cookies/blocking-error-cookie';
|
||||
import { clearCscOAuthFlowCookie, getCscOAuthFlowCookie } from '../cookies/oauth-flow-cookie';
|
||||
import { setCscSadSessionCookie } from '../cookies/sad-session-cookie';
|
||||
import { setCscServiceSessionCookie } from '../cookies/service-session-cookie';
|
||||
import { loadCscCredential, upsertCscCredential } from '../credential';
|
||||
import { updateCscSessionWithSad } from '../sign-session';
|
||||
import { getCscTransport } from '../transport';
|
||||
import type { HonoCscEnv } from './context';
|
||||
|
||||
/**
|
||||
* `GET /api/csc/oauth/callback` — landing point for the recipient's return
|
||||
* from the TSP after the round-trip initiated by `oauth-authorize`. Reads
|
||||
* the `csc_oauth_flow` cookie, verifies CSRF, exchanges the code, and
|
||||
* branches on the cookie's `scope`:
|
||||
*
|
||||
* - `service`: pulls `credentials/list` + `credentials/info`, validates the
|
||||
* cert + algorithm policy, persists the `CscCredential` row + service
|
||||
* token, sets the `csc_service_session` cookie, and redirects to
|
||||
* `/sign/{token}`. Blocking validation errors (empty list, bad cert,
|
||||
* refused algorithm) round-trip via the `csc_blocking_error` cookie so the
|
||||
* signing-page loader can render a stable error UI.
|
||||
* - `credential`: exchanges code → SAD, stamps it onto the existing
|
||||
* `CscSession`, sets the `csc_sad_session` cookie, and redirects to
|
||||
* `/sign/{token}`. Credential-scope failures bubble to `.onError` — the
|
||||
* recipient simply re-clicks Sign.
|
||||
*
|
||||
* Non-blocking errors bubble to the parent app's `.onError` (see
|
||||
* `./index.ts`) — mirrors `oauth-authorize.ts`.
|
||||
*/
|
||||
|
||||
const ZCallbackQuerySchema = z.object({
|
||||
state: z.string().min(1),
|
||||
code: z.string().min(1).optional(),
|
||||
error: z.string().min(1).optional(),
|
||||
error_description: z.string().optional(),
|
||||
});
|
||||
|
||||
const BLOCKING_SERVICE_ERROR_CODES = new Set<string>([
|
||||
AppErrorCode.CSC_CREDENTIAL_LIST_EMPTY,
|
||||
AppErrorCode.CSC_CERT_INVALID,
|
||||
AppErrorCode.CSC_ALGORITHM_REFUSED,
|
||||
]);
|
||||
|
||||
const isBlockingServiceError = (code: string): boolean => BLOCKING_SERVICE_ERROR_CODES.has(code);
|
||||
|
||||
export const cscOAuthCallbackRoute = new Hono<HonoCscEnv>().get(
|
||||
'/',
|
||||
sValidator('query', ZCallbackQuerySchema),
|
||||
async (c) => {
|
||||
const logger = c.get('logger');
|
||||
|
||||
const query = c.req.valid('query');
|
||||
|
||||
const cookie = await getCscOAuthFlowCookie(c);
|
||||
|
||||
if (!cookie) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC OAuth flow cookie missing or expired.',
|
||||
});
|
||||
}
|
||||
|
||||
if (query.state !== cookie.state) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'CSC OAuth callback state mismatch — possible CSRF.',
|
||||
});
|
||||
}
|
||||
|
||||
// The single-round-trip carrier is spent regardless of subsequent
|
||||
// outcome; clear it now so a retry restarts from `/api/csc/oauth/authorize`.
|
||||
clearCscOAuthFlowCookie(c);
|
||||
|
||||
if (query.error) {
|
||||
throw new AppError(AppErrorCode.CSC_REQUEST_FAILED, {
|
||||
message: `CSC TSP returned OAuth error: ${query.error}${query.error_description ? ' — ' + query.error_description : ''}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!query.code) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC OAuth callback missing code parameter.',
|
||||
});
|
||||
}
|
||||
|
||||
const transport = await getCscTransport();
|
||||
|
||||
const recipient = await getRecipientByToken({ token: cookie.recipientToken }).catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found for CSC OAuth flow cookie.',
|
||||
});
|
||||
}
|
||||
|
||||
if (cookie.scope === 'service') {
|
||||
const tokens = await exchangeCscAuthorizationCode({
|
||||
client: transport.oauthClient,
|
||||
oauthBaseUrl: transport.oauthBaseUrl,
|
||||
code: query.code,
|
||||
codeVerifier: cookie.codeVerifier,
|
||||
});
|
||||
|
||||
try {
|
||||
const listResp = await cscCredentialsList({
|
||||
baseUrl: transport.serviceBaseUrl,
|
||||
accessToken: tokens.accessToken(),
|
||||
});
|
||||
|
||||
// V1 picks the first credential per spec section "Out of scope for
|
||||
// V1": multi-credential selection UI lands in a later iteration.
|
||||
const credentialId = listResp.credentialIDs[0];
|
||||
|
||||
const infoResp = await cscCredentialsInfo({
|
||||
baseUrl: transport.serviceBaseUrl,
|
||||
accessToken: tokens.accessToken(),
|
||||
credentialID: credentialId,
|
||||
certificates: 'chain',
|
||||
certInfo: true,
|
||||
});
|
||||
|
||||
const policy = resolveCscAlgorithmPolicy(infoResp);
|
||||
|
||||
if (!infoResp.cert.certificates || infoResp.cert.certificates.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: 'CSC credential info response omitted required certificate chain.',
|
||||
});
|
||||
}
|
||||
|
||||
const certCache = encodeCscCertChain(infoResp.cert.certificates);
|
||||
const serviceTokenCiphertext = encryptCscToken(tokens.accessToken());
|
||||
const serviceTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||
|
||||
await upsertCscCredential({
|
||||
recipientId: recipient.id,
|
||||
providerId: transport.serviceBaseUrl,
|
||||
credentialId,
|
||||
certCache,
|
||||
signatureAlgorithm: policy.signAlgoOid,
|
||||
keyType: policy.keyType,
|
||||
digestAlgorithm: policy.digestAlgorithm,
|
||||
keyLenBits: policy.keyLenBits,
|
||||
serviceTokenCiphertext,
|
||||
serviceTokenExpiresAt,
|
||||
});
|
||||
|
||||
await setCscServiceSessionCookie({
|
||||
c,
|
||||
recipientToken: cookie.recipientToken,
|
||||
ttlSeconds: tokens.accessTokenExpiresInSeconds(),
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATED,
|
||||
envelopeId: recipient.envelopeId,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata: extractRequestMetadata(c.req.raw),
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: transport.serviceBaseUrl,
|
||||
credentialId,
|
||||
signatureAlgorithm: policy.signAlgoOid,
|
||||
digestAlgorithm: policy.digestAlgorithm,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
logger.info({
|
||||
event: 'csc.oauth.callback.service.complete',
|
||||
recipientId: recipient.id,
|
||||
});
|
||||
|
||||
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
|
||||
} catch (err) {
|
||||
if (err instanceof AppError && isBlockingServiceError(err.code)) {
|
||||
await setCscBlockingErrorCookie({
|
||||
c,
|
||||
payload: { code: err.code, recipientToken: cookie.recipientToken },
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHENTICATION_FAILED,
|
||||
envelopeId: recipient.envelopeId,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata: extractRequestMetadata(c.req.raw),
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: transport.serviceBaseUrl,
|
||||
reason: err.code,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
logger.warn({
|
||||
event: 'csc.oauth.callback.service.blocking',
|
||||
recipientId: recipient.id,
|
||||
code: err.code,
|
||||
});
|
||||
|
||||
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!cookie.sessionId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC credential-scope OAuth callback missing sessionId in cookie.',
|
||||
});
|
||||
}
|
||||
|
||||
const tokens = await exchangeCscAuthorizationCode({
|
||||
client: transport.oauthClient,
|
||||
oauthBaseUrl: transport.oauthBaseUrl,
|
||||
code: query.code,
|
||||
codeVerifier: cookie.codeVerifier,
|
||||
});
|
||||
|
||||
// CSC §8.3.3 says credential-scope returns `token_type === 'SAD'`. We
|
||||
// don't hard-fail on a divergent label — the binding is by scope + hash,
|
||||
// not by `token_type` — but we log so operator metrics can spot loose
|
||||
// TSPs.
|
||||
if (tokens.tokenType() !== 'SAD') {
|
||||
logger.warn({
|
||||
event: 'csc.oauth.callback.credential.unexpected_token_type',
|
||||
actual: tokens.tokenType(),
|
||||
});
|
||||
}
|
||||
|
||||
const sadCiphertext = encryptCscToken(tokens.accessToken());
|
||||
const sadExpiresAt = tokens.accessTokenExpiresAt();
|
||||
|
||||
await updateCscSessionWithSad({
|
||||
sessionId: cookie.sessionId,
|
||||
encryptedSad: sadCiphertext,
|
||||
sadExpiresAt,
|
||||
});
|
||||
|
||||
await setCscSadSessionCookie({
|
||||
c,
|
||||
sessionId: cookie.sessionId,
|
||||
expiresAt: sadExpiresAt,
|
||||
});
|
||||
|
||||
const credential = await loadCscCredential(recipient.id);
|
||||
|
||||
if (!credential) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC credential missing at credential-scope callback.',
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_AUTHORIZED,
|
||||
envelopeId: recipient.envelopeId,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata: extractRequestMetadata(c.req.raw),
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: credential.providerId,
|
||||
credentialId: credential.credentialId,
|
||||
sessionId: cookie.sessionId,
|
||||
sadExpiresAt,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
logger.info({
|
||||
event: 'csc.oauth.callback.credential.complete',
|
||||
recipientId: recipient.id,
|
||||
sessionId: cookie.sessionId,
|
||||
});
|
||||
|
||||
return c.redirect(formatSigningLink(cookie.recipientToken), 302);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,230 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { PDF } from '@libpdf/core';
|
||||
|
||||
import { buildTspAnchorName, buildTspStampName } from './pdf-names';
|
||||
|
||||
export type MaterializeTspAnchorsForEnvelopeOptions = {
|
||||
envelopeId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Pre-allocate per-recipient AcroForm signature anchors and per-page `/Stamp`
|
||||
* overlay annotations on every envelope item of a TSP (AES/QES) envelope.
|
||||
*
|
||||
* Mutates the existing `DocumentData` row in place — the `envelopeItem.
|
||||
* documentDataId` pointer is preserved across materialisation. Materialise
|
||||
* is distribution housekeeping (pre-allocate fixed anchor slots before any
|
||||
* recipient signs), not a content version bump, so a pointer swap +
|
||||
* audit-log entry would mis-attribute the change. The new uploaded row
|
||||
* created by `putPdfFileServerSide` is kept as an orphan rather than
|
||||
* deleted — it preserves the standard upload mechanics (S3 PUT or BYTES_64
|
||||
* encode) without a separate "copy then drop" dance.
|
||||
*
|
||||
* Idempotent: re-runs are no-ops when every expected anchor/stamp is
|
||||
* already present. No-op for SES envelopes.
|
||||
*/
|
||||
export const materializeTspAnchorsForEnvelope = async ({
|
||||
envelopeId,
|
||||
}: MaterializeTspAnchorsForEnvelopeOptions): Promise<void> => {
|
||||
const envelope = await prisma.envelope.findUnique({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
},
|
||||
include: {
|
||||
recipients: true,
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
fields: {
|
||||
select: {
|
||||
recipientId: true,
|
||||
envelopeItemId: true,
|
||||
page: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Envelope ${envelopeId} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isTspEnvelope(envelope)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const envelopeItem of envelope.envelopeItems) {
|
||||
const expectedAnchorNames = envelope.recipients.map((recipient) =>
|
||||
buildTspAnchorName(recipient.id, envelopeItem.id),
|
||||
);
|
||||
|
||||
const expectedStampNames: string[] = [];
|
||||
|
||||
for (const recipient of envelope.recipients) {
|
||||
const pagesWithFields = new Set<number>();
|
||||
|
||||
for (const field of envelope.fields) {
|
||||
if (field.recipientId === recipient.id && field.envelopeItemId === envelopeItem.id) {
|
||||
pagesWithFields.add(field.page);
|
||||
}
|
||||
}
|
||||
|
||||
for (const page of pagesWithFields) {
|
||||
expectedStampNames.push(buildTspStampName(recipient.id, envelopeItem.id, page));
|
||||
}
|
||||
}
|
||||
|
||||
const bytes = await getFileServerSide(envelopeItem.documentData);
|
||||
const pdfDoc = await PDF.load(bytes);
|
||||
|
||||
if (isAlreadyMaterialised(pdfDoc, expectedAnchorNames, expectedStampNames)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Bake operator AcroForm, annotations and OCG layers into static graphics
|
||||
// so the materialised PDF is a deterministic surface. `skipSignatures`
|
||||
// preserves any operator-placed signature widgets and (on re-materialise)
|
||||
// the TSP anchors created previously.
|
||||
pdfDoc.flattenAll({
|
||||
form: {
|
||||
skipSignatures: true,
|
||||
},
|
||||
});
|
||||
|
||||
const form = pdfDoc.getOrCreateForm();
|
||||
|
||||
if (pdfDoc.getPageCount() === 0) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Envelope item ${envelopeItem.id} PDF has no pages`,
|
||||
});
|
||||
}
|
||||
|
||||
// Anchors are AcroForm signature fields with no pre-attached widget.
|
||||
// libpdf forbids `drawField` for signature fields — at sign time
|
||||
// `pdf.sign({ fieldName })` promotes the existing field dict in place
|
||||
// to a merged field/widget (Type=Annot, Subtype=Widget, P=page0,
|
||||
// Rect=[0,0,0,0]) without modifying the page object. That preserves the
|
||||
// per-recipient `/ByteRange` invariant across sequential signatures.
|
||||
for (const anchorName of expectedAnchorNames) {
|
||||
if (form.getSignatureField(anchorName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
form.createSignatureField(anchorName);
|
||||
}
|
||||
|
||||
for (const recipient of envelope.recipients) {
|
||||
const pagesWithFields = new Set<number>();
|
||||
|
||||
for (const field of envelope.fields) {
|
||||
if (field.recipientId === recipient.id && field.envelopeItemId === envelopeItem.id) {
|
||||
pagesWithFields.add(field.page);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pageNumber of pagesWithFields) {
|
||||
const stampName = buildTspStampName(recipient.id, envelopeItem.id, pageNumber);
|
||||
const page = pdfDoc.getPage(pageNumber - 1);
|
||||
|
||||
if (!page) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Envelope item ${envelopeItem.id} missing page ${pageNumber} referenced by field`,
|
||||
});
|
||||
}
|
||||
|
||||
const existing = page.getStampAnnotations().some((stamp) => stamp.stampName === stampName);
|
||||
|
||||
if (existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
page.addStampAnnotation({
|
||||
name: stampName,
|
||||
rect: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const newBytes = await pdfDoc.save({ useXRefStream: true });
|
||||
|
||||
// CRITICAL: persist via `putPdfFileServerSide` (raw). The normalised path
|
||||
// would call `form.flatten()` without `skipSignatures` and wipe anchors.
|
||||
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
|
||||
|
||||
const uploaded = await putPdfFileServerSide(
|
||||
{
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(newBytes),
|
||||
},
|
||||
envelopeItem.documentData.initialData ?? undefined,
|
||||
);
|
||||
|
||||
// Copy the persisted bytes reference (S3 key or BYTES_64 payload) onto the
|
||||
// existing DocumentData row in place. `envelopeItem.documentDataId` stays
|
||||
// put — see file-level docblock for the rationale.
|
||||
await prisma.documentData.update({
|
||||
where: { id: envelopeItem.documentDataId },
|
||||
data: {
|
||||
type: uploaded.documentData.type,
|
||||
data: uploaded.documentData.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whole-item idempotency probe: returns true only when every expected anchor
|
||||
* and stamp name is already present on the loaded PDF. Partial state is
|
||||
* treated as not-materialised — the whole item is rebuilt.
|
||||
*/
|
||||
const isAlreadyMaterialised = (pdfDoc: PDF, expectedAnchorNames: string[], expectedStampNames: string[]): boolean => {
|
||||
const form = pdfDoc.getForm();
|
||||
|
||||
if (!form) {
|
||||
return expectedAnchorNames.length === 0 && expectedStampNames.length === 0;
|
||||
}
|
||||
|
||||
for (const anchorName of expectedAnchorNames) {
|
||||
if (!form.getSignatureField(anchorName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (expectedStampNames.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const presentStampNames = new Set<string>();
|
||||
|
||||
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
|
||||
const page = pdfDoc.getPage(i);
|
||||
|
||||
if (!page) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const stamp of page.getStampAnnotations()) {
|
||||
presentStampNames.add(stamp.stampName);
|
||||
}
|
||||
}
|
||||
|
||||
return expectedStampNames.every((name) => presentStampNames.has(name));
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { bytesToHex, utf8ToBytes } from '@noble/ciphers/utils';
|
||||
import { sha1 } from '@noble/hashes/legacy';
|
||||
|
||||
/**
|
||||
* Deterministic PDF object names for CSC TSP signing.
|
||||
*
|
||||
* Materialise-time and sign-time both derive these from the same
|
||||
* `(recipient, item [, page])` tuple — they MUST agree byte-for-byte.
|
||||
*
|
||||
* Output is opaque: SHA-1(label) hex-encoded uppercase (40 chars). The PDF
|
||||
* persists only the hex serial so recipient / envelope-item IDs never leak
|
||||
* into the document.
|
||||
*/
|
||||
|
||||
const hashToOpaqueSerial = (label: string): string => bytesToHex(sha1(utf8ToBytes(label))).toUpperCase();
|
||||
|
||||
/** AcroForm signature-field name (TSP anchor) for a recipient + envelope item. */
|
||||
export const buildTspAnchorName = (recipientId: number, envelopeItemId: string): string =>
|
||||
hashToOpaqueSerial(`recipient:${recipientId}|item:${envelopeItemId}`);
|
||||
|
||||
/** `/Stamp` annotation name for a recipient + envelope item on a specific page. */
|
||||
export const buildTspStampName = (recipientId: number, envelopeItemId: string, pageNumber: number): string =>
|
||||
hashToOpaqueSerial(`recipient:${recipientId}|item:${envelopeItemId}|page:${pageNumber}`);
|
||||
@@ -0,0 +1,248 @@
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { TCscSessionItems } from '@documenso/lib/types/csc-session';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '@documenso/lib/types/document-audit-logs';
|
||||
import { isTspEnvelope } from '@documenso/lib/types/signature-level';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { getFileServerSide } from '@documenso/lib/universal/upload/get-file.server';
|
||||
import { putPdfFileServerSide } from '@documenso/lib/universal/upload/put-file.server';
|
||||
import { createDocumentAuditLogData } from '@documenso/lib/utils/document-audit-logs';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
import { PDF } from '@libpdf/core';
|
||||
|
||||
import { type CscDigest, policyToLibpdfSignerAlgo } from './algorithm-resolver';
|
||||
import { decodeCscCertChain } from './cert-chain';
|
||||
import { loadCscCredential } from './credential';
|
||||
import { buildTspAnchorName, buildTspStampName } from './pdf-names';
|
||||
import { renderRecipientOverlay } from './render-overlay';
|
||||
import { upsertCscSession } from './sign-session';
|
||||
import { CscCaptureSigner } from './signers/capture-signer';
|
||||
|
||||
/**
|
||||
* CSC TSP prep-phase orchestrator.
|
||||
*
|
||||
* Per envelope item:
|
||||
*
|
||||
* 1. Render the recipient's overlay into the materialised PDF in memory.
|
||||
* 2. Persist the rendered bytes as a fresh `DocumentData` row — this is the
|
||||
* immutable byte-source the sign pass will load. Pinning the rendered PDF
|
||||
* (rather than re-rendering at sign time) eliminates the determinism risk
|
||||
* of running Konva twice across the OAuth round-trip.
|
||||
* 3. Reload `pdfDoc` from the persisted bytes and dry-run `pdf.sign` with
|
||||
* `CscCaptureSigner` to derive the `signedAttrs` digest — captured over
|
||||
* the same bytes the sign pass will load.
|
||||
*
|
||||
* The resulting `{ envelopeItemId, documentDataId, hashB64, ordinal }` tuples
|
||||
* are stored on `CscSession.itemsJson`. `documentDataId` pins the orphan
|
||||
* rendered row, not `envelopeItem.documentDataId` — the latter stays stable
|
||||
* (in-place data updates only, mirroring the materialise pattern).
|
||||
*
|
||||
* Sequential per item — PDF parse + libpdf sign is CPU-heavy and per-recipient
|
||||
* concurrency is wasted on a single Node event loop.
|
||||
*/
|
||||
|
||||
export type PrepareCscRecipientSigningOptions = {
|
||||
/** Recipient token from `/sign/{token}` URL. */
|
||||
recipientToken: string;
|
||||
/** Forwarded for audit log attribution. */
|
||||
requestMetadata?: RequestMetadata;
|
||||
};
|
||||
|
||||
export type PrepareCscRecipientSigningResult = {
|
||||
status: 'REDIRECT';
|
||||
redirectUrl: string;
|
||||
};
|
||||
|
||||
export const prepareCscRecipientSigning = async (
|
||||
opts: PrepareCscRecipientSigningOptions,
|
||||
): Promise<PrepareCscRecipientSigningResult> => {
|
||||
const { recipientToken, requestMetadata } = opts;
|
||||
|
||||
const recipient = await prisma.recipient
|
||||
.findFirst({
|
||||
where: { token: recipientToken },
|
||||
// `signature` must be eager-loaded — `renderRecipientOverlay` runs the
|
||||
// field renderer in `export` mode, which throws `MISSING_SIGNATURE` for
|
||||
// any inserted SIGNATURE field without signature data. Mirrors the
|
||||
// include pattern in `seal-document.handler.ts`.
|
||||
include: { fields: { include: { signature: true } } },
|
||||
})
|
||||
.catch(() => null);
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Recipient with token "${recipientToken}" not found.`,
|
||||
});
|
||||
}
|
||||
|
||||
const envelope = await prisma.envelope.findUniqueOrThrow({
|
||||
where: { id: recipient.envelopeId },
|
||||
include: {
|
||||
envelopeItems: {
|
||||
include: {
|
||||
documentData: true,
|
||||
},
|
||||
},
|
||||
recipients: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isTspEnvelope(envelope)) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'prepareCscRecipientSigning called for a non-TSP envelope.',
|
||||
});
|
||||
}
|
||||
|
||||
const credential = await loadCscCredential(recipient.id);
|
||||
|
||||
if (!credential) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'CSC credential missing — service-scope OAuth must complete first.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!credential.certCache) {
|
||||
throw new AppError(AppErrorCode.CSC_CERT_INVALID, {
|
||||
message: 'CSC credential has no persisted certificate chain.',
|
||||
});
|
||||
}
|
||||
|
||||
if (credential.keyLenBits === null) {
|
||||
throw new AppError(AppErrorCode.CSC_ALGORITHM_REFUSED, {
|
||||
message: 'CSC credential omits persisted keyLenBits — service-scope OAuth must re-run.',
|
||||
});
|
||||
}
|
||||
|
||||
const chain = decodeCscCertChain(credential.certCache);
|
||||
|
||||
const algo = policyToLibpdfSignerAlgo({
|
||||
keyType: credential.keyType as 'RSA' | 'ECDSA',
|
||||
digestAlgorithm: credential.digestAlgorithm as CscDigest,
|
||||
signAlgoOid: credential.signatureAlgorithm,
|
||||
keyLenBits: credential.keyLenBits,
|
||||
// `policyToLibpdfSignerAlgo` does not read `hashAlgoOid`; passing empty
|
||||
// string keeps the synthetic policy type-correct without re-derivation.
|
||||
hashAlgoOid: '',
|
||||
});
|
||||
|
||||
// Pin a single signingTime for every per-item capture so the embed pass
|
||||
// re-derives byte-identical signedAttrs digests.
|
||||
const signingTime = new Date();
|
||||
|
||||
const items: TCscSessionItems = [];
|
||||
|
||||
for (const envelopeItem of envelope.envelopeItems) {
|
||||
const recipientFieldsOnItem = recipient.fields.filter((field) => field.envelopeItemId === envelopeItem.id);
|
||||
|
||||
const pagesWithFields = new Set<number>();
|
||||
|
||||
for (const field of recipientFieldsOnItem) {
|
||||
pagesWithFields.add(field.page);
|
||||
}
|
||||
|
||||
const bytes = await getFileServerSide(envelopeItem.documentData);
|
||||
const pdfDoc = await PDF.load(bytes);
|
||||
|
||||
for (const pageNumber of pagesWithFields) {
|
||||
const fieldsOnPage: FieldWithSignature[] = recipientFieldsOnItem.filter((field) => field.page === pageNumber);
|
||||
|
||||
await renderRecipientOverlay({
|
||||
pdfDoc,
|
||||
stampName: buildTspStampName(recipient.id, envelopeItem.id, pageNumber),
|
||||
pageNumber,
|
||||
fields: fieldsOnPage,
|
||||
});
|
||||
}
|
||||
|
||||
// Persist the rendered PDF as an orphan `DocumentData` row before the
|
||||
// capture pass so sign-time can load byte-identical input — eliminates
|
||||
// the determinism risk of running Konva again after the OAuth round-trip.
|
||||
const renderedBytes = await pdfDoc.save({ incremental: true });
|
||||
|
||||
const fileName = envelope.title.endsWith('.pdf') ? envelope.title : `${envelope.title || 'envelope'}.pdf`;
|
||||
|
||||
const renderedUpload = await putPdfFileServerSide(
|
||||
{
|
||||
name: fileName,
|
||||
type: 'application/pdf',
|
||||
arrayBuffer: async () => Promise.resolve(renderedBytes),
|
||||
},
|
||||
envelopeItem.documentData.initialData ?? undefined,
|
||||
);
|
||||
|
||||
// Reload from the persisted bytes so the capture pass operates on the
|
||||
// exact same bytes the sign pass will fetch from storage. Skipping the
|
||||
// reload would compute the digest over an in-memory incremental update
|
||||
// that diverges from what `PDF.load(renderedBytes)` produces.
|
||||
const capturePdfDoc = await PDF.load(renderedBytes);
|
||||
|
||||
const captureSigner = new CscCaptureSigner({
|
||||
certificate: chain[0],
|
||||
certificateChain: chain.slice(1),
|
||||
algo,
|
||||
});
|
||||
|
||||
const anchorName = buildTspAnchorName(recipient.id, envelopeItem.id);
|
||||
|
||||
// Capture at B-B even though the eventual embed pass is B-T. The B-T
|
||||
// signature timestamp is a CMS *unsigned* attribute, added by libpdf
|
||||
// after `signer.sign()` runs over the signed-attrs digest — so B-B and
|
||||
// B-T produce byte-identical signed-attrs for the same `(signer,
|
||||
// documentHash, digestAlgorithm, signingTime)` tuple. See the matching
|
||||
// note in `execute-tsp-sign.ts`.
|
||||
await capturePdfDoc.sign({
|
||||
signer: captureSigner,
|
||||
fieldName: anchorName,
|
||||
signingTime,
|
||||
level: 'B-B',
|
||||
digestAlgorithm: algo.digestAlgorithm,
|
||||
});
|
||||
|
||||
if (captureSigner.capturedDigest === null) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CscCaptureSigner was not invoked by pdf.sign during prep.',
|
||||
});
|
||||
}
|
||||
|
||||
items.push({
|
||||
envelopeItemId: envelopeItem.id,
|
||||
documentDataId: renderedUpload.documentData.id,
|
||||
hashB64: Buffer.from(captureSigner.capturedDigest).toString('base64'),
|
||||
ordinal: items.length,
|
||||
});
|
||||
}
|
||||
|
||||
const session = await upsertCscSession({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: envelope.id,
|
||||
signingTime,
|
||||
items,
|
||||
});
|
||||
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_RECIPIENT_CSC_SIGN_REQUESTED,
|
||||
envelopeId: envelope.id,
|
||||
user: { name: recipient.name, email: recipient.email },
|
||||
requestMetadata,
|
||||
data: {
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
recipientId: recipient.id,
|
||||
recipientRole: recipient.role,
|
||||
providerId: credential.providerId,
|
||||
credentialId: credential.credentialId,
|
||||
sessionId: session.id,
|
||||
numSignatures: items.length,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const redirectUrl = `${NEXT_PUBLIC_WEBAPP_URL()}/api/csc/oauth/authorize?scope=credential&session=${session.id}`;
|
||||
|
||||
return {
|
||||
status: 'REDIRECT',
|
||||
redirectUrl,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,162 @@
|
||||
import { AnnotationFlags, ops, PDF, PdfArray, PdfDict, PdfName, PdfNumber } from '@libpdf/core';
|
||||
|
||||
// `Operator` is declared in `@libpdf/core` but not exported. Derive it from
|
||||
// `ops.pushGraphicsState`'s return type instead of importing.
|
||||
type LibpdfOperator = ReturnType<typeof ops.pushGraphicsState>;
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { insertFieldInPDFV2 } from '@documenso/lib/server-only/pdf/insert-field-in-pdf-v2';
|
||||
import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature';
|
||||
|
||||
/**
|
||||
* CSC TSP recipient overlay renderer.
|
||||
*
|
||||
* Writes a recipient's per-page field values into the pre-allocated
|
||||
* `/Stamp` annotation's normal appearance (`/AP /N`), reusing the Konva
|
||||
* overlay generator that powers the SES path.
|
||||
*
|
||||
* SES uses `page.drawPage(embeddedPage)` to paint directly onto the page
|
||||
* content stream. For TSP that would create a new page object in the
|
||||
* incremental update and invalidate prior recipients' `/ByteRange`. Routing
|
||||
* the same embedded FormXObject through a stamp's appearance keeps the page
|
||||
* dict untouched while reusing the embed pipeline `drawPage` does.
|
||||
*
|
||||
* The appearance stream mirrors `drawPage`'s `x=0, y=0, scale=1, no-rotate`
|
||||
* branch: a single `concatMatrix(1, 0, 0, 1, -box.x, -box.y)` compensates
|
||||
* for any non-origin MediaBox on the overlay PDF before `paintXObject`. The
|
||||
* stamp's `/Rect` and the appearance `/BBox` both span `[0, 0, page.width,
|
||||
* page.height]`, so the PDF reader maps content 1:1 and page rotation
|
||||
* applies at the page level (not inside the appearance).
|
||||
*/
|
||||
|
||||
export type RenderRecipientOverlayOptions = {
|
||||
/** The loaded PDF the stamp lives on. */
|
||||
pdfDoc: PDF;
|
||||
/** Stamp name from `buildTspStampName(recipientId, envelopeItemId, pageNumber)`. */
|
||||
stampName: string;
|
||||
/** 1-based page number. */
|
||||
pageNumber: number;
|
||||
/** Recipient's fields for THIS page only. */
|
||||
fields: FieldWithSignature[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Render `fields` into the pre-allocated `/Stamp` annotation named `stampName`
|
||||
* on `pageNumber`. Mutates `pdfDoc` in place.
|
||||
*
|
||||
* Throws when the named stamp can't be located — every call site must have
|
||||
* materialised the stamp first via `materializeTspAnchorsForEnvelope`.
|
||||
*/
|
||||
export const renderRecipientOverlay = async ({
|
||||
pdfDoc,
|
||||
stampName,
|
||||
pageNumber,
|
||||
fields,
|
||||
}: RenderRecipientOverlayOptions): Promise<void> => {
|
||||
const page = pdfDoc.getPage(pageNumber - 1);
|
||||
|
||||
if (!page) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `Page ${pageNumber} not found on PDF.`,
|
||||
});
|
||||
}
|
||||
|
||||
const stamp = page.getStampAnnotations().find((annotation) => annotation.stampName === stampName);
|
||||
|
||||
if (!stamp) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `TSP stamp ${stampName} not found on page ${pageNumber}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const overlayBytes = await insertFieldInPDFV2({
|
||||
pageWidth: page.width,
|
||||
pageHeight: page.height,
|
||||
fields,
|
||||
});
|
||||
|
||||
const overlayDoc = await PDF.load(overlayBytes);
|
||||
const embedded = await pdfDoc.embedPage(overlayDoc, 0);
|
||||
|
||||
// Bind the embedded page under a local XObject name in the appearance's
|
||||
// own /Resources. Appearance streams are scoped — they can't see the
|
||||
// parent page's resource dict.
|
||||
const xobjectName = 'X0';
|
||||
|
||||
// Mirror `PDFPage.drawPage`'s no-rotation, no-scale branch:
|
||||
// translateX = x - embedded.box.x * scaleX (x = 0, scaleX = 1)
|
||||
// translateY = y - embedded.box.y * scaleY (y = 0, scaleY = 1)
|
||||
// concatMatrix(scaleX, 0, 0, scaleY, translateX, translateY)
|
||||
// Identity matrix when the overlay PDF has an origin-aligned MediaBox;
|
||||
// a translate-only shift otherwise. No-op cost is negligible.
|
||||
const operators: LibpdfOperator[] = [
|
||||
ops.pushGraphicsState(),
|
||||
ops.concatMatrix(1, 0, 0, 1, -embedded.box.x, -embedded.box.y),
|
||||
ops.paintXObject(xobjectName),
|
||||
ops.popGraphicsState(),
|
||||
];
|
||||
|
||||
const contentBytes = serializeOperators(operators);
|
||||
|
||||
const appearanceRef = pdfDoc.createStream(
|
||||
{
|
||||
Type: PdfName.of('XObject'),
|
||||
Subtype: PdfName.of('Form'),
|
||||
FormType: PdfNumber.of(1),
|
||||
BBox: new PdfArray([PdfNumber.of(0), PdfNumber.of(0), PdfNumber.of(page.width), PdfNumber.of(page.height)]),
|
||||
Resources: PdfDict.of({
|
||||
XObject: PdfDict.of({
|
||||
[xobjectName]: embedded.ref,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
contentBytes,
|
||||
);
|
||||
|
||||
// Direct dict write — bypasses `PDFAnnotation.setNormalAppearance`, which
|
||||
// (a) re-registers the stream and (b) has a no-op branch when `/AP` is
|
||||
// absent on the annotation. See `node_modules/@libpdf/core/dist/index.mjs:
|
||||
// 4347-4357`. The PDF reader and libpdf's `getAppearance` (index.mjs:4337)
|
||||
// both follow refs transparently, so `/AP -> { N: <ref> }` is valid.
|
||||
stamp.dict.set('AP', PdfDict.of({ N: appearanceRef }));
|
||||
|
||||
stamp.setFlag(AnnotationFlags.Print, true);
|
||||
stamp.setFlag(AnnotationFlags.ReadOnly, true);
|
||||
stamp.setFlag(AnnotationFlags.Locked, true);
|
||||
stamp.setFlag(AnnotationFlags.LockedContents, true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Serialize a content-stream operator sequence into a single byte buffer,
|
||||
* newline-separated. Mirrors libpdf's internal `serializeOperators` (not
|
||||
* exported from `@libpdf/core`); each `Operator.toBytes()` returns one
|
||||
* operator's `operand1 operand2 ... op` slice.
|
||||
*/
|
||||
const serializeOperators = (operators: LibpdfOperator[]): Uint8Array => {
|
||||
if (operators.length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
|
||||
const chunks = operators.map((operator) => operator.toBytes());
|
||||
|
||||
let totalLength = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
totalLength += chunk.length + 1; // +1 for trailing newline
|
||||
}
|
||||
|
||||
const out = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
|
||||
offset += chunk.length;
|
||||
|
||||
out[offset] = 0x0a;
|
||||
|
||||
offset += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { type TCscSessionItems, ZCscSessionItemsSchema } from '@documenso/lib/types/csc-session';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { Prisma } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* DB helpers for `CscSession` — the per-recipient transient row that bridges
|
||||
* prep, the credential-scope OAuth round-trip, and the sync sign mutation.
|
||||
*
|
||||
* Four operations cover the spec's lifecycle:
|
||||
*
|
||||
* - {@link upsertCscSession} — prep time; clears any prior SAD by writing
|
||||
* `encryptedSad = null` so a re-clicked Sign starts fresh.
|
||||
* - {@link updateCscSessionWithSad} — credential-scope callback; sets the
|
||||
* SAD + its TSP-asserted expiry.
|
||||
* - {@link loadCscSession} — authorize route, signing-page loader, sync
|
||||
* mutation. Returns null on missing (cookie referenced a deleted session).
|
||||
* - {@link consumeCscSession} — sync mutation success path; single-use delete
|
||||
* returning the consumed row so the caller can use its data post-deletion.
|
||||
*
|
||||
* `itemsJson` is parsed through `ZCscSessionItemsSchema` on every read so the
|
||||
* caller works with typed {@link TCscSessionItems}.
|
||||
*/
|
||||
|
||||
export type CscSessionRow = {
|
||||
id: string;
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
signingTime: Date;
|
||||
items: TCscSessionItems;
|
||||
encryptedSad: Uint8Array | null;
|
||||
sadExpiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
};
|
||||
|
||||
type UpsertCscSessionInput = {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
signingTime: Date;
|
||||
items: TCscSessionItems;
|
||||
};
|
||||
|
||||
/**
|
||||
* Create or refresh the per-recipient session row at prep time. The recipient
|
||||
* has at most one in-flight session (`@@unique([recipientId])`); re-clicking
|
||||
* Sign overwrites prior `itemsJson` + clears `encryptedSad` / `sadExpiresAt`
|
||||
* so the next credential-scope callback starts from a clean SAD slot.
|
||||
*/
|
||||
export const upsertCscSession = async (input: UpsertCscSessionInput): Promise<CscSessionRow> => {
|
||||
const { recipientId, envelopeId, signingTime, items } = input;
|
||||
|
||||
const row = await prisma.cscSession.upsert({
|
||||
where: { recipientId },
|
||||
create: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
signingTime,
|
||||
itemsJson: items,
|
||||
encryptedSad: null,
|
||||
sadExpiresAt: null,
|
||||
},
|
||||
update: {
|
||||
envelopeId,
|
||||
signingTime,
|
||||
itemsJson: items,
|
||||
encryptedSad: null,
|
||||
sadExpiresAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
return toCscSessionRow(row);
|
||||
};
|
||||
|
||||
type UpdateCscSessionWithSadInput = {
|
||||
sessionId: string;
|
||||
encryptedSad: Uint8Array;
|
||||
sadExpiresAt: Date;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stamp the credential-scope SAD onto an existing session at the OAuth
|
||||
* callback. Throws when the session id was already consumed or never existed
|
||||
* — that's a flow-state bug the caller must surface, not silently skip.
|
||||
*/
|
||||
export const updateCscSessionWithSad = async (input: UpdateCscSessionWithSadInput): Promise<CscSessionRow> => {
|
||||
const { sessionId, encryptedSad, sadExpiresAt } = input;
|
||||
|
||||
try {
|
||||
const row = await prisma.cscSession.update({
|
||||
where: {
|
||||
id: sessionId,
|
||||
},
|
||||
data: {
|
||||
encryptedSad: Buffer.from(encryptedSad),
|
||||
sadExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
return toCscSessionRow(row);
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `CSC session "${sessionId}" not found at SAD attach time.`,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch a session by id. Returns `null` when the row is absent — callers MUST
|
||||
* handle the missing case (cookie outliving the row is a normal terminal
|
||||
* outcome, not an error).
|
||||
*/
|
||||
export const loadCscSession = async (sessionId: string): Promise<CscSessionRow | null> => {
|
||||
const row = await prisma.cscSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
|
||||
return row ? toCscSessionRow(row) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Atomically delete the session row and return its parsed contents. Used by
|
||||
* the sync mutation's success path so the caller still has the session data
|
||||
* for post-sign side effects (audit log, webhook payloads).
|
||||
*
|
||||
* Throws `NOT_FOUND` when the row is already gone — semantically distinct
|
||||
* from {@link loadCscSession}'s nullable return because consume is the
|
||||
* success-path single-use closer; a missing row at that point means another
|
||||
* branch raced to consume and the caller should not double-count.
|
||||
*/
|
||||
export const consumeCscSession = async (sessionId: string, tx?: Prisma.TransactionClient): Promise<CscSessionRow> => {
|
||||
const client = tx ?? prisma;
|
||||
|
||||
try {
|
||||
const row = await client.cscSession.delete({
|
||||
where: { id: sessionId },
|
||||
});
|
||||
|
||||
return toCscSessionRow(row);
|
||||
} catch (err) {
|
||||
if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2025') {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: `CSC session "${sessionId}" already consumed or never existed.`,
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Project a raw Prisma `CscSession` into the helper's parsed shape. Throws
|
||||
* on `itemsJson` parse failure — that's a data-integrity issue, not a
|
||||
* recoverable runtime case.
|
||||
*/
|
||||
const toCscSessionRow = (row: {
|
||||
id: string;
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
signingTime: Date;
|
||||
itemsJson: Prisma.JsonValue;
|
||||
encryptedSad: Uint8Array | null;
|
||||
sadExpiresAt: Date | null;
|
||||
createdAt: Date;
|
||||
}): CscSessionRow => {
|
||||
const items = ZCscSessionItemsSchema.parse(row.itemsJson);
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
recipientId: row.recipientId,
|
||||
envelopeId: row.envelopeId,
|
||||
signingTime: row.signingTime,
|
||||
items,
|
||||
encryptedSad: row.encryptedSad,
|
||||
sadExpiresAt: row.sadExpiresAt,
|
||||
createdAt: row.createdAt,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* CSC dry-run capture signer.
|
||||
*
|
||||
* Libpdf's signing flow expects an inline signer that hashes the
|
||||
* `signedAttrs` bytes and returns a CMS signature. For the CSC §11.9
|
||||
* `signatures/signHash` contract the actual signature is produced
|
||||
* remotely by the TSP, so a single libpdf sign cycle has to be split
|
||||
* into two passes:
|
||||
*
|
||||
* 1. Dry-run — drive `pdf.sign()` with this capture signer to derive
|
||||
* the `signedAttrs` digest libpdf would otherwise sign. The
|
||||
* resulting PDF is discarded; only `capturedDigest` matters.
|
||||
* 2. Embed pass — the `CscFifoSigner` re-runs `pdf.sign()` and feeds
|
||||
* the TSP-produced signature bytes back into the same byte slots.
|
||||
*
|
||||
* The placeholder bytes returned from `sign()` are sized to the
|
||||
* chosen algorithm so libpdf's downstream CMS construction is not
|
||||
* surprised by an unexpectedly short signature.
|
||||
*/
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { Signer } from '@libpdf/core';
|
||||
import { sha256, sha384, sha512 } from '@noble/hashes/sha2';
|
||||
|
||||
import type { LibpdfSignerAlgo } from '../algorithm-resolver';
|
||||
|
||||
type DigestAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512';
|
||||
|
||||
type KeyType = 'RSA' | 'EC';
|
||||
|
||||
type SignatureAlgorithm = 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
|
||||
|
||||
export type CscCaptureSignerOptions = {
|
||||
certificate: Uint8Array;
|
||||
certificateChain?: Uint8Array[];
|
||||
algo: LibpdfSignerAlgo;
|
||||
};
|
||||
|
||||
export class CscCaptureSigner implements Signer {
|
||||
readonly certificate: Uint8Array;
|
||||
readonly certificateChain?: Uint8Array[];
|
||||
readonly keyType: KeyType;
|
||||
readonly signatureAlgorithm: SignatureAlgorithm;
|
||||
private readonly algo: LibpdfSignerAlgo;
|
||||
|
||||
/** Populated by `sign()`. `null` until libpdf calls into the signer. */
|
||||
capturedDigest: Uint8Array | null = null;
|
||||
|
||||
constructor(options: CscCaptureSignerOptions) {
|
||||
this.certificate = options.certificate;
|
||||
this.certificateChain = options.certificateChain;
|
||||
this.keyType = options.algo.keyType;
|
||||
this.signatureAlgorithm = options.algo.signatureAlgorithm;
|
||||
this.algo = options.algo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash `data` with `algorithm` to derive the `signedAttrs` digest libpdf
|
||||
* would normally sign, stash it on `capturedDigest`, then return a
|
||||
* placeholder buffer sized to the chosen key so libpdf's CMS scaffolding
|
||||
* accepts it. The placeholder bytes are never inspected — the resulting
|
||||
* PDF is discarded after the digest is read.
|
||||
*/
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: intentional
|
||||
async sign(data: Uint8Array, algorithm: DigestAlgorithm): Promise<Uint8Array> {
|
||||
if (this.capturedDigest !== null) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CscCaptureSigner.sign() called more than once — capture signers are single-use.',
|
||||
});
|
||||
}
|
||||
|
||||
this.capturedDigest = hashData(data, algorithm);
|
||||
|
||||
return new Uint8Array(placeholderSize(this.algo));
|
||||
}
|
||||
}
|
||||
|
||||
const hashData = (data: Uint8Array, algorithm: DigestAlgorithm): Uint8Array => {
|
||||
if (algorithm === 'SHA-256') {
|
||||
return sha256(data);
|
||||
}
|
||||
|
||||
if (algorithm === 'SHA-384') {
|
||||
return sha384(data);
|
||||
}
|
||||
|
||||
if (algorithm === 'SHA-512') {
|
||||
return sha512(data);
|
||||
}
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `CscCaptureSigner.sign() called with unsupported digest algorithm '${String(algorithm)}'.`,
|
||||
});
|
||||
};
|
||||
|
||||
const placeholderSize = (algo: LibpdfSignerAlgo): number => {
|
||||
if (algo.keyType === 'RSA') {
|
||||
// RSA signature length === modulus length in bytes.
|
||||
if (algo.keyLenBits >= 4096) {
|
||||
return 512;
|
||||
}
|
||||
|
||||
if (algo.keyLenBits >= 3072) {
|
||||
return 384;
|
||||
}
|
||||
|
||||
return 256;
|
||||
}
|
||||
|
||||
// ECDSA DER-encoded SEQUENCE { INTEGER r, INTEGER s }. Upper bounds:
|
||||
// P-256 ≈ 72 bytes, P-384 ≈ 104, P-521 ≈ 139. The dry-run PDF is
|
||||
// discarded — exact size is informational, not load-bearing.
|
||||
if (algo.keyLenBits >= 512) {
|
||||
return 139;
|
||||
}
|
||||
|
||||
if (algo.keyLenBits >= 384) {
|
||||
return 104;
|
||||
}
|
||||
|
||||
return 72;
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* CSC embed-pass FIFO signer.
|
||||
*
|
||||
* `signatures/signHash` (CSC §11.9) returns one signature per submitted
|
||||
* hash, in the same position-bound order as the request `hash[]` array.
|
||||
* The embed pass re-runs `pdf.sign()` once per anchor in that same order,
|
||||
* so a FIFO queue of signature bytes — popped on each `sign()` call —
|
||||
* is sufficient to feed libpdf without any per-anchor binding metadata.
|
||||
*/
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { Signer } from '@libpdf/core';
|
||||
|
||||
import type { LibpdfSignerAlgo } from '../algorithm-resolver';
|
||||
|
||||
type DigestAlgorithm = 'SHA-256' | 'SHA-384' | 'SHA-512';
|
||||
|
||||
type KeyType = 'RSA' | 'EC';
|
||||
|
||||
type SignatureAlgorithm = 'RSASSA-PKCS1-v1_5' | 'RSA-PSS' | 'ECDSA';
|
||||
|
||||
export type CscFifoSignerOptions = {
|
||||
certificate: Uint8Array;
|
||||
certificateChain?: Uint8Array[];
|
||||
algo: LibpdfSignerAlgo;
|
||||
/** Base64-decoded raw signature bytes in the order produced by `signatures/signHash`. */
|
||||
signatures: Uint8Array[];
|
||||
};
|
||||
|
||||
export class CscFifoSigner implements Signer {
|
||||
readonly certificate: Uint8Array;
|
||||
readonly certificateChain?: Uint8Array[];
|
||||
readonly keyType: KeyType;
|
||||
readonly signatureAlgorithm: SignatureAlgorithm;
|
||||
private readonly queue: Uint8Array[];
|
||||
|
||||
constructor(options: CscFifoSignerOptions) {
|
||||
this.certificate = options.certificate;
|
||||
this.certificateChain = options.certificateChain;
|
||||
this.keyType = options.algo.keyType;
|
||||
this.signatureAlgorithm = options.algo.signatureAlgorithm;
|
||||
this.queue = [...options.signatures];
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: intentional
|
||||
async sign(_data: Uint8Array, _algorithm: DigestAlgorithm): Promise<Uint8Array> {
|
||||
const next = this.queue.shift();
|
||||
|
||||
if (next === undefined) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'CSC FIFO signer exhausted — more sign() calls than queued signatures.',
|
||||
});
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { IS_INSTANCE_CSC_MODE, NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { assertLicensedFor } from '@documenso/lib/server-only/license/assert-licensed-for';
|
||||
import { requireEnv } from '@documenso/lib/utils/env';
|
||||
import type { OAuth2Client } from 'arctic';
|
||||
|
||||
import { cscInfo } from './client/info';
|
||||
import { createCscOAuthClient } from './client/oauth';
|
||||
import type { TCscInfoResponse } from './client/types';
|
||||
import { isEnvTsaConfigured } from './tsa-resolver';
|
||||
|
||||
/**
|
||||
* Lazily-built, globally-cached CSC transport.
|
||||
*
|
||||
* Boot-discovers `cscInfo` (§11.1) once, caches the OAuth base URL +
|
||||
* `signatures/timestamp` capability, and exposes a configured arctic
|
||||
* `OAuth2Client`. License + env + discovery are gated at construction so a
|
||||
* misconfigured instance fails at the first call site, not at sign time.
|
||||
*
|
||||
* Cached on `globalThis` so Hono routes and Remix loaders share one instance
|
||||
* across bundles (mirrors {@link LicenseClient}'s strategy).
|
||||
*
|
||||
* A failed build is **not** cached — the next caller retries. This keeps a
|
||||
* transient discovery hiccup from permanently breaking the transport while
|
||||
* still amortising the success path to one round-trip per process.
|
||||
*/
|
||||
|
||||
const DISCOVERY_TIMEOUT_MS = 10_000;
|
||||
|
||||
const CSC_TIMESTAMP_METHOD = 'signatures/timestamp';
|
||||
|
||||
export type CscTransport = {
|
||||
/** Service base URI from `NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL`. */
|
||||
serviceBaseUrl: string;
|
||||
/** OAuth base URI from `info.oauth2` (§11.1). MAY differ from `serviceBaseUrl`. */
|
||||
oauthBaseUrl: string;
|
||||
/** Pre-configured arctic client bound to the TSP's OAuth registration. */
|
||||
oauthClient: OAuth2Client;
|
||||
/**
|
||||
* Documenso's callback URL registered with the TSP. Derived from
|
||||
* `NEXT_PUBLIC_WEBAPP_URL` and the fixed `/api/csc/oauth/callback` mount —
|
||||
* mirrors `packages/auth/server/config.ts` for the sign-in OAuth providers.
|
||||
* Operators must register this exact URL with the TSP.
|
||||
*/
|
||||
oauthRedirectUri: string;
|
||||
/** True when the TSP advertises `signatures/timestamp` in `info.methods`. */
|
||||
supportsTimestamp: boolean;
|
||||
/** Raw discovery response, exposed for callers needing other fields (`name`, `region`, `lang`). */
|
||||
info: TCscInfoResponse;
|
||||
};
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __documenso_csc_transport__: CscTransport | undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var __documenso_csc_transport_promise__: Promise<CscTransport> | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current CSC transport, building + caching it on first call.
|
||||
*
|
||||
* Throws:
|
||||
* - `NOT_SETUP` — instance is not in CSC mode, or a required env var is unset.
|
||||
* - `CSC_UNLICENSED` — `instanceCscSigning` license flag missing.
|
||||
* - `CSC_PROVIDER_INFO_FAILED` — `info` discovery failed or response omits
|
||||
* the REQUIRED `oauth2` base URL.
|
||||
*
|
||||
* Safe to call concurrently — a second call during in-flight discovery
|
||||
* awaits the same promise instead of starting a duplicate request.
|
||||
*/
|
||||
export const getCscTransport = async (): Promise<CscTransport> => {
|
||||
if (globalThis.__documenso_csc_transport__) {
|
||||
return globalThis.__documenso_csc_transport__;
|
||||
}
|
||||
|
||||
if (!globalThis.__documenso_csc_transport_promise__) {
|
||||
globalThis.__documenso_csc_transport_promise__ = buildCscTransport()
|
||||
.then((transport) => {
|
||||
globalThis.__documenso_csc_transport__ = transport;
|
||||
return transport;
|
||||
})
|
||||
.finally(() => {
|
||||
globalThis.__documenso_csc_transport_promise__ = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
return await globalThis.__documenso_csc_transport_promise__;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop the cached transport. Intended for tests / operator-triggered reload
|
||||
* after a license-key swap. Next {@link getCscTransport} call re-runs the
|
||||
* full build pipeline (license + env + discovery).
|
||||
*/
|
||||
export const resetCscTransport = (): void => {
|
||||
globalThis.__documenso_csc_transport__ = undefined;
|
||||
globalThis.__documenso_csc_transport_promise__ = undefined;
|
||||
};
|
||||
|
||||
const buildCscTransport = async (): Promise<CscTransport> => {
|
||||
if (!IS_INSTANCE_CSC_MODE()) {
|
||||
throw new AppError(AppErrorCode.NOT_SETUP, {
|
||||
message: 'CSC transport requested but NEXT_PRIVATE_SIGNING_TRANSPORT is not "csc".',
|
||||
});
|
||||
}
|
||||
|
||||
await assertLicensedFor('instanceCscSigning', { errorCode: AppErrorCode.CSC_UNLICENSED });
|
||||
|
||||
const serviceBaseUrl = requireEnv('NEXT_PRIVATE_SIGNING_CSC_PROVIDER_BASE_URL');
|
||||
const clientId = requireEnv('NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_ID');
|
||||
const clientSecret = requireEnv('NEXT_PRIVATE_SIGNING_CSC_OAUTH_CLIENT_SECRET');
|
||||
const oauthRedirectUri = `${NEXT_PUBLIC_WEBAPP_URL()}/api/csc/oauth/callback`;
|
||||
|
||||
const oauthClient = createCscOAuthClient({ clientId, clientSecret, redirectUri: oauthRedirectUri });
|
||||
|
||||
const info = await cscInfo({
|
||||
baseUrl: serviceBaseUrl,
|
||||
signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!info.oauth2) {
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_INFO_FAILED, {
|
||||
message:
|
||||
'CSC TSP info response omits the required `oauth2` base URL. CSC QES V1 only supports OAuth-based authorization (§8.3) — non-OAuth TSPs are not compatible.',
|
||||
});
|
||||
}
|
||||
|
||||
const supportsTimestamp = info.methods.includes(CSC_TIMESTAMP_METHOD);
|
||||
|
||||
// Boot-time TSA invariant: `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is
|
||||
// required unconditionally in CSC mode. Sign-time B-T can use the TSP's
|
||||
// own `signatures/timestamp` endpoint when advertised, but seal-time
|
||||
// B-LTA archival is env-only by design (operators should pin a dedicated
|
||||
// qualified archival TSA — see `resolveCscSealTimeTsa`). Without env, an
|
||||
// envelope would sign successfully and then hang in
|
||||
// WAITING_FOR_SIGNATURE_COMPLETION when the seal job throws. Catch the
|
||||
// misconfiguration at boot instead so the instance refuses to start.
|
||||
if (!isEnvTsaConfigured()) {
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
|
||||
message:
|
||||
'NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY is unset. AES/QES envelopes require a TSA for B-LTA archival at seal time regardless of whether the CSC TSP advertises signatures/timestamp for B-T sign-time. Configure NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY.',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
serviceBaseUrl,
|
||||
oauthBaseUrl: info.oauth2,
|
||||
oauthClient,
|
||||
oauthRedirectUri,
|
||||
supportsTimestamp,
|
||||
info,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY } from '@documenso/lib/constants/app';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { HttpTimestampAuthority, type TimestampAuthority } from '@libpdf/core';
|
||||
|
||||
import type { CscTransport } from './transport';
|
||||
import { CscTspTimestampAuthority } from './tsp-timestamp-authority';
|
||||
|
||||
/**
|
||||
* Two-phase TSA resolution for the CSC transport.
|
||||
*
|
||||
* Phase 1 — sign time (PAdES B-T, per recipient signature).
|
||||
* Each recipient's CMS gets a signature timestamp embedded as an unsigned
|
||||
* attribute. {@link resolveCscSignTimeTsa} returns a libpdf-shaped
|
||||
* `TimestampAuthority` bound to either the TSP's `signatures/timestamp`
|
||||
* endpoint (authorised with the recipient's own service-scope bearer) or
|
||||
* the operator's env-configured RFC 3161 TSA, whichever is configured.
|
||||
* TSP wins precedence so a TSP-supplied TSA is the default when the TSP
|
||||
* advertises the method.
|
||||
*
|
||||
* Phase 2 — seal time (PAdES B-LTA archival timestamp).
|
||||
* The seal-document job emits one `/DocTimeStamp` over the fully-signed
|
||||
* envelope. {@link resolveCscSealTimeTsa} returns the env-configured TSA
|
||||
* only — the archival anchor SHOULD be a dedicated qualified archival
|
||||
* TSA, independent of the per-recipient TSP. Using the TSP here would
|
||||
* couple archive longevity to a TSP that may rotate or revoke, and seal
|
||||
* time has no recipient context to carry a service-scope bearer anyway.
|
||||
*
|
||||
* Boot-time guard: {@link buildCscTransport} asserts
|
||||
* `NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY` is set unconditionally — seal
|
||||
* time always needs it, so making it env-or-fail at boot also satisfies
|
||||
* the sign-time fallback. The defensive throws inside the resolvers below
|
||||
* should be unreachable in practice.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build a libpdf `TimestampAuthority` for a recipient's B-T sign-time
|
||||
* signature timestamp.
|
||||
*
|
||||
* Precedence: TSP first, env fallback. Selection is made up-front based on
|
||||
* the boot-discovered transport capability — we don't try TSP then fall
|
||||
* through to env on a runtime error. If the chosen source fails at call
|
||||
* time, the recipient's sign attempt fails (operator's recourse is to
|
||||
* configure env, which then wins on the next sign).
|
||||
*
|
||||
* `serviceToken` is the decrypted, non-expired service-scope bearer for
|
||||
* the current recipient — used only when the TSP source is selected.
|
||||
*/
|
||||
export const resolveCscSignTimeTsa = (transport: CscTransport, serviceToken: string): TimestampAuthority => {
|
||||
if (transport.supportsTimestamp) {
|
||||
return new CscTspTimestampAuthority({ transport, serviceToken });
|
||||
}
|
||||
|
||||
const envUrls = parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY());
|
||||
|
||||
if (envUrls.length > 0) {
|
||||
return new HttpTimestampAuthority(envUrls[0]);
|
||||
}
|
||||
|
||||
// Boot-time guard in `buildCscTransport` should have rejected this
|
||||
// configuration before any recipient hit this code path.
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
|
||||
message:
|
||||
'CSC sign-time TSA unresolved: TSP does not advertise signatures/timestamp and NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY is unset. This should have been caught by the boot-time guard in buildCscTransport.',
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the seal-time archival TSA URLs (env only).
|
||||
*
|
||||
* Returns the parsed env list; the caller picks how to consume it (today
|
||||
* `finalize-tsp-completion.ts` uses the first URL).
|
||||
*/
|
||||
export const resolveCscSealTimeTsa = (): { urls: string[] } => {
|
||||
const envUrls = parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY());
|
||||
|
||||
if (envUrls.length === 0) {
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
|
||||
message:
|
||||
'CSC seal-time archival timestamps require NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY. This should have been caught by the boot-time guard in buildCscTransport — the env var is required at seal time even when the TSP advertises signatures/timestamp.',
|
||||
});
|
||||
}
|
||||
|
||||
return { urls: envUrls };
|
||||
};
|
||||
|
||||
/**
|
||||
* Cheap boot-time predicate — used by `buildCscTransport` to decide
|
||||
* whether the env TSA satisfies the "at least one source must be
|
||||
* configured" invariant. Keeping the env parsing in one place avoids
|
||||
* drift between the guard and the resolvers.
|
||||
*/
|
||||
export const isEnvTsaConfigured = (): boolean => {
|
||||
return parseTsaEnv(NEXT_PRIVATE_SIGNING_TIMESTAMP_AUTHORITY()).length > 0;
|
||||
};
|
||||
|
||||
const parseTsaEnv = (raw: string | undefined): string[] => {
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return raw
|
||||
.split(',')
|
||||
.map((url) => url.trim())
|
||||
.filter(Boolean);
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import type { DigestAlgorithm, TimestampAuthority } from '@libpdf/core';
|
||||
|
||||
import { hashOidForDigest } from './algorithm-resolver';
|
||||
import { cscTimestamp } from './client/signatures';
|
||||
import type { CscTransport } from './transport';
|
||||
|
||||
/**
|
||||
* libpdf {@link TimestampAuthority} backed by the CSC TSP's
|
||||
* `signatures/timestamp` endpoint (§11.10).
|
||||
*
|
||||
* Used only at sign time, per recipient, when {@link resolveCscSignTimeTsa}
|
||||
* selects the TSP source — that is, when the TSP advertises
|
||||
* `signatures/timestamp` in `info.methods`. The token wired in is the
|
||||
* current recipient's own service-scope bearer (the same one authorising
|
||||
* the `signatures/signHash` call alongside it), so the timestamp gets
|
||||
* attributed to the same identity that just authorised the signature.
|
||||
*
|
||||
* Seal-time archival timestamps do not use this class — they go through
|
||||
* the env-only path in `finalize-tsp-completion.ts`.
|
||||
*
|
||||
* Failure semantics: a single `signatures/timestamp` call. On any error
|
||||
* (HTTP, schema, expired token) we surface `CSC_PROVIDER_NO_TSA` with the
|
||||
* upstream message folded in. There's no try-in-order — at sign time the
|
||||
* recipient is fixed, so there's no other token to fall through to.
|
||||
*/
|
||||
|
||||
type CscTspTimestampAuthorityOptions = {
|
||||
transport: CscTransport;
|
||||
/** Decrypted service-scope access token for the current recipient. */
|
||||
serviceToken: string;
|
||||
/** Optional deadline for the `signatures/timestamp` call. */
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export class CscTspTimestampAuthority implements TimestampAuthority {
|
||||
private readonly transport: CscTransport;
|
||||
|
||||
private readonly serviceToken: string;
|
||||
|
||||
private readonly signal?: AbortSignal;
|
||||
|
||||
constructor(opts: CscTspTimestampAuthorityOptions) {
|
||||
this.transport = opts.transport;
|
||||
this.serviceToken = opts.serviceToken;
|
||||
this.signal = opts.signal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a CSC §11.10 timestamp for the supplied digest, authorised with
|
||||
* the recipient's service-scope bearer. Returns the decoded TimeStampToken
|
||||
* bytes. Throws `CSC_PROVIDER_NO_TSA` carrying the upstream error message
|
||||
* on failure.
|
||||
*
|
||||
* `algorithm` is libpdf's `DigestAlgorithm` (`SHA-256` / `SHA-384` /
|
||||
* `SHA-512`), translated to the matching `hashAlgo` OID via the existing
|
||||
* {@link hashOidForDigest} mapping so the spec's OID-typed payload stays
|
||||
* in one place.
|
||||
*/
|
||||
async timestamp(digest: Uint8Array, algorithm: DigestAlgorithm): Promise<Uint8Array> {
|
||||
const hash = Buffer.from(digest).toString('base64');
|
||||
const hashAlgo = hashOidForDigest(algorithm);
|
||||
|
||||
try {
|
||||
const response = await cscTimestamp({
|
||||
baseUrl: this.transport.serviceBaseUrl,
|
||||
accessToken: this.serviceToken,
|
||||
hash,
|
||||
hashAlgo,
|
||||
signal: this.signal,
|
||||
});
|
||||
|
||||
return Buffer.from(response.timestamp, 'base64');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
throw new AppError(AppErrorCode.CSC_PROVIDER_NO_TSA, {
|
||||
message: `CSC TSP timestamp endpoint refused the recipient's service token: ${message}.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export type CreateCheckoutSessionOptions = {
|
||||
customerId: string;
|
||||
priceId: string;
|
||||
returnUrl: string;
|
||||
subscriptionMetadata?: Stripe.Metadata;
|
||||
};
|
||||
|
||||
export const createCheckoutSession = async ({
|
||||
customerId,
|
||||
priceId,
|
||||
returnUrl,
|
||||
subscriptionMetadata,
|
||||
}: CreateCheckoutSessionOptions) => {
|
||||
export const createCheckoutSession = async ({ customerId, priceId, returnUrl }: CreateCheckoutSessionOptions) => {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
@@ -27,9 +20,6 @@ export const createCheckoutSession = async ({
|
||||
success_url: `${returnUrl}?success=true`,
|
||||
cancel_url: `${returnUrl}?canceled=true`,
|
||||
billing_address_collection: 'required',
|
||||
subscription_data: {
|
||||
metadata: subscriptionMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
||||
|
||||
export type SyncStripeCustomerSubscriptionOptions = {
|
||||
customerId: string;
|
||||
|
||||
/**
|
||||
* When true, the organisationClaim will not be synced.
|
||||
*
|
||||
* Used by the admin sync route to update only the Subscription
|
||||
* row while leaving claim entitlements untouched.
|
||||
*/
|
||||
bypassClaimUpdate?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Idempotent, convergent sync of a Stripe customer's subscription state into the local database.
|
||||
*
|
||||
* Fetches the current truth from Stripe and writes it locally, regardless of which
|
||||
* webhook event (or manual trigger) initiated the sync. Safe to run at any time,
|
||||
* any number of times.
|
||||
*
|
||||
* This function never creates organisations.
|
||||
*/
|
||||
export const syncStripeCustomerSubscription = async ({
|
||||
customerId,
|
||||
bypassClaimUpdate = false,
|
||||
}: SyncStripeCustomerSubscriptionOptions) => {
|
||||
// Note: `data.items.data.price.product` would exceed Stripe's 4-level expansion
|
||||
// limit on list endpoints, so the product is fetched separately when needed.
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const liveSubscriptions = stripeSubscriptions.data.filter((subscription) =>
|
||||
LIVE_SUBSCRIPTION_STATUSES.includes(subscription.status),
|
||||
);
|
||||
|
||||
if (liveSubscriptions.length > 1) {
|
||||
console.error(`Customer ${customerId} has ${liveSubscriptions.length} live subscriptions, expected at most 1`);
|
||||
|
||||
throw new Error(`Customer ${customerId} has multiple live subscriptions`);
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
console.error(`Organisation not found for customer ${customerId}, nothing to sync`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const liveSubscription = liveSubscriptions[0];
|
||||
|
||||
if (!liveSubscription) {
|
||||
await handleNoLiveSubscription({ organisation });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleLiveSubscription({
|
||||
organisation,
|
||||
subscription: liveSubscription,
|
||||
customerId,
|
||||
bypassClaimUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
type OrganisationWithClaimAndSubscription = Prisma.OrganisationGetPayload<{
|
||||
include: { organisationClaim: true; subscription: true };
|
||||
}>;
|
||||
|
||||
type HandleNoLiveSubscriptionOptions = {
|
||||
organisation: OrganisationWithClaimAndSubscription;
|
||||
};
|
||||
|
||||
const handleNoLiveSubscription = async ({ organisation }: HandleNoLiveSubscriptionOptions) => {
|
||||
// Individuals get their subscription deleted so they can return to the free plan.
|
||||
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (organisation.subscription) {
|
||||
await tx.subscription.delete({
|
||||
where: {
|
||||
id: organisation.subscription.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other cases, mark the subscription as inactive if a row exists.
|
||||
if (organisation.subscription) {
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
id: organisation.subscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type HandleLiveSubscriptionOptions = {
|
||||
organisation: OrganisationWithClaimAndSubscription;
|
||||
subscription: Stripe.Subscription;
|
||||
customerId: string;
|
||||
bypassClaimUpdate: boolean;
|
||||
};
|
||||
|
||||
const handleLiveSubscription = async ({
|
||||
organisation,
|
||||
subscription,
|
||||
customerId,
|
||||
bypassClaimUpdate,
|
||||
}: HandleLiveSubscriptionOptions) => {
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error(`No support for multiple subscription items on subscription ${subscription.id}`);
|
||||
|
||||
throw new Error(`No support for multiple subscription items on subscription ${subscription.id}`);
|
||||
}
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
|
||||
const claim = await extractStripeClaim(subscriptionItem.price);
|
||||
|
||||
if (!claim) {
|
||||
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
|
||||
throw new Error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
}
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
const periodEnd =
|
||||
subscription.status === 'trialing' && subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(subscription.current_period_end * 1000);
|
||||
|
||||
const shouldUpdateClaim =
|
||||
!bypassClaimUpdate && organisation.organisationClaim.originalSubscriptionClaimId !== claim.id;
|
||||
|
||||
// Migrate the organisation type if it is no longer an individual/free plan.
|
||||
// Never demote an ORGANISATION back to PERSONAL.
|
||||
const shouldMigrateOrganisationType =
|
||||
organisation.type === OrganisationType.PERSONAL &&
|
||||
claim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
|
||||
claim.id !== INTERNAL_CLAIM_ID.FREE;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.upsert({
|
||||
where: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
create: {
|
||||
organisationId: organisation.id,
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscriptionItem.price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscriptionItem.price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldUpdateClaim) {
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: claim.id,
|
||||
...createOrganisationClaimUpsertData(claim),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldMigrateOrganisationType) {
|
||||
await tx.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: OrganisationType.ORGANISATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
* The order of priority is:
|
||||
* 1. Price metadata
|
||||
* 2. Product metadata
|
||||
*
|
||||
* @returns The claimId or null if no claimId is found.
|
||||
*/
|
||||
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
||||
if (priceId.metadata.claimId) {
|
||||
return priceId.metadata.claimId;
|
||||
}
|
||||
|
||||
// Use the expanded product when available to avoid an extra API call.
|
||||
if (typeof priceId.product !== 'string' && 'metadata' in priceId.product) {
|
||||
return priceId.product.metadata.claimId || null;
|
||||
}
|
||||
|
||||
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
||||
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
|
||||
return product.metadata.claimId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
*/
|
||||
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
||||
const claimId = await extractStripeClaimId(priceId);
|
||||
|
||||
if (!claimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||
where: { id: claimId },
|
||||
});
|
||||
|
||||
if (!subscriptionClaim) {
|
||||
console.error(`Subscription claim ${claimId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionClaim;
|
||||
};
|
||||
@@ -2,17 +2,29 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { onSubscriptionCreated } from './on-subscription-created';
|
||||
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
||||
import { onSubscriptionUpdated } from './on-subscription-updated';
|
||||
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Events that trigger a sync of the customer's subscription state.
|
||||
*
|
||||
* The event payload is never trusted beyond extracting the customer ID,
|
||||
* the sync function fetches the current truth from Stripe.
|
||||
*/
|
||||
const SYNCED_EVENT_TYPES: string[] = [
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated',
|
||||
'customer.subscription.deleted',
|
||||
'checkout.session.completed',
|
||||
'invoice.payment_succeeded',
|
||||
'invoice.payment_failed',
|
||||
];
|
||||
|
||||
export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
@@ -60,69 +72,45 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
|
||||
/**
|
||||
* Notes:
|
||||
* - Dropped invoice.payment_succeeded
|
||||
* - Dropped invoice.payment_failed
|
||||
* - Dropped checkout-session.completed
|
||||
*/
|
||||
return await match(event.type)
|
||||
.with('customer.subscription.created', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
if (!SYNCED_EVENT_TYPES.includes(event.type)) {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
await onSubscriptionCreated({ subscription });
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const eventObject = event.data.object as { customer?: string | Stripe.Customer | null };
|
||||
|
||||
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.with('customer.subscription.updated', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
const customerId = typeof eventObject.customer === 'string' ? eventObject.customer : eventObject.customer?.id;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const previousAttributes = event.data.previous_attributes as Partial<Stripe.Subscription> | null;
|
||||
if (!customerId) {
|
||||
console.error(`No customer found on ${event.type} event ${event.id}, nothing to sync`);
|
||||
|
||||
await onSubscriptionUpdated({ subscription, previousAttributes });
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.with('customer.subscription.deleted', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
await syncStripeCustomerSubscription({ customerId });
|
||||
|
||||
await onSubscriptionDeleted({ subscription });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
})
|
||||
.otherwise(() => {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Response) {
|
||||
const message = await err.json();
|
||||
console.error(message);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import {
|
||||
createOrganisation,
|
||||
createOrganisationClaimUpsertData,
|
||||
} from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { InternalClaim, StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription';
|
||||
import { INTERNAL_CLAIM_ID, ZStripeOrganisationCreateMetadataSchema } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { extractStripeClaim } from './on-subscription-updated';
|
||||
|
||||
export type OnSubscriptionCreatedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Todo: We might want to pull this into a job so we can do steps. Since if organisation creation passes but
|
||||
* fails after this would be automatically rerun by Stripe, which means duplicate organisations can be
|
||||
* potentially created.
|
||||
*/
|
||||
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
|
||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
const claim = await extractStripeClaim(subscriptionItem.price);
|
||||
|
||||
// Todo: logging
|
||||
if (!claim) {
|
||||
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisationCreateData = subscription.metadata?.organisationCreateData;
|
||||
|
||||
// A new subscription can be for an existing organisation or a new one.
|
||||
const organisationId = organisationCreateData
|
||||
? await handleOrganisationCreate({
|
||||
customerId,
|
||||
claim,
|
||||
unknownCreateData: organisationCreateData,
|
||||
})
|
||||
: await handleOrganisationUpdate({
|
||||
customerId,
|
||||
claim,
|
||||
});
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
const periodEnd =
|
||||
subscription.status === 'trialing' && subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(subscription.current_period_end * 1000);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: {
|
||||
organisationId,
|
||||
},
|
||||
create: {
|
||||
organisationId,
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type HandleOrganisationCreateOptions = {
|
||||
customerId: string;
|
||||
claim: InternalClaim;
|
||||
unknownCreateData: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the creation of an organisation.
|
||||
*/
|
||||
const handleOrganisationCreate = async ({ customerId, claim, unknownCreateData }: HandleOrganisationCreateOptions) => {
|
||||
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
|
||||
|
||||
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(JSON.parse(unknownCreateData));
|
||||
|
||||
if (!parseResult.success) {
|
||||
console.error('Invalid organisation create flow data');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid organisation create flow data',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
organisationCreateFlowData = parseResult.data;
|
||||
|
||||
const createdOrganisation = await createOrganisation({
|
||||
name: organisationCreateFlowData.organisationName,
|
||||
userId: organisationCreateFlowData.userId,
|
||||
type: OrganisationType.ORGANISATION,
|
||||
customerId,
|
||||
claim,
|
||||
});
|
||||
|
||||
return createdOrganisation.id;
|
||||
};
|
||||
|
||||
type HandleOrganisationUpdateOptions = {
|
||||
customerId: string;
|
||||
claim: InternalClaim;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the updating an exist organisation claims.
|
||||
*/
|
||||
const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisationUpdateOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Todo: logging
|
||||
if (organisation.subscription && organisation.subscription.status !== SubscriptionStatus.INACTIVE) {
|
||||
console.error('Organisation already has an active subscription');
|
||||
|
||||
// This should never happen
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation already has an active subscription`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let newOrganisationType: OrganisationType = OrganisationType.ORGANISATION;
|
||||
|
||||
// Keep the organisation as personal if the claim is for an individual.
|
||||
if (organisation.type === OrganisationType.PERSONAL && claim.id === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||
newOrganisationType = OrganisationType.PERSONAL;
|
||||
}
|
||||
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: newOrganisationType,
|
||||
organisationClaim: {
|
||||
update: {
|
||||
originalSubscriptionClaimId: claim.id,
|
||||
...createOrganisationClaimUpsertData(claim),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return organisation.id;
|
||||
};
|
||||
@@ -1,88 +0,0 @@
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { INTERNAL_CLAIM_ID, internalClaims } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
|
||||
import { extractStripeClaimId } from './on-subscription-updated';
|
||||
|
||||
export type OnSubscriptionDeletedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
|
||||
const existingSubscription = await prisma.subscription.findUnique({
|
||||
where: {
|
||||
planId: subscription.id,
|
||||
},
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// If the subscription doesn't exist, we don't need to do anything.
|
||||
if (!existingSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionClaimId = await extractClaimIdFromStripeSubscription(subscription);
|
||||
|
||||
// Individuals get their subscription deleted so they can return to the
|
||||
// free plan.
|
||||
if (subscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.delete({
|
||||
where: {
|
||||
id: existingSubscription.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: existingSubscription.organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||
...createOrganisationClaimUpsertData(internalClaims[INTERNAL_CLAIM_ID.FREE]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other cases, mark the subscription as inactive since
|
||||
// they should still have a "Personal" account.
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
id: existingSubscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the claim ID from the Stripe subscription.
|
||||
*
|
||||
* Returns `null` if no claim ID found.
|
||||
*/
|
||||
const extractClaimIdFromStripeSubscription = async (subscription: Stripe.Subscription) => {
|
||||
const deletedItem = subscription.items.data[0];
|
||||
|
||||
if (!deletedItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await extractStripeClaimId(deletedItem.price);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,182 +0,0 @@
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type OnSubscriptionUpdatedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
previousAttributes: Partial<Stripe.Subscription> | null;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const onSubscriptionUpdated = async ({ subscription, previousAttributes }: OnSubscriptionUpdatedOptions) => {
|
||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
organisation.subscription &&
|
||||
organisation.subscription.status !== SubscriptionStatus.INACTIVE &&
|
||||
organisation.subscription.planId !== subscription.id
|
||||
) {
|
||||
console.error('[WARNING]: Organisation might have two subscriptions');
|
||||
}
|
||||
|
||||
const previousItem = previousAttributes?.items?.data[0];
|
||||
const updatedItem = subscription.items.data[0];
|
||||
|
||||
const previousSubscriptionClaimId = previousItem ? await extractStripeClaimId(previousItem.price) : null;
|
||||
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
|
||||
|
||||
if (!updatedSubscriptionClaim) {
|
||||
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${updatedItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
const periodEnd =
|
||||
subscription.status === 'trialing' && subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(subscription.current_period_end * 1000);
|
||||
|
||||
// Migrate the organisation type if it is no longer an individual plan.
|
||||
if (
|
||||
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
|
||||
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.FREE &&
|
||||
organisation.type === OrganisationType.PERSONAL
|
||||
) {
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: OrganisationType.ORGANISATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.update({
|
||||
where: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
data: {
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
// Override current organisation claim if new one is found.
|
||||
if (newClaimFound) {
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
|
||||
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
* The order of priority is:
|
||||
* 1. Price metadata
|
||||
* 2. Product metadata
|
||||
*
|
||||
* @returns The claimId or null if no claimId is found.
|
||||
*/
|
||||
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
||||
if (priceId.metadata.claimId) {
|
||||
return priceId.metadata.claimId;
|
||||
}
|
||||
|
||||
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
||||
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
|
||||
return product.metadata.claimId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
*/
|
||||
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
||||
const claimId = await extractStripeClaimId(priceId);
|
||||
|
||||
if (!claimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||
where: { id: claimId },
|
||||
});
|
||||
|
||||
if (!subscriptionClaim) {
|
||||
console.error(`Subscription claim ${claimId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionClaim;
|
||||
};
|
||||
@@ -12,12 +12,13 @@
|
||||
"index.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3002 --dir templates",
|
||||
"dev": "react-router dev --config preview/vite.config.ts",
|
||||
"preview:build": "react-router build --config preview/vite.config.ts",
|
||||
"clean": "rimraf node_modules"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/nodemailer-resend": "4.0.0",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@react-email/body": "0.2.0",
|
||||
"@react-email/button": "0.2.0",
|
||||
"@react-email/code-block": "0.2.0",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/.react-router/
|
||||
/build/
|
||||
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
import type { FieldConfig } from '../lib/templates';
|
||||
import { templates } from '../lib/templates';
|
||||
import { viewports } from '../lib/viewports';
|
||||
import { PropFields } from './prop-fields';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const;
|
||||
|
||||
const LANGUAGE_LABELS: Record<string, string> = {
|
||||
en: 'English',
|
||||
de: 'German',
|
||||
fr: 'French',
|
||||
es: 'Spanish',
|
||||
it: 'Italian',
|
||||
nl: 'Dutch',
|
||||
pl: 'Polish',
|
||||
'pt-BR': 'Portuguese (Brazil)',
|
||||
ja: 'Japanese',
|
||||
ko: 'Korean',
|
||||
zh: 'Chinese',
|
||||
};
|
||||
|
||||
const DEFAULT_COLORS = {
|
||||
primary: '#a2e771',
|
||||
primaryForeground: '#162c07',
|
||||
background: '#ffffff',
|
||||
foreground: '#0f172a',
|
||||
};
|
||||
|
||||
type PlaygroundProps = {
|
||||
slug: string;
|
||||
fields: Record<string, FieldConfig>;
|
||||
defaultProps: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [props, setProps] = useState(defaultProps);
|
||||
const [html, setHtml] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [theme, setTheme] = useState<Theme>('light');
|
||||
const [viewportIndex, setViewportIndex] = useState(2);
|
||||
const [lang, setLang] = useState('en');
|
||||
|
||||
const [brandingEnabled, setBrandingEnabled] = useState(false);
|
||||
const [colors, setColors] = useState(DEFAULT_COLORS);
|
||||
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
const groupedTemplates = useMemo(() => {
|
||||
const entries = Object.entries(templates);
|
||||
|
||||
return GROUP_ORDER.map((group) => ({
|
||||
group,
|
||||
entries: entries.filter(([, def]) => def.group === group),
|
||||
})).filter((section) => section.entries.length > 0);
|
||||
}, []);
|
||||
|
||||
const fetchHtml = useCallback(
|
||||
async (currentProps: Record<string, unknown>, currentLang: string, brandColors: typeof colors | null) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/render', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
slug,
|
||||
props: currentProps,
|
||||
lang: currentLang,
|
||||
colors: brandColors,
|
||||
assetBaseUrl: window.location.origin,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setHtml(await response.text());
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[slug],
|
||||
);
|
||||
|
||||
// Reset props when navigating to a different template.
|
||||
useEffect(() => {
|
||||
setProps(defaultProps);
|
||||
}, [defaultProps]);
|
||||
|
||||
// Re-render on any input change (debounced).
|
||||
useEffect(() => {
|
||||
clearTimeout(debounceRef.current);
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
void fetchHtml(props, lang, brandingEnabled ? colors : null);
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(debounceRef.current);
|
||||
}, [props, lang, brandingEnabled, colors, fetchHtml]);
|
||||
|
||||
const handlePropChange = (key: string, value: unknown) => {
|
||||
setProps((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleColorChange = (key: keyof typeof colors, value: string) => {
|
||||
setColors((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Force dark mode inside the iframe by neutralising the prefers-color-scheme
|
||||
// media query (color-scheme alone doesn't trigger it inside an iframe).
|
||||
const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html;
|
||||
|
||||
const viewport = viewports[viewportIndex];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden bg-neutral-100 font-sans text-neutral-900">
|
||||
{/* Sidebar */}
|
||||
<aside className="flex h-full w-60 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white">
|
||||
<div className="border-neutral-200 border-b px-4 py-3">
|
||||
<h1 className="font-semibold text-sm">Email Preview</h1>
|
||||
<p className="text-neutral-500 text-xs">{Object.keys(templates).length} templates</p>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-2 py-2">
|
||||
{groupedTemplates.map((section) => (
|
||||
<div key={section.group} className="mb-3">
|
||||
<div className="px-2 py-1 font-medium text-neutral-400 text-xs uppercase tracking-wide">
|
||||
{section.group}
|
||||
</div>
|
||||
|
||||
{section.entries.map(([id, def]) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
onClick={() => navigate(`/${id}`)}
|
||||
className={`block w-full rounded-md px-2 py-1.5 text-left text-sm transition-colors ${
|
||||
slug === id ? 'bg-neutral-900 text-white' : 'text-neutral-700 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{def.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Props panel */}
|
||||
<section className="flex h-full w-72 flex-shrink-0 flex-col overflow-y-auto border-neutral-200 border-r bg-white px-4 py-3">
|
||||
<h2 className="mb-3 font-medium text-neutral-500 text-xs uppercase tracking-wide">Props</h2>
|
||||
<PropFields fields={fields} values={props} onChange={handlePropChange} />
|
||||
</section>
|
||||
|
||||
{/* Main */}
|
||||
<main className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
<Toolbar
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
viewportIndex={viewportIndex}
|
||||
setViewportIndex={setViewportIndex}
|
||||
lang={lang}
|
||||
setLang={setLang}
|
||||
brandingEnabled={brandingEnabled}
|
||||
setBrandingEnabled={setBrandingEnabled}
|
||||
colors={colors}
|
||||
onColorChange={handleColorChange}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={`flex flex-1 items-start justify-center overflow-auto p-6 ${
|
||||
theme === 'dark' ? 'bg-neutral-800' : 'bg-neutral-200'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className="flex-shrink-0 overflow-hidden rounded-lg bg-white shadow-lg"
|
||||
style={{ width: viewport.width }}
|
||||
>
|
||||
<iframe
|
||||
title={`${viewport.name} ${theme}`}
|
||||
srcDoc={displayHtml}
|
||||
className="h-[calc(100vh-8rem)] w-full border-0"
|
||||
style={{ colorScheme: theme }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ToolbarProps = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
viewportIndex: number;
|
||||
setViewportIndex: (index: number) => void;
|
||||
lang: string;
|
||||
setLang: (lang: string) => void;
|
||||
brandingEnabled: boolean;
|
||||
setBrandingEnabled: (enabled: boolean) => void;
|
||||
colors: typeof DEFAULT_COLORS;
|
||||
onColorChange: (key: keyof typeof DEFAULT_COLORS, value: string) => void;
|
||||
loading: boolean;
|
||||
};
|
||||
|
||||
const Toolbar = (props: ToolbarProps) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-4 border-neutral-200 border-b bg-white px-4 py-2">
|
||||
<SegmentedControl
|
||||
label="Theme"
|
||||
value={props.theme}
|
||||
options={[
|
||||
{ value: 'light', label: 'Light' },
|
||||
{ value: 'dark', label: 'Dark' },
|
||||
]}
|
||||
onChange={(value) => props.setTheme(value as Theme)}
|
||||
/>
|
||||
|
||||
<SegmentedControl
|
||||
label="Viewport"
|
||||
value={String(props.viewportIndex)}
|
||||
options={viewports.map((viewport, index) => ({ value: String(index), label: viewport.name }))}
|
||||
onChange={(value) => props.setViewportIndex(Number(value))}
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
|
||||
<span className="font-medium">Language</span>
|
||||
<select
|
||||
value={props.lang}
|
||||
onChange={(event) => props.setLang(event.target.value)}
|
||||
className="rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs"
|
||||
>
|
||||
{SUPPORTED_LANGUAGE_CODES.map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{LANGUAGE_LABELS[code] ?? code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-neutral-600 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.brandingEnabled}
|
||||
onChange={(event) => props.setBrandingEnabled(event.target.checked)}
|
||||
/>
|
||||
<span className="font-medium">Brand colours</span>
|
||||
</label>
|
||||
|
||||
{props.brandingEnabled && (
|
||||
<div className="flex items-center gap-3">
|
||||
<ColorInput
|
||||
label="Primary"
|
||||
value={props.colors.primary}
|
||||
onChange={(value) => props.onColorChange('primary', value)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="On primary"
|
||||
value={props.colors.primaryForeground}
|
||||
onChange={(value) => props.onColorChange('primaryForeground', value)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Background"
|
||||
value={props.colors.background}
|
||||
onChange={(value) => props.onColorChange('background', value)}
|
||||
/>
|
||||
<ColorInput
|
||||
label="Text"
|
||||
value={props.colors.foreground}
|
||||
onChange={(value) => props.onColorChange('foreground', value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className="ml-auto text-neutral-400 text-xs">{props.loading ? 'Rendering…' : ''}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SegmentedControlProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
options: { value: string; label: string }[];
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const SegmentedControl = (props: SegmentedControlProps) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-neutral-600 text-xs">{props.label}</span>
|
||||
<div className="flex overflow-hidden rounded-md border border-neutral-300">
|
||||
{props.options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => props.onChange(option.value)}
|
||||
className={`px-2.5 py-1 text-xs transition-colors ${
|
||||
props.value === option.value
|
||||
? 'bg-neutral-900 text-white'
|
||||
: 'bg-white text-neutral-700 hover:bg-neutral-100'
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ColorInputProps = {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const ColorInput = (props: ColorInputProps) => {
|
||||
return (
|
||||
<label className="flex items-center gap-1 text-neutral-600 text-xs">
|
||||
<span>{props.label}</span>
|
||||
<input
|
||||
type="color"
|
||||
value={props.value}
|
||||
onChange={(event) => props.onChange(event.target.value)}
|
||||
className="h-6 w-6 cursor-pointer rounded border border-neutral-300 bg-white p-0"
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { FieldConfig } from '../lib/templates';
|
||||
|
||||
type PropFieldsProps = {
|
||||
fields: Record<string, FieldConfig>;
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export const PropFields = ({ fields, values, onChange }: PropFieldsProps) => {
|
||||
const entries = Object.entries(fields);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return <p className="text-neutral-400 text-xs">No editable props.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
{entries.map(([key, field]) => (
|
||||
<PropField key={key} name={key} field={field} value={values[key]} onChange={(value) => onChange(key, value)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type PropFieldProps = {
|
||||
name: string;
|
||||
field: FieldConfig;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
};
|
||||
|
||||
const inputClass =
|
||||
'w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-neutral-900 text-xs focus:border-neutral-500 focus:outline-none';
|
||||
|
||||
const PropField = ({ name, field, value, onChange }: PropFieldProps) => {
|
||||
const id = `prop-${name}`;
|
||||
|
||||
return (
|
||||
<div className="grid gap-1">
|
||||
<label htmlFor={id} className="font-medium text-neutral-600 text-xs">
|
||||
{field.label}
|
||||
</label>
|
||||
|
||||
{field.type === 'text' && (
|
||||
<input
|
||||
id={id}
|
||||
className={inputClass}
|
||||
value={String(value ?? '')}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'textarea' && (
|
||||
<textarea
|
||||
id={id}
|
||||
className={`${inputClass} min-h-16 resize-y font-mono`}
|
||||
value={String(value ?? '')}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'number' && (
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
className={inputClass}
|
||||
value={value === undefined || value === null ? '' : String(value)}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onChange(event.target.value === '' ? undefined : Number(event.target.value))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'boolean' && (
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={Boolean(value)}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'list' && (
|
||||
<textarea
|
||||
id={id}
|
||||
className={`${inputClass} min-h-16 resize-y font-mono`}
|
||||
value={Array.isArray(value) ? value.join('\n') : ''}
|
||||
placeholder={field.placeholder}
|
||||
onChange={(event) => onChange(event.target.value === '' ? [] : event.target.value.split('\n'))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field.type === 'select' && field.options && (
|
||||
<select
|
||||
id={id}
|
||||
className={inputClass}
|
||||
value={String(value ?? '')}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
{field.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
{field.description && <p className="text-neutral-400 text-xs">{field.description}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode, startTransition } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
||||
import { ServerRouter } from 'react-router';
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: AppLoadContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
|
||||
const readyOption: keyof RenderToPipeableStreamOptions =
|
||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? 'onAllReady' : 'onShellReady';
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(<ServerRouter context={routerContext} url={request.url} />, {
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
import type { ComponentType } from 'react';
|
||||
|
||||
import { AccessAuth2FAEmailTemplate } from '../../../templates/access-auth-2fa';
|
||||
import { AdminUserCreatedTemplate } from '../../../templates/admin-user-created';
|
||||
import { BulkSendCompleteEmail } from '../../../templates/bulk-send-complete';
|
||||
import { ConfirmEmailTemplate } from '../../../templates/confirm-email';
|
||||
import { ConfirmTeamEmailTemplate } from '../../../templates/confirm-team-email';
|
||||
import { DocumentCancelTemplate } from '../../../templates/document-cancel';
|
||||
import { DocumentCompletedEmailTemplate } from '../../../templates/document-completed';
|
||||
import { DocumentCreatedFromDirectTemplateEmailTemplate } from '../../../templates/document-created-from-direct-template';
|
||||
import { DocumentInviteEmailTemplate } from '../../../templates/document-invite';
|
||||
import { DocumentPendingEmailTemplate } from '../../../templates/document-pending';
|
||||
import { DocumentRecipientSignedEmailTemplate } from '../../../templates/document-recipient-signed';
|
||||
import { DocumentRejectedEmail } from '../../../templates/document-rejected';
|
||||
import { DocumentRejectionConfirmedEmail } from '../../../templates/document-rejection-confirmed';
|
||||
import { DocumentReminderEmailTemplate } from '../../../templates/document-reminder';
|
||||
import { DocumentSelfSignedEmailTemplate } from '../../../templates/document-self-signed';
|
||||
import { DocumentSuperDeleteEmailTemplate } from '../../../templates/document-super-delete';
|
||||
import { ForgotPasswordTemplate } from '../../../templates/forgot-password';
|
||||
import { OrganisationAccountLinkConfirmationTemplate } from '../../../templates/organisation-account-link-confirmation';
|
||||
import { OrganisationDeleteEmailTemplate } from '../../../templates/organisation-delete';
|
||||
import { OrganisationInviteEmailTemplate } from '../../../templates/organisation-invite';
|
||||
import { OrganisationJoinEmailTemplate } from '../../../templates/organisation-join';
|
||||
import { OrganisationLeaveEmailTemplate } from '../../../templates/organisation-leave';
|
||||
import { OrganisationLimitAlertEmailTemplate } from '../../../templates/organisation-limit-alert';
|
||||
import { RecipientExpiredTemplate } from '../../../templates/recipient-expired';
|
||||
import { RecipientRemovedFromDocumentTemplate } from '../../../templates/recipient-removed-from-document';
|
||||
import { ResetPasswordTemplate } from '../../../templates/reset-password';
|
||||
import { TeamDeleteEmailTemplate } from '../../../templates/team-delete';
|
||||
import { TeamEmailRemovedTemplate } from '../../../templates/team-email-removed';
|
||||
|
||||
export type FieldType = 'text' | 'textarea' | 'number' | 'boolean' | 'select' | 'list';
|
||||
|
||||
export type FieldConfig = {
|
||||
type: FieldType;
|
||||
label: string;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
default: unknown;
|
||||
options?: { label: string; value: string }[];
|
||||
};
|
||||
|
||||
export type TemplateDefinition = {
|
||||
/** Human label for the sidebar. */
|
||||
name: string;
|
||||
/** Loose grouping for the sidebar. */
|
||||
group: 'Documents' | 'Recipients' | 'Organisations' | 'Teams' | 'Account' | 'Admin';
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
component: ComponentType<any>;
|
||||
/** Editable props surfaced in the preview UI. */
|
||||
fields: Record<string, FieldConfig>;
|
||||
};
|
||||
|
||||
// --- Reusable field presets ---
|
||||
|
||||
const documentNameField: FieldConfig = {
|
||||
type: 'text',
|
||||
label: 'Document name',
|
||||
default: 'Open Source Pledge.pdf',
|
||||
};
|
||||
|
||||
const recipientNameField: FieldConfig = {
|
||||
type: 'text',
|
||||
label: 'Recipient name',
|
||||
default: 'Lucas Smith',
|
||||
};
|
||||
|
||||
const roleField: FieldConfig = {
|
||||
type: 'select',
|
||||
label: 'Recipient role',
|
||||
default: 'SIGNER',
|
||||
options: [
|
||||
{ label: 'Signer', value: 'SIGNER' },
|
||||
{ label: 'Viewer', value: 'VIEWER' },
|
||||
{ label: 'Approver', value: 'APPROVER' },
|
||||
{ label: 'CC', value: 'CC' },
|
||||
{ label: 'Assistant', value: 'ASSISTANT' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Explicit template registry. Each entry maps a slug → component + editable
|
||||
* `fields`. The slug is the route param (`/:slug`) and matches the source
|
||||
* filename (sans extension).
|
||||
*
|
||||
* `fields` drives both the default preview values AND the editable inputs in
|
||||
* the UI, so production templates stay free of preview-only defaults.
|
||||
*/
|
||||
export const templates: Record<string, TemplateDefinition> = {
|
||||
// ---- Documents ----
|
||||
'document-invite': {
|
||||
name: 'Document invite',
|
||||
group: 'Documents',
|
||||
component: DocumentInviteEmailTemplate,
|
||||
fields: {
|
||||
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
|
||||
inviterEmail: { type: 'text', label: 'Inviter email', default: 'lucas@documenso.com' },
|
||||
documentName: documentNameField,
|
||||
role: roleField,
|
||||
customBody: {
|
||||
type: 'textarea',
|
||||
label: 'Custom message',
|
||||
default: '',
|
||||
description: 'Leave blank to use the default invite copy.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'document-completed': {
|
||||
name: 'Document completed',
|
||||
group: 'Documents',
|
||||
component: DocumentCompletedEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
customBody: { type: 'textarea', label: 'Custom message', default: '' },
|
||||
},
|
||||
},
|
||||
'document-self-signed': {
|
||||
name: 'Document self-signed',
|
||||
group: 'Documents',
|
||||
component: DocumentSelfSignedEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
'document-pending': {
|
||||
name: 'Document pending',
|
||||
group: 'Documents',
|
||||
component: DocumentPendingEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
'document-reminder': {
|
||||
name: 'Document reminder',
|
||||
group: 'Documents',
|
||||
component: DocumentReminderEmailTemplate,
|
||||
fields: {
|
||||
recipientName: recipientNameField,
|
||||
documentName: documentNameField,
|
||||
role: roleField,
|
||||
customBody: { type: 'textarea', label: 'Custom message', default: '' },
|
||||
},
|
||||
},
|
||||
'document-cancel': {
|
||||
name: 'Document cancelled',
|
||||
group: 'Documents',
|
||||
component: DocumentCancelTemplate,
|
||||
fields: {
|
||||
inviterName: { type: 'text', label: 'Inviter name', default: 'Lucas Smith' },
|
||||
documentName: documentNameField,
|
||||
cancellationReason: {
|
||||
type: 'textarea',
|
||||
label: 'Cancellation reason',
|
||||
default: '',
|
||||
description: 'Optional. Blank renders no reason block.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'document-rejected': {
|
||||
name: 'Document rejected',
|
||||
group: 'Documents',
|
||||
component: DocumentRejectedEmail,
|
||||
fields: {
|
||||
recipientName: recipientNameField,
|
||||
documentName: documentNameField,
|
||||
documentUrl: { type: 'text', label: 'Document URL', default: 'https://documenso.com' },
|
||||
rejectionReason: {
|
||||
type: 'textarea',
|
||||
label: 'Rejection reason',
|
||||
default: 'The pledge amount is incorrect.',
|
||||
description: 'Optional in production; blank renders no reason block.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'document-rejection-confirmed': {
|
||||
name: 'Document rejection confirmed',
|
||||
group: 'Documents',
|
||||
component: DocumentRejectionConfirmedEmail,
|
||||
fields: {
|
||||
recipientName: recipientNameField,
|
||||
documentName: documentNameField,
|
||||
documentOwnerName: { type: 'text', label: 'Document owner', default: 'Timur Ercan' },
|
||||
reason: {
|
||||
type: 'textarea',
|
||||
label: 'Rejection reason',
|
||||
default: 'The pledge amount is incorrect.',
|
||||
description: 'Optional in production; blank renders no reason block.',
|
||||
},
|
||||
},
|
||||
},
|
||||
'document-created-from-direct-template': {
|
||||
name: 'Document created (direct template)',
|
||||
group: 'Documents',
|
||||
component: DocumentCreatedFromDirectTemplateEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
'document-super-delete': {
|
||||
name: 'Document deleted (admin)',
|
||||
group: 'Documents',
|
||||
component: DocumentSuperDeleteEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
'bulk-send-complete': {
|
||||
name: 'Bulk send complete',
|
||||
group: 'Documents',
|
||||
component: BulkSendCompleteEmail,
|
||||
fields: {
|
||||
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
|
||||
templateName: { type: 'text', label: 'Template name', default: 'NDA Template' },
|
||||
totalProcessed: { type: 'number', label: 'Total processed', default: 50 },
|
||||
successCount: { type: 'number', label: 'Success count', default: 48 },
|
||||
failedCount: { type: 'number', label: 'Failed count', default: 2 },
|
||||
errors: {
|
||||
type: 'list',
|
||||
label: 'Errors',
|
||||
default: ['Row 12: invalid email', 'Row 30: missing name'],
|
||||
description: 'One error per line. Rendered when failed count > 0.',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Recipients ----
|
||||
'document-recipient-signed': {
|
||||
name: 'Recipient signed',
|
||||
group: 'Recipients',
|
||||
component: DocumentRecipientSignedEmailTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
recipientName: recipientNameField,
|
||||
},
|
||||
},
|
||||
'recipient-expired': {
|
||||
name: 'Recipient expired',
|
||||
group: 'Recipients',
|
||||
component: RecipientExpiredTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
recipientName: recipientNameField,
|
||||
},
|
||||
},
|
||||
'recipient-removed-from-document': {
|
||||
name: 'Recipient removed',
|
||||
group: 'Recipients',
|
||||
component: RecipientRemovedFromDocumentTemplate,
|
||||
fields: {
|
||||
documentName: documentNameField,
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Organisations ----
|
||||
'organisation-invite': {
|
||||
name: 'Organisation invite',
|
||||
group: 'Organisations',
|
||||
component: OrganisationInviteEmailTemplate,
|
||||
fields: {
|
||||
senderName: { type: 'text', label: 'Sender name', default: 'Lucas Smith' },
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-join': {
|
||||
name: 'Organisation join',
|
||||
group: 'Organisations',
|
||||
component: OrganisationJoinEmailTemplate,
|
||||
fields: {
|
||||
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-leave': {
|
||||
name: 'Organisation leave',
|
||||
group: 'Organisations',
|
||||
component: OrganisationLeaveEmailTemplate,
|
||||
fields: {
|
||||
memberName: { type: 'text', label: 'Member name', default: 'Lucas Smith' },
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-delete': {
|
||||
name: 'Organisation delete',
|
||||
group: 'Organisations',
|
||||
component: OrganisationDeleteEmailTemplate,
|
||||
fields: {
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-limit-alert': {
|
||||
name: 'Organisation limit alert',
|
||||
group: 'Organisations',
|
||||
component: OrganisationLimitAlertEmailTemplate,
|
||||
fields: {
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'organisation-account-link-confirmation': {
|
||||
name: 'Account link confirmation',
|
||||
group: 'Organisations',
|
||||
component: OrganisationAccountLinkConfirmationTemplate,
|
||||
fields: {
|
||||
organisationName: { type: 'text', label: 'Organisation name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Teams ----
|
||||
'confirm-team-email': {
|
||||
name: 'Confirm team email',
|
||||
group: 'Teams',
|
||||
component: ConfirmTeamEmailTemplate,
|
||||
fields: {
|
||||
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
|
||||
},
|
||||
},
|
||||
'team-delete': {
|
||||
name: 'Team delete',
|
||||
group: 'Teams',
|
||||
component: TeamDeleteEmailTemplate,
|
||||
fields: {},
|
||||
},
|
||||
'team-email-removed': {
|
||||
name: 'Team email removed',
|
||||
group: 'Teams',
|
||||
component: TeamEmailRemovedTemplate,
|
||||
fields: {
|
||||
teamName: { type: 'text', label: 'Team name', default: 'Documenso' },
|
||||
teamEmail: { type: 'text', label: 'Team email', default: 'team@documenso.com' },
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Account ----
|
||||
'confirm-email': {
|
||||
name: 'Confirm email',
|
||||
group: 'Account',
|
||||
component: ConfirmEmailTemplate,
|
||||
fields: {
|
||||
confirmationLink: {
|
||||
type: 'text',
|
||||
label: 'Confirmation link',
|
||||
default: 'https://documenso.com/confirm',
|
||||
},
|
||||
},
|
||||
},
|
||||
'forgot-password': {
|
||||
name: 'Forgot password',
|
||||
group: 'Account',
|
||||
component: ForgotPasswordTemplate,
|
||||
fields: {
|
||||
resetPasswordLink: {
|
||||
type: 'text',
|
||||
label: 'Reset link',
|
||||
default: 'https://documenso.com/reset',
|
||||
},
|
||||
},
|
||||
},
|
||||
'reset-password': {
|
||||
name: 'Reset password',
|
||||
group: 'Account',
|
||||
component: ResetPasswordTemplate,
|
||||
fields: {
|
||||
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
|
||||
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
|
||||
},
|
||||
},
|
||||
'access-auth-2fa': {
|
||||
name: 'Access auth 2FA',
|
||||
group: 'Account',
|
||||
component: AccessAuth2FAEmailTemplate,
|
||||
fields: {
|
||||
documentTitle: { type: 'text', label: 'Document title', default: 'Open Source Pledge.pdf' },
|
||||
code: { type: 'text', label: 'Code', default: '123456' },
|
||||
userEmail: { type: 'text', label: 'User email', default: 'lucas@documenso.com' },
|
||||
userName: { type: 'text', label: 'User name', default: 'Lucas Smith' },
|
||||
expiresInMinutes: { type: 'number', label: 'Expires in (min)', default: 10 },
|
||||
},
|
||||
},
|
||||
|
||||
// ---- Admin ----
|
||||
'admin-user-created': {
|
||||
name: 'Admin user created',
|
||||
group: 'Admin',
|
||||
component: AdminUserCreatedTemplate,
|
||||
fields: {
|
||||
resetPasswordLink: {
|
||||
type: 'text',
|
||||
label: 'Reset link',
|
||||
default: 'https://documenso.com/reset',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type TemplateId = keyof typeof templates;
|
||||
|
||||
/** Extract the default prop values from a template's field config. */
|
||||
export const getDefaultProps = (fields: Record<string, FieldConfig>): Record<string, unknown> => {
|
||||
const props: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, field] of Object.entries(fields)) {
|
||||
props[key] = field.default;
|
||||
}
|
||||
|
||||
return props;
|
||||
};
|
||||
|
||||
export const getTemplate = (slug: string): TemplateDefinition | undefined => templates[slug];
|
||||
@@ -0,0 +1,10 @@
|
||||
export type Viewport = {
|
||||
name: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
export const viewports: Viewport[] = [
|
||||
{ name: 'Mobile', width: 390 },
|
||||
{ name: 'Tablet', width: 768 },
|
||||
{ name: 'Desktop', width: 1024 },
|
||||
];
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from 'react-router';
|
||||
|
||||
import type { Route } from './+types/root';
|
||||
import stylesheet from './app.css?url';
|
||||
|
||||
export const links: Route.LinksFunction = () => [{ rel: 'stylesheet', href: stylesheet }];
|
||||
|
||||
export const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
const App = () => {
|
||||
return <Outlet />;
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,7 @@
|
||||
import { index, type RouteConfig, route } from '@react-router/dev/routes';
|
||||
|
||||
export default [
|
||||
index('routes/_index.tsx'),
|
||||
route('api/render', 'routes/api.render.tsx'),
|
||||
route(':slug', 'routes/$slug.tsx'),
|
||||
] satisfies RouteConfig;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user