kubun 0.11.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commands/graph.js +4 -2
- package/lib/commands/hub.d.ts +2 -0
- package/lib/commands/hub.js +105 -0
- package/lib/commands/model.js +4 -2
- package/lib/commands/peer.d.ts +2 -0
- package/lib/commands/peer.js +275 -0
- package/lib/commands/serve.js +24 -54
- package/lib/engine.d.ts +74 -0
- package/lib/engine.js +154 -0
- package/lib/hub.d.ts +38 -0
- package/lib/hub.js +109 -0
- package/lib/identity.d.ts +2 -2
- package/lib/program.js +4 -0
- package/lib/ui.js +7 -1
- package/package.json +43 -34
package/lib/commands/graph.js
CHANGED
|
@@ -9,8 +9,10 @@ function parseVariables(value) {
|
|
|
9
9
|
}
|
|
10
10
|
try {
|
|
11
11
|
return JSON.parse(value);
|
|
12
|
-
} catch
|
|
13
|
-
throw new Error(`--variables is not valid JSON: ${value}
|
|
12
|
+
} catch (cause) {
|
|
13
|
+
throw new Error(`--variables is not valid JSON: ${value}`, {
|
|
14
|
+
cause
|
|
15
|
+
});
|
|
14
16
|
}
|
|
15
17
|
}
|
|
16
18
|
function createMutateCommand() {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { getKubunLogger } from '@kubun/logger';
|
|
2
|
+
import { configureSync, getConsoleSink } from '@logtape/logtape';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { startHub } from '../hub.js';
|
|
5
|
+
import { withAllowedOrigin, withLogLevel, withPort, withPrivateKey } from '../options.js';
|
|
6
|
+
import { renderNotice, withSpinner } from '../ui.js';
|
|
7
|
+
/**
|
|
8
|
+
* Resolve who the relay accepts. Exactly one of the two flags is required: a
|
|
9
|
+
* hub started with neither would run refusing every DID, which looks like a
|
|
10
|
+
* broken hub rather than a missing decision.
|
|
11
|
+
*/ function resolveHubAuth(opts) {
|
|
12
|
+
if (opts.acceptDid != null && opts.acceptAll) {
|
|
13
|
+
throw new Error('--accept-did and --accept-all are mutually exclusive');
|
|
14
|
+
}
|
|
15
|
+
if (opts.acceptDid == null && !opts.acceptAll) {
|
|
16
|
+
throw new Error('one of --accept-did or --accept-all is required');
|
|
17
|
+
}
|
|
18
|
+
if (opts.acceptAll) {
|
|
19
|
+
return {
|
|
20
|
+
mode: 'open'
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const allowedDIDs = (opts.acceptDid ?? '').split(',').map((did)=>did.trim()).filter(Boolean);
|
|
24
|
+
if (allowedDIDs.length === 0) {
|
|
25
|
+
throw new Error('--accept-did lists no DID');
|
|
26
|
+
}
|
|
27
|
+
// A wildcard in the list would match no issuer and quietly deny everyone, so
|
|
28
|
+
// the operator asking for open is sent to the flag that means it.
|
|
29
|
+
if (allowedDIDs.includes('*')) {
|
|
30
|
+
throw new Error('--accept-did takes DIDs, not a wildcard. Use --accept-all to accept any DID.');
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
mode: 'allowlist',
|
|
34
|
+
allowedDIDs
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function createHubCommand() {
|
|
38
|
+
const cmd = new Command('hub').description('Start a blind relay hub for groups this process is not a member of').option('--accept-did <dids>', 'comma-separated DIDs allowed to use the relay').option('--accept-all', 'accept every signed DID (development only)').option('--db <path>', 'path to the hub database');
|
|
39
|
+
withAllowedOrigin(cmd);
|
|
40
|
+
withLogLevel(cmd);
|
|
41
|
+
withPort(cmd);
|
|
42
|
+
withPrivateKey(cmd);
|
|
43
|
+
cmd.action(async (opts)=>{
|
|
44
|
+
const auth = resolveHubAuth(opts);
|
|
45
|
+
configureSync({
|
|
46
|
+
reset: true,
|
|
47
|
+
sinks: {
|
|
48
|
+
console: getConsoleSink()
|
|
49
|
+
},
|
|
50
|
+
loggers: [
|
|
51
|
+
{
|
|
52
|
+
category: [
|
|
53
|
+
'logtape',
|
|
54
|
+
'meta'
|
|
55
|
+
],
|
|
56
|
+
lowestLevel: 'error',
|
|
57
|
+
sinks: [
|
|
58
|
+
'console'
|
|
59
|
+
]
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
category: [
|
|
63
|
+
'kubun'
|
|
64
|
+
],
|
|
65
|
+
lowestLevel: opts.logLevel ?? 'warning',
|
|
66
|
+
sinks: [
|
|
67
|
+
'console'
|
|
68
|
+
]
|
|
69
|
+
}
|
|
70
|
+
]
|
|
71
|
+
});
|
|
72
|
+
if (auth.mode === 'open') {
|
|
73
|
+
const logger = getKubunLogger('cli');
|
|
74
|
+
logger.warn('================================================================');
|
|
75
|
+
logger.warn(' RELAY AUTH IS OPEN — --accept-all');
|
|
76
|
+
logger.warn(' Any signed token from any DID will be accepted.');
|
|
77
|
+
logger.warn(' Never use this setting in production.');
|
|
78
|
+
logger.warn('================================================================');
|
|
79
|
+
}
|
|
80
|
+
const port = opts.port == null ? undefined : typeof opts.port === 'number' ? opts.port : Number.parseInt(opts.port, 10);
|
|
81
|
+
const hub = await withSpinner('Starting the hub...', ()=>startHub({
|
|
82
|
+
auth,
|
|
83
|
+
...opts.db == null ? {} : {
|
|
84
|
+
db: opts.db
|
|
85
|
+
},
|
|
86
|
+
...opts.privateKey == null ? {} : {
|
|
87
|
+
privateKey: opts.privateKey
|
|
88
|
+
},
|
|
89
|
+
...opts.allowedOrigin == null ? {} : {
|
|
90
|
+
allowedOrigin: opts.allowedOrigin
|
|
91
|
+
},
|
|
92
|
+
...port == null ? {} : {
|
|
93
|
+
port
|
|
94
|
+
}
|
|
95
|
+
}));
|
|
96
|
+
const accepted = auth.mode === 'open' ? 'any signed DID' : `${auth.allowedDIDs?.length ?? 0} DID(s)`;
|
|
97
|
+
renderNotice('success', `Hub relay listening at ${hub.url}\n DID: ${hub.did}\n Accepting: ${accepted}`);
|
|
98
|
+
await new Promise((resolve)=>{
|
|
99
|
+
process.once('SIGINT', resolve);
|
|
100
|
+
process.once('SIGTERM', resolve);
|
|
101
|
+
});
|
|
102
|
+
await hub.dispose();
|
|
103
|
+
});
|
|
104
|
+
return cmd;
|
|
105
|
+
}
|
package/lib/commands/model.js
CHANGED
|
@@ -5,8 +5,10 @@ import { renderNotice } from '../ui.js';
|
|
|
5
5
|
function parseJSON(label, value) {
|
|
6
6
|
try {
|
|
7
7
|
return JSON.parse(value);
|
|
8
|
-
} catch
|
|
9
|
-
throw new Error(`${label} is not valid JSON: ${value}
|
|
8
|
+
} catch (cause) {
|
|
9
|
+
throw new Error(`${label} is not valid JSON: ${value}`, {
|
|
10
|
+
cause
|
|
11
|
+
});
|
|
10
12
|
}
|
|
11
13
|
}
|
|
12
14
|
function createClusterCommand() {
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { getKubunLogger } from '@kubun/logger';
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { announceSelf, buildEngine, createAdapter, deployPeerGraph } from '../engine.js';
|
|
4
|
+
import { resolveIdentity } from '../identity.js';
|
|
5
|
+
import { withLogLevel, withPrivateKey } from '../options.js';
|
|
6
|
+
import { renderNotice } from '../ui.js';
|
|
7
|
+
const PREPARE_JOIN_REQUEST = 'mutation { prepareJoinRequest { joinRequest } }';
|
|
8
|
+
const COMPLETE_JOIN = `mutation($invitePayload: String!, $send: ShareInput, $receive: ShareReceiveInput) {
|
|
9
|
+
completeJoin(invitePayload: $invitePayload, send: $send, receive: $receive) { group { id name } }
|
|
10
|
+
}`;
|
|
11
|
+
const CREATE_GROUP = `mutation($name: String!, $url: URL!) {
|
|
12
|
+
createGroup(input: { name: $name, hubs: [{ url: $url }] }) { group { id name } }
|
|
13
|
+
}`;
|
|
14
|
+
const ADMIT = `mutation($groupID: ID!, $joinRequest: String!, $send: ShareInput, $receive: ShareReceiveInput) {
|
|
15
|
+
admitJoinRequest(groupID: $groupID, joinRequest: $joinRequest, send: $send, receive: $receive) {
|
|
16
|
+
peerDID
|
|
17
|
+
invitePayload
|
|
18
|
+
}
|
|
19
|
+
}`;
|
|
20
|
+
const GROUPS = '{ groups { id name } }';
|
|
21
|
+
const PEER_DEVICES = `query($groupID: ID!) {
|
|
22
|
+
peerDevices(groupID: $groupID) { peerDID label availability }
|
|
23
|
+
}`;
|
|
24
|
+
const SYNC_PEER = `mutation($groupID: ID!, $peerDID: ID!) {
|
|
25
|
+
syncPeer(groupID: $groupID, peerDID: $peerDID) {
|
|
26
|
+
messagesSent
|
|
27
|
+
messagesReceived
|
|
28
|
+
divergentBuckets
|
|
29
|
+
}
|
|
30
|
+
}`;
|
|
31
|
+
/** `--send a,b` as the `ShareInput` the admission mutations take, or null for none. */ function shareInput(models) {
|
|
32
|
+
if (models == null) return null;
|
|
33
|
+
const list = models.split(',').map((entry)=>entry.trim()).filter(Boolean);
|
|
34
|
+
return {
|
|
35
|
+
models: list
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run one p2p mutation against a local device and stop.
|
|
40
|
+
*
|
|
41
|
+
* Both halves of the offline exchange are one-shot, but they must land in the
|
|
42
|
+
* SAME database: `peer request` writes the key material `peer join` consumes,
|
|
43
|
+
* so a default in-memory adapter would make the pair silently unusable.
|
|
44
|
+
*/ async function withPeerDevice(opts, run) {
|
|
45
|
+
if (opts.db == null) {
|
|
46
|
+
throw new Error('--db is required: the join exchange spans two commands and must persist');
|
|
47
|
+
}
|
|
48
|
+
const logger = getKubunLogger('cli');
|
|
49
|
+
const { engine } = buildEngine({
|
|
50
|
+
identity: resolveIdentity({
|
|
51
|
+
...opts,
|
|
52
|
+
p2p: true
|
|
53
|
+
}, logger),
|
|
54
|
+
adapter: createAdapter(opts.db),
|
|
55
|
+
p2p: {}
|
|
56
|
+
});
|
|
57
|
+
const execute = async (kind, id, text, variables)=>{
|
|
58
|
+
const params = {
|
|
59
|
+
id,
|
|
60
|
+
text,
|
|
61
|
+
...variables == null ? {} : {
|
|
62
|
+
variables
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const result = kind === 'mutate' ? await engine.mutateGraph(params) : await engine.queryGraph(params);
|
|
66
|
+
if (result.errors != null) {
|
|
67
|
+
throw new Error(result.errors.map((error)=>error.message).join('; '));
|
|
68
|
+
}
|
|
69
|
+
if (result.data == null) {
|
|
70
|
+
throw new Error(`${kind} returned no data`);
|
|
71
|
+
}
|
|
72
|
+
return result.data;
|
|
73
|
+
};
|
|
74
|
+
try {
|
|
75
|
+
const id = await deployPeerGraph(engine);
|
|
76
|
+
return await run({
|
|
77
|
+
mutate: (text, variables)=>execute('mutate', id, text, variables),
|
|
78
|
+
query: (text, variables)=>execute('query', id, text, variables),
|
|
79
|
+
engine,
|
|
80
|
+
hubReady: async ()=>{
|
|
81
|
+
const api = await engine.getAPI('p2p');
|
|
82
|
+
await api.hubReady;
|
|
83
|
+
return api;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
} finally{
|
|
87
|
+
await engine.dispose();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function withPeerOptions(cmd) {
|
|
91
|
+
cmd.option('--db <path>', 'path to the local SQLite database');
|
|
92
|
+
withLogLevel(cmd);
|
|
93
|
+
withPrivateKey(cmd, {
|
|
94
|
+
required: true
|
|
95
|
+
});
|
|
96
|
+
return cmd;
|
|
97
|
+
}
|
|
98
|
+
export function createPeerCommand() {
|
|
99
|
+
const cmd = new Command('peer').description('Pair devices, advertise this one, and sync with them');
|
|
100
|
+
const request = new Command('request').description('Print a join request for another device to admit');
|
|
101
|
+
withPeerOptions(request);
|
|
102
|
+
request.action(async (opts)=>{
|
|
103
|
+
const data = await withPeerDevice(opts, async ({ mutate })=>{
|
|
104
|
+
return await mutate(PREPARE_JOIN_REQUEST);
|
|
105
|
+
});
|
|
106
|
+
// The payload goes to stdout alone so it can be piped; everything else this
|
|
107
|
+
// command says goes to stderr.
|
|
108
|
+
process.stdout.write(`${data.prepareJoinRequest.joinRequest}\n`);
|
|
109
|
+
});
|
|
110
|
+
const join = new Command('join').description('Complete a join from an invite payload');
|
|
111
|
+
join.requiredOption('--invite <payload>', 'invite payload minted by the admitting device');
|
|
112
|
+
// Each device declares its OWN half in the call it already makes. Without
|
|
113
|
+
// `--send` this device grants nothing back, so the admitter can never pull
|
|
114
|
+
// what this one authors and the pair syncs one way while looking bidirectional.
|
|
115
|
+
join.option('--send <models>', 'comma-separated model ids this device shares back');
|
|
116
|
+
join.option('--receive', 'activate the seeded catalog, so this device pulls the share scope');
|
|
117
|
+
withPeerOptions(join);
|
|
118
|
+
join.action(async (opts)=>{
|
|
119
|
+
const data = await withPeerDevice(opts, async ({ mutate })=>{
|
|
120
|
+
return await mutate(COMPLETE_JOIN, {
|
|
121
|
+
invitePayload: opts.invite,
|
|
122
|
+
send: shareInput(opts.send),
|
|
123
|
+
receive: opts.receive === true ? {
|
|
124
|
+
activate: true
|
|
125
|
+
} : null
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
renderNotice('success', `Joined ${data.completeJoin.group.name}\n Group: ${data.completeJoin.group.id}`);
|
|
129
|
+
});
|
|
130
|
+
const createGroup = new Command('create-group').description('Create a group on this device and bind it to a hub');
|
|
131
|
+
createGroup.requiredOption('--name <name>', 'group name');
|
|
132
|
+
createGroup.requiredOption('--hub <url>', 'hub relay URL the group publishes through');
|
|
133
|
+
withPeerOptions(createGroup);
|
|
134
|
+
createGroup.action(async (opts)=>{
|
|
135
|
+
const data = await withPeerDevice(opts, async ({ mutate, hubReady })=>{
|
|
136
|
+
// Before the group's first commit, not after: the creation commit rides
|
|
137
|
+
// the relay, and a one-shot process that dials before its relay exists
|
|
138
|
+
// reports an empty group rather than an unreachable one.
|
|
139
|
+
await hubReady();
|
|
140
|
+
return await mutate(CREATE_GROUP, {
|
|
141
|
+
name: opts.name,
|
|
142
|
+
url: opts.hub
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
renderNotice('success', `Created ${data.createGroup.group.name}\n Group: ${data.createGroup.group.id}`);
|
|
146
|
+
});
|
|
147
|
+
const admit = new Command('admit').description('Admit a join request into a group and print the invite payload');
|
|
148
|
+
admit.requiredOption('--group <id>', 'group to admit into');
|
|
149
|
+
admit.requiredOption('--request <payload>', 'join request printed by `peer request`');
|
|
150
|
+
admit.option('--send <models>', 'comma-separated model ids to share with the admitted device');
|
|
151
|
+
admit.option('--receive', 'activate the created catalog, so this device pulls the share scope');
|
|
152
|
+
withPeerOptions(admit);
|
|
153
|
+
admit.action(async (opts)=>{
|
|
154
|
+
const data = await withPeerDevice(opts, async ({ mutate, hubReady })=>{
|
|
155
|
+
// An admission drives an MLS commit whose fan-out to the group's EXISTING
|
|
156
|
+
// members rides the relay, and `withPeerDevice` disposes the engine the
|
|
157
|
+
// moment this returns. Without the wait, a group with a third member is a
|
|
158
|
+
// plausible silently-dropped commit.
|
|
159
|
+
await hubReady();
|
|
160
|
+
return await mutate(ADMIT, {
|
|
161
|
+
groupID: opts.group,
|
|
162
|
+
joinRequest: opts.request,
|
|
163
|
+
send: shareInput(opts.send),
|
|
164
|
+
receive: opts.receive === true ? {
|
|
165
|
+
activate: true
|
|
166
|
+
} : null
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
process.stderr.write(` Admitted: ${data.admitJoinRequest.peerDID}\n`);
|
|
170
|
+
// The payload alone on stdout, as `peer request` does, so it can be piped.
|
|
171
|
+
process.stdout.write(`${data.admitJoinRequest.invitePayload}\n`);
|
|
172
|
+
});
|
|
173
|
+
const profile = new Command('profile').description('Declare what this device is, so co-members can see it at all');
|
|
174
|
+
profile.requiredOption('--label <label>', 'display name shown to co-members');
|
|
175
|
+
profile.option('--availability <tier>', 'always-on | interactive | mobile (default: always-on)', 'always-on');
|
|
176
|
+
withPeerOptions(profile);
|
|
177
|
+
profile.action(async (opts)=>{
|
|
178
|
+
// Commander enforces `--label`, so this only fires if the option is ever
|
|
179
|
+
// made optional — a cast would go on compiling and announce `undefined`.
|
|
180
|
+
const label = opts.label;
|
|
181
|
+
if (label == null) {
|
|
182
|
+
throw new Error('--label is required');
|
|
183
|
+
}
|
|
184
|
+
const availability = opts.availability ?? 'always-on';
|
|
185
|
+
if (availability !== 'always-on' && availability !== 'interactive' && availability !== 'mobile') {
|
|
186
|
+
throw new Error(`--availability must be always-on, interactive or mobile, got ${availability}`);
|
|
187
|
+
}
|
|
188
|
+
await withPeerDevice(opts, async ({ engine, hubReady })=>{
|
|
189
|
+
// The announce publishes on each joined group's lane, so the relay has to
|
|
190
|
+
// be up first or this device advertises only to its own store.
|
|
191
|
+
await hubReady();
|
|
192
|
+
await announceSelf(engine, {
|
|
193
|
+
label,
|
|
194
|
+
availability
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
renderNotice('success', `Announced as ${label} (${availability})`);
|
|
198
|
+
});
|
|
199
|
+
const list = new Command('list').description("This group's other devices, and who answered");
|
|
200
|
+
list.option('--group <id>', 'group to list (default: every joined group)');
|
|
201
|
+
withPeerOptions(list);
|
|
202
|
+
list.action(async (opts)=>{
|
|
203
|
+
const rows = await withPeerDevice(opts, async ({ query, engine, hubReady })=>{
|
|
204
|
+
const api = await hubReady();
|
|
205
|
+
// Every announce seeds this device's OWN row, and a device never answers
|
|
206
|
+
// its own gather — so leaving it in prints this machine as `silent`, which
|
|
207
|
+
// is both useless and wrong.
|
|
208
|
+
const selfDID = engine.identity.id;
|
|
209
|
+
// Named from the store even when `--group` picks one, so the column reads
|
|
210
|
+
// as a name in both modes rather than as an id in one of them.
|
|
211
|
+
const joined = (await query(GROUPS)).groups;
|
|
212
|
+
const groups = opts.group == null ? joined : joined.filter((group)=>group.id === opts.group);
|
|
213
|
+
if (opts.group != null && groups.length === 0) {
|
|
214
|
+
throw new Error(`this device has not joined group ${opts.group}`);
|
|
215
|
+
}
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const group of groups){
|
|
218
|
+
// Announce then gather, as the app's device list does: a device that has
|
|
219
|
+
// gone quiet should hear this one is here in the same round trip it is
|
|
220
|
+
// asked to answer.
|
|
221
|
+
const answered = await api.refreshPeerPresence(group.id);
|
|
222
|
+
const live = new Set(answered.map((peer)=>peer.peerDID));
|
|
223
|
+
const devices = await query(PEER_DEVICES, {
|
|
224
|
+
groupID: group.id
|
|
225
|
+
});
|
|
226
|
+
for (const device of devices.peerDevices){
|
|
227
|
+
if (device.peerDID === selfDID) continue;
|
|
228
|
+
out.push({
|
|
229
|
+
group: group.name,
|
|
230
|
+
did: device.peerDID,
|
|
231
|
+
label: `${device.label} (${device.availability})`,
|
|
232
|
+
live: live.has(device.peerDID)
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return out;
|
|
237
|
+
});
|
|
238
|
+
if (rows.length === 0) {
|
|
239
|
+
// Never "no devices": a device that has not announced is invisible here,
|
|
240
|
+
// which is a different fact from not existing.
|
|
241
|
+
renderNotice('info', 'No devices have answered yet');
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
for (const row of rows){
|
|
245
|
+
// "answered" and "silent", never "online"/"offline": the gather reports who
|
|
246
|
+
// replied inside its window, and asleep is indistinguishable from gone.
|
|
247
|
+
process.stdout.write(`${row.live ? 'answered' : 'silent '} ${row.label} ${row.did} [${row.group}]\n`);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
const sync = new Command('sync').description('Catch up with one named device now');
|
|
251
|
+
sync.argument('<peerDID>', 'the device to sync with');
|
|
252
|
+
sync.requiredOption('--group <id>', 'group whose hub tunnel routes the session');
|
|
253
|
+
withPeerOptions(sync);
|
|
254
|
+
sync.action(async (peerDID, opts)=>{
|
|
255
|
+
const data = await withPeerDevice(opts, async ({ mutate, hubReady })=>{
|
|
256
|
+
await hubReady();
|
|
257
|
+
return await mutate(SYNC_PEER, {
|
|
258
|
+
groupID: opts.group,
|
|
259
|
+
peerDID
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
const synced = data.syncPeer;
|
|
263
|
+
// All-zero is a real outcome, not a failure: a device with no ACTIVE catalog
|
|
264
|
+
// resolving to a concrete owner short-circuits before choosing a direction.
|
|
265
|
+
renderNotice('success', `Synced with ${peerDID}\n Messages: ${synced.messagesReceived} in, ${synced.messagesSent} out\n Divergent buckets: ${synced.divergentBuckets}`);
|
|
266
|
+
});
|
|
267
|
+
cmd.addCommand(request);
|
|
268
|
+
cmd.addCommand(join);
|
|
269
|
+
cmd.addCommand(createGroup);
|
|
270
|
+
cmd.addCommand(admit);
|
|
271
|
+
cmd.addCommand(profile);
|
|
272
|
+
cmd.addCommand(list);
|
|
273
|
+
cmd.addCommand(sync);
|
|
274
|
+
return cmd;
|
|
275
|
+
}
|
package/lib/commands/serve.js
CHANGED
|
@@ -1,18 +1,12 @@
|
|
|
1
|
-
import { NodeSQLiteAdapter } from '@kubun/db-node-sqlite';
|
|
2
|
-
import { PostgresAdapter } from '@kubun/db-postgres';
|
|
3
|
-
import { KubunEngine } from '@kubun/engine';
|
|
4
1
|
import { getKubunLogger } from '@kubun/logger';
|
|
5
|
-
import { createHTTPPlugin } from '@kubun/plugin-http';
|
|
6
|
-
import { createP2PPlugin } from '@kubun/plugin-p2p';
|
|
7
|
-
import { createRPCPlugin } from '@kubun/plugin-rpc';
|
|
8
2
|
import { configureSync, getConsoleSink } from '@logtape/logtape';
|
|
9
3
|
import { Command } from 'commander';
|
|
10
|
-
import {
|
|
4
|
+
import { createAdapter, startNode } from '../engine.js';
|
|
11
5
|
import { resolveIdentity } from '../identity.js';
|
|
12
6
|
import { withAllowedOrigin, withLogLevel, withPort, withPrivateKey } from '../options.js';
|
|
13
7
|
import { renderNotice, withSpinner } from '../ui.js';
|
|
14
8
|
export function createServeCommand() {
|
|
15
|
-
const cmd = new Command('serve').description('Start a local Kubun server').option('--auto-accept-peers <dids>', 'comma-separated DIDs to auto-accept for peer join flows').option('--db <path>', 'path to the local SQLite database').option('--id <id>', 'server ID').option('--p2p', 'enable P2P mode with sync and graph protocols
|
|
9
|
+
const cmd = new Command('serve').description('Start a local Kubun server').option('--auto-accept-peers <dids>', 'comma-separated DIDs to auto-accept for peer join flows').option('--db <path>', 'path to the local SQLite database').option('--id <id>', 'server ID').option('--label <label>', 'display name advertised to co-members (default: the host name)').option('--no-http', 'run as a hub-only peer, binding no HTTP listener (requires --p2p)').option('--p2p', 'enable P2P mode with sync and graph protocols');
|
|
16
10
|
withAllowedOrigin(cmd);
|
|
17
11
|
withLogLevel(cmd);
|
|
18
12
|
withPort(cmd);
|
|
@@ -24,6 +18,9 @@ export function createServeCommand() {
|
|
|
24
18
|
if (opts.autoAcceptPeers != null && !opts.p2p) {
|
|
25
19
|
throw new Error('--auto-accept-peers requires --p2p');
|
|
26
20
|
}
|
|
21
|
+
if (!opts.http && !opts.p2p) {
|
|
22
|
+
throw new Error('--no-http requires --p2p: without either there is nothing to reach');
|
|
23
|
+
}
|
|
27
24
|
configureSync({
|
|
28
25
|
reset: true,
|
|
29
26
|
sinks: {
|
|
@@ -53,56 +50,29 @@ export function createServeCommand() {
|
|
|
53
50
|
});
|
|
54
51
|
const logger = getKubunLogger('cli');
|
|
55
52
|
const identity = resolveIdentity(opts, logger);
|
|
56
|
-
const adapter = opts.db == null || opts.db === ':memory:' ? new NodeSQLiteAdapter({
|
|
57
|
-
database: ':memory:'
|
|
58
|
-
}) : opts.db.startsWith('postgres://') || opts.db.startsWith('postgresql://') ? new PostgresAdapter({
|
|
59
|
-
url: opts.db
|
|
60
|
-
}) : new NodeSQLiteAdapter({
|
|
61
|
-
database: resolvePath(opts.db)
|
|
62
|
-
});
|
|
63
53
|
const autoAcceptPeers = opts.autoAcceptPeers ? opts.autoAcceptPeers.split(',').map((s)=>s.trim()) : undefined;
|
|
64
|
-
const rpcAccessRules = autoAcceptPeers != null ? {
|
|
65
|
-
'*': {
|
|
66
|
-
allow: [
|
|
67
|
-
identity.id,
|
|
68
|
-
...autoAcceptPeers
|
|
69
|
-
]
|
|
70
|
-
}
|
|
71
|
-
} : undefined;
|
|
72
54
|
const port = opts.port == null ? undefined : typeof opts.port === 'number' ? opts.port : Number.parseInt(opts.port, 10);
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
55
|
+
const { engine, url } = await withSpinner('Starting the server...', async ()=>{
|
|
56
|
+
return await startNode({
|
|
57
|
+
identity,
|
|
58
|
+
adapter: createAdapter(opts.db),
|
|
59
|
+
...opts.label != null ? {
|
|
60
|
+
label: opts.label
|
|
77
61
|
} : undefined,
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
})
|
|
90
|
-
}
|
|
91
|
-
const engine = new KubunEngine({
|
|
92
|
-
db: adapter,
|
|
93
|
-
identity,
|
|
94
|
-
plugins
|
|
95
|
-
});
|
|
96
|
-
const http = await withSpinner('Starting the server...', async ()=>{
|
|
97
|
-
const api = await engine.getAPI('http');
|
|
98
|
-
await api.listening;
|
|
99
|
-
if (opts.p2p) {
|
|
100
|
-
const p2p = await engine.getAPI('p2p');
|
|
101
|
-
await p2p.syncReady;
|
|
102
|
-
}
|
|
103
|
-
return api;
|
|
62
|
+
...opts.http ? {
|
|
63
|
+
http: {
|
|
64
|
+
port,
|
|
65
|
+
allowedOrigin: opts.allowedOrigin
|
|
66
|
+
}
|
|
67
|
+
} : undefined,
|
|
68
|
+
...opts.p2p ? {
|
|
69
|
+
p2p: autoAcceptPeers == null ? {} : {
|
|
70
|
+
autoAcceptPeers
|
|
71
|
+
}
|
|
72
|
+
} : undefined
|
|
73
|
+
});
|
|
104
74
|
});
|
|
105
|
-
renderNotice('success', `HTTP server listening at ${
|
|
75
|
+
renderNotice('success', url == null ? `Hub-only peer running, no HTTP listener\n DID: ${identity.id}` : `HTTP server listening at ${url}\n DID: ${identity.id}`);
|
|
106
76
|
if (opts.p2p && autoAcceptPeers) {
|
|
107
77
|
process.stderr.write(` Auto-accept peers: ${autoAcceptPeers.length} DID(s)\n`);
|
|
108
78
|
}
|
package/lib/engine.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Identity, OwnIdentity } from '@kokuin/token';
|
|
2
|
+
import { KubunDB } from '@kubun/db';
|
|
3
|
+
import { NodeSQLiteAdapter } from '@kubun/db-node-sqlite';
|
|
4
|
+
import { PostgresAdapter } from '@kubun/db-postgres';
|
|
5
|
+
import { KubunEngine } from '@kubun/engine';
|
|
6
|
+
/**
|
|
7
|
+
* The graph a peer device serves its own p2p surface from.
|
|
8
|
+
*
|
|
9
|
+
* Deployed with no clusters: a headless peer learns a model from whichever
|
|
10
|
+
* device already has it (the negotiate response carries the cluster), so
|
|
11
|
+
* requiring the operator to supply model definitions up front would be asking
|
|
12
|
+
* for something sync provides.
|
|
13
|
+
*/
|
|
14
|
+
export declare const PEER_GRAPH_ID = "kubun-peer";
|
|
15
|
+
/** The adapters a CLI device can run on, kept concrete so `@kubun/db-adapter` stays out of the manifest. */
|
|
16
|
+
export type PeerAdapter = NodeSQLiteAdapter | PostgresAdapter;
|
|
17
|
+
export declare function createAdapter(db?: string): PeerAdapter;
|
|
18
|
+
export type BuildEngineParams = {
|
|
19
|
+
identity: Identity | OwnIdentity;
|
|
20
|
+
adapter: PeerAdapter;
|
|
21
|
+
/** Omit to bind no HTTP listener at all — the hub-only participant. */
|
|
22
|
+
http?: {
|
|
23
|
+
port?: number;
|
|
24
|
+
allowedOrigin?: string;
|
|
25
|
+
};
|
|
26
|
+
p2p?: {
|
|
27
|
+
autoAcceptPeers?: Array<string>;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The engine every command builds, with or without an HTTP listener.
|
|
32
|
+
*
|
|
33
|
+
* With `http` omitted the p2p plugin also drops its HTTP sync transport, so the
|
|
34
|
+
* device is reachable only through a group's hub tunnel. That is the whole
|
|
35
|
+
* difference between the two topologies: hub relay and tunnel dialling are
|
|
36
|
+
* always wired, and the hub client factory defaults to HTTP on its own.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildEngine(params: BuildEngineParams): {
|
|
39
|
+
engine: KubunEngine;
|
|
40
|
+
db: KubunDB;
|
|
41
|
+
};
|
|
42
|
+
/** Deploy the peer graph, which is what carries the p2p mutations. */
|
|
43
|
+
export declare function deployPeerGraph(engine: KubunEngine): Promise<string>;
|
|
44
|
+
/**
|
|
45
|
+
* Advertise this device to its co-members.
|
|
46
|
+
*
|
|
47
|
+
* Presence is opt-in — a device with no local profile publishes nothing and
|
|
48
|
+
* never appears in another device's projection, so without this call a headless
|
|
49
|
+
* peer is running and unselectable.
|
|
50
|
+
*/
|
|
51
|
+
export declare function announceSelf(engine: KubunEngine, params: {
|
|
52
|
+
label: string;
|
|
53
|
+
availability: 'always-on' | 'interactive' | 'mobile';
|
|
54
|
+
}): Promise<void>;
|
|
55
|
+
export type StartNodeParams = BuildEngineParams & {
|
|
56
|
+
label?: string;
|
|
57
|
+
};
|
|
58
|
+
export type StartedNode = {
|
|
59
|
+
engine: KubunEngine;
|
|
60
|
+
/** The device's stores, so a caller can read what the engine wrote. */
|
|
61
|
+
db: KubunDB;
|
|
62
|
+
/** The peer graph's id, or null when p2p is off and none was deployed. */
|
|
63
|
+
deployID: string | null;
|
|
64
|
+
/** Null for a hub-only peer, which binds no listener. */
|
|
65
|
+
url: string | null;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* Bring a device up: the engine, its peer graph, and its advertisement.
|
|
69
|
+
*
|
|
70
|
+
* `serve` is a thin wrapper over this so a test can start the same device
|
|
71
|
+
* without a process to signal — the alternative is a test that re-types the
|
|
72
|
+
* startup and then agrees with itself.
|
|
73
|
+
*/
|
|
74
|
+
export declare function startNode(params: StartNodeParams): Promise<StartedNode>;
|
package/lib/engine.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { KubunDB } from '@kubun/db';
|
|
3
|
+
import { NodeSQLiteAdapter } from '@kubun/db-node-sqlite';
|
|
4
|
+
import { PostgresAdapter } from '@kubun/db-postgres';
|
|
5
|
+
import { KubunEngine } from '@kubun/engine';
|
|
6
|
+
import { createHTTPPlugin } from '@kubun/plugin-http';
|
|
7
|
+
import { createP2PPlugin, MERKLE_SYNC_PROTOCOL } from '@kubun/plugin-p2p';
|
|
8
|
+
import { createRPCPlugin } from '@kubun/plugin-rpc';
|
|
9
|
+
import { resolvePath } from './fs.js';
|
|
10
|
+
/**
|
|
11
|
+
* The graph a peer device serves its own p2p surface from.
|
|
12
|
+
*
|
|
13
|
+
* Deployed with no clusters: a headless peer learns a model from whichever
|
|
14
|
+
* device already has it (the negotiate response carries the cluster), so
|
|
15
|
+
* requiring the operator to supply model definitions up front would be asking
|
|
16
|
+
* for something sync provides.
|
|
17
|
+
*/ export const PEER_GRAPH_ID = 'kubun-peer';
|
|
18
|
+
export function createAdapter(db) {
|
|
19
|
+
if (db == null || db === ':memory:') {
|
|
20
|
+
return new NodeSQLiteAdapter({
|
|
21
|
+
database: ':memory:'
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
if (db.startsWith('postgres://') || db.startsWith('postgresql://')) {
|
|
25
|
+
return new PostgresAdapter({
|
|
26
|
+
url: db
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return new NodeSQLiteAdapter({
|
|
30
|
+
database: resolvePath(db)
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The engine every command builds, with or without an HTTP listener.
|
|
35
|
+
*
|
|
36
|
+
* With `http` omitted the p2p plugin also drops its HTTP sync transport, so the
|
|
37
|
+
* device is reachable only through a group's hub tunnel. That is the whole
|
|
38
|
+
* difference between the two topologies: hub relay and tunnel dialling are
|
|
39
|
+
* always wired, and the hub client factory defaults to HTTP on its own.
|
|
40
|
+
*/ export function buildEngine(params) {
|
|
41
|
+
const { adapter, identity, http, p2p } = params;
|
|
42
|
+
const plugins = [
|
|
43
|
+
createRPCPlugin({
|
|
44
|
+
...p2p?.autoAcceptPeers != null ? {
|
|
45
|
+
accessRules: {
|
|
46
|
+
'*': {
|
|
47
|
+
allow: [
|
|
48
|
+
identity.id,
|
|
49
|
+
...p2p.autoAcceptPeers
|
|
50
|
+
]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} : undefined,
|
|
54
|
+
allowDelegatedMutations: p2p != null
|
|
55
|
+
})
|
|
56
|
+
];
|
|
57
|
+
if (http != null) {
|
|
58
|
+
plugins.push(createHTTPPlugin({
|
|
59
|
+
port: http.port,
|
|
60
|
+
allowedOrigin: http.allowedOrigin
|
|
61
|
+
}));
|
|
62
|
+
}
|
|
63
|
+
if (p2p != null) {
|
|
64
|
+
plugins.push(createP2PPlugin({
|
|
65
|
+
...http != null ? {
|
|
66
|
+
http: true
|
|
67
|
+
} : undefined,
|
|
68
|
+
...p2p.autoAcceptPeers != null ? {
|
|
69
|
+
autoAcceptPeers: p2p.autoAcceptPeers
|
|
70
|
+
} : undefined
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
const db = new KubunDB({
|
|
74
|
+
adapter
|
|
75
|
+
});
|
|
76
|
+
return {
|
|
77
|
+
engine: new KubunEngine({
|
|
78
|
+
db,
|
|
79
|
+
identity,
|
|
80
|
+
plugins
|
|
81
|
+
}),
|
|
82
|
+
db
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** Deploy the peer graph, which is what carries the p2p mutations. */ export async function deployPeerGraph(engine) {
|
|
86
|
+
const deployed = await engine.deployGraph({
|
|
87
|
+
id: PEER_GRAPH_ID,
|
|
88
|
+
name: 'Kubun peer',
|
|
89
|
+
clusters: [],
|
|
90
|
+
plugins: {
|
|
91
|
+
p2p: {}
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
return deployed.id;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Advertise this device to its co-members.
|
|
98
|
+
*
|
|
99
|
+
* Presence is opt-in — a device with no local profile publishes nothing and
|
|
100
|
+
* never appears in another device's projection, so without this call a headless
|
|
101
|
+
* peer is running and unselectable.
|
|
102
|
+
*/ export async function announceSelf(engine, params) {
|
|
103
|
+
const p2p = await engine.getAPI('p2p');
|
|
104
|
+
await p2p.setLocalPeerProfile({
|
|
105
|
+
label: params.label,
|
|
106
|
+
availability: params.availability,
|
|
107
|
+
capabilities: [
|
|
108
|
+
{
|
|
109
|
+
protocol: MERKLE_SYNC_PROTOCOL,
|
|
110
|
+
version: 1,
|
|
111
|
+
transports: null
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Bring a device up: the engine, its peer graph, and its advertisement.
|
|
118
|
+
*
|
|
119
|
+
* `serve` is a thin wrapper over this so a test can start the same device
|
|
120
|
+
* without a process to signal — the alternative is a test that re-types the
|
|
121
|
+
* startup and then agrees with itself.
|
|
122
|
+
*/ export async function startNode(params) {
|
|
123
|
+
const { engine, db } = buildEngine(params);
|
|
124
|
+
let deployID = null;
|
|
125
|
+
if (params.p2p != null) {
|
|
126
|
+
const p2p = await engine.getAPI('p2p');
|
|
127
|
+
await p2p.syncReady;
|
|
128
|
+
// The p2p mutations live on a deployed graph, and a hub-only peer has no
|
|
129
|
+
// client to deploy one for it.
|
|
130
|
+
deployID = await deployPeerGraph(engine);
|
|
131
|
+
await announceSelf(engine, {
|
|
132
|
+
label: params.label ?? hostname(),
|
|
133
|
+
// A process meant to be left running is exactly what selection should
|
|
134
|
+
// prefer, and this is the only place that claim can be made honestly.
|
|
135
|
+
availability: params.http == null ? 'always-on' : 'interactive'
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (params.http == null) {
|
|
139
|
+
return {
|
|
140
|
+
engine,
|
|
141
|
+
db,
|
|
142
|
+
deployID,
|
|
143
|
+
url: null
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const api = await engine.getAPI('http');
|
|
147
|
+
await api.listening;
|
|
148
|
+
return {
|
|
149
|
+
engine,
|
|
150
|
+
db,
|
|
151
|
+
deployID,
|
|
152
|
+
url: api.getURL()
|
|
153
|
+
};
|
|
154
|
+
}
|
package/lib/hub.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type HubAuthConfig, type Relay, type RelayObservers } from '@kubun/hub';
|
|
2
|
+
export type StartHubParams = {
|
|
3
|
+
/**
|
|
4
|
+
* Who may use the relay. Required: an unset `auth` would leave `createRelay`
|
|
5
|
+
* with no access rules at all, which refuses every DID without saying so.
|
|
6
|
+
*/
|
|
7
|
+
auth: HubAuthConfig;
|
|
8
|
+
/** SQLite path, `:memory:`, or a postgres URL. Defaults to `:memory:`. */
|
|
9
|
+
db?: string;
|
|
10
|
+
/**
|
|
11
|
+
* Base64 private key for the hub's signing identity. Absent generates an
|
|
12
|
+
* ephemeral one — every client holding the old DID then has to rediscover it
|
|
13
|
+
* from `/info`, which is why `resolveIdentity` warns about it.
|
|
14
|
+
*/
|
|
15
|
+
privateKey?: string;
|
|
16
|
+
allowedOrigin?: string;
|
|
17
|
+
/** Bind port. Absent probes for a free one, preferring {@link DEFAULT_HUB_PORT}. */
|
|
18
|
+
port?: number;
|
|
19
|
+
/** Relay diagnostics — auth refusals, stores, fetches. */
|
|
20
|
+
observers?: RelayObservers;
|
|
21
|
+
};
|
|
22
|
+
export type StartedHub = {
|
|
23
|
+
/** The relay endpoint a device binds a group to. */
|
|
24
|
+
url: string;
|
|
25
|
+
/** The hub's DID — the audience of every token it accepts, served at `/info`. */
|
|
26
|
+
did: string;
|
|
27
|
+
relay: Relay;
|
|
28
|
+
dispose: () => Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Serve a blind relay hub over HTTP.
|
|
32
|
+
*
|
|
33
|
+
* The hub holds no group state and is a member of nothing: it stores encrypted
|
|
34
|
+
* envelopes and serves topic subscriptions for groups it cannot read. Its only
|
|
35
|
+
* authentication is `auth`, checked against the issuer of each signed Enkaku
|
|
36
|
+
* envelope — there is nothing to check at the HTTP layer.
|
|
37
|
+
*/
|
|
38
|
+
export declare function startHub(params: StartHubParams): Promise<StartedHub>;
|
package/lib/hub.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { serve } from '@hono/node-server';
|
|
2
|
+
import { NodeSQLiteAdapter } from '@kubun/db-node-sqlite';
|
|
3
|
+
import { PostgresAdapter } from '@kubun/db-postgres';
|
|
4
|
+
import { createRelay } from '@kubun/hub';
|
|
5
|
+
import { getKubunLogger } from '@kubun/logger';
|
|
6
|
+
import { getPort } from '@tejika/env';
|
|
7
|
+
import { resolvePath } from './fs.js';
|
|
8
|
+
import { resolveIdentity } from './identity.js';
|
|
9
|
+
/**
|
|
10
|
+
* Where the relay's Enkaku transport is mounted. The relay is path-agnostic —
|
|
11
|
+
* it is one transport behind a `fetch` — so this prefix is ours to pick, and it
|
|
12
|
+
* is the URL a device binds a group to.
|
|
13
|
+
*/ const RELAY_PATH = '/relay';
|
|
14
|
+
/**
|
|
15
|
+
* The name `/info` advertises the relay mount under, mirroring how a device's
|
|
16
|
+
* HTTP surface advertises its own protocols. A device binds the mount path
|
|
17
|
+
* directly, so this is discovery for a reader that has only the origin.
|
|
18
|
+
*/ const RELAY_PROTOCOL_NAME = 'relay';
|
|
19
|
+
/** Preferred port when none is given; a taken one yields another free port. */ const DEFAULT_HUB_PORT = 4321;
|
|
20
|
+
/**
|
|
21
|
+
* Serve a blind relay hub over HTTP.
|
|
22
|
+
*
|
|
23
|
+
* The hub holds no group state and is a member of nothing: it stores encrypted
|
|
24
|
+
* envelopes and serves topic subscriptions for groups it cannot read. Its only
|
|
25
|
+
* authentication is `auth`, checked against the issuer of each signed Enkaku
|
|
26
|
+
* envelope — there is nothing to check at the HTTP layer.
|
|
27
|
+
*/ export async function startHub(params) {
|
|
28
|
+
const logger = getKubunLogger('cli');
|
|
29
|
+
// No `--id` counterpart here: the relay signs its own response tokens, so an
|
|
30
|
+
// identity that cannot sign is not a hub identity.
|
|
31
|
+
const identity = resolveIdentity({
|
|
32
|
+
privateKey: params.privateKey
|
|
33
|
+
}, logger);
|
|
34
|
+
const adapter = params.db == null || params.db === ':memory:' ? new NodeSQLiteAdapter({
|
|
35
|
+
database: ':memory:'
|
|
36
|
+
}) : params.db.startsWith('postgres://') || params.db.startsWith('postgresql://') ? new PostgresAdapter({
|
|
37
|
+
url: params.db
|
|
38
|
+
}) : new NodeSQLiteAdapter({
|
|
39
|
+
database: resolvePath(params.db)
|
|
40
|
+
});
|
|
41
|
+
const relay = await createRelay({
|
|
42
|
+
db: adapter,
|
|
43
|
+
identity,
|
|
44
|
+
auth: params.auth,
|
|
45
|
+
retention: {
|
|
46
|
+
olderThanSeconds: 30 * 24 * 60 * 60
|
|
47
|
+
},
|
|
48
|
+
...params.allowedOrigin == null ? {} : {
|
|
49
|
+
allowedOrigin: params.allowedOrigin
|
|
50
|
+
},
|
|
51
|
+
...params.observers == null ? {} : {
|
|
52
|
+
observers: params.observers
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
// OPTIONS is served alongside POST: the relay answers the CORS preflight
|
|
56
|
+
// itself, and a POST-only mount leaves a browser client unable to reach it.
|
|
57
|
+
const handler = async (request)=>{
|
|
58
|
+
const { pathname } = new URL(request.url);
|
|
59
|
+
if (pathname === RELAY_PATH || pathname.startsWith(`${RELAY_PATH}/`)) {
|
|
60
|
+
return request.method === 'POST' || request.method === 'OPTIONS' ? await relay.fetch(request) : new Response(null, {
|
|
61
|
+
status: 405
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
if (request.method === 'GET' && pathname === '/health') {
|
|
65
|
+
return Response.json({
|
|
66
|
+
status: 'ok'
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
// Discovery, in the shape a device already reads from another device's
|
|
70
|
+
// `/info`: the DID every token addressed to this hub must name, plus where
|
|
71
|
+
// the relay is mounted. A hub advertising a DID it cannot sign with is
|
|
72
|
+
// simply unreachable — its own server refuses the audience — so nothing
|
|
73
|
+
// here has to be verified by the reader.
|
|
74
|
+
if (request.method === 'GET' && pathname === '/info') {
|
|
75
|
+
return Response.json({
|
|
76
|
+
did: identity.id,
|
|
77
|
+
protocols: {
|
|
78
|
+
[RELAY_PROTOCOL_NAME]: RELAY_PATH
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return new Response(null, {
|
|
83
|
+
status: 404
|
|
84
|
+
});
|
|
85
|
+
};
|
|
86
|
+
const port = params.port ?? await getPort('kubun', {
|
|
87
|
+
default: DEFAULT_HUB_PORT
|
|
88
|
+
});
|
|
89
|
+
const httpServer = await new Promise((resolve)=>{
|
|
90
|
+
const server = serve({
|
|
91
|
+
fetch: handler,
|
|
92
|
+
port
|
|
93
|
+
}, ()=>resolve(server));
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
url: `http://localhost:${port}${RELAY_PATH}`,
|
|
97
|
+
did: identity.id,
|
|
98
|
+
relay,
|
|
99
|
+
dispose: async ()=>{
|
|
100
|
+
await new Promise((resolve, reject)=>{
|
|
101
|
+
httpServer.close((error)=>{
|
|
102
|
+
if (error == null) resolve();
|
|
103
|
+
else reject(error);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
await relay.dispose();
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
package/lib/identity.d.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type { Identity, OwnIdentity } from '@kokuin/token';
|
|
1
|
+
import type { DIDString, Identity, OwnIdentity } from '@kokuin/token';
|
|
2
2
|
type Logger = {
|
|
3
3
|
warn: (message: string, properties?: Record<string, unknown>) => void;
|
|
4
4
|
};
|
|
5
5
|
export type IdentityOptions = {
|
|
6
6
|
privateKey?: string;
|
|
7
|
-
id?:
|
|
7
|
+
id?: DIDString;
|
|
8
8
|
p2p?: boolean;
|
|
9
9
|
};
|
|
10
10
|
/**
|
package/lib/program.js
CHANGED
|
@@ -5,8 +5,10 @@ import { buildProgram as tejikaBuildProgram } from '@tejika/cli';
|
|
|
5
5
|
import { createAccountCommand } from './commands/account.js';
|
|
6
6
|
import { createGraphCommand } from './commands/graph.js';
|
|
7
7
|
import { createGraphQLCommand } from './commands/graphql.js';
|
|
8
|
+
import { createHubCommand } from './commands/hub.js';
|
|
8
9
|
import { createMCPCommand } from './commands/mcp.js';
|
|
9
10
|
import { createModelCommand } from './commands/model.js';
|
|
11
|
+
import { createPeerCommand } from './commands/peer.js';
|
|
10
12
|
import { createServeCommand } from './commands/serve.js';
|
|
11
13
|
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '../package.json');
|
|
12
14
|
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
@@ -18,8 +20,10 @@ export function buildProgram() {
|
|
|
18
20
|
createAccountCommand(),
|
|
19
21
|
createGraphCommand(),
|
|
20
22
|
createGraphQLCommand(),
|
|
23
|
+
createHubCommand(),
|
|
21
24
|
createModelCommand(),
|
|
22
25
|
createMCPCommand(),
|
|
26
|
+
createPeerCommand(),
|
|
23
27
|
createServeCommand()
|
|
24
28
|
]
|
|
25
29
|
});
|
package/lib/ui.js
CHANGED
|
@@ -8,7 +8,13 @@ const renderToStderr = {
|
|
|
8
8
|
const { unmount } = render(/*#__PURE__*/ _jsx(SystemNotice, {
|
|
9
9
|
variant: variant,
|
|
10
10
|
text: text
|
|
11
|
-
}),
|
|
11
|
+
}), {
|
|
12
|
+
...renderToStderr,
|
|
13
|
+
// Nothing to protect from interleaved logs: this mounts and unmounts in one
|
|
14
|
+
// turn. Patching the global console for that costs a process-wide side
|
|
15
|
+
// effect, and throws outright wherever `console` is already a stand-in.
|
|
16
|
+
patchConsole: false
|
|
17
|
+
});
|
|
12
18
|
unmount();
|
|
13
19
|
}
|
|
14
20
|
/** Show a live spinner on stderr for the duration of `fn`, then unmount. */ export async function withSpinner(label, fn) {
|
package/package.json
CHANGED
|
@@ -1,59 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kubun",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"license": "see LICENSE.md",
|
|
3
|
+
"version": "0.12.1",
|
|
5
4
|
"description": "Kubun CLI",
|
|
6
5
|
"keywords": [],
|
|
6
|
+
"license": "see LICENSE.md",
|
|
7
7
|
"type": "module",
|
|
8
|
-
"engines": {
|
|
9
|
-
"node": ">=22.0.0"
|
|
10
|
-
},
|
|
11
8
|
"main": "lib/index.js",
|
|
12
9
|
"types": "lib/index.d.ts",
|
|
10
|
+
"bin": {
|
|
11
|
+
"kubun": "./bin/run.js"
|
|
12
|
+
},
|
|
13
13
|
"files": [
|
|
14
14
|
"/bin",
|
|
15
15
|
"/lib"
|
|
16
16
|
],
|
|
17
|
-
"bin": {
|
|
18
|
-
"kubun": "./bin/run.js"
|
|
19
|
-
},
|
|
20
17
|
"dependencies": {
|
|
21
|
-
"@
|
|
22
|
-
"@
|
|
23
|
-
"@
|
|
24
|
-
"@
|
|
25
|
-
"@
|
|
26
|
-
"@tejika/
|
|
27
|
-
"@tejika/
|
|
18
|
+
"@hono/node-server": "^2.1.0",
|
|
19
|
+
"@kokuin/token": "^0.4.0",
|
|
20
|
+
"@logtape/logtape": "^2.3.0",
|
|
21
|
+
"@mokei/context-server": "^0.12.0",
|
|
22
|
+
"@sozai/codec": "^0.4.0",
|
|
23
|
+
"@tejika/cli": "^0.4.1",
|
|
24
|
+
"@tejika/env": "^0.5.0",
|
|
25
|
+
"@tejika/ui": "^0.4.1",
|
|
28
26
|
"commander": "^15.0.0",
|
|
29
27
|
"graphql": "^16.14.2",
|
|
30
|
-
"ink": "^7.1.
|
|
28
|
+
"ink": "^7.1.1",
|
|
31
29
|
"react": "19.2.3",
|
|
32
|
-
"@kubun/client": "^0.
|
|
33
|
-
"@kubun/
|
|
34
|
-
"@kubun/db
|
|
35
|
-
"@kubun/
|
|
36
|
-
"@kubun/
|
|
37
|
-
"@kubun/graphql": "^0.
|
|
38
|
-
"@kubun/
|
|
39
|
-
"@kubun/logger": "^0.
|
|
40
|
-
"@kubun/
|
|
41
|
-
"@kubun/plugin-
|
|
42
|
-
"@kubun/
|
|
43
|
-
"@kubun/
|
|
30
|
+
"@kubun/client": "^0.12.1",
|
|
31
|
+
"@kubun/db-postgres": "^0.12.1",
|
|
32
|
+
"@kubun/db": "^0.12.1",
|
|
33
|
+
"@kubun/db-node-sqlite": "^0.12.1",
|
|
34
|
+
"@kubun/engine": "^0.12.1",
|
|
35
|
+
"@kubun/graphql": "^0.13.0",
|
|
36
|
+
"@kubun/http-client": "^0.12.1",
|
|
37
|
+
"@kubun/logger": "^0.12.0",
|
|
38
|
+
"@kubun/mcp": "^0.12.1",
|
|
39
|
+
"@kubun/plugin-http": "^0.12.1",
|
|
40
|
+
"@kubun/hub": "^0.12.1",
|
|
41
|
+
"@kubun/plugin-p2p": "^0.12.1",
|
|
42
|
+
"@kubun/protocol": "^0.13.0",
|
|
43
|
+
"@kubun/plugin-rpc": "^0.12.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
|
-
"ink-testing-library": "^4.0.0"
|
|
46
|
+
"ink-testing-library": "^4.0.0",
|
|
47
|
+
"@kubun/id": "^0.12.0",
|
|
48
|
+
"@kubun/store-graph": "^0.13.0",
|
|
49
|
+
"@kubun/store-p2p": "^0.12.1"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22.0.0"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
47
56
|
},
|
|
48
57
|
"scripts": {
|
|
49
|
-
"
|
|
58
|
+
"build": "pnpm run build:clean && pnpm run build:js && pnpm run build:types",
|
|
50
59
|
"build:clean": "del lib",
|
|
51
60
|
"build:js": "swc src -d ./lib --config-file ../../node_modules/@kigu/dev/swc.json --strip-leading-paths",
|
|
52
61
|
"build:types": "tsc --emitDeclarationOnly --skipLibCheck",
|
|
53
62
|
"build:types:ci": "tsc --emitDeclarationOnly --skipLibCheck --declarationMap false",
|
|
54
|
-
"
|
|
55
|
-
"test
|
|
56
|
-
"test:
|
|
57
|
-
"test": "
|
|
63
|
+
"kubun": "./bin/dev.js",
|
|
64
|
+
"test": "pnpm run test:types && pnpm run test:unit",
|
|
65
|
+
"test:types": "tsc --noEmit --skipLibCheck -p tsconfig.test.json",
|
|
66
|
+
"test:unit": "vitest run"
|
|
58
67
|
}
|
|
59
68
|
}
|