or3-provider-sqlite 0.0.2 → 0.0.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.
- package/README.md +14 -1
- package/dist/module.json +1 -1
- package/dist/runtime/server/connect/sqlite-connect-store.d.ts +2 -0
- package/dist/runtime/server/connect/sqlite-connect-store.js +885 -0
- package/dist/runtime/server/db/migrate.js +19 -2
- package/dist/runtime/server/db/migrations/009_or3_connect.d.ts +4 -0
- package/dist/runtime/server/db/migrations/009_or3_connect.js +91 -0
- package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.d.ts +10 -0
- package/dist/runtime/server/db/migrations/010_connect_user_code_hmac.js +97 -0
- package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.d.ts +4 -0
- package/dist/runtime/server/db/migrations/011_connect_environment_scope_indexes.js +26 -0
- package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.d.ts +4 -0
- package/dist/runtime/server/db/migrations/012_connect_credential_redelivery.js +124 -0
- package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.d.ts +10 -0
- package/dist/runtime/server/db/migrations/013_connect_environment_lifecycle.js +261 -0
- package/dist/runtime/server/db/migrations/014_connect_retention_activation.d.ts +4 -0
- package/dist/runtime/server/db/migrations/014_connect_retention_activation.js +44 -0
- package/dist/runtime/server/db/migrations/015_rate_limits.d.ts +4 -0
- package/dist/runtime/server/db/migrations/015_rate_limits.js +18 -0
- package/dist/runtime/server/db/schema.d.ts +57 -0
- package/dist/runtime/server/plugins/register.js +23 -8
- package/dist/runtime/server/rate-limit/sqlite-provider.d.ts +7 -0
- package/dist/runtime/server/rate-limit/sqlite-provider.js +70 -0
- package/package.json +5 -1
|
@@ -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
|
+
}
|