diff --git a/AGENTS.md b/AGENTS.md
index 0506d62f2..535cfb6a8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -812,7 +812,7 @@ and SMTP credentials, dedicated IP pools for sub-accounts, tracking and receivin
endpoint filters by sub-account, budgets per sub-account, a portal for end users, MCP support, rollups of
sub-account reputation counts (each evaluation counts its window live), and sub-account tenant pause webhooks,
Sign's archival timestamps in seals (PAdES B-LTA: renewing a document timestamp before its TSA certificate expires) and a standalone certificate download,
-a price for `sign.completed_requests`, and Sign in the iOS app and the admin console,
+Sign in the iOS app and the admin console,
Forms in the iOS app, malware scanning of form uploads, conditional or multi-page forms, a drag-and-drop form builder,
per-form webhook URLs (endpoints filter by event type), custom domains for hosted forms,
sending from inboxes at coss.com's managed inbound domain in live environments (they only receive), inbox
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index fdd674d4d..ab41ee0b5 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -131,8 +131,8 @@ export type CreateAppOptions = {
plans?: PlanGate;
/**
* Hard usage caps (`createUsageGate`): what a new live inbox reserves, and what a canceled or
- * paused broadcast gives back of its reservation. Must match the email service's. Defaults to
- * none.
+ * paused broadcast gives back of its reservation (also the default Sign service's). Must match
+ * the email service's. Defaults to none.
*/
billing?: UsageGate;
/** The caller's address for per-IP limits (`CLIENT_IP_HEADER`). Defaults to none. */
@@ -361,7 +361,7 @@ export function createApp(opts: CreateAppOptions) {
}),
signService:
opts.signService === undefined
- ? createSignService({ store: new FakeDocumentStore() })
+ ? createSignService({ store: new FakeDocumentStore(), billing: opts.billing })
: opts.signService,
domainDeps,
webhookDeps: opts.webhookDeps,
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 5270009ee..58e84b540 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -112,7 +112,7 @@ const app = createApp({
: null,
},
// Documents live in SIGN_DOCUMENTS_BUCKET; production without it has no Sign (never Postgres).
- signService: signStore ? createSignService({ store: signStore }) : null,
+ signService: signStore ? createSignService({ store: signStore, billing: usage }) : null,
billing: usage,
links: { keys: linkKeys, baseUrl: trackingUrl },
domainDeps: email.domainDeps,
diff --git a/apps/api/src/routes/sign.ts b/apps/api/src/routes/sign.ts
index 1ea37c9ab..aa7c3ad4c 100644
--- a/apps/api/src/routes/sign.ts
+++ b/apps/api/src/routes/sign.ts
@@ -1,6 +1,5 @@
import { authorizeLiveSender } from "@coss/email";
import {
- cancelSignRequest,
createSignSession,
getSignDocument,
getSignRequest,
@@ -12,7 +11,6 @@ import {
requestSignReminders,
type SignActor,
type SignService,
- sendSignRequest,
toSignSettingsObject,
updateSignSettings,
} from "@coss/sign";
@@ -633,7 +631,7 @@ export function createSignRoutes({
router.openapi(sendSignRequestRoute, async (c) => {
const { id } = c.req.valid("param");
const { organizationId, environmentId } = c.var.auth;
- const request = await sendSignRequest({
+ const request = await available().send({
organizationId,
environmentId,
requestId: id,
@@ -645,7 +643,7 @@ export function createSignRoutes({
router.openapi(cancelSignRequestRoute, async (c) => {
const { id } = c.req.valid("param");
const { organizationId, environmentId } = c.var.auth;
- const request = await cancelSignRequest({
+ const request = await available().cancel({
organizationId,
environmentId,
requestId: id,
diff --git a/apps/site/content/docs/sign/index.mdx b/apps/site/content/docs/sign/index.mdx
index 98ae84633..d9599031d 100644
--- a/apps/site/content/docs/sign/index.mdx
+++ b/apps/site/content/docs/sign/index.mdx
@@ -67,6 +67,15 @@ sealed, so any later change shows.
+## Pricing
+
+Each completed request is , charged once when its sealed
+document exists, however many signers and documents it has. Declined, canceled and expired requests
+are free, and so are sandbox environments. The Free plan includes
+: a live request counts when it's sent (a canceled one
+gives its place back), and past that sending fails with `402 usage_limit_exceeded` until the next
+month or an upgrade. See [Billing](/docs/billing).
+
## Tools
- **CLI**: [`coss sign`](/docs/cli#sign) `upload`, `create`, `send`, `list`, `get`, `remind`, `cancel`, `download`,
diff --git a/apps/site/content/docs/sign/testing.mdx b/apps/site/content/docs/sign/testing.mdx
index be620224a..3682a3f5a 100644
--- a/apps/site/content/docs/sign/testing.mdx
+++ b/apps/site/content/docs/sign/testing.mdx
@@ -10,7 +10,10 @@ page, signers sign, and the request is sealed and completed. What's different:
provider: read them in the dashboard's emails page instead.
- **The signing page says it's a test** ("signing it has no legal effect"), and so does the
certificate.
-- **Nothing is billed.**
+- **The seal is a test seal.** The completed PDF is sealed and verifies, but with a self-signed
+ certificate ("coss.com Sign (sandbox test seal)"), so PDF readers like Acrobat show the signature
+ as not trusted. Live requests are sealed with coss.com's trusted certificate.
+- **Nothing is billed**, and sandbox requests don't count against the Free plan's requests.
- **`allowed_origins` can include `http://localhost`** for embedding while you develop.
Test PDFs need nothing special; put anchors like `{{signature:signer_1}}` in them to place fields
diff --git a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/sign/actions.ts b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/sign/actions.ts
index a3e30bc3b..2fe4e1b77 100644
--- a/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/sign/actions.ts
+++ b/apps/web/src/app/[orgSlug]/[projectSlug]/[envSlug]/sign/actions.ts
@@ -2,7 +2,6 @@
import { authorizeLiveSender } from "@coss/email";
import {
- cancelSignRequest,
createSignBuilderSession,
deleteSignTemplate,
requestSignReminders,
@@ -73,7 +72,7 @@ export async function cancelSignRequestAction(
const environment = await environmentFromForm(context, formData);
const requestId = formString(formData, "requestId");
const request = await signed(() =>
- cancelSignRequest({
+ signService().cancel({
organizationId: context.organization.id,
environmentId: environment.id,
requestId,
diff --git a/apps/web/src/lib/sign.ts b/apps/web/src/lib/sign.ts
index 4254f01d3..7a8fd40d2 100644
--- a/apps/web/src/lib/sign.ts
+++ b/apps/web/src/lib/sign.ts
@@ -1,10 +1,13 @@
import "server-only";
+import { createUsageGate } from "@coss/billing";
import { createSignService, type DocumentStore, type SignService } from "@coss/sign";
import { signStoreFromEnv } from "@coss/sign/runtime";
import { env } from "@/env";
import { ActionError } from "@/lib/actions";
let store: DocumentStore | null | undefined;
+// The dashboard only cancels requests (giving their reservations back): sending is the API's.
+const usage = createUsageGate();
/** Sign's document store (S3, or the fake one outside production); `null` while Sign is off. */
export function signStore(): DocumentStore | null {
@@ -16,7 +19,7 @@ export function signStore(): DocumentStore | null {
export function signService(): SignService {
const current = signStore();
if (!current) throw new ActionError("Sign is temporarily unavailable.");
- return createSignService({ store: current });
+ return createSignService({ store: current, billing: usage });
}
/** Where signing and builder links point (`SIGN_PUBLIC_URL`); unset until the app is deployed. */
diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts
index 1be847e55..92b071f19 100644
--- a/apps/worker/src/index.ts
+++ b/apps/worker/src/index.ts
@@ -252,8 +252,8 @@ const secretsKeys = parseSecretsKeys(env.WEBHOOK_SECRETS_KEY);
// The dashboard accepts local endpoints outside production; let them be reached there too.
const send = createWebhookSender({ allowPrivateNetworks: process.env.NODE_ENV !== "production" });
-// Sign: completed requests are sealed here. Production has no document store (nor a certificate)
-// yet, so nothing waits there to be sealed.
+// Sign: completed requests are sealed here (live ones with the CA's certificate, sandbox ones with a
+// self-signed test seal).
const signSeal = await sealerFromEnv(env);
const signStore = signStoreFromEnv(env);
const signCompletion = signSeal && signStore ? { store: signStore, ...signSeal } : null;
diff --git a/packages/billing/src/catalog/meters.ts b/packages/billing/src/catalog/meters.ts
index f5283d8e6..88a4b024d 100644
--- a/packages/billing/src/catalog/meters.ts
+++ b/packages/billing/src/catalog/meters.ts
@@ -135,8 +135,8 @@ export const METERS = {
dimensions: ["fee"],
requires: "plan",
},
- // Sign: a signature request every signer signed, once its sealed document exists. Not priced yet:
- // until a rate card prices it, invoices show the usage as an uncharged line.
+ // Sign: a signature request every signer signed, once its sealed document exists (live
+ // environments only).
"sign.completed_requests": {
product: "sign",
label: "Signature requests",
diff --git a/packages/billing/src/catalog/plans.ts b/packages/billing/src/catalog/plans.ts
index 15e5c5f5f..f2d4fbe02 100644
--- a/packages/billing/src/catalog/plans.ts
+++ b/packages/billing/src/catalog/plans.ts
@@ -2,8 +2,8 @@
* The plans. A plan's monthly fee is credit spendable on any product's usage, so a month costs
* the larger of the fee and the usage (plus add-ons). Free has no credit but includes 3,000
* emails, 1,000 MB each of attachments and received data past each email's first MB, 1,000 form
- * submissions, 1,000 MB of their uploads and 50,000 Hooks messages a month and blocks usage beyond
- * its allowance. Every plan includes 100 phone checks, 3 inboxes, Hooks messages, Scheduling hosts
+ * submissions, 1,000 MB of their uploads, 50,000 Hooks messages and 5 completed signature requests
+ * a month and blocks usage beyond its allowance. Every plan includes 100 phone checks, 3 inboxes, Hooks messages, Scheduling hosts
* and Risk assessments a month (past Free's, assessments go on in a reduced form instead of
* stopping: `packages/risk/AGENTS.md`).
*/
@@ -84,6 +84,8 @@ export const PLANS = {
"forms.submissions": 1_000,
"forms.file_uploads": 1_000,
"scheduling.bookings": 100,
+ // Counted when a live request is sent (Sign reserves one), so at most 5 can complete.
+ "sign.completed_requests": 5,
"email.inboxes": INBOX_DAYS_INCLUDED,
"risk.phone_checks": PHONE_CHECKS_INCLUDED,
"hooks.messages": HOOKS_MESSAGES_INCLUDED.free,
diff --git a/packages/billing/src/catalog/rates.ts b/packages/billing/src/catalog/rates.ts
index ebbb30893..1b5cb2005 100644
--- a/packages/billing/src/catalog/rates.ts
+++ b/packages/billing/src/catalog/rates.ts
@@ -150,6 +150,10 @@ const NOVEMBER_2026: RateCard = {
],
},
},
+ // Sign: $0.50 per completed request. Its seal is one signing on SSL.com eSigner ($0.25 each at
+ // 1,000 a month, $0.1875 on its annual tier, less by contract above that). First priced when
+ // Sign was switched on in production (2026-11-06); no organization had completed one there.
+ "sign.completed_requests": { default: flatRate(500_000_000n) },
// $0.25 per 1,000 content checks. Jev costs about $0.042 per million input tokens, so at most
// about $0.10 per 1,000 for the longest text classified (8,000 characters).
"risk.content_checks": { default: flatRate(250_000n) },
diff --git a/packages/billing/test/engine.test.ts b/packages/billing/test/engine.test.ts
index 0af2ac399..289aa2ae3 100644
--- a/packages/billing/test/engine.test.ts
+++ b/packages/billing/test/engine.test.ts
@@ -122,7 +122,7 @@ describe("terms", () => {
"sms.received_segments": 0,
"sms.numbers": 0,
"sms.registrations": 0,
- "sign.completed_requests": 0,
+ "sign.completed_requests": 5,
"risk.phone_checks": 100,
"verify.verifications": 0,
"verify.sms": 0,
diff --git a/packages/sign/AGENTS.md b/packages/sign/AGENTS.md
index 0e99bbd98..0d0eb21a5 100644
--- a/packages/sign/AGENTS.md
+++ b/packages/sign/AGENTS.md
@@ -83,7 +83,8 @@ templates and the embedded builder. See "Not built yet" in the root `AGENTS.md`
`authMode: explicit`; the chain comes from `credentials/info` in `prepare()`, before the first
seal, never at start; a signature that doesn't verify against the chain is refused and the
credential re-read) and `LocalSealer` (a self-signed key per process). `sealerFromEnv`
- (`./runtime`) reads `signSealEnvShape` (`./env`). **Long-term validation** (B-LT, `ltv.ts` +
+ (`./runtime`) reads `signSealEnvShape` (`./env`) and adds a `sandboxSealer` (a `LocalSealer`;
+ the same one with `local`) that `SignCompletionDeps` uses for sandbox requests. **Long-term validation** (B-LT, `ltv.ts` +
`dss.ts`): after the CMS is written, every certificate in it and in its timestamp token gets an
OCSP response (SHA-1 CertID; signed by the issuer or a delegated `id-kp-OCSPSigning` responder
it issued — checked by `signedByResponder`, not pkijs's `verify`, which wants the signer
@@ -156,9 +157,14 @@ templates and the embedded builder. See "Not built yet" in the root `AGENTS.md`
racing the deletion loses its fenced update and deletes its own copy. Deleting a document
(`deleteSignDocument`) records its key the same way (`store-cleanup.ts`).
- **Billing**: meter `sign.completed_requests` (`@coss/billing/metering`), recorded once in the
- sealing transaction (behind its compare-and-set), live environments only. It has no rate yet:
- invoices show the usage as an uncharged line until a rate card prices it (pricing is a product
- decision).
+ sealing transaction (behind its compare-and-set), live environments only: $0.50 each (from the
+ 2026-11 card; a seal is one SSL.com eSigner signing, about $0.19–0.25 at our tier). Free includes
+ 5 a month, enforced by `SignServiceOptions.billing` (`UsageGate`): a live request reserves one
+ when it is sent (`create` with `send`, `send`), in that transaction, and `cancel` of a sent one
+ releases it to the period it was sent in; declined and expired requests keep theirs. Sends go
+ through the service (`service.send`/`service.cancel`), never the bare `sendSignRequest`/
+ `cancelSignRequest`, which are uncapped. Sandbox requests are sealed by `sealerFromEnv`'s
+ `sandboxSealer` (a self-signed test seal per process), so they never spend the CA's signings.
- **Retention**: `RETENTION_POLICY` `signature_requests` — kept until the customer deletes them;
`signing_sessions` — signing and builder sessions `RETENTION_DAYS.signSessions` after expiry
(`purgeExpiredSignSessions`, the worker's `retention` loop).
@@ -193,6 +199,8 @@ Sign is on in production since these steps (in this order):
(`SIGN_CSC_CREDENTIAL_ID`); seal a test PDF with it before switching.
4. **Switch on (done):** `SIGN_DOCUMENTS_BUCKET` (web, api, worker, sign), `terraform apply`,
deploy. `SIGN_NOTIFICATIONS_FROM` is unset (`NOTIFICATIONS_FROM`'s address).
-5. **Before announcing:** legal reviewed `DEFAULT_SIGN_DISCLOSURE` and the certificate wording
- (2026-11-06; change them only with another review); `sign.completed_requests` still needs a
- price (a new rate card).
+5. **Before announcing (done):** legal reviewed `DEFAULT_SIGN_DISCLOSURE` and the certificate
+ wording (2026-11-06; change them only with another review), and `sign.completed_requests` is
+ priced (above). SSL.com's eSigner trial (unlimited signings) ends 30 days after the certificate
+ was issued (2026-12-06): an eSigner document signing tier must be bought before then, or live
+ seals fail (and retry with backoff).
diff --git a/packages/sign/src/complete.ts b/packages/sign/src/complete.ts
index e5aeb39f7..1f0e5067d 100644
--- a/packages/sign/src/complete.ts
+++ b/packages/sign/src/complete.ts
@@ -35,6 +35,8 @@ type RequestRow = typeof signRequest.$inferSelect;
export type SignCompletionDeps = {
store: DocumentStore;
sealer: Sealer;
+ /** Seals sandbox requests (`sealerFromEnv`'s self-signed test seal); default `sealer`. */
+ sandboxSealer?: Sealer;
/** Without one, seals carry no trusted timestamp (PAdES B-B instead of B-T). */
tsa?: TimestampAuthority | null;
/** Fetches the seal's validation data (OCSP, CRLs); default the global `fetch`. */
@@ -200,7 +202,7 @@ async function seal(request: RequestRow, deps: SignCompletionDeps, now: Date): P
const completed = await completedRequest(request, deps.store);
const document = await buildCompletedPdf(completed);
const bytes = await sealPdf(document, {
- sealer: deps.sealer,
+ sealer: completed.sandbox ? (deps.sandboxSealer ?? deps.sealer) : deps.sealer,
tsa: deps.tsa,
name: "coss.com",
reason: `Completed signature request ${request.id}: every signer signed.`,
diff --git a/packages/sign/src/requests.ts b/packages/sign/src/requests.ts
index 3040f5aaf..c296169b3 100644
--- a/packages/sign/src/requests.ts
+++ b/packages/sign/src/requests.ts
@@ -3,6 +3,7 @@
* with their signers and fields, in one transaction with the first events of their audit trail.
* Reads return the latest shape (`SignRequestObject`).
*/
+import type { UsageGate } from "@coss/billing/gate";
import { BUDGET_EXCEEDED_MESSAGES, budgetHoldFor, isOnBillingHold } from "@coss/billing/metering";
import { db, type Executor, schema, type Transaction } from "@coss/db";
import { ID_PREFIXES, isId, newId } from "@coss/db/id";
@@ -108,6 +109,10 @@ export type SignService = {
input: Parameters[1],
): Promise;
deleteDocument(input: Parameters[1]): Promise;
+ /** `sendSignRequest`, under the plan's cap. */
+ send(input: Parameters[0]): Promise;
+ /** `cancelSignRequest`, giving a sent request's reservation back. */
+ cancel(input: Parameters[0]): Promise;
deleteRequest(input: Parameters[1]): Promise;
createTemplate(
input: Parameters[1],
@@ -119,6 +124,11 @@ export type SignService = {
export type SignServiceOptions = {
store: DocumentStore;
+ /**
+ * Capped plans' completed requests (`createUsageGate`): a live request reserves one when it is
+ * sent and gives it back if it is canceled. Without it, uncapped.
+ */
+ billing?: UsageGate | undefined;
/** The clock, for tests. */
now?: () => Date;
};
@@ -371,6 +381,8 @@ async function transition(
refused: (status: SignRequestStatus) => SignError;
/** Throws when the locked row can't make this change after all. */
check?: (row: { expiresAt: Date | null }) => void;
+ /** Runs in the change's transaction, after it (billing). */
+ after?: (tx: Transaction, before: SignRequestRow, sandbox: boolean) => Promise;
},
now: Date,
): Promise {
@@ -392,10 +404,13 @@ async function transition(
{ request: updated ?? row, sandbox },
{ type: change.event, actor: input.actor, occurredAt: now },
);
+ await change.after?.(tx, row, sandbox);
});
return getSignRequest(input);
}
+const SIGN_METER = "sign.completed_requests";
+
/** A live request is usage once sealed, and its emails cost before that. */
async function refuseWhileHeld(
source: { organizationId: string; projectId: string },
@@ -415,6 +430,7 @@ async function refuseWhileHeld(
export async function sendSignRequest(
input: { organizationId: string; environmentId: string; requestId: string; actor: SignActor },
now: Date = new Date(),
+ opts: { billing?: UsageGate | undefined } = {},
): Promise {
if (!(await environmentIsSandbox(input))) {
const [found] = await db
@@ -446,6 +462,15 @@ export async function sendSignRequest(
);
}
},
+ // A live request counts against a capped plan from when it is sent.
+ after: async (tx, _row, sandbox) => {
+ if (sandbox) return;
+ await opts.billing?.reserve(tx, {
+ organizationId: input.organizationId,
+ meter: SIGN_METER,
+ quantity: 1,
+ });
+ },
},
now,
);
@@ -454,6 +479,7 @@ export async function sendSignRequest(
export async function cancelSignRequest(
input: { organizationId: string; environmentId: string; requestId: string; actor: SignActor },
now: Date = new Date(),
+ opts: { billing?: UsageGate | undefined } = {},
): Promise {
return transition(
input,
@@ -464,6 +490,17 @@ export async function cancelSignRequest(
event: "canceled",
refused: (status) =>
new SignError("request_not_cancelable", `A ${status} request can't be canceled.`),
+ // A sent request will never complete now: its reservation goes back to the period it was
+ // sent in. Declined and expired ones keep theirs (their signers were asked).
+ after: async (tx, row, sandbox) => {
+ if (sandbox || row.status !== "sent") return;
+ await opts.billing?.release?.(tx, {
+ organizationId: input.organizationId,
+ meter: SIGN_METER,
+ quantity: 1,
+ reservedAt: row.sentAt ?? now,
+ });
+ },
},
now,
);
@@ -613,6 +650,13 @@ export function createSignService(opts: SignServiceOptions): SignService {
{ organizationId: env.organizationId, projectId: env.projectId },
tx,
);
+ if (send) {
+ await opts.billing?.reserve(tx, {
+ organizationId: env.organizationId,
+ meter: SIGN_METER,
+ quantity: 1,
+ });
+ }
}
checkExpiry(params.expires_at, now);
if (inline.length > 0) await tx.insert(signDocument).values(inline);
@@ -741,6 +785,8 @@ export function createSignService(opts: SignServiceOptions): SignService {
replay,
uploadDocument: (input) => uploadSignDocument(opts.store, input),
deleteDocument: (input) => deleteSignDocument(opts.store, input),
+ send: (input) => sendSignRequest(input, clock(), { billing: opts.billing }),
+ cancel: (input) => cancelSignRequest(input, clock(), { billing: opts.billing }),
deleteRequest: (input) => deleteSignRequest(opts.store, input),
createTemplate: (input) => createSignTemplate(opts.store, input),
updateTemplate: (input) => updateSignTemplate(opts.store, input),
diff --git a/packages/sign/src/runtime.ts b/packages/sign/src/runtime.ts
index 06cd923d4..5b0dad57e 100644
--- a/packages/sign/src/runtime.ts
+++ b/packages/sign/src/runtime.ts
@@ -26,11 +26,20 @@ type SignSealEnv = {
SIGN_TSA_URL?: string;
};
+/**
+ * What seals completed requests: live ones with `sealer`, sandbox ones with `sandboxSealer`, a
+ * self-signed test seal (no viewer trusts it), so tests never spend a CA's signings.
+ */
export async function sealerFromEnv(
env: SignSealEnv,
-): Promise<{ sealer: Sealer; tsa: TimestampAuthority | null } | null> {
+): Promise<{ sealer: Sealer; sandboxSealer: Sealer; tsa: TimestampAuthority | null } | null> {
const tsa = env.SIGN_TSA_URL ? new HttpTsa(env.SIGN_TSA_URL) : null;
- if (env.SIGN_SEALER === "local") return { sealer: await LocalSealer.create(), tsa };
+ if (env.SIGN_SEALER === "local") {
+ const sealer = await LocalSealer.create();
+ return { sealer, sandboxSealer: sealer, tsa };
+ }
+ if (!env.SIGN_SEALER) return null;
+ const sandboxSealer = await LocalSealer.create("coss.com Sign (sandbox test seal)");
if (env.SIGN_SEALER === "kms") {
if (!env.SIGN_SEAL_KMS_KEY_ID || !env.SIGN_SEAL_CERTIFICATE_CHAIN) {
throw new Error(
@@ -42,6 +51,7 @@ export async function sealerFromEnv(
keyId: env.SIGN_SEAL_KMS_KEY_ID,
certificateChain: certificatesFromPem(env.SIGN_SEAL_CERTIFICATE_CHAIN),
}),
+ sandboxSealer,
tsa,
};
}
@@ -71,6 +81,7 @@ export async function sealerFromEnv(
credentialId,
totpSecret: env.SIGN_CSC_TOTP_SECRET,
}),
+ sandboxSealer,
tsa,
};
}
diff --git a/packages/sign/test/ship.test.ts b/packages/sign/test/ship.test.ts
index db44f81de..c0b95963f 100644
--- a/packages/sign/test/ship.test.ts
+++ b/packages/sign/test/ship.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
+import { BillingError, createUsageGate } from "@coss/billing";
import { db, schema } from "@coss/db";
import { createTestTenant } from "@coss/tenancy/testing";
import { and, eq } from "drizzle-orm";
@@ -17,12 +18,15 @@ import {
openSigning,
prepareDocument,
purgeExpiredSignSessions,
+ readPdfSeal,
+ readSignedDocument,
runDueSignCleanup,
runDueSignCompletions,
S3DocumentStore,
type SignEnvironment,
SignError,
type SignS3Api,
+ sendSignRequest,
signAsSigner,
} from "../src/index.ts";
import { signStoreFromEnv } from "../src/runtime.ts";
@@ -111,6 +115,98 @@ describe("billing", () => {
});
});
+describe("Free's cap", () => {
+ const capped = createSignService({ store, billing: createUsageGate() });
+ const create = async (env: SignEnvironment, send = true) =>
+ (
+ await capped.create({
+ environment: env,
+ apiKeyId: null,
+ actor: { kind: "api_key" },
+ params: {
+ title: "NDA",
+ send,
+ documents: [{ filename: "nda.pdf", content: await buildTestPdfBase64() }],
+ signers: [
+ {
+ name: "Ada",
+ email: "ada@example.com",
+ fields: [{ type: "signature", anchor: "{{signature:signer_1}}" }],
+ },
+ ],
+ },
+ })
+ ).request;
+ const actor = { kind: "api_key" as const };
+
+ test("live requests count when sent, up to 5 a month; a canceled one gives its place back", async () => {
+ const { env } = await environment("live");
+ const sent = [];
+ for (let index = 0; index < 5; index++) sent.push(await create(env));
+ const refused = create(env);
+ await expect(refused).rejects.toBeInstanceOf(BillingError);
+ await expect(refused).rejects.toMatchObject({ code: "usage_limit_exceeded" });
+
+ // Drafts don't count until they are sent.
+ const draft = await create(env, false);
+ await expect(capped.send({ ...env, requestId: draft.id, actor })).rejects.toBeInstanceOf(
+ BillingError,
+ );
+ expect((await getSignRequest({ ...env, requestId: draft.id })).status).toBe("draft");
+
+ // Canceling a draft frees nothing; canceling a sent request does.
+ await capped.cancel({ ...env, requestId: draft.id, actor });
+ await expect(create(env)).rejects.toBeInstanceOf(BillingError);
+ await capped.cancel({ ...env, requestId: sent[0]?.id ?? "", actor });
+ // A retried cancel gives nothing more back.
+ await capped.cancel({ ...env, requestId: sent[0]?.id ?? "", actor });
+ await create(env);
+ await expect(create(env)).rejects.toBeInstanceOf(BillingError);
+ });
+
+ test("sandbox requests never count", async () => {
+ const { env } = await environment("sandbox");
+ for (let index = 0; index < 6; index++) await create(env);
+ const draft = await create(env, false);
+ await capped.send({ ...env, requestId: draft.id, actor });
+ });
+
+ test("without a gate, sending is uncapped", async () => {
+ const { env } = await environment("live");
+ const draft = await create(env, false);
+ expect((await sendSignRequest({ ...env, requestId: draft.id, actor })).status).toBe("sent");
+ });
+});
+
+describe("sandbox seals", () => {
+ test("are made with the sandbox sealer, live ones with the real one", async () => {
+ const live = await environment("live");
+ const sandbox = await environment("sandbox");
+ const liveRequest = await signed(live.env);
+ const sandboxRequest = await signed(sandbox.env);
+ const sealer = await LocalSealer.create("real seal");
+ const sandboxSealer = await LocalSealer.create("test seal");
+ await runDueSignCompletions(
+ {
+ limit: 5,
+ scope: { organizationIds: [live.tenant.organization.id, sandbox.tenant.organization.id] },
+ },
+ { store, sealer, sandboxSealer },
+ );
+ const certificateOf = async (env: SignEnvironment, requestId: string) => {
+ const { content } = await readSignedDocument({ ...env, requestId, store });
+ return Buffer.from(readPdfSeal(content).cms).toString("latin1");
+ };
+ const latin1 = (der: Uint8Array | undefined) => Buffer.from(der ?? []).toString("latin1");
+ expect(await certificateOf(live.env, liveRequest.id)).toContain(
+ latin1(sealer.certificateChain[0]),
+ );
+ const sandboxCms = await certificateOf(sandbox.env, sandboxRequest.id);
+ expect(sandboxCms).toContain(latin1(sandboxSealer.certificateChain[0]));
+ expect(sandboxCms).not.toContain(latin1(sealer.certificateChain[0]));
+ });
+});
+
describe("deleting requests", () => {
test("only when not sent; takes the signatures and signed document with it", async () => {
const { tenant, env } = await environment("sandbox");