@kraki/head 0.20.1 → 0.20.3

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.
@@ -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 { LeaseIssuer } from './lease-issuer.js';
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
- export interface HeadServerOptions {
9
- authProviders?: Map<string, AuthProvider>;
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
- private storage;
52
- private options;
11
+ storage: Storage;
12
+ options: HeadServerOptions;
53
13
  private static readonly PING_INTERVAL;
54
- private connections;
55
- private authBackend;
56
- private userByDevice;
57
- private clients;
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
- private pulseHub;
21
+ pulseHub: PulseHub;
62
22
  private pulseTickTimer;
63
23
  constructor(storage: Storage, options: HeadServerOptions);
64
24
  private startPingInterval;
@@ -76,7 +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
- private getVapidPublicKey;
39
+ getVapidPublicKey(): string | undefined;
80
40
  /**
81
41
  * Build the voice capability advertisement for `auth_ok` from this head's
82
42
  * config. Returns `undefined` (omit the field) when no broker URL is
@@ -87,14 +47,13 @@ export declare class HeadServer {
87
47
  * advertises Beijing's broker, and US main advertises nothing, all from a
88
48
  * single helper.
89
49
  */
90
- private getVoiceCapability;
50
+ getVoiceCapability(): VoiceCapability | undefined;
91
51
  attach(server: Server): void;
92
52
  acceptConnection(ws: WebSocket): void;
93
53
  /** (Re)start the deadline for this socket to authenticate (G1). */
94
54
  private armAuthTimer;
95
55
  private onConnection;
96
56
  private onMessage;
97
- private handleRequestVoiceLease;
98
57
  /** Send a pulse frame (control or forwarded data) to a device's socket, as a
99
58
  * minimal unicast envelope carrying only the `pulse` field. Returns true if
100
59
  * the device is online. Used by the PulseHub to reach endpoints. */
@@ -105,27 +64,17 @@ export declare class HeadServer {
105
64
  * wrapped as `{from:'@head', msg}` — the receiver's pulse layer recognizes the
106
65
  * `@head` marker and dispatches `msg` WITHOUT decrypting. Non-durable:
107
66
  * presence/prefs are ephemeral. */
108
- private sendControlToDevice;
67
+ sendControlToDevice(deviceId: string, msg: Record<string, unknown>): void;
109
68
  /** Presence (device_joined/left/removed/pending): the destination's next
110
69
  * `auth_ok` device list supersedes it, so it is never replayed across a
111
70
  * reconnect (a stale device_left would hide an online peer). */
112
- private sendPresenceToDevice;
71
+ sendPresenceToDevice(deviceId: string, msg: Record<string, unknown>): void;
113
72
  /** A pulse frame addressed to HEAD_PULSE_TARGET was delivered in-order by the
114
73
  * source device's endpoint. The payload is PLAINTEXT control JSON. Resolve the
115
74
  * originating authenticated connection and route it through the SAME control
116
75
  * handlers the raw-WS path uses, so auth context (state.userId/deviceId) and
117
76
  * responses (over ws) are identical. */
118
77
  private deliverPulseToSelf;
119
- /** The relay-terminated control ops, callable from BOTH the raw-WS dispatch
120
- * (onMessage) and the pulse deliver-to-self path. Returns true if handled. */
121
- private handleControlMessage;
122
- /** Persist a preferences update and echo the merged result to the sender + the
123
- * user's other online devices. */
124
- private handleUpdatePreferences;
125
- /** Account data, not session data: no tentacle is involved. The sender
126
- * always gets the resulting list back (its acknowledgement); other online
127
- * apps of the account get it only when something was applied. */
128
- private handleUpdateVoiceVocabulary;
129
78
  /** Fan-out targets for a pulse broadcast from `fromDevice`: all OTHER
130
79
  * ONLINE devices of the same user. Offline devices are excluded because
131
80
  * broadcasts (user_message / agent_message / delta / active / idle /
@@ -140,77 +89,16 @@ export declare class HeadServer {
140
89
  * streaming delta arrives 16s later than the previous" bug). Reconnecting
141
90
  * devices resync via session_messages_range instead. */
142
91
  private pulseBroadcastTargets;
143
- /** Notify offline devices from a pushPreview side-channel. The preview rides the
144
- * same pulse broadcast envelope as the reliable message. */
145
- private firePushPreview;
146
92
  private removeConnection;
147
- private sendAuthError;
148
- private handleAuth;
149
- /** After an awaited auth step: is this socket still open, still ours and
150
- * still unauthenticated? The auth deadline may have closed it meanwhile;
151
- * registering a dead socket would evict the device's live connection. */
152
- private socketStillAuthenticating;
153
- private handleChallengeResponse;
154
- /**
155
- * Handle a result from AuthBackend — registers connection state and sends response.
156
- * Used for both initial auth and challenge-response verification.
157
- */
158
- private handleBackendAuthResult;
159
- /**
160
- * Common post-auth completion logic, used by every auth path.
161
- *
162
- * Responsibilities:
163
- * - Read user preferences from local storage and merge into the user object.
164
- * - Flush any locally-queued pending messages for this device (offline-queue).
165
- * - Build and send the `auth_ok` response.
166
- * - Broadcast `device_joined` to the user's other connected devices.
167
- *
168
- * Each caller is responsible for path-specific work BEFORE calling this:
169
- * validating credentials, persisting user/device records, setting up
170
- * ClientState, populating `connections` / `userByDevice` maps, and logging
171
- * the path-specific "authenticated via X" line.
172
- *
173
- * Centralising the tail eliminates a class of bugs where new auth paths
174
- * (notably the multi-region delegated-backend path) silently diverge from
175
- * the established post-auth flow.
176
- */
177
- private completeAuthHandshake;
178
- /**
179
- * A device is about to register `ws` as its connection. If the SAME device id
180
- * already had a socket, that older socket is now orphaned: once we overwrite
181
- * the map entry its close handler hits the reconnect-guard and skips cleanup,
182
- * and the ping timer (which only walks `connections`) never visits it again.
183
- * It would sit in `clients` holding its ClientState until TCP finally gives
184
- * up. Retire both the WebSocket and its Pulse link explicitly before the new
185
- * connection is installed.
186
- *
187
- * Must be called BEFORE `connections.set(deviceId, ws)`.
188
- */
189
- private evictPreviousConnection;
190
- private handleCreatePairingToken;
191
- private handleRequestPairingToken;
192
- private handleRemoveDevice;
193
93
  /** Users whose deletion is in flight (a double tap must not run it twice). */
194
- private deletingAccounts;
195
- /**
196
- * Delete the requesting user's account: at the account store (the account
197
- * service in edge mode) and in this relay's own storage and pulse state.
198
- * Then tell every connected device and close it. Devices that were offline
199
- * get `auth_error` code `account_deleted` when they next connect.
200
- */
201
- private handleDeleteAccount;
202
- private handleRegisterPushToken;
203
- private handleUnregisterPushToken;
204
- private broadcastDeviceRemoved;
205
- private broadcastDeviceJoined;
206
- private broadcastDeviceLeft;
207
- private broadcastDevicePending;
94
+ deletingAccounts: Set<string>;
208
95
  /** Handle pong from either protocol-level or JSON pong.
209
96
  * Re-promotes to device_joined if we already broadcast device_pending.
210
97
  * Guards against stale pongs from replaced connections (reconnect race). */
211
98
  private onPongReceived;
212
- private getDeviceSummaries;
213
- private sendError;
99
+ sendError(ws: WebSocket, message: string): void;
100
+ /** Live gauges for `/internal/metrics` (counts only). */
101
+ getGauges(): Record<string, number>;
214
102
  getStats(): {
215
103
  users: {
216
104
  total: number;