@substrat-run/engine-protocol 0.3.6 → 0.4.1
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.
- package/README.md +12 -7
- package/dist/index.d.ts +323 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +863 -100
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { dataSubjectId, entityRef, moduleManifest, permissionKey, } from '@substrat-run/contracts';
|
|
2
|
+
import { dataSubjectId, entityRef, instant, moduleManifest, permissionKey, principalId, } from '@substrat-run/contracts';
|
|
3
3
|
import { assertAllowed, ulid, } from '@substrat-run/kernel';
|
|
4
4
|
// ============================================================================
|
|
5
5
|
// The protocol engine (docs/design/engine-protocol.md, extracted at milestone
|
|
@@ -9,25 +9,88 @@ import { assertAllowed, ulid, } from '@substrat-run/kernel';
|
|
|
9
9
|
// invariants; template CONTENT (which protocols exist, what they contain)
|
|
10
10
|
// is 100% vertical-owned:
|
|
11
11
|
//
|
|
12
|
-
// 1.
|
|
13
|
-
// 2. content_hash
|
|
14
|
-
//
|
|
15
|
-
// 3. counter-sign
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// 6. void, not delete — a protocol is superseded, never mutated or removed
|
|
12
|
+
// 1. freeze freezes — any write to a frozen instance's content fails
|
|
13
|
+
// 2. content_hash — SHA-256 over template content + the frozen content;
|
|
14
|
+
// verifiable against replayed state
|
|
15
|
+
// 3. counter-sign — an ADDITIONAL signature row on the same frozen
|
|
16
|
+
// content (hash re-verified, never new content)
|
|
17
|
+
// 4. append-only — a response edit is a NEW row; history is audit
|
|
18
|
+
// material ("4.2 → 5.1 before signing")
|
|
19
|
+
// 5. version-pinned — templates version immutably; an instance pins
|
|
20
|
+
// (key, version) at instantiation forever
|
|
21
|
+
// 6. void, not delete — a protocol is superseded, never mutated or removed
|
|
23
22
|
//
|
|
24
23
|
// Entity-agnostic: an instance binds to any EntityRef ('workorder' today,
|
|
25
24
|
// anything tomorrow). The vertical declares the `protocol → <parent>` entity
|
|
26
25
|
// relation in ITS manifest — the engine cannot know the vertical's vocabulary.
|
|
26
|
+
//
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// TWO CONTENT KINDS (milestone D). The engine's attestation half — hash,
|
|
29
|
+
// freeze, signatures, the guards — was always content-agnostic; only `fill`
|
|
30
|
+
// and the template shape were checklist-specific. That seam is now exposed:
|
|
31
|
+
//
|
|
32
|
+
// kind: 'checklist' — sections/items, filled response-by-response. The
|
|
33
|
+
// original shape; templates that predate this carry no `kind` and parse as
|
|
34
|
+
// checklist, and their stored content_json is NEVER rewritten (the hash
|
|
35
|
+
// covers it verbatim — a migration that touched it would invalidate every
|
|
36
|
+
// signature ever made).
|
|
37
|
+
//
|
|
38
|
+
// kind: 'document' — content the ENGINE NEVER SEES. A priced avtal, a
|
|
39
|
+
// styrelserapport, a PDF: the vertical owns the rows and computes their
|
|
40
|
+
// hash, and binds (contentRef, contentHash) to the instance. The engine
|
|
41
|
+
// attests that a signature was made over exactly that hash at that time,
|
|
42
|
+
// and says so honestly rather than pretending its recipe covered content
|
|
43
|
+
// it never read. Recomputation is the VERTICAL's obligation — the engine
|
|
44
|
+
// cannot verify what it cannot see, and claiming otherwise would be the
|
|
45
|
+
// false audit trail a degenerate one-item checklist produces.
|
|
46
|
+
//
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// FREEZE IS SEPARATE FROM SIGNING (milestone D). Signing used to freeze as a
|
|
49
|
+
// side effect, which was sound only because `signProtocol` is synchronous:
|
|
50
|
+
// the authenticated principal taps sign and the window between "what we showed
|
|
51
|
+
// them" and "what we hashed" is microseconds.
|
|
52
|
+
//
|
|
53
|
+
// An external signing flow (BankID via Scrive) is not synchronous, and the
|
|
54
|
+
// signatory is not a principal at all:
|
|
55
|
+
//
|
|
56
|
+
// we dispatch a signing request → days pass → a customer signatory with
|
|
57
|
+
// no account in the system signs → a webhook reports party X signed at
|
|
58
|
+
// time T with evidence Y
|
|
59
|
+
//
|
|
60
|
+
// With freeze welded to signing, that instance stays `open` — and therefore
|
|
61
|
+
// writable — for the entire days-long window, so the document the customer
|
|
62
|
+
// saw and the content the hash is computed over can differ with no detection.
|
|
63
|
+
// That is a hole in the freeze invariant for ANY asynchronous signature,
|
|
64
|
+
// checklist or document alike.
|
|
65
|
+
//
|
|
66
|
+
// So freezing is now its own transition, and the missing noun is the
|
|
67
|
+
// SIGNATURE REQUEST:
|
|
68
|
+
//
|
|
69
|
+
// open --requestSignatures--> pending_signature --all resolved--> signed
|
|
70
|
+
// \ |
|
|
71
|
+
// \--signProtocol (in-app)----------+--------------------------> signed
|
|
72
|
+
// |
|
|
73
|
+
// cancelSignatureRequests --> open (renegotiate)
|
|
74
|
+
//
|
|
75
|
+
// `pending_signature` is frozen: no fill, no rebind. The requests table also
|
|
76
|
+
// makes MULTI-PARTY expressible — "every requested party has signed" is
|
|
77
|
+
// `requireAllSigned`, which primary/counter alone could never say.
|
|
78
|
+
//
|
|
79
|
+
// What the engine deliberately does NOT do: talk to Scrive. Module code makes
|
|
80
|
+
// no network calls (boundary-lint R3). `requestSignatures` emits a fat
|
|
81
|
+
// `protocol.signatures-requested`; a connector/executor outside the scope
|
|
82
|
+
// effects it. The return path — webhook ingress and an inbound authority seam
|
|
83
|
+
// that lets a callback invoke `recordSignature` — does not exist in the kernel
|
|
84
|
+
// yet (see the issues filed alongside this change). `recordSignature` is
|
|
85
|
+
// shaped to be callable by that ingress when it lands, and is permissioned as
|
|
86
|
+
// its own key so nothing else can reach it in the meantime.
|
|
27
87
|
// ============================================================================
|
|
28
88
|
export const PROTOCOL_PERM = {
|
|
29
89
|
create: permissionKey.parse('protocol:create'),
|
|
30
90
|
fill: permissionKey.parse('protocol:fill'),
|
|
91
|
+
bind: permissionKey.parse('protocol:bind'),
|
|
92
|
+
requestSignature: permissionKey.parse('protocol:request-signature'),
|
|
93
|
+
recordSignature: permissionKey.parse('protocol:record-signature'),
|
|
31
94
|
sign: permissionKey.parse('protocol:sign'),
|
|
32
95
|
countersign: permissionKey.parse('protocol:countersign'),
|
|
33
96
|
read: permissionKey.parse('protocol:read'),
|
|
@@ -35,20 +98,27 @@ export const PROTOCOL_PERM = {
|
|
|
35
98
|
};
|
|
36
99
|
export const protocolManifest = moduleManifest.parse({
|
|
37
100
|
id: '@substrat-run/engine-protocol',
|
|
38
|
-
version: '0.0.
|
|
101
|
+
version: '0.0.2',
|
|
39
102
|
kernelContract: '^0.0.1',
|
|
40
103
|
permissions: [
|
|
41
104
|
{ key: 'protocol:create', description: 'Define protocol templates and start protocol instances on entities' },
|
|
42
|
-
{ key: 'protocol:fill', description: 'Record responses on an open protocol (append-only)' },
|
|
43
|
-
{ key: 'protocol:
|
|
105
|
+
{ key: 'protocol:fill', description: 'Record responses on an open checklist protocol (append-only)' },
|
|
106
|
+
{ key: 'protocol:bind', description: 'Bind vertical-owned document content (ref + hash) to an open document protocol' },
|
|
107
|
+
{ key: 'protocol:request-signature', description: 'Freeze a protocol and request signatures from named parties (external signing flows); also cancels a pending request set' },
|
|
108
|
+
{ key: 'protocol:record-signature', description: 'Record a signature reported by an external signing provider — held by connector ingress, never by a human role' },
|
|
109
|
+
{ key: 'protocol:sign', description: 'Sign a protocol in-app — freezes it forever (separate from fill: the technician fills, the arbetsledare signs)' },
|
|
44
110
|
{ key: 'protocol:countersign', description: 'Counter-sign an already-signed protocol — a second signature on the same frozen content (customer at pickup)' },
|
|
45
|
-
{ key: 'protocol:read', description: 'Read protocol templates, instances, responses and signatures' },
|
|
111
|
+
{ key: 'protocol:read', description: 'Read protocol templates, instances, responses, signature requests and signatures' },
|
|
46
112
|
{ key: 'protocol:void', description: 'Void (supersede) a protocol — never deletes' },
|
|
47
113
|
],
|
|
48
114
|
events: {
|
|
49
115
|
emits: [
|
|
50
116
|
{ type: 'protocol.instantiated', schemaVersion: 1 },
|
|
51
117
|
{ type: 'protocol.response-recorded', schemaVersion: 1 },
|
|
118
|
+
{ type: 'protocol.content-bound', schemaVersion: 1 },
|
|
119
|
+
{ type: 'protocol.signatures-requested', schemaVersion: 1 },
|
|
120
|
+
{ type: 'protocol.signature-declined', schemaVersion: 1 },
|
|
121
|
+
{ type: 'protocol.signatures-cancelled', schemaVersion: 1 },
|
|
52
122
|
{ type: 'protocol.signed', schemaVersion: 1 },
|
|
53
123
|
{ type: 'protocol.countersigned', schemaVersion: 1 },
|
|
54
124
|
{ type: 'protocol.voided', schemaVersion: 1 },
|
|
@@ -109,12 +179,143 @@ export const protocolMigrations = [
|
|
|
109
179
|
);
|
|
110
180
|
`,
|
|
111
181
|
},
|
|
182
|
+
// 0002 — MILESTONE D. Asynchronous, non-principal signatures and the
|
|
183
|
+
// document content kind. Three shape changes:
|
|
184
|
+
//
|
|
185
|
+
// 1. `protocol_instances.status` gains 'pending_signature'. SQLite cannot
|
|
186
|
+
// ALTER a CHECK constraint, so this is the standard table rebuild.
|
|
187
|
+
// 2. `protocol_instances` gains the frozen hash and the document binding.
|
|
188
|
+
// `frozen_hash` is BACKFILLED from each instance's earliest signature,
|
|
189
|
+
// so already-signed instances carry the hash they were frozen at.
|
|
190
|
+
// 3. `protocol_signature_requests` — the new noun.
|
|
191
|
+
//
|
|
192
|
+
// The rebuild covers all three data tables rather than just `instances`,
|
|
193
|
+
// because `responses`/`signatures` carry `REFERENCES protocol_instances(id)`
|
|
194
|
+
// clauses: renaming out from under them leaves those clauses pointing at a
|
|
195
|
+
// dropped table. The FK clauses are dropped rather than re-pointed —
|
|
196
|
+
// neither adapter enables `PRAGMA foreign_keys` (and DO SQLite restricts
|
|
197
|
+
// PRAGMA entirely), so they were never enforced; the engine is the only
|
|
198
|
+
// writer and enforces the relationships in code.
|
|
199
|
+
//
|
|
200
|
+
// `protocol_templates` is untouched, and NO stored `content_json` is
|
|
201
|
+
// rewritten: the content hash covers that string verbatim, so adding an
|
|
202
|
+
// explicit `"kind":"checklist"` would invalidate every signature ever made.
|
|
203
|
+
// Legacy content parses as checklist by normalisation at read time instead.
|
|
204
|
+
//
|
|
205
|
+
// Column names and order are preserved for every pre-existing column so that
|
|
206
|
+
// Callout's `0003-protocols-to-engine` extraction handoff — which INSERTs
|
|
207
|
+
// into these tables by explicit column list and runs after this migration —
|
|
208
|
+
// keeps working untouched.
|
|
209
|
+
{
|
|
210
|
+
version: '0002-signature-requests',
|
|
211
|
+
sql: `
|
|
212
|
+
CREATE TABLE protocol_instances_v2 (
|
|
213
|
+
id TEXT PRIMARY KEY,
|
|
214
|
+
template_key TEXT NOT NULL,
|
|
215
|
+
template_version INTEGER NOT NULL,
|
|
216
|
+
entity_type TEXT NOT NULL,
|
|
217
|
+
entity_id TEXT NOT NULL,
|
|
218
|
+
status TEXT NOT NULL
|
|
219
|
+
CHECK (status IN ('open','pending_signature','signed','voided')),
|
|
220
|
+
created_by TEXT NOT NULL,
|
|
221
|
+
created_at TEXT NOT NULL,
|
|
222
|
+
voided_by TEXT,
|
|
223
|
+
voided_reason TEXT,
|
|
224
|
+
voided_at TEXT,
|
|
225
|
+
content_ref_type TEXT,
|
|
226
|
+
content_ref_id TEXT,
|
|
227
|
+
bound_hash TEXT,
|
|
228
|
+
frozen_hash TEXT,
|
|
229
|
+
frozen_at TEXT
|
|
230
|
+
);
|
|
231
|
+
INSERT INTO protocol_instances_v2
|
|
232
|
+
(id, template_key, template_version, entity_type, entity_id, status,
|
|
233
|
+
created_by, created_at, voided_by, voided_reason, voided_at,
|
|
234
|
+
content_ref_type, content_ref_id, bound_hash, frozen_hash, frozen_at)
|
|
235
|
+
SELECT i.id, i.template_key, i.template_version, i.entity_type, i.entity_id, i.status,
|
|
236
|
+
i.created_by, i.created_at, i.voided_by, i.voided_reason, i.voided_at,
|
|
237
|
+
NULL, NULL, NULL,
|
|
238
|
+
(SELECT s.content_hash FROM protocol_signatures s
|
|
239
|
+
WHERE s.instance_id = i.id ORDER BY s.rowid LIMIT 1),
|
|
240
|
+
(SELECT s.signed_at FROM protocol_signatures s
|
|
241
|
+
WHERE s.instance_id = i.id ORDER BY s.rowid LIMIT 1)
|
|
242
|
+
FROM protocol_instances i;
|
|
243
|
+
|
|
244
|
+
CREATE TABLE protocol_responses_v2 (
|
|
245
|
+
id TEXT PRIMARY KEY,
|
|
246
|
+
instance_id TEXT NOT NULL,
|
|
247
|
+
item_key TEXT NOT NULL,
|
|
248
|
+
value_json TEXT NOT NULL,
|
|
249
|
+
note TEXT,
|
|
250
|
+
responded_by TEXT NOT NULL,
|
|
251
|
+
responded_at TEXT NOT NULL
|
|
252
|
+
);
|
|
253
|
+
INSERT INTO protocol_responses_v2
|
|
254
|
+
(id, instance_id, item_key, value_json, note, responded_by, responded_at)
|
|
255
|
+
SELECT id, instance_id, item_key, value_json, note, responded_by, responded_at
|
|
256
|
+
FROM protocol_responses;
|
|
257
|
+
|
|
258
|
+
CREATE TABLE protocol_signatures_v2 (
|
|
259
|
+
id TEXT PRIMARY KEY,
|
|
260
|
+
instance_id TEXT NOT NULL,
|
|
261
|
+
signed_by TEXT NOT NULL,
|
|
262
|
+
kind TEXT NOT NULL CHECK (kind IN ('primary','counter')),
|
|
263
|
+
method TEXT NOT NULL,
|
|
264
|
+
content_hash TEXT NOT NULL,
|
|
265
|
+
evidence_ref TEXT,
|
|
266
|
+
signed_at TEXT NOT NULL,
|
|
267
|
+
request_id TEXT,
|
|
268
|
+
signatory_kind TEXT NOT NULL DEFAULT 'principal'
|
|
269
|
+
CHECK (signatory_kind IN ('principal','external')),
|
|
270
|
+
signatory_label TEXT
|
|
271
|
+
);
|
|
272
|
+
INSERT INTO protocol_signatures_v2
|
|
273
|
+
(id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
|
|
274
|
+
request_id, signatory_kind, signatory_label)
|
|
275
|
+
SELECT id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
|
|
276
|
+
NULL, 'principal', NULL
|
|
277
|
+
FROM protocol_signatures;
|
|
278
|
+
|
|
279
|
+
DROP TABLE protocol_signatures;
|
|
280
|
+
DROP TABLE protocol_responses;
|
|
281
|
+
DROP TABLE protocol_instances;
|
|
282
|
+
ALTER TABLE protocol_instances_v2 RENAME TO protocol_instances;
|
|
283
|
+
ALTER TABLE protocol_responses_v2 RENAME TO protocol_responses;
|
|
284
|
+
ALTER TABLE protocol_signatures_v2 RENAME TO protocol_signatures;
|
|
285
|
+
|
|
286
|
+
CREATE TABLE protocol_signature_requests (
|
|
287
|
+
id TEXT PRIMARY KEY,
|
|
288
|
+
instance_id TEXT NOT NULL,
|
|
289
|
+
party_label TEXT NOT NULL,
|
|
290
|
+
party_kind TEXT NOT NULL CHECK (party_kind IN ('principal','external')),
|
|
291
|
+
party_ref TEXT,
|
|
292
|
+
signature_kind TEXT NOT NULL CHECK (signature_kind IN ('primary','counter')),
|
|
293
|
+
method TEXT NOT NULL,
|
|
294
|
+
status TEXT NOT NULL
|
|
295
|
+
CHECK (status IN ('pending','signed','declined','expired','cancelled')),
|
|
296
|
+
content_hash TEXT NOT NULL,
|
|
297
|
+
external_ref TEXT,
|
|
298
|
+
resolved_note TEXT,
|
|
299
|
+
requested_by TEXT NOT NULL,
|
|
300
|
+
requested_at TEXT NOT NULL,
|
|
301
|
+
resolved_at TEXT
|
|
302
|
+
);
|
|
303
|
+
CREATE INDEX protocol_signature_requests_by_instance
|
|
304
|
+
ON protocol_signature_requests (instance_id, status);
|
|
305
|
+
CREATE INDEX protocol_instances_by_entity
|
|
306
|
+
ON protocol_instances (entity_type, entity_id, template_key, status);
|
|
307
|
+
`,
|
|
308
|
+
},
|
|
112
309
|
];
|
|
113
310
|
// ---------------------------------------------------------------------------
|
|
114
311
|
// Template content SHAPE — engine-owned so fills can be validated against the
|
|
115
312
|
// pinned template. The content VALUES (sections, items, vocabulary,
|
|
116
|
-
// branschprotokoll packs) are written by verticals.
|
|
117
|
-
//
|
|
313
|
+
// branschprotokoll packs) are written by verticals.
|
|
314
|
+
//
|
|
315
|
+
// Two kinds, discriminated on `kind`. Content stored before the discriminant
|
|
316
|
+
// existed carries no `kind` and is normalised to 'checklist' at PARSE time
|
|
317
|
+
// only — never rewritten in the database, because the hash covers the stored
|
|
318
|
+
// string byte-for-byte.
|
|
118
319
|
// ---------------------------------------------------------------------------
|
|
119
320
|
export const protocolItem = z.object({
|
|
120
321
|
key: z.string().min(1),
|
|
@@ -122,13 +323,78 @@ export const protocolItem = z.object({
|
|
|
122
323
|
type: z.enum(['check', 'value', 'text']),
|
|
123
324
|
unit: z.string().optional(), // 'MΩ' on measurements
|
|
124
325
|
});
|
|
125
|
-
|
|
326
|
+
/** The original shape: sections of items, filled response-by-response. */
|
|
327
|
+
export const checklistContent = z.object({
|
|
328
|
+
kind: z.literal('checklist'),
|
|
126
329
|
sections: z
|
|
127
330
|
.array(z.object({ title: z.string().min(1), items: z.array(protocolItem).min(1) }))
|
|
128
331
|
.min(1),
|
|
129
332
|
});
|
|
333
|
+
/**
|
|
334
|
+
* Content the engine never sees. The template says what KIND of document this
|
|
335
|
+
* is and how to render it; the instance carries the vertical's `EntityRef` and
|
|
336
|
+
* the hash the vertical computed over its own rows.
|
|
337
|
+
*
|
|
338
|
+
* `hashRecipe` is free text, and it is the load-bearing honesty of this kind:
|
|
339
|
+
* a document signature attests to a hash the engine did not compute, so the
|
|
340
|
+
* recipe for reproducing it must be written down where an auditor reading the
|
|
341
|
+
* template finds it. The engine cannot enforce that the text is true — but a
|
|
342
|
+
* signature over an unreproducible hash is worth nothing, and a required field
|
|
343
|
+
* is what makes the vertical say out loud how to reproduce it.
|
|
344
|
+
*/
|
|
345
|
+
export const documentContent = z.object({
|
|
346
|
+
kind: z.literal('document'),
|
|
347
|
+
/** Vertical vocabulary for what this is — 'avtal', 'styrelserapport'. */
|
|
348
|
+
documentType: z.string().min(1),
|
|
349
|
+
/** How to recompute `boundHash` from the vertical's own rows. */
|
|
350
|
+
hashRecipe: z.string().min(1),
|
|
351
|
+
description: z.string().optional(),
|
|
352
|
+
});
|
|
353
|
+
const contentUnion = z.discriminatedUnion('kind', [checklistContent, documentContent]);
|
|
354
|
+
/**
|
|
355
|
+
* Parses either kind, defaulting a missing discriminant to 'checklist' so
|
|
356
|
+
* every template defined before milestone D still parses. Note this is a
|
|
357
|
+
* READ-time normalisation: `defineTemplate` stores what it is given after
|
|
358
|
+
* parsing, so new templates carry an explicit `kind`, and old rows keep their
|
|
359
|
+
* bytes (and therefore their hashes) exactly as signed.
|
|
360
|
+
*/
|
|
361
|
+
export const protocolTemplateContent = z.preprocess((value) => value && typeof value === 'object' && !Array.isArray(value) && !('kind' in value)
|
|
362
|
+
? { ...value, kind: 'checklist' }
|
|
363
|
+
: value, contentUnion);
|
|
130
364
|
/** Booleans for checks; strings for measurements/text (decimals stay strings, K-14). */
|
|
131
365
|
const responseValue = z.union([z.boolean(), z.string()]);
|
|
366
|
+
/**
|
|
367
|
+
* Who signed. Two kinds, and the difference is the whole point of milestone D:
|
|
368
|
+
*
|
|
369
|
+
* - `principal` — an authenticated principal in this scope. `ref` is their
|
|
370
|
+
* `PrincipalId`. Every in-app signature.
|
|
371
|
+
* - `external` — a human with no account, identified by an external provider
|
|
372
|
+
* (BankID via Scrive). `ref` is an OPAQUE `DataSubjectId` the vertical minted
|
|
373
|
+
* for that person.
|
|
374
|
+
*
|
|
375
|
+
* A personnummer, an email or a name must NEVER land in `ref`. It is `direct`
|
|
376
|
+
* PII, and `subjectId` on the emitted event is what crypto-shredding keys the
|
|
377
|
+
* erasure on (§5.3) — a `DataSubjectId` is shreddable, a personnummer written
|
|
378
|
+
* into a signature row is a GDPR liability that immutability makes permanent.
|
|
379
|
+
* The provider's own party identifier belongs in `evidenceRef`, which is where
|
|
380
|
+
* the sealed PDF and the provider audit log are reachable from.
|
|
381
|
+
*
|
|
382
|
+
* This follows `engines/booking`'s `partyRef`: a participant is a person with
|
|
383
|
+
* no principal, and it names them with a `DataSubjectId` for exactly this
|
|
384
|
+
* reason.
|
|
385
|
+
*/
|
|
386
|
+
export const signatory = z.discriminatedUnion('kind', [
|
|
387
|
+
z.object({
|
|
388
|
+
kind: z.literal('principal'),
|
|
389
|
+
ref: principalId,
|
|
390
|
+
label: z.string().min(1).optional(),
|
|
391
|
+
}),
|
|
392
|
+
z.object({
|
|
393
|
+
kind: z.literal('external'),
|
|
394
|
+
ref: dataSubjectId,
|
|
395
|
+
label: z.string().min(1).optional(),
|
|
396
|
+
}),
|
|
397
|
+
]);
|
|
132
398
|
const protocolRef = (id) => ({ entityType: 'protocol', entityId: id });
|
|
133
399
|
function getInstanceRow(ctx, instanceId) {
|
|
134
400
|
const row = ctx.sql.query('SELECT * FROM protocol_instances WHERE id = ?', [instanceId])[0];
|
|
@@ -142,6 +408,7 @@ function getTemplateRow(ctx, key, version) {
|
|
|
142
408
|
throw new Error(`protocol template not found: ${key}@${version}`);
|
|
143
409
|
return row;
|
|
144
410
|
}
|
|
411
|
+
const templateContentOf = (template) => protocolTemplateContent.parse(JSON.parse(template.content_json));
|
|
145
412
|
/** Append order is authoritative for "latest wins" — rowid, not ULID (same-ms safe). */
|
|
146
413
|
function getResponseRows(ctx, instanceId) {
|
|
147
414
|
return ctx.sql.query('SELECT * FROM protocol_responses WHERE instance_id = ? ORDER BY rowid', [instanceId]);
|
|
@@ -149,6 +416,9 @@ function getResponseRows(ctx, instanceId) {
|
|
|
149
416
|
function getSignatureRows(ctx, instanceId) {
|
|
150
417
|
return ctx.sql.query('SELECT * FROM protocol_signatures WHERE instance_id = ? ORDER BY rowid', [instanceId]);
|
|
151
418
|
}
|
|
419
|
+
function getRequestRows(ctx, instanceId) {
|
|
420
|
+
return ctx.sql.query('SELECT * FROM protocol_signature_requests WHERE instance_id = ? ORDER BY rowid', [instanceId]);
|
|
421
|
+
}
|
|
152
422
|
function latestPerItem(responses) {
|
|
153
423
|
const latest = {};
|
|
154
424
|
for (const r of responses)
|
|
@@ -156,17 +426,53 @@ function latestPerItem(responses) {
|
|
|
156
426
|
return latest;
|
|
157
427
|
}
|
|
158
428
|
const frozenAnswers = (latest) => Object.fromEntries(Object.entries(latest).map(([k, r]) => [k, JSON.parse(r.value_json)]));
|
|
159
|
-
|
|
429
|
+
const signatoryOf = (row) => ({
|
|
430
|
+
kind: row.signatory_kind,
|
|
431
|
+
ref: row.signed_by,
|
|
432
|
+
...(row.signatory_label ? { label: row.signatory_label } : {}),
|
|
433
|
+
});
|
|
434
|
+
async function sha256Hex(input) {
|
|
435
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input));
|
|
436
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
437
|
+
}
|
|
438
|
+
export async function protocolContentHash(template, latest, boundHash) {
|
|
439
|
+
const head = `${template.key}@${template.version}\n${template.content_json}\n`;
|
|
440
|
+
if (boundHash)
|
|
441
|
+
return sha256Hex(`${head}document:${boundHash}\n`);
|
|
160
442
|
const lines = Object.keys(latest)
|
|
161
443
|
.sort()
|
|
162
444
|
.map((k) => `${k}=${latest[k].value_json}\n`)
|
|
163
445
|
.join('');
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
446
|
+
return sha256Hex(`${head}${lines}`);
|
|
447
|
+
}
|
|
448
|
+
/** The hash an instance's content currently produces, whatever kind it is. */
|
|
449
|
+
async function currentHash(ctx, instance) {
|
|
450
|
+
const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
|
|
451
|
+
const content = templateContentOf(template);
|
|
452
|
+
if (content.kind === 'document') {
|
|
453
|
+
if (!instance.bound_hash) {
|
|
454
|
+
throw new Error(`document protocol ${instance.id} has no bound content — bind it before freezing`);
|
|
455
|
+
}
|
|
456
|
+
return protocolContentHash(template, {}, instance.bound_hash);
|
|
457
|
+
}
|
|
458
|
+
return protocolContentHash(template, latestPerItem(getResponseRows(ctx, instance.id)));
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Re-derive the frozen hash and refuse if it moved. Fail closed: a signature
|
|
462
|
+
* binds to VERIFIED frozen content, never to a trusted column.
|
|
463
|
+
*/
|
|
464
|
+
async function verifyFrozen(ctx, instance) {
|
|
465
|
+
if (!instance.frozen_hash) {
|
|
466
|
+
throw new Error(`protocol ${instance.id} is not frozen: nothing to sign against`);
|
|
467
|
+
}
|
|
468
|
+
const replayed = await currentHash(ctx, instance);
|
|
469
|
+
if (replayed !== instance.frozen_hash) {
|
|
470
|
+
throw new Error(`content hash mismatch: frozen ${instance.frozen_hash}, replayed ${replayed}`);
|
|
471
|
+
}
|
|
472
|
+
return instance.frozen_hash;
|
|
167
473
|
}
|
|
168
474
|
// ---------------------------------------------------------------------------
|
|
169
|
-
// THE
|
|
475
|
+
// THE GUARDS (engine-protocol.md §6, kernel-design open question 11). One
|
|
170
476
|
// predicate body, TWO ways to reach it — that is the whole open question:
|
|
171
477
|
//
|
|
172
478
|
// pole 1 — VERTICAL-COMPOSED (milestone A): the vertical calls requireSigned()
|
|
@@ -197,7 +503,7 @@ export function requireSigned(ctx, entity, templateKey) {
|
|
|
197
503
|
}
|
|
198
504
|
/**
|
|
199
505
|
* The stronger form: signed AND counter-signed — the frozen content was
|
|
200
|
-
* ACCEPTED by a second
|
|
506
|
+
* ACCEPTED by a second signatory (the customer at pickup). Invariant 3 already
|
|
201
507
|
* guarantees a counter-signature can only exist on verified frozen content, so
|
|
202
508
|
* the existence of the row is the whole check.
|
|
203
509
|
*/
|
|
@@ -213,6 +519,14 @@ export function requireCountersigned(ctx, entity, templateKey) {
|
|
|
213
519
|
`(${entity.entityType} ${entity.entityId})`);
|
|
214
520
|
}
|
|
215
521
|
}
|
|
522
|
+
/**
|
|
523
|
+
* NOTE on multi-party: there is deliberately no `requireAllSigned`. In the
|
|
524
|
+
* request-driven path an instance reaches `signed` only once EVERY requested
|
|
525
|
+
* party has signed (see `recordSignature`), so "all parties signed" IS
|
|
526
|
+
* `requireSigned` — a separate guard would read as though it checked something
|
|
527
|
+
* stronger while checking the same thing. The multi-party question is answered
|
|
528
|
+
* by the state machine, not by a second predicate.
|
|
529
|
+
*/
|
|
216
530
|
/**
|
|
217
531
|
* Config for the `protocol/all-signed` predicate — parsed by the PREDICATE, not
|
|
218
532
|
* by the kernel (the kernel keeps `config` opaque). `entityIdFrom` names the
|
|
@@ -224,7 +538,7 @@ export const allSignedGuardConfig = z.object({
|
|
|
224
538
|
templateKey: z.string().min(1), // vertical content: 'tillstandsrapport'
|
|
225
539
|
entityType: z.string().min(1), // what the protocol hangs on: 'workorder'
|
|
226
540
|
entityIdFrom: z.string().min(1), // input field holding the id: 'orderId'
|
|
227
|
-
countersigned: z.boolean().default(false), // require
|
|
541
|
+
countersigned: z.boolean().default(false), // require a second signatory's acceptance too
|
|
228
542
|
});
|
|
229
543
|
/** The named predicate the kernel resolves for `predicate: 'protocol/all-signed'`. */
|
|
230
544
|
export const allSignedPredicate = (ctx, rawConfig, input) => {
|
|
@@ -284,8 +598,11 @@ export function instantiateProtocol(ctx, rawInput) {
|
|
|
284
598
|
const template = ctx.sql.query('SELECT * FROM protocol_templates WHERE key = ? ORDER BY version DESC LIMIT 1', [input.templateKey])[0];
|
|
285
599
|
if (!template)
|
|
286
600
|
throw new Error(`protocol template not found: ${input.templateKey}`);
|
|
601
|
+
// An instance being signed is still "in play": a second one would race the
|
|
602
|
+
// first for the same (template, entity) slot.
|
|
287
603
|
const dup = ctx.sql.query(`SELECT id FROM protocol_instances
|
|
288
|
-
WHERE entity_type = ? AND entity_id = ? AND template_key = ?
|
|
604
|
+
WHERE entity_type = ? AND entity_id = ? AND template_key = ?
|
|
605
|
+
AND status IN ('open','pending_signature') LIMIT 1`, [input.entity.entityType, input.entity.entityId, input.templateKey])[0];
|
|
289
606
|
if (dup) {
|
|
290
607
|
throw new Error(`protocol '${input.templateKey}' already open on this ${input.entity.entityType}`);
|
|
291
608
|
}
|
|
@@ -314,11 +631,23 @@ export function instantiateProtocol(ctx, rawInput) {
|
|
|
314
631
|
templateKey: template.key,
|
|
315
632
|
templateVersion: template.version,
|
|
316
633
|
title: template.title,
|
|
634
|
+
contentKind: templateContentOf(template).kind,
|
|
317
635
|
entity: input.entity,
|
|
318
636
|
},
|
|
319
637
|
});
|
|
320
638
|
return getInstanceRow(ctx, id);
|
|
321
639
|
}
|
|
640
|
+
/** The one place that decides whether content may still change. */
|
|
641
|
+
function assertUnfrozen(instance, what) {
|
|
642
|
+
if (instance.status === 'open')
|
|
643
|
+
return;
|
|
644
|
+
if (instance.status === 'pending_signature') {
|
|
645
|
+
throw new Error(`protocol is out for signature: content is frozen until the requests resolve ` +
|
|
646
|
+
`or are cancelled (instance ${instance.id})`);
|
|
647
|
+
}
|
|
648
|
+
throw new Error(`protocol is ${instance.status}: content is frozen, ${what} can no longer change ` +
|
|
649
|
+
`(append-only history kept)`);
|
|
650
|
+
}
|
|
322
651
|
export const fillProtocolInput = z.object({
|
|
323
652
|
instanceId: z.string().min(1),
|
|
324
653
|
itemKey: z.string().min(1),
|
|
@@ -328,12 +657,14 @@ export const fillProtocolInput = z.object({
|
|
|
328
657
|
export function fillProtocol(ctx, rawInput) {
|
|
329
658
|
const input = fillProtocolInput.parse(rawInput);
|
|
330
659
|
const instance = getInstanceRow(ctx, input.instanceId);
|
|
331
|
-
// Invariant 1+4: responses bind to an
|
|
332
|
-
|
|
333
|
-
throw new Error(`protocol is ${instance.status}: responses are frozen (append-only history kept)`);
|
|
334
|
-
}
|
|
660
|
+
// Invariant 1+4: responses bind to an UNFROZEN instance only, and always append.
|
|
661
|
+
assertUnfrozen(instance, 'responses');
|
|
335
662
|
const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
|
|
336
|
-
const content =
|
|
663
|
+
const content = templateContentOf(template);
|
|
664
|
+
if (content.kind !== 'checklist') {
|
|
665
|
+
throw new Error(`template ${instance.template_key}@${instance.template_version} is a '${content.kind}' ` +
|
|
666
|
+
`protocol: it carries no items — bind its content instead of filling it`);
|
|
667
|
+
}
|
|
337
668
|
const item = content.sections.flatMap((s) => s.items).find((i) => i.key === input.itemKey);
|
|
338
669
|
if (!item) {
|
|
339
670
|
throw new Error(`unknown item '${input.itemKey}' in template ${instance.template_key}@${instance.template_version}`);
|
|
@@ -374,55 +705,455 @@ export function fillProtocol(ctx, rawInput) {
|
|
|
374
705
|
id,
|
|
375
706
|
])[0];
|
|
376
707
|
}
|
|
708
|
+
export const bindDocumentInput = z.object({
|
|
709
|
+
instanceId: z.string().min(1),
|
|
710
|
+
/** The vertical entity that holds the real content — an avtal, a report. */
|
|
711
|
+
contentRef: entityRef,
|
|
712
|
+
/** The hash the VERTICAL computed over its own rows, per `hashRecipe`. */
|
|
713
|
+
contentHash: z.string().regex(/^[0-9a-f]{64}$/, 'contentHash must be lowercase hex SHA-256'),
|
|
714
|
+
});
|
|
377
715
|
/**
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
716
|
+
* Bind (or re-bind) a document protocol's content while it is still open —
|
|
717
|
+
* the document-kind counterpart of `fillProtocol`. Re-binding is the whole
|
|
718
|
+
* point during negotiation: an avtal's price changes until it is sent out, and
|
|
719
|
+
* each rebind moves the hash the signature will be taken over.
|
|
720
|
+
*
|
|
721
|
+
* Once frozen, this fails like any other write to frozen content.
|
|
383
722
|
*/
|
|
384
|
-
export
|
|
385
|
-
const
|
|
723
|
+
export function bindDocument(ctx, rawInput) {
|
|
724
|
+
const input = bindDocumentInput.parse(rawInput);
|
|
725
|
+
const instance = getInstanceRow(ctx, input.instanceId);
|
|
726
|
+
assertUnfrozen(instance, 'the binding');
|
|
727
|
+
const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
|
|
728
|
+
const content = templateContentOf(template);
|
|
729
|
+
if (content.kind !== 'document') {
|
|
730
|
+
throw new Error(`template ${instance.template_key}@${instance.template_version} is a '${content.kind}' ` +
|
|
731
|
+
`protocol: fill its items instead of binding content`);
|
|
732
|
+
}
|
|
733
|
+
ctx.sql.exec(`UPDATE protocol_instances
|
|
734
|
+
SET content_ref_type = ?, content_ref_id = ?, bound_hash = ? WHERE id = ?`, [input.contentRef.entityType, input.contentRef.entityId, input.contentHash, instance.id]);
|
|
735
|
+
ctx.emit({
|
|
736
|
+
type: 'protocol.content-bound',
|
|
737
|
+
schemaVersion: 1,
|
|
738
|
+
entity: protocolRef(instance.id),
|
|
739
|
+
piiClass: 'none',
|
|
740
|
+
payload: {
|
|
741
|
+
instanceId: instance.id,
|
|
742
|
+
templateKey: instance.template_key,
|
|
743
|
+
templateVersion: instance.template_version,
|
|
744
|
+
documentType: content.documentType,
|
|
745
|
+
contentRef: input.contentRef,
|
|
746
|
+
boundHash: input.contentHash,
|
|
747
|
+
entity: { entityType: instance.entity_type, entityId: instance.entity_id },
|
|
748
|
+
},
|
|
749
|
+
});
|
|
750
|
+
return getInstanceRow(ctx, instance.id);
|
|
751
|
+
}
|
|
752
|
+
// ---------------------------------------------------------------------------
|
|
753
|
+
// Asynchronous signing — freeze first, collect signatures over days.
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
export const signatureRequestParty = z.object({
|
|
756
|
+
/** Display name for the role, never PII: 'Beställare', 'Leverantör'. */
|
|
757
|
+
label: z.string().min(1),
|
|
758
|
+
kind: z.enum(['principal', 'external']),
|
|
759
|
+
/**
|
|
760
|
+
* Who is expected to sign, when that is known up front. A `PrincipalId` for
|
|
761
|
+
* `principal`, an opaque `DataSubjectId` for `external`.
|
|
762
|
+
*
|
|
763
|
+
* Optional because it often is NOT known: a BankID flow addressed to a
|
|
764
|
+
* company mailbox is signed by whichever firmatecknare opens it, and their
|
|
765
|
+
* identity only becomes known when the provider reports it. Left unset, the
|
|
766
|
+
* signatory is whoever `recordSignature` reports; set, it is a constraint
|
|
767
|
+
* the recorded signatory must match.
|
|
768
|
+
*/
|
|
769
|
+
ref: z.string().min(1).optional(),
|
|
770
|
+
/**
|
|
771
|
+
* 'primary' for the issuing party, 'counter' for accepting parties.
|
|
772
|
+
*
|
|
773
|
+
* Optional, and resolved so that a request set ALWAYS has exactly one
|
|
774
|
+
* primary: declare one explicitly, or the first party becomes it. A set with
|
|
775
|
+
* no primary would leave a signed instance whose issuing signature is null —
|
|
776
|
+
* `requireCountersigned` would then pass on a document nobody issued.
|
|
777
|
+
*/
|
|
778
|
+
signatureKind: z.enum(['primary', 'counter']).optional(),
|
|
779
|
+
});
|
|
780
|
+
export const requestSignaturesInput = z.object({
|
|
781
|
+
instanceId: z.string().min(1),
|
|
782
|
+
/** 'scrive', 'bankid' — the provider a connector will dispatch to. */
|
|
783
|
+
method: z.string().min(1),
|
|
784
|
+
parties: z.array(signatureRequestParty).min(1),
|
|
785
|
+
});
|
|
786
|
+
/**
|
|
787
|
+
* Freeze the content and ask named parties to sign it — the asynchronous
|
|
788
|
+
* counterpart of `signProtocol`.
|
|
789
|
+
*
|
|
790
|
+
* This is the transition that closes the drift window: the instance leaves
|
|
791
|
+
* `open` immediately, so nothing can fill or rebind it while it sits at the
|
|
792
|
+
* provider. The hash is computed ONCE, here, and every signature that comes
|
|
793
|
+
* back must match it.
|
|
794
|
+
*
|
|
795
|
+
* The engine dispatches nothing. It emits `protocol.signatures-requested` with
|
|
796
|
+
* everything a connector needs (the hash, the parties, the method) and an
|
|
797
|
+
* executor outside the scope makes the call — module code never touches the
|
|
798
|
+
* network (boundary-lint R3).
|
|
799
|
+
*/
|
|
800
|
+
export async function requestSignatures(ctx, rawInput) {
|
|
801
|
+
const input = requestSignaturesInput.parse(rawInput);
|
|
802
|
+
const instance = getInstanceRow(ctx, input.instanceId);
|
|
386
803
|
if (instance.status !== 'open') {
|
|
387
|
-
throw new Error(`protocol is ${instance.status}: only an open protocol can be
|
|
804
|
+
throw new Error(`protocol is ${instance.status}: only an open protocol can be sent for signature`);
|
|
388
805
|
}
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
806
|
+
const primaries = input.parties.filter((p) => p.signatureKind === 'primary');
|
|
807
|
+
if (primaries.length > 1) {
|
|
808
|
+
throw new Error('at most one party may sign as primary — the rest counter-sign');
|
|
809
|
+
}
|
|
810
|
+
// Exactly one primary, always: the declared one, else the first party.
|
|
811
|
+
const primaryIndex = primaries.length === 1
|
|
812
|
+
? input.parties.findIndex((p) => p.signatureKind === 'primary')
|
|
813
|
+
: 0;
|
|
814
|
+
// Validate the refs that were supplied, so a personnummer cannot be smuggled
|
|
815
|
+
// into a request row and land in a signature by way of the matching check.
|
|
816
|
+
for (const party of input.parties) {
|
|
817
|
+
if (party.ref === undefined)
|
|
818
|
+
continue;
|
|
819
|
+
signatory.parse({ kind: party.kind, ref: party.ref, label: party.label });
|
|
820
|
+
}
|
|
821
|
+
const contentHash = await currentHash(ctx, instance);
|
|
822
|
+
const now = new Date().toISOString();
|
|
823
|
+
ctx.sql.exec(`UPDATE protocol_instances
|
|
824
|
+
SET status = 'pending_signature', frozen_hash = ?, frozen_at = ? WHERE id = ?`, [contentHash, now, instance.id]);
|
|
825
|
+
const created = [];
|
|
826
|
+
for (const [index, party] of input.parties.entries()) {
|
|
827
|
+
const id = ulid();
|
|
828
|
+
created.push(id);
|
|
829
|
+
ctx.sql.exec(`INSERT INTO protocol_signature_requests
|
|
830
|
+
(id, instance_id, party_label, party_kind, party_ref, signature_kind, method,
|
|
831
|
+
status, content_hash, external_ref, resolved_note, requested_by, requested_at, resolved_at)
|
|
832
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, NULL, NULL, ?, ?, NULL)`, [
|
|
833
|
+
id,
|
|
834
|
+
instance.id,
|
|
835
|
+
party.label,
|
|
836
|
+
party.kind,
|
|
837
|
+
party.ref ?? null,
|
|
838
|
+
index === primaryIndex ? 'primary' : 'counter',
|
|
839
|
+
input.method,
|
|
840
|
+
contentHash,
|
|
841
|
+
ctx.principal,
|
|
842
|
+
now,
|
|
843
|
+
]);
|
|
844
|
+
}
|
|
845
|
+
const requests = getRequestRows(ctx, instance.id).filter((r) => created.includes(r.id));
|
|
397
846
|
ctx.emit({
|
|
398
|
-
type: 'protocol.
|
|
847
|
+
type: 'protocol.signatures-requested',
|
|
399
848
|
schemaVersion: 1,
|
|
400
849
|
entity: protocolRef(instance.id),
|
|
401
|
-
piiClass: '
|
|
402
|
-
subjectId: dataSubjectId.parse(ctx.principal),
|
|
850
|
+
piiClass: 'none', // party refs are opaque ids; labels are role names, never PII
|
|
403
851
|
payload: {
|
|
404
852
|
instanceId: instance.id,
|
|
405
853
|
templateKey: instance.template_key,
|
|
406
854
|
templateVersion: instance.template_version,
|
|
407
855
|
entity: { entityType: instance.entity_type, entityId: instance.entity_id },
|
|
408
|
-
|
|
409
|
-
method: 'in-app',
|
|
856
|
+
method: input.method,
|
|
410
857
|
contentHash,
|
|
411
|
-
//
|
|
412
|
-
|
|
858
|
+
// Fat: a connector must never need a cross-module read to dispatch.
|
|
859
|
+
contentRef: instance.content_ref_type && instance.content_ref_id
|
|
860
|
+
? { entityType: instance.content_ref_type, entityId: instance.content_ref_id }
|
|
861
|
+
: null,
|
|
862
|
+
boundHash: instance.bound_hash,
|
|
863
|
+
parties: requests.map((r) => ({
|
|
864
|
+
requestId: r.id,
|
|
865
|
+
label: r.party_label,
|
|
866
|
+
kind: r.party_kind,
|
|
867
|
+
ref: r.party_ref,
|
|
868
|
+
signatureKind: r.signature_kind,
|
|
869
|
+
})),
|
|
413
870
|
},
|
|
414
871
|
});
|
|
415
|
-
return {
|
|
872
|
+
return { instance: getInstanceRow(ctx, instance.id), contentHash, requests };
|
|
873
|
+
}
|
|
874
|
+
export const recordSignatureInput = z.object({
|
|
875
|
+
requestId: z.string().min(1),
|
|
876
|
+
signatory,
|
|
877
|
+
/** When the party actually signed, per the provider — NOT when we heard. */
|
|
878
|
+
signedAt: instant,
|
|
879
|
+
/**
|
|
880
|
+
* The hash the provider signed over, as the provider reports it. Checked
|
|
881
|
+
* against the frozen hash: a mismatch means the document that was signed is
|
|
882
|
+
* not the document we froze, and that must fail closed rather than record a
|
|
883
|
+
* signature over unknown content.
|
|
884
|
+
*/
|
|
885
|
+
contentHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
886
|
+
/** Sealed PDF, provider transaction id, audit log — where the proof lives. */
|
|
887
|
+
evidenceRef: z.string().min(1).optional(),
|
|
888
|
+
});
|
|
889
|
+
/**
|
|
890
|
+
* Record a signature that happened OUTSIDE this system — the webhook's half.
|
|
891
|
+
*
|
|
892
|
+
* Everything `signProtocol` takes from ambient context, this takes as data:
|
|
893
|
+
* the signatory is supplied (and may be an external person with no account),
|
|
894
|
+
* the timestamp is the provider's, the method is the request's, and the
|
|
895
|
+
* evidence reference points at the provider's sealed artifact.
|
|
896
|
+
*
|
|
897
|
+
* The last pending request resolving is what transitions the instance to
|
|
898
|
+
* `signed` — which is the multi-party rule stated in code: an avtal is signed
|
|
899
|
+
* when every requested party has signed it, not when the first one has.
|
|
900
|
+
*
|
|
901
|
+
* NOTE ON THE CALLER: there is no webhook ingress in the kernel yet, and no
|
|
902
|
+
* inbound authority seam that would let a provider callback invoke a scope
|
|
903
|
+
* operation (`ScopeHost.getScope` demands a `PrincipalId`; `ExecutorHandler`
|
|
904
|
+
* has no return path into a scope). Until those land this is reachable only by
|
|
905
|
+
* a principal holding `protocol:record-signature` — a key deliberately held by
|
|
906
|
+
* no human role in any demo.
|
|
907
|
+
*/
|
|
908
|
+
export async function recordSignature(ctx, rawInput) {
|
|
909
|
+
const input = recordSignatureInput.parse(rawInput);
|
|
910
|
+
const request = ctx.sql.query('SELECT * FROM protocol_signature_requests WHERE id = ?', [input.requestId])[0];
|
|
911
|
+
if (!request)
|
|
912
|
+
throw new Error(`signature request not found: ${input.requestId}`);
|
|
913
|
+
if (request.status !== 'pending') {
|
|
914
|
+
throw new Error(`signature request is already ${request.status}: ${request.id}`);
|
|
915
|
+
}
|
|
916
|
+
const instance = getInstanceRow(ctx, request.instance_id);
|
|
917
|
+
if (instance.status !== 'pending_signature') {
|
|
918
|
+
throw new Error(`protocol is ${instance.status}: signatures are only recorded while out for signature`);
|
|
919
|
+
}
|
|
920
|
+
// Re-derive rather than trust the column, then check the provider agrees.
|
|
921
|
+
const frozen = await verifyFrozen(ctx, instance);
|
|
922
|
+
if (input.contentHash !== frozen) {
|
|
923
|
+
throw new Error(`signed content does not match the frozen protocol: provider reported ` +
|
|
924
|
+
`${input.contentHash}, frozen ${frozen}`);
|
|
925
|
+
}
|
|
926
|
+
if (request.party_kind !== input.signatory.kind) {
|
|
927
|
+
throw new Error(`signature request ${request.id} expects a ${request.party_kind} signatory, ` +
|
|
928
|
+
`got ${input.signatory.kind}`);
|
|
929
|
+
}
|
|
930
|
+
if (request.party_ref && request.party_ref !== input.signatory.ref) {
|
|
931
|
+
throw new Error(`signature request ${request.id} was addressed to a different party than the one who signed`);
|
|
932
|
+
}
|
|
933
|
+
if (getSignatureRows(ctx, instance.id).some((s) => s.signed_by === input.signatory.ref)) {
|
|
934
|
+
throw new Error('this signatory has already signed this protocol');
|
|
935
|
+
}
|
|
936
|
+
const id = ulid();
|
|
937
|
+
ctx.sql.exec(`INSERT INTO protocol_signatures
|
|
938
|
+
(id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
|
|
939
|
+
request_id, signatory_kind, signatory_label)
|
|
940
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
941
|
+
id,
|
|
942
|
+
instance.id,
|
|
943
|
+
input.signatory.ref,
|
|
944
|
+
request.signature_kind,
|
|
945
|
+
request.method,
|
|
946
|
+
frozen,
|
|
947
|
+
input.evidenceRef ?? null,
|
|
948
|
+
input.signedAt,
|
|
949
|
+
request.id,
|
|
950
|
+
input.signatory.kind,
|
|
951
|
+
input.signatory.label ?? request.party_label,
|
|
952
|
+
]);
|
|
953
|
+
ctx.sql.exec(`UPDATE protocol_signature_requests
|
|
954
|
+
SET status = 'signed', resolved_at = ?, external_ref = COALESCE(?, external_ref)
|
|
955
|
+
WHERE id = ?`, [input.signedAt, input.evidenceRef ?? null, request.id]);
|
|
956
|
+
// The instance is signed only when EVERY requested party signed — not merely
|
|
957
|
+
// when nothing is left pending. A declined or expired request is not pending
|
|
958
|
+
// either, and treating that as completion would mark an avtal fully executed
|
|
959
|
+
// that one party refused. An unresolved refusal holds the instance frozen
|
|
960
|
+
// until someone explicitly withdraws the request set.
|
|
961
|
+
const unsigned = ctx.sql.query(`SELECT COUNT(*) AS n FROM protocol_signature_requests
|
|
962
|
+
WHERE instance_id = ? AND status <> 'signed'`, [instance.id])[0].n;
|
|
963
|
+
const complete = unsigned === 0;
|
|
964
|
+
if (complete) {
|
|
965
|
+
ctx.sql.exec(`UPDATE protocol_instances SET status = 'signed' WHERE id = ?`, [instance.id]);
|
|
966
|
+
}
|
|
967
|
+
const signature = getSignatureRows(ctx, instance.id).find((s) => s.id === id);
|
|
968
|
+
emitSignatureEvent(ctx, {
|
|
416
969
|
instance: getInstanceRow(ctx, instance.id),
|
|
417
|
-
signature
|
|
970
|
+
signature,
|
|
971
|
+
contentHash: frozen,
|
|
972
|
+
complete,
|
|
973
|
+
});
|
|
974
|
+
return { instance: getInstanceRow(ctx, instance.id), signature };
|
|
975
|
+
}
|
|
976
|
+
export const declineSignatureInput = z.object({
|
|
977
|
+
requestId: z.string().min(1),
|
|
978
|
+
reason: z.string().min(1),
|
|
979
|
+
/** 'declined' when a party refused; 'expired' when the provider timed out. */
|
|
980
|
+
outcome: z.enum(['declined', 'expired']).default('declined'),
|
|
981
|
+
});
|
|
982
|
+
/**
|
|
983
|
+
* A party refused, or the provider's window expired. The instance stays
|
|
984
|
+
* `pending_signature` and therefore frozen — a refusal is not permission to
|
|
985
|
+
* edit. Renegotiating means cancelling the request set explicitly, which is a
|
|
986
|
+
* separate, permissioned, audited act.
|
|
987
|
+
*/
|
|
988
|
+
export function declineSignature(ctx, rawInput) {
|
|
989
|
+
const input = declineSignatureInput.parse(rawInput);
|
|
990
|
+
const request = ctx.sql.query('SELECT * FROM protocol_signature_requests WHERE id = ?', [input.requestId])[0];
|
|
991
|
+
if (!request)
|
|
992
|
+
throw new Error(`signature request not found: ${input.requestId}`);
|
|
993
|
+
if (request.status !== 'pending') {
|
|
994
|
+
throw new Error(`signature request is already ${request.status}: ${request.id}`);
|
|
995
|
+
}
|
|
996
|
+
const instance = getInstanceRow(ctx, request.instance_id);
|
|
997
|
+
ctx.sql.exec(`UPDATE protocol_signature_requests
|
|
998
|
+
SET status = ?, resolved_at = ?, resolved_note = ? WHERE id = ?`, [input.outcome ?? 'declined', new Date().toISOString(), input.reason, request.id]);
|
|
999
|
+
ctx.emit({
|
|
1000
|
+
type: 'protocol.signature-declined',
|
|
1001
|
+
schemaVersion: 1,
|
|
1002
|
+
entity: protocolRef(instance.id),
|
|
1003
|
+
piiClass: 'none',
|
|
1004
|
+
payload: {
|
|
1005
|
+
instanceId: instance.id,
|
|
1006
|
+
requestId: request.id,
|
|
1007
|
+
templateKey: instance.template_key,
|
|
1008
|
+
entity: { entityType: instance.entity_type, entityId: instance.entity_id },
|
|
1009
|
+
partyLabel: request.party_label,
|
|
1010
|
+
outcome: input.outcome ?? 'declined',
|
|
1011
|
+
reason: input.reason,
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
return ctx.sql.query('SELECT * FROM protocol_signature_requests WHERE id = ?', [request.id])[0];
|
|
1015
|
+
}
|
|
1016
|
+
export const cancelSignatureRequestsInput = z.object({
|
|
1017
|
+
instanceId: z.string().min(1),
|
|
1018
|
+
reason: z.string().min(1),
|
|
1019
|
+
});
|
|
1020
|
+
/**
|
|
1021
|
+
* Withdraw an outstanding request set and thaw the instance — the
|
|
1022
|
+
* renegotiation path an avtal needs when a party declines or the price moves.
|
|
1023
|
+
*
|
|
1024
|
+
* Cancelling THAWS: status returns to `open` and the frozen hash is cleared,
|
|
1025
|
+
* so the next `requestSignatures` freezes fresh content at a fresh hash.
|
|
1026
|
+
* Signatures already collected are NOT removed — they are append-only history
|
|
1027
|
+
* attesting to content that really was frozen at the time — but they were
|
|
1028
|
+
* taken over the OLD hash, so they can never satisfy the new one. That is the
|
|
1029
|
+
* intended reading: a party who signed v1 has not signed v2.
|
|
1030
|
+
*/
|
|
1031
|
+
export function cancelSignatureRequests(ctx, rawInput) {
|
|
1032
|
+
const input = cancelSignatureRequestsInput.parse(rawInput);
|
|
1033
|
+
const instance = getInstanceRow(ctx, input.instanceId);
|
|
1034
|
+
if (instance.status !== 'pending_signature') {
|
|
1035
|
+
throw new Error(`protocol is ${instance.status}: only a protocol out for signature can be withdrawn`);
|
|
1036
|
+
}
|
|
1037
|
+
const now = new Date().toISOString();
|
|
1038
|
+
const cancelled = ctx.sql.exec(`UPDATE protocol_signature_requests
|
|
1039
|
+
SET status = 'cancelled', resolved_at = ?, resolved_note = ?
|
|
1040
|
+
WHERE instance_id = ? AND status = 'pending'`, [now, input.reason, instance.id]);
|
|
1041
|
+
ctx.sql.exec(`UPDATE protocol_instances
|
|
1042
|
+
SET status = 'open', frozen_hash = NULL, frozen_at = NULL WHERE id = ?`, [instance.id]);
|
|
1043
|
+
ctx.emit({
|
|
1044
|
+
type: 'protocol.signatures-cancelled',
|
|
1045
|
+
schemaVersion: 1,
|
|
1046
|
+
entity: protocolRef(instance.id),
|
|
1047
|
+
piiClass: 'none',
|
|
1048
|
+
payload: {
|
|
1049
|
+
instanceId: instance.id,
|
|
1050
|
+
templateKey: instance.template_key,
|
|
1051
|
+
entity: { entityType: instance.entity_type, entityId: instance.entity_id },
|
|
1052
|
+
cancelled: cancelled.changes,
|
|
1053
|
+
reason: input.reason,
|
|
1054
|
+
},
|
|
1055
|
+
});
|
|
1056
|
+
return getInstanceRow(ctx, instance.id);
|
|
1057
|
+
}
|
|
1058
|
+
// ---------------------------------------------------------------------------
|
|
1059
|
+
// In-app signing — the everyday field case, unchanged in shape and behaviour.
|
|
1060
|
+
// ---------------------------------------------------------------------------
|
|
1061
|
+
/** Both signing paths emit the same pair of events, so they agree by construction. */
|
|
1062
|
+
function emitSignatureEvent(ctx, args) {
|
|
1063
|
+
const { instance, signature, contentHash, complete } = args;
|
|
1064
|
+
const signatories = getSignatureRows(ctx, instance.id).map(signatoryOf);
|
|
1065
|
+
const base = {
|
|
1066
|
+
instanceId: instance.id,
|
|
1067
|
+
templateKey: instance.template_key,
|
|
1068
|
+
templateVersion: instance.template_version,
|
|
1069
|
+
entity: { entityType: instance.entity_type, entityId: instance.entity_id },
|
|
1070
|
+
method: signature.method,
|
|
1071
|
+
contentHash,
|
|
1072
|
+
// Document kind: what was signed lives in the vertical, so the event
|
|
1073
|
+
// carries the pointer and the hash rather than the content.
|
|
1074
|
+
contentRef: instance.content_ref_type && instance.content_ref_id
|
|
1075
|
+
? { entityType: instance.content_ref_type, entityId: instance.content_ref_id }
|
|
1076
|
+
: null,
|
|
1077
|
+
boundHash: instance.bound_hash,
|
|
1078
|
+
// fat payload: the frozen answers travel with the event (checklist kind)
|
|
1079
|
+
responses: args.responses ?? {},
|
|
1080
|
+
signatory: signatoryOf(signature),
|
|
1081
|
+
// Retained for consumers that read the flat field: the signatory's ref.
|
|
1082
|
+
signedBy: signature.signed_by,
|
|
1083
|
+
evidenceRef: signature.evidence_ref,
|
|
1084
|
+
signedAt: signature.signed_at,
|
|
1085
|
+
/** False while other requested parties are still outstanding. */
|
|
1086
|
+
complete,
|
|
1087
|
+
signatories,
|
|
418
1088
|
};
|
|
1089
|
+
if (signature.kind === 'primary') {
|
|
1090
|
+
ctx.emit({
|
|
1091
|
+
type: 'protocol.signed',
|
|
1092
|
+
schemaVersion: 1,
|
|
1093
|
+
entity: protocolRef(instance.id),
|
|
1094
|
+
piiClass: 'pseudonymous',
|
|
1095
|
+
subjectId: dataSubjectId.parse(signature.signed_by),
|
|
1096
|
+
payload: base,
|
|
1097
|
+
});
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const primary = getSignatureRows(ctx, instance.id).find((s) => s.kind === 'primary');
|
|
1101
|
+
ctx.emit({
|
|
1102
|
+
type: 'protocol.countersigned',
|
|
1103
|
+
schemaVersion: 1,
|
|
1104
|
+
entity: protocolRef(instance.id),
|
|
1105
|
+
piiClass: 'pseudonymous',
|
|
1106
|
+
subjectId: dataSubjectId.parse(signature.signed_by),
|
|
1107
|
+
payload: {
|
|
1108
|
+
...base,
|
|
1109
|
+
signedBy: primary?.signed_by ?? null,
|
|
1110
|
+
countersignedBy: signature.signed_by,
|
|
1111
|
+
countersignatory: signatoryOf(signature),
|
|
1112
|
+
},
|
|
1113
|
+
});
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* In-app sign (engine-protocol.md §5): the authenticated principal signs, now;
|
|
1117
|
+
* integrity comes from the hash + immutability + the spine event. Freezing and
|
|
1118
|
+
* signing coincide here, which is sound precisely BECAUSE it is synchronous —
|
|
1119
|
+
* there is no window between what the signer saw and what was hashed.
|
|
1120
|
+
*
|
|
1121
|
+
* For an external provider flow (BankID via Scrive) use `requestSignatures` +
|
|
1122
|
+
* `recordSignature` instead: the signatory is not `ctx.principal`, the moment
|
|
1123
|
+
* is not now, and freezing must happen at dispatch rather than at signature.
|
|
1124
|
+
* Exactly ONE primary signature per instance — enforced by the open → signed
|
|
1125
|
+
* transition.
|
|
1126
|
+
*/
|
|
1127
|
+
export async function signProtocol(ctx, input) {
|
|
1128
|
+
const instance = getInstanceRow(ctx, z.string().min(1).parse(input.instanceId));
|
|
1129
|
+
if (instance.status !== 'open') {
|
|
1130
|
+
throw new Error(`protocol is ${instance.status}: only an open protocol can be signed`);
|
|
1131
|
+
}
|
|
1132
|
+
const latest = latestPerItem(getResponseRows(ctx, instance.id));
|
|
1133
|
+
const contentHash = await currentHash(ctx, instance);
|
|
1134
|
+
const now = new Date().toISOString();
|
|
1135
|
+
const id = ulid();
|
|
1136
|
+
ctx.sql.exec(`INSERT INTO protocol_signatures
|
|
1137
|
+
(id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
|
|
1138
|
+
request_id, signatory_kind, signatory_label)
|
|
1139
|
+
VALUES (?, ?, ?, 'primary', 'in-app', ?, NULL, ?, NULL, 'principal', NULL)`, [id, instance.id, ctx.principal, contentHash, now]);
|
|
1140
|
+
ctx.sql.exec(`UPDATE protocol_instances SET status = 'signed', frozen_hash = ?, frozen_at = ? WHERE id = ?`, [contentHash, now, instance.id]);
|
|
1141
|
+
const signature = getSignatureRows(ctx, instance.id).find((s) => s.id === id);
|
|
1142
|
+
emitSignatureEvent(ctx, {
|
|
1143
|
+
instance: getInstanceRow(ctx, instance.id),
|
|
1144
|
+
signature,
|
|
1145
|
+
contentHash,
|
|
1146
|
+
complete: true,
|
|
1147
|
+
responses: frozenAnswers(latest),
|
|
1148
|
+
});
|
|
1149
|
+
return { instance: getInstanceRow(ctx, instance.id), signature };
|
|
419
1150
|
}
|
|
420
1151
|
/**
|
|
421
1152
|
* Counter-sign (invariant 3): a SECOND signature on the SAME frozen content —
|
|
422
1153
|
* the customer at pickup. Requires a signed instance; the content hash is
|
|
423
|
-
* recomputed and must equal the
|
|
424
|
-
*
|
|
425
|
-
*
|
|
1154
|
+
* recomputed and must equal the frozen hash (frozen content, verified, never
|
|
1155
|
+
* assumed). One counter-signature per signatory; a signatory never
|
|
1156
|
+
* counter-signs what they primary-signed.
|
|
426
1157
|
*/
|
|
427
1158
|
export async function countersignProtocol(ctx, input) {
|
|
428
1159
|
const instance = getInstanceRow(ctx, z.string().min(1).parse(input.instanceId));
|
|
@@ -433,46 +1164,27 @@ export async function countersignProtocol(ctx, input) {
|
|
|
433
1164
|
const primary = signatures.find((s) => s.kind === 'primary');
|
|
434
1165
|
if (!primary)
|
|
435
1166
|
throw new Error(`signed protocol has no primary signature: ${instance.id}`); // corrupt state, fail closed
|
|
436
|
-
if (
|
|
437
|
-
throw new Error('counter-signature must come from a
|
|
438
|
-
}
|
|
439
|
-
if (signatures.some((s) => s.kind === 'counter' && s.signed_by === ctx.principal)) {
|
|
440
|
-
throw new Error('already counter-signed by this principal');
|
|
1167
|
+
if (signatures.some((s) => s.signed_by === ctx.principal)) {
|
|
1168
|
+
throw new Error('counter-signature must come from a signatory who has not already signed');
|
|
441
1169
|
}
|
|
442
|
-
// Re-run the
|
|
443
|
-
//
|
|
444
|
-
const
|
|
1170
|
+
// Re-run the recipe against stored state: the counter-signature binds to
|
|
1171
|
+
// verified frozen content, not to a trusted column.
|
|
1172
|
+
const contentHash = await verifyFrozen(ctx, instance);
|
|
445
1173
|
const latest = latestPerItem(getResponseRows(ctx, instance.id));
|
|
446
|
-
const contentHash = await protocolContentHash(template, latest);
|
|
447
|
-
if (contentHash !== primary.content_hash) {
|
|
448
|
-
throw new Error(`content hash mismatch on counter-sign: stored ${primary.content_hash}, replayed ${contentHash}`);
|
|
449
|
-
}
|
|
450
1174
|
const id = ulid();
|
|
451
1175
|
ctx.sql.exec(`INSERT INTO protocol_signatures
|
|
452
|
-
(id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
templateKey: instance.template_key,
|
|
463
|
-
templateVersion: instance.template_version,
|
|
464
|
-
entity: { entityType: instance.entity_type, entityId: instance.entity_id },
|
|
465
|
-
signedBy: primary.signed_by,
|
|
466
|
-
countersignedBy: ctx.principal,
|
|
467
|
-
method: 'in-app',
|
|
468
|
-
contentHash,
|
|
469
|
-
responses: frozenAnswers(latest),
|
|
470
|
-
},
|
|
1176
|
+
(id, instance_id, signed_by, kind, method, content_hash, evidence_ref, signed_at,
|
|
1177
|
+
request_id, signatory_kind, signatory_label)
|
|
1178
|
+
VALUES (?, ?, ?, 'counter', 'in-app', ?, NULL, ?, NULL, 'principal', NULL)`, [id, instance.id, ctx.principal, contentHash, new Date().toISOString()]);
|
|
1179
|
+
const signature = getSignatureRows(ctx, instance.id).find((s) => s.id === id);
|
|
1180
|
+
emitSignatureEvent(ctx, {
|
|
1181
|
+
instance,
|
|
1182
|
+
signature,
|
|
1183
|
+
contentHash,
|
|
1184
|
+
complete: true,
|
|
1185
|
+
responses: frozenAnswers(latest),
|
|
471
1186
|
});
|
|
472
|
-
return {
|
|
473
|
-
instance: getInstanceRow(ctx, instance.id),
|
|
474
|
-
signature: getSignatureRows(ctx, instance.id).find((s) => s.id === id),
|
|
475
|
-
};
|
|
1187
|
+
return { instance: getInstanceRow(ctx, instance.id), signature };
|
|
476
1188
|
}
|
|
477
1189
|
/** Voiding, not deleting: a superseded protocol keeps its rows forever. */
|
|
478
1190
|
export function voidProtocol(ctx, input) {
|
|
@@ -480,8 +1192,15 @@ export function voidProtocol(ctx, input) {
|
|
|
480
1192
|
const instance = getInstanceRow(ctx, z.string().min(1).parse(input.instanceId));
|
|
481
1193
|
if (instance.status === 'voided')
|
|
482
1194
|
throw new Error('protocol is already voided');
|
|
1195
|
+
const now = new Date().toISOString();
|
|
1196
|
+
// An outstanding request set dies with the protocol — leaving rows `pending`
|
|
1197
|
+
// on a voided instance would keep `requireAllSigned` reading a live gate on
|
|
1198
|
+
// a document that is out of play.
|
|
1199
|
+
ctx.sql.exec(`UPDATE protocol_signature_requests
|
|
1200
|
+
SET status = 'cancelled', resolved_at = ?, resolved_note = ?
|
|
1201
|
+
WHERE instance_id = ? AND status = 'pending'`, [now, `protocol voided: ${reason}`, instance.id]);
|
|
483
1202
|
ctx.sql.exec(`UPDATE protocol_instances
|
|
484
|
-
SET status = 'voided', voided_by = ?, voided_reason = ?, voided_at = ? WHERE id = ?`, [ctx.principal, reason,
|
|
1203
|
+
SET status = 'voided', voided_by = ?, voided_reason = ?, voided_at = ? WHERE id = ?`, [ctx.principal, reason, now, instance.id]);
|
|
485
1204
|
ctx.emit({
|
|
486
1205
|
type: 'protocol.voided',
|
|
487
1206
|
schemaVersion: 1,
|
|
@@ -508,12 +1227,13 @@ export function getProtocol(ctx, instanceId) {
|
|
|
508
1227
|
key: template.key,
|
|
509
1228
|
version: template.version,
|
|
510
1229
|
title: template.title,
|
|
511
|
-
content:
|
|
1230
|
+
content: templateContentOf(template),
|
|
512
1231
|
},
|
|
513
1232
|
responses,
|
|
514
1233
|
latest: latestPerItem(responses),
|
|
515
1234
|
signature: signatures.find((s) => s.kind === 'primary') ?? null,
|
|
516
1235
|
signatures,
|
|
1236
|
+
requests: getRequestRows(ctx, instance.id),
|
|
517
1237
|
};
|
|
518
1238
|
}
|
|
519
1239
|
export function listProtocolsForEntity(ctx, entity) {
|
|
@@ -521,21 +1241,33 @@ export function listProtocolsForEntity(ctx, entity) {
|
|
|
521
1241
|
WHERE entity_type = ? AND entity_id = ? ORDER BY rowid`, [entity.entityType, entity.entityId]);
|
|
522
1242
|
return instances.map((instance) => {
|
|
523
1243
|
const template = getTemplateRow(ctx, instance.template_key, instance.template_version);
|
|
524
|
-
const content =
|
|
525
|
-
|
|
526
|
-
|
|
1244
|
+
const content = templateContentOf(template);
|
|
1245
|
+
// A document has one thing to settle — its bound content — so it reads as
|
|
1246
|
+
// 0/1 or 1/1 rather than pretending to a checklist's item count.
|
|
1247
|
+
const total = content.kind === 'checklist'
|
|
1248
|
+
? content.sections.reduce((n, s) => n + s.items.length, 0)
|
|
1249
|
+
: 1;
|
|
1250
|
+
const answered = content.kind === 'checklist'
|
|
1251
|
+
? Object.keys(latestPerItem(getResponseRows(ctx, instance.id))).length
|
|
1252
|
+
: instance.bound_hash
|
|
1253
|
+
? 1
|
|
1254
|
+
: 0;
|
|
527
1255
|
const signatures = getSignatureRows(ctx, instance.id);
|
|
528
1256
|
const primary = signatures.find((s) => s.kind === 'primary');
|
|
529
1257
|
const counter = signatures.filter((s) => s.kind === 'counter').at(-1);
|
|
1258
|
+
const pendingSignatures = ctx.sql.query(`SELECT COUNT(*) AS n FROM protocol_signature_requests
|
|
1259
|
+
WHERE instance_id = ? AND status = 'pending'`, [instance.id])[0].n;
|
|
530
1260
|
return {
|
|
531
1261
|
instance,
|
|
532
1262
|
title: template.title,
|
|
1263
|
+
contentKind: content.kind,
|
|
533
1264
|
answered,
|
|
534
1265
|
total,
|
|
535
1266
|
signedBy: primary?.signed_by ?? null,
|
|
536
1267
|
signedAt: primary?.signed_at ?? null,
|
|
537
1268
|
countersignedBy: counter?.signed_by ?? null,
|
|
538
1269
|
countersignedAt: counter?.signed_at ?? null,
|
|
1270
|
+
pendingSignatures,
|
|
539
1271
|
};
|
|
540
1272
|
});
|
|
541
1273
|
}
|
|
@@ -564,6 +1296,32 @@ const fillOp = async (ctx, input) => {
|
|
|
564
1296
|
assertAllowed(await ctx.check(PROTOCOL_PERM.fill, protocolRef(input.instanceId)));
|
|
565
1297
|
return fillProtocol(ctx, input);
|
|
566
1298
|
};
|
|
1299
|
+
const bindOp = async (ctx, input) => {
|
|
1300
|
+
assertAllowed(await ctx.check(PROTOCOL_PERM.bind, protocolRef(input.instanceId)));
|
|
1301
|
+
return bindDocument(ctx, input);
|
|
1302
|
+
};
|
|
1303
|
+
const requestSignaturesOp = async (ctx, input) => {
|
|
1304
|
+
assertAllowed(await ctx.check(PROTOCOL_PERM.requestSignature, protocolRef(input.instanceId)));
|
|
1305
|
+
return requestSignatures(ctx, input);
|
|
1306
|
+
};
|
|
1307
|
+
const cancelSignaturesOp = async (ctx, input) => {
|
|
1308
|
+
assertAllowed(await ctx.check(PROTOCOL_PERM.requestSignature, protocolRef(input.instanceId)));
|
|
1309
|
+
return cancelSignatureRequests(ctx, input);
|
|
1310
|
+
};
|
|
1311
|
+
/**
|
|
1312
|
+
* The ingress-facing pair. Both check `protocol:record-signature`, which is a
|
|
1313
|
+
* connector's key rather than a person's — the permission diff is where a
|
|
1314
|
+
* deployment declares that it trusts something to speak for an external
|
|
1315
|
+
* signing provider.
|
|
1316
|
+
*/
|
|
1317
|
+
const recordSignatureOp = async (ctx, input) => {
|
|
1318
|
+
assertAllowed(await ctx.check(PROTOCOL_PERM.recordSignature));
|
|
1319
|
+
return recordSignature(ctx, input);
|
|
1320
|
+
};
|
|
1321
|
+
const declineSignatureOp = async (ctx, input) => {
|
|
1322
|
+
assertAllowed(await ctx.check(PROTOCOL_PERM.recordSignature));
|
|
1323
|
+
return declineSignature(ctx, input);
|
|
1324
|
+
};
|
|
567
1325
|
const signOp = async (ctx, input) => {
|
|
568
1326
|
assertAllowed(await ctx.check(PROTOCOL_PERM.sign, protocolRef(input.instanceId)));
|
|
569
1327
|
return signProtocol(ctx, input);
|
|
@@ -599,6 +1357,11 @@ export const protocolModule = {
|
|
|
599
1357
|
'protocol/list-templates': listTemplatesOp,
|
|
600
1358
|
'protocol/instantiate': instantiateOp,
|
|
601
1359
|
'protocol/fill': fillOp,
|
|
1360
|
+
'protocol/bind-document': bindOp,
|
|
1361
|
+
'protocol/request-signatures': requestSignaturesOp,
|
|
1362
|
+
'protocol/cancel-signatures': cancelSignaturesOp,
|
|
1363
|
+
'protocol/record-signature': recordSignatureOp,
|
|
1364
|
+
'protocol/decline-signature': declineSignatureOp,
|
|
602
1365
|
'protocol/sign': signOp,
|
|
603
1366
|
'protocol/countersign': countersignOp,
|
|
604
1367
|
'protocol/void': voidOp,
|