@autono/pinbox-core 0.1.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 +17 -0
- package/dist/auth/verify.d.ts +15 -0
- package/dist/auth/verify.js +2 -0
- package/dist/connectors/github.d.ts +5 -0
- package/dist/connectors/github.js +49 -0
- package/dist/connectors/index.d.ts +37 -0
- package/dist/connectors/index.js +103 -0
- package/dist/context-BHcEpzVb.js +30 -0
- package/dist/delivery/openclaw.d.ts +10 -0
- package/dist/delivery/openclaw.js +50 -0
- package/dist/delivery/resume.d.ts +23 -0
- package/dist/delivery/resume.js +102 -0
- package/dist/delivery/router.d.ts +2 -0
- package/dist/delivery/router.js +262 -0
- package/dist/delivery/webhook.d.ts +9 -0
- package/dist/delivery/webhook.js +52 -0
- package/dist/do.d.ts +67 -0
- package/dist/do.js +713 -0
- package/dist/hub-QYz6OqYQ.js +328 -0
- package/dist/hub-server.d.ts +44 -0
- package/dist/hub-server.js +251 -0
- package/dist/hub.d.ts +125 -0
- package/dist/hub.js +2 -0
- package/dist/markdown.d.ts +14 -0
- package/dist/markdown.js +87 -0
- package/dist/payload-m1DbRWDD.d.ts +6 -0
- package/dist/poll-BrcAuaAz.js +252 -0
- package/dist/proc-BMp_pbPS.js +42 -0
- package/dist/router-D3dDjIaD.d.ts +62 -0
- package/dist/schema-BOTmn5SM.d.ts +297 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +121 -0
- package/dist/schema.json +403 -0
- package/dist/sessions-CJdMBH3C.d.ts +47 -0
- package/dist/sessions-DrCVTMfI.js +129 -0
- package/dist/sessions.d.ts +2 -0
- package/dist/sessions.js +2 -0
- package/dist/store-DHbwWu93.d.ts +105 -0
- package/dist/store-DM8MjB8M.js +479 -0
- package/dist/store.d.ts +2 -0
- package/dist/store.js +3 -0
- package/dist/types-BLNQb7MH.d.ts +28 -0
- package/dist/verify-BDt9d9Np.js +55 -0
- package/dist/ws-protocol.d.ts +61 -0
- package/dist/ws-protocol.js +51 -0
- package/dist/ws.d.ts +7 -0
- package/dist/ws.js +1 -0
- package/package.json +104 -0
package/dist/do.js
ADDED
|
@@ -0,0 +1,713 @@
|
|
|
1
|
+
import { AttachmentSchema, PinInputSchema, PinSchema, SessionRefSchema, ThreadMessageSchema } from "./schema.js";
|
|
2
|
+
import { a as NotFoundError, i as ConflictError, o as newId, t as SessionSchema } from "./sessions-DrCVTMfI.js";
|
|
3
|
+
import { t as MIGRATIONS } from "./store-DM8MjB8M.js";
|
|
4
|
+
import { n as createHubHandler, o as ok, r as err } from "./hub-QYz6OqYQ.js";
|
|
5
|
+
import { ClientHelloSchema, WS_CLOSE_PROTOCOL, WS_CLOSE_UNAUTHORIZED, WS_TOKEN_SUBPROTOCOL_PREFIX, encodeWsEvent } from "./ws-protocol.js";
|
|
6
|
+
import { i as verifyJwt, n as verifyNone, r as verifyToken } from "./verify-BDt9d9Np.js";
|
|
7
|
+
//#region src/do-store-registries.ts
|
|
8
|
+
function parseSession(json) {
|
|
9
|
+
return SessionSchema.parse(JSON.parse(json));
|
|
10
|
+
}
|
|
11
|
+
var DoSessionStore = class {
|
|
12
|
+
sql;
|
|
13
|
+
storage;
|
|
14
|
+
constructor(sql, storage) {
|
|
15
|
+
this.sql = sql;
|
|
16
|
+
this.storage = storage;
|
|
17
|
+
}
|
|
18
|
+
register(ref) {
|
|
19
|
+
return this.storage.transactionSync(() => {
|
|
20
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
21
|
+
const existing = this.findByRef(ref);
|
|
22
|
+
const session = SessionSchema.parse({
|
|
23
|
+
id: existing?.id ?? newId("ses"),
|
|
24
|
+
agent: ref.agent,
|
|
25
|
+
key: ref.key,
|
|
26
|
+
...ref.cwd !== void 0 ? { cwd: ref.cwd } : {},
|
|
27
|
+
registeredAt: existing?.registeredAt ?? now,
|
|
28
|
+
lastSeenAt: now
|
|
29
|
+
});
|
|
30
|
+
this.sql.exec(`INSERT INTO sessions (id, agent, key, cwd, registered_at, last_seen_at, ended_at, json)
|
|
31
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL, ?)
|
|
32
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
33
|
+
cwd = excluded.cwd, last_seen_at = excluded.last_seen_at,
|
|
34
|
+
ended_at = NULL, json = excluded.json`, session.id, session.agent, session.key, session.cwd ?? null, session.registeredAt, session.lastSeenAt, JSON.stringify(session));
|
|
35
|
+
return session;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
get(id) {
|
|
39
|
+
const row = this.sql.exec("SELECT json FROM sessions WHERE id = ?", id).toArray()[0];
|
|
40
|
+
return row ? parseSession(row.json) : null;
|
|
41
|
+
}
|
|
42
|
+
findByRef(ref) {
|
|
43
|
+
const row = this.sql.exec("SELECT json FROM sessions WHERE agent = ? AND key = ?", ref.agent, ref.key).toArray()[0];
|
|
44
|
+
return row ? parseSession(row.json) : null;
|
|
45
|
+
}
|
|
46
|
+
active() {
|
|
47
|
+
const row = this.sql.exec("SELECT json FROM sessions WHERE ended_at IS NULL ORDER BY last_seen_at DESC, rowid DESC LIMIT 1").toArray()[0];
|
|
48
|
+
return row ? parseSession(row.json) : null;
|
|
49
|
+
}
|
|
50
|
+
list() {
|
|
51
|
+
return this.sql.exec("SELECT json FROM sessions ORDER BY last_seen_at DESC, rowid DESC").toArray().map((row) => parseSession(row.json));
|
|
52
|
+
}
|
|
53
|
+
touch(id) {
|
|
54
|
+
this.refresh(id, (session, now) => ({
|
|
55
|
+
...session,
|
|
56
|
+
lastSeenAt: now
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
end(id) {
|
|
60
|
+
this.refresh(id, (session, now) => ({
|
|
61
|
+
...session,
|
|
62
|
+
endedAt: now
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
refresh(id, update) {
|
|
66
|
+
this.storage.transactionSync(() => {
|
|
67
|
+
const existing = this.get(id);
|
|
68
|
+
if (!existing) throw new NotFoundError(`session not found: ${id}`);
|
|
69
|
+
const session = SessionSchema.parse(update(existing, (/* @__PURE__ */ new Date()).toISOString()));
|
|
70
|
+
this.sql.exec("UPDATE sessions SET last_seen_at = ?, ended_at = ?, json = ? WHERE id = ?", session.lastSeenAt, session.endedAt ?? null, JSON.stringify(session), id);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
var DoCursorStore = class {
|
|
75
|
+
sql;
|
|
76
|
+
constructor(sql) {
|
|
77
|
+
this.sql = sql;
|
|
78
|
+
}
|
|
79
|
+
get(consumerId) {
|
|
80
|
+
return this.sql.exec("SELECT last_seq FROM cursors WHERE consumer_id = ?", consumerId).toArray()[0]?.last_seq ?? 0;
|
|
81
|
+
}
|
|
82
|
+
set(consumerId, lastSeq) {
|
|
83
|
+
this.sql.exec(`INSERT INTO cursors (consumer_id, last_seq, updated_at) VALUES (?, ?, ?)
|
|
84
|
+
ON CONFLICT(consumer_id) DO UPDATE SET
|
|
85
|
+
last_seq = excluded.last_seq, updated_at = excluded.updated_at`, consumerId, lastSeq, (/* @__PURE__ */ new Date()).toISOString());
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
var DoLinkStore = class {
|
|
89
|
+
sql;
|
|
90
|
+
constructor(sql) {
|
|
91
|
+
this.sql = sql;
|
|
92
|
+
}
|
|
93
|
+
forPin(pinId) {
|
|
94
|
+
return this.sql.exec("SELECT pin_id, connector, ref, url, last_synced_at FROM links WHERE pin_id = ? ORDER BY rowid ASC", pinId).toArray().map((row) => ({
|
|
95
|
+
connector: row.connector,
|
|
96
|
+
ref: row.ref,
|
|
97
|
+
url: row.url
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
all() {
|
|
101
|
+
return this.sql.exec("SELECT pin_id, connector, ref, url, last_synced_at FROM links ORDER BY rowid ASC").toArray().map((row) => ({
|
|
102
|
+
pinId: row.pin_id,
|
|
103
|
+
link: {
|
|
104
|
+
connector: row.connector,
|
|
105
|
+
ref: row.ref,
|
|
106
|
+
url: row.url
|
|
107
|
+
},
|
|
108
|
+
lastSyncedAt: row.last_synced_at
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
markSynced(pinId, link, at) {
|
|
112
|
+
this.sql.exec("UPDATE links SET last_synced_at = ? WHERE pin_id = ? AND connector = ? AND ref = ?", at, pinId, link.connector, link.ref);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
const DELIVERY_COLUMNS = "id, event_seq, session_id, adapter, status, attempts, due_at, last_error, updated_at";
|
|
116
|
+
function mapDelivery(row) {
|
|
117
|
+
return {
|
|
118
|
+
id: row.id,
|
|
119
|
+
eventSeq: row.event_seq,
|
|
120
|
+
sessionId: row.session_id,
|
|
121
|
+
adapter: row.adapter,
|
|
122
|
+
status: row.status,
|
|
123
|
+
attempts: row.attempts,
|
|
124
|
+
dueAt: row.due_at,
|
|
125
|
+
lastError: row.last_error,
|
|
126
|
+
updatedAt: row.updated_at
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
var DoDeliveryStore = class {
|
|
130
|
+
sql;
|
|
131
|
+
constructor(sql) {
|
|
132
|
+
this.sql = sql;
|
|
133
|
+
}
|
|
134
|
+
enqueue(row) {
|
|
135
|
+
return mapDelivery(this.sql.exec(`INSERT INTO deliveries (event_seq, session_id, adapter, status, attempts, due_at, updated_at)
|
|
136
|
+
VALUES (?, ?, ?, ?, 0, ?, ?) RETURNING ${DELIVERY_COLUMNS}`, row.eventSeq, row.sessionId, row.adapter, row.status ?? "pending", row.dueAt, (/* @__PURE__ */ new Date()).toISOString()).one());
|
|
137
|
+
}
|
|
138
|
+
due(now) {
|
|
139
|
+
return this.sql.exec(`SELECT ${DELIVERY_COLUMNS} FROM deliveries
|
|
140
|
+
WHERE status = 'pending' AND (due_at IS NULL OR due_at <= ?) ORDER BY id ASC`, now).toArray().map(mapDelivery);
|
|
141
|
+
}
|
|
142
|
+
pendingForSession(sessionId) {
|
|
143
|
+
return this.sql.exec(`SELECT ${DELIVERY_COLUMNS} FROM deliveries
|
|
144
|
+
WHERE status = 'pending' AND session_id = ? ORDER BY id ASC`, sessionId).toArray().map(mapDelivery);
|
|
145
|
+
}
|
|
146
|
+
unassigned() {
|
|
147
|
+
return this.sql.exec(`SELECT ${DELIVERY_COLUMNS} FROM deliveries
|
|
148
|
+
WHERE status = 'pending' AND session_id IS NULL ORDER BY id ASC`).toArray().map(mapDelivery);
|
|
149
|
+
}
|
|
150
|
+
assign(id, sessionId) {
|
|
151
|
+
this.sql.exec("UPDATE deliveries SET session_id = ?, updated_at = ? WHERE id = ?", sessionId, (/* @__PURE__ */ new Date()).toISOString(), id);
|
|
152
|
+
}
|
|
153
|
+
markDelivered(id) {
|
|
154
|
+
this.sql.exec("UPDATE deliveries SET status = 'delivered', updated_at = ? WHERE id = ?", (/* @__PURE__ */ new Date()).toISOString(), id);
|
|
155
|
+
}
|
|
156
|
+
markFailed(id, error, retryAt) {
|
|
157
|
+
this.sql.exec(`UPDATE deliveries SET attempts = attempts + 1, last_error = ?,
|
|
158
|
+
status = ?, due_at = ?, updated_at = ? WHERE id = ?`, error, retryAt === null ? "failed" : "pending", retryAt, (/* @__PURE__ */ new Date()).toISOString(), id);
|
|
159
|
+
}
|
|
160
|
+
lastEventSeq() {
|
|
161
|
+
return this.sql.exec("SELECT COALESCE(MAX(event_seq), 0) AS last FROM deliveries").one().last;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
//#endregion
|
|
165
|
+
//#region src/do-store.ts
|
|
166
|
+
const VERSION_KEY = "pinbox:user_version";
|
|
167
|
+
function versionCursor(sql, storage) {
|
|
168
|
+
try {
|
|
169
|
+
const read = () => Number(sql.exec("PRAGMA user_version").one().user_version);
|
|
170
|
+
const current = read();
|
|
171
|
+
sql.exec(`PRAGMA user_version = ${current}`);
|
|
172
|
+
if (read() !== current) throw new Error("PRAGMA user_version write did not persist");
|
|
173
|
+
return {
|
|
174
|
+
get: read,
|
|
175
|
+
set: (version) => void sql.exec(`PRAGMA user_version = ${version}`)
|
|
176
|
+
};
|
|
177
|
+
} catch {
|
|
178
|
+
return {
|
|
179
|
+
get: () => storage.kv.get(VERSION_KEY) ?? 0,
|
|
180
|
+
set: (version) => storage.kv.put(VERSION_KEY, version)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
function migrate(sql, storage) {
|
|
185
|
+
const cursor = versionCursor(sql, storage);
|
|
186
|
+
for (const migration of MIGRATIONS) {
|
|
187
|
+
if (migration.version <= cursor.get()) continue;
|
|
188
|
+
storage.transactionSync(() => {
|
|
189
|
+
sql.exec(migration.ddl);
|
|
190
|
+
cursor.set(migration.version);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
function parsePin(json) {
|
|
195
|
+
return PinSchema.parse(JSON.parse(json));
|
|
196
|
+
}
|
|
197
|
+
function openDoStore(sql, storage) {
|
|
198
|
+
migrate(sql, storage);
|
|
199
|
+
return new DoSqlitePinStore(sql, storage);
|
|
200
|
+
}
|
|
201
|
+
var DoSqlitePinStore = class {
|
|
202
|
+
sql;
|
|
203
|
+
storage;
|
|
204
|
+
sessions;
|
|
205
|
+
deliveries;
|
|
206
|
+
cursors;
|
|
207
|
+
links;
|
|
208
|
+
listeners = /* @__PURE__ */ new Set();
|
|
209
|
+
pendingEvents = [];
|
|
210
|
+
constructor(sql, storage) {
|
|
211
|
+
this.sql = sql;
|
|
212
|
+
this.storage = storage;
|
|
213
|
+
this.sessions = new DoSessionStore(sql, storage);
|
|
214
|
+
this.deliveries = new DoDeliveryStore(sql);
|
|
215
|
+
this.cursors = new DoCursorStore(sql);
|
|
216
|
+
this.links = new DoLinkStore(sql);
|
|
217
|
+
}
|
|
218
|
+
createPin(input, env) {
|
|
219
|
+
const parsed = PinInputSchema.parse(input);
|
|
220
|
+
const mergedEnv = { ...parsed.env };
|
|
221
|
+
if (env.branch !== void 0) mergedEnv.branch = env.branch;
|
|
222
|
+
if (env.commit !== void 0) mergedEnv.commit = env.commit;
|
|
223
|
+
const pin = PinSchema.parse({
|
|
224
|
+
...parsed,
|
|
225
|
+
env: mergedEnv,
|
|
226
|
+
id: newId("pin"),
|
|
227
|
+
schemaVersion: 1,
|
|
228
|
+
status: "open",
|
|
229
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
230
|
+
});
|
|
231
|
+
return this.mutate(() => {
|
|
232
|
+
this.sql.exec("INSERT INTO pins (id, status, created_at, json) VALUES (?, ?, ?, ?)", pin.id, pin.status, pin.createdAt, JSON.stringify(pin));
|
|
233
|
+
this.sql.exec("INSERT INTO pins_fts (pin_id, kind, body) VALUES (?, ?, ?)", pin.id, "pin", pin.text);
|
|
234
|
+
this.appendEvent("pin.created", pin.createdAt, pin);
|
|
235
|
+
return pin;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
getPin(id) {
|
|
239
|
+
const row = this.sql.exec("SELECT json FROM pins WHERE id = ?", id).toArray()[0];
|
|
240
|
+
return row ? parsePin(row.json) : null;
|
|
241
|
+
}
|
|
242
|
+
listPins(filter) {
|
|
243
|
+
return (filter?.status ? this.sql.exec("SELECT json FROM pins WHERE status = ? ORDER BY created_at DESC, rowid DESC", filter.status) : this.sql.exec("SELECT json FROM pins ORDER BY created_at DESC, rowid DESC")).toArray().map((row) => parsePin(row.json));
|
|
244
|
+
}
|
|
245
|
+
addThreadMessage(pinId, role, text, opts) {
|
|
246
|
+
const message = ThreadMessageSchema.parse({
|
|
247
|
+
id: newId("msg"),
|
|
248
|
+
pinId,
|
|
249
|
+
role,
|
|
250
|
+
text,
|
|
251
|
+
...opts?.origin !== void 0 ? { origin: opts.origin } : {},
|
|
252
|
+
...opts?.attachments !== void 0 ? { attachments: opts.attachments } : {},
|
|
253
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
254
|
+
});
|
|
255
|
+
return this.mutate(() => {
|
|
256
|
+
this.mustGetPin(pinId);
|
|
257
|
+
this.sql.exec("INSERT INTO thread_messages (id, pin_id, at, json) VALUES (?, ?, ?, ?)", message.id, message.pinId, message.at, JSON.stringify(message));
|
|
258
|
+
this.sql.exec("INSERT INTO pins_fts (pin_id, kind, body) VALUES (?, ?, ?)", pinId, "thread", message.text);
|
|
259
|
+
this.appendEvent("thread.message", message.at, message);
|
|
260
|
+
return message;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
getThread(pinId) {
|
|
264
|
+
return this.sql.exec("SELECT json FROM thread_messages WHERE pin_id = ? ORDER BY rowid ASC", pinId).toArray().map((row) => ThreadMessageSchema.parse(JSON.parse(row.json)));
|
|
265
|
+
}
|
|
266
|
+
resolvePin(id, by, note, commit) {
|
|
267
|
+
return this.mutate(() => {
|
|
268
|
+
const pin = this.mustGetPin(id);
|
|
269
|
+
if (pin.status === "resolved") throw new ConflictError(`pin already resolved: ${id}`);
|
|
270
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
271
|
+
const resolved = PinSchema.parse({
|
|
272
|
+
...pin,
|
|
273
|
+
status: "resolved",
|
|
274
|
+
resolution: {
|
|
275
|
+
by,
|
|
276
|
+
at,
|
|
277
|
+
...note !== void 0 ? { note } : {},
|
|
278
|
+
...commit !== void 0 ? { commit } : {}
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
this.updatePinRow(resolved);
|
|
282
|
+
this.appendEvent("pin.resolved", at, resolved);
|
|
283
|
+
return resolved;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
verifyPin(id, outcome) {
|
|
287
|
+
return this.mutate(() => {
|
|
288
|
+
const pin = this.mustGetPin(id);
|
|
289
|
+
if (pin.status !== "resolved") throw new ConflictError(`pin is not resolved, cannot verify: ${id}`);
|
|
290
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
291
|
+
const verified = PinSchema.parse({
|
|
292
|
+
...pin,
|
|
293
|
+
status: outcome === "reopened" ? "open" : "resolved",
|
|
294
|
+
verification: {
|
|
295
|
+
outcome,
|
|
296
|
+
at
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
this.updatePinRow(verified);
|
|
300
|
+
this.appendEvent("pin.verified", at, verified);
|
|
301
|
+
return verified;
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
searchPins(query) {
|
|
305
|
+
const match = query.split(/\s+/).filter(Boolean).map((token) => `"${token.replaceAll("\"", "\"\"")}"`).join(" ");
|
|
306
|
+
if (match === "") return [];
|
|
307
|
+
return this.sql.exec(`SELECT json FROM pins
|
|
308
|
+
WHERE id IN (SELECT pin_id FROM pins_fts WHERE pins_fts MATCH ?)
|
|
309
|
+
ORDER BY created_at DESC, rowid DESC`, match).toArray().map((row) => parsePin(row.json));
|
|
310
|
+
}
|
|
311
|
+
addLink(pinId, link) {
|
|
312
|
+
return this.mutate(() => {
|
|
313
|
+
const pin = this.mustGetPin(pinId);
|
|
314
|
+
if (this.sql.exec("SELECT ref FROM links WHERE pin_id = ? AND connector = ? AND ref = ?", pinId, link.connector, link.ref).toArray()[0]) throw new ConflictError(`link already exists: ${link.connector}:${link.ref} on ${pinId}`);
|
|
315
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
316
|
+
this.sql.exec("INSERT INTO links (pin_id, connector, ref, url, created_at, last_synced_at) VALUES (?, ?, ?, ?, ?, NULL)", pinId, link.connector, link.ref, link.url, at);
|
|
317
|
+
const updated = PinSchema.parse({
|
|
318
|
+
...pin,
|
|
319
|
+
links: [...pin.links ?? [], link]
|
|
320
|
+
});
|
|
321
|
+
this.updatePinRow(updated);
|
|
322
|
+
this.appendEvent("pin.linked", at, updated);
|
|
323
|
+
return updated;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
bindSession(pinId, ref) {
|
|
327
|
+
return this.mutate(() => {
|
|
328
|
+
const pin = this.mustGetPin(pinId);
|
|
329
|
+
const bound = PinSchema.parse({
|
|
330
|
+
...pin,
|
|
331
|
+
agentSession: SessionRefSchema.parse(ref)
|
|
332
|
+
});
|
|
333
|
+
this.updatePinRow(bound);
|
|
334
|
+
return bound;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
setDueAt(id, at) {
|
|
338
|
+
this.mutate(() => {
|
|
339
|
+
this.mustGetPin(id);
|
|
340
|
+
this.sql.exec("UPDATE pins SET due_at = ? WHERE id = ?", at, id);
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
pinsDueBefore(at) {
|
|
344
|
+
return this.sql.exec("SELECT json FROM pins WHERE due_at IS NOT NULL AND due_at <= ? ORDER BY due_at ASC", at).toArray().map((row) => parsePin(row.json));
|
|
345
|
+
}
|
|
346
|
+
eventsAfter(seq) {
|
|
347
|
+
return this.sql.exec("SELECT seq, type, at, payload FROM events WHERE seq > ? ORDER BY seq ASC", seq).toArray().map((row) => ({
|
|
348
|
+
seq: row.seq,
|
|
349
|
+
type: row.type,
|
|
350
|
+
at: row.at,
|
|
351
|
+
payload: JSON.parse(row.payload)
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
summary() {
|
|
355
|
+
const row = this.sql.exec(`SELECT
|
|
356
|
+
(SELECT COUNT(*) FROM pins WHERE status = 'open') AS open,
|
|
357
|
+
(SELECT COUNT(*) FROM pins WHERE status = 'resolved') AS resolved,
|
|
358
|
+
(SELECT COALESCE(MAX(seq), 0) FROM events) AS lastEventSeq`).one();
|
|
359
|
+
return {
|
|
360
|
+
open: row.open,
|
|
361
|
+
resolved: row.resolved,
|
|
362
|
+
lastEventSeq: row.lastEventSeq
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
subscribe(listener) {
|
|
366
|
+
this.listeners.add(listener);
|
|
367
|
+
return () => void this.listeners.delete(listener);
|
|
368
|
+
}
|
|
369
|
+
close() {}
|
|
370
|
+
/** Runs fn in transactionSync, then flushes appended events post-commit in seq order. */
|
|
371
|
+
mutate(fn) {
|
|
372
|
+
let result;
|
|
373
|
+
try {
|
|
374
|
+
result = this.storage.transactionSync(fn);
|
|
375
|
+
} catch (error) {
|
|
376
|
+
this.pendingEvents.length = 0;
|
|
377
|
+
throw error;
|
|
378
|
+
}
|
|
379
|
+
const events = this.pendingEvents.splice(0);
|
|
380
|
+
for (const event of events) for (const listener of this.listeners) try {
|
|
381
|
+
listener(event);
|
|
382
|
+
} catch {}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
appendEvent(type, at, payload) {
|
|
386
|
+
const row = this.sql.exec("INSERT INTO events (type, at, payload) VALUES (?, ?, ?) RETURNING seq", type, at, JSON.stringify(payload)).one();
|
|
387
|
+
this.pendingEvents.push({
|
|
388
|
+
seq: row.seq,
|
|
389
|
+
type,
|
|
390
|
+
at,
|
|
391
|
+
payload
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
updatePinRow(pin) {
|
|
395
|
+
this.sql.exec("UPDATE pins SET status = ?, json = ? WHERE id = ?", pin.status, JSON.stringify(pin), pin.id);
|
|
396
|
+
}
|
|
397
|
+
mustGetPin(id) {
|
|
398
|
+
const pin = this.getPin(id);
|
|
399
|
+
if (!pin) throw new NotFoundError(`pin not found: ${id}`);
|
|
400
|
+
return pin;
|
|
401
|
+
}
|
|
402
|
+
};
|
|
403
|
+
//#endregion
|
|
404
|
+
//#region src/r2.ts
|
|
405
|
+
const REGION = "auto";
|
|
406
|
+
const SERVICE = "s3";
|
|
407
|
+
const MAX_EXPIRES = 604800;
|
|
408
|
+
async function presignR2Put(opts) {
|
|
409
|
+
const amzDate = toAmzDate(opts.now ?? /* @__PURE__ */ new Date());
|
|
410
|
+
const dateStamp = amzDate.slice(0, 8);
|
|
411
|
+
const expires = Math.min(Math.max(Math.trunc(opts.expiresSeconds ?? 900), 1), MAX_EXPIRES);
|
|
412
|
+
const host = `${opts.accountId}.r2.cloudflarestorage.com`;
|
|
413
|
+
const credential = `${opts.accessKeyId}/${dateStamp}/${REGION}/${SERVICE}/aws4_request`;
|
|
414
|
+
const canonicalPath = `/${uriEncodePath(opts.bucket)}/${uriEncodePath(opts.key)}`;
|
|
415
|
+
const headers = [["host", host]];
|
|
416
|
+
if (opts.contentLength !== void 0) headers.push(["content-length", String(Math.trunc(opts.contentLength))]);
|
|
417
|
+
headers.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
418
|
+
const canonicalHeaders = headers.map(([name, value]) => `${name}:${value}\n`).join("");
|
|
419
|
+
const signedHeaders = headers.map(([name]) => name).join(";");
|
|
420
|
+
const canonicalQuery = [
|
|
421
|
+
["X-Amz-Algorithm", "AWS4-HMAC-SHA256"],
|
|
422
|
+
["X-Amz-Credential", credential],
|
|
423
|
+
["X-Amz-Date", amzDate],
|
|
424
|
+
["X-Amz-Expires", String(expires)],
|
|
425
|
+
["X-Amz-SignedHeaders", signedHeaders],
|
|
426
|
+
["response-content-type", opts.contentType]
|
|
427
|
+
].map(([name, value]) => [uriEncode(name), uriEncode(value)]).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([name, value]) => `${name}=${value}`).join("&");
|
|
428
|
+
const canonicalRequest = [
|
|
429
|
+
"PUT",
|
|
430
|
+
canonicalPath,
|
|
431
|
+
canonicalQuery,
|
|
432
|
+
canonicalHeaders,
|
|
433
|
+
signedHeaders,
|
|
434
|
+
"UNSIGNED-PAYLOAD"
|
|
435
|
+
].join("\n");
|
|
436
|
+
const stringToSign = [
|
|
437
|
+
"AWS4-HMAC-SHA256",
|
|
438
|
+
amzDate,
|
|
439
|
+
`${dateStamp}/${REGION}/${SERVICE}/aws4_request`,
|
|
440
|
+
await sha256Hex(canonicalRequest)
|
|
441
|
+
].join("\n");
|
|
442
|
+
return `https://${host}${canonicalPath}?${canonicalQuery}&X-Amz-Signature=${toHex(await hmac(await deriveSigningKey(opts.secretAccessKey, dateStamp), stringToSign))}`;
|
|
443
|
+
}
|
|
444
|
+
function toAmzDate(date) {
|
|
445
|
+
return `${date.toISOString().slice(0, 19).replaceAll("-", "").replaceAll(":", "")}Z`;
|
|
446
|
+
}
|
|
447
|
+
function uriEncode(value) {
|
|
448
|
+
return encodeURIComponent(value).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
449
|
+
}
|
|
450
|
+
function uriEncodePath(path) {
|
|
451
|
+
return path.split("/").map(uriEncode).join("/");
|
|
452
|
+
}
|
|
453
|
+
async function sha256Hex(data) {
|
|
454
|
+
return toHex(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(data)));
|
|
455
|
+
}
|
|
456
|
+
async function hmac(key, data) {
|
|
457
|
+
const cryptoKey = await crypto.subtle.importKey("raw", key, {
|
|
458
|
+
name: "HMAC",
|
|
459
|
+
hash: "SHA-256"
|
|
460
|
+
}, false, ["sign"]);
|
|
461
|
+
return crypto.subtle.sign("HMAC", cryptoKey, new TextEncoder().encode(data));
|
|
462
|
+
}
|
|
463
|
+
async function deriveSigningKey(secret, dateStamp) {
|
|
464
|
+
return hmac(await hmac(await hmac(await hmac(new TextEncoder().encode(`AWS4${secret}`), dateStamp), REGION), SERVICE), "aws4_request");
|
|
465
|
+
}
|
|
466
|
+
function toHex(buffer) {
|
|
467
|
+
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
468
|
+
}
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/do.ts
|
|
471
|
+
const MAX_ATTACHMENT_BYTES = 5242880;
|
|
472
|
+
function overCap() {
|
|
473
|
+
return err(413, "E_ATTACHMENT", "attachment exceeds the 5 MB cap", { hint: "downscale the capture; attachments carry a path or URL, never inline bytes" });
|
|
474
|
+
}
|
|
475
|
+
function attachmentInputError(req, url) {
|
|
476
|
+
const declared = req.headers.get("content-length");
|
|
477
|
+
if (declared !== null) {
|
|
478
|
+
const declaredBytes = Number(declared);
|
|
479
|
+
if (!Number.isFinite(declaredBytes) || declaredBytes > MAX_ATTACHMENT_BYTES) return overCap();
|
|
480
|
+
}
|
|
481
|
+
const kind = url.searchParams.get("kind") ?? "file";
|
|
482
|
+
if (kind !== "screenshot" && kind !== "file") return err(400, "E_INVALID_INPUT", `unknown attachment kind: ${kind}`, { hint: "use ?kind=screenshot or ?kind=file" });
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
async function measureBody(req, cap) {
|
|
486
|
+
const body = req.body;
|
|
487
|
+
if (body === null) return 0;
|
|
488
|
+
const reader = body.getReader();
|
|
489
|
+
let total = 0;
|
|
490
|
+
try {
|
|
491
|
+
for (;;) {
|
|
492
|
+
const { done, value } = await reader.read();
|
|
493
|
+
if (done) return total;
|
|
494
|
+
total += value.byteLength;
|
|
495
|
+
if (total > cap) {
|
|
496
|
+
await reader.cancel("attachment exceeds the 5 MB cap");
|
|
497
|
+
return null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
} finally {
|
|
501
|
+
reader.releaseLock();
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
function r2Credentials(env) {
|
|
505
|
+
const { R2_ACCOUNT_ID, R2_BUCKET, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY } = env;
|
|
506
|
+
if (!R2_ACCOUNT_ID || !R2_BUCKET || !R2_ACCESS_KEY_ID || !R2_SECRET_ACCESS_KEY) return null;
|
|
507
|
+
return {
|
|
508
|
+
accountId: R2_ACCOUNT_ID,
|
|
509
|
+
bucket: R2_BUCKET,
|
|
510
|
+
accessKeyId: R2_ACCESS_KEY_ID,
|
|
511
|
+
secretAccessKey: R2_SECRET_ACCESS_KEY
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function buildStrategy(env) {
|
|
515
|
+
const strategy = env.AUTH_STRATEGY ?? "token";
|
|
516
|
+
if (strategy === "none") {
|
|
517
|
+
if (env.ALLOW_UNAUTHENTICATED !== "1") return {
|
|
518
|
+
error: "AUTH_STRATEGY \"none\" refused",
|
|
519
|
+
hint: "set ALLOW_UNAUTHENTICATED=1 to explicitly run an unauthenticated hub (loopback/dev only)"
|
|
520
|
+
};
|
|
521
|
+
return { verify: verifyNone() };
|
|
522
|
+
}
|
|
523
|
+
if (strategy === "jwt") {
|
|
524
|
+
const { JWT_ISSUER, JWT_JWKS_URL, JWT_AUDIENCE } = env;
|
|
525
|
+
if (!JWT_ISSUER || !JWT_JWKS_URL || !JWT_AUDIENCE) return {
|
|
526
|
+
error: "AUTH_STRATEGY \"jwt\" is missing configuration",
|
|
527
|
+
hint: "set JWT_ISSUER, JWT_JWKS_URL, and JWT_AUDIENCE"
|
|
528
|
+
};
|
|
529
|
+
return { verify: verifyJwt({
|
|
530
|
+
issuer: JWT_ISSUER,
|
|
531
|
+
jwksUrl: JWT_JWKS_URL,
|
|
532
|
+
audience: JWT_AUDIENCE
|
|
533
|
+
}) };
|
|
534
|
+
}
|
|
535
|
+
if (!env.PINBOX_TOKEN) return {
|
|
536
|
+
error: "AUTH_STRATEGY \"token\" has no PINBOX_TOKEN",
|
|
537
|
+
hint: "set the PINBOX_TOKEN secret (wrangler secret put PINBOX_TOKEN)"
|
|
538
|
+
};
|
|
539
|
+
return { verify: verifyToken(env.PINBOX_TOKEN) };
|
|
540
|
+
}
|
|
541
|
+
function refuse(req, url, strategy) {
|
|
542
|
+
if (req.method === "GET" && url.pathname === "/health") return err(503, "E_INTERNAL", `hub is unhealthy: ${strategy.error}`, { hint: strategy.hint });
|
|
543
|
+
return err(500, "E_INTERNAL", strategy.error, { hint: strategy.hint });
|
|
544
|
+
}
|
|
545
|
+
var DoBroadcaster = class {
|
|
546
|
+
state;
|
|
547
|
+
constructor(state) {
|
|
548
|
+
this.state = state;
|
|
549
|
+
}
|
|
550
|
+
publish(topic, data) {
|
|
551
|
+
for (const ws of this.state.getWebSockets(topic)) try {
|
|
552
|
+
ws.send(data);
|
|
553
|
+
} catch {}
|
|
554
|
+
}
|
|
555
|
+
subscriberCount(topic) {
|
|
556
|
+
return this.state.getWebSockets(topic).length;
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
var PinboxHubDO = class {
|
|
560
|
+
store;
|
|
561
|
+
broadcaster;
|
|
562
|
+
topic;
|
|
563
|
+
ctx;
|
|
564
|
+
env;
|
|
565
|
+
strategy;
|
|
566
|
+
handler;
|
|
567
|
+
constructor(ctx, env) {
|
|
568
|
+
this.ctx = ctx;
|
|
569
|
+
this.env = env;
|
|
570
|
+
this.store = openDoStore(ctx.storage.sql, ctx.storage);
|
|
571
|
+
this.topic = `project:${ctx.id.name ?? ctx.id.toString()}`;
|
|
572
|
+
this.broadcaster = new DoBroadcaster(ctx);
|
|
573
|
+
this.strategy = buildStrategy(env);
|
|
574
|
+
this.handler = createHubHandler({
|
|
575
|
+
store: this.store,
|
|
576
|
+
token: env.PINBOX_TOKEN ?? "",
|
|
577
|
+
..."verify" in this.strategy ? { verify: this.strategy.verify } : {}
|
|
578
|
+
});
|
|
579
|
+
this.store.subscribe((event) => this.broadcaster.publish(this.topic, encodeWsEvent(event)));
|
|
580
|
+
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair("ping", "pong"));
|
|
581
|
+
}
|
|
582
|
+
async fetch(req) {
|
|
583
|
+
const url = new URL(req.url);
|
|
584
|
+
if ("error" in this.strategy) return refuse(req, url, this.strategy);
|
|
585
|
+
const verify = this.strategy.verify;
|
|
586
|
+
if (url.pathname === "/ws") return this.upgrade(req, url, verify);
|
|
587
|
+
return await this.intercept(req, url, verify) ?? this.handler(req);
|
|
588
|
+
}
|
|
589
|
+
async intercept(req, url, verify) {
|
|
590
|
+
if (req.method === "POST" && url.pathname === "/attachments") return await this.unauthorized(verify, req) ?? this.createAttachment(req, url);
|
|
591
|
+
const mediaKey = /^\/media\/([^/]+)$/.exec(url.pathname)?.[1];
|
|
592
|
+
if (req.method === "GET" && mediaKey !== void 0) return await this.unauthorized(verify, req) ?? this.serveMedia(mediaKey);
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
webSocketMessage(ws, message) {
|
|
596
|
+
if (typeof message !== "string") {
|
|
597
|
+
this.protocolError(ws, "binary frames are not part of protocol 1");
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (ws.deserializeAttachment() !== null) {
|
|
601
|
+
this.protocolError(ws, "protocol 1 has exactly one client message: hello");
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
let raw;
|
|
605
|
+
try {
|
|
606
|
+
raw = JSON.parse(message);
|
|
607
|
+
} catch {
|
|
608
|
+
this.protocolError(ws, "hello frame is not valid JSON");
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
const hello = ClientHelloSchema.safeParse(raw);
|
|
612
|
+
if (!hello.success) {
|
|
613
|
+
this.protocolError(ws, "malformed hello frame");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (hello.data.protocol < 1) {
|
|
617
|
+
this.protocolError(ws, `protocol ${hello.data.protocol} is below the minimum 1 — upgrade the client`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
ws.serializeAttachment({ consumerId: hello.data.consumerId });
|
|
621
|
+
this.store.cursors.set(hello.data.consumerId, hello.data.lastSeq);
|
|
622
|
+
ws.send(JSON.stringify({
|
|
623
|
+
type: "catch-up",
|
|
624
|
+
protocol: 1,
|
|
625
|
+
minProtocol: 1,
|
|
626
|
+
lastSeq: this.store.summary().lastEventSeq,
|
|
627
|
+
events: this.store.eventsAfter(hello.data.lastSeq).map((event) => ({
|
|
628
|
+
type: "event",
|
|
629
|
+
seq: event.seq,
|
|
630
|
+
eventType: event.type,
|
|
631
|
+
at: event.at,
|
|
632
|
+
payload: event.payload
|
|
633
|
+
}))
|
|
634
|
+
}));
|
|
635
|
+
}
|
|
636
|
+
webSocketClose(_ws, _code, _reason, _wasClean) {}
|
|
637
|
+
async alarm() {}
|
|
638
|
+
async upgrade(req, url, verify) {
|
|
639
|
+
let token = url.searchParams.get("token");
|
|
640
|
+
let acceptedProtocol;
|
|
641
|
+
for (const entry of (req.headers.get("sec-websocket-protocol") ?? "").split(",")) {
|
|
642
|
+
const candidate = entry.trim();
|
|
643
|
+
if (candidate.startsWith("pinbox.token.")) {
|
|
644
|
+
token = candidate.slice(WS_TOKEN_SUBPROTOCOL_PREFIX.length);
|
|
645
|
+
acceptedProtocol = candidate;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
const identity = await verify(new Request(url.origin, token === null ? {} : { headers: { authorization: `Bearer ${token}` } }));
|
|
649
|
+
const pair = new WebSocketPair();
|
|
650
|
+
const client = pair[0];
|
|
651
|
+
const server = pair[1];
|
|
652
|
+
if (identity === null) {
|
|
653
|
+
server.accept();
|
|
654
|
+
server.close(WS_CLOSE_UNAUTHORIZED, "missing or invalid token");
|
|
655
|
+
} else this.ctx.acceptWebSocket(server, [this.topic]);
|
|
656
|
+
return new Response(null, {
|
|
657
|
+
status: 101,
|
|
658
|
+
headers: acceptedProtocol === void 0 ? {} : { "sec-websocket-protocol": acceptedProtocol },
|
|
659
|
+
webSocket: client
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
protocolError(ws, message) {
|
|
663
|
+
ws.send(JSON.stringify({
|
|
664
|
+
type: "error",
|
|
665
|
+
code: "E_WS_PROTOCOL",
|
|
666
|
+
message
|
|
667
|
+
}));
|
|
668
|
+
ws.close(WS_CLOSE_PROTOCOL, message.slice(0, 123));
|
|
669
|
+
}
|
|
670
|
+
async unauthorized(verify, req) {
|
|
671
|
+
if (await verify(req) !== null) return null;
|
|
672
|
+
return err(401, "E_AUTH", "request rejected by the configured verifier", { hint: "send a credential the hub's verify strategy accepts" });
|
|
673
|
+
}
|
|
674
|
+
async createAttachment(req, url) {
|
|
675
|
+
const rejected = attachmentInputError(req, url);
|
|
676
|
+
if (rejected !== null) return rejected;
|
|
677
|
+
const creds = r2Credentials(this.env);
|
|
678
|
+
if (creds === null) return err(500, "E_INTERNAL", "R2 S3 credentials are not configured", { hint: "set R2_ACCESS_KEY_ID/R2_SECRET_ACCESS_KEY — the R2 binding cannot presign" });
|
|
679
|
+
const contentLength = await measureBody(req, MAX_ATTACHMENT_BYTES);
|
|
680
|
+
if (contentLength === null) return overCap();
|
|
681
|
+
const id = newId("att");
|
|
682
|
+
const kind = url.searchParams.get("kind") ?? "file";
|
|
683
|
+
const contentType = req.headers.get("content-type") ?? "application/octet-stream";
|
|
684
|
+
const uploadUrl = await presignR2Put({
|
|
685
|
+
...creds,
|
|
686
|
+
key: id,
|
|
687
|
+
contentType,
|
|
688
|
+
contentLength
|
|
689
|
+
});
|
|
690
|
+
const attachment = AttachmentSchema.parse({
|
|
691
|
+
id,
|
|
692
|
+
kind,
|
|
693
|
+
url: `/media/${id}`,
|
|
694
|
+
contentType
|
|
695
|
+
});
|
|
696
|
+
return ok(201, {
|
|
697
|
+
attachment,
|
|
698
|
+
uploadUrl
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
async serveMedia(key) {
|
|
702
|
+
const media = this.env.MEDIA;
|
|
703
|
+
if (!media) return err(500, "E_INTERNAL", "no MEDIA bucket binding", { hint: "bind an R2 bucket named MEDIA in wrangler.jsonc" });
|
|
704
|
+
const object = await media.get(key);
|
|
705
|
+
if (object === null) return err(404, "E_NOT_FOUND", `media not found: ${key}`);
|
|
706
|
+
return new Response(object.body, {
|
|
707
|
+
status: 200,
|
|
708
|
+
headers: { "content-type": object.httpMetadata?.contentType ?? "application/octet-stream" }
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
//#endregion
|
|
713
|
+
export { DoBroadcaster, PinboxHubDO };
|