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.12.0

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 (basicstandard, the default; strongse_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 { registerScriveConnector, sweepScriveReconciliations } from '@substrat-run/connector-scrive';

// 1. Register the connector on the scope host (host code, like an engine module).
registerScriveConnector(host, { baseUrl: SCRIVE_TESTBED /* or SCRIVE_PRODUCTION */ });

// 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:
//    Node:        startPlatformSweeper(host, { sweepers: { scrive: sweepScriveReconciliations }, 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.

    Read the participant number: #2, not #1. Scrive never invites the author — it is the sending account — so the rule does not reach it, and the gap splits in two:

    | party set | what happens | |---|---| | a real counterparty to invite | refused at start — loud, retried, journalled | | only the author (see below) | starts, reports itself sent, delivers to nobody |

    The second row is reachable without anyone choosing it. requestSignatures resolves the issuing party unconditionally — the declared one, else the first — so a caller naming only counterparties has one of them silently made the issuer, and this connector maps primary to is_author. Production reached it that way. A contact field alone will not close it: an author is uninvitable whatever address it carries, so the carrier needs the companion invariant that no document goes out with nobody to deliver to. Both rows are asserted in test/dispatch.test.ts, the second one so that closing it is a deliberate edit.

  3. 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.

  4. 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.openAttachment, on the very connection it is about to send with) and puts those bytes out under the vertical's own filename. 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).