import { type ConnectionActivity, type ConnectionActivitySource, type ConnectionCredential, type ConnectionId, type ConnectionProbe } from '@substrat-run/contracts'; import type { ConnectorHandler, ConnectorOptions, FetchLike, ScopeHost } from '@substrat-run/kernel'; export { ScriveApi, ScriveApiError, SCRIVE_TESTBED, SCRIVE_PRODUCTION } from './api.js'; export { ScriveMock, type ScriveMockOptions } from './mock.js'; export { renderPdf } from './pdf.js'; /** * The Scrive connector — the OUTBOUND half of external signing. * * `engine-protocol` emits `protocol.signatures-requested` when a vertical * freezes a document and sends it for signature. This turns that into a Scrive * document: create → set file → set parties (BankID) → start. * * ## The return path exists now (#97), but nothing schedules it yet * * The outbound half above is verified against the real testbed. The return path * — recording a completed signature back onto the protocol instance — is * `reconcileScriveDispatch` below. It could not be written until #97: a * signature lives in the SCOPE database, `getScope` demands a `PrincipalId`, and * a connector is not one. #97 gave a connection its own door * (`getConnectorScope`) and made its authority an ordinary permission grant, so * the driver records a signature by invoking `protocol/record-signature` as the * connection itself. * * What each earlier gap became: * * 1. **Recording the provider's document id / dispatch idempotency.** Solved * without #97 by a directory-side ledger (`ctx.admin.putConnectorState`): a * redelivery finds the row and skips instead of sending a SECOND document. * Directory-side because a connector runs INSIDE the scope's dispatch and * re-entering the scope actor deadlocks. A narrow residual remains (ledger * write failing after `start` succeeds) — closable with provider-side dedup * via the `substrat_instance` tag. * 2. **Recording a signature.** Solved by `reconcileScriveDispatch` on the #97 * seam — a top-level operation, OUTSIDE any dispatch, so re-entering the scope * is safe. `sweepScriveReconciliations` is the poll driver over it: it * enumerates the dispatch ledger (`listConnectorState`) and reconciles every * outstanding instance, so completion needs no per-instance caller. * * Both triggers for the return path exist now (#96): * * - **Poll, the floor:** `sweepScriveReconciliations` runs on the platform * sweeper — `startPlatformSweeper` in a node deployment (live in * `demos/meridian/src/server.ts`), `definePlatformSweeperDO`'s alarm on * Cloudflare — and is the source of truth that survives lost callbacks. * - **Push, the latency layer:** `handleScriveCallback` verifies a capability * URL (Scrive callbacks carry no signature, so the URL's minted token is the * whole authentication) and runs the same reconcile immediately. The * deployment mounts `SCRIVE_CALLBACK_ROUTE` and configures `callbackUrl`. */ export interface ScriveConnectorOptions { /** * The provider base, REQUIRED (#990). `SCRIVE_TESTBED` or `SCRIVE_PRODUCTION` — the * latter needs a paid licence. * * Deliberately not defaulted. It used to fall back to the testbed, which is right for a * developer and wrong for a deployment: a production credential sent to the testbed * comes back 401, indistinguishable from a mistyped key, and that is exactly how * production called the testbed for weeks (#610). A deployment now names its * environment in the type system, not in a comment. */ baseUrl: string; /** * What `authLevel: 'basic'` means for THIS connection (#620) — the Scrive * authentication method a party gets when the request asks for no more than * control of a contact address. * * `'standard'` by default, and that default is the fix: the connector used to * hardcode `se_bankid` for every external party, which Scrive refuses with * `409 … requires valid personal number field` unless the party carries a * personnummer — one this platform deliberately never supplies (B6). Every * document this connector had ever sent failed on it. * * A deployment whose signatories all use BankID and which supplies personal * numbers by some other means can set `'se_bankid'` here; a request asking for * `authLevel: 'strong'` overrides it either way. */ defaultAuthMethod?: 'standard' | 'se_bankid'; /** * Where Scrive should POST status changes (#96, the push layer). * * Scrive's callbacks are **unauthenticated** — there is no signature to * verify — so this must be a capability URL (an unguessable secret in the * path), and a callback must never be trusted as a fact. It is a hint to * re-read `documents/{id}/get`. Optional because polling alone is a complete * strategy: the sweep is the floor, push only collapses its latency. * * The `ref` carries everything `handleScriveCallback` resolves the hint by — * the connection, the instance, and the freshly minted capability token — * and `scriveCallbackPath(ref)` is the canonical way to lay them into a * path. A deployment supplies only its public base: * `(ref) => `${base}${scriveCallbackPath(ref)}``. */ callbackUrl?: (ref: ScriveCallbackRef) => string; } /** * What a Scrive callback URL must carry for the ingress to resolve it without * trusting anything in the request body (#96): which connection, which * dispatched instance, and the capability token minted for exactly that * dispatch. The ids are routing, not secrets; the token is the entire * authentication, because the provider offers none. */ export interface ScriveCallbackRef { connectionId: string; instanceId: string; token: string; } /** * The canonical callback path — mint side and ingress side agree through this * one function, so a deployment mounts one route shape and never re-derives it. */ export declare const scriveCallbackPath: (ref: ScriveCallbackRef) => string; /** The same path as a `:param` route pattern, for mounting the ingress. */ export declare const SCRIVE_CALLBACK_ROUTE = "/hooks/scrive/:connectionId/:instanceId/:token"; /** * The standing grants this connector's RETURN path needs (#726 gap 3). * * Declared here because this is the only place that knows: the connector is what * calls `protocol/record-signature` and lands the sealed PDF through the * `attachmentTargets` write gate. Before this existed the requirement lived in prose * — a README line and a CHANGELOG entry — while the dashboard's catalog hardcoded a * list beside it, and nothing checked that the two agreed. They did not: `attach` * was missing from a live connection for months, failing the sealed-copy landing into * a `skipped` reason nobody reads, and it was found by a human reading a diff on an * unrelated PR (#716). * * `pnpm lint:connector-grants` is what makes this load-bearing rather than a second * piece of prose: a requirement no dashboard door can carry is a red in CI, not a * dispatch that dead-letters months later. * * **`protocol:read` is deliberately absent.** Reading the bound document is a * per-dispatch act, and since #726 its authority is the delivery itself — the host * admits the attachments of the entity the delivered event names. There is no standing * grant to hold, so there is none to miss. Only the return path, which runs top-level * with no delivered event behind it, still needs standing authority — which is exactly * what this list is. */ /** * The label sent for the non-signing author party (#852). * * Cosmetic by construction: Scrive overwrites the author's name and email with the * account holder's, so this string never reaches a signatory. It exists so that a * developer reading an outbound payload can tell the sender party from a signatory, * and so the reconcile has a name to skip rather than a blank. */ export declare const SENDER_PARTY_LABEL = "Avsandare (Scrive-kontot)"; export declare const SCRIVE_CONNECTION_GRANTS: readonly ['protocol:record-signature', 'protocol:attach']; /** * What the connector remembers about a dispatch, stored per-connection in the * directory (`ctx.admin.putConnectorState`). * * Two jobs. **Outbound idempotency:** a redelivery finds this row and skips * instead of creating a second document. **The return path (#97):** the poll * driver reads it to map a signed provider party back to the scope operation * that records it — which needs, per party, the `requestId` it resolves and the * `signatory` to attribute it to, plus the frozen `contentHash` `recordSignature` * checks the provider against, and the `vertical` to reopen the connection under. * None of that is derivable from Scrive's document, so it is captured here at * dispatch, when the event still carries it. */ export interface ScriveDispatchState { documentId: string; instanceId: string; scopeId: string; tenantId: string; /** The scope's vertical — half the key that reopens the connection to poll. */ vertical: string; /** * The frozen content hash from `protocol.signatures-requested`. Reported back * verbatim on record: `recordSignature` re-derives the frozen hash and refuses * a signature whose reported hash disagrees, so the document that was signed is * provably the document that was frozen. */ contentHash: string; /** * The dispatched parties, in the order sent to Scrive — which is the order * Scrive returns them, so the Nth provider party is this Nth entry. Carries * what `recordSignature` cannot get from the provider: the `requestId` to * resolve and the substrat `ref`/`kind` to attribute the signature to. */ parties: { requestId: string; label: string; kind: 'principal' | 'external'; /** The substrat signatory, when known up front; null when identity is only learned at signing. */ ref: string | null; }[]; /** * The non-signing author party sent AHEAD of `parties` (#852), when one was. * * `parties` stays exactly the substrat signatories, so everything that counts * signatures or renders progress is unchanged. What moved is the provider index: * the Nth signatory is now provider party N+1, because party 0 is the sender. * Recording it here rather than assuming it is what keeps a dispatch made by the * PREVIOUS version readable — that state has no `senderParty`, the offset is 0, * and its reconcile behaves exactly as it did before. */ senderParty?: { label: string; }; /** * The attachment whose bytes were SENT (#711), when the vertical bound one — absent * when the signatory was shown this connector's own attestation sheet. * * Recorded because it is the only durable answer to "what did the counterparty * actually read": Scrive holds the bytes but not their provenance, and the instance's * binding can be re-bound after the dispatch. The activity projection reads it, which * is how an operator tells a real contract from a fallback sheet at a glance. */ documentAttachmentId?: string; /** Requests already recorded by a prior poll — so a re-poll is a no-op, not a double. */ recordedRequestIds?: string[]; /** * The capability token minted for this dispatch's callback URL (#96) — present * exactly when the connector was configured with `callbackUrl`. The ingress * compares a presented token against this in constant time and answers * uniformly otherwise; a row without one (older dispatch, poll-only config) * simply has no callback door, and the sweep remains its only trigger. */ webhookToken?: string; /** * The attachment id of the sealed signed PDF, once landed (#476 step 2). Set after the * document closes and the file is fetched and stored via the blob-store attachment * surface; its presence is what makes a re-poll skip the download rather than land a * second copy — the connection can write attachments but not list them, so the ledger is * the only idempotency handle. */ sealedAttachmentId?: string; dispatchedAt: string; } /** * Build the handler. Register it with `host.registerConnector`. * * Only reacts to `method: 'scrive'` — a vertical asking for BankID through * another provider emits the same event, and this must not answer for it. */ export declare function scriveConnector(options: ScriveConnectorOptions): ConnectorHandler; /** * Register the connector on a host. * * `maxAttempts` is deliberately higher than the executor default: a provider * being briefly unreachable is ordinary, and giving up on a signature request * after five tries would be giving up on a contract. */ export declare function registerScriveConnector(host: ScopeHost, options: ScriveConnectorOptions & { id?: string; retry?: ConnectorOptions; }): void; /** * Register the connector for ROUTING ONLY — a CP-less host that will never run the * handler (`demos/meridian/src/worker.ts`). * * A dispatch vertical registers so its host knows which events are connector * deliveries: the drain then routes each one onto the platform-requests surface as a * `connector:scrive` intent, and the control plane — which holds the directory, the * sealed credential and the egress — is what dispatches it. * * Its own function rather than `registerScriveConnector(host, {})` (#990). Now that * `baseUrl` is required, "no provider base" is a statement — this host does not call * the provider — instead of an omission that used to silently mean the testbed. */ export declare function declareScriveConnector(host: ScopeHost, options?: { id?: string; retry?: ConnectorOptions; }): void; /** The outcome of reconciling one dispatched instance against the provider. */ export interface ScriveReconcileResult { /** The provider document reconciled. */ documentId: string; /** Scrive's current document status (`pending`, `closed`, `rejected`, …). */ documentStatus: string; /** Requests recorded as signed on THIS run (empty if nothing new completed). */ recorded: { requestId: string; signedAt: string; }[]; /** Parties the provider reports as signed that the driver could not record, and why. */ skipped: { requestId: string; reason: string; }[]; /** True once every party in the set has been recorded into the scope. */ complete: boolean; /** * The sealed signed PDF's fate this run (#476 step 2): `{ attachmentId }` when it was * fetched and landed, `{ skipped }` with a reason when the document is not yet closed, * already landed, or the store was unreachable (retried next poll). Absent when the * document never reached completion. */ sealedDocument?: { attachmentId: string; } | { skipped: string; }; } /** * The RETURN path (#97): read the provider's state for one dispatched instance * and record any completed signatures back into the scope. * * This is the half the connector could not do until #97 landed. A provider's * signature has to be written onto the protocol instance in the SCOPE, and a * connector is not a `PrincipalId`, so `getScope` could not let it in. #97 gives * the door a connection can walk through — `getConnectorScope(connectionId, * scopeId)` returns a stub whose authority is the connection itself, and what it * may do is an ordinary permission check against `connection:` grants. So * this records a signature by invoking `protocol/record-signature` on that stub; * it works iff the connection was granted `protocol:record-signature` * (`grantToConnection`), which appears in the permission diff like any grant. * * **Why a top-level function and not the dispatch handler.** A connector runs * INSIDE the scope's dispatch, and re-entering the scope actor from there * deadlocks (the reason dispatch idempotency lives in the directory, not the * scope). Recording runs as its own top-level operation, outside any dispatch — * which is exactly what a poll driver or a callback ingress is. Neither exists * yet (nothing schedules this — issue #96); this is the reconcile step both will * call, made correct and testable now, invoked by hand or by a test until a * scheduler lands. * * Idempotent by construction: signed requests are remembered in the ledger, so a * re-poll of a half-signed set records only what is newly done, and re-polling a * fully-signed set records nothing. * * `fetch` is passed in because sanctioned egress is the host's to own and it * exposes no top-level opener; the same `fetch` the host was built with is * bound here to the connection (with health recorded via `recordConnectionUse`), * mirroring what the dispatch context does internally. */ export declare function reconcileScriveDispatch(host: ScopeHost, connectionId: ConnectionId, instanceId: string, options: { fetch: FetchLike; baseUrl: string; timeoutMs?: number; }): Promise; /** * The identity half of a connection, as the directory holds it — everything the two * read paths below need to reopen the credential. Deliberately not the whole * `Connection`: these functions reopen a connection, they do not inspect one. */ export interface ScriveConnectionRef { id: ConnectionId; tenantId: string; vertical: string; } /** * **Probe** the credential (#605): one cheap authenticated read, answering whether * Scrive accepts these keys and whose account they act as. * * Why this exists. A connection's health (§3.7) is written by whatever call happened * last, so a freshly connected credential carries no health at all until the first real * dispatch — which for this connector means a legal document going out to real * signatories. Verifying at connect time is the difference between finding a typo now * and finding it in a failed signing request days later. * * A rejected credential is a RESULT, not an exception: `{ ok: false, error }` carries * the provider's own words, and "This feature is disabled" reading differently from * "No valid access credentials were provided" is the entire point. Failures that are * not the provider's answer — no live connection, a secret that cannot be unsealed — * still throw, because those are platform faults and must not read as a bad key. * * Rides the same connection-bound `fetch` as every other call here, so a probe also * refreshes `lastOkAt` / `lastError` — verifying is itself a use. */ export declare function probeScriveConnection(host: ScopeHost, connection: ScriveConnectionRef, options: { fetch: FetchLike; baseUrl: string; timeoutMs?: number; }): Promise; /** * **Probe a credential that is not stored yet** (#605) — the connect-time check. * * The gap this closes: connecting used to mean *storing*. The row was written, the UI * said "Connected", and the first evidence that Scrive disagreed arrived on the next * dispatch or sweep — by which point a signature request had already failed. Worse for a * ROTATION, where writing first replaces a working credential with a broken one. * * So this takes the candidate secret directly, touches no connection and no store, and * lets the caller decide before anything is written. It records no health for the same * reason: there may be no connection to record it against, and a candidate's failure is * not a fact about the live connection. * * `refused` on the result is what makes the answer actionable — see the field's own note: * a 401/403 is grounds to reject the connect, an unreachable provider is not. */ export declare function probeScriveSecret(secret: Record, options: { fetch: FetchLike; baseUrl: string; timeoutMs?: number; }): Promise; /** * **What this connection has done** (#605) — the dispatch ledger, projected into the * platform's declared activity shape. * * The ledger is the only durable record that an outbound call ever happened: the audit * log deliberately holds none (`openConnection` is unaudited — one row per outbound HTTP * call would drown the log that matters) and health keeps exactly one line. So this is * the read that answers "has anything gone out, and what happened to it". * * **The projection is the redaction.** A ledger row carries the callback capability * token — the entire authentication of the webhook door — so the row must never be * served raw. Mapping it here, field by field into a declared shape, is what makes * that structural rather than something a route has to remember. * * `live` joins the provider's CURRENT status onto each row with ONE `documents/list` * call. A provider failure degrades to the ledger's own view (`live: false`) rather * than failing the read: a console that can't reach Scrive should still show what was * sent. The flag travels in the result precisely so the UI never presents a stale * ledger status as the provider's truth. */ export declare function scriveConnectionActivity(host: ScopeHost, connection: ScriveConnectionRef, options: { fetch: FetchLike; baseUrl: string; timeoutMs?: number; live?: boolean; /** `provider` lists Scrive's own archive instead; see {@link scriveProviderDocuments}. */ source?: ConnectionActivitySource; }): Promise; /** * **The provider's own archive** (#605) — `documents/list`, straight through. * * A different question from the ledger, and worth asking separately: this shows every * document in the Scrive account, including ones nobody here created (someone working in * Scrive's own UI) and ones sent before this connection existed. The ledger cannot show * those, and this cannot show a dispatch the platform recorded but Scrive never received. * Two views, both true, neither a superset — which is why `source` travels in the answer. * * Rows the platform DID send are marked as such: the connector tags every document it * creates with `substrat_instance`, so "sent from here" is a fact the provider itself * carries rather than a join this has to guess at. * * Unlike the ledger read, a provider failure here THROWS. There is no degraded view to * fall back to — an empty list would read as "the account is empty", which is a lie. */ export declare function scriveProviderDocuments(host: ScopeHost, connection: ScriveConnectionRef, options: { fetch: FetchLike; baseUrl: string; timeoutMs?: number; max?: number; }): Promise; /** * **The stored credential, as a console may see it** (#605). * * The four-part OAuth1 credential goes into the store and never comes back out — that * rule is why `Connection` cannot carry a secret. But it left an integrations screen * where "connected" and "connected with a mistyped token" looked the same, and the only * repair on offer was to paste all four fields again blind. * * So the connector — the only party that knows which of ITS fields are identifiers and * which are secrets — answers a reduced view. Scrive's own UI calls two of the four * "credentials identifier"; those are shown whole, because an identifier that cannot be * read identifies nothing. The two secrets are reduced to a bullet run and their last * four characters: enough to tell two credentials apart at a glance, not enough to sign * a request with. Anything shorter than eight characters is masked entirely rather than * mostly revealed. */ export declare function scriveCredentialSummary(host: ScopeHost, connection: ScriveConnectionRef): Promise; /** What one sweep of a connection's outstanding dispatches did. */ export interface ScriveSweepResult { /** Dispatch ledger rows enumerated for the connection. */ found: number; /** Rows the ledger already shows fully recorded — not polled against the provider. */ skipped: number; /** Rows reconciled against the provider this sweep. */ polled: number; /** Instances that reached "every party signed" this sweep. */ completed: string[]; /** Instances polled but still awaiting at least one signature. */ outstanding: string[]; /** Per-instance reconcile failures; the sweep continues past them. */ failed: { instanceId: string; error: string; }[]; } /** * The SCHEDULER's unit of work (#96, poll path): reconcile every outstanding * dispatch for one connection against the provider. * * `reconcileScriveDispatch` records the signatures for ONE known instance; this * is what finds the instances. It enumerates the dispatch ledger * (`listConnectorState(connectionId, 'scrive:dispatch:')` — the read that method * exists for) and reconciles each row that is not already fully recorded. A * timer calls this; it holds no timer itself. That keeps the trigger a * deployment concern (a Cloudflare cron or Durable Object alarm, the same home * `drainDue` still needs) and this a plain, testable function. * * Robust by construction, because a poller must be: a row already complete per * the ledger is skipped without touching the provider (so a finished signature * is not re-fetched on every tick), and a provider error on one instance is * recorded and stepped over rather than sinking the batch. Idempotent — running * it twice over the same state records nothing the second time. * * Scoped to one connection deliberately: a connection is (tenant, vertical, * provider), so a sweep never crosses a tenant. A platform sweeper iterates the * connections it is responsible for and calls this for each. */ export declare function sweepScriveReconciliations(host: ScopeHost, connectionId: ConnectionId, options: { fetch: FetchLike; baseUrl: string; timeoutMs?: number; }): Promise; /** What the ingress did with one callback. */ export type ScriveCallbackOutcome = /** Token verified; the reconcile ran. `result` is the same shape a poll returns. */ { accepted: true; result: ScriveReconcileResult; } /** * Not verified — unknown connection, unknown instance, no callback door on * the row, or a token mismatch. Deliberately ONE shape for all four: which it * was is logged server-side, never answered to the caller, so the response is * no oracle for probing which instances exist. */ | { accepted: false; reason: string; }; /** * The webhook INGRESS (#96) — the push half of the return path, beside the poll. * * Scrive's callbacks are unauthenticated and their bodies are untrusted, so * this takes no body at all: the capability URL is the entire input. Verify the * token against the dispatch ledger row, and on a match run the SAME * `reconcileScriveDispatch` the sweep runs — re-fetch the provider's truth, * record what is newly signed, idempotently (connections.md §5: a webhook is a * cache invalidation, not a fact). * * That design is also the replay protection. A replayed or stale callback * cannot assert anything — it only triggers another idempotent reconcile whose * facts come from `documents/{id}/get` — so a seen-set with a retention window * (the shape #96 sketched for providers that sign their callbacks) has nothing * to protect here. The cost of a replay is one provider read. * * Fail-closed and quiet: anything short of a verified token returns * `{ accepted: false }` with a uniform shape and NO provider egress — an * attacker without the token cannot make this function fetch. A reconcile * failure after verification throws (the caller should answer 5xx so the * provider retries); the poll floor covers whatever push drops. */ export declare function handleScriveCallback(host: ScopeHost, ref: ScriveCallbackRef, options: { fetch: FetchLike; baseUrl: string; timeoutMs?: number; }): Promise; //# sourceMappingURL=index.d.ts.map