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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 2 additions & 4 deletions apps/api/src/routes/sign.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { authorizeLiveSender } from "@coss/email";
import {
cancelSignRequest,
createSignSession,
getSignDocument,
getSignRequest,
Expand All @@ -12,7 +11,6 @@ import {
requestSignReminders,
type SignActor,
type SignService,
sendSignRequest,
toSignSettingsObject,
updateSignSettings,
} from "@coss/sign";
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions apps/site/content/docs/sign/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ sealed, so any later change shows.
</Card>
</Cards>

## Pricing

Each completed request is <Rate meter="sign.completed_requests" />, 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
<Included meter="sign.completed_requests" />: 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`,
Expand Down
5 changes: 4 additions & 1 deletion apps/site/content/docs/sign/testing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import { authorizeLiveSender } from "@coss/email";
import {
cancelSignRequest,
createSignBuilderSession,
deleteSignTemplate,
requestSignReminders,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/lib/sign.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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. */
Expand Down
4 changes: 2 additions & 2 deletions apps/worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions packages/billing/src/catalog/meters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 4 additions & 2 deletions packages/billing/src/catalog/plans.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
*/
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 4 additions & 0 deletions packages/billing/src/catalog/rates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) },
Expand Down
2 changes: 1 addition & 1 deletion packages/billing/test/engine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 15 additions & 7 deletions packages/sign/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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).
4 changes: 3 additions & 1 deletion packages/sign/src/complete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`. */
Expand Down Expand Up @@ -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.`,
Expand Down
46 changes: 46 additions & 0 deletions packages/sign/src/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -108,6 +109,10 @@ export type SignService = {
input: Parameters<typeof uploadSignDocument>[1],
): Promise<SignDocumentObjectOutput>;
deleteDocument(input: Parameters<typeof deleteSignDocument>[1]): Promise<void>;
/** `sendSignRequest`, under the plan's cap. */
send(input: Parameters<typeof sendSignRequest>[0]): Promise<SignRequestObjectOutput>;
/** `cancelSignRequest`, giving a sent request's reservation back. */
cancel(input: Parameters<typeof cancelSignRequest>[0]): Promise<SignRequestObjectOutput>;
deleteRequest(input: Parameters<typeof deleteSignRequest>[1]): Promise<void>;
createTemplate(
input: Parameters<typeof createSignTemplate>[1],
Expand All @@ -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;
};
Expand Down Expand Up @@ -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<void>;
},
now: Date,
): Promise<SignRequestObjectOutput> {
Expand All @@ -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 },
Expand All @@ -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<SignRequestObjectOutput> {
if (!(await environmentIsSandbox(input))) {
const [found] = await db
Expand Down Expand Up @@ -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,
);
Expand All @@ -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<SignRequestObjectOutput> {
return transition(
input,
Expand All @@ -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,
);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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),
Expand Down
Loading
Loading