@substrat-run/connector-scrive
v0.14.68
Published
Substrat connector: Scrive eSign (Swedish BankID). Turns a protocol.signatures-requested event into a Scrive signing flow (create → set file → parties → start) and records the completed signature back into the scope through the #97 authority seam. Host co
Readme
@substrat-run/connector-scrive
Scrive eSign (Swedish BankID) for Substrat: turns a vertical's signature request into a real Scrive signing flow, and records the completed signatures back into the scope. A connector is host code — you register it on a scope host; it is never module code.
Full documentation: https://substrat.net/connectors/scrive
What it does
Two halves, both built and tested against the real testbed API.
Outbound. engine-protocol emits protocol.signatures-requested when a vertical freezes a
document and sends it for signature. The connector (only for method: 'scrive') turns that into a
Scrive document: create → set file → set parties → start. Each party's authentication method
comes from the request's provider-agnostic authLevel (basic → standard, the default;
strong → se_bankid), falling back to the connection's defaultAuthMethod. A se_bankid party
is sent an empty personal_number field, which is all Scrive's auth-to-sign check wants — the
signatory fills it in during the ceremony, and no personnummer ever enters this platform.
It records each dispatch in a directory-side ledger (putConnectorState, keyed by the connection)
so an at-least-once redelivery skips instead of creating a second document — duplicate legal
paperwork to real signatories. Directory-side because a connector runs inside the scope's
dispatch and re-entering the scope actor deadlocks.
Inbound. Once parties sign at Scrive, reconcileScriveDispatch(host, connectionId, instanceId,
{ fetch }) reads documents/{id}/get, maps each signed party back to its request, and records
the signature onto the protocol instance by invoking protocol/record-signature through
getConnectorScope — the connection acting as itself (#97),
a top-level operation (not the dispatch handler, where re-entering the scope deadlocks). It
re-checks the provider-reported content hash against the frozen one and fails closed on a
mismatch, and it is idempotent across polls. sweepScriveReconciliations(host, connectionId,
{ fetch }) is the poll driver over it: it enumerates the dispatch ledger
(listConnectorState(id, 'scrive:dispatch:')) and reconciles every outstanding instance, skipping
those already complete and stepping past a provider error on any one.
Using it
import {
declareScriveConnector,
registerScriveConnector,
sweepScriveReconciliations,
SCRIVE_TESTBED,
SCRIVE_PRODUCTION,
} from '@substrat-run/connector-scrive';
// 0. ONE provider base for this deployment. Everything below takes it — registration,
// the sweep, the callback ingress — so a deployment cannot dispatch to production
// and poll the testbed.
const baseUrl = SCRIVE_TESTBED; // or SCRIVE_PRODUCTION
// 1. Register the connector on the scope host (host code, like an engine module).
// `baseUrl` is REQUIRED — there is no default, deliberately: the old fallback was the
// testbed, and a production credential sent there comes back 401, indistinguishable
// from a mistyped key. A CP-less vertical, which registers only so its host knows
// which events are connector deliveries, uses `declareScriveConnector(host)` instead —
// it never calls the provider, so it names no base.
registerScriveConnector(host, { baseUrl });
// 2. Open a connection with the OAuth1 credential, and grant it the one permission
// that lets it write a signature back — held by NO human role.
await host.admin.createConnection(actor, {
id, tenantId, vertical, provider: 'scrive', label,
secret: { clientId, clientSecret, tokenId, tokenSecret }, // sealed by the host's SecretBox
});
await host.admin.grantToConnection(actor, {
connectionId: id, permission: 'protocol:record-signature', node, grantedBy: actor,
});
// 3. Schedule the poll — YOUR deployment calls the sweep on a timer. Both triggers ship:
// The sweeper is bound to the SAME `baseUrl` step 1 registered with:
const sweeper = (h, id, o) => sweepScriveReconciliations(h, id, { ...o, baseUrl });
// Node: startPlatformSweeper(host, { sweepers: { scrive: sweeper }, intervalMs })
// Cloudflare: definePlatformSweeperDO (@substrat-run/adapter-cloudflare) — a self-re-arming
// Durable Object alarm whose pass calls runPlatformSweep(host, { sweepers: { scrive: … } }).
// An alarm rather than a cron because a vertical pushed into a Workers-for-Platforms
// dispatch namespace gets no `triggers.crons`; where a cron IS available, point
// scheduled() at the DO's ensureArmed() as a safety net.The credential is Scrive's OAuth1 "personal access credentials" — four parts that combine into a
PLAINTEXT signature ({ clientId, clientSecret, tokenId, tokenSecret }), not OAuth2 bearer. A
signatory's personnummer is passed through to Scrive on the signing request and never stored:
it is direct PII, and engine-protocol records an opaque DataSubjectId as the signatory
instead. The host needs a SecretBox configured to seal the credential at rest.
Caveats worth knowing
Your deployment must schedule the poll (step 3). The connector provides the driver; it cannot hold a timer — that is a deployment concern. Both triggers now exist off the shelf:
startPlatformSweeper(node, a self-rescheduling interval) anddefinePlatformSweeperDO(@substrat-run/adapter-cloudflare, a self-re-arming Durable Object alarm — works in a Workers-for-Platforms dispatch namespace, where crons do not). Without one wired, dispatch works but signatures are never recorded back. Note the sweep enumerates connections throughhost.admin.listConnections, so the deployment that runs it must hold the connection directory — a control-plane-less vertical worker (scope-local-permissions.md Phase 3) cannot sweep until its connections are reachable from its runtime.No party carries an address, so no counterparty can be invited. Probed against the testbed (#687): a party with only a name draws
409 invalid_invitation_delivery_info— "Invitation delivery for participant #2 requires valid email field" — atbasicas much as atstrong.protocol.signatures-requestedcarries no contact, andScriveParty.emailis therefore never populated. This is the live blocker, and it is one carrier away (docs/architecture/signature-contact-carrier.md); every other caveat here is downstream of it.CLOSED by #852. The author is now a party of its own that does not sign, so no party the vertical names is ever the author, and every one of them is invited normally. The dispatch refuses before egress when any party carries neither email nor mobile, naming the party — which is the companion invariant this caveat asked for. Both are asserted in
test/dispatch.test.ts.
The author is the account, and the API does not say so
Worth stating plainly, because it is not in Scrive's documentation and it decides who signs:
Scrive binds the author party to the API account holder and silently overwrites the name and
email you send on it. Measured against api-testbed.scrive.com, not inferred — a party sent
as Not The Account Holder <[email protected]> with is_author: true comes back carrying
the account's own name and address, with no error and no warning. Sending no author party does
not avoid it: Scrive claims party #1 and overwrites that instead.
Two consequences, both of which reached production before this was understood:
- While the issuing party was the author, the Scrive account owner signed for the sender's organisation — whoever the vertical actually named. A live vertical sent an agreement naming one person and a different person was invited to sign it.
- The return path could never record that signature.
reconcileScriveSignaturesrefuses to attribute when the provider's party name disagrees with the dispatched label — a fail-closed guard doing exactly its job — and the substituted name never agrees.
So the connector sends the account as a non-signing author (is_author: true,
is_signatory: false → signatory_role: "viewer"), and every party the vertical names is an
ordinary signatory that keeps its own identity. A named signatory whose address happens to equal
the account holder's stays a separate signing party rather than folding into the author — also
verified live.
ScriveDispatchState.senderParty records that the sender was sent, because for a dispatch with no
stored party ids the Nth signatory sits at provider party N+1. State written before #852 has no
senderParty and reads an offset of 0.
A signature is matched by Scrive's party id (#1927), which a dispatch records from start's
answer. The id survives the name and address edits a sender or signatory can make at Scrive.
A dispatch recorded before ids were kept has its ids pinned by position on the first poll, but only when position is supported by evidence. The party count and roles must be unchanged. And every signing party's name must still be its label, or exactly one must differ. That one's label must be on no other signing party, and its new name must not be another party's label. Nothing is pinned when two dispatched parties share a label, because their names cannot tell a swap from no change. Two parties that changed places, or two renamed at once, are therefore never pinned to each other's requests.
Without pinned ids, a party is attributed by position only when its slot shows its exact label
and no other signing party does. Everything else lands in needsAttention, which the
connection's activity view shows.
The live BankID signing round-trip is unverified. The outbound lifecycle is checked against
api-testbed.scrive.com, butse_bankid-to-sign is disabled on the testbed account (start→ 409authentication_to_sign_method_disabled), so the actual signature — and Scrive's real signed-getparty shape and order — have only been exercised againstScriveMock. Because the reconcile fails closed on a party-shape mismatch, a wrong assumption skips (visibly, in the sweep result), never mis-records. It stays a0.xrelease for this reason.What is no longer true: that
authLevel: 'strong'cannot be satisfied.0.7.0refused it before egress, reasoning that Scrive's BankID auth-to-sign needs apersonal_numberon the party and Substrat may carry no personnummer (design rule B6 — it reaches neither the kernel, the events, nor the audit trail). The requirement is real; the inference was wrong. Scrive validates that the field is present, not that it holds a value: an emptypersonal_numberdraws exactly the samestarterrors as a filled one, and the signatory completes it during the BankID ceremony.updatesends that empty field for everyse_bankidparty,strongmaps straight through, and no PII carrier is needed for the auth level. (BankID agrees on direction: since API v6 it does not accept apersonalNumberfrom the relying party at all.)test/live.test.tsasserts this against the testbed, including the control case that shows the error returning when the field is absent.It sends the vertical's document when one is bound, and its own attestation sheet when none is (#711). Rendering the real document belongs to the vertical — a connector cannot read another module's tables — so the vertical uploads its rendered file onto the protocol instance and names it when binding:
const doc = await attachments.upload({ entity: { entityType: 'protocol', entityId }, … }); await scope.invoke('protocol/bind-document', { instanceId, contentRef, contentHash, documentAttachmentId: doc.id });protocol.signatures-requestedthen carriesdocumentAttachmentId, andcreateopens it (conn.openAttachmentStream, on the very connection it is about to send with) and streams those bytes intosetfileunder the vertical's own filename, never holding the whole file. Bytes that fail their integrity check part-way abort the upload before it completes, so Scrive never gets a file from them; the draft document is left behind, nostartfollows, and the delivery is retried or dead-letters as any failure does. With nothing bound, today's one-page sheet goes out unchanged — the template, the parties, and the content hash — which stays the honest artifact for a caller that renders nothing.Three things worth knowing about the shape:
- The connection needs
protocol:read(grantToConnection), on top ofprotocol:record-signatureandprotocol:attach. It is a permission-diff line. - A bound-but-unreadable document is a hard failure, not a quiet fallback. Once a
vertical has said which bytes its signatory must see, sending different paper instead is
the exact failure this closed — quieter than a refusal and worse, because a document still
goes out and a counterparty still signs it. So a missing grant or a deleted attachment
dead-letters; the ledger row is written only after
start, so the retry after the fix sends the right document. - The id is named, never searched for. The return path lands the sealed SIGNED copy on this same instance, so a connector that picked "the document on this instance" could mail a counterparty their own signed contract to sign again. Naming an id makes that unrepresentable rather than merely unlikely.
What is signed is still the hash: this changes the bytes shown to the signatory, not the identity of what the signature attests to.
bindDocumentis where the two are reconciled — it refuses an attachment that is not on the instance being bound, and carries the kernel's ownsha256of the bytes ontoprotocol.content-bound.What was never true, in either direction: this caveat first said the store did not exist, then that only the connector was missing a line.
attachmentTargetshas been implemented in both adapters since #473, and this connector already wrote through it on the return path — so the store was never missing. But the outbound leg needed a read the platform genuinely did not have, and neither reading told the truth about where the work was:- on
adapter-sqlitea connector runs INSIDE the scope's actor task, and the ordinary attachment surface re-enqueues per verb — reading from a dispatch wedged the scope (packages/adapter-sqlite/test/connector-reads.test.tspins it); - on the hosted Cloudflare path only
uploadcrossed the/internalseam, so the control plane held the credential while the vertical held the bytes.
Both are closed:
ScopedConnectorConnection.openAttachment— the read hangs off the connectionctx.connection(provider)returns, so it is authorized as the credential the dispatch is actually using and cannot drift from it — andopennow crosses the delegation seam besideupload.listdeliberately does not; see the third bullet above.- The connection needs
Verified against the testbed
The API layer was checked against api-testbed.scrive.com, not just the docs — and the first
version, written from the docs, was wrong in three ways one live call exposed at once:
- auth is OAuth1 PLAINTEXT, not OAuth2 bearer (the UI's "Client" + "Token" credentials are
two halves of one four-part signature; the
oauth2.scrive.comendpoint rejects them) documents/newreturns nostatus— onlygetdoes, so mutation responses are parsed for their id and status is re-readsetfileismultipart/form-data, not a base64 body
test/live.test.ts runs the real lifecycle (new → setfile → update → get), what start
validates, and that Scrive accepts a document this codebase did not render (caveat 4), when
connectors/scrive/.dev.vars holds a complete OAuth1 credential; it skips
otherwise — so CI without secrets stays offline and a local run against the testbed verifies the
actual API. Nothing it creates is delivered: no document reaches pending (the account setting in
caveat 3 sees to that), no party carries a real address, and every document is cancelled and
deleted.
The start tests read Scrive's error list rather than a single message, which is what lets
them assert the interesting thing — which errors are absent. authentication_to_sign_method_disabled
is present in all of them and cannot be avoided from this account; everything else is controlled
by the party shape the connector builds. That the suite never called start at all is why the
personal_number 409 was discoverable only in production.
Testing
ScriveMock implements the endpoints in memory, so the whole lifecycle runs without a provider
account — credential resolution, egress, health, retry, and the dispatch → sign → reconcile loop
(a test signs the mock's parties, then drives runPlatformSweep).
What a mock proves: that our shape works. What it cannot prove: that our reading of
Scrive's API is correct at the one step the testbed cannot reach — the BankID signature and its
get shape. The mock is our reading; green here meant ready to check against the testbed, and
the outbound half now has been (caveat 2 is the residue).
