@kraki/head 0.20.0 → 0.20.2
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/README.md +16 -0
- package/dist/account-api.d.ts +10 -0
- package/dist/account-api.js +67 -15
- package/dist/account-api.js.map +1 -1
- package/dist/auth-backend.d.ts +16 -5
- package/dist/auth.d.ts +0 -1
- package/dist/auth.js +32 -20
- package/dist/auth.js.map +1 -1
- package/dist/cli.js +25 -1
- package/dist/cli.js.map +1 -1
- package/dist/lease-issuer.js +1 -1
- package/dist/lease-issuer.js.map +1 -1
- package/dist/local-auth-backend.d.ts +5 -1
- package/dist/local-auth-backend.js +30 -17
- package/dist/local-auth-backend.js.map +1 -1
- package/dist/metrics.d.ts +50 -0
- package/dist/metrics.js +117 -0
- package/dist/metrics.js.map +1 -0
- package/dist/observability.d.ts +40 -0
- package/dist/observability.js +58 -0
- package/dist/observability.js.map +1 -0
- package/dist/pulse-hub.d.ts +9 -0
- package/dist/pulse-hub.js +67 -5
- package/dist/pulse-hub.js.map +1 -1
- package/dist/push/apns.js +21 -2
- package/dist/push/apns.js.map +1 -1
- package/dist/push/index.d.ts +2 -0
- package/dist/push/index.js +16 -0
- package/dist/push/index.js.map +1 -1
- package/dist/remote-auth-backend.d.ts +2 -1
- package/dist/remote-auth-backend.js +10 -2
- package/dist/remote-auth-backend.js.map +1 -1
- package/dist/server/account-control.d.ts +28 -0
- package/dist/server/account-control.js +288 -0
- package/dist/server/account-control.js.map +1 -0
- package/dist/server/auth-flow.d.ts +82 -0
- package/dist/server/auth-flow.js +315 -0
- package/dist/server/auth-flow.js.map +1 -0
- package/dist/server/presence.d.ts +8 -0
- package/dist/server/presence.js +103 -0
- package/dist/server/presence.js.map +1 -0
- package/dist/server/shared.d.ts +106 -0
- package/dist/server/shared.js +49 -0
- package/dist/server/shared.js.map +1 -0
- package/dist/server/voice-lease.d.ts +5 -0
- package/dist/server/voice-lease.js +111 -0
- package/dist/server/voice-lease.js.map +1 -0
- package/dist/server.d.ts +20 -125
- package/dist/server.js +114 -1072
- package/dist/server.js.map +1 -1
- package/dist/storage.d.ts +21 -1
- package/dist/storage.js +88 -12
- package/dist/storage.js.map +1 -1
- package/dist/trace.d.ts +0 -1
- package/dist/trace.js +0 -3
- package/dist/trace.js.map +1 -1
- package/package.json +3 -4
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/** Voice leases: short-lived credentials for the voice service, within the daily quota. */
|
|
2
|
+
import { getLogger } from '../logger.js';
|
|
3
|
+
import { DEFAULT_VOICE_LEASE_TTL_SEC, DEFAULT_VOICE_DAILY_QUOTA_SEC, MIN_VOICE_LEASE_GRANT_SEC, VALID_VOICE_RESOURCES } from './shared.js';
|
|
4
|
+
export function handleRequestVoiceLease(server, ws, state, msg) {
|
|
5
|
+
const logger = getLogger();
|
|
6
|
+
const userId = state.userId;
|
|
7
|
+
const deviceId = state.deviceId;
|
|
8
|
+
if (!userId || !deviceId) {
|
|
9
|
+
ws.send(JSON.stringify({
|
|
10
|
+
type: 'voice_lease_denied',
|
|
11
|
+
reason: 'invalid_request',
|
|
12
|
+
detail: 'Not authenticated',
|
|
13
|
+
}));
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const issuer = server.options.leaseIssuer;
|
|
17
|
+
if (!issuer) {
|
|
18
|
+
server.sendControlToDevice(deviceId, {
|
|
19
|
+
type: 'voice_lease_denied',
|
|
20
|
+
reason: 'not_entitled',
|
|
21
|
+
detail: 'Voice lease issuance is not enabled on this head',
|
|
22
|
+
});
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
// Validate request shape.
|
|
26
|
+
const requestedDeviceId = typeof msg.deviceId === 'string' ? msg.deviceId : undefined;
|
|
27
|
+
const requestedResource = typeof msg.resource === 'string' ? msg.resource : undefined;
|
|
28
|
+
if (!requestedDeviceId || !requestedResource) {
|
|
29
|
+
server.sendControlToDevice(deviceId, {
|
|
30
|
+
type: 'voice_lease_denied',
|
|
31
|
+
reason: 'invalid_request',
|
|
32
|
+
detail: 'Missing deviceId or resource',
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
// Lease may only be requested for the authenticated device — no proxying.
|
|
37
|
+
if (requestedDeviceId !== deviceId) {
|
|
38
|
+
server.sendControlToDevice(deviceId, {
|
|
39
|
+
type: 'voice_lease_denied',
|
|
40
|
+
reason: 'invalid_request',
|
|
41
|
+
detail: 'deviceId does not match authenticated device',
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!VALID_VOICE_RESOURCES.has(requestedResource)) {
|
|
46
|
+
server.sendControlToDevice(deviceId, {
|
|
47
|
+
type: 'voice_lease_denied',
|
|
48
|
+
reason: 'invalid_request',
|
|
49
|
+
detail: `Unknown resource: ${String(requestedResource)}`,
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
// A lease is a long-lived credential for this device; the audio budget
|
|
54
|
+
// is granted incrementally by usage reports, charged to the UTC day the
|
|
55
|
+
// audio is used. So issuance only checks that anything is left today.
|
|
56
|
+
const nowSec = Math.floor(Date.now() / 1000);
|
|
57
|
+
const dailyCap = server.options.voiceDailyQuotaSec ?? DEFAULT_VOICE_DAILY_QUOTA_SEC;
|
|
58
|
+
server.storage.revokePendingVoiceLeases(userId, deviceId, requestedResource, nowSec);
|
|
59
|
+
const accountedToday = server.storage.voiceSecondsAccountedToday(userId, nowSec);
|
|
60
|
+
if (dailyCap - accountedToday < MIN_VOICE_LEASE_GRANT_SEC) {
|
|
61
|
+
logger.info('Voice lease denied: daily quota exhausted', {
|
|
62
|
+
userId, deviceId, accountedToday, dailyCap,
|
|
63
|
+
});
|
|
64
|
+
server.sendControlToDevice(deviceId, {
|
|
65
|
+
type: 'voice_lease_denied',
|
|
66
|
+
reason: 'quota_exhausted',
|
|
67
|
+
detail: `Daily quota reached (${accountedToday}/${dailyCap}s)`,
|
|
68
|
+
});
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const ttl = server.options.voiceLeaseTtlSec ?? DEFAULT_VOICE_LEASE_TTL_SEC;
|
|
72
|
+
// Signed ceiling for one lease: all the UTC days it can span. The real
|
|
73
|
+
// limit is the daily grant Head hands out on each usage report.
|
|
74
|
+
const grant = dailyCap * (Math.ceil(ttl / 86_400) + 1);
|
|
75
|
+
const lease = issuer.issue({
|
|
76
|
+
userId,
|
|
77
|
+
deviceId,
|
|
78
|
+
quotaSeconds: grant,
|
|
79
|
+
ttlSeconds: ttl,
|
|
80
|
+
resource: requestedResource,
|
|
81
|
+
nowUnixSec: nowSec,
|
|
82
|
+
});
|
|
83
|
+
try {
|
|
84
|
+
server.storage.recordVoiceLease({
|
|
85
|
+
jti: lease.payload.jti,
|
|
86
|
+
userId,
|
|
87
|
+
deviceId,
|
|
88
|
+
resource: requestedResource,
|
|
89
|
+
quotaSeconds: grant,
|
|
90
|
+
issuedAtUnixSec: lease.payload.iat,
|
|
91
|
+
expiresAtUnixSec: lease.payload.exp,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
logger.error('Voice lease persist failed', {
|
|
96
|
+
userId, deviceId, jti: lease.payload.jti, error: err.message,
|
|
97
|
+
});
|
|
98
|
+
server.sendControlToDevice(deviceId, {
|
|
99
|
+
type: 'voice_lease_denied',
|
|
100
|
+
reason: 'invalid_request',
|
|
101
|
+
detail: 'Lease persistence failed',
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
logger.info('Voice lease issued', {
|
|
106
|
+
userId, deviceId, jti: lease.payload.jti,
|
|
107
|
+
quotaSeconds: grant, ttlSec: ttl, accountedToday, dailyCap,
|
|
108
|
+
});
|
|
109
|
+
server.sendControlToDevice(deviceId, { type: 'voice_lease_grant', lease });
|
|
110
|
+
}
|
|
111
|
+
//# sourceMappingURL=voice-lease.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"voice-lease.js","sourceRoot":"","sources":["../../src/server/voice-lease.ts"],"names":[],"mappings":"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"}
|
package/dist/server.d.ts
CHANGED
|
@@ -1,64 +1,24 @@
|
|
|
1
1
|
import { WebSocket } from 'ws';
|
|
2
2
|
import type { Server } from 'http';
|
|
3
|
+
import type { VoiceCapability } from '@kraki/protocol';
|
|
3
4
|
import { Storage } from './storage.js';
|
|
4
|
-
import {
|
|
5
|
-
import type { AuthProvider } from './auth.js';
|
|
6
|
-
import type { PushManager } from './push/index.js';
|
|
5
|
+
import { PulseHub } from './pulse-hub.js';
|
|
7
6
|
import type { AuthBackend } from './auth-backend.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
authProvider?: AuthProvider;
|
|
11
|
-
maxPayload?: number;
|
|
12
|
-
pairingEnabled?: boolean;
|
|
13
|
-
pairingTtl?: number;
|
|
14
|
-
version?: string;
|
|
15
|
-
pushManager?: PushManager;
|
|
16
|
-
/** Optional auth backend. If provided, auth is delegated to it instead of using local providers. */
|
|
17
|
-
authBackend?: AuthBackend;
|
|
18
|
-
/** This head's region identifier (e.g., 'us', 'china'). Sent to auth backend for region checks. */
|
|
19
|
-
region?: string;
|
|
20
|
-
/** Voice-broker lease signer. If absent, request_voice_lease is rejected with 'not_entitled'. */
|
|
21
|
-
leaseIssuer?: LeaseIssuer;
|
|
22
|
-
/** Lease (device voice credential) lifetime in seconds. Default 86400 (24h). */
|
|
23
|
-
voiceLeaseTtlSec?: number;
|
|
24
|
-
/** The only voice limit: max seconds of audio per user per UTC day. Default 7200 (2h). */
|
|
25
|
-
voiceDailyQuotaSec?: number;
|
|
26
|
-
/**
|
|
27
|
-
* Public WSS URL of the voice broker for this region (e.g.
|
|
28
|
-
* `wss://cn.stt.kraki.chat/voice`). When set, head advertises a
|
|
29
|
-
* `VoiceCapability` inside `auth_ok.voice` so arm can render the mic UI.
|
|
30
|
-
* When unset, the field is omitted entirely — arm hides the mic UI.
|
|
31
|
-
*
|
|
32
|
-
* Must be set together with `leaseIssuer`: cli-side interlock refuses to
|
|
33
|
-
* start in either-without-the-other configurations because:
|
|
34
|
-
* - Advertising a broker URL but having no issuer would mean clients
|
|
35
|
-
* show the mic UI then get `not_entitled` on every press.
|
|
36
|
-
* - Having an issuer but no advertised URL means clients have no way
|
|
37
|
-
* to actually use the leases they're being issued.
|
|
38
|
-
*/
|
|
39
|
-
voiceBrokerUrl?: string;
|
|
40
|
-
/**
|
|
41
|
-
* Close sockets that have not authenticated within this time (ms).
|
|
42
|
-
* Default 15s. A socket that asked for `auth_info` (an app on its login
|
|
43
|
-
* screen, waiting for the user to finish signing in) gets
|
|
44
|
-
* `loginScreenAuthTimeoutMs` instead (default 10 min).
|
|
45
|
-
*/
|
|
46
|
-
authTimeoutMs?: number;
|
|
47
|
-
loginScreenAuthTimeoutMs?: number;
|
|
48
|
-
}
|
|
7
|
+
import { type ClientState, type HeadServerOptions } from './server/shared.js';
|
|
8
|
+
export type { HeadServerOptions } from './server/shared.js';
|
|
49
9
|
export declare class HeadServer {
|
|
50
10
|
private wss;
|
|
51
|
-
|
|
52
|
-
|
|
11
|
+
storage: Storage;
|
|
12
|
+
options: HeadServerOptions;
|
|
53
13
|
private static readonly PING_INTERVAL;
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
14
|
+
connections: Map<string, WebSocket>;
|
|
15
|
+
authBackend: AuthBackend;
|
|
16
|
+
userByDevice: Map<string, string>;
|
|
17
|
+
clients: Map<WebSocket, ClientState>;
|
|
58
18
|
private pingTimer;
|
|
59
19
|
private livenessTimer;
|
|
60
20
|
/** Per-hop pulse hub — the reliable-delivery layer for every connection. */
|
|
61
|
-
|
|
21
|
+
pulseHub: PulseHub;
|
|
62
22
|
private pulseTickTimer;
|
|
63
23
|
constructor(storage: Storage, options: HeadServerOptions);
|
|
64
24
|
private startPingInterval;
|
|
@@ -76,10 +36,7 @@ export declare class HeadServer {
|
|
|
76
36
|
private linkProgressing;
|
|
77
37
|
/** Test hook: force a liveness sweep without waiting for the timer. */
|
|
78
38
|
forceLivenessSweep(): void;
|
|
79
|
-
|
|
80
|
-
private getAuthProviderForMode;
|
|
81
|
-
private getGitHubClientId;
|
|
82
|
-
private getVapidPublicKey;
|
|
39
|
+
getVapidPublicKey(): string | undefined;
|
|
83
40
|
/**
|
|
84
41
|
* Build the voice capability advertisement for `auth_ok` from this head's
|
|
85
42
|
* config. Returns `undefined` (omit the field) when no broker URL is
|
|
@@ -90,15 +47,13 @@ export declare class HeadServer {
|
|
|
90
47
|
* advertises Beijing's broker, and US main advertises nothing, all from a
|
|
91
48
|
* single helper.
|
|
92
49
|
*/
|
|
93
|
-
|
|
94
|
-
private findGitHubProvider;
|
|
50
|
+
getVoiceCapability(): VoiceCapability | undefined;
|
|
95
51
|
attach(server: Server): void;
|
|
96
52
|
acceptConnection(ws: WebSocket): void;
|
|
97
53
|
/** (Re)start the deadline for this socket to authenticate (G1). */
|
|
98
54
|
private armAuthTimer;
|
|
99
55
|
private onConnection;
|
|
100
56
|
private onMessage;
|
|
101
|
-
private handleRequestVoiceLease;
|
|
102
57
|
/** Send a pulse frame (control or forwarded data) to a device's socket, as a
|
|
103
58
|
* minimal unicast envelope carrying only the `pulse` field. Returns true if
|
|
104
59
|
* the device is online. Used by the PulseHub to reach endpoints. */
|
|
@@ -109,27 +64,17 @@ export declare class HeadServer {
|
|
|
109
64
|
* wrapped as `{from:'@head', msg}` — the receiver's pulse layer recognizes the
|
|
110
65
|
* `@head` marker and dispatches `msg` WITHOUT decrypting. Non-durable:
|
|
111
66
|
* presence/prefs are ephemeral. */
|
|
112
|
-
|
|
67
|
+
sendControlToDevice(deviceId: string, msg: Record<string, unknown>): void;
|
|
113
68
|
/** Presence (device_joined/left/removed/pending): the destination's next
|
|
114
69
|
* `auth_ok` device list supersedes it, so it is never replayed across a
|
|
115
70
|
* reconnect (a stale device_left would hide an online peer). */
|
|
116
|
-
|
|
71
|
+
sendPresenceToDevice(deviceId: string, msg: Record<string, unknown>): void;
|
|
117
72
|
/** A pulse frame addressed to HEAD_PULSE_TARGET was delivered in-order by the
|
|
118
73
|
* source device's endpoint. The payload is PLAINTEXT control JSON. Resolve the
|
|
119
74
|
* originating authenticated connection and route it through the SAME control
|
|
120
75
|
* handlers the raw-WS path uses, so auth context (state.userId/deviceId) and
|
|
121
76
|
* responses (over ws) are identical. */
|
|
122
77
|
private deliverPulseToSelf;
|
|
123
|
-
/** The relay-terminated control ops, callable from BOTH the raw-WS dispatch
|
|
124
|
-
* (onMessage) and the pulse deliver-to-self path. Returns true if handled. */
|
|
125
|
-
private handleControlMessage;
|
|
126
|
-
/** Persist a preferences update and echo the merged result to the sender + the
|
|
127
|
-
* user's other online devices. */
|
|
128
|
-
private handleUpdatePreferences;
|
|
129
|
-
/** Account data, not session data: no tentacle is involved. The sender
|
|
130
|
-
* always gets the resulting list back (its acknowledgement); other online
|
|
131
|
-
* apps of the account get it only when something was applied. */
|
|
132
|
-
private handleUpdateVoiceVocabulary;
|
|
133
78
|
/** Fan-out targets for a pulse broadcast from `fromDevice`: all OTHER
|
|
134
79
|
* ONLINE devices of the same user. Offline devices are excluded because
|
|
135
80
|
* broadcasts (user_message / agent_message / delta / active / idle /
|
|
@@ -144,66 +89,16 @@ export declare class HeadServer {
|
|
|
144
89
|
* streaming delta arrives 16s later than the previous" bug). Reconnecting
|
|
145
90
|
* devices resync via session_messages_range instead. */
|
|
146
91
|
private pulseBroadcastTargets;
|
|
147
|
-
/** Notify offline devices from a pushPreview side-channel. The preview rides the
|
|
148
|
-
* same pulse broadcast envelope as the reliable message. */
|
|
149
|
-
private firePushPreview;
|
|
150
92
|
private removeConnection;
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
private handlePairingAuth;
|
|
154
|
-
private handleChallengeResponse;
|
|
155
|
-
/**
|
|
156
|
-
* Handle a result from AuthBackend — registers connection state and sends response.
|
|
157
|
-
* Used for both initial auth and challenge-response verification.
|
|
158
|
-
*/
|
|
159
|
-
private handleBackendAuthResult;
|
|
160
|
-
/**
|
|
161
|
-
* Common post-auth completion logic, used by every auth path.
|
|
162
|
-
*
|
|
163
|
-
* Responsibilities:
|
|
164
|
-
* - Read user preferences from local storage and merge into the user object.
|
|
165
|
-
* - Flush any locally-queued pending messages for this device (offline-queue).
|
|
166
|
-
* - Build and send the `auth_ok` response.
|
|
167
|
-
* - Broadcast `device_joined` to the user's other connected devices.
|
|
168
|
-
*
|
|
169
|
-
* Each caller is responsible for path-specific work BEFORE calling this:
|
|
170
|
-
* validating credentials, persisting user/device records, setting up
|
|
171
|
-
* ClientState, populating `connections` / `userByDevice` maps, and logging
|
|
172
|
-
* the path-specific "authenticated via X" line.
|
|
173
|
-
*
|
|
174
|
-
* Centralising the tail eliminates a class of bugs where new auth paths
|
|
175
|
-
* (notably the multi-region delegated-backend path) silently diverge from
|
|
176
|
-
* the established post-auth flow.
|
|
177
|
-
*/
|
|
178
|
-
private completeAuthHandshake;
|
|
179
|
-
/**
|
|
180
|
-
* A device is about to register `ws` as its connection. If the SAME device id
|
|
181
|
-
* already had a socket, that older socket is now orphaned: once we overwrite
|
|
182
|
-
* the map entry its close handler hits the reconnect-guard and skips cleanup,
|
|
183
|
-
* and the ping timer (which only walks `connections`) never visits it again.
|
|
184
|
-
* It would sit in `clients` holding its ClientState until TCP finally gives
|
|
185
|
-
* up. Retire both the WebSocket and its Pulse link explicitly before the new
|
|
186
|
-
* connection is installed.
|
|
187
|
-
*
|
|
188
|
-
* Must be called BEFORE `connections.set(deviceId, ws)`.
|
|
189
|
-
*/
|
|
190
|
-
private evictPreviousConnection;
|
|
191
|
-
private completeAuth;
|
|
192
|
-
private handleCreatePairingToken;
|
|
193
|
-
private handleRequestPairingToken;
|
|
194
|
-
private handleRemoveDevice;
|
|
195
|
-
private handleRegisterPushToken;
|
|
196
|
-
private handleUnregisterPushToken;
|
|
197
|
-
private broadcastDeviceRemoved;
|
|
198
|
-
private broadcastDeviceJoined;
|
|
199
|
-
private broadcastDeviceLeft;
|
|
200
|
-
private broadcastDevicePending;
|
|
93
|
+
/** Users whose deletion is in flight (a double tap must not run it twice). */
|
|
94
|
+
deletingAccounts: Set<string>;
|
|
201
95
|
/** Handle pong from either protocol-level or JSON pong.
|
|
202
96
|
* Re-promotes to device_joined if we already broadcast device_pending.
|
|
203
97
|
* Guards against stale pongs from replaced connections (reconnect race). */
|
|
204
98
|
private onPongReceived;
|
|
205
|
-
|
|
206
|
-
|
|
99
|
+
sendError(ws: WebSocket, message: string): void;
|
|
100
|
+
/** Live gauges for `/internal/metrics` (counts only). */
|
|
101
|
+
getGauges(): Record<string, number>;
|
|
207
102
|
getStats(): {
|
|
208
103
|
users: {
|
|
209
104
|
total: number;
|