@serfab/quereus-plugin-sereus 0.11.0 → 0.12.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.
@@ -1,57 +1,58 @@
1
- import type { Database, VTablePluginInfo, FunctionPluginInfo, CollationPluginInfo } from '@quereus/quereus';
2
- import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
3
- import { createLibp2pNode } from '@optimystic/db-p2p/rn';
4
- import { webSockets } from '@libp2p/websockets';
5
- import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
6
- import { IndexedDBRawStorage, openOptimysticWebDb } from '@optimystic/db-p2p-storage-web';
7
- import type { Libp2p } from '@libp2p/interface';
8
- import type { StrandConnectionOptions, SereusPluginResult } from './types.js';
9
- import { composeStrand, applyRegistrations } from './compose-strand.js';
10
-
11
- /** The subset of the crypto plugin result we register inline in the browser. */
12
- interface CryptoPluginResult {
13
- vtables: VTablePluginInfo[];
14
- functions: FunctionPluginInfo[];
15
- collations: CollationPluginInfo[];
16
- }
17
-
18
- /**
19
- * Connect a Quereus Database to a Sereus strand from a browser/worker.
20
- *
21
- * Thin adapter over the shared {@link composeStrand} composition, supplying the
22
- * browser platform seams:
23
- * - crypto registered inline (no runtime `@quereus/quereus` import, so the
24
- * bundle does not duplicate the host's instance),
25
- * - storage defaulting to `IndexedDBRawStorage` keyed by `sereus-strand-<id>`,
26
- * - a node created via the TCP-free `@optimystic/db-p2p/rn` entry with explicit
27
- * `webSockets()` + `circuitRelayTransport()` transports.
28
- */
29
- export async function connectToStrandBrowser(
30
- db: Database,
31
- options: StrandConnectionOptions,
32
- ): Promise<SereusPluginResult> {
33
- return composeStrand(db, options, {
34
- registerCrypto(database) {
35
- applyRegistrations(database, cryptoPlugin(database, {}) as unknown as CryptoPluginResult);
36
- },
37
- async resolveStorage({ strandId, resolvedTransactor, requestedStorage }) {
38
- if (requestedStorage) return requestedStorage;
39
- // Browsers always default to IndexedDB so a reload survives; the
40
- // unit-test fake transactor needs no storage.
41
- if (resolvedTransactor === 'test') return undefined;
42
- const dbHandle = await openOptimysticWebDb(`sereus-strand-${strandId}`);
43
- return new IndexedDBRawStorage(dbHandle);
44
- },
45
- async createNode({ networkName, bootstrapNodes, fretProfile, clusterSize, storage }) {
46
- return createLibp2pNode({
47
- transports: [webSockets(), circuitRelayTransport()],
48
- listenAddrs: [],
49
- bootstrapNodes,
50
- networkName,
51
- fretProfile,
52
- clusterSize,
53
- ...(storage && { storage }),
54
- }) as Promise<Libp2p>;
55
- },
56
- });
57
- }
1
+ import type { Database, VTablePluginInfo, FunctionPluginInfo, CollationPluginInfo } from '@quereus/quereus';
2
+ import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
3
+ import { createLibp2pNode } from '@optimystic/db-p2p/rn';
4
+ import { webSockets } from '@libp2p/websockets';
5
+ import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
6
+ import { IndexedDBRawStorage, openOptimysticWebDb } from '@optimystic/db-p2p-storage-web';
7
+ import type { Libp2p } from '@libp2p/interface';
8
+ import type { StrandConnectionOptions, SereusPluginResult } from './types.js';
9
+ import { composeStrand, applyRegistrations } from './compose-strand.js';
10
+
11
+ /** The subset of the crypto plugin result we register inline in the browser. */
12
+ interface CryptoPluginResult {
13
+ vtables: VTablePluginInfo[];
14
+ functions: FunctionPluginInfo[];
15
+ collations: CollationPluginInfo[];
16
+ }
17
+
18
+ /**
19
+ * Connect a Quereus Database to a Sereus strand from a browser/worker.
20
+ *
21
+ * Thin adapter over the shared {@link composeStrand} composition, supplying the
22
+ * browser platform seams:
23
+ * - crypto registered inline (no runtime `@quereus/quereus` import, so the
24
+ * bundle does not duplicate the host's instance),
25
+ * - storage defaulting to `IndexedDBRawStorage` keyed by `sereus-strand-<id>`,
26
+ * - a node created via the TCP-free `@optimystic/db-p2p/rn` entry with explicit
27
+ * `webSockets()` + `circuitRelayTransport()` transports.
28
+ */
29
+ export async function connectToStrandBrowser(
30
+ db: Database,
31
+ options: StrandConnectionOptions,
32
+ ): Promise<SereusPluginResult> {
33
+ return composeStrand(db, options, {
34
+ registerCrypto(database) {
35
+ applyRegistrations(database, cryptoPlugin(database, {}) as unknown as CryptoPluginResult);
36
+ },
37
+ async resolveStorage({ strandId, resolvedTransactor, requestedStorage }) {
38
+ if (requestedStorage) return requestedStorage;
39
+ // Browsers always default to IndexedDB so a reload survives; the
40
+ // unit-test fake transactor needs no storage.
41
+ if (resolvedTransactor === 'test') return undefined;
42
+ const dbHandle = await openOptimysticWebDb(`sereus-strand-${strandId}`);
43
+ return new IndexedDBRawStorage(dbHandle);
44
+ },
45
+ async createNode({ networkName, bootstrapNodes, fretProfile, clusterSize, clusterPolicy, storage }) {
46
+ return createLibp2pNode({
47
+ transports: [webSockets(), circuitRelayTransport()],
48
+ listenAddrs: [],
49
+ bootstrapNodes,
50
+ networkName,
51
+ fretProfile,
52
+ clusterSize,
53
+ clusterPolicy,
54
+ ...(storage && { storage }),
55
+ }) as Promise<Libp2p>;
56
+ },
57
+ });
58
+ }
package/src/connect.ts CHANGED
@@ -1,38 +1,39 @@
1
- import type { Database } from '@quereus/quereus';
2
- import { registerPlugin } from '@quereus/quereus';
3
- import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
4
- import type { Libp2p } from '@libp2p/interface';
5
- import type { StrandConnectionOptions, SereusPluginResult } from './types.js';
6
- import { composeStrand } from './compose-strand.js';
7
-
8
- /**
9
- * Connect a Quereus Database to a Sereus strand (Node).
10
- *
11
- * Thin adapter over the shared {@link composeStrand} composition, supplying the
12
- * Node platform seams: crypto via `@quereus/quereus`'s `registerPlugin`, and a
13
- * TCP libp2p node created (only when not injected) via `@optimystic/db-p2p`.
14
- */
15
- export async function connectToStrand(
16
- db: Database,
17
- options: StrandConnectionOptions,
18
- ): Promise<SereusPluginResult> {
19
- return composeStrand(db, options, {
20
- async registerCrypto(database) {
21
- await registerPlugin(database, cryptoPlugin);
22
- },
23
- async createNode({ networkName, bootstrapNodes, fretProfile, port, clusterSize, storage }) {
24
- // Dynamically import to keep the module cross-platform friendly: this
25
- // pulls the Node-only `@optimystic/db-p2p` (TCP) entry, and is only
26
- // reached when actually creating a node (never for injected nodes).
27
- const { createLibp2pNode } = await import('@optimystic/db-p2p');
28
- return createLibp2pNode({
29
- port,
30
- bootstrapNodes,
31
- networkName,
32
- fretProfile,
33
- clusterSize,
34
- ...(storage && { storage }),
35
- }) as Promise<Libp2p>;
36
- },
37
- });
38
- }
1
+ import type { Database } from '@quereus/quereus';
2
+ import { registerPlugin } from '@quereus/quereus';
3
+ import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
4
+ import type { Libp2p } from '@libp2p/interface';
5
+ import type { StrandConnectionOptions, SereusPluginResult } from './types.js';
6
+ import { composeStrand } from './compose-strand.js';
7
+
8
+ /**
9
+ * Connect a Quereus Database to a Sereus strand (Node).
10
+ *
11
+ * Thin adapter over the shared {@link composeStrand} composition, supplying the
12
+ * Node platform seams: crypto via `@quereus/quereus`'s `registerPlugin`, and a
13
+ * TCP libp2p node created (only when not injected) via `@optimystic/db-p2p`.
14
+ */
15
+ export async function connectToStrand(
16
+ db: Database,
17
+ options: StrandConnectionOptions,
18
+ ): Promise<SereusPluginResult> {
19
+ return composeStrand(db, options, {
20
+ async registerCrypto(database) {
21
+ await registerPlugin(database, cryptoPlugin);
22
+ },
23
+ async createNode({ networkName, bootstrapNodes, fretProfile, port, clusterSize, clusterPolicy, storage }) {
24
+ // Dynamically import to keep the module cross-platform friendly: this
25
+ // pulls the Node-only `@optimystic/db-p2p` (TCP) entry, and is only
26
+ // reached when actually creating a node (never for injected nodes).
27
+ const { createLibp2pNode } = await import('@optimystic/db-p2p');
28
+ return createLibp2pNode({
29
+ port,
30
+ bootstrapNodes,
31
+ networkName,
32
+ fretProfile,
33
+ clusterSize,
34
+ clusterPolicy,
35
+ ...(storage && { storage }),
36
+ }) as Promise<Libp2p>;
37
+ },
38
+ });
39
+ }
@@ -108,6 +108,18 @@ export const STRAND_SCHEMA = ` table Header (
108
108
  -- rows — no caller-supplied context decides admission (unlike NotExpired's Now).
109
109
  constraint NotCancelled check on insert (
110
110
  not exists (select 1 from CancelledInvite C where C.InviteKey = new.InviteKey)
111
+ ),
112
+ -- A SEALED strand (a closed strand whose Manager table its sole manager has
113
+ -- deliberately emptied — see the Manager table comment) admits nobody, and that
114
+ -- includes the holder of an unspent invitation issued BEFORE the seal.
115
+ -- cancelInvite itself needs a manager, so after sealing nobody could kill such
116
+ -- an invitation — this gate is what kills it. The subquery reads the LIVE
117
+ -- Manager table and defers to commit, so it sees the post-image: at least one
118
+ -- manager must survive the transaction, and sealing plus consuming in the SAME
119
+ -- transaction is rejected too. Blocking this insert blocks the whole join, for
120
+ -- exactly the reason NotCancelled gives above.
121
+ constraint NotSealed check on insert (
122
+ exists (select 1 from Manager)
111
123
  )
112
124
  ) with context (InviteSignature text null, Now datetime null);
113
125
 
@@ -136,7 +148,8 @@ export const STRAND_SCHEMA = ` table Header (
136
148
  -- "forever".
137
149
  -- NOTE: a cancellation must PROPAGATE before the holder redeems — a holder consuming
138
150
  -- on a node that has not yet seen this row still gets in. Same convergence class as
139
- -- the NotRevoked / MinOneMember / MinOneManager local-visibility notes; not solved here.
151
+ -- the NotRevoked / MinOneMember local-visibility notes and the Manager table's
152
+ -- seal-propagation note; not solved here.
140
153
  table CancelledInvite (
141
154
  InviteKey text primary key,
142
155
  constraint OnlyClosed check (
@@ -382,7 +395,7 @@ export const STRAND_SCHEMA = ` table Header (
382
395
  -- NOTE: a per-transaction check against locally visible rows. Two partitioned nodes
383
396
  -- — one promoting X, one removing X's Member and Manager rows — can each pass
384
397
  -- locally and converge to a Manager row with no Member row. Same convergence class
385
- -- as the MinOneMember / MinOneManager / NotRevoked notes; not solved here.
398
+ -- as the MinOneMember / NotRevoked / seal-propagation notes; not solved here.
386
399
  constraint MemberExists check on insert (
387
400
  exists (select 1 from Member M where M.Key = new.MemberKey)
388
401
  ),
@@ -390,17 +403,21 @@ export const STRAND_SCHEMA = ` table Header (
390
403
  -- (which proves only that old.MemberKey consented) double as a hand-off: the
391
404
  -- former-manager branch would accept re-pointing the row at any new key.
392
405
  constraint NoUpdate check on update (false),
393
- -- A closed strand must never lose its last manager. Every admit path (Invite,
394
- -- addMemberByManager, addManager) requires an existing Manager row, so an
395
- -- admin-less strand can never admit anyone again. Deferred (subquery), so the
396
- -- count it sees is the POST-delete count.
397
- -- NOTE: this is a per-transaction check against locally visible rows. Two nodes
398
- -- that concurrently remove different managers can each see a surviving one and
399
- -- still converge to zero; if partitioned rotation ever becomes a real workflow,
400
- -- the floor needs a cross-node guard, not a local count.
401
- constraint MinOneManager check on delete (
402
- (select count(1) from Manager) >= 1
403
- ),
406
+ -- A closed strand loses its LAST manager only by SEALING — a distinct,
407
+ -- self-signed act (the 'seal' branch of Authorized below), never an ordinary
408
+ -- resignation and never an admin removal. There is deliberately NO
409
+ -- min-one-manager floor: a strand with no managers is frozen, not bricked.
410
+ -- Every admission path (Invite, addMemberByManager, addManager) requires a
411
+ -- Manager row, so a sealed strand's membership can never grow again — and that
412
+ -- freeze is the privacy guarantee its remaining members are buying: nobody
413
+ -- holds the power to admit a party who would then read the strand's whole
414
+ -- history. Sealing is irreversible: the founding branch of Authorized closes
415
+ -- for good once any manager stamp has been retired into Revocation.
416
+ -- NOTE: a seal must PROPAGATE before it binds a given node — a node that has
417
+ -- not yet converged on the delete still shows the manager row, so the
418
+ -- ex-manager's OWN key could still admit there until the delete arrives. Only
419
+ -- that one key; no stranger gains anything. Same convergence class as the
420
+ -- MinOneMember / NotRevoked local-visibility notes; not solved here.
404
421
  constraint Authorized check on insert, delete (
405
422
  -- Every signed branch below is a domain/action-tagged digest bound to the
406
423
  -- row's StampId — the same single-use idiom as Member.Authorized: an
@@ -415,23 +432,47 @@ export const STRAND_SCHEMA = ` table Header (
415
432
  -- is belt-and-braces since MemberExists above enforces it on EVERY insert —
416
433
  -- kept so this branch's own claim ("this manager IS that member") reads in
417
434
  -- place rather than by cross-reference.
435
+ -- The Revocation gate closes this branch FOREVER once any manager seat has
436
+ -- been retired: founding happens exactly once per strand (Header, then
437
+ -- Member, then Manager, with no Revocation rows in existence), so on a
438
+ -- SEALED strand a lone surviving member cannot re-seat itself as a
439
+ -- generation-0 founder and start admitting. Accepted, deliberate: between
440
+ -- its own founding Member and Manager inserts a founder could, with its own
441
+ -- key, file a junk Revocation('Manager', ...) tombstone (RowIsGone permits
442
+ -- stamps naming no real row) and permanently block its own founding — pure
443
+ -- self-harm needing the founder's private key; no other party can reach
444
+ -- that window (there are no other committed members yet).
418
445
  (old.MemberKey is null
419
446
  and new.Generation = 0
420
447
  and (select count(1) from Manager) <= 1
421
448
  and (select count(1) from Member) <= 1
422
- and exists (select 1 from Member M where M.Key = new.MemberKey))
449
+ and exists (select 1 from Member M where M.Key = new.MemberKey)
450
+ and not exists (select 1 from Revocation R where R.TableName = 'Manager'))
423
451
 
424
- -- or authorized by this former manager (self-resignation), over the
425
- -- resign-tagged digest bound to its own row's stamp. The 'resign' tag
426
- -- (vs the admin branch's 'remove') is belt-and-braces — the two
427
- -- branches verify against different keys; the anti-replay fix is the
428
- -- stamp: once this manager is re-promoted under a fresh stamp, a
429
- -- captured resignation no longer removes it.
452
+ -- or removed by the departing manager ITSELF, over one of two
453
+ -- self-signed, action-tagged digests bound to its own row's stamp. The
454
+ -- POST-image manager count is what distinguishes the two: 'resign' is
455
+ -- an ordinary departure that leaves at least one manager standing,
456
+ -- while 'seal' is the delete that empties the table and permanently
457
+ -- freezes admission (see the table comment above). The tag is what
458
+ -- makes each a signed statement of THAT intent — a captured
459
+ -- resignation approval can never seal, nor a seal approval merely
460
+ -- resign — and the stamp is still what makes each single-use: once
461
+ -- this manager is re-promoted under a fresh stamp, neither captured
462
+ -- approval removes the new row. Both counts are subqueries, so the
463
+ -- whole check defers to commit and the counts are the post-image.
430
464
  or (
431
465
  old.MemberKey is not null
432
466
  and old.MemberKey = context.ManagerKey
467
+ and (select count(1) from Manager) >= 1
433
468
  and verify(digest('Strand.Manager', 'resign', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')
434
469
  )
470
+ or (
471
+ old.MemberKey is not null
472
+ and old.MemberKey = context.ManagerKey
473
+ and (select count(1) from Manager) = 0
474
+ and verify(digest('Strand.Manager', 'seal', old.MemberKey, old.StampId), context.Signature, old.MemberKey, 'ed25519')
475
+ )
435
476
 
436
477
  -- or a promotion signed by an EARLIER-generation manager, over the
437
478
  -- add-tagged digest binding (MemberKey, Generation, StampId) — so a
@@ -483,7 +524,7 @@ export const STRAND_SCHEMA = ` table Header (
483
524
  -- NOTE: the guarded tables' NotRevoked CHECK runs against locally visible rows, so
484
525
  -- a node that has not yet converged on a Revocation row can still accept a replayed
485
526
  -- add and the resurrected row coexists with the tombstone after merge — the same
486
- -- convergence class as the MinOneMember / MinOneManager local-count notes.
527
+ -- convergence class as the MinOneMember / Manager seal-propagation notes.
487
528
  -- NOTE: this table only grows — one row per membership deletion, plus any junk any
488
529
  -- member chooses to file, and Immutable means rows never go away. Fine at strand
489
530
  -- scale; if membership churn ever gets large it needs pruning bounded by something