@substrat-run/connector-scrive 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +89 -83
  2. package/package.json +11 -5
package/README.md CHANGED
@@ -1,98 +1,104 @@
1
1
  # @substrat-run/connector-scrive
2
2
 
3
- Scrive eSign (BankID) the **outbound half** of external signing.
4
-
5
- `engine-protocol` emits `protocol.signatures-requested` when a vertical freezes a document and
6
- sends it for signature. This turns that into a Scrive document: create → set file → set parties
7
- with `se_bankid` start.
8
-
9
- ## ⚠️ This connector cannot complete a signature
10
-
11
- It is `private` and unpublished on purpose. Four things are missing, none of them here:
12
-
13
- 1. ~~**Dispatch is not idempotent.**~~ **Done.** A redelivery once created a *second* Scrive
14
- document duplicate legal paperwork to real signatories. The connector now records each
15
- dispatch in a directory-side ledger (`ctx.admin.putConnectorState`, keyed by the connection)
16
- and skips if a prior dispatch is found. Directory-side because a connector runs *inside* the
17
- scope's dispatch and re-entering the scope actor deadlocks (verified). A narrow residual
18
- remains — if the ledger write itself fails after the provider `start` succeeds, the retry
19
- still duplicates closable with provider-side dedup via the `substrat_instance` tag the
20
- connector already sets, once a list-by-tag query lands.
21
-
22
- 2. ~~**Connector state has no home.**~~ **Done** — the ledger above is that home.
23
-
24
- 3. ~~**Recording the signature back**~~ **Done** ([#97](https://github.com/substrat-run/substrat/issues/97)).
25
- When a party signs, the signature belongs on the protocol instance in the *scope*.
26
- `reconcileScriveDispatch(host, connectionId, instanceId, { fetch })` reads
27
- `documents/{id}/get`, maps each signed party back to its request, and records it by invoking
28
- `protocol/record-signature` through `getConnectorScope` the connection acting as itself,
29
- as a top-level operation (not the dispatch handler, where re-entering the scope deadlocks).
30
- The connection must hold `protocol:record-signature` (`grantToConnection`), which shows up in
31
- the permission diff. Idempotent across polls; verified against `ScriveMock` advanced to
32
- `closed`. The one thing a mock can't prove — Scrive's real `get` shape and party order —
33
- waits on a testbed BankID round-trip (BankID-to-sign is disabled on the account).
34
-
35
- 4. **The poll driver exists; nothing calls it on a timer.**
36
- `sweepScriveReconciliations(host, connectionId, { fetch })` is the scheduler's unit of work:
37
- it enumerates the dispatch ledger (`HostAdmin.listConnectorState(id, 'scrive:dispatch:')`) and
38
- reconciles every outstanding instance, skipping ones the ledger already shows complete and
39
- stepping past a provider error on any one instance. It is idempotent and scoped to one
40
- connection (a connection never crosses a tenant). What is *still* missing is the **timer** —
41
- there is no cron, queue or Durable Object alarm in any wrangler config, the same trigger
42
- `drainDue` still lacks. A platform sweeper would, on a schedule, iterate the connections it
43
- owns and call this for each:
44
-
45
- ```ts
46
- // in a Cloudflare Worker's scheduled() handler, or a DO alarm:
47
- for (const connectionId of scriveConnections) {
48
- await sweepScriveReconciliations(host, connectionId, { fetch });
49
- }
50
- ```
51
-
52
- That trigger is a deployment concern, not connector code — which is why it, not the seam or
53
- the driver, is the remaining reason the connector stays unpublished.
54
-
55
- 5. **No document store.** `attachmentTargets` is declared in the manifest contract and
56
- implemented nowhere, so there is no place for rendered bytes.
57
-
58
- ## What it therefore sends
59
-
60
- An **attestation sheet**, not the avtal: the template, the parties, and the content hash the
61
- signature refers to. That is honest for a hash-attestation model and enough to exercise the
62
- seam, but it is not a contract anybody should sign.
63
-
64
- Rendering the real document belongs to the **vertical**, which owns the content — a connector
65
- cannot read another module's tables and should not learn a vertical's vocabulary to try. It
66
- needs (4) to hand the bytes over.
3
+ Scrive eSign (Swedish **BankID**) for Substrat: turns a vertical's signature request into a real
4
+ Scrive signing flow, and records the completed signatures back into the scope. A **connector** is
5
+ host code you register it on a scope host; it is never module code.
6
+
7
+ ## What it does
8
+
9
+ Two halves, both built and tested against the real testbed API.
10
+
11
+ **Outbound.** `engine-protocol` emits `protocol.signatures-requested` when a vertical freezes a
12
+ document and sends it for signature. The connector (only for `method: 'scrive'`) turns that into a
13
+ Scrive document: **create set file set parties start**, external signatories on `se_bankid`.
14
+ It records each dispatch in a directory-side ledger (`putConnectorState`, keyed by the connection)
15
+ so an at-least-once redelivery skips instead of creating a *second* document — duplicate legal
16
+ paperwork to real signatories. Directory-side because a connector runs *inside* the scope's
17
+ dispatch and re-entering the scope actor deadlocks.
18
+
19
+ **Inbound.** Once parties sign at Scrive, `reconcileScriveDispatch(host, connectionId, instanceId,
20
+ { fetch })` reads `documents/{id}/get`, maps each signed party back to its request, and records
21
+ the signature onto the protocol instance by invoking `protocol/record-signature` through
22
+ `getConnectorScope` — the connection acting as itself ([#97](https://github.com/substrat-run/substrat/issues/97)),
23
+ a top-level operation (not the dispatch handler, where re-entering the scope deadlocks). It
24
+ re-checks the provider-reported content hash against the frozen one and **fails closed** on a
25
+ mismatch, and it is idempotent across polls. `sweepScriveReconciliations(host, connectionId,
26
+ { fetch })` is the poll driver over it: it enumerates the dispatch ledger
27
+ (`listConnectorState(id, 'scrive:dispatch:')`) and reconciles every outstanding instance, skipping
28
+ those already complete and stepping past a provider error on any one.
29
+
30
+ ## Using it
31
+
32
+ ```ts
33
+ import { registerScriveConnector, sweepScriveReconciliations } from '@substrat-run/connector-scrive';
34
+
35
+ // 1. Register the connector on the scope host (host code, like an engine module).
36
+ registerScriveConnector(host, { baseUrl: SCRIVE_TESTBED /* or SCRIVE_PRODUCTION */ });
37
+
38
+ // 2. Open a connection with the OAuth1 credential, and grant it the one permission
39
+ // that lets it write a signature back held by NO human role.
40
+ await host.admin.createConnection(actor, {
41
+ id, tenantId, vertical, provider: 'scrive', label,
42
+ secret: { clientId, clientSecret, tokenId, tokenSecret }, // sealed by the host's SecretBox
43
+ });
44
+ await host.admin.grantToConnection(actor, {
45
+ connectionId: id, permission: 'protocol:record-signature', node, grantedBy: actor,
46
+ });
47
+
48
+ // 3. Schedule the poll — YOUR deployment calls the sweep on a timer.
49
+ // Node: startPlatformSweeper(host, { sweepers: { scrive: sweepScriveReconciliations }, intervalMs })
50
+ // Cloudflare: a scheduled() Cron / DO alarm calling runPlatformSweep(host, { sweepers: { scrive: … } })
51
+ ```
52
+
53
+ The credential is Scrive's OAuth1 "personal access credentials" four parts that combine into a
54
+ PLAINTEXT signature (`{ clientId, clientSecret, tokenId, tokenSecret }`), **not** OAuth2 bearer. A
55
+ signatory's personnummer is passed through to Scrive on the signing request and **never stored**:
56
+ it is `direct` PII, and `engine-protocol` records an opaque `DataSubjectId` as the signatory
57
+ instead. The host needs a `SecretBox` configured to seal the credential at rest.
58
+
59
+ ## Caveats worth knowing
60
+
61
+ 1. **Your deployment must schedule the poll** (step 3). The connector provides the driver; it
62
+ cannot hold a timer — that is a deployment concern (a cron, a Durable Object alarm, or
63
+ `startPlatformSweeper`'s interval). Without one, dispatch works but signatures are never
64
+ recorded back.
65
+
66
+ 2. **The live BankID signing round-trip is unverified.** The outbound lifecycle is checked against
67
+ `api-testbed.scrive.com`, but `se_bankid`-to-sign is **disabled on the testbed account**
68
+ (`start` → 409), so the actual signature — and Scrive's real signed-`get` party shape and order
69
+ — have only been exercised against `ScriveMock`. Because the reconcile fails closed on a
70
+ party-shape mismatch, a wrong assumption *skips* (visibly, in the sweep result), never
71
+ mis-records. It stays a `0.x` release for this reason.
72
+
73
+ 3. **It sends an attestation sheet, not the avtal.** A one-page PDF naming the template, the
74
+ parties, and the content hash the signature refers to — honest for a hash-attestation model,
75
+ but not the contract itself. Rendering the real document belongs to the **vertical** (a
76
+ connector cannot read another module's tables), and it needs a document store that does not
77
+ exist yet (`attachmentTargets` is declared in the manifest contract and implemented nowhere).
67
78
 
68
79
  ## Verified against the testbed
69
80
 
70
- The API layer was **checked against `api-testbed.scrive.com`**, not just the docs — and the
71
- first version, written from the docs, was wrong in three ways one live call exposed at once:
81
+ The API layer was checked against `api-testbed.scrive.com`, not just the docs — and the first
82
+ version, written from the docs, was wrong in three ways one live call exposed at once:
72
83
 
73
84
  - **auth is OAuth1 PLAINTEXT**, not OAuth2 bearer (the UI's "Client" + "Token" credentials are
74
85
  two halves of one four-part signature; the `oauth2.scrive.com` endpoint rejects them)
75
- - **`documents/new` returns no `status`** — only `get` does, so mutation responses are parsed
76
- for their id and status is re-read
86
+ - **`documents/new` returns no `status`** — only `get` does, so mutation responses are parsed for
87
+ their id and status is re-read
77
88
  - **`setfile` is `multipart/form-data`**, not a base64 body
78
89
 
79
90
  `test/live.test.ts` runs the real lifecycle (`new → setfile → update → get`) when
80
- `connectors/scrive/.dev.vars` holds a complete OAuth1 credential, and **skips** otherwise — so
81
- CI without secrets stays offline and a local run with the testbed verifies the actual API. It
82
- uses `standard` auth because **`se_bankid`-to-sign is disabled on the testbed account** (`start`
83
- returns 409); the BankID round-trip waits on that setting.
91
+ `connectors/scrive/.dev.vars` holds a complete OAuth1 credential, and **skips** otherwise — so CI
92
+ without secrets stays offline and a local run against the testbed verifies the actual API. It uses
93
+ `standard` auth because `se_bankid`-to-sign is disabled on the account (see caveat 2).
84
94
 
85
95
  ## Testing
86
96
 
87
- `ScriveMock` implements the documented endpoints in memory, so the whole lifecycle runs without
88
- a provider account.
97
+ `ScriveMock` implements the endpoints in memory, so the whole lifecycle runs without a provider
98
+ account credential resolution, egress, health, retry, and the dispatch → sign → reconcile loop
99
+ (a test signs the mock's parties, then drives `runPlatformSweep`).
89
100
 
90
- **What a mock proves:** that our shape works credential resolution, egress, health, retry,
91
- the document lifecycle.
92
-
93
- **What it cannot prove:** that our reading of Scrive's API is correct. The mock *is* that
94
- reading — same author, same misunderstandings, on both sides of the call. Green here means
95
- *ready to check against `api-testbed.scrive.com`*, never *verified*.
96
-
97
- It stays useful afterwards: a real provider will not return 503 on demand, or let you
98
- fast-forward two days to a signature.
101
+ **What a mock proves:** that our shape works. **What it cannot prove:** that our reading of
102
+ Scrive's API is correct at the one step the testbed cannot reach — the BankID signature and its
103
+ `get` shape. The mock *is* our reading; green here meant *ready to check against the testbed*, and
104
+ the outbound half now has been (caveat 2 is the residue).
package/package.json CHANGED
@@ -1,8 +1,14 @@
1
1
  {
2
2
  "name": "@substrat-run/connector-scrive",
3
- "version": "0.0.2",
3
+ "version": "0.1.0",
4
4
  "description": "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 code — registered on a ScopeHost, never module code.",
5
5
  "license": "AGPL-3.0-only",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/substrat-run/substrat.git",
9
+ "directory": "connectors/scrive"
10
+ },
11
+ "homepage": "https://github.com/substrat-run/substrat",
6
12
  "publishConfig": {
7
13
  "access": "public"
8
14
  },
@@ -20,14 +26,14 @@
20
26
  },
21
27
  "dependencies": {
22
28
  "zod": "^4.4.3",
23
- "@substrat-run/contracts": "^0.11.0",
24
- "@substrat-run/kernel": "^0.11.0"
29
+ "@substrat-run/kernel": "^0.11.0",
30
+ "@substrat-run/contracts": "^0.11.0"
25
31
  },
26
32
  "devDependencies": {
27
33
  "typescript": "^5.6.0",
28
34
  "vitest": "^3.0.0",
29
- "@substrat-run/engine-protocol": "^0.4.2",
30
- "@substrat-run/adapter-sqlite": "^0.11.0"
35
+ "@substrat-run/adapter-sqlite": "^0.11.0",
36
+ "@substrat-run/engine-protocol": "^0.4.2"
31
37
  },
32
38
  "scripts": {
33
39
  "build": "tsc -p tsconfig.json",