@abot-ai/socket 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +129 -2
  4. package/bin/abot-socket.mjs +15 -0
  5. package/config.example.json +14 -0
  6. package/docs/APP-REVIEW.md +184 -0
  7. package/docs/DEPLOYMENT.md +124 -0
  8. package/docs/INSTALLATION.md +177 -0
  9. package/docs/MIGRATION.md +84 -0
  10. package/docs/PROTOCOL.md +371 -0
  11. package/docs/SECURITY.md +116 -0
  12. package/docs/SELF-HOSTING.md +161 -0
  13. package/package.json +65 -4
  14. package/src/auth/auth-database.mjs +105 -0
  15. package/src/auth/auth-limits.mjs +17 -0
  16. package/src/auth/auth-pairing.mjs +95 -0
  17. package/src/auth/auth-sessions.mjs +119 -0
  18. package/src/auth/auth-storage-path.mjs +70 -0
  19. package/src/auth/auth.mjs +45 -0
  20. package/src/auth/runtime-credentials.mjs +39 -0
  21. package/src/cli/cli-arguments.mjs +23 -0
  22. package/src/cli/cli-entry.mjs +84 -0
  23. package/src/cli/cli-environment.mjs +31 -0
  24. package/src/cli/cli-help.mjs +34 -0
  25. package/src/cli/cli-output.mjs +35 -0
  26. package/src/cli/cli.mjs +58 -0
  27. package/src/cli/client-capabilities.mjs +14 -0
  28. package/src/cli/doctor.mjs +106 -0
  29. package/src/config/config-validation.mjs +41 -0
  30. package/src/config/config.mjs +57 -0
  31. package/src/deployment/deployment-command.mjs +42 -0
  32. package/src/deployment/deployment-input.mjs +76 -0
  33. package/src/deployment/deployment-plan.mjs +62 -0
  34. package/src/deployment/deployment-templates.mjs +109 -0
  35. package/src/main.mjs +26 -0
  36. package/src/notifications/notification-delivery-credentials.mjs +48 -0
  37. package/src/notifications/notification-delivery-fcm.mjs +100 -0
  38. package/src/notifications/notification-delivery-gateway.mjs +36 -0
  39. package/src/notifications/notification-delivery-http.mjs +53 -0
  40. package/src/notifications/notification-delivery-payload.mjs +45 -0
  41. package/src/notifications/notification-delivery.mjs +43 -0
  42. package/src/notifications/notification-gateway-command.mjs +48 -0
  43. package/src/notifications/notification-gateway-store.mjs +159 -0
  44. package/src/notifications/notification-gateway.mjs +181 -0
  45. package/src/notifications/notification-http.mjs +39 -0
  46. package/src/notifications/notification-input.mjs +82 -0
  47. package/src/notifications/notification-outbox.mjs +60 -0
  48. package/src/notifications/notification-preview-crypto.mjs +42 -0
  49. package/src/notifications/notification-preview-schema.mjs +7 -0
  50. package/src/notifications/notification-preview-store.mjs +63 -0
  51. package/src/notifications/notification-preview-text.mjs +20 -0
  52. package/src/notifications/notification-projection.mjs +20 -0
  53. package/src/notifications/notification-schema.mjs +22 -0
  54. package/src/notifications/notification-service.mjs +147 -0
  55. package/src/notifications/notification-store.mjs +132 -0
  56. package/src/pairing/pairing-qr-payload.mjs +32 -0
  57. package/src/pairing/pairing-qr.mjs +42 -0
  58. package/src/protocol/approval-protocol.mjs +39 -0
  59. package/src/protocol/protocol-validation.mjs +137 -0
  60. package/src/protocol/protocol.mjs +120 -0
  61. package/src/protocol/spark-chat-protocol.mjs +51 -0
  62. package/src/relay/relay-approvals.mjs +86 -0
  63. package/src/relay/relay-client-admission.mjs +40 -0
  64. package/src/relay/relay-correlations.mjs +101 -0
  65. package/src/relay/relay-model-catalog.mjs +69 -0
  66. package/src/relay/relay-request-adoption.mjs +97 -0
  67. package/src/relay/relay-requests.mjs +211 -0
  68. package/src/relay/relay-runtime-registration.mjs +49 -0
  69. package/src/relay/relay-session-mutations.mjs +122 -0
  70. package/src/relay/relay.mjs +222 -0
  71. package/src/relay/request-ownership-schema.mjs +18 -0
  72. package/src/relay/request-ownership-store.mjs +115 -0
  73. package/src/review/review-demo-isolation.mjs +132 -0
  74. package/src/review/review-demo-setup.mjs +97 -0
  75. package/src/review/review-portal-access.mjs +64 -0
  76. package/src/review/review-portal-form.mjs +24 -0
  77. package/src/review/review-portal-page.mjs +68 -0
  78. package/src/review/review-portal-server.mjs +105 -0
  79. package/src/setup/setup-files.mjs +37 -0
  80. package/src/setup/setup-questions.mjs +62 -0
  81. package/src/setup/setup-wizard.mjs +50 -0
  82. package/src/setup/setup.mjs +42 -0
  83. package/src/transport/http-api.mjs +32 -0
  84. package/src/transport/server.mjs +175 -0
  85. package/src/transport/transport-limits.mjs +85 -0
