@pellux/goodvibes-tui 0.24.1 → 0.26.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/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/handlers/calendar/caldav-client.ts +661 -0
- package/src/daemon/handlers/calendar/ics.ts +556 -0
- package/src/daemon/handlers/calendar/index.ts +316 -0
- package/src/daemon/handlers/context.ts +29 -0
- package/src/daemon/handlers/contracts.ts +77 -0
- package/src/daemon/handlers/credentials.ts +129 -0
- package/src/daemon/handlers/drafts/draft-store.ts +427 -0
- package/src/daemon/handlers/drafts/index.ts +17 -0
- package/src/daemon/handlers/drafts/register.ts +331 -0
- package/src/daemon/handlers/email/config.ts +164 -0
- package/src/daemon/handlers/email/imap-connector.ts +441 -0
- package/src/daemon/handlers/email/imap-parsing.ts +499 -0
- package/src/daemon/handlers/email/index.ts +43 -0
- package/src/daemon/handlers/email/read-handlers.ts +80 -0
- package/src/daemon/handlers/email/runtime.ts +140 -0
- package/src/daemon/handlers/email/smtp-connector.ts +557 -0
- package/src/daemon/handlers/email/validation.ts +147 -0
- package/src/daemon/handlers/email/write-handlers.ts +133 -0
- package/src/daemon/handlers/errors.ts +18 -0
- package/src/daemon/handlers/inbox/cursor-store.ts +290 -0
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/handlers/inbox/mapping.ts +192 -0
- package/src/daemon/handlers/inbox/poller.ts +155 -0
- package/src/daemon/handlers/inbox/provider-adapter.ts +156 -0
- package/src/daemon/handlers/inbox/providers/discord.ts +251 -0
- package/src/daemon/handlers/inbox/providers/email.ts +151 -0
- package/src/daemon/handlers/inbox/providers/imap-client.ts +300 -0
- package/src/daemon/handlers/inbox/providers/route-util.ts +23 -0
- package/src/daemon/handlers/inbox/providers/slack.ts +262 -0
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/handlers/remote/backends/cloud-terminal.ts +142 -0
- package/src/daemon/handlers/remote/backends/docker.ts +79 -0
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/handlers/remote/backends/local-process.ts +113 -0
- package/src/daemon/handlers/remote/backends/process-runner.ts +127 -0
- package/src/daemon/handlers/remote/backends/ssh.ts +125 -0
- package/src/daemon/handlers/remote/backends/types.ts +97 -0
- package/src/daemon/handlers/remote/dispatcher.ts +181 -0
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/handlers/remote/peer-registry.ts +357 -0
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/handlers/routing/inbox-bridge.ts +71 -0
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/handlers/routing/route-store.ts +319 -0
- package/src/daemon/handlers/routing/routing-resolver.ts +75 -0
- package/src/daemon/handlers/sqlite-store.ts +124 -0
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/handlers/triage/pipeline.ts +273 -0
- package/src/daemon/handlers/triage/scorer.ts +287 -0
- package/src/daemon/handlers/triage/tagger/discord.ts +186 -0
- package/src/daemon/handlers/triage/tagger/imap.ts +383 -0
- package/src/daemon/handlers/triage/tagger/index.ts +184 -0
- package/src/daemon/handlers/triage/tagger/shared.ts +70 -0
- package/src/daemon/handlers/triage/tagger/slack.ts +69 -0
- package/src/daemon/handlers/triage/types.ts +50 -0
- package/src/runtime/services.ts +48 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import type { AtRestCipher } from '../credentials.ts';
|
|
3
|
+
import { HandlerSqliteStore } from '../sqlite-store.ts';
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Draft Sync Backend store (rebased onto the daemon handler foundation).
|
|
7
|
+
//
|
|
8
|
+
// Mirrors the agent's local draft store (channels/drafts.json) server-side so
|
|
9
|
+
// drafts are visible across surfaces. The agent's local file remains the source
|
|
10
|
+
// of truth; this store is a sync mirror. Conflict resolution (most-recent
|
|
11
|
+
// updatedAt wins) is the integrator's concern — this store performs a plain
|
|
12
|
+
// upsert and records the supplied/derived updatedAt.
|
|
13
|
+
//
|
|
14
|
+
// SECURITY POSTURE:
|
|
15
|
+
// - The plaintext message body is NEVER persisted. It is encrypted at rest
|
|
16
|
+
// via the daemon at-rest cipher (AES-256-GCM) as `bodyEnc`.
|
|
17
|
+
// - The webhook URL is encrypted at rest (`webhookEnc`) and is ALWAYS
|
|
18
|
+
// redacted ('[redacted]') in every list/get response — the raw URL never
|
|
19
|
+
// leaves the store.
|
|
20
|
+
// - `messageDigest` (sha256First(body, 12)) is computed at save time and
|
|
21
|
+
// stored alongside, so reads never need to decrypt the body. The required
|
|
22
|
+
// `message` field of every emitted record carries the DIGEST, never the
|
|
23
|
+
// plaintext body.
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/** SHA-256 of `input`, truncated to the first `hexChars` hex characters. */
|
|
27
|
+
export function sha256First(input: string, hexChars: number): string {
|
|
28
|
+
const digest = createHash('sha256').update(input, 'utf-8').digest('hex');
|
|
29
|
+
return digest.slice(0, Math.max(0, hexChars));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Redact a stored webhook for responses: present → '[redacted]', else undefined. */
|
|
33
|
+
export function redactWebhook(value?: string | null): string | undefined {
|
|
34
|
+
return value === undefined || value === null || value === '' ? undefined : '[redacted]';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type DraftStatus = 'draft' | 'queued' | 'sent' | 'failed';
|
|
38
|
+
|
|
39
|
+
/** Statuses callers are permitted to write via save(). */
|
|
40
|
+
export const WRITABLE_DRAFT_STATUSES: readonly DraftStatus[] = ['draft', 'queued'];
|
|
41
|
+
|
|
42
|
+
/** All known statuses (writable + terminal states set by the send pipeline). */
|
|
43
|
+
export const ALL_DRAFT_STATUSES: readonly DraftStatus[] = [
|
|
44
|
+
'draft',
|
|
45
|
+
'queued',
|
|
46
|
+
'sent',
|
|
47
|
+
'failed',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
export const DRAFT_MESSAGE_DIGEST_HEX = 12;
|
|
51
|
+
export const DEFAULT_DRAFT_VERSION = 1;
|
|
52
|
+
export const DEFAULT_DRAFT_LIST_LIMIT = 50;
|
|
53
|
+
export const MAX_DRAFT_LIST_LIMIT = 200;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Public draft record returned by list/get/save. Matches the SDK
|
|
57
|
+
* CHANNEL_DRAFT_SCHEMA: the required `message` field carries the message
|
|
58
|
+
* DIGEST (never the plaintext body). `webhook` is always '[redacted]' when set.
|
|
59
|
+
* `messageDigest` is emitted ONLY by `get` (whose output schema permits
|
|
60
|
+
* additional properties); strict list/save records omit it.
|
|
61
|
+
*/
|
|
62
|
+
export interface DraftRecord {
|
|
63
|
+
version: number;
|
|
64
|
+
id: string;
|
|
65
|
+
createdAt: string; // ISO-8601
|
|
66
|
+
updatedAt: string; // ISO-8601
|
|
67
|
+
status: DraftStatus;
|
|
68
|
+
message: string; // sha256First(body, 12) digest — plaintext body is NEVER transmitted
|
|
69
|
+
title?: string;
|
|
70
|
+
channel?: string;
|
|
71
|
+
route?: string;
|
|
72
|
+
webhook?: string; // '[redacted]' when a webhook is stored, else omitted
|
|
73
|
+
link?: string;
|
|
74
|
+
tags?: string[];
|
|
75
|
+
sentResponseId?: string;
|
|
76
|
+
sendError?: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Normalized input accepted by upsert(). `message` is the FULL plaintext body. */
|
|
80
|
+
export interface DraftSaveInput {
|
|
81
|
+
id?: string;
|
|
82
|
+
version?: number;
|
|
83
|
+
title?: string;
|
|
84
|
+
message: string;
|
|
85
|
+
channel?: string;
|
|
86
|
+
route?: string;
|
|
87
|
+
webhook?: string;
|
|
88
|
+
link?: string;
|
|
89
|
+
tags?: string[];
|
|
90
|
+
status?: DraftStatus;
|
|
91
|
+
createdAt?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Caller-supplied last-modified timestamp (ISO-8601). When provided it is
|
|
94
|
+
* persisted verbatim, enabling the sync contract's 'most recent updatedAt
|
|
95
|
+
* wins' conflict model; when omitted the store stamps updatedAt = now().
|
|
96
|
+
*/
|
|
97
|
+
updatedAt?: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface DraftListQuery {
|
|
101
|
+
status?: DraftStatus;
|
|
102
|
+
limit?: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface DraftSaveResult {
|
|
106
|
+
draft: DraftRecord;
|
|
107
|
+
created: boolean;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface DraftSyncStoreOptions {
|
|
111
|
+
workingDirectory: string;
|
|
112
|
+
cipher: AtRestCipher;
|
|
113
|
+
/** Override the sqlite filename (tests). Defaults to 'drafts.sqlite'. */
|
|
114
|
+
fileName?: string;
|
|
115
|
+
/** UUID generator injection point (tests). Defaults to crypto.randomUUID. */
|
|
116
|
+
generateId?: () => string;
|
|
117
|
+
/** Clock injection point (tests). Defaults to () => new Date().toISOString(). */
|
|
118
|
+
now?: () => string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const SCHEMA: string[] = [
|
|
122
|
+
`CREATE TABLE IF NOT EXISTS drafts (
|
|
123
|
+
id TEXT PRIMARY KEY,
|
|
124
|
+
version INTEGER NOT NULL,
|
|
125
|
+
createdAt TEXT NOT NULL,
|
|
126
|
+
updatedAt TEXT NOT NULL,
|
|
127
|
+
status TEXT NOT NULL,
|
|
128
|
+
title TEXT,
|
|
129
|
+
bodyEnc TEXT NOT NULL,
|
|
130
|
+
messageDigest TEXT NOT NULL,
|
|
131
|
+
channel TEXT,
|
|
132
|
+
route TEXT,
|
|
133
|
+
webhookEnc TEXT,
|
|
134
|
+
link TEXT,
|
|
135
|
+
tags TEXT,
|
|
136
|
+
sentResponseId TEXT,
|
|
137
|
+
sendError TEXT
|
|
138
|
+
)`,
|
|
139
|
+
'CREATE INDEX IF NOT EXISTS idx_drafts_status ON drafts (status)',
|
|
140
|
+
'CREATE INDEX IF NOT EXISTS idx_drafts_updatedAt ON drafts (updatedAt)',
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
interface DraftRow {
|
|
144
|
+
id: string;
|
|
145
|
+
version: number;
|
|
146
|
+
createdAt: string;
|
|
147
|
+
updatedAt: string;
|
|
148
|
+
status: string;
|
|
149
|
+
title: string | null;
|
|
150
|
+
messageDigest: string;
|
|
151
|
+
channel: string | null;
|
|
152
|
+
route: string | null;
|
|
153
|
+
webhookEnc: string | null;
|
|
154
|
+
link: string | null;
|
|
155
|
+
tags: string | null;
|
|
156
|
+
sentResponseId: string | null;
|
|
157
|
+
sendError: string | null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function nullable(value: string | undefined): string | null {
|
|
161
|
+
return value === undefined ? null : value;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function parseTags(raw: string | null): string[] | undefined {
|
|
165
|
+
if (raw === null || raw === '') return undefined;
|
|
166
|
+
try {
|
|
167
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
168
|
+
if (Array.isArray(parsed)) {
|
|
169
|
+
const tags = parsed.filter((t): t is string => typeof t === 'string');
|
|
170
|
+
return tags.length > 0 ? tags : undefined;
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// Corrupt tags column — treat as absent rather than throwing on read.
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeStatus(status: DraftStatus | undefined): DraftStatus {
|
|
179
|
+
return status ?? 'draft';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function normalizeVersion(version: number | undefined): number {
|
|
183
|
+
if (version === undefined || !Number.isFinite(version)) return DEFAULT_DRAFT_VERSION;
|
|
184
|
+
const floored = Math.floor(version);
|
|
185
|
+
return floored < 1 ? DEFAULT_DRAFT_VERSION : floored;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Server-side mirror of agent drafts. Wraps HandlerSqliteStore; all body and
|
|
190
|
+
* webhook material is encrypted at rest via the injected AtRestCipher.
|
|
191
|
+
*/
|
|
192
|
+
export class DraftSyncStore {
|
|
193
|
+
private readonly store: HandlerSqliteStore;
|
|
194
|
+
private readonly cipher: AtRestCipher;
|
|
195
|
+
private readonly generateId: () => string;
|
|
196
|
+
private readonly now: () => string;
|
|
197
|
+
private initialized = false;
|
|
198
|
+
|
|
199
|
+
constructor(options: DraftSyncStoreOptions) {
|
|
200
|
+
this.store = new HandlerSqliteStore({
|
|
201
|
+
workingDirectory: options.workingDirectory,
|
|
202
|
+
fileName: options.fileName ?? 'drafts.sqlite',
|
|
203
|
+
schema: SCHEMA,
|
|
204
|
+
});
|
|
205
|
+
this.cipher = options.cipher;
|
|
206
|
+
this.generateId = options.generateId ?? (() => crypto.randomUUID());
|
|
207
|
+
this.now = options.now ?? (() => new Date().toISOString());
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
get dbPath(): string {
|
|
211
|
+
return this.store.dbPath;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async init(): Promise<void> {
|
|
215
|
+
if (this.initialized) return;
|
|
216
|
+
await this.store.init();
|
|
217
|
+
this.initialized = true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Persist the in-memory db to disk (atomic tmp+rename via the base store). */
|
|
221
|
+
async save(): Promise<void> {
|
|
222
|
+
await this.store.save();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
close(): void {
|
|
226
|
+
this.store.close();
|
|
227
|
+
this.initialized = false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Create (no id) or update (id present) a draft. Encrypts the body and
|
|
232
|
+
* webhook at rest, computes messageDigest, preserves createdAt on update, and
|
|
233
|
+
* sets updatedAt to input.updatedAt when present (enabling 'most recent
|
|
234
|
+
* updatedAt wins') or now() otherwise.
|
|
235
|
+
*
|
|
236
|
+
* FULL-REPLACE SEMANTICS (intentional, per the full-snapshot sync contract):
|
|
237
|
+
* every caller-supplied field is a complete snapshot, NOT a partial patch.
|
|
238
|
+
* The ON CONFLICT UPDATE SET clause assigns each column from the incoming row,
|
|
239
|
+
* so an OMITTED optional field is CLEARED on update (webhookEnc/tags/title/
|
|
240
|
+
* channel/route/link → NULL). The ONLY exceptions are sentResponseId/
|
|
241
|
+
* sendError, which are owned by the send pipeline and survive a save().
|
|
242
|
+
*/
|
|
243
|
+
async upsert(input: DraftSaveInput): Promise<DraftSaveResult> {
|
|
244
|
+
const existing = input.id !== undefined ? this.getRow(input.id) : null;
|
|
245
|
+
const created = existing === null;
|
|
246
|
+
const id = existing?.id ?? input.id ?? this.generateId();
|
|
247
|
+
const timestamp = this.now();
|
|
248
|
+
const createdAt = existing?.createdAt ?? input.createdAt ?? timestamp;
|
|
249
|
+
const updatedAt = input.updatedAt ?? timestamp;
|
|
250
|
+
const status = normalizeStatus(input.status);
|
|
251
|
+
const version = normalizeVersion(input.version);
|
|
252
|
+
|
|
253
|
+
const bodyEnc = await this.cipher.encrypt(input.message);
|
|
254
|
+
const messageDigest = sha256First(input.message, DRAFT_MESSAGE_DIGEST_HEX);
|
|
255
|
+
const webhookEnc =
|
|
256
|
+
input.webhook !== undefined && input.webhook !== ''
|
|
257
|
+
? await this.cipher.encrypt(input.webhook)
|
|
258
|
+
: null;
|
|
259
|
+
const tagsJson =
|
|
260
|
+
input.tags !== undefined && input.tags.length > 0
|
|
261
|
+
? JSON.stringify(input.tags)
|
|
262
|
+
: null;
|
|
263
|
+
|
|
264
|
+
this.store.run(
|
|
265
|
+
`INSERT INTO drafts (
|
|
266
|
+
id, version, createdAt, updatedAt, status, title, bodyEnc, messageDigest,
|
|
267
|
+
channel, route, webhookEnc, link, tags, sentResponseId, sendError
|
|
268
|
+
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
|
269
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
270
|
+
version = excluded.version,
|
|
271
|
+
updatedAt = excluded.updatedAt,
|
|
272
|
+
status = excluded.status,
|
|
273
|
+
title = excluded.title,
|
|
274
|
+
bodyEnc = excluded.bodyEnc,
|
|
275
|
+
messageDigest = excluded.messageDigest,
|
|
276
|
+
channel = excluded.channel,
|
|
277
|
+
route = excluded.route,
|
|
278
|
+
webhookEnc = excluded.webhookEnc,
|
|
279
|
+
link = excluded.link,
|
|
280
|
+
tags = excluded.tags`,
|
|
281
|
+
[
|
|
282
|
+
id,
|
|
283
|
+
version,
|
|
284
|
+
createdAt,
|
|
285
|
+
updatedAt,
|
|
286
|
+
status,
|
|
287
|
+
nullable(input.title),
|
|
288
|
+
bodyEnc,
|
|
289
|
+
messageDigest,
|
|
290
|
+
nullable(input.channel),
|
|
291
|
+
nullable(input.route),
|
|
292
|
+
webhookEnc,
|
|
293
|
+
nullable(input.link),
|
|
294
|
+
tagsJson,
|
|
295
|
+
// sentResponseId / sendError are owned by the send pipeline, not save().
|
|
296
|
+
// On update they are preserved (omitted from UPDATE SET); on insert NULL.
|
|
297
|
+
null,
|
|
298
|
+
null,
|
|
299
|
+
],
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
const row = this.getRow(id);
|
|
303
|
+
return { draft: toRecord(row ?? this.fallbackRow(id, version, createdAt, updatedAt, status, messageDigest, webhookEnc, input)), created };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** List drafts, newest-updated first, optionally filtered by status. */
|
|
307
|
+
list(query: DraftListQuery = {}): DraftRecord[] {
|
|
308
|
+
const limit = clampLimit(query.limit);
|
|
309
|
+
const rows =
|
|
310
|
+
query.status !== undefined
|
|
311
|
+
? this.store.all<DraftRow>(
|
|
312
|
+
`SELECT id, version, createdAt, updatedAt, status, title, messageDigest,
|
|
313
|
+
channel, route, webhookEnc, link, tags, sentResponseId, sendError
|
|
314
|
+
FROM drafts WHERE status = ? ORDER BY updatedAt DESC, id ASC LIMIT ?`,
|
|
315
|
+
[query.status, limit],
|
|
316
|
+
)
|
|
317
|
+
: this.store.all<DraftRow>(
|
|
318
|
+
`SELECT id, version, createdAt, updatedAt, status, title, messageDigest,
|
|
319
|
+
channel, route, webhookEnc, link, tags, sentResponseId, sendError
|
|
320
|
+
FROM drafts ORDER BY updatedAt DESC, id ASC LIMIT ?`,
|
|
321
|
+
[limit],
|
|
322
|
+
);
|
|
323
|
+
return rows.map((row) => toRecord(row));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Total number of stored drafts (optionally status-filtered). */
|
|
327
|
+
count(status?: DraftStatus): number {
|
|
328
|
+
const row =
|
|
329
|
+
status !== undefined
|
|
330
|
+
? this.store.get<{ n: number }>('SELECT COUNT(*) AS n FROM drafts WHERE status = ?', [status])
|
|
331
|
+
: this.store.get<{ n: number }>('SELECT COUNT(*) AS n FROM drafts');
|
|
332
|
+
return row?.n ?? 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Fetch a single draft as a redacted record, or null when absent. */
|
|
336
|
+
get(id: string): DraftRecord | null {
|
|
337
|
+
const row = this.getRow(id);
|
|
338
|
+
return row === null ? null : toRecord(row);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Delete a draft. Returns true when a row was removed. */
|
|
342
|
+
delete(id: string): boolean {
|
|
343
|
+
const existed = this.getRow(id) !== null;
|
|
344
|
+
if (existed) {
|
|
345
|
+
this.store.run('DELETE FROM drafts WHERE id = ?', [id]);
|
|
346
|
+
}
|
|
347
|
+
return existed;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
private getRow(id: string): DraftRow | null {
|
|
351
|
+
return this.store.get<DraftRow>(
|
|
352
|
+
`SELECT id, version, createdAt, updatedAt, status, title, messageDigest,
|
|
353
|
+
channel, route, webhookEnc, link, tags, sentResponseId, sendError
|
|
354
|
+
FROM drafts WHERE id = ?`,
|
|
355
|
+
[id],
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Defensive synthesis if a read-back ever returns null (should not happen for
|
|
360
|
+
// the row we just wrote); keeps upsert total even under a degraded backend.
|
|
361
|
+
private fallbackRow(
|
|
362
|
+
id: string,
|
|
363
|
+
version: number,
|
|
364
|
+
createdAt: string,
|
|
365
|
+
updatedAt: string,
|
|
366
|
+
status: DraftStatus,
|
|
367
|
+
messageDigest: string,
|
|
368
|
+
webhookEnc: string | null,
|
|
369
|
+
input: DraftSaveInput,
|
|
370
|
+
): DraftRow {
|
|
371
|
+
return {
|
|
372
|
+
id,
|
|
373
|
+
version,
|
|
374
|
+
createdAt,
|
|
375
|
+
updatedAt,
|
|
376
|
+
status,
|
|
377
|
+
title: nullable(input.title),
|
|
378
|
+
messageDigest,
|
|
379
|
+
channel: nullable(input.channel),
|
|
380
|
+
route: nullable(input.route),
|
|
381
|
+
webhookEnc,
|
|
382
|
+
link: nullable(input.link),
|
|
383
|
+
tags:
|
|
384
|
+
input.tags !== undefined && input.tags.length > 0
|
|
385
|
+
? JSON.stringify(input.tags)
|
|
386
|
+
: null,
|
|
387
|
+
sentResponseId: null,
|
|
388
|
+
sendError: null,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function clampLimit(limit: number | undefined): number {
|
|
394
|
+
if (limit === undefined || !Number.isFinite(limit)) return DEFAULT_DRAFT_LIST_LIMIT;
|
|
395
|
+
const floored = Math.floor(limit);
|
|
396
|
+
if (floored < 1) return 1;
|
|
397
|
+
if (floored > MAX_DRAFT_LIST_LIMIT) return MAX_DRAFT_LIST_LIMIT;
|
|
398
|
+
return floored;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Map a stored row to a wire record. The required `message` field carries the
|
|
403
|
+
* DIGEST (never plaintext); the webhook is redacted; tags are parsed.
|
|
404
|
+
*/
|
|
405
|
+
export function toRecord(row: DraftRow): DraftRecord {
|
|
406
|
+
const record: DraftRecord = {
|
|
407
|
+
version: row.version,
|
|
408
|
+
id: row.id,
|
|
409
|
+
createdAt: row.createdAt,
|
|
410
|
+
updatedAt: row.updatedAt,
|
|
411
|
+
status: row.status as DraftStatus,
|
|
412
|
+
message: row.messageDigest,
|
|
413
|
+
};
|
|
414
|
+
if (row.title !== null) record.title = row.title;
|
|
415
|
+
if (row.channel !== null) record.channel = row.channel;
|
|
416
|
+
if (row.route !== null) record.route = row.route;
|
|
417
|
+
// Webhook is encrypted at rest; reads ALWAYS redact. Presence of webhookEnc
|
|
418
|
+
// means a webhook exists → emit '[redacted]'; the raw URL never leaves here.
|
|
419
|
+
const redacted = redactWebhook(row.webhookEnc);
|
|
420
|
+
if (redacted !== undefined) record.webhook = redacted;
|
|
421
|
+
if (row.link !== null) record.link = row.link;
|
|
422
|
+
const tags = parseTags(row.tags);
|
|
423
|
+
if (tags !== undefined) record.tags = tags;
|
|
424
|
+
if (row.sentResponseId !== null) record.sentResponseId = row.sentResponseId;
|
|
425
|
+
if (row.sendError !== null) record.sendError = row.sendError;
|
|
426
|
+
return record;
|
|
427
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels.drafts.* handler surface barrel.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports only the concrete register entrypoint and the store/types other
|
|
5
|
+
* code may need. It imports concrete submodules (no project index barrels), so
|
|
6
|
+
* it introduces no import cycle.
|
|
7
|
+
*/
|
|
8
|
+
export { registerDraftMethods } from './register.ts';
|
|
9
|
+
export type { RegisterDraftsOptions } from './register.ts';
|
|
10
|
+
export { DraftSyncStore, sha256First, redactWebhook } from './draft-store.ts';
|
|
11
|
+
export type {
|
|
12
|
+
DraftListQuery,
|
|
13
|
+
DraftRecord,
|
|
14
|
+
DraftSaveInput,
|
|
15
|
+
DraftSaveResult,
|
|
16
|
+
DraftStatus,
|
|
17
|
+
} from './draft-store.ts';
|