@mono-agent/web 0.18.3 → 0.19.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.
- package/README.md +79 -18
- package/dist/contracts.d.ts +46 -1
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +3 -0
- package/dist/contracts.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/operator-client.d.ts +10 -1
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +85 -0
- package/dist/operator-client.js.map +1 -1
- package/dist/push-preview.d.ts +8 -0
- package/dist/push-preview.d.ts.map +1 -0
- package/dist/push-preview.js +146 -0
- package/dist/push-preview.js.map +1 -0
- package/dist/push.d.ts +65 -0
- package/dist/push.d.ts.map +1 -0
- package/dist/push.js +586 -0
- package/dist/push.js.map +1 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +195 -4
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +36 -2
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +251 -8
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +87 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +590 -5
- package/dist/store.js.map +1 -1
- package/package.json +8 -6
- package/webapp/dist/assets/{assistant-ui-DTnHvSvW.js → assistant-ui-CxqQJmH9.js} +13 -13
- package/webapp/dist/assets/index-DBk-VjNE.css +1 -0
- package/webapp/dist/assets/index-DntvgDjy.js +51 -0
- package/webapp/dist/assets/{markdown-CTWk6ujC.js → markdown-8pwMczg6.js} +1 -1
- package/webapp/dist/index.html +8 -7
- package/webapp/dist/manifest.webmanifest +17 -1
- package/webapp/dist/notification-sw.js +162 -4
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/assets/index-BDLWl9d1.css +0 -1
- package/webapp/dist/assets/index-byaz_i41.js +0 -51
package/dist/store.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
1
|
+
import { createECDH, createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { chmod, lstat, readdir, unlink } from "node:fs/promises";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { DatabaseSync } from "node:sqlite";
|
|
5
5
|
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, } from "@mono-agent/agent-contracts";
|
|
6
6
|
import { WEB_MAX_FILES_PER_TURN, WEB_MAX_LIVE_INPUTS_PER_THREAD, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_MAX_TURN_TEXT_CHARACTERS, } from "./contracts.js";
|
|
7
7
|
import { WebConsoleError } from "./errors.js";
|
|
8
|
+
import { webPushPreview } from "./push-preview.js";
|
|
8
9
|
import { prepareWebStatePaths } from "./state-paths.js";
|
|
10
|
+
const WEB_STORAGE_SCHEMA_VERSION = 4;
|
|
11
|
+
const MAX_ACTIVE_PUSH_SUBSCRIPTIONS = 32;
|
|
12
|
+
const MAX_PENDING_PUSH_DELIVERIES_PER_SUBSCRIPTION = 200;
|
|
13
|
+
const PUSH_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
|
|
9
14
|
export class WebStore {
|
|
10
15
|
paths;
|
|
11
16
|
database;
|
|
@@ -40,6 +45,7 @@ export class WebStore {
|
|
|
40
45
|
]);
|
|
41
46
|
store.recoverInterruptedTurns();
|
|
42
47
|
store.recoverLiveInputs();
|
|
48
|
+
store.recoverWebPushDeliveries();
|
|
43
49
|
return store;
|
|
44
50
|
}
|
|
45
51
|
catch (error) {
|
|
@@ -180,6 +186,17 @@ export class WebStore {
|
|
|
180
186
|
UPDATE notification_deliveries SET completed_at = ?
|
|
181
187
|
WHERE source_id = ? AND delivery_key = ? AND completed_at IS NULL
|
|
182
188
|
`).run(now, reservation.sourceId, reservation.deliveryKey);
|
|
189
|
+
const agent = this.getAgent(reservation.sourceId);
|
|
190
|
+
this.enqueueWebPushEventInTransaction({
|
|
191
|
+
logicalKey: `web-new:${reservation.threadId}`,
|
|
192
|
+
kind: "response.ready",
|
|
193
|
+
threadId: reservation.threadId,
|
|
194
|
+
sourceId: reservation.sourceId,
|
|
195
|
+
title: `${agent?.label ?? "mono-agent"} · ${reservation.triggerKind.toUpperCase()}`,
|
|
196
|
+
body: reservation.text,
|
|
197
|
+
expiresAt: new Date(new Date(now).getTime() + 24 * 60 * 60 * 1_000).toISOString(),
|
|
198
|
+
notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
|
|
199
|
+
});
|
|
183
200
|
});
|
|
184
201
|
return { thread: this.requireThread(reservation.threadId), duplicate: false };
|
|
185
202
|
}
|
|
@@ -271,6 +288,12 @@ export class WebStore {
|
|
|
271
288
|
const attachments = this.database.prepare("SELECT * FROM attachments WHERE thread_id = ?")
|
|
272
289
|
.all(id);
|
|
273
290
|
this.transaction(() => {
|
|
291
|
+
const now = this.now();
|
|
292
|
+
this.database.prepare(`
|
|
293
|
+
UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'thread_deleted'
|
|
294
|
+
WHERE event_id IN (SELECT id FROM push_events WHERE thread_id = ?)
|
|
295
|
+
AND status IN ('pending', 'sending')
|
|
296
|
+
`).run(now, now, id);
|
|
274
297
|
this.database.prepare("DELETE FROM notification_deliveries WHERE thread_id = ?").run(id);
|
|
275
298
|
this.database.prepare("DELETE FROM revisions WHERE entity_kind = 'thread' AND entity_id = ?").run(id);
|
|
276
299
|
this.database.prepare("DELETE FROM threads WHERE id = ?").run(id);
|
|
@@ -724,17 +747,320 @@ export class WebStore {
|
|
|
724
747
|
const row = this.database.prepare("SELECT thread_id FROM turns WHERE id = ?").get(turnId);
|
|
725
748
|
return row?.thread_id;
|
|
726
749
|
}
|
|
750
|
+
ensureWebPushIdentity(generate) {
|
|
751
|
+
const rows = this.database.prepare(`
|
|
752
|
+
SELECT key, value FROM settings
|
|
753
|
+
WHERE key IN ('web_push_public_key', 'web_push_private_key', 'web_push_key_fingerprint')
|
|
754
|
+
`).all();
|
|
755
|
+
const values = new Map(rows.map((row) => [row.key, row.value]));
|
|
756
|
+
const publicKey = values.get("web_push_public_key");
|
|
757
|
+
const privateKey = values.get("web_push_private_key");
|
|
758
|
+
const fingerprint = values.get("web_push_key_fingerprint");
|
|
759
|
+
const present = [publicKey, privateKey, fingerprint].filter((value) => value !== undefined).length;
|
|
760
|
+
if (present > 0 && present < 3) {
|
|
761
|
+
throw new WebConsoleError("web_push_identity_corrupt", "The stored Web Push identity is incomplete. Reset web state before enabling notifications again.", 500);
|
|
762
|
+
}
|
|
763
|
+
if (publicKey !== undefined && privateKey !== undefined && fingerprint !== undefined) {
|
|
764
|
+
if (!isValidVapidKeyPair(publicKey, privateKey) || fingerprint !== pushKeyFingerprint(publicKey)) {
|
|
765
|
+
throw new WebConsoleError("web_push_identity_corrupt", "The stored Web Push identity is invalid. Reset web state before enabling notifications again.", 500);
|
|
766
|
+
}
|
|
767
|
+
return { publicKey, privateKey, fingerprint };
|
|
768
|
+
}
|
|
769
|
+
const generated = generate();
|
|
770
|
+
if (!isValidVapidKeyPair(generated.publicKey, generated.privateKey)) {
|
|
771
|
+
throw new WebConsoleError("web_push_identity_generation_failed", "Unable to generate a valid Web Push identity.", 500);
|
|
772
|
+
}
|
|
773
|
+
const generatedFingerprint = pushKeyFingerprint(generated.publicKey);
|
|
774
|
+
this.transaction(() => {
|
|
775
|
+
this.setSetting("web_push_public_key", generated.publicKey);
|
|
776
|
+
this.setSetting("web_push_private_key", generated.privateKey);
|
|
777
|
+
this.setSetting("web_push_key_fingerprint", generatedFingerprint);
|
|
778
|
+
});
|
|
779
|
+
return { ...generated, fingerprint: generatedFingerprint };
|
|
780
|
+
}
|
|
781
|
+
registerWebPushSubscription(input) {
|
|
782
|
+
const endpointSha256 = createHash("sha256").update(input.endpoint).digest("hex");
|
|
783
|
+
const previousEndpointSha256 = input.previousEndpoint === undefined
|
|
784
|
+
? undefined
|
|
785
|
+
: createHash("sha256").update(input.previousEndpoint).digest("hex");
|
|
786
|
+
return this.transaction(() => {
|
|
787
|
+
const existing = this.database.prepare("SELECT * FROM push_subscriptions WHERE endpoint_sha256 = ?")
|
|
788
|
+
.get(endpointSha256);
|
|
789
|
+
const replacements = new Set();
|
|
790
|
+
if (input.previousSubscriptionId !== undefined) {
|
|
791
|
+
const row = this.database.prepare("SELECT id FROM push_subscriptions WHERE id = ? AND site_origin = ?")
|
|
792
|
+
.get(input.previousSubscriptionId, input.siteOrigin);
|
|
793
|
+
if (row !== undefined)
|
|
794
|
+
replacements.add(row.id);
|
|
795
|
+
}
|
|
796
|
+
if (previousEndpointSha256 !== undefined) {
|
|
797
|
+
const row = this.database.prepare("SELECT id FROM push_subscriptions WHERE endpoint_sha256 = ? AND site_origin = ?")
|
|
798
|
+
.get(previousEndpointSha256, input.siteOrigin);
|
|
799
|
+
if (row !== undefined)
|
|
800
|
+
replacements.add(row.id);
|
|
801
|
+
}
|
|
802
|
+
if (existing !== undefined)
|
|
803
|
+
replacements.delete(existing.id);
|
|
804
|
+
const now = this.now();
|
|
805
|
+
for (const replacementId of replacements) {
|
|
806
|
+
this.database.prepare(`
|
|
807
|
+
UPDATE push_subscriptions SET state = 'expired', disabled_at = ?, updated_at = ?,
|
|
808
|
+
last_error_at = ?, last_error_code = 'subscription_rotated'
|
|
809
|
+
WHERE id = ? AND state = 'active'
|
|
810
|
+
`).run(now, now, now, replacementId);
|
|
811
|
+
this.database.prepare(`
|
|
812
|
+
UPDATE push_deliveries SET status = 'stale', updated_at = ?, finished_at = ?,
|
|
813
|
+
last_error_code = 'subscription_rotated'
|
|
814
|
+
WHERE subscription_id = ? AND status IN ('pending', 'sending')
|
|
815
|
+
`).run(now, now, replacementId);
|
|
816
|
+
}
|
|
817
|
+
if (existing?.state !== "active") {
|
|
818
|
+
const active = this.database.prepare("SELECT COUNT(*) AS count FROM push_subscriptions WHERE state = 'active'")
|
|
819
|
+
.get();
|
|
820
|
+
if (active.count >= MAX_ACTIVE_PUSH_SUBSCRIPTIONS) {
|
|
821
|
+
throw new WebConsoleError("push_subscription_limit", `This console already has ${MAX_ACTIVE_PUSH_SUBSCRIPTIONS} active notification subscriptions.`, 409);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const id = existing?.id ?? randomUUID();
|
|
825
|
+
this.database.prepare(`
|
|
826
|
+
INSERT INTO push_subscriptions (
|
|
827
|
+
id, endpoint, endpoint_sha256, p256dh, auth, expiration_time, site_origin,
|
|
828
|
+
key_fingerprint, state, created_at, updated_at, disabled_at,
|
|
829
|
+
last_success_at, last_error_at, last_error_code
|
|
830
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, NULL, NULL, NULL, NULL)
|
|
831
|
+
ON CONFLICT(endpoint_sha256) DO UPDATE SET
|
|
832
|
+
endpoint = excluded.endpoint,
|
|
833
|
+
p256dh = excluded.p256dh,
|
|
834
|
+
auth = excluded.auth,
|
|
835
|
+
expiration_time = excluded.expiration_time,
|
|
836
|
+
site_origin = excluded.site_origin,
|
|
837
|
+
key_fingerprint = excluded.key_fingerprint,
|
|
838
|
+
state = 'active',
|
|
839
|
+
updated_at = excluded.updated_at,
|
|
840
|
+
disabled_at = NULL,
|
|
841
|
+
last_error_at = NULL,
|
|
842
|
+
last_error_code = NULL
|
|
843
|
+
`).run(id, input.endpoint, endpointSha256, input.p256dh, input.auth, input.expirationTime ?? null, input.siteOrigin, input.keyFingerprint, now, now);
|
|
844
|
+
return this.requirePushSubscriptionStatus(id);
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
getWebPushSubscription(id) {
|
|
848
|
+
const row = this.database.prepare("SELECT * FROM push_subscriptions WHERE id = ?").get(id);
|
|
849
|
+
return row === undefined ? undefined : mapPushSubscriptionStatus(row);
|
|
850
|
+
}
|
|
851
|
+
disableWebPushSubscription(id) {
|
|
852
|
+
const now = this.now();
|
|
853
|
+
const result = this.database.prepare(`
|
|
854
|
+
UPDATE push_subscriptions SET state = 'disabled', disabled_at = ?, updated_at = ?
|
|
855
|
+
WHERE id = ? AND state != 'disabled'
|
|
856
|
+
`).run(now, now, id);
|
|
857
|
+
if (result.changes === 0 && this.getWebPushSubscription(id) === undefined) {
|
|
858
|
+
throw new WebConsoleError("push_subscription_not_found", "Notification subscription not found.", 404);
|
|
859
|
+
}
|
|
860
|
+
this.database.prepare(`
|
|
861
|
+
UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'subscription_disabled'
|
|
862
|
+
WHERE subscription_id = ? AND status IN ('pending', 'sending')
|
|
863
|
+
`).run(now, now, id);
|
|
864
|
+
}
|
|
865
|
+
expireWebPushSubscription(id, reason) {
|
|
866
|
+
const now = this.now();
|
|
867
|
+
this.transaction(() => {
|
|
868
|
+
this.database.prepare(`
|
|
869
|
+
UPDATE push_subscriptions SET state = 'expired', disabled_at = ?, updated_at = ?,
|
|
870
|
+
last_error_at = ?, last_error_code = ? WHERE id = ?
|
|
871
|
+
`).run(now, now, now, reason, id);
|
|
872
|
+
this.database.prepare(`
|
|
873
|
+
UPDATE push_deliveries SET status = 'stale', updated_at = ?, finished_at = ?, last_error_code = ?
|
|
874
|
+
WHERE subscription_id = ? AND status IN ('pending', 'sending')
|
|
875
|
+
`).run(now, now, reason, id);
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
enqueueWebPushEvent(input) {
|
|
879
|
+
return this.transaction(() => this.enqueueWebPushEventInTransaction(input));
|
|
880
|
+
}
|
|
881
|
+
enqueueWebPushTest(subscriptionId) {
|
|
882
|
+
const subscription = this.requirePushSubscription(subscriptionId);
|
|
883
|
+
if (subscription.state !== "active") {
|
|
884
|
+
throw new WebConsoleError("push_subscription_inactive", "Notification subscription is not active.", 409);
|
|
885
|
+
}
|
|
886
|
+
const latest = this.database.prepare(`
|
|
887
|
+
SELECT e.created_at FROM push_events e
|
|
888
|
+
JOIN push_deliveries d ON d.event_id = e.id
|
|
889
|
+
WHERE d.subscription_id = ? AND e.kind = 'test'
|
|
890
|
+
ORDER BY e.created_at DESC LIMIT 1
|
|
891
|
+
`).get(subscriptionId);
|
|
892
|
+
if (latest !== undefined && this.clock().getTime() - new Date(latest.created_at).getTime() < 10_000) {
|
|
893
|
+
throw new WebConsoleError("push_test_rate_limited", "Wait 10 seconds before sending another test notification.", 429);
|
|
894
|
+
}
|
|
895
|
+
const now = this.clock();
|
|
896
|
+
const event = this.enqueueWebPushEvent({
|
|
897
|
+
logicalKey: `test:${randomUUID()}`,
|
|
898
|
+
kind: "test",
|
|
899
|
+
title: "mono-agent notifications",
|
|
900
|
+
body: "Push notifications are connected.",
|
|
901
|
+
expiresAt: new Date(now.getTime() + 60_000).toISOString(),
|
|
902
|
+
notBefore: now.toISOString(),
|
|
903
|
+
subscriptionId,
|
|
904
|
+
});
|
|
905
|
+
if (event === undefined)
|
|
906
|
+
throw new WebConsoleError("push_subscription_inactive", "Notification subscription is not active.", 409);
|
|
907
|
+
return event;
|
|
908
|
+
}
|
|
909
|
+
webPushEventByLogicalKey(logicalKey) {
|
|
910
|
+
const row = this.database.prepare("SELECT * FROM push_events WHERE logical_key = ?").get(logicalKey);
|
|
911
|
+
return row === undefined ? undefined : mapPushEvent(row);
|
|
912
|
+
}
|
|
913
|
+
acknowledgeWebPushEvent(eventId, subscriptionId) {
|
|
914
|
+
const now = this.now();
|
|
915
|
+
const result = this.database.prepare(`
|
|
916
|
+
UPDATE push_deliveries SET status = 'suppressed', updated_at = ?, finished_at = ?, last_error_code = NULL
|
|
917
|
+
WHERE event_id = ? AND subscription_id = ? AND status = 'pending' AND attempts = 0
|
|
918
|
+
`).run(now, now, eventId, subscriptionId);
|
|
919
|
+
return result.changes > 0;
|
|
920
|
+
}
|
|
921
|
+
claimDueWebPushDeliveries(limit) {
|
|
922
|
+
const now = this.now();
|
|
923
|
+
return this.transaction(() => {
|
|
924
|
+
this.database.prepare(`
|
|
925
|
+
UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'expired'
|
|
926
|
+
WHERE status IN ('pending', 'sending')
|
|
927
|
+
AND event_id IN (SELECT id FROM push_events WHERE expires_at <= ?)
|
|
928
|
+
`).run(now, now, now);
|
|
929
|
+
const rows = this.database.prepare(`
|
|
930
|
+
SELECT d.event_id, d.subscription_id, d.status, d.attempts, d.next_attempt_at,
|
|
931
|
+
d.last_status_code, d.last_error_code, d.created_at, d.updated_at, d.finished_at,
|
|
932
|
+
e.id AS e_id, e.logical_key, e.kind, e.thread_id, e.source_id, e.title, e.body,
|
|
933
|
+
e.tag, e.topic, e.expires_at, e.created_at AS e_created_at,
|
|
934
|
+
s.id AS s_id, s.endpoint, s.endpoint_sha256, s.p256dh, s.auth, s.expiration_time,
|
|
935
|
+
s.site_origin, s.key_fingerprint, s.state, s.created_at AS s_created_at,
|
|
936
|
+
s.updated_at AS s_updated_at, s.disabled_at, s.last_success_at, s.last_error_at, s.last_error_code AS s_last_error_code
|
|
937
|
+
FROM push_deliveries d
|
|
938
|
+
JOIN push_events e ON e.id = d.event_id
|
|
939
|
+
JOIN push_subscriptions s ON s.id = d.subscription_id
|
|
940
|
+
WHERE d.status = 'pending' AND d.next_attempt_at <= ? AND e.expires_at > ? AND s.state = 'active'
|
|
941
|
+
ORDER BY d.next_attempt_at, d.created_at, d.rowid
|
|
942
|
+
LIMIT ?
|
|
943
|
+
`).all(now, now, limit);
|
|
944
|
+
const claim = this.database.prepare(`
|
|
945
|
+
UPDATE push_deliveries SET status = 'sending', updated_at = ?
|
|
946
|
+
WHERE event_id = ? AND subscription_id = ? AND status = 'pending'
|
|
947
|
+
`);
|
|
948
|
+
const claimed = [];
|
|
949
|
+
for (const row of rows) {
|
|
950
|
+
if (claim.run(now, row.event_id, row.subscription_id).changes === 0)
|
|
951
|
+
continue;
|
|
952
|
+
claimed.push({
|
|
953
|
+
event: mapPushEvent({
|
|
954
|
+
id: row.e_id,
|
|
955
|
+
logical_key: row.logical_key,
|
|
956
|
+
kind: row.kind,
|
|
957
|
+
thread_id: row.thread_id,
|
|
958
|
+
source_id: row.source_id,
|
|
959
|
+
title: row.title,
|
|
960
|
+
body: row.body,
|
|
961
|
+
tag: row.tag,
|
|
962
|
+
topic: row.topic,
|
|
963
|
+
expires_at: row.expires_at,
|
|
964
|
+
created_at: row.e_created_at,
|
|
965
|
+
}),
|
|
966
|
+
subscription: mapPushSubscription({
|
|
967
|
+
id: row.s_id,
|
|
968
|
+
endpoint: row.endpoint,
|
|
969
|
+
endpoint_sha256: row.endpoint_sha256,
|
|
970
|
+
p256dh: row.p256dh,
|
|
971
|
+
auth: row.auth,
|
|
972
|
+
expiration_time: row.expiration_time,
|
|
973
|
+
site_origin: row.site_origin,
|
|
974
|
+
key_fingerprint: row.key_fingerprint,
|
|
975
|
+
state: row.state,
|
|
976
|
+
created_at: row.s_created_at,
|
|
977
|
+
updated_at: row.s_updated_at,
|
|
978
|
+
disabled_at: row.disabled_at,
|
|
979
|
+
last_success_at: row.last_success_at,
|
|
980
|
+
last_error_at: row.last_error_at,
|
|
981
|
+
last_error_code: row.s_last_error_code,
|
|
982
|
+
}),
|
|
983
|
+
attempts: row.attempts,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
return claimed;
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
settleWebPushDelivery(input) {
|
|
990
|
+
const now = this.now();
|
|
991
|
+
this.transaction(() => {
|
|
992
|
+
this.database.prepare(`
|
|
993
|
+
UPDATE push_deliveries SET status = ?, attempts = attempts + 1, updated_at = ?, finished_at = ?,
|
|
994
|
+
last_status_code = ?, last_error_code = ?
|
|
995
|
+
WHERE event_id = ? AND subscription_id = ? AND status = 'sending'
|
|
996
|
+
`).run(input.status, now, now, input.statusCode ?? null, input.errorCode ?? null, input.eventId, input.subscriptionId);
|
|
997
|
+
if (input.status === "accepted") {
|
|
998
|
+
this.database.prepare(`
|
|
999
|
+
UPDATE push_subscriptions SET last_success_at = ?, updated_at = ?, last_error_code = NULL
|
|
1000
|
+
WHERE id = ?
|
|
1001
|
+
`).run(now, now, input.subscriptionId);
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
this.database.prepare(`
|
|
1005
|
+
UPDATE push_subscriptions SET last_error_at = ?, updated_at = ?, last_error_code = ?
|
|
1006
|
+
WHERE id = ?
|
|
1007
|
+
`).run(now, now, input.errorCode ?? input.status, input.subscriptionId);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
retryWebPushDelivery(input) {
|
|
1012
|
+
const now = this.now();
|
|
1013
|
+
this.database.prepare(`
|
|
1014
|
+
UPDATE push_deliveries SET status = 'pending', attempts = attempts + 1, next_attempt_at = ?,
|
|
1015
|
+
updated_at = ?, last_status_code = ?, last_error_code = ?
|
|
1016
|
+
WHERE event_id = ? AND subscription_id = ? AND status = 'sending'
|
|
1017
|
+
`).run(input.nextAttemptAt, now, input.statusCode ?? null, input.errorCode, input.eventId, input.subscriptionId);
|
|
1018
|
+
}
|
|
1019
|
+
deferClaimedWebPushDelivery(eventId, subscriptionId, nextAttemptAt) {
|
|
1020
|
+
this.database.prepare(`
|
|
1021
|
+
UPDATE push_deliveries SET status = 'pending', next_attempt_at = ?, updated_at = ?
|
|
1022
|
+
WHERE event_id = ? AND subscription_id = ? AND status = 'sending'
|
|
1023
|
+
`).run(nextAttemptAt, this.now(), eventId, subscriptionId);
|
|
1024
|
+
}
|
|
1025
|
+
staleWebPushEvent(logicalKey, reason = "resolved") {
|
|
1026
|
+
const now = this.now();
|
|
1027
|
+
this.database.prepare(`
|
|
1028
|
+
UPDATE push_deliveries SET status = 'stale', updated_at = ?, finished_at = ?, last_error_code = ?
|
|
1029
|
+
WHERE event_id = (SELECT id FROM push_events WHERE logical_key = ?)
|
|
1030
|
+
AND status IN ('pending', 'sending')
|
|
1031
|
+
`).run(now, now, reason, logicalKey);
|
|
1032
|
+
}
|
|
1033
|
+
webPushDueQueueDepth() {
|
|
1034
|
+
const row = this.database.prepare(`
|
|
1035
|
+
SELECT COUNT(*) AS count FROM push_deliveries
|
|
1036
|
+
WHERE status = 'sending' OR (status = 'pending' AND next_attempt_at <= ?)
|
|
1037
|
+
`).get(this.now());
|
|
1038
|
+
return row.count;
|
|
1039
|
+
}
|
|
1040
|
+
purgeWebPushState() {
|
|
1041
|
+
const before = new Date(this.clock().getTime() - PUSH_RETENTION_MS).toISOString();
|
|
1042
|
+
this.transaction(() => {
|
|
1043
|
+
this.database.prepare(`
|
|
1044
|
+
DELETE FROM push_subscriptions WHERE state IN ('disabled', 'expired') AND updated_at < ?
|
|
1045
|
+
`).run(before);
|
|
1046
|
+
this.database.prepare(`
|
|
1047
|
+
DELETE FROM push_events WHERE created_at < ? AND NOT EXISTS (
|
|
1048
|
+
SELECT 1 FROM push_deliveries d WHERE d.event_id = push_events.id AND d.status IN ('pending', 'sending')
|
|
1049
|
+
)
|
|
1050
|
+
`).run(before);
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
727
1053
|
initialize() {
|
|
728
1054
|
try {
|
|
729
1055
|
this.database.exec("PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;");
|
|
730
1056
|
const versionRow = this.database.prepare("PRAGMA user_version").get();
|
|
731
|
-
if (versionRow.user_version >
|
|
732
|
-
throw new WebConsoleError("unsupported_storage_schema", `Web state schema ${versionRow.user_version} is newer than supported schema
|
|
1057
|
+
if (versionRow.user_version > WEB_STORAGE_SCHEMA_VERSION) {
|
|
1058
|
+
throw new WebConsoleError("unsupported_storage_schema", `Web state schema ${versionRow.user_version} is newer than supported schema ${WEB_STORAGE_SCHEMA_VERSION}.`, 500);
|
|
733
1059
|
}
|
|
734
1060
|
if (versionRow.user_version < 0) {
|
|
735
1061
|
throw new WebConsoleError("storage_corrupt", "Web state schema version is invalid.", 500);
|
|
736
1062
|
}
|
|
737
|
-
const migrating = versionRow.user_version <
|
|
1063
|
+
const migrating = versionRow.user_version < WEB_STORAGE_SCHEMA_VERSION;
|
|
738
1064
|
if (migrating)
|
|
739
1065
|
this.database.exec("BEGIN IMMEDIATE");
|
|
740
1066
|
try {
|
|
@@ -842,6 +1168,58 @@ export class WebStore {
|
|
|
842
1168
|
completed_at TEXT,
|
|
843
1169
|
PRIMARY KEY (source_id, delivery_key)
|
|
844
1170
|
);
|
|
1171
|
+
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
1172
|
+
id TEXT PRIMARY KEY,
|
|
1173
|
+
endpoint TEXT NOT NULL,
|
|
1174
|
+
endpoint_sha256 TEXT NOT NULL UNIQUE,
|
|
1175
|
+
p256dh TEXT NOT NULL,
|
|
1176
|
+
auth TEXT NOT NULL,
|
|
1177
|
+
expiration_time INTEGER,
|
|
1178
|
+
site_origin TEXT NOT NULL,
|
|
1179
|
+
key_fingerprint TEXT NOT NULL,
|
|
1180
|
+
state TEXT NOT NULL CHECK (state IN ('active', 'disabled', 'expired')),
|
|
1181
|
+
created_at TEXT NOT NULL,
|
|
1182
|
+
updated_at TEXT NOT NULL,
|
|
1183
|
+
disabled_at TEXT,
|
|
1184
|
+
last_success_at TEXT,
|
|
1185
|
+
last_error_at TEXT,
|
|
1186
|
+
last_error_code TEXT
|
|
1187
|
+
);
|
|
1188
|
+
CREATE INDEX IF NOT EXISTS push_subscriptions_by_state
|
|
1189
|
+
ON push_subscriptions(state, updated_at);
|
|
1190
|
+
CREATE TABLE IF NOT EXISTS push_events (
|
|
1191
|
+
id TEXT PRIMARY KEY,
|
|
1192
|
+
logical_key TEXT NOT NULL UNIQUE,
|
|
1193
|
+
kind TEXT NOT NULL CHECK (kind IN (
|
|
1194
|
+
'response.ready', 'input.required', 'run.failed', 'run.cancelled', 'run.interrupted', 'test'
|
|
1195
|
+
)),
|
|
1196
|
+
thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
|
|
1197
|
+
source_id TEXT,
|
|
1198
|
+
title TEXT NOT NULL,
|
|
1199
|
+
body TEXT NOT NULL,
|
|
1200
|
+
tag TEXT NOT NULL,
|
|
1201
|
+
topic TEXT NOT NULL,
|
|
1202
|
+
expires_at TEXT NOT NULL,
|
|
1203
|
+
created_at TEXT NOT NULL
|
|
1204
|
+
);
|
|
1205
|
+
CREATE INDEX IF NOT EXISTS push_events_by_expiry ON push_events(expires_at);
|
|
1206
|
+
CREATE TABLE IF NOT EXISTS push_deliveries (
|
|
1207
|
+
event_id TEXT NOT NULL REFERENCES push_events(id) ON DELETE CASCADE,
|
|
1208
|
+
subscription_id TEXT NOT NULL REFERENCES push_subscriptions(id) ON DELETE CASCADE,
|
|
1209
|
+
status TEXT NOT NULL CHECK (status IN (
|
|
1210
|
+
'pending', 'sending', 'accepted', 'suppressed', 'stale', 'failed', 'config_error', 'dropped'
|
|
1211
|
+
)),
|
|
1212
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
1213
|
+
next_attempt_at TEXT NOT NULL,
|
|
1214
|
+
last_status_code INTEGER,
|
|
1215
|
+
last_error_code TEXT,
|
|
1216
|
+
created_at TEXT NOT NULL,
|
|
1217
|
+
updated_at TEXT NOT NULL,
|
|
1218
|
+
finished_at TEXT,
|
|
1219
|
+
PRIMARY KEY (event_id, subscription_id)
|
|
1220
|
+
);
|
|
1221
|
+
CREATE INDEX IF NOT EXISTS push_deliveries_due
|
|
1222
|
+
ON push_deliveries(status, next_attempt_at, created_at);
|
|
845
1223
|
`);
|
|
846
1224
|
if (versionRow.user_version === 1) {
|
|
847
1225
|
const columns = this.database.prepare("PRAGMA table_info(threads)").all();
|
|
@@ -850,7 +1228,7 @@ export class WebStore {
|
|
|
850
1228
|
}
|
|
851
1229
|
}
|
|
852
1230
|
if (migrating)
|
|
853
|
-
this.database.exec(
|
|
1231
|
+
this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
|
|
854
1232
|
}
|
|
855
1233
|
catch (error) {
|
|
856
1234
|
if (this.database.isTransaction)
|
|
@@ -880,6 +1258,9 @@ export class WebStore {
|
|
|
880
1258
|
"revisions",
|
|
881
1259
|
"settings",
|
|
882
1260
|
"notification_deliveries",
|
|
1261
|
+
"push_subscriptions",
|
|
1262
|
+
"push_events",
|
|
1263
|
+
"push_deliveries",
|
|
883
1264
|
]);
|
|
884
1265
|
const tables = this.database.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all();
|
|
885
1266
|
for (const table of tables)
|
|
@@ -930,6 +1311,13 @@ export class WebStore {
|
|
|
930
1311
|
}
|
|
931
1312
|
});
|
|
932
1313
|
}
|
|
1314
|
+
recoverWebPushDeliveries() {
|
|
1315
|
+
const now = this.now();
|
|
1316
|
+
this.database.prepare(`
|
|
1317
|
+
UPDATE push_deliveries SET status = 'pending', next_attempt_at = ?, updated_at = ?
|
|
1318
|
+
WHERE status = 'sending'
|
|
1319
|
+
`).run(now, now);
|
|
1320
|
+
}
|
|
933
1321
|
finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime) {
|
|
934
1322
|
const turn = this.requireTurn(turnId);
|
|
935
1323
|
const existing = this.requireMessage(turn.assistant_message_id);
|
|
@@ -943,6 +1331,8 @@ export class WebStore {
|
|
|
943
1331
|
parts.push({ type: "error", ...(errorCode === undefined ? {} : { code: errorCode }), message: errorMessage });
|
|
944
1332
|
}
|
|
945
1333
|
const now = this.now();
|
|
1334
|
+
const thread = this.requireThread(turn.thread_id);
|
|
1335
|
+
const agent = this.getAgent(thread.sourceId);
|
|
946
1336
|
this.transaction(() => {
|
|
947
1337
|
this.database.prepare(`
|
|
948
1338
|
UPDATE turns SET status = ?, finished_at = ?, error_code = ?, error_message = ?,
|
|
@@ -955,6 +1345,43 @@ export class WebStore {
|
|
|
955
1345
|
this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
|
|
956
1346
|
.run(now, turn.thread_id);
|
|
957
1347
|
this.recordThreadRevision(turn.thread_id, `turn_${status}`, now);
|
|
1348
|
+
const recentEnoughForRecoveredInterruption = status !== "interrupted"
|
|
1349
|
+
|| new Date(now).getTime() - new Date(existing.updatedAt).getTime() <= 60 * 60 * 1_000;
|
|
1350
|
+
if (recentEnoughForRecoveredInterruption) {
|
|
1351
|
+
const kind = status === "complete"
|
|
1352
|
+
? "response.ready"
|
|
1353
|
+
: status === "cancelled"
|
|
1354
|
+
? "run.cancelled"
|
|
1355
|
+
: status === "interrupted"
|
|
1356
|
+
? "run.interrupted"
|
|
1357
|
+
: "run.failed";
|
|
1358
|
+
const label = agent?.label ?? "mono-agent";
|
|
1359
|
+
const body = status === "complete"
|
|
1360
|
+
? parts.filter((part) => part.type === "text")
|
|
1361
|
+
.map((part) => part.text)
|
|
1362
|
+
.join(" ")
|
|
1363
|
+
: status === "cancelled"
|
|
1364
|
+
? "The run was cancelled."
|
|
1365
|
+
: status === "interrupted"
|
|
1366
|
+
? "The run was interrupted when the web service stopped."
|
|
1367
|
+
: errorMessage ?? "The run failed.";
|
|
1368
|
+
this.enqueueWebPushEventInTransaction({
|
|
1369
|
+
logicalKey: `turn:${turnId}:terminal`,
|
|
1370
|
+
kind,
|
|
1371
|
+
threadId: turn.thread_id,
|
|
1372
|
+
sourceId: thread.sourceId,
|
|
1373
|
+
title: status === "complete"
|
|
1374
|
+
? `${label} replied`
|
|
1375
|
+
: status === "cancelled"
|
|
1376
|
+
? `${label} run cancelled`
|
|
1377
|
+
: status === "interrupted"
|
|
1378
|
+
? `${label} run interrupted`
|
|
1379
|
+
: `${label} run failed`,
|
|
1380
|
+
body,
|
|
1381
|
+
expiresAt: new Date(new Date(now).getTime() + (status === "complete" ? 24 : 1) * 60 * 60 * 1_000).toISOString(),
|
|
1382
|
+
notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
958
1385
|
});
|
|
959
1386
|
return this.requireThreadDetail(turn.thread_id);
|
|
960
1387
|
}
|
|
@@ -1071,6 +1498,90 @@ export class WebStore {
|
|
|
1071
1498
|
throw new WebConsoleError("attachment_not_found", "Attachment not found.", 404);
|
|
1072
1499
|
return attachment;
|
|
1073
1500
|
}
|
|
1501
|
+
requirePushSubscription(id) {
|
|
1502
|
+
const row = this.database.prepare("SELECT * FROM push_subscriptions WHERE id = ?")
|
|
1503
|
+
.get(id);
|
|
1504
|
+
if (row === undefined) {
|
|
1505
|
+
throw new WebConsoleError("push_subscription_not_found", "Notification subscription not found.", 404);
|
|
1506
|
+
}
|
|
1507
|
+
return mapPushSubscription(row);
|
|
1508
|
+
}
|
|
1509
|
+
requirePushSubscriptionStatus(id) {
|
|
1510
|
+
const status = this.getWebPushSubscription(id);
|
|
1511
|
+
if (status === undefined) {
|
|
1512
|
+
throw new WebConsoleError("push_subscription_not_found", "Notification subscription not found.", 404);
|
|
1513
|
+
}
|
|
1514
|
+
return status;
|
|
1515
|
+
}
|
|
1516
|
+
enqueueWebPushEventInTransaction(input) {
|
|
1517
|
+
const existing = this.database.prepare("SELECT * FROM push_events WHERE logical_key = ?")
|
|
1518
|
+
.get(input.logicalKey);
|
|
1519
|
+
if (existing !== undefined)
|
|
1520
|
+
return mapPushEvent(existing);
|
|
1521
|
+
const targets = input.subscriptionId === undefined
|
|
1522
|
+
? this.database.prepare("SELECT id FROM push_subscriptions WHERE state = 'active' ORDER BY created_at, id")
|
|
1523
|
+
.all()
|
|
1524
|
+
: this.database.prepare("SELECT id FROM push_subscriptions WHERE id = ? AND state = 'active'")
|
|
1525
|
+
.all(input.subscriptionId);
|
|
1526
|
+
if (targets.length === 0)
|
|
1527
|
+
return undefined;
|
|
1528
|
+
const now = this.now();
|
|
1529
|
+
const expiry = new Date(input.expiresAt);
|
|
1530
|
+
if (!Number.isFinite(expiry.getTime()) || expiry.getTime() <= new Date(now).getTime()) {
|
|
1531
|
+
throw new WebConsoleError("invalid_push_event", "Notification expiry must be in the future.", 500);
|
|
1532
|
+
}
|
|
1533
|
+
const nextAttemptAt = input.notBefore ?? new Date(new Date(now).getTime() + 3_000).toISOString();
|
|
1534
|
+
const id = randomUUID();
|
|
1535
|
+
const title = webPushPreview(input.title, "mono-agent");
|
|
1536
|
+
const body = webPushPreview(input.body);
|
|
1537
|
+
const topic = createHash("sha256")
|
|
1538
|
+
.update(input.logicalKey)
|
|
1539
|
+
.digest("base64url")
|
|
1540
|
+
.slice(0, 32);
|
|
1541
|
+
const tag = `mono-agent-${id}`;
|
|
1542
|
+
this.database.prepare(`
|
|
1543
|
+
INSERT INTO push_events (
|
|
1544
|
+
id, logical_key, kind, thread_id, source_id, title, body, tag, topic, expires_at, created_at
|
|
1545
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1546
|
+
`).run(id, input.logicalKey, input.kind, input.threadId ?? null, input.sourceId ?? null, title, body, tag, topic, expiry.toISOString(), now);
|
|
1547
|
+
const insert = this.database.prepare(`
|
|
1548
|
+
INSERT INTO push_deliveries (
|
|
1549
|
+
event_id, subscription_id, status, attempts, next_attempt_at,
|
|
1550
|
+
last_status_code, last_error_code, created_at, updated_at, finished_at
|
|
1551
|
+
) VALUES (?, ?, 'pending', 0, ?, NULL, NULL, ?, ?, NULL)
|
|
1552
|
+
`);
|
|
1553
|
+
for (const target of targets) {
|
|
1554
|
+
const pending = this.database.prepare(`
|
|
1555
|
+
SELECT COUNT(*) AS count FROM push_deliveries
|
|
1556
|
+
WHERE subscription_id = ? AND status IN ('pending', 'sending')
|
|
1557
|
+
`).get(target.id);
|
|
1558
|
+
const dropCount = Math.max(0, pending.count - MAX_PENDING_PUSH_DELIVERIES_PER_SUBSCRIPTION + 1);
|
|
1559
|
+
if (dropCount > 0) {
|
|
1560
|
+
this.database.prepare(`
|
|
1561
|
+
UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'queue_limit'
|
|
1562
|
+
WHERE rowid IN (
|
|
1563
|
+
SELECT rowid FROM push_deliveries
|
|
1564
|
+
WHERE subscription_id = ? AND status = 'pending'
|
|
1565
|
+
ORDER BY created_at, rowid LIMIT ?
|
|
1566
|
+
)
|
|
1567
|
+
`).run(now, now, target.id, dropCount);
|
|
1568
|
+
}
|
|
1569
|
+
insert.run(id, target.id, nextAttemptAt, now, now);
|
|
1570
|
+
}
|
|
1571
|
+
return {
|
|
1572
|
+
id,
|
|
1573
|
+
logicalKey: input.logicalKey,
|
|
1574
|
+
kind: input.kind,
|
|
1575
|
+
...(input.threadId === undefined ? {} : { threadId: input.threadId }),
|
|
1576
|
+
...(input.sourceId === undefined ? {} : { sourceId: input.sourceId }),
|
|
1577
|
+
title,
|
|
1578
|
+
body,
|
|
1579
|
+
tag,
|
|
1580
|
+
topic,
|
|
1581
|
+
expiresAt: expiry.toISOString(),
|
|
1582
|
+
createdAt: now,
|
|
1583
|
+
};
|
|
1584
|
+
}
|
|
1074
1585
|
recordThreadRevision(threadId, event, now) {
|
|
1075
1586
|
const row = this.database.prepare("SELECT revision FROM threads WHERE id = ?").get(threadId);
|
|
1076
1587
|
this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, ?, ?, ?)")
|
|
@@ -1096,6 +1607,80 @@ export class WebStore {
|
|
|
1096
1607
|
return this.clock().toISOString();
|
|
1097
1608
|
}
|
|
1098
1609
|
}
|
|
1610
|
+
function mapPushSubscriptionStatus(row) {
|
|
1611
|
+
const state = row.state === "disabled" || row.state === "expired"
|
|
1612
|
+
? row.state
|
|
1613
|
+
: "active";
|
|
1614
|
+
return {
|
|
1615
|
+
id: row.id,
|
|
1616
|
+
state,
|
|
1617
|
+
keyFingerprint: row.key_fingerprint,
|
|
1618
|
+
createdAt: row.created_at,
|
|
1619
|
+
updatedAt: row.updated_at,
|
|
1620
|
+
...(row.last_success_at === null ? {} : { lastSuccessAt: row.last_success_at }),
|
|
1621
|
+
...(row.last_error_at === null ? {} : { lastErrorAt: row.last_error_at }),
|
|
1622
|
+
...(row.last_error_code === null ? {} : { lastErrorCode: row.last_error_code }),
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
function mapPushSubscription(row) {
|
|
1626
|
+
return {
|
|
1627
|
+
...mapPushSubscriptionStatus(row),
|
|
1628
|
+
endpoint: row.endpoint,
|
|
1629
|
+
p256dh: row.p256dh,
|
|
1630
|
+
auth: row.auth,
|
|
1631
|
+
...(row.expiration_time === null ? {} : { expirationTime: row.expiration_time }),
|
|
1632
|
+
siteOrigin: row.site_origin,
|
|
1633
|
+
};
|
|
1634
|
+
}
|
|
1635
|
+
function mapPushEvent(row) {
|
|
1636
|
+
const validKinds = [
|
|
1637
|
+
"response.ready",
|
|
1638
|
+
"input.required",
|
|
1639
|
+
"run.failed",
|
|
1640
|
+
"run.cancelled",
|
|
1641
|
+
"run.interrupted",
|
|
1642
|
+
"test",
|
|
1643
|
+
];
|
|
1644
|
+
if (!validKinds.includes(row.kind)) {
|
|
1645
|
+
throw new WebConsoleError("storage_corrupt", `Push event ${row.id} has an invalid kind.`, 500);
|
|
1646
|
+
}
|
|
1647
|
+
return {
|
|
1648
|
+
id: row.id,
|
|
1649
|
+
logicalKey: row.logical_key,
|
|
1650
|
+
kind: row.kind,
|
|
1651
|
+
...(row.thread_id === null ? {} : { threadId: row.thread_id }),
|
|
1652
|
+
...(row.source_id === null ? {} : { sourceId: row.source_id }),
|
|
1653
|
+
title: row.title,
|
|
1654
|
+
body: row.body,
|
|
1655
|
+
tag: row.tag,
|
|
1656
|
+
topic: row.topic,
|
|
1657
|
+
expiresAt: row.expires_at,
|
|
1658
|
+
createdAt: row.created_at,
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
function pushKeyFingerprint(publicKey) {
|
|
1662
|
+
return createHash("sha256").update(publicKey).digest("base64url");
|
|
1663
|
+
}
|
|
1664
|
+
function isBase64Url(value, minimum, maximum) {
|
|
1665
|
+
return value.length >= minimum && value.length <= maximum && /^[A-Za-z0-9_-]+$/u.test(value);
|
|
1666
|
+
}
|
|
1667
|
+
function isValidVapidKeyPair(publicKey, privateKey) {
|
|
1668
|
+
if (!isBase64Url(publicKey, 32, 128) || !isBase64Url(privateKey, 24, 96))
|
|
1669
|
+
return false;
|
|
1670
|
+
try {
|
|
1671
|
+
const decodedPublic = Buffer.from(publicKey, "base64url");
|
|
1672
|
+
const decodedPrivate = Buffer.from(privateKey, "base64url");
|
|
1673
|
+
if (decodedPublic.byteLength !== 65 || decodedPublic[0] !== 4 || decodedPrivate.byteLength !== 32)
|
|
1674
|
+
return false;
|
|
1675
|
+
const ecdh = createECDH("prime256v1");
|
|
1676
|
+
ecdh.setPrivateKey(decodedPrivate);
|
|
1677
|
+
const derivedPublic = ecdh.getPublicKey();
|
|
1678
|
+
return derivedPublic.byteLength === decodedPublic.byteLength && timingSafeEqual(derivedPublic, decodedPublic);
|
|
1679
|
+
}
|
|
1680
|
+
catch {
|
|
1681
|
+
return false;
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1099
1684
|
function threadSelectSql(suffix) {
|
|
1100
1685
|
return `
|
|
1101
1686
|
SELECT t.id, t.source_id, t.title, t.trigger_kind, t.archived_at, t.created_at, t.updated_at, t.revision,
|