or3-provider-sqlite 0.0.2 → 0.0.4

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 (25) hide show
  1. package/README.md +14 -1
  2. package/dist/module.json +1 -1
  3. package/dist/runtime/server/connect/sqlite-connect-store.d.ts +2 -0
  4. package/dist/runtime/server/connect/sqlite-connect-store.js +885 -0
  5. package/dist/runtime/server/db/migrate.js +19 -2
  6. package/dist/runtime/server/db/migrations/009_or3_connect.d.ts +4 -0
  7. package/dist/runtime/server/db/migrations/009_or3_connect.js +91 -0
  8. package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.d.ts +10 -0
  9. package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.js +97 -0
  10. package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.d.ts +4 -0
  11. package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.js +26 -0
  12. package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.d.ts +4 -0
  13. package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.js +124 -0
  14. package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.d.ts +10 -0
  15. package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.js +261 -0
  16. package/dist/runtime/server/db/migrations/014_connect_retention_activation.d.ts +4 -0
  17. package/dist/runtime/server/db/migrations/014_connect_retention_activation.js +44 -0
  18. package/dist/runtime/server/db/migrations/015_rate_limits.d.ts +4 -0
  19. package/dist/runtime/server/db/migrations/015_rate_limits.js +18 -0
  20. package/dist/runtime/server/db/schema.d.ts +57 -0
  21. package/dist/runtime/server/plugins/register.d.ts +1 -1
  22. package/dist/runtime/server/plugins/register.js +24 -8
  23. package/dist/runtime/server/rate-limit/sqlite-provider.d.ts +7 -0
  24. package/dist/runtime/server/rate-limit/sqlite-provider.js +70 -0
  25. package/package.json +10 -2
