@real-life/trust-protocol 0.4.0 → 0.6.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.
- package/CHANGELOG.md +137 -0
- package/README.md +91 -24
- package/dist/adapters/vti-mediator.d.ts +36 -8
- package/dist/adapters/vti-mediator.d.ts.map +1 -1
- package/dist/adapters/vti-mediator.js +193 -29
- package/dist/adapters/vti-mediator.js.map +1 -1
- package/dist/codec.d.ts +2 -1
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js +75 -26
- package/dist/codec.js.map +1 -1
- package/dist/engine.d.ts +77 -4
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +419 -31
- package/dist/engine.js.map +1 -1
- package/dist/entropy.d.ts +1 -1
- package/dist/entropy.d.ts.map +1 -1
- package/dist/entropy.js +1 -1
- package/dist/entropy.js.map +1 -1
- package/dist/events.d.ts +21 -0
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/holder-api.d.ts +44 -2
- package/dist/holder-api.d.ts.map +1 -1
- package/dist/holder-api.js +40 -5
- package/dist/holder-api.js.map +1 -1
- package/dist/{holder-state-v1.d.ts → holder-state-v3.d.ts} +261 -201
- package/dist/holder-state-v3.d.ts.map +1 -0
- package/dist/holder-state-v3.js +2 -0
- package/dist/holder-state-v3.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -6
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +2 -2
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +1 -1
- package/dist/internal.js.map +1 -1
- package/dist/introduction/holder.d.ts.map +1 -1
- package/dist/introduction/holder.js +7 -0
- package/dist/introduction/holder.js.map +1 -1
- package/dist/invariants.d.ts.map +1 -1
- package/dist/invariants.js +70 -0
- package/dist/invariants.js.map +1 -1
- package/dist/membership/holder.d.ts +3 -5
- package/dist/membership/holder.d.ts.map +1 -1
- package/dist/membership/holder.js +165 -88
- package/dist/membership/holder.js.map +1 -1
- package/dist/membership.d.ts +31 -0
- package/dist/membership.d.ts.map +1 -1
- package/dist/membership.js +25 -2
- package/dist/membership.js.map +1 -1
- package/dist/message/holder.d.ts +49 -0
- package/dist/message/holder.d.ts.map +1 -0
- package/dist/message/holder.js +290 -0
- package/dist/message/holder.js.map +1 -0
- package/dist/message.d.ts +43 -0
- package/dist/message.d.ts.map +1 -0
- package/dist/message.js +51 -0
- package/dist/message.js.map +1 -0
- package/dist/receive.d.ts +4 -3
- package/dist/receive.d.ts.map +1 -1
- package/dist/receive.js +23 -4
- package/dist/receive.js.map +1 -1
- package/dist/results.d.ts +22 -3
- package/dist/results.d.ts.map +1 -1
- package/dist/results.js.map +1 -1
- package/dist/schemas.d.ts.map +1 -1
- package/dist/schemas.js +9 -6
- package/dist/schemas.js.map +1 -1
- package/dist/state.d.ts +88 -14
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js +2 -1
- package/dist/state.js.map +1 -1
- package/dist/trust/continuity.d.ts.map +1 -1
- package/dist/trust/continuity.js +7 -1
- package/dist/trust/continuity.js.map +1 -1
- package/dist/trust/entries.d.ts.map +1 -1
- package/dist/trust/entries.js +103 -2
- package/dist/trust/entries.js.map +1 -1
- package/dist/views.d.ts +34 -2
- package/dist/views.d.ts.map +1 -1
- package/dist/views.js +19 -1
- package/dist/views.js.map +1 -1
- package/dist/wire.d.ts +47 -6
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js.map +1 -1
- package/package.json +6 -4
- package/schemas/{holder-state-v1.schema.json → holder-state-v3.schema.json} +300 -12
- package/dist/holder-state-v1.d.ts.map +0 -1
- package/dist/holder-state-v1.js +0 -2
- package/dist/holder-state-v1.js.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// membership/holder — groups on the holder (lib Stage 2, PR 7a; Membership
|
|
2
|
-
// Tasks 0.
|
|
2
|
+
// Tasks 0.20 on Access 0.57, Delivery 0.82): founding, the prelude and
|
|
3
3
|
// the invite, the accept, admission and welcome, evidence and vouches, on
|
|
4
4
|
// HolderState and the engine. The pure forms and checks are the root
|
|
5
5
|
// module's (membership.ts).
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// that context is the founder's member
|
|
11
11
|
// anchor, its memberOp the genesis)
|
|
12
12
|
// membership.invite the prelude toward a contact (Membership
|
|
13
|
-
//
|
|
13
|
+
// 3.5); the invite follows its answer
|
|
14
14
|
// membership.vouch a vouch@2 for a held consent pair
|
|
15
15
|
// (Access 5.3), relayed as evidence
|
|
16
16
|
// decision group-invite: accept signs and sends the accept (3.2),
|
|
@@ -18,21 +18,24 @@
|
|
|
18
18
|
// tasks membership-invite/0.2 (proof absent), membership-accept/0.2
|
|
19
19
|
// (proof required), membership-evidence/0.2 and /0.1 (proof
|
|
20
20
|
// absent): channel bindings on member keys (receive.ts);
|
|
21
|
-
//
|
|
22
|
-
// reply/0.1 (tuple binding
|
|
23
|
-
// (channel binding), this
|
|
24
|
-
// signature-class: signed acks
|
|
21
|
+
// membership-prelude-request/0.1 and membership-prelude-
|
|
22
|
+
// reply/0.1 (proof required, tuple binding; Membership 3.5),
|
|
23
|
+
// and x-membership-welcome/0.1 (channel binding), this
|
|
24
|
+
// library's experimental form; all signature-class: signed acks
|
|
25
25
|
// keepKeys the member keys of held groups and of groups on the way in,
|
|
26
|
-
// and the tuple rkid of every
|
|
27
|
-
//
|
|
26
|
+
// and the tuple rkid of every prelude sent and held, and of an
|
|
27
|
+
// answered one while its answer may still travel
|
|
28
|
+
// retain declined and expired invitations leave `joining`; a
|
|
29
|
+
// binding once its relationship ended and no open or accepted
|
|
30
|
+
// invitation rests on it (RLTP-MT-3945)
|
|
28
31
|
//
|
|
29
32
|
// Choices this file makes where spec and note leave them open:
|
|
30
|
-
// - the prelude
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
+
// - the prelude (Membership 3.5) travels over the relationship tuple as
|
|
34
|
+
// its two registered types; the invitee's holder answers on its own
|
|
35
|
+
// (3960), registering its member label and key first (Identity 6.1),
|
|
33
36
|
// with a displayed member card (2.6) whose key the invite is sealed
|
|
34
37
|
// to. An answer is taken only on a thread whose request this holder
|
|
35
|
-
// sent
|
|
38
|
+
// sent and still holds, on its tuple (3965)
|
|
36
39
|
// - the welcome: Membership 3.3 carries it in access-operation/0.1 with
|
|
37
40
|
// a member.add envelope and a sealed rltp-welcome/0.1 (Section 4),
|
|
38
41
|
// Access's materialization deciding; until the Access envelope is
|
|
@@ -45,34 +48,37 @@
|
|
|
45
48
|
// 3.3), a member's the oid of its accept document's digest (a member admitted later is
|
|
46
49
|
// unknown to earlier members until their port materializes it)
|
|
47
50
|
// - the admission without a human act at the admitting member: the
|
|
48
|
-
// invitation
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
51
|
+
// invitation stands as the inviter's act of admission (RLTP-MT-3282),
|
|
52
|
+
// so the accept (n = 1) or the evidence that completes the vouches
|
|
53
|
+
// admits in the same commit — before invite.validUntil +
|
|
54
|
+
// membership-skew + the longest give-up horizon by the inviter's
|
|
55
|
+
// clock; later the consent stays as evidence (an admission prompt is
|
|
56
|
+
// not built)
|
|
54
57
|
// - founding: the genesis admits under the rule of one (vouchThreshold
|
|
55
58
|
// "1"); a stricter rule needs policy.change, which is not built, so
|
|
56
59
|
// found refuses another (threshold-unsupported). A member's own vouch
|
|
57
60
|
// for its own invitee never counts beside its admission and is refused
|
|
58
61
|
// (own-admission)
|
|
59
62
|
// - the prelude is bound both ways: the request names the inviter's
|
|
60
|
-
// member anchor and the answering holder
|
|
61
|
-
// (genesis digest, inviter)
|
|
62
|
-
// asked as that inviter; the inviter files its request by thread
|
|
63
|
+
// member anchor and the answering holder keeps the binding under
|
|
64
|
+
// (genesis digest, inviter) with the arrival tuple (3925), so an invite
|
|
65
|
+
// surfaces as the contact that asked as that inviter; the inviter files its request by thread
|
|
63
66
|
// (group, contact, own rkid) and takes an answer only for exactly that
|
|
64
67
|
// group on exactly that thread and tuple
|
|
65
68
|
// - invitations are kept per thread: a new invite opens its own thread
|
|
66
69
|
// beside earlier ones, so an accept signed in time on an earlier thread
|
|
67
70
|
// still answers it; an accept answers exactly the invitation of its
|
|
68
71
|
// thread, and an admission answers every invitation to its subject
|
|
72
|
+
// - the lock keys of 3950–3957 and 3980: the engine serializes every
|
|
73
|
+
// operation of a holder and commits by CAS over the whole state
|
|
74
|
+
// (receive.ts), a superset of every key the rules name
|
|
69
75
|
// - bounded state (tick): an issued invitation leaves past validUntil +
|
|
70
76
|
// membership-skew + the longest give-up horizon (P90D, RLTP-MT-3090),
|
|
71
77
|
// as does evidence (an accept signed in time may arrive that late), or
|
|
72
78
|
// once its subject is on the roster; an outgoing prelude past an
|
|
73
79
|
// invite's life and the horizon (its answer may travel that long); an
|
|
74
|
-
// answered prelude
|
|
75
|
-
//
|
|
80
|
+
// answered prelude's binding once its relationship ended and no open
|
|
81
|
+
// or accepted invitation rests on it (3945); an accepted or joined invitation past
|
|
76
82
|
// validUntil + skew + two horizons — the accept's delivery and the
|
|
77
83
|
// welcome's — so the welcome finds the accepted state and the member
|
|
78
84
|
// key (kept while an invitation is open or accepted); an explicit abort
|
|
@@ -83,9 +89,10 @@
|
|
|
83
89
|
// under the inviter's member key over (anchor, group, thread, and both
|
|
84
90
|
// pair anchors of the channel it travels on, checked against the
|
|
85
91
|
// document's issuer and recipient as stage 8 authenticated them), so
|
|
86
|
-
// no other contact can replay it on its own channel; the
|
|
87
|
-
// answering holder
|
|
88
|
-
// another
|
|
92
|
+
// no other contact can replay it on its own channel (3905, 3910); the
|
|
93
|
+
// answering holder keeps one binding per relationship (3930, 3935):
|
|
94
|
+
// a request from another relationship is refused while the bound one
|
|
95
|
+
// is current, whichever entries they feed. Residue: the binding links the inviter's member
|
|
89
96
|
// anchor to its pair channel transferably (the invite names that
|
|
90
97
|
// anchor before consent anyway)
|
|
91
98
|
// - the admission policy: the group rule vouchThreshold n reads as the
|
|
@@ -117,18 +124,17 @@ import { b64uOf, fromB64u, iso, sameDigest, toU } from '../core.js';
|
|
|
117
124
|
import { anchorOfEd, diSignWith, diVerify, digestBytes, digestDoc } from '../crypto.js';
|
|
118
125
|
import { seal } from '../delivery.js';
|
|
119
126
|
import { cardBody } from '../encounter.js';
|
|
120
|
-
import { ACCEPT_TYPE, EVIDENCE_TYPE, EVIDENCE_TYPE_01, INVITE_TYPE, MEMBERSHIP_SKEW, acceptProblem, enclosedCardOk, evidenceProblem, inviteCredentialBody, inviteProblem, memberLabelOf, membershipSchemaOk, oidOf, vouchCredentialBody, vouchProblem, withinBudget, } from '../membership.js';
|
|
127
|
+
import { ACCEPT_TYPE, EVIDENCE_TYPE, EVIDENCE_TYPE_01, INVITE_TYPE, MEMBERSHIP_SKEW, PRELUDE_REPLY_TYPE, PRELUDE_REQUEST_TYPE, acceptProblem, enclosedCardOk, evidenceProblem, inviteCredentialBody, inviteProblem, inviterBindingBody, memberLabelOf, membershipSchemaOk, oidOf, preludeRequestProblem, vouchCredentialBody, vouchProblem, withinBudget, } from '../membership.js';
|
|
121
128
|
import { withLabel, withPairNonce } from '../identity-state.js';
|
|
122
129
|
import { ED_PKCS8 } from '../keys-seed.js';
|
|
123
130
|
import { tupleByRkid } from '../receive.js';
|
|
131
|
+
import { INVITE_RATE, blockedRel, rateCount, relOfRkid, withRate } from '../message/holder.js';
|
|
124
132
|
import { entryOf, findContact, liveRel, activeTuple } from '../trust/state.js';
|
|
125
133
|
import { groupStarRematch, withGroupHits } from '../trust/group-star.js';
|
|
126
134
|
const TT = 'https://real-life.org/trust-tasks/';
|
|
127
|
-
/** This library's experimental
|
|
128
|
-
export const PRELUDE_REQUEST_TYPE = TT + 'x-membership-prelude-request/0.1';
|
|
129
|
-
export const PRELUDE_REPLY_TYPE = TT + 'x-membership-prelude-reply/0.1';
|
|
135
|
+
/** This library's experimental form of the welcome (receive.ts SIGNATURE_CLASS). */
|
|
130
136
|
export const WELCOME_TYPE = TT + 'x-membership-welcome/0.1';
|
|
131
|
-
export { INVITE_TYPE, ACCEPT_TYPE, EVIDENCE_TYPE };
|
|
137
|
+
export { INVITE_TYPE, ACCEPT_TYPE, EVIDENCE_TYPE, PRELUDE_REQUEST_TYPE, PRELUDE_REPLY_TYPE };
|
|
132
138
|
/** How long an invite is answerable (validUntil − validFrom), as the probe had it. */
|
|
133
139
|
export const INVITE_LIFE = 24 * 3_600_000;
|
|
134
140
|
/** The longest give-up horizon (Delivery [P1D, P90D]): what late answers may still need (RLTP-MT-3090). */
|
|
@@ -161,6 +167,12 @@ const ownName = (state) => {
|
|
|
161
167
|
return typeof n === 'string' && n.length ? [...n].slice(0, 200).join('') : undefined;
|
|
162
168
|
};
|
|
163
169
|
const groupOfLabel = (state, label) => label === undefined ? undefined : Object.values(state.groups).find((g) => g.memberLabel === label);
|
|
170
|
+
/** RLTP-MT-3282: the inviter admits on its own invitation without a
|
|
171
|
+
* further human act only before invite.validUntil + membership-skew +
|
|
172
|
+
* the longest give-up horizon, by its own clock; later, the admission
|
|
173
|
+
* would wait for a human act, which this library does not offer — the
|
|
174
|
+
* consent stays as evidence until its retention lets it go. */
|
|
175
|
+
const admissionOpen = (invite, when) => when < untilOf(invite) + MEMBERSHIP_SKEW + HORIZON_MAX;
|
|
164
176
|
/** The policy (see the header): admitted when vouchThreshold distinct
|
|
165
177
|
* members stand behind it, the admitting member one of them. */
|
|
166
178
|
function policyMet(g, ev) {
|
|
@@ -255,16 +267,18 @@ const inviteDef = {
|
|
|
255
267
|
const ch = channelTo(ctx.snapshot, input.contactId);
|
|
256
268
|
if (!ch)
|
|
257
269
|
return no('no-own-channel');
|
|
270
|
+
// no invitation toward a blocked relationship (Message Tasks 6410)
|
|
271
|
+
if (blockedRel(ctx.snapshot, ch.rel.id))
|
|
272
|
+
return no('blocked', 'RLTP-MSG-6410');
|
|
258
273
|
const own = await ctx.keys.anchorOf(ch.tuple.ownPairLabel);
|
|
259
274
|
const at = iso(ctx.when);
|
|
260
275
|
const body = {
|
|
261
276
|
id: ctx.entropy.id('doc.id'), type: PRELUDE_REQUEST_TYPE, issuer: own.anchor, recipient: ch.tuple.counterpartAnchor,
|
|
262
277
|
threadId: ctx.entropy.id('thread.id'), issuedAt: at, payload: { genesisDigest: g.id, group: g.did ?? '', name: g.name, inviter: g.memberAnchor, binding: {} },
|
|
263
278
|
};
|
|
264
|
-
// the inviter
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
}, at);
|
|
279
|
+
// the inviter binding (RLTP-MT-3905): signed under the member anchor
|
|
280
|
+
// over (inviter, genesis digest, thread, from, to)
|
|
281
|
+
body.payload.binding = await signDoc(ctx, g.memberLabel, g.memberAnchor, inviterBindingBody({ inviter: g.memberAnchor, genesisDigest: g.id, threadId: body.threadId, from: own.anchor, to: ch.tuple.counterpartAnchor }), at);
|
|
268
282
|
const doc = await signDoc(ctx, ch.tuple.ownPairLabel, own.anchor, body, at);
|
|
269
283
|
return sealed(ctx, doc, { contactId: ch.contact.id, rkid: ch.tuple.rkid, keyAgreement: ch.tuple.counterpartKeyAgreement });
|
|
270
284
|
},
|
|
@@ -286,22 +300,20 @@ function arrival(state, rkid) {
|
|
|
286
300
|
const preludeKey = (group, inviter) => `${group} ${inviter}`;
|
|
287
301
|
const preludeRequestTask = {
|
|
288
302
|
proof: 'required',
|
|
303
|
+
// stage 7: the payload schema (RLTP-MT-3905)
|
|
289
304
|
decode(p) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
x.binding === null || typeof x.binding !== 'object')
|
|
294
|
-
return refuse('malformed');
|
|
295
|
-
return memberLabelOf(x.genesisDigest) === null ? refuse('validation-failed') : x;
|
|
305
|
+
if (!membershipSchemaOk('payload-membership-prelude-request.schema.json', p))
|
|
306
|
+
return refuse('malformed', 'RLTP-MT-3905');
|
|
307
|
+
return memberLabelOf(p.genesisDigest) === null ? refuse('validation-failed') : p;
|
|
296
308
|
},
|
|
297
309
|
async prepare(ctx, doc, arr) {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
return refuse('validation-failed', 'RLTP-MT-
|
|
310
|
+
// the document proof under its issuer, the binding's fields (genesis
|
|
311
|
+
// digests by their decoded bytes) and its proof under the inviter
|
|
312
|
+
// (RLTP-MT-3905, 3910); the issuer and recipient are the arrival
|
|
313
|
+
// tuple's pair anchors by the core's tuple binding (receive.ts)
|
|
314
|
+
const bad = await preludeRequestProblem(doc, ctx.crypto.subtle);
|
|
315
|
+
if (bad)
|
|
316
|
+
return refuse('validation-failed', bad === 'documentProof' ? 'RLTP-MT-3900' : 'RLTP-MT-3905');
|
|
305
317
|
const a = arrival(ctx.snapshot, arr.rkid);
|
|
306
318
|
const label = memberLabelOf(doc.payload.genesisDigest);
|
|
307
319
|
const member = await ctx.keys.anchorOf(label);
|
|
@@ -318,14 +330,17 @@ const preludeRequestTask = {
|
|
|
318
330
|
apply(state, prepared, doc, arr) {
|
|
319
331
|
const p = prepared.value;
|
|
320
332
|
const a = arrival(state, arr.rkid);
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
// (
|
|
324
|
-
//
|
|
333
|
+
// the binding per relationship (RLTP-MT-3925–3935): a request under a
|
|
334
|
+
// held key from a tuple of the bound tuple's relationship replaces the
|
|
335
|
+
// tuple (3930); from another relationship it is refused while the bound
|
|
336
|
+
// one is current, whatever entry either feeds (3935), and replaces the
|
|
337
|
+
// binding once that one has ended. The relationships are read through
|
|
338
|
+
// the tuples now (3940): a merge, a split or a chaining moves them
|
|
325
339
|
const bound = state.joining.preludes[preludeKey(toU(doc.payload.genesisDigest), doc.payload.inviter)];
|
|
326
|
-
const was = bound ?
|
|
327
|
-
|
|
328
|
-
|
|
340
|
+
const was = bound ? tupleByRkid(state, bound.rkid) : null;
|
|
341
|
+
const here = tupleByRkid(state, arr.rkid);
|
|
342
|
+
if (was && liveRel(was.rel) && was.rel.id !== here.rel.id)
|
|
343
|
+
return refuse('validation-failed', 'RLTP-MT-3935');
|
|
329
344
|
const keys = Object.hasOwn(state.addresses.keys, p.rkid) ? state.addresses.keys : { ...state.addresses.keys, [p.rkid]: { label: p.label, lastShown: arr.when } };
|
|
330
345
|
return {
|
|
331
346
|
state: {
|
|
@@ -341,24 +356,23 @@ const preludeRequestTask = {
|
|
|
341
356
|
};
|
|
342
357
|
const preludeReplyTask = {
|
|
343
358
|
proof: 'required',
|
|
359
|
+
// stage 7: the payload schema (RLTP-MT-3960)
|
|
344
360
|
decode(p) {
|
|
345
|
-
|
|
346
|
-
if (x === null || typeof x !== 'object' || Object.keys(x).length !== 2 || typeof x.genesisDigest !== 'string' || x.card === null || typeof x.card !== 'object')
|
|
347
|
-
return refuse('malformed');
|
|
348
|
-
return x;
|
|
361
|
+
return membershipSchemaOk('payload-membership-prelude-reply.schema.json', p) ? p : refuse('malformed', 'RLTP-MT-3960');
|
|
349
362
|
},
|
|
350
363
|
async prepare(ctx, doc, arr) {
|
|
351
364
|
const s = ctx.snapshot;
|
|
352
365
|
if (!(await diVerify(doc, doc.issuer, ctx.crypto.subtle)))
|
|
353
|
-
return refuse('validation-failed', 'RLTP-MT-
|
|
354
|
-
// the answer to exactly the prelude this holder sent
|
|
366
|
+
return refuse('validation-failed', 'RLTP-MT-3960');
|
|
367
|
+
// the answer to exactly the prelude this holder sent and still holds:
|
|
368
|
+
// its group (genesis digests by their decoded bytes), thread and tuple (3965)
|
|
355
369
|
const g = s.groups[toU(doc.payload.genesisDigest) ?? ''];
|
|
356
370
|
const asked = g?.preludes[doc.threadId];
|
|
357
371
|
if (!g || g.memberAnchor === undefined || !asked || asked.rkid !== arr.rkid)
|
|
358
|
-
return refuse('validation-failed', 'RLTP-MT-
|
|
372
|
+
return refuse('validation-failed', 'RLTP-MT-3965');
|
|
359
373
|
const card = doc.payload.card;
|
|
360
374
|
if (!(await enclosedCardOk(card, card.anchor, ctx.crypto.subtle)))
|
|
361
|
-
return refuse('validation-failed', 'RLTP-MT-
|
|
375
|
+
return refuse('validation-failed', 'RLTP-MT-3960');
|
|
362
376
|
const a = arrival(s, arr.rkid);
|
|
363
377
|
const S = ctx.crypto.subtle;
|
|
364
378
|
const at = iso(ctx.when);
|
|
@@ -381,6 +395,12 @@ const preludeReplyTask = {
|
|
|
381
395
|
const g = state.groups[toU(doc.payload.genesisDigest)];
|
|
382
396
|
const preludes = { ...g.preludes };
|
|
383
397
|
delete preludes[doc.threadId];
|
|
398
|
+
// under a block of the relationship the reply arrives on: the waiting
|
|
399
|
+
// prelude closes, acknowledged, and no invitation is issued or sent —
|
|
400
|
+
// one transaction with the block check (RLTP-MT-3975, 3980)
|
|
401
|
+
const rel = relOfRkid(state, arr.rkid);
|
|
402
|
+
if (rel !== null && blockedRel(state, rel))
|
|
403
|
+
return { state: { ...state, groups: { ...state.groups, [g.id]: { ...g, preludes } } } };
|
|
384
404
|
// a member is not invited again; every invitation is its own thread,
|
|
385
405
|
// kept beside earlier ones (an accept signed in time may still travel)
|
|
386
406
|
if (Object.hasOwn(g.roster, p.invitee))
|
|
@@ -396,44 +416,91 @@ const preludeReplyTask = {
|
|
|
396
416
|
};
|
|
397
417
|
// ── the invite at the invitee (Membership 3.1) ─────────────────────────
|
|
398
418
|
const invitePromptId = (credDigest) => 'invite:' + credDigest;
|
|
419
|
+
/** The invitation's attribution (RLTP-MT-3100): the relationship of the
|
|
420
|
+
* prelude binding held for its genesis digest and issuer, read through
|
|
421
|
+
* the binding's tuple now (3940), or none. */
|
|
422
|
+
function attribution(state, inv) {
|
|
423
|
+
const pre = state.joining.preludes[preludeKey(toU(inv.credentialSubject.genesisDigest) ?? '', inv.issuer)];
|
|
424
|
+
const relId = pre ? relOfRkid(state, pre.rkid) : null;
|
|
425
|
+
return pre && relId !== null ? { pre, relId } : null;
|
|
426
|
+
}
|
|
427
|
+
/** Whether the holder holds the credential digest in any record (3105):
|
|
428
|
+
* surfaced, declined, or a received invitation. */
|
|
429
|
+
const knownInvite = (state, credDigest) => state.joining.surfaced.includes(credDigest) || state.joining.declined.includes(credDigest) || Object.hasOwn(state.joining.invites, credDigest);
|
|
430
|
+
const sortedWith = (xs, x) => (xs.includes(x) ? [...xs] : [...xs, x].sort());
|
|
399
431
|
const inviteTask = {
|
|
400
432
|
proof: 'absent',
|
|
401
433
|
decode: (p) => (membershipSchemaOk('payload-membership-invite.schema.json', p) ? p : refuse('malformed')),
|
|
402
434
|
// the prelude check (3030): the invite reaches the key of the own
|
|
403
|
-
// derivation from its genesis digest
|
|
435
|
+
// derivation from its genesis digest. The ack is sealed to the inviter's
|
|
436
|
+
// card and filed under the contact of the binding's relationship, or
|
|
437
|
+
// under the member where no binding attributes it (3100)
|
|
404
438
|
channel(state, doc, rkid) {
|
|
405
439
|
const inv = doc.payload.invite;
|
|
406
440
|
const label = memberLabelOf(inv.credentialSubject.genesisDigest);
|
|
407
441
|
if (label === null || state.addresses.keys[rkid]?.label !== label)
|
|
408
442
|
return refuse('validation-failed', 'RLTP-MT-3030');
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
return { keyAgreement: inv.credentialSubject.card.keyAgreement, contactId: pre.contactId, via: pre.rkid };
|
|
443
|
+
const at = attribution(state, inv);
|
|
444
|
+
return at
|
|
445
|
+
? { keyAgreement: inv.credentialSubject.card.keyAgreement, contactId: at.pre.contactId, via: at.pre.rkid }
|
|
446
|
+
: { keyAgreement: inv.credentialSubject.card.keyAgreement, contactId: 'member:' + inv.issuer };
|
|
414
447
|
},
|
|
415
448
|
async prepare(ctx, doc) {
|
|
416
449
|
const bad = await inviteProblem(doc, ctx.crypto.subtle);
|
|
417
450
|
if (bad)
|
|
418
451
|
return refuse('validation-failed', bad);
|
|
419
|
-
|
|
452
|
+
const credDigest = await digestDoc(doc.payload.invite, ctx.crypto.subtle);
|
|
453
|
+
// the rate (3105, Message Tasks 4115): a digest new to the holder, on a
|
|
454
|
+
// relationship that accepted 20 new ones in the window, counted before
|
|
455
|
+
// the block, waits at its carrier: failed(rate-limited) at stage 8,
|
|
456
|
+
// nothing committed (Delivery RLTP-DEL-6270); a known digest never
|
|
457
|
+
// counts and is never refused by it
|
|
458
|
+
const s = ctx.snapshot;
|
|
459
|
+
const at = attribution(s, doc.payload.invite);
|
|
460
|
+
if (at && !knownInvite(s, credDigest) && rateCount(s, at.relId, 'invites', ctx.when) >= INVITE_RATE)
|
|
461
|
+
return refuse('rate-limited', 'RLTP-MT-3105');
|
|
462
|
+
return { credDigest };
|
|
420
463
|
},
|
|
421
|
-
// durable buffering and
|
|
464
|
+
// the defined effect (3095): durable buffering and the surfacing
|
|
465
|
+
// decision, in one transaction under the credential digest's key (3110;
|
|
466
|
+
// the engine serializes the whole state, receive.ts): exactly one of
|
|
467
|
+
// decline, surface, or no new effect
|
|
422
468
|
apply(state, prepared, doc, arr) {
|
|
423
469
|
const inv = doc.payload.invite;
|
|
424
470
|
const gd = toU(inv.credentialSubject.genesisDigest);
|
|
425
471
|
const { credDigest } = prepared.value;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
472
|
+
const at = attribution(state, inv);
|
|
473
|
+
// the rate counts the new digest before the block is applied (3105, Message Tasks 4130)
|
|
474
|
+
let next = at && !knownInvite(state, credDigest) ? withRate(state, at.relId, 'invites', arr.when) : state;
|
|
475
|
+
// decline under a block (3115, Message Tasks 6.4): the register holds
|
|
476
|
+
// the digest, an open prompt of it closes as declined, silently
|
|
477
|
+
if (at && blockedRel(state, at.relId)) {
|
|
478
|
+
const promptId = invitePromptId(credDigest);
|
|
479
|
+
const prompts = { ...next.prompts };
|
|
480
|
+
delete prompts[promptId];
|
|
481
|
+
const held = next.joining.invites[credDigest];
|
|
482
|
+
const invites = held && held.status === 'open' ? { ...next.joining.invites, [credDigest]: { ...held, status: 'declined' } } : next.joining.invites;
|
|
483
|
+
return { state: { ...next, prompts, joining: { ...next.joining, invites, declined: sortedWith(next.joining.declined, credDigest) } } };
|
|
484
|
+
}
|
|
485
|
+
// no new effect: the register holds it (3120), or it surfaced before
|
|
486
|
+
// (3125). An invitation into a group the holder holds surfaces like any
|
|
487
|
+
// other: 3125 names no exception, and the person decides
|
|
488
|
+
if (next.joining.declined.includes(credDigest) || next.joining.surfaced.includes(credDigest) || Object.hasOwn(next.joining.invites, credDigest))
|
|
489
|
+
return { state: next };
|
|
490
|
+
// surface (3125): a prompt opened, the digest kept for the holder's life (3130)
|
|
429
491
|
const validUntil = Date.parse(inv.validUntil);
|
|
430
492
|
const r = {
|
|
431
|
-
credDigest, group: gd, name: typeof inv.credentialSubject.name === 'string' ? inv.credentialSubject.name : '',
|
|
493
|
+
credDigest, group: gd, name: typeof inv.credentialSubject.name === 'string' ? inv.credentialSubject.name : '',
|
|
494
|
+
...(at ? { contactId: at.pre.contactId, rkid: at.pre.rkid } : {}),
|
|
432
495
|
memberLabel: memberLabelOf(gd), invite: doc, promptId: invitePromptId(credDigest), validUntil, status: 'open', at: arr.when,
|
|
433
496
|
};
|
|
434
|
-
const prompt = { id: r.promptId, kind: 'group-invite', contactId: r.contactId, groupId: gd, openedAt: arr.when, expiresAt: validUntil + MEMBERSHIP_SKEW };
|
|
497
|
+
const prompt = { id: r.promptId, kind: 'group-invite', ...(r.contactId !== undefined ? { contactId: r.contactId } : {}), groupId: gd, openedAt: arr.when, expiresAt: validUntil + MEMBERSHIP_SKEW };
|
|
435
498
|
return {
|
|
436
|
-
state: {
|
|
499
|
+
state: {
|
|
500
|
+
...next,
|
|
501
|
+
joining: { ...next.joining, invites: { ...next.joining.invites, [credDigest]: r }, surfaced: sortedWith(next.joining.surfaced, credDigest) },
|
|
502
|
+
prompts: { ...next.prompts, [prompt.id]: prompt },
|
|
503
|
+
},
|
|
437
504
|
events: [{ type: 'prompt.opened', promptId: prompt.id, kind: 'group-invite' }],
|
|
438
505
|
};
|
|
439
506
|
},
|
|
@@ -458,7 +525,7 @@ const acceptDecision = {
|
|
|
458
525
|
const doc = await signDoc(ctx, r.memberLabel, me.anchor, body, at);
|
|
459
526
|
if (!withinBudget(doc))
|
|
460
527
|
return no('unknown-group', 'RLTP-MT-2220');
|
|
461
|
-
const out = await sealed(ctx, doc, { contactId: r.contactId, rkid: rkidOf(ctx.snapshot, r.memberLabel) ?? '', keyAgreement: inv.credentialSubject.card.keyAgreement, anchor: inv.issuer, via: r.rkid });
|
|
528
|
+
const out = await sealed(ctx, doc, { contactId: r.contactId ?? 'member:' + inv.issuer, rkid: rkidOf(ctx.snapshot, r.memberLabel) ?? '', keyAgreement: inv.credentialSubject.card.keyAgreement, anchor: inv.issuer, ...(r.rkid !== undefined ? { via: r.rkid } : {}) });
|
|
462
529
|
return { ...out, acceptDigest: await digestDoc(doc, ctx.crypto.subtle) };
|
|
463
530
|
},
|
|
464
531
|
apply(state, prompt, choice, when, prepared) {
|
|
@@ -554,7 +621,7 @@ const acceptTask = {
|
|
|
554
621
|
return refuse('validation-failed', bad);
|
|
555
622
|
const acceptDigest = await digestDoc(doc, ctx.crypto.subtle);
|
|
556
623
|
const ev = { invite: inv.invite, accept: doc, vouches: [], at: arr.when };
|
|
557
|
-
const docs = await admissionDocs(ctx, g, ev, acceptDigest, policyMet(g, ev));
|
|
624
|
+
const docs = await admissionDocs(ctx, g, ev, acceptDigest, policyMet(g, ev) && admissionOpen(inv.invite, arr.when));
|
|
558
625
|
return { group: g.id, acceptDigest, ...docs };
|
|
559
626
|
},
|
|
560
627
|
// the defined effect: the consent recorded (3280); the admission where
|
|
@@ -607,7 +674,7 @@ const evidenceTask = (schema) => ({
|
|
|
607
674
|
const merged = mergeVouches(held?.vouches ?? [], vouches);
|
|
608
675
|
const ev = { invite, accept, vouches: merged, at: arr.when };
|
|
609
676
|
// the inviter admits once the policy holds (the header)
|
|
610
|
-
const admit = invite.payload.invite.issuer === g.memberAnchor && !Object.hasOwn(g.roster, accept.payload.accept.subject) && policyMet(g, ev);
|
|
677
|
+
const admit = invite.payload.invite.issuer === g.memberAnchor && !Object.hasOwn(g.roster, accept.payload.accept.subject) && policyMet(g, ev) && admissionOpen(invite, arr.when);
|
|
611
678
|
const welcome = admit ? (await admissionDocs(ctx, g, ev, acceptDigest, true)).welcome : null;
|
|
612
679
|
return { group: g.id, acceptDigest, vouches, welcome };
|
|
613
680
|
},
|
|
@@ -717,7 +784,7 @@ const welcomeTask = {
|
|
|
717
784
|
const i = acceptedInvite(state, doc);
|
|
718
785
|
if (!i || state.addresses.keys[rkid]?.label !== i.memberLabel)
|
|
719
786
|
return refuse('validation-failed');
|
|
720
|
-
return { keyAgreement: i.invite.payload.invite.credentialSubject.card.keyAgreement, contactId: i.contactId, via: i.rkid };
|
|
787
|
+
return { keyAgreement: i.invite.payload.invite.credentialSubject.card.keyAgreement, contactId: i.contactId ?? 'member:' + doc.issuer, ...(i.rkid !== undefined ? { via: i.rkid } : {}) };
|
|
721
788
|
},
|
|
722
789
|
// the group stars already accepted are matched against the joined group (5520)
|
|
723
790
|
async prepare(ctx, doc) {
|
|
@@ -743,7 +810,7 @@ const welcomeTask = {
|
|
|
743
810
|
}
|
|
744
811
|
const roster = {};
|
|
745
812
|
for (const m of welcomeRoster(w.roster).values()) {
|
|
746
|
-
roster[m.anchor] = { anchor: m.anchor, op: m.op, keyAgreement: m.keyAgreement, ...(m.name !== undefined ? { name: m.name } : {}), addedAt: arr.when, ...(m.anchor === doc.issuer ? { contactId: i.contactId, rkid: i.rkid } : {}) };
|
|
813
|
+
roster[m.anchor] = { anchor: m.anchor, op: m.op, keyAgreement: m.keyAgreement, ...(m.name !== undefined ? { name: m.name } : {}), addedAt: arr.when, ...(m.anchor === doc.issuer && i.contactId !== undefined && i.rkid !== undefined ? { contactId: i.contactId, rkid: i.rkid } : {}) };
|
|
747
814
|
}
|
|
748
815
|
const g = {
|
|
749
816
|
id: w.genesisDigest, name: w.name, did: w.group, genesis: w.genesis, memberLabel: i.memberLabel, rules: { vouchThreshold: w.vouchThreshold },
|
|
@@ -761,7 +828,7 @@ const welcomeTask = {
|
|
|
761
828
|
// ── keys and retention ─────────────────────────────────────────────────
|
|
762
829
|
/** The member keys still needed: held groups' and those on the way in
|
|
763
830
|
* (an answered prelude, an invitation open or accepted). */
|
|
764
|
-
function memberKeys(state) {
|
|
831
|
+
function memberKeys(state, when) {
|
|
765
832
|
const labels = new Set(Object.values(state.groups).map((g) => g.memberLabel));
|
|
766
833
|
for (const p of Object.values(state.joining.preludes))
|
|
767
834
|
labels.add(p.memberLabel);
|
|
@@ -775,8 +842,11 @@ function memberKeys(state) {
|
|
|
775
842
|
for (const g of Object.values(state.groups))
|
|
776
843
|
for (const p of Object.values(g.preludes))
|
|
777
844
|
out.push(p.rkid);
|
|
845
|
+
// an answered prelude's tuple, while its answer and the acks may still
|
|
846
|
+
// travel; the binding itself lives longer (3945) and needs no key
|
|
778
847
|
for (const p of Object.values(state.joining.preludes))
|
|
779
|
-
|
|
848
|
+
if (p.at + INVITE_LIFE + MEMBERSHIP_SKEW + HORIZON_MAX >= when)
|
|
849
|
+
out.push(p.rkid);
|
|
780
850
|
return out;
|
|
781
851
|
}
|
|
782
852
|
const untilOf = (invite) => Date.parse(invite.payload.invite.validUntil);
|
|
@@ -797,12 +867,18 @@ function retainMembership(state, when) {
|
|
|
797
867
|
// the accept → admission → welcome chain: two deliveries, each within a give-up horizon
|
|
798
868
|
const late = (i) => i.validUntil + MEMBERSHIP_SKEW + 2 * HORIZON_MAX < when;
|
|
799
869
|
const invites = Object.fromEntries(Object.entries(state.joining.invites).filter(([, i]) => !(i.status === 'declined' || (i.status === 'open' && i.validUntil + MEMBERSHIP_SKEW < when) || ((i.status === 'accepted' || i.status === 'joined') && late(i)))));
|
|
800
|
-
//
|
|
801
|
-
|
|
802
|
-
|
|
870
|
+
// a binding lives while the relationship of its tuple is current or an
|
|
871
|
+
// invitation attributed to it is open or accepted (RLTP-MT-3945); tick
|
|
872
|
+
// deletes it in its own serialized commit, deciding on the state it reads
|
|
873
|
+
// there (3947)
|
|
874
|
+
// an invitation rests on the binding of its key (genesis digest, inviter):
|
|
875
|
+
// a renewal may have moved the binding to a newer tuple since (3930)
|
|
876
|
+
const rests = (p) => Object.values(invites).some((i) => i.group === p.group && i.invite.payload.invite.issuer === p.inviter && (i.status === 'open' || i.status === 'accepted'));
|
|
877
|
+
const current = (p) => { const t = tupleByRkid(state, p.rkid); return t !== null && liveRel(t.rel); };
|
|
878
|
+
const preludes = Object.fromEntries(Object.entries(state.joining.preludes).filter(([, p]) => current(p) || rests(p)));
|
|
803
879
|
if (Object.keys(invites).length !== Object.keys(state.joining.invites).length || Object.keys(preludes).length !== Object.keys(state.joining.preludes).length)
|
|
804
880
|
changed = true;
|
|
805
|
-
return changed ? { ...state, groups, joining: { preludes, invites } } : state;
|
|
881
|
+
return changed ? { ...state, groups, joining: { ...state.joining, preludes, invites } } : state;
|
|
806
882
|
}
|
|
807
883
|
/** When retainMembership lets something go (the timer contract, engine.ts). */
|
|
808
884
|
function membershipDeadlines(state) {
|
|
@@ -821,6 +897,7 @@ function membershipDeadlines(state) {
|
|
|
821
897
|
if (i.status === 'accepted' || i.status === 'joined')
|
|
822
898
|
out.push(i.validUntil + MEMBERSHIP_SKEW + 2 * HORIZON_MAX + 1);
|
|
823
899
|
}
|
|
900
|
+
// an answered prelude's tuple key leaves keepKeys then (memberKeys)
|
|
824
901
|
for (const p of Object.values(state.joining.preludes))
|
|
825
902
|
out.push(p.at + INVITE_LIFE + MEMBERSHIP_SKEW + HORIZON_MAX + 1);
|
|
826
903
|
return out;
|