@kubun/hub 0.14.0 → 0.16.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/lib/create-relay.d.ts +15 -0
- package/lib/create-relay.js +2 -0
- package/lib/hub-store.js +26 -31
- package/lib/index.d.ts +2 -0
- package/lib/index.js +1 -0
- package/lib/join-lane-authorize.d.ts +13 -0
- package/lib/join-lane-authorize.js +26 -0
- package/package.json +14 -14
package/lib/create-relay.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Identity } from '@kokuin/token';
|
|
2
2
|
import type { Adapter } from '@kubun/db-adapter';
|
|
3
3
|
import { type AdapterInput } from '@kubun/db-provider';
|
|
4
|
+
import type { AuthorizeHook } from '@kumiai/hub-server';
|
|
4
5
|
import { Kysely } from 'kysely';
|
|
5
6
|
import { type HubDatabase, type KubunHubStore } from './hub-store.js';
|
|
6
7
|
import { type RelayObservers } from './observers.js';
|
|
@@ -20,6 +21,13 @@ export type CreateRelayOptions = {
|
|
|
20
21
|
db: AdapterInput;
|
|
21
22
|
identity: Identity;
|
|
22
23
|
auth?: HubAuthConfig;
|
|
24
|
+
/**
|
|
25
|
+
* Per-action authorization hook, forwarded verbatim to `@kumiai/hub-server`
|
|
26
|
+
* `createHub`. Composes with `auth`: the coarse `AccessRules` gate runs first,
|
|
27
|
+
* then this refines per action and topic. Omitted, the hub allows any
|
|
28
|
+
* authenticated request (its default).
|
|
29
|
+
*/
|
|
30
|
+
authorize?: AuthorizeHook;
|
|
23
31
|
allowedOrigin?: string | Array<string>;
|
|
24
32
|
retention?: RelayRetentionConfig;
|
|
25
33
|
defaultRetention?: number;
|
|
@@ -38,6 +46,13 @@ export type RelayInternalOptions = {
|
|
|
38
46
|
adapter: Adapter;
|
|
39
47
|
identity: Identity;
|
|
40
48
|
auth?: HubAuthConfig;
|
|
49
|
+
/**
|
|
50
|
+
* Per-action authorization hook, forwarded verbatim to `@kumiai/hub-server`
|
|
51
|
+
* `createHub`. Composes with `auth`: the coarse `AccessRules` gate runs first,
|
|
52
|
+
* then this refines per action and topic. Omitted, the hub allows any
|
|
53
|
+
* authenticated request (its default).
|
|
54
|
+
*/
|
|
55
|
+
authorize?: AuthorizeHook;
|
|
41
56
|
allowedOrigin?: string | Array<string>;
|
|
42
57
|
retention?: RelayRetentionConfig;
|
|
43
58
|
defaultRetention?: number;
|
package/lib/create-relay.js
CHANGED
|
@@ -97,6 +97,7 @@ export async function createRelayInternal(options) {
|
|
|
97
97
|
store: hubStore,
|
|
98
98
|
identity: options.identity,
|
|
99
99
|
accessRules,
|
|
100
|
+
authorize: options.authorize,
|
|
100
101
|
purge: false
|
|
101
102
|
});
|
|
102
103
|
if (observers?.onAuthRejected != null) {
|
|
@@ -222,6 +223,7 @@ export async function createRelay(options) {
|
|
|
222
223
|
adapter,
|
|
223
224
|
identity: options.identity,
|
|
224
225
|
auth: options.auth,
|
|
226
|
+
authorize: options.authorize,
|
|
225
227
|
allowedOrigin: options.allowedOrigin,
|
|
226
228
|
retention: options.retention,
|
|
227
229
|
defaultRetention: options.defaultRetention,
|
package/lib/hub-store.js
CHANGED
|
@@ -8,11 +8,10 @@ function toBytes(value) {
|
|
|
8
8
|
/**
|
|
9
9
|
* How long an idempotency record outlives its own publish, in seconds.
|
|
10
10
|
*
|
|
11
|
-
* A year: a
|
|
12
|
-
* exceed any replay a real client could still
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* stops being a coherent claim.
|
|
11
|
+
* A year: past this a repeated publish is treated as new, so the window must
|
|
12
|
+
* exceed any replay a real client could still hold — a parked commit, a device
|
|
13
|
+
* restored from backup, a lane retried after an outage. Not a host-tuned knob;
|
|
14
|
+
* it is the horizon past which "the same publish" stops being coherent.
|
|
16
15
|
*/ const PUBLISH_ID_RETENTION_SECONDS = 365 * 24 * 60 * 60;
|
|
17
16
|
/** Frames deleted per statement — see {@link deleteFrames}. */ const DELETE_CHUNK_SIZE = 500;
|
|
18
17
|
/** Matches `@kumiai/hub-server`'s own defaults — a kubun relay is not a laxer hub. */ const DEFAULT_MAX_KEY_PACKAGES_PER_DID = 100;
|
|
@@ -36,11 +35,10 @@ export function createHubStore(params) {
|
|
|
36
35
|
/**
|
|
37
36
|
* Remove frames and the deliveries pointing at them, a chunk at a time.
|
|
38
37
|
*
|
|
39
|
-
* SQLite binds one parameter per
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* be pushed once its referent is gone.
|
|
38
|
+
* SQLite binds one parameter per `in` element and throws "too many SQL
|
|
39
|
+
* variables" past its limit (32766 here, 999 on older builds), so an unchunked
|
|
40
|
+
* sweep fails precisely when the backlog is large. Deliveries first: a delivery
|
|
41
|
+
* references a frame and cannot be pushed once its referent is gone.
|
|
44
42
|
*/ async function deleteFrames(executor, sequenceIDs) {
|
|
45
43
|
for(let index = 0; index < sequenceIDs.length; index += DELETE_CHUNK_SIZE){
|
|
46
44
|
const chunk = sequenceIDs.slice(index, index + DELETE_CHUNK_SIZE);
|
|
@@ -52,10 +50,9 @@ export function createHubStore(params) {
|
|
|
52
50
|
// global counter. Fixed-width zero-padded so lexicographic order matches
|
|
53
51
|
// numeric order (`"10" > "9"`), and strictly increasing.
|
|
54
52
|
//
|
|
55
|
-
// One row, so every publish on every topic serializes behind it
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
// hold each other's next lock.
|
|
53
|
+
// One row, so every publish on every topic serializes behind it — the cost of
|
|
54
|
+
// a single total order. It deadlocks nothing: every writer takes this row FIRST
|
|
55
|
+
// and per-topic rows after, so no two transactions hold each other's next lock.
|
|
59
56
|
async function nextSequenceID(trx) {
|
|
60
57
|
const row = await trx.updateTable('kubun_hub_sequence').set((eb)=>({
|
|
61
58
|
counter: eb('counter', '+', 1)
|
|
@@ -310,22 +307,20 @@ export function createHubStore(params) {
|
|
|
310
307
|
const kysely = await db();
|
|
311
308
|
const now = Date.now();
|
|
312
309
|
const olderThan = purgeParams.olderThan;
|
|
313
|
-
// Idempotency records outlive the frames they dedup, on their own clock
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
// is far longer than any plausible replay — a publish repeated after it is
|
|
319
|
-
// accepted as new, which is what a fresh publishID would be anyway.
|
|
310
|
+
// Idempotency records outlive the frames they dedup, on their own clock: the
|
|
311
|
+
// contract is that no message deleter reaches one for a frame it still
|
|
312
|
+
// guards, NOT that they are kept forever. Every commit publish writes one, so
|
|
313
|
+
// without this the table is the fastest-growing on a long-lived hub. The
|
|
314
|
+
// window far exceeds any plausible replay (see PUBLISH_ID_RETENTION_SECONDS).
|
|
320
315
|
await kysely.deleteFrom('kubun_hub_publish_ids').where('recorded_at', '<=', now - PUBLISH_ID_RETENTION_SECONDS * 1000).execute();
|
|
321
316
|
// Per topic, the effective age bound is the longest retention any CURRENT
|
|
322
317
|
// subscriber asked for, floored at the hub default (and at the sweep's
|
|
323
318
|
// olderThan). Applies to both classes. Never touches the head or a publishID
|
|
324
319
|
// record for a frame it removes.
|
|
325
320
|
const topicRows = await kysely.selectFrom('kubun_hub_messages').select('topic_id').distinct().execute();
|
|
326
|
-
// Every topic's longest CURRENT subscriber retention in one grouped read
|
|
327
|
-
//
|
|
328
|
-
//
|
|
321
|
+
// Every topic's longest CURRENT subscriber retention in one grouped read, not
|
|
322
|
+
// one read per topic: the sweep walks every topic, so per-topic reads would be
|
|
323
|
+
// a query count that grows with the hub.
|
|
329
324
|
const retentionRows = await kysely.selectFrom('kubun_hub_subscriptions').select((eb)=>[
|
|
330
325
|
'topic_id',
|
|
331
326
|
eb.fn.max('retention').as('maxRetention')
|
|
@@ -358,7 +353,7 @@ export function createHubStore(params) {
|
|
|
358
353
|
async function deleteExpiredKeyPackages(kysely, ownerDID) {
|
|
359
354
|
await kysely.deleteFrom('kubun_hub_key_packages').where('owner_did', '=', ownerDID).where('not_after', 'is not', null).where('not_after', '<=', nowSeconds()).execute();
|
|
360
355
|
}
|
|
361
|
-
async function storeKeyPackage(ownerDID, keyPackage, notAfter) {
|
|
356
|
+
async function storeKeyPackage({ ownerDID, keyPackage, notAfter }) {
|
|
362
357
|
const kysely = await db();
|
|
363
358
|
// Drop the dead BEFORE charging the cap. The cap rejects rather than evicts, so
|
|
364
359
|
// nothing else would ever remove them: a pool that filled with expired entries
|
|
@@ -374,7 +369,7 @@ export function createHubStore(params) {
|
|
|
374
369
|
not_after: notAfter ?? null
|
|
375
370
|
}).execute();
|
|
376
371
|
}
|
|
377
|
-
async function fetchKeyPackages(ownerDID, count) {
|
|
372
|
+
async function fetchKeyPackages({ ownerDID, count }) {
|
|
378
373
|
const kysely = await db();
|
|
379
374
|
// Expired entries are DELETED first, not filtered after the limit: a run of dead
|
|
380
375
|
// rows would otherwise consume the whole page and return nothing while live
|
|
@@ -386,7 +381,7 @@ export function createHubStore(params) {
|
|
|
386
381
|
const rows = await kysely.deleteFrom('kubun_hub_key_packages').where('id', 'in', kysely.selectFrom('kubun_hub_key_packages').select('id').where('owner_did', '=', ownerDID).orderBy('id', 'asc').limit(limit)).returning('key_package').execute();
|
|
387
382
|
return rows.map((r)=>r.key_package);
|
|
388
383
|
}
|
|
389
|
-
async function countKeyPackages(ownerDID) {
|
|
384
|
+
async function countKeyPackages({ ownerDID }) {
|
|
390
385
|
const kysely = await db();
|
|
391
386
|
const row = await kysely.selectFrom('kubun_hub_key_packages').select((eb)=>eb.fn.countAll().as('count')).where('owner_did', '=', ownerDID).where((eb)=>eb.or([
|
|
392
387
|
eb('not_after', 'is', null),
|
|
@@ -394,7 +389,7 @@ export function createHubStore(params) {
|
|
|
394
389
|
])).executeTakeFirst();
|
|
395
390
|
return Number(row?.count ?? 0);
|
|
396
391
|
}
|
|
397
|
-
async function storeLastResortKeyPackage(ownerDID, keyPackage) {
|
|
392
|
+
async function storeLastResortKeyPackage({ ownerDID, keyPackage }) {
|
|
398
393
|
const kysely = await db();
|
|
399
394
|
await kysely.insertInto('kubun_hub_last_resort_key_packages').values({
|
|
400
395
|
owner_did: ownerDID,
|
|
@@ -403,7 +398,7 @@ export function createHubStore(params) {
|
|
|
403
398
|
key_package: (eb)=>eb.ref('excluded.key_package')
|
|
404
399
|
})).execute();
|
|
405
400
|
}
|
|
406
|
-
async function fetchLastResortKeyPackage(ownerDID) {
|
|
401
|
+
async function fetchLastResortKeyPackage({ ownerDID }) {
|
|
407
402
|
const kysely = await db();
|
|
408
403
|
// Never consumes. Reuse is the point: the package carries MLS's `last_resort`
|
|
409
404
|
// extension, so serving it twice is the availability floor rather than the
|
|
@@ -442,7 +437,7 @@ export function createHubStore(params) {
|
|
|
442
437
|
retention: (eb)=>eb.ref('excluded.retention')
|
|
443
438
|
})).execute();
|
|
444
439
|
}
|
|
445
|
-
async function unsubscribe(subscriberDID, topicID) {
|
|
440
|
+
async function unsubscribe({ subscriberDID, topicID }) {
|
|
446
441
|
const kysely = await db();
|
|
447
442
|
await kysely.deleteFrom('kubun_hub_subscriptions').where('topic_id', '=', topicID).where('subscriber_did', '=', subscriberDID).execute();
|
|
448
443
|
// unsubscribe is a delivery operation, not a trim: it drops this subscriber's
|
|
@@ -460,7 +455,7 @@ export function createHubStore(params) {
|
|
|
460
455
|
await kysely.deleteFrom('kubun_hub_messages').where('sequence_id', 'in', orphaned).where('retain', '=', 'mailbox').execute();
|
|
461
456
|
}
|
|
462
457
|
}
|
|
463
|
-
async function getSubscribers(topicID) {
|
|
458
|
+
async function getSubscribers({ topicID }) {
|
|
464
459
|
const kysely = await db();
|
|
465
460
|
const rows = await kysely.selectFrom('kubun_hub_subscriptions').select('subscriber_did').where('topic_id', '=', topicID).execute();
|
|
466
461
|
return rows.map((r)=>r.subscriber_did);
|
package/lib/index.d.ts
CHANGED
|
@@ -4,8 +4,10 @@ import type { Kysely } from 'kysely';
|
|
|
4
4
|
import { type HubAuthConfig, type RelayRetentionConfig } from './create-relay.js';
|
|
5
5
|
import type { HubDatabase } from './hub-store.js';
|
|
6
6
|
import type { RelayObservers } from './observers.js';
|
|
7
|
+
export type { AuthorizeDecision, AuthorizeHook, AuthorizeRequest } from '@kumiai/hub-server';
|
|
7
8
|
export { type CreateRelayOptions, createRelay, createRelayInternal, type HubAuthConfig, type Relay, type RelayRetentionConfig, } from './create-relay.js';
|
|
8
9
|
export { type CreateHubStoreParams, createHubStore, type HubDatabase, type KubunHubStore, type PurgedSubscription, type PurgeSubscriptionsParams, } from './hub-store.js';
|
|
10
|
+
export { type JoinLaneAuthorizeConfig, joinLaneAuthorize } from './join-lane-authorize.js';
|
|
9
11
|
export { createHubMigrations, type MigrationContext } from './migrations.js';
|
|
10
12
|
export { decorateHubStore, type RelayObserverAckInfo, type RelayObserverAuthRejectedInfo, type RelayObserverErrorInfo, type RelayObserverErrorStage, type RelayObserverFetchInfo, type RelayObserverPurgeInfo, type RelayObserverRetentionConfigClampedInfo, type RelayObserverStoreInfo, type RelayObserverSubscriptionPurgeInfo, type RelayObservers, } from './observers.js';
|
|
11
13
|
export type HubOptions = {
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Hono } from 'hono';
|
|
|
4
4
|
import { createRelayInternal } from './create-relay.js';
|
|
5
5
|
export { createRelay, createRelayInternal } from './create-relay.js';
|
|
6
6
|
export { createHubStore } from './hub-store.js';
|
|
7
|
+
export { joinLaneAuthorize } from './join-lane-authorize.js';
|
|
7
8
|
export { createHubMigrations } from './migrations.js';
|
|
8
9
|
export { decorateHubStore } from './observers.js';
|
|
9
10
|
export async function createHub(options) {
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { AuthorizeHook } from '@kumiai/hub-server';
|
|
2
|
+
export type JoinLaneAuthorizeConfig = {
|
|
3
|
+
/** The single topic a not-yet-member device may touch. */
|
|
4
|
+
joinTopic: string;
|
|
5
|
+
/** Whether a DID is already a member (gets full access). */
|
|
6
|
+
isMember: (did: string) => boolean | Promise<boolean>;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Build an `authorize` hook implementing a pre-membership "join lane": a member
|
|
10
|
+
* DID may do anything; a non-member may only publish/subscribe/fetch exactly the
|
|
11
|
+
* one join topic, and nothing else. Pair with `createRelay({ authorize })`.
|
|
12
|
+
*/
|
|
13
|
+
export declare function joinLaneAuthorize(config: JoinLaneAuthorizeConfig): AuthorizeHook;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Actions a non-member is allowed on the join topic — the minimum to post a
|
|
2
|
+
// join request and receive the answer. Everything else is denied.
|
|
3
|
+
const JOIN_LANE_ACTIONS = new Set([
|
|
4
|
+
'publish',
|
|
5
|
+
'subscribe',
|
|
6
|
+
'topic/fetch'
|
|
7
|
+
]);
|
|
8
|
+
/**
|
|
9
|
+
* Build an `authorize` hook implementing a pre-membership "join lane": a member
|
|
10
|
+
* DID may do anything; a non-member may only publish/subscribe/fetch exactly the
|
|
11
|
+
* one join topic, and nothing else. Pair with `createRelay({ authorize })`.
|
|
12
|
+
*/ export function joinLaneAuthorize(config) {
|
|
13
|
+
return async (req)=>{
|
|
14
|
+
if (await config.isMember(req.did)) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
const topicID = 'topicID' in req ? req.topicID : undefined;
|
|
18
|
+
if (JOIN_LANE_ACTIONS.has(req.action) && topicID === config.joinTopic) {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
allow: false,
|
|
23
|
+
reason: 'join lane: non-members may only use the join topic'
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kubun/hub",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"license": "see LICENSE.md",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"type": "module",
|
|
@@ -18,26 +18,26 @@
|
|
|
18
18
|
"@enkaku/server": "^0.21.2",
|
|
19
19
|
"@hono/node-server": "^2.1.1",
|
|
20
20
|
"@kokuin/token": "^0.5.0",
|
|
21
|
-
"@kubun/db-adapter": "^0.
|
|
22
|
-
"@kubun/db-provider": "^0.
|
|
23
|
-
"@kubun/http-util": "^0.
|
|
24
|
-
"@kumiai/hub-protocol": "^0.
|
|
25
|
-
"@kumiai/hub-server": "^0.
|
|
21
|
+
"@kubun/db-adapter": "^0.16.0",
|
|
22
|
+
"@kubun/db-provider": "^0.16.0",
|
|
23
|
+
"@kubun/http-util": "^0.16.0",
|
|
24
|
+
"@kumiai/hub-protocol": "^0.9.0",
|
|
25
|
+
"@kumiai/hub-server": "^0.9.0",
|
|
26
26
|
"@sozai/event": "^0.1.3",
|
|
27
27
|
"get-port": "^7.2.0",
|
|
28
|
-
"hono": "^4.13.
|
|
28
|
+
"hono": "^4.13.5",
|
|
29
29
|
"kysely": "^0.29.5"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@enkaku/client": "^0.21.
|
|
32
|
+
"@enkaku/client": "^0.21.2",
|
|
33
33
|
"@enkaku/http-fetch": "^0.21.0",
|
|
34
|
-
"@enkaku/protocol": "^0.21.
|
|
34
|
+
"@enkaku/protocol": "^0.21.1",
|
|
35
35
|
"@enkaku/transport": "^0.21.0",
|
|
36
|
-
"@kubun/db-better-sqlite": "^0.
|
|
37
|
-
"@kubun/db-node-sqlite": "^0.
|
|
38
|
-
"@kubun/db-postgres": "^0.
|
|
39
|
-
"@kumiai/hub-conformance": "^0.
|
|
40
|
-
"@kumiai/mls": "^0.
|
|
36
|
+
"@kubun/db-better-sqlite": "^0.16.0",
|
|
37
|
+
"@kubun/db-node-sqlite": "^0.16.0",
|
|
38
|
+
"@kubun/db-postgres": "^0.16.0",
|
|
39
|
+
"@kumiai/hub-conformance": "^0.9.0",
|
|
40
|
+
"@kumiai/mls": "^0.9.0",
|
|
41
41
|
"@sozai/async": "^0.2.1",
|
|
42
42
|
"@testcontainers/postgresql": "^12.1.0"
|
|
43
43
|
},
|