npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

  1. 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) and definePlatformSweeperDO (@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 through host.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.

  2. 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" — at basic as much as at strong. protocol.signatures-requested carries no contact, and ScriveParty.email is 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. reconcileScriveSignatures refuses 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.

  1. The live BankID signing round-trip is unverified. The outbound lifecycle is checked against api-testbed.scrive.com, but se_bankid-to-sign is disabled on the testbed account (start → 409 authentication_to_sign_method_disabled), so the actual signature — and Scrive's real signed-get party shape and order — have only been exercised against ScriveMock. Because the reconcile fails closed on a party-shape mismatch, a wrong assumption skips (visibly, in the sweep result), never mis-records. It stays a 0.x release for this reason.

    What is no longer true: that authLevel: 'strong' cannot be satisfied. 0.7.0 refused it before egress, reasoning that Scrive's BankID auth-to-sign needs a personal_number on 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 empty personal_number draws exactly the same start errors as a filled one, and the signatory completes it during the BankID ceremony. update sends that empty field for every se_bankid party, strong maps straight through, and no PII carrier is needed for the auth level. (BankID agrees on direction: since API v6 it does not accept a personalNumber from the relying party at all.) test/live.test.ts asserts this against the testbed, including the control case that shows the error returning when the field is absent.

  2. 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-requested then carries documentAttachmentId, and create opens it (conn.openAttachmentStream, on the very connection it is about to send with) and streams those bytes into setfile under 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, no start follows, 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 of protocol:record-signature and protocol: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. bindDocument is where the two are reconciled — it refuses an attachment that is not on the instance being bound, and carries the kernel's own sha256 of the bytes onto protocol.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. attachmentTargets has 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-sqlite a 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.ts pins it);
    • on the hosted Cloudflare path only upload crossed the /internal seam, so the control plane held the credential while the vertical held the bytes.

    Both are closed: ScopedConnectorConnection.openAttachment — the read hangs off the connection ctx.connection(provider) returns, so it is authorized as the credential the dispatch is actually using and cannot drift from it — and open now crosses the delegation seam beside upload. list deliberately does not; see the third bullet above.

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.com endpoint rejects them)
  • documents/new returns no status — only get does, so mutation responses are parsed for their id and status is re-read
  • setfile is multipart/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).