@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.
Files changed (57) hide show
  1. package/README.md +16 -0
  2. package/dist/account-api.d.ts +10 -0
  3. package/dist/account-api.js +67 -15
  4. package/dist/account-api.js.map +1 -1
  5. package/dist/auth-backend.d.ts +16 -5
  6. package/dist/auth.d.ts +0 -1
  7. package/dist/auth.js +32 -20
  8. package/dist/auth.js.map +1 -1
  9. package/dist/cli.js +25 -1
  10. package/dist/cli.js.map +1 -1
  11. package/dist/lease-issuer.js +1 -1
  12. package/dist/lease-issuer.js.map +1 -1
  13. package/dist/local-auth-backend.d.ts +5 -1
  14. package/dist/local-auth-backend.js +30 -17
  15. package/dist/local-auth-backend.js.map +1 -1
  16. package/dist/metrics.d.ts +50 -0
  17. package/dist/metrics.js +117 -0
  18. package/dist/metrics.js.map +1 -0
  19. package/dist/observability.d.ts +40 -0
  20. package/dist/observability.js +58 -0
  21. package/dist/observability.js.map +1 -0
  22. package/dist/pulse-hub.d.ts +9 -0
  23. package/dist/pulse-hub.js +67 -5
  24. package/dist/pulse-hub.js.map +1 -1
  25. package/dist/push/apns.js +21 -2
  26. package/dist/push/apns.js.map +1 -1
  27. package/dist/push/index.d.ts +2 -0
  28. package/dist/push/index.js +16 -0
  29. package/dist/push/index.js.map +1 -1
  30. package/dist/remote-auth-backend.d.ts +2 -1
  31. package/dist/remote-auth-backend.js +10 -2
  32. package/dist/remote-auth-backend.js.map +1 -1
  33. package/dist/server/account-control.d.ts +28 -0
  34. package/dist/server/account-control.js +288 -0
  35. package/dist/server/account-control.js.map +1 -0
  36. package/dist/server/auth-flow.d.ts +82 -0
  37. package/dist/server/auth-flow.js +315 -0
  38. package/dist/server/auth-flow.js.map +1 -0
  39. package/dist/server/presence.d.ts +8 -0
  40. package/dist/server/presence.js +103 -0
  41. package/dist/server/presence.js.map +1 -0
  42. package/dist/server/shared.d.ts +106 -0
  43. package/dist/server/shared.js +49 -0
  44. package/dist/server/shared.js.map +1 -0
  45. package/dist/server/voice-lease.d.ts +5 -0
  46. package/dist/server/voice-lease.js +111 -0
  47. package/dist/server/voice-lease.js.map +1 -0
  48. package/dist/server.d.ts +20 -125
  49. package/dist/server.js +114 -1072
  50. package/dist/server.js.map +1 -1
  51. package/dist/storage.d.ts +21 -1
  52. package/dist/storage.js +88 -12
  53. package/dist/storage.js.map +1 -1
  54. package/dist/trace.d.ts +0 -1
  55. package/dist/trace.js +0 -3
  56. package/dist/trace.js.map +1 -1
  57. 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 { 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 pairingTokens;
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,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
- private getAuthProvider;
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
- private getVoiceCapability;
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
- private sendControlToDevice;
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
- private sendPresenceToDevice;
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
- private sendAuthError;
152
- private handleAuth;
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
- private getDeviceSummaries;
206
- private sendError;
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;