@@ -0,0 +1,116 @@
1
+ # Security boundaries
2
+
3
+ ABot Socket is a single-owner relay. Each installation has its own runtime
4
+ grants, device credentials, private state and HTTPS origin. Every paired device
5
+ represents that owner; this is not tenant isolation for unrelated users.
6
+
7
+ ## Authentication and credentials
8
+
9
+ - Ordinary installations issue pairing through local operator commands. A random temporary
10
+ pairing key and six-digit code expire after five minutes or one successful use.
11
+ Five incorrect code attempts invalidate the challenge; expiry and attempts
12
+ persist across server restarts. Source and global verification limits apply only
13
+ to requests identifying an unexpired, unlocked pending pairing key. Unrelated
14
+ requests do not spend that quota; general HTTP request limits still apply.
15
+ - Generating fresh pairing details replaces only the pending challenge, not
16
+ existing devices. Consumption and device creation are atomic in SQLite.
17
+ - Device tokens are independently random. Only token digests are stored by the
18
+ server. Sessions last at most 30 days without sliding renewal, survive restarts
19
+ and can be revoked locally or by an authenticated owner device. Revocation and
20
+ expiry close existing WebSockets. At most 10 active device sessions are allowed.
21
+ - The app binds credentials to the selected HTTPS origin in iOS Keychain and
22
+ refuses redirects. The pairing key is temporary, not a permanent shared key.
23
+ - Runtime tokens are separate and bound to one runtime ID. Only their SHA-256
24
+ digest is in server configuration. Never put a runtime token into the app.
25
+ - Configuration is private; state directories are 0700 and files 0600. State
26
+ symlinks, unexpected entries and unsafe ownership/permissions are rejected.
27
+ Keep installation files outside source, and never log codes, keys or bodies.
28
+ - Setup and `pair` intentionally display new one-time details to the operator.
29
+ The ordinary Socket HTTP API does not generate or expose pairing bundles and
30
+ offers no email fallback or login bypass.
31
+ - A separately enabled [app-review portal](APP-REVIEW.md) can generate and display
32
+ bundles after reviewer authentication. It binds to a dedicated review
33
+ installation, uses a separate listener/origin and must not share production
34
+ state or runtime credentials. Protect its access details as credentials.
35
+
36
+ ## Trusted components
37
+
38
+ The relay, its local reverse proxy and runtime computers are trusted. There is no
39
+ email delivery or third-party identity provider. HTTPS/WSS protects transport;
40
+ the relay can read routed messages and attachments. This is not end-to-end
41
+ encryption against its operator or a compromised relay host.
42
+
43
+ Follow [the proxy requirements](SELF-HOSTING.md#3-configure-https-and-the-proxy-boundary).
44
+ The listener is private loopback. Proxy headers must be overwritten by a trusted
45
+ local proxy; forwarded addresses are not trustworthy simply because a header
46
+ exists. CDN chains require a separate trusted-peer policy.
47
+
48
+ ## Permission and routing limits
49
+
50
+ Runtime grants limit the permitted tool modes; `ask` is the setup default.
51
+ Requests and responses are bound to their runtime connection and private
52
+ correlation. Clients cannot use this relay to call generic runtime administration,
53
+ restart, or scheduling. Session deletion/clearing is limited to three validated
54
+ commands for an authorized runtime, with private response correlations and a
55
+ read-before-delete check for active work. See the
56
+ [mutation contract and its cross-client race limit](PROTOCOL.md#session-mutations).
57
+
58
+ The relay validates command shapes, bounds frames, connections and routing state,
59
+ and rejects unsupported operations. These controls do not make a compromised
60
+ authorized device harmless: it can act within its owner's configured grants.
61
+ Revoking that device session removes its relay access.
62
+
63
+ Paired owner devices can continue requests started directly on an authorized
64
+ runtime. Before admitting an unowned request for approval recovery, the relay
65
+ verifies its exact session/request identity through a fresh response from that
66
+ runtime connection. It never replaces an existing ownership binding for a
67
+ different owner or session. The runtime still validates every approval decision
68
+ against its current wait; a session snapshot is not approval authority.
69
+
70
+ The relay does not automatically retry uncertain operations, restore approval
71
+ authority from historical events, or provide exactly-once execution. After a
72
+ disconnect, reconcile against runtime history before resubmission.
73
+
74
+ ## SPARK chat boundary
75
+
76
+ SPARK chat uses the same paired-device runtime access and tool-permission ceiling
77
+ as ordinary chat. Opening it exposes only the canonical session, selected model,
78
+ permission mode and model readiness, never the full preferences or internal
79
+ diagnostics. Runtime support is required before dispatch. The runtime remains
80
+ responsible for its marked companion and rereads its policy at every new send;
81
+ the relay never caches that policy as permission to execute. Existing approval
82
+ ownership and recovery checks remain in force. SPARK configuration and autonomous
83
+ activity are outside this API.
84
+
85
+ ## Push preview boundary
86
+
87
+ Answer previews are opt-in per paired device and off by default. The already
88
+ trusted Socket sees final answers and encrypts a bounded excerpt with a random
89
+ per-device AES-256-GCM key provisioned over the existing authenticated HTTPS API.
90
+ That shared key is stored in the restricted Socket state; it is not sent to the
91
+ publisher gateway, FCM or APNs. The gateway validates and forwards ciphertext only.
92
+ This protects preview content from delivery intermediaries; it does not protect
93
+ against a compromised Socket, device, or its operator. It is not a transcript
94
+ end-to-end encryption claim.
95
+
96
+ Keys are scoped to the paired device, and notification ID and expiry are bound
97
+ into authenticated data. Key replacement, disable, logout and revocation remove
98
+ queued ciphertext; provider token rotation retains the key. The inbox never
99
+ returns preview content or keys. Pending ciphertext is deleted after delivery,
100
+ read or expiry. SQLite deletion is logical cleanup, not forensic secure erasure;
101
+ protect state backups as credentials. The OS may retain displayed notifications,
102
+ and previews can reveal answer content on a lock screen according to user settings.
103
+ Disabling cannot recall alerts already accepted or displayed by the provider/OS.
104
+ The device must remove its local key immediately so later queued ciphertext falls
105
+ back to generic text. Unsupported, expired or invalid payloads also fail generic.
106
+
107
+ ## Validation status
108
+
109
+ Local tests use dummy principals, a mock runtime. Some
110
+ tests exercise loopback HTTP/WebSocket transport. Configuration checking itself
111
+ does not send email or open a listener. None of these checks proves a deployed
112
+ proxy, domain, or real client/runtime behavior.
113
+
114
+ Package publication is not a claim of a completed independent security
115
+ audit. When investigating a problem, use synthetic credentials and redact
116
+ configuration, tokens, codes, and conversation content from reports.
@@ -0,0 +1,161 @@
1
+ # Self-hosting ABot Socket
2
+
3
+ Use [Installation and first connection](INSTALLATION.md) for the consumer
4
+ installation, setup and QR/manual pairing flow. This companion guide covers
5
+ configuration, the proxy trust boundary and ongoing operation. Service/proxy plan
6
+ generation is covered in [Deployment](DEPLOYMENT.md). The source is MIT-licensed;
7
+ the package is distributed as `@abot-ai/socket` on npm.
8
+
9
+ ## Requirements
10
+
11
+ - Node.js 24 or later. npm installs the package's pinned dependencies, `ws` and
12
+ `qrcode-generator`, with either the registry package or a local tarball.
13
+ - A server you control and a canonical HTTPS origin such as
14
+ `https://socket.example.net`.
15
+ - A local HTTPS reverse proxy satisfying the boundary below.
16
+ - An ABot Runtime with its existing agent-bridge adapter, and ABot App with pairing.
17
+
18
+ There is no public signup. Every paired device acts as this installation's single
19
+ owner and can see all runtimes granted to that installation.
20
+
21
+ ## 1. Prepare a private installation
22
+
23
+ Follow the [setup steps](INSTALLATION.md#follow-setup) as the unprivileged account
24
+ that will run the relay. Use your own HTTPS origin and keep the configuration,
25
+ runtime connection file and state outside the installed package directory.
26
+ Setup creates files only; applying a deployment plan and configuring the runtime
27
+ remain separate operator actions.
28
+
29
+ ## 2. Check configuration and issue fresh pairing details
30
+
31
+ For an installation outside the default `~/.abot-socket` directory:
32
+
33
+ ```sh
34
+ abot-socket check-config --config /srv/abot-private/config.json
35
+ abot-socket pair --config /srv/abot-private/config.json
36
+ ```
37
+
38
+ `stateDirectory` is resolved relative to the configuration file, independent of
39
+ the process working directory. An explicit `--config` takes precedence over
40
+ environment settings. Otherwise use `ABOT_SOCKET_CONFIG`; `SOCKET_V2_CONFIG`
41
+ remains a legacy alias and conflicting environment values are rejected.
42
+ No mail credential is used.
43
+
44
+ `check-config` validates the file without opening listeners or state. `pair`
45
+ updates only local authentication state: it replaces any unused pairing details
46
+ and displays a new five-minute bundle. Already paired devices stay connected.
47
+ The running server sees these changes without a restart. Local operator commands
48
+ must run as the same operating-system user that owns the private state directory.
49
+ Generate pairing details after HTTPS is ready, then follow the
50
+ [QR/manual pairing flow](INSTALLATION.md#connect-runtime-then-pair-the-app).
51
+
52
+ ## 3. Configure HTTPS and the proxy boundary
53
+
54
+ The application listens only on `127.0.0.1` at the configured port. Keep the proxy
55
+ on the same host and the application listener private. Containers, remote proxies,
56
+ and additional proxy chains require a separately validated network arrangement;
57
+ this package does not ship a Docker deployment.
58
+
59
+ For direct Internet-to-proxy-to-relay deployment, the proxy must:
60
+
61
+ 1. Accept only the configured HTTPS authority and a valid certificate; preserve
62
+ the exact configured Host, including a non-default port if one is configured.
63
+ 2. Support WebSocket upgrades on `/v2/realtime` and `/agent-bridge`, retaining the
64
+ `Authorization` header.
65
+ 3. Remove incoming `X-Forwarded-Proto` and `X-Socket-Client-IP` values and set its
66
+ own: `https` for the verified HTTPS request and the actual client IP observed
67
+ on the proxy's connection. Never copy a client-supplied forwarding header.
68
+ 4. Preserve an incoming browser `Origin` so the relay can reject an origin that
69
+ differs from its configured public origin.
70
+ 5. Enforce connection, request-body, and timeout limits. Keep credentials, pairing
71
+ codes, request bodies, and authorization headers out of logs.
72
+
73
+ If a CDN or another proxy sits in front, deriving the client IP requires an
74
+ explicit trusted-peer policy. Trusting its claimed IP header from arbitrary
75
+ Internet peers is unsafe. The direct-proxy instructions above do not configure
76
+ that extra trust chain.
77
+
78
+ ## 4. Start and connect
79
+
80
+ Use the [foreground or Linux service instructions](INSTALLATION.md#configure-https-and-start-the-relay),
81
+ then [connect the runtime and pair the app](INSTALLATION.md#connect-runtime-then-pair-the-app).
82
+ The app binds its device credential to the exact HTTPS origin and stores it in
83
+ the platform's protected credential storage. It does not follow authentication
84
+ or WebSocket redirects. A new server requires a new pairing; credentials are
85
+ never silently copied across hosts.
86
+
87
+ ## Optional push delivery
88
+
89
+ Push is disabled by default. To enable it, the operator adds
90
+ `notifications.delivery` to the private Socket configuration and restarts the
91
+ relay deliberately:
92
+
93
+ - A self-built app uses its own Firebase project and
94
+ `{ "type": "fcm", "serviceAccountFile": "./private-sender.json" }`.
95
+ The app and sender must belong to the same Firebase project. Keep the service
96
+ account on the server, owned by the service user with mode `0600`; a relative
97
+ path is resolved from the Socket configuration file.
98
+ - A Store build uses its publisher's trusted gateway with
99
+ `{ "type": "gateway", "gatewayOrigin": "https://<trusted-host>" }`.
100
+ Obtain that origin from the publisher's instructions. The publisher operates
101
+ the gateway and Firebase credentials; each user operates their own Socket.
102
+ Do not enter provider credentials into the app or run a second gateway merely
103
+ to use the Store build.
104
+
105
+ After server configuration, enable notifications in the app and grant platform
106
+ permission. Gateway builds enroll their own endpoint before registering it with
107
+ the paired Socket. A mismatched delivery mode is rejected; the app cannot replace
108
+ the operator's gateway URL. See [the notification protocol](PROTOCOL.md#notifications)
109
+ for device ownership, retention, delivery limitations and optional encrypted
110
+ answer previews. Gateway operation is separately described in
111
+ [Deployment](DEPLOYMENT.md#optional-notification-gateway).
112
+
113
+ ## Operation and recovery
114
+
115
+ ```sh
116
+ abot-socket devices --config /srv/abot-private/config.json
117
+ abot-socket revoke <device-id> --config /srv/abot-private/config.json
118
+ ```
119
+
120
+ The device list contains names, identifiers and expiry, never tokens. Revocation
121
+ also closes an already-open client WebSocket within the next validity sweep
122
+ (approximately one second). At 10 active devices, revoke an unused device before
123
+ pairing another. The server never silently evicts an existing device.
124
+
125
+ Sessions have a fixed maximum lifetime of 30 days and survive server restarts.
126
+ After expiry, pair again. Reinstalling the app may require pairing again depending
127
+ on the platform's secure-storage lifecycle; retaining the device's credential is
128
+ not a guarantee across uninstall, signing or bundle-ID changes.
129
+
130
+ Back up the private configuration, runtime credential and state directory using
131
+ a consistent filesystem/database snapshot or with the service stopped. Losing
132
+ state requires pairing devices again. Restoring an old backup may restore old
133
+ revocation state; reapply necessary revocations. Conversation history remains in
134
+ each runtime. Temporary relay routes do not survive restart. Durable request
135
+ ownership does: a compatible app can query current approvals on a runtime
136
+ advertising `request_approvals_v1` and restore live delivery for that request.
137
+ This does not reconstruct missed events or resend interrupted work; reconcile
138
+ against current runtime state before retrying.
139
+
140
+ Version 0.2.0 uses state schema v4. See the
141
+ [upgrade and rollback procedure](MIGRATION.md#durable-request-ownership-schema-upgrade)
142
+ before upgrading; older binaries require the matching pre-upgrade state backup.
143
+
144
+ The package does not provision a public TLS endpoint or install a service.
145
+ Real-device, proxy and runtime verification remains separate from automated tests.
146
+
147
+ ## Source-checkout development
148
+
149
+ These contributor commands require the repository checkout containing
150
+ `package.json`, `package-lock.json` and the source tests. They are not additional
151
+ steps for an installed npm/tarball consumer:
152
+
153
+ ```sh
154
+ npm ci --omit=optional --ignore-scripts
155
+ npm test
156
+ ```
157
+
158
+ To prepare a separate local development installation from that checkout, use
159
+ `node bin/abot-socket.mjs setup` and the same [installation guide](INSTALLATION.md).
160
+ Keep its private files outside source control. Tests use dummy identities and
161
+ loopback fixtures; they do not establish live runtime, phone or proxy readiness.
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@abot-ai/socket",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "publishConfig": {
5
+ "registry": "https://registry.npmjs.org/",
6
+ "access": "public"
7
+ },
8
+ "type": "module",
9
+ "engines": {
10
+ "node": ">=24"
11
+ },
12
+ "scripts": {
13
+ "start": "node bin/abot-socket.mjs start",
14
+ "setup": "node bin/abot-socket.mjs setup",
15
+ "check-config": "node bin/abot-socket.mjs check-config",
16
+ "test": "node --test test/*.test.mjs",
17
+ "pair": "node bin/abot-socket.mjs pair",
18
+ "devices": "node bin/abot-socket.mjs devices",
19
+ "revoke": "node bin/abot-socket.mjs revoke",
20
+ "doctor": "node bin/abot-socket.mjs doctor",
21
+ "deployment": "node bin/abot-socket.mjs deployment",
22
+ "check-package": "node scripts/check-package.mjs"
23
+ },
24
+ "dependencies": {
25
+ "qrcode-generator": "2.0.4",
26
+ "ws": "8.21.0"
27
+ },
28
+ "description": "Self-hosted authenticated relay for ABot clients and runtimes",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/Dorshitrit/abot-socket.git"
33
+ },
34
+ "bugs": {
35
+ "url": "https://github.com/Dorshitrit/abot-socket/issues"
36
+ },
37
+ "homepage": "https://github.com/Dorshitrit/abot-socket#readme",
38
+ "files": [
39
+ "bin/abot-socket.mjs",
40
+ "src/main.mjs",
41
+ "src/auth/*.mjs",
42
+ "src/cli/*.mjs",
43
+ "src/config/*.mjs",
44
+ "src/deployment/*.mjs",
45
+ "src/notifications/*.mjs",
46
+ "src/pairing/*.mjs",
47
+ "src/protocol/*.mjs",
48
+ "src/relay/*.mjs",
49
+ "src/review/*.mjs",
50
+ "src/setup/*.mjs",
51
+ "src/transport/*.mjs",
52
+ "README.md",
53
+ "CHANGELOG.md",
54
+ "LICENSE",
55
+ "config.example.json",
56
+ "docs/SELF-HOSTING.md",
57
+ "docs/PROTOCOL.md",
58
+ "docs/SECURITY.md",
59
+ "docs/MIGRATION.md",
60
+ "docs/DEPLOYMENT.md",
61
+ "docs/INSTALLATION.md",
62
+ "docs/APP-REVIEW.md"
63
+ ],
64
+ "bin": {
65
+ "abot-socket": "bin/abot-socket.mjs"
66
+ }
67
+ }
@@ -0,0 +1,105 @@
1
+ import { randomBytes, randomUUID } from 'node:crypto';
2
+ import { DatabaseSync } from 'node:sqlite';
3
+ import { assertSameDatabaseFile, prepareAuthenticationDatabase } from './auth-storage-path.mjs';
4
+ import { NOTIFICATION_SCHEMA } from '../notifications/notification-schema.mjs';
5
+ import { NOTIFICATION_PREVIEW_SCHEMA } from '../notifications/notification-preview-schema.mjs';
6
+ import { REQUEST_OWNERSHIP_SCHEMA } from '../relay/request-ownership-schema.mjs';
7
+
8
+ const APPLICATION_ID = 0x41425341;
9
+ const SCHEMA_VERSION = 4;
10
+
11
+ const SCHEMA = `
12
+ CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT;
13
+ CREATE TABLE pairing (
14
+ singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
15
+ id TEXT NOT NULL UNIQUE, digest BLOB NOT NULL, salt BLOB NOT NULL,
16
+ expires_at INTEGER NOT NULL, attempts INTEGER NOT NULL CHECK (attempts BETWEEN 0 AND 5)
17
+ ) STRICT;
18
+ CREATE TABLE devices (
19
+ id TEXT PRIMARY KEY, subject TEXT NOT NULL, device_name TEXT NOT NULL,
20
+ created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL,
21
+ token_hash TEXT NOT NULL UNIQUE
22
+ ) STRICT;
23
+ CREATE INDEX devices_expiry ON devices(expires_at);
24
+ CREATE TABLE pairing_attempts (source TEXT NOT NULL, attempted_at INTEGER NOT NULL) STRICT;
25
+ CREATE INDEX attempts_time ON pairing_attempts(attempted_at);
26
+ CREATE INDEX attempts_source ON pairing_attempts(source, attempted_at);
27
+ `;
28
+
29
+ /** One durable store shared by the operator CLI and relay processes. */
30
+ export class AuthDatabase {
31
+ #connection;
32
+ #closed = false;
33
+
34
+ constructor(stateDirectory) {
35
+ const prepared = prepareAuthenticationDatabase(stateDirectory);
36
+ this.#connection = new DatabaseSync(prepared.path);
37
+ try {
38
+ assertSameDatabaseFile(stateDirectory, prepared);
39
+ this.#connection.exec('PRAGMA busy_timeout = 5000; PRAGMA temp_store = MEMORY; PRAGMA foreign_keys = ON;');
40
+ const mode = this.#connection.prepare('PRAGMA journal_mode').get().journal_mode;
41
+ if (mode !== 'delete') throw new Error('Authentication database must use DELETE journaling');
42
+ this.transaction(() => this.#initialize());
43
+ this.#connection.exec('PRAGMA synchronous = FULL;');
44
+ assertSameDatabaseFile(stateDirectory, prepared);
45
+ } catch (error) {
46
+ this.close();
47
+ throw error;
48
+ }
49
+ }
50
+
51
+ prepare(sql) {
52
+ return this.#connection.prepare(sql);
53
+ }
54
+
55
+ transaction(operation) {
56
+ this.#connection.exec('BEGIN IMMEDIATE');
57
+ try {
58
+ const result = operation();
59
+ this.#connection.exec('COMMIT');
60
+ return result;
61
+ } catch (error) {
62
+ this.#connection.exec('ROLLBACK');
63
+ throw error;
64
+ }
65
+ }
66
+
67
+ get ownerId() {
68
+ return this.prepare("SELECT value FROM metadata WHERE key = 'owner_id'").get().value;
69
+ }
70
+
71
+ get tokenSalt() {
72
+ return this.prepare("SELECT value FROM metadata WHERE key = 'token_salt'").get().value;
73
+ }
74
+
75
+ close() {
76
+ if (this.#closed) return;
77
+ this.#closed = true;
78
+ this.#connection.close();
79
+ }
80
+
81
+ #initialize() {
82
+ const applicationId = this.prepare('PRAGMA application_id').get().application_id;
83
+ const version = this.prepare('PRAGMA user_version').get().user_version;
84
+ if (applicationId === APPLICATION_ID && version === SCHEMA_VERSION) return;
85
+ if (applicationId === APPLICATION_ID && [1, 2, 3].includes(version)) {
86
+ if (version === 1) this.#connection.exec(NOTIFICATION_SCHEMA);
87
+ if (version <= 2) this.#connection.exec(NOTIFICATION_PREVIEW_SCHEMA);
88
+ this.#connection.exec(REQUEST_OWNERSHIP_SCHEMA);
89
+ this.#connection.exec(`PRAGMA user_version = ${SCHEMA_VERSION};`);
90
+ return;
91
+ }
92
+ const objects = this.prepare("SELECT name FROM sqlite_schema WHERE name NOT LIKE 'sqlite_%'").all();
93
+ if (applicationId !== 0 || version !== 0 || objects.length) {
94
+ throw new Error('Unrecognized authentication database; no changes were made');
95
+ }
96
+ this.#connection.exec(SCHEMA);
97
+ this.#connection.exec(NOTIFICATION_SCHEMA);
98
+ this.#connection.exec(NOTIFICATION_PREVIEW_SCHEMA);
99
+ this.#connection.exec(REQUEST_OWNERSHIP_SCHEMA);
100
+ const insert = this.prepare('INSERT INTO metadata(key, value) VALUES (?, ?)');
101
+ insert.run('owner_id', randomUUID());
102
+ insert.run('token_salt', randomBytes(32).toString('hex'));
103
+ this.#connection.exec(`PRAGMA application_id = ${APPLICATION_ID}; PRAGMA user_version = ${SCHEMA_VERSION};`);
104
+ }
105
+ }
@@ -0,0 +1,17 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export class AuthError extends Error {
4
+ constructor(code, status) {
5
+ super(code);
6
+ this.name = 'AuthError';
7
+ this.code = code;
8
+ this.status = status;
9
+ }
10
+ }
11
+
12
+ export function sourceKey(sourceId) {
13
+ if (typeof sourceId !== 'string' || !sourceId || sourceId.length > 128) {
14
+ throw new AuthError('invalid_request', 400);
15
+ }
16
+ return createHash('sha256').update(sourceId).digest('hex');
17
+ }
@@ -0,0 +1,95 @@
1
+ import { randomBytes, randomInt, scryptSync, timingSafeEqual } from 'node:crypto';
2
+ import { AuthError, sourceKey } from './auth-limits.mjs';
3
+ import { issueDeviceSession } from './auth-sessions.mjs';
4
+
5
+ const PAIRING_MS = 5 * 60 * 1000;
6
+ const MAX_ATTEMPTS = 5;
7
+ const RATE_WINDOW_MS = 15 * 60 * 1000;
8
+ const codeDigest = (code, salt) => scryptSync(code, salt, 32);
9
+ const invalidCode = () => ({ error: new AuthError('invalid_code', 401) });
10
+
11
+ function hasPairingInput(input) {
12
+ if (!input || typeof input !== 'object') return false;
13
+ return !Array.isArray(input);
14
+ }
15
+
16
+ function hasValidDeviceName(name) {
17
+ if (typeof name !== 'string' || !name.trim()) return false;
18
+ if (name.length > 80) return false;
19
+ return !/[\x00-\x1f\x7f]/.test(name);
20
+ }
21
+
22
+ function matchesPairingCode(code, pairing) {
23
+ if (typeof code !== 'string' || !/^\d{6}$/.test(code)) return false;
24
+ return timingSafeEqual(pairing.digest, codeDigest(code, pairing.salt));
25
+ }
26
+
27
+ export class AuthPairingStore {
28
+ constructor({ database, now }) {
29
+ this.database = database;
30
+ this.now = now;
31
+ }
32
+
33
+ create() {
34
+ const pairingId = randomBytes(24).toString('base64url');
35
+ const code = randomInt(1_000_000).toString().padStart(6, '0');
36
+ const salt = randomBytes(32);
37
+ const digest = codeDigest(code, salt);
38
+ const expiresAt = this.now() + PAIRING_MS;
39
+ this.database.transaction(() => {
40
+ this.database.prepare('DELETE FROM pairing').run();
41
+ this.database.prepare(`INSERT INTO pairing(singleton, id, digest, salt, expires_at, attempts)
42
+ VALUES (1, ?, ?, ?, ?, 0)`).run(pairingId, digest, salt, expiresAt);
43
+ });
44
+ return { pairingId, code, expiresAt };
45
+ }
46
+
47
+ redeem(input, sourceId) {
48
+ const source = sourceKey(sourceId);
49
+ const result = this.database.transaction(() => this.#redeemTransaction(input, source));
50
+ if (result.error) throw result.error;
51
+ return result;
52
+ }
53
+
54
+ prune() {
55
+ const time = this.now();
56
+ this.database.transaction(() => {
57
+ this.database.prepare('DELETE FROM pairing WHERE expires_at <= ?').run(time);
58
+ this.database.prepare('DELETE FROM pairing_attempts WHERE attempted_at <= ?').run(time - RATE_WINDOW_MS);
59
+ });
60
+ }
61
+
62
+ #redeemTransaction(input, source) {
63
+ const time = this.now();
64
+ if (!hasPairingInput(input)) return invalidCode();
65
+ const { deviceName = 'Device' } = input;
66
+ if (!hasValidDeviceName(deviceName)) return { error: new AuthError('invalid_request', 400) };
67
+ if (typeof input.pairingId !== 'string' || !/^[A-Za-z0-9_-]{32}$/.test(input.pairingId)) return invalidCode();
68
+ const pairing = this.database.prepare('SELECT * FROM pairing WHERE id = ? AND expires_at > ?')
69
+ .get(input.pairingId, time);
70
+ if (!pairing || pairing.attempts >= MAX_ATTEMPTS) return invalidCode();
71
+ // Unrelated or unusable challenges must not spend the verification quota.
72
+ if (!this.#consumeRateLimit(source, time)) return { error: new AuthError('rate_limited', 429) };
73
+ if (!matchesPairingCode(input.code, pairing)) {
74
+ this.database.prepare('UPDATE pairing SET attempts = attempts + 1 WHERE id = ?').run(pairing.id);
75
+ return invalidCode();
76
+ }
77
+ let session;
78
+ try { session = issueDeviceSession(this.database, time, deviceName.trim()); } catch (error) {
79
+ if (error instanceof AuthError) return { error };
80
+ throw error;
81
+ }
82
+ this.database.prepare('DELETE FROM pairing WHERE id = ?').run(pairing.id);
83
+ return session;
84
+ }
85
+
86
+ #consumeRateLimit(source, time) {
87
+ this.database.prepare('DELETE FROM pairing_attempts WHERE attempted_at <= ?').run(time - RATE_WINDOW_MS);
88
+ const total = this.database.prepare('SELECT count(*) AS count FROM pairing_attempts').get().count;
89
+ if (total >= 100) return false;
90
+ const attempts = this.database.prepare('SELECT count(*) AS count FROM pairing_attempts WHERE source = ?').get(source).count;
91
+ if (attempts >= 20) return false;
92
+ this.database.prepare('INSERT INTO pairing_attempts(source, attempted_at) VALUES (?, ?)').run(source, time);
93
+ return true;
94
+ }
95
+ }