@freqhole/haruspex 0.0.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/dist/flows/add-peer/machine.d.ts +36 -0
- package/dist/flows/add-peer/machine.d.ts.map +1 -0
- package/dist/flows/add-peer/machine.js +500 -0
- package/dist/flows/add-peer/machine.test.d.ts +2 -0
- package/dist/flows/add-peer/machine.test.d.ts.map +1 -0
- package/dist/flows/add-peer/machine.test.js +421 -0
- package/dist/flows/add-peer/runner.d.ts +16 -0
- package/dist/flows/add-peer/runner.d.ts.map +1 -0
- package/dist/flows/add-peer/runner.js +349 -0
- package/dist/flows/add-peer/runner.test.d.ts +2 -0
- package/dist/flows/add-peer/runner.test.d.ts.map +1 -0
- package/dist/flows/add-peer/runner.test.js +205 -0
- package/dist/flows/add-peer/types.d.ts +271 -0
- package/dist/flows/add-peer/types.d.ts.map +1 -0
- package/dist/flows/add-peer/types.js +10 -0
- package/dist/flows/index.d.ts +6 -0
- package/dist/flows/index.d.ts.map +1 -0
- package/dist/flows/index.js +6 -0
- package/dist/identity/idbStore.d.ts +51 -0
- package/dist/identity/idbStore.d.ts.map +1 -0
- package/dist/identity/idbStore.js +183 -0
- package/dist/identity/idbStore.test.d.ts +2 -0
- package/dist/identity/idbStore.test.d.ts.map +1 -0
- package/dist/identity/idbStore.test.js +103 -0
- package/dist/identity/index.d.ts +8 -0
- package/dist/identity/index.d.ts.map +1 -0
- package/dist/identity/index.js +3 -0
- package/dist/identity/resolve.d.ts +31 -0
- package/dist/identity/resolve.d.ts.map +1 -0
- package/dist/identity/resolve.js +37 -0
- package/dist/identity/resolve.test.d.ts +2 -0
- package/dist/identity/resolve.test.d.ts.map +1 -0
- package/dist/identity/resolve.test.js +105 -0
- package/dist/identity/types.d.ts +21 -0
- package/dist/identity/types.d.ts.map +1 -0
- package/dist/identity/types.js +1 -0
- package/dist/identity/webLocks.d.ts +22 -0
- package/dist/identity/webLocks.d.ts.map +1 -0
- package/dist/identity/webLocks.js +67 -0
- package/dist/identity/webLocks.test.d.ts +2 -0
- package/dist/identity/webLocks.test.d.ts.map +1 -0
- package/dist/identity/webLocks.test.js +125 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1 -0
- package/dist/index.test.d.ts +2 -0
- package/dist/index.test.d.ts.map +1 -0
- package/dist/index.test.js +7 -0
- package/dist/knock/index.d.ts +10 -0
- package/dist/knock/index.d.ts.map +1 -0
- package/dist/knock/index.js +5 -0
- package/dist/knock/pending-checker.d.ts +19 -0
- package/dist/knock/pending-checker.d.ts.map +1 -0
- package/dist/knock/pending-checker.js +27 -0
- package/dist/knock/pending-checker.test.d.ts +2 -0
- package/dist/knock/pending-checker.test.d.ts.map +1 -0
- package/dist/knock/pending-checker.test.js +93 -0
- package/dist/knock/requester.d.ts +26 -0
- package/dist/knock/requester.d.ts.map +1 -0
- package/dist/knock/requester.js +72 -0
- package/dist/knock/requester.test.d.ts +2 -0
- package/dist/knock/requester.test.d.ts.map +1 -0
- package/dist/knock/requester.test.js +108 -0
- package/dist/knock/responder.d.ts +13 -0
- package/dist/knock/responder.d.ts.map +1 -0
- package/dist/knock/responder.js +40 -0
- package/dist/knock/responder.test.d.ts +2 -0
- package/dist/knock/responder.test.d.ts.map +1 -0
- package/dist/knock/responder.test.js +89 -0
- package/dist/knock/store.d.ts +11 -0
- package/dist/knock/store.d.ts.map +1 -0
- package/dist/knock/store.js +211 -0
- package/dist/knock/store.test.d.ts +2 -0
- package/dist/knock/store.test.d.ts.map +1 -0
- package/dist/knock/store.test.js +195 -0
- package/dist/knock/types.d.ts +144 -0
- package/dist/knock/types.d.ts.map +1 -0
- package/dist/knock/types.js +26 -0
- package/dist/permissions/access.d.ts +54 -0
- package/dist/permissions/access.d.ts.map +1 -0
- package/dist/permissions/access.js +56 -0
- package/dist/permissions/access.test.d.ts +2 -0
- package/dist/permissions/access.test.d.ts.map +1 -0
- package/dist/permissions/access.test.js +72 -0
- package/dist/permissions/index.d.ts +5 -0
- package/dist/permissions/index.d.ts.map +1 -0
- package/dist/permissions/index.js +2 -0
- package/dist/permissions/role-table.d.ts +11 -0
- package/dist/permissions/role-table.d.ts.map +1 -0
- package/dist/permissions/role-table.js +16 -0
- package/dist/protocol/client.d.ts +67 -0
- package/dist/protocol/client.d.ts.map +1 -0
- package/dist/protocol/client.js +195 -0
- package/dist/protocol/client.test.d.ts +2 -0
- package/dist/protocol/client.test.d.ts.map +1 -0
- package/dist/protocol/client.test.js +282 -0
- package/dist/protocol/codec.d.ts +45 -0
- package/dist/protocol/codec.d.ts.map +1 -0
- package/dist/protocol/codec.js +111 -0
- package/dist/protocol/codec.test.d.ts +2 -0
- package/dist/protocol/codec.test.d.ts.map +1 -0
- package/dist/protocol/codec.test.js +93 -0
- package/dist/protocol/extensions.d.ts +33 -0
- package/dist/protocol/extensions.d.ts.map +1 -0
- package/dist/protocol/extensions.js +36 -0
- package/dist/protocol/extensions.test.d.ts +2 -0
- package/dist/protocol/extensions.test.d.ts.map +1 -0
- package/dist/protocol/extensions.test.js +65 -0
- package/dist/protocol/fixtures.test.d.ts +2 -0
- package/dist/protocol/fixtures.test.d.ts.map +1 -0
- package/dist/protocol/fixtures.test.js +51 -0
- package/dist/protocol/index.d.ts +9 -0
- package/dist/protocol/index.d.ts.map +1 -0
- package/dist/protocol/index.js +4 -0
- package/dist/protocol/messages.d.ts +480 -0
- package/dist/protocol/messages.d.ts.map +1 -0
- package/dist/protocol/messages.js +255 -0
- package/dist/protocol/messages.test.d.ts +2 -0
- package/dist/protocol/messages.test.d.ts.map +1 -0
- package/dist/protocol/messages.test.js +75 -0
- package/dist/share/codec.d.ts +58 -0
- package/dist/share/codec.d.ts.map +1 -0
- package/dist/share/codec.js +222 -0
- package/dist/share/codec.test.d.ts +2 -0
- package/dist/share/codec.test.d.ts.map +1 -0
- package/dist/share/codec.test.js +150 -0
- package/dist/share/detect.d.ts +24 -0
- package/dist/share/detect.d.ts.map +1 -0
- package/dist/share/detect.js +25 -0
- package/dist/share/detect.test.d.ts +2 -0
- package/dist/share/detect.test.d.ts.map +1 -0
- package/dist/share/detect.test.js +31 -0
- package/dist/share/index.d.ts +7 -0
- package/dist/share/index.d.ts.map +1 -0
- package/dist/share/index.js +3 -0
- package/dist/share/peer-addr.d.ts +32 -0
- package/dist/share/peer-addr.d.ts.map +1 -0
- package/dist/share/peer-addr.js +72 -0
- package/dist/share/peer-addr.test.d.ts +2 -0
- package/dist/share/peer-addr.test.d.ts.map +1 -0
- package/dist/share/peer-addr.test.js +58 -0
- package/dist/solid/auth-form.d.ts +43 -0
- package/dist/solid/auth-form.d.ts.map +1 -0
- package/dist/solid/auth-form.js +55 -0
- package/dist/solid/auth-form.test.d.ts +2 -0
- package/dist/solid/auth-form.test.d.ts.map +1 -0
- package/dist/solid/auth-form.test.js +131 -0
- package/dist/solid/auth-status.d.ts +21 -0
- package/dist/solid/auth-status.d.ts.map +1 -0
- package/dist/solid/auth-status.js +58 -0
- package/dist/solid/auth-status.test.d.ts +2 -0
- package/dist/solid/auth-status.test.d.ts.map +1 -0
- package/dist/solid/auth-status.test.js +60 -0
- package/dist/solid/index.d.ts +5 -0
- package/dist/solid/index.d.ts.map +1 -0
- package/dist/solid/index.js +10 -0
- package/dist/solid/knock-inbox.d.ts +61 -0
- package/dist/solid/knock-inbox.d.ts.map +1 -0
- package/dist/solid/knock-inbox.js +123 -0
- package/dist/solid/knock-inbox.test.d.ts +2 -0
- package/dist/solid/knock-inbox.test.d.ts.map +1 -0
- package/dist/solid/knock-inbox.test.js +153 -0
- package/dist/solid/reauth-flow.d.ts +26 -0
- package/dist/solid/reauth-flow.d.ts.map +1 -0
- package/dist/solid/reauth-flow.js +34 -0
- package/dist/solid/reauth-flow.test.d.ts +2 -0
- package/dist/solid/reauth-flow.test.d.ts.map +1 -0
- package/dist/solid/reauth-flow.test.js +91 -0
- package/dist/state/endpoint-control.d.ts +22 -0
- package/dist/state/endpoint-control.d.ts.map +1 -0
- package/dist/state/endpoint-control.js +34 -0
- package/dist/state/endpoint-control.test.d.ts +2 -0
- package/dist/state/endpoint-control.test.d.ts.map +1 -0
- package/dist/state/endpoint-control.test.js +63 -0
- package/dist/state/friend-directory.d.ts +37 -0
- package/dist/state/friend-directory.d.ts.map +1 -0
- package/dist/state/friend-directory.js +44 -0
- package/dist/state/friend-directory.test.d.ts +2 -0
- package/dist/state/friend-directory.test.d.ts.map +1 -0
- package/dist/state/friend-directory.test.js +63 -0
- package/dist/state/index.d.ts +6 -0
- package/dist/state/index.d.ts.map +1 -0
- package/dist/state/index.js +3 -0
- package/dist/state/peer-names.d.ts +7 -0
- package/dist/state/peer-names.d.ts.map +1 -0
- package/dist/state/peer-names.js +21 -0
- package/dist/state/peer-names.test.d.ts +2 -0
- package/dist/state/peer-names.test.d.ts.map +1 -0
- package/dist/state/peer-names.test.js +29 -0
- package/dist/testing/idb-harness.d.ts +9 -0
- package/dist/testing/idb-harness.d.ts.map +1 -0
- package/dist/testing/idb-harness.js +19 -0
- package/dist/testing/idb-harness.test.d.ts +2 -0
- package/dist/testing/idb-harness.test.d.ts.map +1 -0
- package/dist/testing/idb-harness.test.js +18 -0
- package/dist/testing/identity-fixtures.d.ts +10 -0
- package/dist/testing/identity-fixtures.d.ts.map +1 -0
- package/dist/testing/identity-fixtures.js +21 -0
- package/dist/testing/identity-fixtures.test.d.ts +2 -0
- package/dist/testing/identity-fixtures.test.d.ts.map +1 -0
- package/dist/testing/identity-fixtures.test.js +28 -0
- package/dist/testing/index.d.ts +7 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +11 -0
- package/dist/testing/knock-fixtures.d.ts +13 -0
- package/dist/testing/knock-fixtures.d.ts.map +1 -0
- package/dist/testing/knock-fixtures.js +45 -0
- package/dist/testing/knock-fixtures.test.d.ts +2 -0
- package/dist/testing/knock-fixtures.test.d.ts.map +1 -0
- package/dist/testing/knock-fixtures.test.js +65 -0
- package/dist/testing/protocol-fixtures.d.ts +7 -0
- package/dist/testing/protocol-fixtures.d.ts.map +1 -0
- package/dist/testing/protocol-fixtures.js +28 -0
- package/dist/testing/protocol-fixtures.test.d.ts +2 -0
- package/dist/testing/protocol-fixtures.test.d.ts.map +1 -0
- package/dist/testing/protocol-fixtures.test.js +19 -0
- package/dist/testing/state-fixtures.d.ts +33 -0
- package/dist/testing/state-fixtures.d.ts.map +1 -0
- package/dist/testing/state-fixtures.js +67 -0
- package/dist/testing/state-fixtures.test.d.ts +2 -0
- package/dist/testing/state-fixtures.test.d.ts.map +1 -0
- package/dist/testing/state-fixtures.test.js +68 -0
- package/dist/testing/webauthn-mocks.d.ts +27 -0
- package/dist/testing/webauthn-mocks.d.ts.map +1 -0
- package/dist/testing/webauthn-mocks.js +78 -0
- package/dist/testing/webauthn-mocks.test.d.ts +2 -0
- package/dist/testing/webauthn-mocks.test.d.ts.map +1 -0
- package/dist/testing/webauthn-mocks.test.js +40 -0
- package/dist/webauthn/ceremony.d.ts +76 -0
- package/dist/webauthn/ceremony.d.ts.map +1 -0
- package/dist/webauthn/ceremony.js +136 -0
- package/dist/webauthn/ceremony.test.d.ts +2 -0
- package/dist/webauthn/ceremony.test.d.ts.map +1 -0
- package/dist/webauthn/ceremony.test.js +245 -0
- package/dist/webauthn/codec.d.ts +39 -0
- package/dist/webauthn/codec.d.ts.map +1 -0
- package/dist/webauthn/codec.js +118 -0
- package/dist/webauthn/codec.test.d.ts +2 -0
- package/dist/webauthn/codec.test.d.ts.map +1 -0
- package/dist/webauthn/codec.test.js +126 -0
- package/dist/webauthn/index.d.ts +7 -0
- package/dist/webauthn/index.d.ts.map +1 -0
- package/dist/webauthn/index.js +3 -0
- package/dist/webauthn/transport.d.ts +58 -0
- package/dist/webauthn/transport.d.ts.map +1 -0
- package/dist/webauthn/transport.js +6 -0
- package/dist/webauthn/types.d.ts +78 -0
- package/dist/webauthn/types.d.ts.map +1 -0
- package/dist/webauthn/types.js +29 -0
- package/package.json +83 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { checkPendingKnocks } from "./pending-checker.js";
|
|
4
|
+
import { sendKnock } from "./requester.js";
|
|
5
|
+
import { createIdbKnockStore } from "./store.js";
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
globalThis.indexedDB = new IDBFactory();
|
|
8
|
+
globalThis.IDBKeyRange = IDBKeyRange;
|
|
9
|
+
});
|
|
10
|
+
function makeStore() {
|
|
11
|
+
return createIdbKnockStore({ databaseName: "app-db", storeName: "knocks" });
|
|
12
|
+
}
|
|
13
|
+
const ACCEPTED_PEER = "ab".repeat(32);
|
|
14
|
+
const DENIED_PEER = "cd".repeat(32);
|
|
15
|
+
const STILL_PENDING_PEER = "ef".repeat(32);
|
|
16
|
+
const UNREACHABLE_PEER = "12".repeat(32);
|
|
17
|
+
const browseScope = { kind: "browse" };
|
|
18
|
+
describe("checkPendingKnocks", () => {
|
|
19
|
+
it("fires onAccepted for newly-accepted knocks and leaves others alone", async () => {
|
|
20
|
+
const store = makeStore();
|
|
21
|
+
const sendTransport = {
|
|
22
|
+
sendKnock: vi.fn().mockResolvedValue({ status: "pending" }),
|
|
23
|
+
checkKnockStatus: vi.fn(),
|
|
24
|
+
};
|
|
25
|
+
await sendKnock(store, sendTransport, ACCEPTED_PEER, { scope: browseScope });
|
|
26
|
+
await sendKnock(store, sendTransport, DENIED_PEER, { scope: browseScope });
|
|
27
|
+
await sendKnock(store, sendTransport, STILL_PENDING_PEER, { scope: browseScope });
|
|
28
|
+
await sendKnock(store, sendTransport, UNREACHABLE_PEER, { scope: browseScope });
|
|
29
|
+
const checkTransport = {
|
|
30
|
+
sendKnock: vi.fn(),
|
|
31
|
+
checkKnockStatus: vi.fn().mockImplementation((nodeId) => {
|
|
32
|
+
if (nodeId === ACCEPTED_PEER) {
|
|
33
|
+
return Promise.resolve({ status: "accepted", grantedResourceIds: ["doc-1"] });
|
|
34
|
+
}
|
|
35
|
+
if (nodeId === DENIED_PEER)
|
|
36
|
+
return Promise.resolve({ status: "denied" });
|
|
37
|
+
if (nodeId === STILL_PENDING_PEER)
|
|
38
|
+
return Promise.resolve({ status: "pending" });
|
|
39
|
+
return Promise.reject(new Error("peer unreachable"));
|
|
40
|
+
}),
|
|
41
|
+
};
|
|
42
|
+
const onAccepted = vi.fn();
|
|
43
|
+
const onDenied = vi.fn();
|
|
44
|
+
await checkPendingKnocks({ store, transport: checkTransport, onAccepted, onDenied });
|
|
45
|
+
expect(onAccepted).toHaveBeenCalledTimes(1);
|
|
46
|
+
expect(onAccepted.mock.calls[0][0].nodeId).toBe(ACCEPTED_PEER);
|
|
47
|
+
expect(onDenied).toHaveBeenCalledTimes(1);
|
|
48
|
+
expect(onDenied.mock.calls[0][0].nodeId).toBe(DENIED_PEER);
|
|
49
|
+
const remainingPending = (await store.listPending()).map((r) => r.nodeId).sort();
|
|
50
|
+
expect(remainingPending.sort()).toEqual([STILL_PENDING_PEER, UNREACHABLE_PEER].sort());
|
|
51
|
+
});
|
|
52
|
+
it("ignores inbound pending knocks - only sweeps outbound ones", async () => {
|
|
53
|
+
const store = makeStore();
|
|
54
|
+
await store.createKnock({
|
|
55
|
+
nodeId: ACCEPTED_PEER,
|
|
56
|
+
direction: "inbound",
|
|
57
|
+
scope: browseScope,
|
|
58
|
+
});
|
|
59
|
+
const transport = {
|
|
60
|
+
sendKnock: vi.fn(),
|
|
61
|
+
checkKnockStatus: vi.fn().mockResolvedValue({ status: "accepted" }),
|
|
62
|
+
};
|
|
63
|
+
const onAccepted = vi.fn();
|
|
64
|
+
await checkPendingKnocks({ store, transport, onAccepted });
|
|
65
|
+
expect(onAccepted).not.toHaveBeenCalled();
|
|
66
|
+
expect(transport.checkKnockStatus).not.toHaveBeenCalled();
|
|
67
|
+
});
|
|
68
|
+
it("does nothing when there are no pending knocks", async () => {
|
|
69
|
+
const store = makeStore();
|
|
70
|
+
const transport = {
|
|
71
|
+
sendKnock: vi.fn(),
|
|
72
|
+
checkKnockStatus: vi.fn(),
|
|
73
|
+
};
|
|
74
|
+
const onAccepted = vi.fn();
|
|
75
|
+
await checkPendingKnocks({ store, transport, onAccepted });
|
|
76
|
+
expect(onAccepted).not.toHaveBeenCalled();
|
|
77
|
+
});
|
|
78
|
+
it("works without an onDenied callback", async () => {
|
|
79
|
+
const store = makeStore();
|
|
80
|
+
const sendTransport = {
|
|
81
|
+
sendKnock: vi.fn().mockResolvedValue({ status: "pending" }),
|
|
82
|
+
checkKnockStatus: vi.fn(),
|
|
83
|
+
};
|
|
84
|
+
await sendKnock(store, sendTransport, DENIED_PEER, { scope: browseScope });
|
|
85
|
+
const transport = {
|
|
86
|
+
sendKnock: vi.fn(),
|
|
87
|
+
checkKnockStatus: vi.fn().mockResolvedValue({ status: "denied" }),
|
|
88
|
+
};
|
|
89
|
+
const onAccepted = vi.fn();
|
|
90
|
+
await expect(checkPendingKnocks({ store, transport, onAccepted })).resolves.toBeUndefined();
|
|
91
|
+
expect(onAccepted).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { KnockRecord, KnockRequest, KnockStore, KnockTransport } from "./types.js";
|
|
2
|
+
export interface CheckKnockStatusOptions {
|
|
3
|
+
/** additional attempts after the first, each with exponential backoff.
|
|
4
|
+
* default 0 (a single attempt, no retry). */
|
|
5
|
+
retries?: number;
|
|
6
|
+
/** base backoff in ms; doubles each retry. default 1000. */
|
|
7
|
+
backoffMs?: number;
|
|
8
|
+
/** injectable delay, overridable in tests. */
|
|
9
|
+
sleep?: (ms: number) => Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* send a fresh knock: creates the outbound record (enforcing the dedup
|
|
13
|
+
* rule - throws KnockConflictError if one is already pending for this node
|
|
14
|
+
* id + scope) and calls the transport once. if the transport call itself
|
|
15
|
+
* fails (peer offline, network error), the record is left pending for a
|
|
16
|
+
* later checkKnockStatus retry rather than surfacing the network error to
|
|
17
|
+
* the caller - only the initial dedup conflict is worth failing loudly on.
|
|
18
|
+
*/
|
|
19
|
+
export declare function sendKnock(store: KnockStore, transport: KnockTransport, nodeId: string, request: KnockRequest): Promise<KnockRecord>;
|
|
20
|
+
/**
|
|
21
|
+
* re-check status for a previously-sent knock. retries with exponential
|
|
22
|
+
* backoff on transport failure (the peer may be transiently unreachable);
|
|
23
|
+
* the last error is thrown once retries are exhausted.
|
|
24
|
+
*/
|
|
25
|
+
export declare function checkKnockStatus(store: KnockStore, transport: KnockTransport, knockId: string, options?: CheckKnockStatusOptions): Promise<KnockRecord>;
|
|
26
|
+
//# sourceMappingURL=requester.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"requester.d.ts","sourceRoot":"","sources":["../../src/knock/requester.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,WAAW,EACX,YAAY,EAEZ,UAAU,EACV,cAAc,EACf,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,uBAAuB;IACtC;kDAC8C;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,4DAA4D;IAC5D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,8CAA8C;IAC9C,KAAK,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC;AAED;;;;;;;GAOG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,cAAc,EACzB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,YAAY,GACpB,OAAO,CAAC,WAAW,CAAC,CActB;AAMD;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,UAAU,EACjB,SAAS,EAAE,cAAc,EACzB,OAAO,EAAE,MAAM,EACf,OAAO,GAAE,uBAA4B,GACpC,OAAO,CAAC,WAAW,CAAC,CA0BtB"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// requester-side operations: sending a knock and re-checking its status.
|
|
2
|
+
/**
|
|
3
|
+
* send a fresh knock: creates the outbound record (enforcing the dedup
|
|
4
|
+
* rule - throws KnockConflictError if one is already pending for this node
|
|
5
|
+
* id + scope) and calls the transport once. if the transport call itself
|
|
6
|
+
* fails (peer offline, network error), the record is left pending for a
|
|
7
|
+
* later checkKnockStatus retry rather than surfacing the network error to
|
|
8
|
+
* the caller - only the initial dedup conflict is worth failing loudly on.
|
|
9
|
+
*/
|
|
10
|
+
export async function sendKnock(store, transport, nodeId, request) {
|
|
11
|
+
const record = await store.createKnock({
|
|
12
|
+
nodeId,
|
|
13
|
+
direction: "outbound",
|
|
14
|
+
scope: request.scope,
|
|
15
|
+
message: request.message,
|
|
16
|
+
});
|
|
17
|
+
try {
|
|
18
|
+
const reply = await transport.sendKnock(nodeId, request);
|
|
19
|
+
return await applyStatusReply(store, record, reply);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return record;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function defaultSleep(ms) {
|
|
26
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* re-check status for a previously-sent knock. retries with exponential
|
|
30
|
+
* backoff on transport failure (the peer may be transiently unreachable);
|
|
31
|
+
* the last error is thrown once retries are exhausted.
|
|
32
|
+
*/
|
|
33
|
+
export async function checkKnockStatus(store, transport, knockId, options = {}) {
|
|
34
|
+
const record = await store.getKnock(knockId);
|
|
35
|
+
if (!record) {
|
|
36
|
+
throw new Error(`knock ${knockId} not found`);
|
|
37
|
+
}
|
|
38
|
+
const retries = options.retries ?? 0;
|
|
39
|
+
const backoffMs = options.backoffMs ?? 1000;
|
|
40
|
+
const sleep = options.sleep ?? defaultSleep;
|
|
41
|
+
let lastError;
|
|
42
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
43
|
+
try {
|
|
44
|
+
const reply = await transport.checkKnockStatus(record.nodeId, {
|
|
45
|
+
scope: record.scope,
|
|
46
|
+
message: record.message,
|
|
47
|
+
});
|
|
48
|
+
return await applyStatusReply(store, record, reply);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
lastError = err;
|
|
52
|
+
if (attempt < retries) {
|
|
53
|
+
await sleep(backoffMs * 2 ** attempt);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* persist a status reply against a record, appending a decision only when
|
|
61
|
+
* the reply actually resolves the knock (accepted/denied) - a "still
|
|
62
|
+
* pending" reply leaves the record and its audit log untouched.
|
|
63
|
+
*/
|
|
64
|
+
async function applyStatusReply(store, record, reply) {
|
|
65
|
+
if (reply.status === "pending")
|
|
66
|
+
return record;
|
|
67
|
+
return store.recordDecision(record.id, {
|
|
68
|
+
byNodeId: record.nodeId,
|
|
69
|
+
outcome: reply.status,
|
|
70
|
+
at: Date.now(),
|
|
71
|
+
}, { grantedResourceIds: reply.grantedResourceIds });
|
|
72
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"requester.test.d.ts","sourceRoot":"","sources":["../../src/knock/requester.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { checkKnockStatus, sendKnock } from "./requester.js";
|
|
4
|
+
import { createIdbKnockStore } from "./store.js";
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
globalThis.indexedDB = new IDBFactory();
|
|
7
|
+
globalThis.IDBKeyRange = IDBKeyRange;
|
|
8
|
+
});
|
|
9
|
+
function makeStore() {
|
|
10
|
+
return createIdbKnockStore({ databaseName: "app-db", storeName: "knocks" });
|
|
11
|
+
}
|
|
12
|
+
const NODE_ID = "ab".repeat(32);
|
|
13
|
+
const browseScope = { kind: "browse" };
|
|
14
|
+
function makeTransport(overrides = {}) {
|
|
15
|
+
return {
|
|
16
|
+
sendKnock: vi.fn().mockResolvedValue({ status: "pending" }),
|
|
17
|
+
checkKnockStatus: vi.fn().mockResolvedValue({ status: "pending" }),
|
|
18
|
+
...overrides,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
describe("sendKnock", () => {
|
|
22
|
+
it("creates an outbound record and applies the transport's reply", async () => {
|
|
23
|
+
const store = makeStore();
|
|
24
|
+
const transport = makeTransport({
|
|
25
|
+
sendKnock: vi.fn().mockResolvedValue({ status: "accepted", grantedResourceIds: ["doc-1"] }),
|
|
26
|
+
});
|
|
27
|
+
const record = await sendKnock(store, transport, NODE_ID, {
|
|
28
|
+
scope: browseScope,
|
|
29
|
+
message: "hi",
|
|
30
|
+
});
|
|
31
|
+
expect(record.direction).toBe("outbound");
|
|
32
|
+
expect(record.status).toBe("accepted");
|
|
33
|
+
expect(record.grantedResourceIds).toEqual(["doc-1"]);
|
|
34
|
+
expect(transport.sendKnock).toHaveBeenCalledWith(NODE_ID, {
|
|
35
|
+
scope: browseScope,
|
|
36
|
+
message: "hi",
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
it("leaves the record pending when the transport reply is still pending", async () => {
|
|
40
|
+
const store = makeStore();
|
|
41
|
+
const transport = makeTransport();
|
|
42
|
+
const record = await sendKnock(store, transport, NODE_ID, { scope: browseScope });
|
|
43
|
+
expect(record.status).toBe("pending");
|
|
44
|
+
expect(record.decisions).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
it("leaves the record pending when the transport call throws", async () => {
|
|
47
|
+
const store = makeStore();
|
|
48
|
+
const transport = makeTransport({
|
|
49
|
+
sendKnock: vi.fn().mockRejectedValue(new Error("peer unreachable")),
|
|
50
|
+
});
|
|
51
|
+
const record = await sendKnock(store, transport, NODE_ID, { scope: browseScope });
|
|
52
|
+
expect(record.status).toBe("pending");
|
|
53
|
+
});
|
|
54
|
+
it("propagates the dedup conflict when a pending knock already exists", async () => {
|
|
55
|
+
const store = makeStore();
|
|
56
|
+
const transport = makeTransport();
|
|
57
|
+
await sendKnock(store, transport, NODE_ID, { scope: browseScope });
|
|
58
|
+
await expect(sendKnock(store, transport, NODE_ID, { scope: browseScope })).rejects.toThrow();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("checkKnockStatus", () => {
|
|
62
|
+
it("resolves the record when the transport reports a decision", async () => {
|
|
63
|
+
const store = makeStore();
|
|
64
|
+
const transport = makeTransport();
|
|
65
|
+
const record = await sendKnock(store, transport, NODE_ID, { scope: browseScope });
|
|
66
|
+
transport.checkKnockStatus = vi.fn().mockResolvedValue({ status: "denied" });
|
|
67
|
+
const updated = await checkKnockStatus(store, transport, record.id);
|
|
68
|
+
expect(updated.status).toBe("denied");
|
|
69
|
+
expect(updated.decisions).toHaveLength(1);
|
|
70
|
+
});
|
|
71
|
+
it("throws for an unknown knock id", async () => {
|
|
72
|
+
const store = makeStore();
|
|
73
|
+
const transport = makeTransport();
|
|
74
|
+
await expect(checkKnockStatus(store, transport, "nope")).rejects.toThrow();
|
|
75
|
+
});
|
|
76
|
+
it("retries with backoff and succeeds before exhausting retries", async () => {
|
|
77
|
+
const store = makeStore();
|
|
78
|
+
const transport = makeTransport();
|
|
79
|
+
const record = await sendKnock(store, transport, NODE_ID, { scope: browseScope });
|
|
80
|
+
let calls = 0;
|
|
81
|
+
transport.checkKnockStatus = vi.fn().mockImplementation(() => {
|
|
82
|
+
calls += 1;
|
|
83
|
+
if (calls < 3)
|
|
84
|
+
return Promise.reject(new Error("unreachable"));
|
|
85
|
+
return Promise.resolve({ status: "accepted" });
|
|
86
|
+
});
|
|
87
|
+
const sleep = vi.fn().mockResolvedValue(undefined);
|
|
88
|
+
const updated = await checkKnockStatus(store, transport, record.id, {
|
|
89
|
+
retries: 3,
|
|
90
|
+
backoffMs: 10,
|
|
91
|
+
sleep,
|
|
92
|
+
});
|
|
93
|
+
expect(updated.status).toBe("accepted");
|
|
94
|
+
expect(calls).toBe(3);
|
|
95
|
+
expect(sleep).toHaveBeenCalledTimes(2);
|
|
96
|
+
expect(sleep).toHaveBeenNthCalledWith(1, 10);
|
|
97
|
+
expect(sleep).toHaveBeenNthCalledWith(2, 20);
|
|
98
|
+
});
|
|
99
|
+
it("throws the last error once retries are exhausted", async () => {
|
|
100
|
+
const store = makeStore();
|
|
101
|
+
const transport = makeTransport();
|
|
102
|
+
const record = await sendKnock(store, transport, NODE_ID, { scope: browseScope });
|
|
103
|
+
transport.checkKnockStatus = vi.fn().mockRejectedValue(new Error("still unreachable"));
|
|
104
|
+
const sleep = vi.fn().mockResolvedValue(undefined);
|
|
105
|
+
await expect(checkKnockStatus(store, transport, record.id, { retries: 2, backoffMs: 5, sleep })).rejects.toThrow("still unreachable");
|
|
106
|
+
expect(transport.checkKnockStatus).toHaveBeenCalledTimes(3);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { KnockPolicy, KnockRecord, KnockStore } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* accept an inbound knock: runs the injected policy and records its result
|
|
4
|
+
* as an accepted decision. `decidedBy` is the responder's own node id,
|
|
5
|
+
* recorded on the decision's audit entry.
|
|
6
|
+
*/
|
|
7
|
+
export declare function acceptKnock(store: KnockStore, knockId: string, policy: KnockPolicy, decidedBy: string): Promise<KnockRecord>;
|
|
8
|
+
/**
|
|
9
|
+
* deny an inbound knock. no policy callback - denial has no side effect to
|
|
10
|
+
* inject.
|
|
11
|
+
*/
|
|
12
|
+
export declare function denyKnock(store: KnockStore, knockId: string, decidedBy: string): Promise<KnockRecord>;
|
|
13
|
+
//# sourceMappingURL=responder.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"responder.d.ts","sourceRoot":"","sources":["../../src/knock/responder.ts"],"names":[],"mappings":"AAQA,OAAO,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAEvE;;;;GAIG;AACH,wBAAsB,WAAW,CAC/B,KAAK,EAAE,UAAU,EACjB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,WAAW,EACnB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,WAAW,CAAC,CAgBtB;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,KAAK,EAAE,UAAU,EACjB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,WAAW,CAAC,CAUtB"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// responder-side operations: accepting or denying an inbound knock.
|
|
2
|
+
//
|
|
3
|
+
// the accept side effect (creating a user, granting a role, whatever
|
|
4
|
+
// accepting a knock actually means for the app) is always the injected
|
|
5
|
+
// `KnockPolicy` callback - this module only records the decision and
|
|
6
|
+
// resolves the knock record's status, never performs an app-specific
|
|
7
|
+
// effect itself.
|
|
8
|
+
/**
|
|
9
|
+
* accept an inbound knock: runs the injected policy and records its result
|
|
10
|
+
* as an accepted decision. `decidedBy` is the responder's own node id,
|
|
11
|
+
* recorded on the decision's audit entry.
|
|
12
|
+
*/
|
|
13
|
+
export async function acceptKnock(store, knockId, policy, decidedBy) {
|
|
14
|
+
const record = await store.getKnock(knockId);
|
|
15
|
+
if (!record) {
|
|
16
|
+
throw new Error(`knock ${knockId} not found`);
|
|
17
|
+
}
|
|
18
|
+
const result = await policy(record);
|
|
19
|
+
return store.recordDecision(knockId, {
|
|
20
|
+
byNodeId: decidedBy,
|
|
21
|
+
outcome: "accepted",
|
|
22
|
+
grantedRole: result.grantedRole,
|
|
23
|
+
at: Date.now(),
|
|
24
|
+
}, { grantedResourceIds: result.grantedResourceIds });
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* deny an inbound knock. no policy callback - denial has no side effect to
|
|
28
|
+
* inject.
|
|
29
|
+
*/
|
|
30
|
+
export async function denyKnock(store, knockId, decidedBy) {
|
|
31
|
+
const record = await store.getKnock(knockId);
|
|
32
|
+
if (!record) {
|
|
33
|
+
throw new Error(`knock ${knockId} not found`);
|
|
34
|
+
}
|
|
35
|
+
return store.recordDecision(knockId, {
|
|
36
|
+
byNodeId: decidedBy,
|
|
37
|
+
outcome: "denied",
|
|
38
|
+
at: Date.now(),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"responder.test.d.ts","sourceRoot":"","sources":["../../src/knock/responder.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { IDBFactory, IDBKeyRange } from "fake-indexeddb";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { acceptKnock, denyKnock } from "./responder.js";
|
|
4
|
+
import { createIdbKnockStore } from "./store.js";
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
globalThis.indexedDB = new IDBFactory();
|
|
7
|
+
globalThis.IDBKeyRange = IDBKeyRange;
|
|
8
|
+
});
|
|
9
|
+
function makeStore() {
|
|
10
|
+
return createIdbKnockStore({ databaseName: "app-db", storeName: "knocks" });
|
|
11
|
+
}
|
|
12
|
+
const REQUESTER_NODE_ID = "ab".repeat(32);
|
|
13
|
+
const RESPONDER_NODE_ID = "cd".repeat(32);
|
|
14
|
+
const browseScope = { kind: "browse" };
|
|
15
|
+
describe("acceptKnock", () => {
|
|
16
|
+
it("runs the injected policy and records an accepted decision", async () => {
|
|
17
|
+
const store = makeStore();
|
|
18
|
+
const record = await store.createKnock({
|
|
19
|
+
nodeId: REQUESTER_NODE_ID,
|
|
20
|
+
direction: "inbound",
|
|
21
|
+
scope: browseScope,
|
|
22
|
+
message: "let me in",
|
|
23
|
+
});
|
|
24
|
+
const policy = vi.fn().mockResolvedValue({
|
|
25
|
+
grantedRole: "member",
|
|
26
|
+
grantedResourceIds: ["doc-1"],
|
|
27
|
+
});
|
|
28
|
+
const updated = await acceptKnock(store, record.id, policy, RESPONDER_NODE_ID);
|
|
29
|
+
expect(policy).toHaveBeenCalledWith(record);
|
|
30
|
+
expect(updated.status).toBe("accepted");
|
|
31
|
+
expect(updated.processedBy).toBe(RESPONDER_NODE_ID);
|
|
32
|
+
expect(updated.decisions).toEqual([
|
|
33
|
+
{
|
|
34
|
+
byNodeId: RESPONDER_NODE_ID,
|
|
35
|
+
outcome: "accepted",
|
|
36
|
+
grantedRole: "member",
|
|
37
|
+
at: updated.processedAt,
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
expect(updated.grantedResourceIds).toEqual(["doc-1"]);
|
|
41
|
+
});
|
|
42
|
+
it("supports a synchronous policy callback", async () => {
|
|
43
|
+
const store = makeStore();
|
|
44
|
+
const record = await store.createKnock({
|
|
45
|
+
nodeId: REQUESTER_NODE_ID,
|
|
46
|
+
direction: "inbound",
|
|
47
|
+
scope: browseScope,
|
|
48
|
+
});
|
|
49
|
+
const policy = () => ({ grantedRole: "viewer" });
|
|
50
|
+
const updated = await acceptKnock(store, record.id, policy, RESPONDER_NODE_ID);
|
|
51
|
+
expect(updated.status).toBe("accepted");
|
|
52
|
+
expect(updated.decisions[0].grantedRole).toBe("viewer");
|
|
53
|
+
});
|
|
54
|
+
it("throws for an unknown knock id", async () => {
|
|
55
|
+
const store = makeStore();
|
|
56
|
+
const policy = () => ({});
|
|
57
|
+
await expect(acceptKnock(store, "nope", policy, RESPONDER_NODE_ID)).rejects.toThrow();
|
|
58
|
+
});
|
|
59
|
+
it("propagates a policy rejection without recording a decision", async () => {
|
|
60
|
+
const store = makeStore();
|
|
61
|
+
const record = await store.createKnock({
|
|
62
|
+
nodeId: REQUESTER_NODE_ID,
|
|
63
|
+
direction: "inbound",
|
|
64
|
+
scope: browseScope,
|
|
65
|
+
});
|
|
66
|
+
const policy = vi.fn().mockRejectedValue(new Error("policy declined"));
|
|
67
|
+
await expect(acceptKnock(store, record.id, policy, RESPONDER_NODE_ID)).rejects.toThrow("policy declined");
|
|
68
|
+
expect((await store.getKnock(record.id))?.status).toBe("pending");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
describe("denyKnock", () => {
|
|
72
|
+
it("records a denied decision with no granted role", async () => {
|
|
73
|
+
const store = makeStore();
|
|
74
|
+
const record = await store.createKnock({
|
|
75
|
+
nodeId: REQUESTER_NODE_ID,
|
|
76
|
+
direction: "inbound",
|
|
77
|
+
scope: browseScope,
|
|
78
|
+
});
|
|
79
|
+
const updated = await denyKnock(store, record.id, RESPONDER_NODE_ID);
|
|
80
|
+
expect(updated.status).toBe("denied");
|
|
81
|
+
expect(updated.decisions).toEqual([
|
|
82
|
+
{ byNodeId: RESPONDER_NODE_ID, outcome: "denied", at: updated.processedAt },
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
it("throws for an unknown knock id", async () => {
|
|
86
|
+
const store = makeStore();
|
|
87
|
+
await expect(denyKnock(store, "nope", RESPONDER_NODE_ID)).rejects.toThrow();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { KnockStore } from "./types.js";
|
|
2
|
+
export interface KnockStoreOptions {
|
|
3
|
+
databaseName: string;
|
|
4
|
+
storeName: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* an idb-backed KnockStore. database/store names are parameterized (see
|
|
8
|
+
* KnockStoreOptions) so an app can point this at its own existing tables.
|
|
9
|
+
*/
|
|
10
|
+
export declare function createIdbKnockStore(options: KnockStoreOptions): KnockStore;
|
|
11
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/knock/store.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAKV,UAAU,EACX,MAAM,YAAY,CAAC;AAGpB,MAAM,WAAW,iBAAiB;IAChC,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;CACnB;AA4ED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,iBAAiB,GAAG,UAAU,CAgJ1E"}
|