@@ -0,0 +1,885 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ ConnectStoreError
4
+ } from "~~/server/connect/store/types";
5
+ import { getRawDb } from "../db/kysely.js";
6
+ const ENVIRONMENT_COLUMNS = `
7
+ id, user_id, workspace_id, name, platform, architecture, host_id,
8
+ signing_public_key, noise_public_key, authorization_id, hostname,
9
+ tunnel_id, dns_record_id, control_token_hash, access_credential_ciphertext,
10
+ tunnel_secret_ciphertext, status, lifecycle_attempts,
11
+ lifecycle_next_attempt_at, lifecycle_claim_token,
12
+ lifecycle_claimed_until, provisioning_deadline_at,
13
+ activation_deadline_at, activation_claimed_at, lifecycle_error,
14
+ relay_authenticator
15
+ `;
16
+ export function createSqliteConnectStore() {
17
+ return new SqliteConnectStore();
18
+ }
19
+ class SqliteConnectStore {
20
+ async createAuthorization(input) {
21
+ const db = getRawDb();
22
+ db.transaction(() => {
23
+ const collision = db.prepare(
24
+ `SELECT 1
25
+ FROM connect_device_authorizations
26
+ WHERE user_code_hash = ?
27
+ AND status = 'pending'
28
+ AND expires_at > ?
29
+ LIMIT 1`
30
+ ).get(input.userCodeHash, input.now);
31
+ if (collision) {
32
+ throw new ConnectStoreError(
33
+ "conflict",
34
+ "Pairing code collision. Generate another code."
35
+ );
36
+ }
37
+ db.prepare(
38
+ `INSERT INTO connect_device_authorizations (
39
+ id, device_code_hash, user_code_hash,
40
+ status, host_json, expires_at, created_at, updated_at
41
+ ) VALUES (?, ?, ?, 'pending', ?, ?, ?, ?)`
42
+ ).run(
43
+ randomUUID(),
44
+ input.deviceCodeHash,
45
+ input.userCodeHash,
46
+ JSON.stringify(input.host),
47
+ input.expiresAt,
48
+ input.now,
49
+ input.now
50
+ );
51
+ })();
52
+ }
53
+ async getAuthorizationByDeviceHash(deviceCodeHash, now, redeliveryWindowMs) {
54
+ const boundedRedeliveryWindow = normalizeRedeliveryWindow(
55
+ redeliveryWindowMs
56
+ );
57
+ const db = getRawDb();
58
+ return db.transaction(() => {
59
+ const row = db.prepare(
60
+ `SELECT id, status, host_json,
61
+ approved_user_id, approved_workspace_id,
62
+ environment_id,
63
+ credential_ciphertext,
64
+ credential_delivery_started_at,
65
+ credential_redeliver_until, expires_at
66
+ FROM connect_device_authorizations
67
+ WHERE device_code_hash = ?`
68
+ ).get(deviceCodeHash);
69
+ if (!row) return null;
70
+ if ((row.status === "pending" || row.status === "approved") && row.expires_at <= now) {
71
+ db.prepare(
72
+ `UPDATE connect_device_authorizations
73
+ SET status = 'expired',
74
+ credential_ciphertext = NULL,
75
+ updated_at = ?
76
+ WHERE id = ? AND status IN ('pending', 'approved')`
77
+ ).run(now, row.id);
78
+ return toAuthorizationRecord({
79
+ ...row,
80
+ status: "expired",
81
+ credential_ciphertext: null
82
+ });
83
+ }
84
+ if (row.status === "approved") {
85
+ const deliveryStarted = db.prepare(
86
+ `UPDATE connect_device_authorizations
87
+ SET status = 'delivering',
88
+ credential_delivery_started_at = ?,
89
+ credential_redeliver_until = ?,
90
+ updated_at = ?
91
+ WHERE id = ? AND status = 'approved'`
92
+ ).run(
93
+ now,
94
+ now + boundedRedeliveryWindow,
95
+ now,
96
+ row.id
97
+ );
98
+ if (deliveryStarted.changes !== 1) {
99
+ return {
100
+ ...toAuthorizationRecord(row),
101
+ status: "consumed",
102
+ credential_ciphertext: void 0
103
+ };
104
+ }
105
+ if (row.environment_id) {
106
+ db.prepare(
107
+ `UPDATE connect_environments
108
+ SET activation_claimed_at = ?,
109
+ activation_deadline_at = NULL,
110
+ updated_at = ?
111
+ WHERE id = ?
112
+ AND status = 'active'
113
+ AND activation_claimed_at IS NULL`
114
+ ).run(now, now, row.environment_id);
115
+ }
116
+ return toAuthorizationRecord(row);
117
+ }
118
+ if (row.status === "delivering") {
119
+ if (row.credential_ciphertext && row.credential_redeliver_until !== null && row.credential_redeliver_until >= now) {
120
+ return {
121
+ ...toAuthorizationRecord(row),
122
+ status: "approved"
123
+ };
124
+ }
125
+ db.prepare(
126
+ `UPDATE connect_device_authorizations
127
+ SET status = 'consumed',
128
+ credential_ciphertext = NULL,
129
+ updated_at = ?
130
+ WHERE id = ? AND status = 'delivering'`
131
+ ).run(now, row.id);
132
+ return {
133
+ ...toAuthorizationRecord(row),
134
+ status: "consumed",
135
+ credential_ciphertext: void 0
136
+ };
137
+ }
138
+ return toAuthorizationRecord(row);
139
+ })();
140
+ }
141
+ async getAuthorizationByUserHash(userCodeHash, now) {
142
+ const row = getRawDb().prepare(
143
+ `SELECT id, status, host_json,
144
+ approved_user_id, approved_workspace_id,
145
+ environment_id, credential_ciphertext, expires_at
146
+ FROM connect_device_authorizations
147
+ WHERE user_code_hash = ?
148
+ ORDER BY created_at DESC
149
+ LIMIT 1`
150
+ ).get(userCodeHash);
151
+ if (!row || row.expires_at <= now || row.status === "denied" || row.status === "expired") {
152
+ return null;
153
+ }
154
+ return toAuthorizationRecord(row);
155
+ }
156
+ async approveAuthorization(input) {
157
+ const db = getRawDb();
158
+ return db.transaction(() => {
159
+ const authorization = db.prepare(
160
+ `SELECT id, status, expires_at
161
+ FROM connect_device_authorizations
162
+ WHERE id = ?`
163
+ ).get(input.authorizationId);
164
+ if (!authorization || authorization.status !== "pending" || authorization.expires_at <= input.now) {
165
+ throw new ConnectStoreError(
166
+ "authorization_unavailable",
167
+ "This connection request is no longer available."
168
+ );
169
+ }
170
+ const active = input.limitPolicy.scope === "workspace" ? db.prepare(
171
+ `SELECT COUNT(*) AS count
172
+ FROM connect_environments
173
+ WHERE user_id = ?
174
+ AND workspace_id = ?
175
+ AND status = 'active'`
176
+ ).get(input.userId, input.workspaceId) : db.prepare(
177
+ `SELECT COUNT(*) AS count
178
+ FROM connect_environments
179
+ WHERE user_id = ? AND status = 'active'`
180
+ ).get(input.userId);
181
+ if (active.count >= input.limitPolicy.maxActiveEnvironments) {
182
+ throw new ConnectStoreError(
183
+ "environment_limit_reached",
184
+ `This ${input.limitPolicy.scope} already has ${input.limitPolicy.maxActiveEnvironments} connected computers.`
185
+ );
186
+ }
187
+ const environment = input.environment;
188
+ db.prepare(
189
+ `INSERT INTO connect_environments (
190
+ id, user_id, workspace_id, name, platform, architecture,
191
+ host_id, signing_public_key, noise_public_key, hostname,
192
+ tunnel_id, dns_record_id, control_token_hash,
193
+ access_credential_ciphertext, status, created_at, updated_at
194
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)`
195
+ ).run(
196
+ environment.id,
197
+ input.userId,
198
+ input.workspaceId,
199
+ environment.name,
200
+ environment.platform,
201
+ environment.architecture,
202
+ environment.host_id ?? null,
203
+ environment.signing_public_key ?? null,
204
+ environment.noise_public_key ?? null,
205
+ environment.hostname,
206
+ environment.tunnel_id,
207
+ environment.dns_record_id,
208
+ environment.control_token_hash,
209
+ environment.access_credential_ciphertext,
210
+ input.now,
211
+ input.now
212
+ );
213
+ const approved = db.prepare(
214
+ `UPDATE connect_device_authorizations
215
+ SET status = 'approved',
216
+ approved_user_id = ?,
217
+ approved_workspace_id = ?,
218
+ environment_id = ?,
219
+ credential_ciphertext = ?,
220
+ updated_at = ?
221
+ WHERE id = ? AND status = 'pending' AND expires_at > ?`
222
+ ).run(
223
+ input.userId,
224
+ input.workspaceId,
225
+ environment.id,
226
+ input.credentialCiphertext,
227
+ input.now,
228
+ input.authorizationId,
229
+ input.now
230
+ );
231
+ if (approved.changes !== 1) {
232
+ throw new ConnectStoreError(
233
+ "conflict",
234
+ "This connection request was already handled."
235
+ );
236
+ }
237
+ return { environment_id: environment.id };
238
+ })();
239
+ }
240
+ async reserveAuthorization(input) {
241
+ const db = getRawDb();
242
+ return db.transaction(() => {
243
+ const authorization = db.prepare(
244
+ `SELECT id, status, expires_at
245
+ FROM connect_device_authorizations
246
+ WHERE id = ?`
247
+ ).get(input.authorizationId);
248
+ if (!authorization || authorization.status !== "pending" || authorization.expires_at <= input.now) {
249
+ throw new ConnectStoreError(
250
+ "authorization_unavailable",
251
+ "This connection request is no longer available."
252
+ );
253
+ }
254
+ const reserved = input.limitPolicy.scope === "workspace" ? db.prepare(
255
+ `SELECT COUNT(*) AS count
256
+ FROM connect_environments
257
+ WHERE user_id = ?
258
+ AND workspace_id = ?
259
+ AND status IN (
260
+ 'provisioning', 'active', 'revoking'
261
+ )`
262
+ ).get(input.userId, input.workspaceId) : db.prepare(
263
+ `SELECT COUNT(*) AS count
264
+ FROM connect_environments
265
+ WHERE user_id = ?
266
+ AND status IN (
267
+ 'provisioning', 'active', 'revoking'
268
+ )`
269
+ ).get(input.userId);
270
+ if (reserved.count >= input.limitPolicy.maxActiveEnvironments) {
271
+ throw new ConnectStoreError(
272
+ "environment_limit_reached",
273
+ `This ${input.limitPolicy.scope} already has ${input.limitPolicy.maxActiveEnvironments} connected computers.`
274
+ );
275
+ }
276
+ const environment = input.environment;
277
+ db.prepare(
278
+ `INSERT INTO connect_environments (
279
+ id, user_id, workspace_id, name, platform, architecture,
280
+ host_id, signing_public_key, noise_public_key,
281
+ authorization_id, hostname, tunnel_id, dns_record_id,
282
+ control_token_hash, access_credential_ciphertext,
283
+ tunnel_secret_ciphertext, status, lifecycle_attempts,
284
+ lifecycle_next_attempt_at, lifecycle_claim_token,
285
+ lifecycle_claimed_until, provisioning_deadline_at,
286
+ activation_deadline_at, activation_claimed_at,
287
+ created_at, updated_at
288
+ ) VALUES (
289
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', '', '', ?, ?, ?,
290
+ 'provisioning', 0, ?, ?, ?, ?, ?, NULL, ?, ?
291
+ )`
292
+ ).run(
293
+ environment.id,
294
+ input.userId,
295
+ input.workspaceId,
296
+ environment.name,
297
+ environment.platform,
298
+ environment.architecture,
299
+ environment.host_id ?? null,
300
+ environment.signing_public_key ?? null,
301
+ environment.noise_public_key ?? null,
302
+ input.authorizationId,
303
+ environment.control_token_hash,
304
+ environment.access_credential_ciphertext,
305
+ environment.tunnel_secret_ciphertext,
306
+ input.now,
307
+ input.claimToken,
308
+ input.claimUntil,
309
+ input.provisioningDeadlineAt,
310
+ input.activationDeadlineAt,
311
+ input.now,
312
+ input.now
313
+ );
314
+ const reservedAuthorization = db.prepare(
315
+ `UPDATE connect_device_authorizations
316
+ SET status = 'provisioning',
317
+ approved_user_id = ?,
318
+ approved_workspace_id = ?,
319
+ environment_id = ?,
320
+ expires_at = CASE
321
+ WHEN expires_at > ? THEN expires_at
322
+ ELSE ?
323
+ END,
324
+ updated_at = ?
325
+ WHERE id = ?
326
+ AND status = 'pending'
327
+ AND expires_at > ?`
328
+ ).run(
329
+ input.userId,
330
+ input.workspaceId,
331
+ environment.id,
332
+ input.authorizationExpiresAt,
333
+ input.authorizationExpiresAt,
334
+ input.now,
335
+ input.authorizationId,
336
+ input.now
337
+ );
338
+ if (reservedAuthorization.changes !== 1) {
339
+ throw new ConnectStoreError(
340
+ "conflict",
341
+ "This connection request was already handled."
342
+ );
343
+ }
344
+ return toEnvironmentRecord(
345
+ requireEnvironmentById(db, environment.id)
346
+ );
347
+ })();
348
+ }
349
+ async claimNextEnvironmentLifecycle(claimToken, now, claimUntil) {
350
+ const db = getRawDb();
351
+ return db.transaction(() => {
352
+ const abandoned = db.prepare(
353
+ `SELECT id
354
+ FROM connect_environments
355
+ WHERE status = 'active'
356
+ AND activation_claimed_at IS NULL
357
+ AND activation_deadline_at IS NOT NULL
358
+ AND activation_deadline_at <= ?
359
+ ORDER BY activation_deadline_at ASC, id ASC
360
+ LIMIT 1`
361
+ ).get(now);
362
+ if (abandoned) {
363
+ db.prepare(
364
+ `UPDATE connect_device_authorizations
365
+ SET status = CASE
366
+ WHEN status IN (
367
+ 'pending', 'provisioning',
368
+ 'approved', 'delivering'
369
+ ) THEN 'expired'
370
+ ELSE status
371
+ END,
372
+ credential_ciphertext = NULL,
373
+ updated_at = ?
374
+ WHERE environment_id = ?`
375
+ ).run(now, abandoned.id);
376
+ const transitioned = db.prepare(
377
+ `UPDATE connect_environments
378
+ SET status = 'revoking',
379
+ access_credential_ciphertext = '',
380
+ tunnel_secret_ciphertext = NULL,
381
+ lifecycle_next_attempt_at = ?,
382
+ lifecycle_claim_token = ?,
383
+ lifecycle_claimed_until = ?,
384
+ lifecycle_error = 'Activation deadline expired.',
385
+ updated_at = ?
386
+ WHERE id = ?
387
+ AND status = 'active'
388
+ AND activation_claimed_at IS NULL
389
+ AND activation_deadline_at <= ?`
390
+ ).run(
391
+ now,
392
+ claimToken,
393
+ claimUntil,
394
+ now,
395
+ abandoned.id,
396
+ now
397
+ );
398
+ if (transitioned.changes === 1) {
399
+ return toEnvironmentRecord(
400
+ requireEnvironmentById(db, abandoned.id)
401
+ );
402
+ }
403
+ }
404
+ const candidate = db.prepare(
405
+ `SELECT id
406
+ FROM connect_environments
407
+ WHERE status IN ('provisioning', 'revoking')
408
+ AND lifecycle_next_attempt_at <= ?
409
+ AND (
410
+ lifecycle_claimed_until IS NULL
411
+ OR lifecycle_claimed_until <= ?
412
+ )
413
+ ORDER BY lifecycle_next_attempt_at ASC, created_at ASC
414
+ LIMIT 1`
415
+ ).get(now, now);
416
+ if (!candidate) return null;
417
+ const claimed = db.prepare(
418
+ `UPDATE connect_environments
419
+ SET lifecycle_claim_token = ?,
420
+ lifecycle_claimed_until = ?,
421
+ updated_at = ?
422
+ WHERE id = ?
423
+ AND status IN ('provisioning', 'revoking')
424
+ AND (
425
+ lifecycle_claimed_until IS NULL
426
+ OR lifecycle_claimed_until <= ?
427
+ )`
428
+ ).run(claimToken, claimUntil, now, candidate.id, now);
429
+ if (claimed.changes !== 1) return null;
430
+ return toEnvironmentRecord(
431
+ requireEnvironmentById(db, candidate.id)
432
+ );
433
+ })();
434
+ }
435
+ async saveEnvironmentRelayProgress(environmentId, expectedStatus, claimToken, progress, now) {
436
+ const assignments = ["updated_at = ?"];
437
+ const values = [now];
438
+ if (progress.hostname !== void 0) {
439
+ assignments.push("hostname = ?");
440
+ values.push(progress.hostname);
441
+ }
442
+ if (progress.tunnelId !== void 0) {
443
+ assignments.push("tunnel_id = ?");
444
+ values.push(progress.tunnelId);
445
+ }
446
+ if (progress.dnsRecordId !== void 0) {
447
+ assignments.push("dns_record_id = ?");
448
+ values.push(progress.dnsRecordId);
449
+ }
450
+ if (progress.relayAuthenticator !== void 0) {
451
+ assignments.push("relay_authenticator = ?");
452
+ values.push(progress.relayAuthenticator);
453
+ }
454
+ values.push(environmentId, expectedStatus, claimToken);
455
+ const result = getRawDb().prepare(
456
+ `UPDATE connect_environments
457
+ SET ${assignments.join(", ")}
458
+ WHERE id = ?
459
+ AND status = ?
460
+ AND lifecycle_claim_token = ?`
461
+ ).run(...values);
462
+ return result.changes === 1;
463
+ }
464
+ async completeEnvironmentProvisioning(environmentId, claimToken, credentialCiphertext, now) {
465
+ const db = getRawDb();
466
+ return db.transaction(() => {
467
+ const environment = requireEnvironmentById(db, environmentId);
468
+ if (environment.status !== "provisioning" || environment.lifecycle_claim_token !== claimToken || !environment.authorization_id || !environment.hostname || !environment.tunnel_id || !environment.dns_record_id) {
469
+ return false;
470
+ }
471
+ const approved = db.prepare(
472
+ `UPDATE connect_device_authorizations
473
+ SET status = 'approved',
474
+ credential_ciphertext = ?,
475
+ updated_at = ?
476
+ WHERE id = ? AND status = 'provisioning'`
477
+ ).run(
478
+ credentialCiphertext,
479
+ now,
480
+ environment.authorization_id
481
+ );
482
+ if (approved.changes !== 1) {
483
+ throw new ConnectStoreError(
484
+ "conflict",
485
+ "The reserved connection request could not be finalized."
486
+ );
487
+ }
488
+ const activated = db.prepare(
489
+ `UPDATE connect_environments
490
+ SET status = 'active',
491
+ tunnel_secret_ciphertext = NULL,
492
+ lifecycle_claim_token = NULL,
493
+ lifecycle_claimed_until = NULL,
494
+ lifecycle_next_attempt_at = 0,
495
+ lifecycle_error = NULL,
496
+ updated_at = ?
497
+ WHERE id = ?
498
+ AND status = 'provisioning'
499
+ AND lifecycle_claim_token = ?`
500
+ ).run(now, environmentId, claimToken);
501
+ if (activated.changes !== 1) {
502
+ throw new ConnectStoreError(
503
+ "conflict",
504
+ "The reserved environment could not be activated."
505
+ );
506
+ }
507
+ return true;
508
+ })();
509
+ }
510
+ async beginEnvironmentRevocation(input) {
511
+ const db = getRawDb();
512
+ return db.transaction(() => {
513
+ const row = db.prepare(
514
+ `SELECT ${ENVIRONMENT_COLUMNS}
515
+ FROM connect_environments
516
+ WHERE id = ? AND user_id = ? AND workspace_id = ?`
517
+ ).get(
518
+ input.environmentId,
519
+ input.scope.userId,
520
+ input.scope.workspaceId
521
+ );
522
+ if (!row) return null;
523
+ if (row.status === "revoked") {
524
+ return {
525
+ claimed: false,
526
+ environment: toEnvironmentRecord(row)
527
+ };
528
+ }
529
+ if (row.status !== "active" && row.status !== "revoking") {
530
+ return null;
531
+ }
532
+ const canClaim = row.status === "active" || row.lifecycle_claimed_until === null || row.lifecycle_claimed_until <= input.now;
533
+ if (!canClaim) {
534
+ return {
535
+ claimed: false,
536
+ environment: toEnvironmentRecord(row)
537
+ };
538
+ }
539
+ db.prepare(
540
+ `UPDATE connect_device_authorizations
541
+ SET status = CASE
542
+ WHEN status IN (
543
+ 'pending', 'provisioning',
544
+ 'approved', 'delivering'
545
+ ) THEN 'expired'
546
+ ELSE status
547
+ END,
548
+ credential_ciphertext = NULL,
549
+ updated_at = ?
550
+ WHERE environment_id = ?`
551
+ ).run(input.now, input.environmentId);
552
+ const result = db.prepare(
553
+ `UPDATE connect_environments
554
+ SET status = 'revoking',
555
+ access_credential_ciphertext = '',
556
+ tunnel_secret_ciphertext = NULL,
557
+ lifecycle_claim_token = ?,
558
+ lifecycle_claimed_until = ?,
559
+ lifecycle_next_attempt_at = ?,
560
+ lifecycle_error = NULL,
561
+ updated_at = ?
562
+ WHERE id = ?
563
+ AND user_id = ?
564
+ AND workspace_id = ?
565
+ AND status IN ('active', 'revoking')
566
+ AND (
567
+ status = 'active'
568
+ OR lifecycle_claimed_until IS NULL
569
+ OR lifecycle_claimed_until <= ?
570
+ )`
571
+ ).run(
572
+ input.claimToken,
573
+ input.claimUntil,
574
+ input.now,
575
+ input.now,
576
+ input.environmentId,
577
+ input.scope.userId,
578
+ input.scope.workspaceId,
579
+ input.now
580
+ );
581
+ if (result.changes !== 1) return null;
582
+ return {
583
+ claimed: true,
584
+ environment: toEnvironmentRecord(
585
+ requireEnvironmentById(db, input.environmentId)
586
+ )
587
+ };
588
+ })();
589
+ }
590
+ async abandonEnvironmentProvisioning(environmentId, claimToken, now) {
591
+ const db = getRawDb();
592
+ return db.transaction(() => {
593
+ const environment = requireEnvironmentById(db, environmentId);
594
+ if (environment.status !== "provisioning" || environment.lifecycle_claim_token !== claimToken) {
595
+ return null;
596
+ }
597
+ if (environment.authorization_id) {
598
+ db.prepare(
599
+ `UPDATE connect_device_authorizations
600
+ SET status = 'expired',
601
+ credential_ciphertext = NULL,
602
+ updated_at = ?
603
+ WHERE id = ? AND status = 'provisioning'`
604
+ ).run(now, environment.authorization_id);
605
+ }
606
+ const changed = db.prepare(
607
+ `UPDATE connect_environments
608
+ SET status = 'revoking',
609
+ access_credential_ciphertext = '',
610
+ tunnel_secret_ciphertext = NULL,
611
+ lifecycle_next_attempt_at = ?,
612
+ lifecycle_error = 'Provisioning deadline expired.',
613
+ updated_at = ?
614
+ WHERE id = ?
615
+ AND status = 'provisioning'
616
+ AND lifecycle_claim_token = ?`
617
+ ).run(now, now, environmentId, claimToken);
618
+ return changed.changes === 1 ? toEnvironmentRecord(requireEnvironmentById(db, environmentId)) : null;
619
+ })();
620
+ }
621
+ async completeEnvironmentRevocation(environmentId, claimToken, now) {
622
+ const db = getRawDb();
623
+ return db.transaction(() => {
624
+ const environment = requireEnvironmentById(db, environmentId);
625
+ if (environment.status !== "revoking" || environment.lifecycle_claim_token !== claimToken) {
626
+ return false;
627
+ }
628
+ if (environment.authorization_id) {
629
+ db.prepare(
630
+ `UPDATE connect_device_authorizations
631
+ SET status = CASE
632
+ WHEN status IN (
633
+ 'provisioning', 'approved', 'delivering'
634
+ ) THEN 'expired'
635
+ ELSE status
636
+ END,
637
+ credential_ciphertext = NULL,
638
+ updated_at = ?
639
+ WHERE id = ?`
640
+ ).run(now, environment.authorization_id);
641
+ }
642
+ const result = db.prepare(
643
+ `UPDATE connect_environments
644
+ SET status = 'revoked',
645
+ access_credential_ciphertext = '',
646
+ tunnel_secret_ciphertext = NULL,
647
+ hostname = '',
648
+ tunnel_id = '',
649
+ dns_record_id = '',
650
+ relay_authenticator = NULL,
651
+ lifecycle_claim_token = NULL,
652
+ lifecycle_claimed_until = NULL,
653
+ lifecycle_next_attempt_at = 0,
654
+ lifecycle_error = NULL,
655
+ revoked_at = ?,
656
+ updated_at = ?
657
+ WHERE id = ?
658
+ AND status = 'revoking'
659
+ AND lifecycle_claim_token = ?`
660
+ ).run(now, now, environmentId, claimToken);
661
+ return result.changes === 1;
662
+ })();
663
+ }
664
+ async recordEnvironmentLifecycleFailure(environmentId, expectedStatus, claimToken, errorMessage, nextAttemptAt, now) {
665
+ const result = getRawDb().prepare(
666
+ `UPDATE connect_environments
667
+ SET lifecycle_attempts = lifecycle_attempts + 1,
668
+ lifecycle_next_attempt_at = ?,
669
+ lifecycle_claim_token = NULL,
670
+ lifecycle_claimed_until = NULL,
671
+ lifecycle_error = ?,
672
+ updated_at = ?
673
+ WHERE id = ?
674
+ AND status = ?
675
+ AND lifecycle_claim_token = ?`
676
+ ).run(
677
+ nextAttemptAt,
678
+ errorMessage.slice(0, 500),
679
+ now,
680
+ environmentId,
681
+ expectedStatus,
682
+ claimToken
683
+ );
684
+ return result.changes === 1;
685
+ }
686
+ async denyAuthorization(authorizationId, now) {
687
+ const result = getRawDb().prepare(
688
+ `UPDATE connect_device_authorizations
689
+ SET status = 'denied', updated_at = ?
690
+ WHERE id = ? AND status = 'pending' AND expires_at > ?`
691
+ ).run(now, authorizationId, now);
692
+ return result.changes === 1;
693
+ }
694
+ async getEnvironmentByControlTokenHash(controlTokenHash, scope) {
695
+ const row = getRawDb().prepare(
696
+ `SELECT ${ENVIRONMENT_COLUMNS}
697
+ FROM connect_environments
698
+ WHERE user_id = ?
699
+ AND workspace_id = ?
700
+ AND control_token_hash = ?`
701
+ ).get(
702
+ scope.userId,
703
+ scope.workspaceId,
704
+ controlTokenHash
705
+ );
706
+ return row ? toEnvironmentRecord(row) : null;
707
+ }
708
+ async listEnvironments(scope) {
709
+ const rows = getRawDb().prepare(
710
+ `SELECT ${ENVIRONMENT_COLUMNS}
711
+ FROM connect_environments
712
+ WHERE user_id = ?
713
+ AND workspace_id = ?
714
+ AND status = 'active'
715
+ ORDER BY updated_at DESC, id ASC`
716
+ ).all(scope.userId, scope.workspaceId);
717
+ return rows.map(toEnvironmentRecord);
718
+ }
719
+ async revokeEnvironment(environmentId, scope, now) {
720
+ const db = getRawDb();
721
+ return db.transaction(() => {
722
+ db.prepare(
723
+ `UPDATE connect_device_authorizations
724
+ SET status = CASE
725
+ WHEN status IN (
726
+ 'pending', 'provisioning',
727
+ 'approved', 'delivering'
728
+ ) THEN 'expired'
729
+ ELSE status
730
+ END,
731
+ credential_ciphertext = NULL,
732
+ updated_at = ?
733
+ WHERE environment_id = ?`
734
+ ).run(now, environmentId);
735
+ const result = db.prepare(
736
+ `UPDATE connect_environments
737
+ SET status = 'revoked',
738
+ access_credential_ciphertext = '',
739
+ tunnel_secret_ciphertext = NULL,
740
+ hostname = '',
741
+ tunnel_id = '',
742
+ dns_record_id = '',
743
+ relay_authenticator = NULL,
744
+ revoked_at = ?,
745
+ updated_at = ?
746
+ WHERE user_id = ?
747
+ AND workspace_id = ?
748
+ AND id = ?
749
+ AND status <> 'revoked'`
750
+ ).run(
751
+ now,
752
+ now,
753
+ scope.userId,
754
+ scope.workspaceId,
755
+ environmentId
756
+ );
757
+ return result.changes === 1;
758
+ })();
759
+ }
760
+ async purgeConnectRecords(input) {
761
+ const batchSize = normalizePurgeBatchSize(input.batchSize);
762
+ const db = getRawDb();
763
+ return db.transaction(() => {
764
+ const authorizations = db.prepare(
765
+ `DELETE FROM connect_device_authorizations
766
+ WHERE id IN (
767
+ SELECT id
768
+ FROM connect_device_authorizations
769
+ WHERE status IN ('denied', 'consumed', 'expired')
770
+ AND updated_at <= ?
771
+ ORDER BY updated_at ASC, id ASC
772
+ LIMIT ?
773
+ )`
774
+ ).run(input.authorizationUpdatedBefore, batchSize).changes;
775
+ const environments = db.prepare(
776
+ `DELETE FROM connect_environments
777
+ WHERE id IN (
778
+ SELECT id
779
+ FROM connect_environments
780
+ WHERE status = 'revoked'
781
+ AND updated_at <= ?
782
+ ORDER BY updated_at ASC, id ASC
783
+ LIMIT ?
784
+ )`
785
+ ).run(input.revokedEnvironmentUpdatedBefore, batchSize).changes;
786
+ return { authorizations, environments };
787
+ })();
788
+ }
789
+ async rotateAuthorizationCredential(authorizationId, expectedCiphertext, replacementCiphertext, now) {
790
+ const result = getRawDb().prepare(
791
+ `UPDATE connect_device_authorizations
792
+ SET credential_ciphertext = ?, updated_at = ?
793
+ WHERE id = ? AND credential_ciphertext = ?`
794
+ ).run(
795
+ replacementCiphertext,
796
+ now,
797
+ authorizationId,
798
+ expectedCiphertext
799
+ );
800
+ return result.changes === 1;
801
+ }
802
+ async rotateEnvironmentCredential(environmentId, purpose, expectedCiphertext, replacementCiphertext, now) {
803
+ const column = purpose === "access" ? "access_credential_ciphertext" : "tunnel_secret_ciphertext";
804
+ const result = getRawDb().prepare(
805
+ `UPDATE connect_environments
806
+ SET ${column} = ?, updated_at = ?
807
+ WHERE id = ? AND ${column} = ?`
808
+ ).run(
809
+ replacementCiphertext,
810
+ now,
811
+ environmentId,
812
+ expectedCiphertext
813
+ );
814
+ return result.changes === 1;
815
+ }
816
+ }
817
+ function toAuthorizationRecord(row) {
818
+ const parsed = JSON.parse(row.host_json);
819
+ return {
820
+ _id: row.id,
821
+ status: row.status,
822
+ host: parsed,
823
+ approved_user_id: row.approved_user_id ?? void 0,
824
+ approved_workspace_id: row.approved_workspace_id ?? void 0,
825
+ environment_id: row.environment_id ?? void 0,
826
+ credential_ciphertext: row.credential_ciphertext ?? void 0,
827
+ expires_at: row.expires_at
828
+ };
829
+ }
830
+ function toEnvironmentRecord(row) {
831
+ return {
832
+ id: row.id,
833
+ user_id: row.user_id,
834
+ workspace_id: row.workspace_id,
835
+ name: row.name,
836
+ platform: row.platform,
837
+ architecture: row.architecture,
838
+ host_id: row.host_id ?? void 0,
839
+ signing_public_key: row.signing_public_key ?? void 0,
840
+ noise_public_key: row.noise_public_key ?? void 0,
841
+ authorization_id: row.authorization_id ?? void 0,
842
+ hostname: row.hostname,
843
+ tunnel_id: row.tunnel_id,
844
+ dns_record_id: row.dns_record_id,
845
+ access_credential_ciphertext: row.access_credential_ciphertext,
846
+ tunnel_secret_ciphertext: row.tunnel_secret_ciphertext ?? void 0,
847
+ status: row.status,
848
+ lifecycle_attempts: row.lifecycle_attempts,
849
+ lifecycle_next_attempt_at: row.lifecycle_next_attempt_at,
850
+ lifecycle_claim_token: row.lifecycle_claim_token ?? void 0,
851
+ lifecycle_claimed_until: row.lifecycle_claimed_until ?? void 0,
852
+ provisioning_deadline_at: row.provisioning_deadline_at ?? void 0,
853
+ activation_deadline_at: row.activation_deadline_at ?? void 0,
854
+ activation_claimed_at: row.activation_claimed_at ?? void 0,
855
+ relay_authenticator: row.relay_authenticator ?? void 0,
856
+ control_token_hash: row.control_token_hash,
857
+ lifecycle_error: row.lifecycle_error ?? void 0
858
+ };
859
+ }
860
+ function requireEnvironmentById(db, environmentId) {
861
+ const row = db.prepare(
862
+ `SELECT ${ENVIRONMENT_COLUMNS}
863
+ FROM connect_environments
864
+ WHERE id = ?`
865
+ ).get(environmentId);
866
+ if (!row) {
867
+ throw new ConnectStoreError(
868
+ "authorization_unavailable",
869
+ "The Connect environment is no longer available."
870
+ );
871
+ }
872
+ return row;
873
+ }
874
+ function normalizeRedeliveryWindow(value) {
875
+ if (!Number.isSafeInteger(value) || value <= 0) {
876
+ throw new Error("Invalid Connect credential redelivery window");
877
+ }
878
+ return Math.min(value, 5 * 6e4);
879
+ }
880
+ function normalizePurgeBatchSize(value) {
881
+ if (!Number.isSafeInteger(value) || value <= 0) {
882
+ throw new Error("Invalid Connect retention purge batch size");
883
+ }
884
+ return Math.min(500, value);
885
+ }