@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,316 @@
|
|
|
1
|
+
// Handler registration for the CalDAV calendar surface.
|
|
2
|
+
//
|
|
3
|
+
// Attaches host handlers to the five SDK-registered calendar gateway method
|
|
4
|
+
// descriptors BY ID (the SDK auto-registers them with handler:undefined). No
|
|
5
|
+
// method id, descriptor, or schema is authored here — `registerCatalogHandlers`
|
|
6
|
+
// looks up the canonical descriptor via `catalog.get(id)` and re-registers it
|
|
7
|
+
// with the wrapped handler. Method IDs handled (exactly):
|
|
8
|
+
// calendar.events.list read:calendar no confirm
|
|
9
|
+
// calendar.events.get read:calendar no confirm
|
|
10
|
+
// calendar.events.create write:calendar confirm:true + explicitUserRequest
|
|
11
|
+
// calendar.ics.import write:calendar confirm:true + explicitUserRequest
|
|
12
|
+
// calendar.ics.export read:calendar no confirm
|
|
13
|
+
//
|
|
14
|
+
// SECURITY: CalDAV credentials and authenticated URLs NEVER appear in any
|
|
15
|
+
// response. `calendarId` is a logical id. Attendees are surfaced as display
|
|
16
|
+
// names only (no raw addresses). Outputs are stripped to exactly the SDK output
|
|
17
|
+
// schema fields (the SDK schemas set additionalProperties:false).
|
|
18
|
+
|
|
19
|
+
import { HandlerError } from '../errors.ts';
|
|
20
|
+
import type { HandlerContext } from '../context.ts';
|
|
21
|
+
import {
|
|
22
|
+
registerCatalogHandlers,
|
|
23
|
+
type CatalogHandlerEntry,
|
|
24
|
+
type TypedHandler,
|
|
25
|
+
type Unregister,
|
|
26
|
+
} from '../register.ts';
|
|
27
|
+
import {
|
|
28
|
+
createCalDavClient,
|
|
29
|
+
resolveCalDavConfig,
|
|
30
|
+
type CalDavClient,
|
|
31
|
+
type CalDavConfigContext,
|
|
32
|
+
type CalDavEvent,
|
|
33
|
+
} from './caldav-client.ts';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The exact set of method IDs this surface publishes — a single source of truth
|
|
37
|
+
* for the calendar capability set, consumed by integration wiring and tests.
|
|
38
|
+
*/
|
|
39
|
+
export const CALENDAR_METHOD_IDS = [
|
|
40
|
+
'calendar.events.list',
|
|
41
|
+
'calendar.events.get',
|
|
42
|
+
'calendar.events.create',
|
|
43
|
+
'calendar.ics.import',
|
|
44
|
+
'calendar.ics.export',
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Client factory injection (real client by default; tests inject a stub).
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export type CalDavClientFactory = (ctx: CalDavConfigContext) => Promise<CalDavClient>;
|
|
52
|
+
|
|
53
|
+
const realClientFactory: CalDavClientFactory = async (ctx) => {
|
|
54
|
+
const config = await resolveCalDavConfig(ctx);
|
|
55
|
+
return createCalDavClient({ config });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export interface RegisterCalendarOptions {
|
|
59
|
+
/** Override the CalDAV client factory (for tests). */
|
|
60
|
+
clientFactory?: CalDavClientFactory;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
// Input validation helpers
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
function asRecord(body: unknown): Record<string, unknown> {
|
|
68
|
+
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
|
|
69
|
+
throw new HandlerError('Request body must be an object.', 'CALENDAR_BAD_INPUT', 400);
|
|
70
|
+
}
|
|
71
|
+
return body as Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function optionalString(record: Record<string, unknown>, key: string): string | undefined {
|
|
75
|
+
const value = record[key];
|
|
76
|
+
if (value === undefined || value === null) return undefined;
|
|
77
|
+
if (typeof value !== 'string') {
|
|
78
|
+
throw new HandlerError(`Field '${key}' must be a string.`, 'CALENDAR_BAD_INPUT', 400);
|
|
79
|
+
}
|
|
80
|
+
const trimmed = value.trim();
|
|
81
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function requiredString(record: Record<string, unknown>, key: string): string {
|
|
85
|
+
const value = optionalString(record, key);
|
|
86
|
+
if (value === undefined) {
|
|
87
|
+
throw new HandlerError(`Field '${key}' is required.`, 'CALENDAR_BAD_INPUT', 400);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function optionalNumber(record: Record<string, unknown>, key: string): number | undefined {
|
|
93
|
+
const value = record[key];
|
|
94
|
+
if (value === undefined || value === null) return undefined;
|
|
95
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
96
|
+
throw new HandlerError(`Field '${key}' must be a number.`, 'CALENDAR_BAD_INPUT', 400);
|
|
97
|
+
}
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function optionalStringArray(record: Record<string, unknown>, key: string): string[] | undefined {
|
|
102
|
+
const value = record[key];
|
|
103
|
+
if (value === undefined || value === null) return undefined;
|
|
104
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
|
|
105
|
+
throw new HandlerError(`Field '${key}' must be an array of strings.`, 'CALENDAR_BAD_INPUT', 400);
|
|
106
|
+
}
|
|
107
|
+
return (value as string[]).map((item) => item.trim()).filter((item) => item.length > 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function validateIsoDate(value: string, field: string): string {
|
|
111
|
+
if (Number.isNaN(new Date(value).getTime())) {
|
|
112
|
+
throw new HandlerError(`Field '${field}' must be a valid ISO-8601 date.`, 'CALENDAR_BAD_INPUT', 400);
|
|
113
|
+
}
|
|
114
|
+
return value;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Response mapping (credential-free, PII-stripped, schema-exact)
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Wire shape for `calendar.events.list` items — EXACTLY the SDK
|
|
123
|
+
* CALENDAR_EVENT_SUMMARY_SCHEMA (additionalProperties:false). Optional fields
|
|
124
|
+
* are present only when populated. `calendarId`/`allDay`/organizer are NOT in
|
|
125
|
+
* the SDK schema and are intentionally dropped.
|
|
126
|
+
*/
|
|
127
|
+
export interface CalendarEventSummary {
|
|
128
|
+
id: string;
|
|
129
|
+
title: string;
|
|
130
|
+
start: string;
|
|
131
|
+
end: string;
|
|
132
|
+
location?: string;
|
|
133
|
+
description?: string;
|
|
134
|
+
attendees?: string[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function displayAttendees(event: CalDavEvent): string[] {
|
|
138
|
+
return event.attendees.map((a) => a.displayName).filter((name) => name.length > 0);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toSummary(event: CalDavEvent): CalendarEventSummary {
|
|
142
|
+
const attendees = displayAttendees(event);
|
|
143
|
+
const summary: CalendarEventSummary = {
|
|
144
|
+
id: event.href || event.uid,
|
|
145
|
+
title: event.summary,
|
|
146
|
+
start: event.start,
|
|
147
|
+
end: event.end,
|
|
148
|
+
};
|
|
149
|
+
if (event.location !== undefined) summary.location = event.location;
|
|
150
|
+
if (event.description !== undefined) summary.description = event.description;
|
|
151
|
+
if (attendees.length > 0) summary.attendees = attendees;
|
|
152
|
+
return summary;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Wire shape for `calendar.events.get` — EXACTLY the SDK
|
|
157
|
+
* CALENDAR_EVENT_DETAIL_SCHEMA (additionalProperties:false), returned directly
|
|
158
|
+
* (not wrapped). `uid` carries the raw iCalendar UID; attendees are display
|
|
159
|
+
* names only.
|
|
160
|
+
*/
|
|
161
|
+
export interface CalendarEventDetail {
|
|
162
|
+
id: string;
|
|
163
|
+
uid: string;
|
|
164
|
+
title: string;
|
|
165
|
+
start: string;
|
|
166
|
+
end: string;
|
|
167
|
+
location?: string;
|
|
168
|
+
description?: string;
|
|
169
|
+
attendees?: string[];
|
|
170
|
+
recurrence?: string;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function toDetail(event: CalDavEvent): CalendarEventDetail {
|
|
174
|
+
const attendees = displayAttendees(event);
|
|
175
|
+
const detail: CalendarEventDetail = {
|
|
176
|
+
id: event.href || event.uid,
|
|
177
|
+
uid: event.uid,
|
|
178
|
+
title: event.summary,
|
|
179
|
+
start: event.start,
|
|
180
|
+
end: event.end,
|
|
181
|
+
};
|
|
182
|
+
if (event.location !== undefined) detail.location = event.location;
|
|
183
|
+
if (event.description !== undefined) detail.description = event.description;
|
|
184
|
+
if (attendees.length > 0) detail.attendees = attendees;
|
|
185
|
+
if (event.recurrence !== undefined) detail.recurrence = event.recurrence;
|
|
186
|
+
return detail;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Handler bodies
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
interface ListBody {
|
|
194
|
+
calendarId?: string;
|
|
195
|
+
from?: string;
|
|
196
|
+
to?: string;
|
|
197
|
+
limit?: number;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface GetBody {
|
|
201
|
+
eventId: string;
|
|
202
|
+
calendarId?: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
interface ExportBody {
|
|
206
|
+
calendarId?: string;
|
|
207
|
+
from?: string;
|
|
208
|
+
to?: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Register all five calendar handlers against the SDK catalog held by `ctx`.
|
|
213
|
+
* Returns a single Unregister that detaches them in reverse order.
|
|
214
|
+
*/
|
|
215
|
+
export function registerCalendarMethods(
|
|
216
|
+
ctx: HandlerContext,
|
|
217
|
+
options: RegisterCalendarOptions = {},
|
|
218
|
+
): Unregister {
|
|
219
|
+
const clientFactory = options.clientFactory ?? realClientFactory;
|
|
220
|
+
const getClient = (): Promise<CalDavClient> => clientFactory(ctx);
|
|
221
|
+
|
|
222
|
+
const listHandler: TypedHandler<unknown, { events: CalendarEventSummary[] }> = async ({ body }) => {
|
|
223
|
+
const record = body === undefined || body === null ? {} : asRecord(body);
|
|
224
|
+
const input: ListBody = {
|
|
225
|
+
calendarId: optionalString(record, 'calendarId'),
|
|
226
|
+
from: optionalString(record, 'from'),
|
|
227
|
+
to: optionalString(record, 'to'),
|
|
228
|
+
limit: optionalNumber(record, 'limit'),
|
|
229
|
+
};
|
|
230
|
+
if (input.from) validateIsoDate(input.from, 'from');
|
|
231
|
+
if (input.to) validateIsoDate(input.to, 'to');
|
|
232
|
+
const limit = input.limit !== undefined ? Math.max(1, Math.min(200, Math.floor(input.limit))) : 20;
|
|
233
|
+
const client = await getClient();
|
|
234
|
+
const events = await client.listEvents({ ...input, limit });
|
|
235
|
+
return { events: events.map(toSummary) };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const getHandler: TypedHandler<unknown, CalendarEventDetail> = async ({ body }) => {
|
|
239
|
+
const record = asRecord(body);
|
|
240
|
+
const input: GetBody = {
|
|
241
|
+
eventId: requiredString(record, 'eventId'),
|
|
242
|
+
calendarId: optionalString(record, 'calendarId'),
|
|
243
|
+
};
|
|
244
|
+
const client = await getClient();
|
|
245
|
+
const event = await client.getEvent(input.eventId, input.calendarId);
|
|
246
|
+
if (!event) {
|
|
247
|
+
throw new HandlerError(`Event not found: ${input.eventId}`, 'CALENDAR_NOT_FOUND', 404);
|
|
248
|
+
}
|
|
249
|
+
return toDetail(event);
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const createHandler: TypedHandler<unknown, { eventId: string; uid: string; createdAt: string }> = async ({ body }) => {
|
|
253
|
+
const record = asRecord(body);
|
|
254
|
+
const title = requiredString(record, 'title');
|
|
255
|
+
const start = validateIsoDate(requiredString(record, 'start'), 'start');
|
|
256
|
+
const end = validateIsoDate(requiredString(record, 'end'), 'end');
|
|
257
|
+
if (new Date(end).getTime() < new Date(start).getTime()) {
|
|
258
|
+
throw new HandlerError("Field 'end' must not be before 'start'.", 'CALENDAR_BAD_INPUT', 400);
|
|
259
|
+
}
|
|
260
|
+
const client = await getClient();
|
|
261
|
+
const created = await client.createEvent({
|
|
262
|
+
title,
|
|
263
|
+
start,
|
|
264
|
+
end,
|
|
265
|
+
description: optionalString(record, 'description'),
|
|
266
|
+
attendees: optionalStringArray(record, 'attendees'),
|
|
267
|
+
location: optionalString(record, 'location'),
|
|
268
|
+
calendarId: optionalString(record, 'calendarId'),
|
|
269
|
+
});
|
|
270
|
+
return { eventId: created.eventId, uid: created.uid, createdAt: created.createdAt };
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const importHandler: TypedHandler<unknown, { imported: number; eventIds: string[]; errors: string[] }> = async ({ body }) => {
|
|
274
|
+
const record = asRecord(body);
|
|
275
|
+
const icsContent = requiredString(record, 'icsContent');
|
|
276
|
+
const calendarId = optionalString(record, 'calendarId');
|
|
277
|
+
const client = await getClient();
|
|
278
|
+
return client.importIcs(icsContent, calendarId);
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const exportHandler: TypedHandler<unknown, { icsContent: string; eventCount: number }> = async ({ body }) => {
|
|
282
|
+
const record = body === undefined || body === null ? {} : asRecord(body);
|
|
283
|
+
const input: ExportBody = {
|
|
284
|
+
calendarId: optionalString(record, 'calendarId'),
|
|
285
|
+
from: optionalString(record, 'from'),
|
|
286
|
+
to: optionalString(record, 'to'),
|
|
287
|
+
};
|
|
288
|
+
if (input.from) validateIsoDate(input.from, 'from');
|
|
289
|
+
if (input.to) validateIsoDate(input.to, 'to');
|
|
290
|
+
const client = await getClient();
|
|
291
|
+
return client.exportIcs(input);
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
const entries: CatalogHandlerEntry[] = [
|
|
295
|
+
{ id: 'calendar.events.list', handler: listHandler as TypedHandler<unknown, unknown> },
|
|
296
|
+
{ id: 'calendar.events.get', handler: getHandler as TypedHandler<unknown, unknown> },
|
|
297
|
+
{
|
|
298
|
+
id: 'calendar.events.create',
|
|
299
|
+
handler: createHandler as TypedHandler<unknown, unknown>,
|
|
300
|
+
options: { confirm: true },
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: 'calendar.ics.import',
|
|
304
|
+
handler: importHandler as TypedHandler<unknown, unknown>,
|
|
305
|
+
options: { confirm: true },
|
|
306
|
+
},
|
|
307
|
+
{ id: 'calendar.ics.export', handler: exportHandler as TypedHandler<unknown, unknown> },
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
return registerCatalogHandlers(ctx.catalog, entries);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** SurfaceRegister-compatible entry point used by the daemon composition root. */
|
|
314
|
+
export function registerCalendar(ctx: HandlerContext): Unregister {
|
|
315
|
+
return registerCalendarMethods(ctx);
|
|
316
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host-facing context passed to every surface register function. Replaces the
|
|
3
|
+
* former OperatorContext. It carries the SDK gateway catalog (handlers attach
|
|
4
|
+
* to it), the daemon credential store, a read-only slice of the config manager,
|
|
5
|
+
* resolved directories, and a logger. No SDK descriptor or schema is declared
|
|
6
|
+
* here — the catalog type is re-exported through the contracts seam.
|
|
7
|
+
*/
|
|
8
|
+
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
9
|
+
import type { GatewayMethodCatalog } from './contracts.ts';
|
|
10
|
+
import type { DaemonCredentialStore } from './credentials.ts';
|
|
11
|
+
import type { Unregister } from './register.ts';
|
|
12
|
+
|
|
13
|
+
export interface HandlerLogger {
|
|
14
|
+
info(message: string, meta?: unknown): void;
|
|
15
|
+
warn(message: string, meta?: unknown): void;
|
|
16
|
+
error(message: string, meta?: unknown): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HandlerContext {
|
|
20
|
+
readonly catalog: GatewayMethodCatalog;
|
|
21
|
+
readonly credentials: DaemonCredentialStore;
|
|
22
|
+
readonly configManager: Pick<ConfigManager, 'get' | 'getCategory'>;
|
|
23
|
+
readonly workingDirectory: string;
|
|
24
|
+
readonly homeDirectory: string;
|
|
25
|
+
readonly logger: HandlerLogger;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Every surface module exports a register function of this shape. */
|
|
29
|
+
export type SurfaceRegister = (ctx: HandlerContext) => Unregister;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single SDK-contract import seam for the daemon handler layer.
|
|
3
|
+
*
|
|
4
|
+
* Every other module under `src/daemon/handlers/` imports SDK contract
|
|
5
|
+
* identifiers from HERE and nowhere else. Concentrating the SDK imports in one
|
|
6
|
+
* concrete-submodule module keeps the rest of the layer free of barrel cycles
|
|
7
|
+
* and guarantees the host NEVER re-declares an SDK id, descriptor, or schema —
|
|
8
|
+
* it only attaches handlers to the descriptors the SDK already registered.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Catalog + invocation contract types (concrete control-plane subpath, not a project barrel).
|
|
12
|
+
export type {
|
|
13
|
+
GatewayMethodCatalog,
|
|
14
|
+
GatewayMethodDescriptor,
|
|
15
|
+
GatewayMethodInvocation,
|
|
16
|
+
GatewayMethodInvocationContext,
|
|
17
|
+
GatewayMethodHandler,
|
|
18
|
+
} from '@pellux/goodvibes-sdk/platform/control-plane';
|
|
19
|
+
|
|
20
|
+
// Channel domain types reused in handler signatures (read-only SDK interfaces; never re-declared).
|
|
21
|
+
export type {
|
|
22
|
+
ChannelIdentity,
|
|
23
|
+
ChannelResolvedTarget,
|
|
24
|
+
ChannelAccountRecord,
|
|
25
|
+
} from '@pellux/goodvibes-sdk/platform/channels';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Per-peer authentication envelope passed to peer-scoped remote routes.
|
|
29
|
+
*
|
|
30
|
+
* The daemon-sdk ships `RemotePeerAuth` only as an UNEXPORTED local alias
|
|
31
|
+
* inside `@pellux/goodvibes-daemon-sdk/remote-routes` (it is `unknown` there),
|
|
32
|
+
* so it cannot be re-exported. We mirror that exact shape here for the host
|
|
33
|
+
* implementation. This is an implementable runtime contract, not a method
|
|
34
|
+
* descriptor or schema — declaring it does not violate the no-re-declaration
|
|
35
|
+
* rule for catalog methods.
|
|
36
|
+
*/
|
|
37
|
+
export type RemotePeerAuth = unknown;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Remote distributed-runtime route service the HOST must implement and supply
|
|
41
|
+
* as `RuntimeServices.distributedRuntime`. The SDK facade injects the instance
|
|
42
|
+
* into `DaemonRemoteRouteContext.distributedRuntime` so the published
|
|
43
|
+
* `remote.peers.*` HTTP routes can dispatch to it.
|
|
44
|
+
*
|
|
45
|
+
* The daemon-sdk declares this interface locally in
|
|
46
|
+
* `@pellux/goodvibes-daemon-sdk/remote-routes` but does NOT export it (the
|
|
47
|
+
* module ends with `export {}`), so it cannot be imported. This declaration
|
|
48
|
+
* mirrors the SDK's exact structural shape (17 methods, verbatim signatures)
|
|
49
|
+
* so a host implementation is assignable to the SDK's context field. The SDK
|
|
50
|
+
* ships no docker/ssh/cloud backend — the host owns the implementation.
|
|
51
|
+
*/
|
|
52
|
+
export interface DistributedRuntimeRouteService {
|
|
53
|
+
listPairRequests(): unknown;
|
|
54
|
+
approvePairRequest(requestId: string, input: Record<string, unknown>): Promise<unknown | null>;
|
|
55
|
+
rejectPairRequest(requestId: string, input: Record<string, unknown>): Promise<unknown | null>;
|
|
56
|
+
listPeers(): unknown;
|
|
57
|
+
rotatePeerToken(peerId: string, input: Record<string, unknown>): Promise<unknown | null>;
|
|
58
|
+
revokePeerToken(peerId: string, input: Record<string, unknown>): Promise<unknown | null>;
|
|
59
|
+
disconnectPeer(peerId: string, input: Record<string, unknown>): Promise<unknown | null>;
|
|
60
|
+
listWork(): unknown;
|
|
61
|
+
invokePeer(input: Record<string, unknown>): Promise<unknown>;
|
|
62
|
+
cancelWork(workId: string, input: Record<string, unknown>): Promise<unknown | null>;
|
|
63
|
+
getNodeHostContract(): unknown;
|
|
64
|
+
requestPairing(input: Record<string, unknown>): Promise<unknown>;
|
|
65
|
+
verifyPairRequest(
|
|
66
|
+
requestId: string,
|
|
67
|
+
challenge: string,
|
|
68
|
+
input: Record<string, unknown>,
|
|
69
|
+
): Promise<unknown | null>;
|
|
70
|
+
heartbeatPeer(auth: RemotePeerAuth, input: Record<string, unknown>): Promise<unknown>;
|
|
71
|
+
claimWork(auth: RemotePeerAuth, input: Record<string, unknown>): Promise<unknown>;
|
|
72
|
+
completeWork(
|
|
73
|
+
auth: RemotePeerAuth,
|
|
74
|
+
workId: string,
|
|
75
|
+
input: Record<string, unknown>,
|
|
76
|
+
): Promise<unknown | null>;
|
|
77
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
|
+
import type { SecretsManager } from '../../config/secrets.ts';
|
|
3
|
+
import {
|
|
4
|
+
buildGoodVibesSecretKey,
|
|
5
|
+
isSecretReferenceValue,
|
|
6
|
+
} from '../../config/secret-config.ts';
|
|
7
|
+
|
|
8
|
+
export interface DaemonCredentialStore {
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a goodvibes://secrets/ reference OR a raw secret key to its
|
|
11
|
+
* plaintext value (daemon-internal only).
|
|
12
|
+
*/
|
|
13
|
+
resolveRef(ref: string): Promise<string | null>;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a config-key-derived secret
|
|
16
|
+
* (e.g. 'surfaces.slack.botToken' → GOODVIBES_SURFACES_SLACK_BOT_TOKEN).
|
|
17
|
+
*/
|
|
18
|
+
resolveConfigSecret(configKey: string): Promise<string | null>;
|
|
19
|
+
/** Store a daemon-owned credential. scope defaults 'user'. */
|
|
20
|
+
put(
|
|
21
|
+
secretKey: string,
|
|
22
|
+
value: string,
|
|
23
|
+
opts?: { scope?: 'user' | 'project'; medium?: 'plaintext' | 'secure' },
|
|
24
|
+
): Promise<void>;
|
|
25
|
+
has(secretKey: string): Promise<boolean>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Extract the trailing secret-key segment from a goodvibes://secrets/ reference. */
|
|
29
|
+
function secretKeyFromReference(ref: string): string {
|
|
30
|
+
const normalized = ref.trim();
|
|
31
|
+
const segments = normalized.split('/');
|
|
32
|
+
const last = segments[segments.length - 1] ?? '';
|
|
33
|
+
return decodeURIComponent(last);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createDaemonCredentialStore(secrets: SecretsManager): DaemonCredentialStore {
|
|
37
|
+
return {
|
|
38
|
+
async resolveRef(ref: string): Promise<string | null> {
|
|
39
|
+
const key = isSecretReferenceValue(ref) ? secretKeyFromReference(ref) : ref;
|
|
40
|
+
return secrets.get(key);
|
|
41
|
+
},
|
|
42
|
+
async resolveConfigSecret(configKey: string): Promise<string | null> {
|
|
43
|
+
return secrets.get(buildGoodVibesSecretKey(configKey));
|
|
44
|
+
},
|
|
45
|
+
async put(
|
|
46
|
+
secretKey: string,
|
|
47
|
+
value: string,
|
|
48
|
+
opts?: { scope?: 'user' | 'project'; medium?: 'plaintext' | 'secure' },
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
await secrets.set(secretKey, value, {
|
|
51
|
+
scope: opts?.scope ?? 'user',
|
|
52
|
+
medium: opts?.medium ?? 'secure',
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
async has(secretKey: string): Promise<boolean> {
|
|
56
|
+
const value = await secrets.get(secretKey);
|
|
57
|
+
return value !== null && value.length > 0;
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// At-rest encryption helper for drafts (draft body must be encrypted at rest).
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
export interface AtRestCipher {
|
|
67
|
+
encrypt(plaintext: string): Promise<string>;
|
|
68
|
+
decrypt(ciphertext: string): Promise<string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_DRAFT_KEY_NAME = 'GOODVIBES_DAEMON_DRAFT_AESKEY';
|
|
72
|
+
const AES_KEY_BYTES = 32; // AES-256
|
|
73
|
+
const GCM_IV_BYTES = 12;
|
|
74
|
+
const GCM_TAG_BYTES = 16;
|
|
75
|
+
|
|
76
|
+
async function loadOrCreateKey(
|
|
77
|
+
store: DaemonCredentialStore,
|
|
78
|
+
keyName: string,
|
|
79
|
+
): Promise<Buffer> {
|
|
80
|
+
const existing = await store.resolveRef(keyName);
|
|
81
|
+
if (existing && existing.length > 0) {
|
|
82
|
+
const buf = Buffer.from(existing, 'base64');
|
|
83
|
+
if (buf.length === AES_KEY_BYTES) return buf;
|
|
84
|
+
}
|
|
85
|
+
const generated = randomBytes(AES_KEY_BYTES);
|
|
86
|
+
await store.put(keyName, generated.toString('base64'), { medium: 'secure' });
|
|
87
|
+
return generated;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create an AES-256-GCM at-rest cipher backed by a daemon-owned key.
|
|
92
|
+
* encrypt() returns base64(iv | tag | ciphertext). Uses node:crypto (Bun-compatible).
|
|
93
|
+
* NEVER log resolved keys or plaintext.
|
|
94
|
+
*/
|
|
95
|
+
export function createAtRestCipher(
|
|
96
|
+
store: DaemonCredentialStore,
|
|
97
|
+
keyName: string = DEFAULT_DRAFT_KEY_NAME,
|
|
98
|
+
): AtRestCipher {
|
|
99
|
+
let keyPromise: Promise<Buffer> | null = null;
|
|
100
|
+
const getKey = (): Promise<Buffer> => {
|
|
101
|
+
if (!keyPromise) keyPromise = loadOrCreateKey(store, keyName);
|
|
102
|
+
return keyPromise;
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
async encrypt(plaintext: string): Promise<string> {
|
|
107
|
+
const key = await getKey();
|
|
108
|
+
const iv = randomBytes(GCM_IV_BYTES);
|
|
109
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
110
|
+
const encrypted = Buffer.concat([
|
|
111
|
+
cipher.update(plaintext, 'utf-8'),
|
|
112
|
+
cipher.final(),
|
|
113
|
+
]);
|
|
114
|
+
const tag = cipher.getAuthTag();
|
|
115
|
+
return Buffer.concat([iv, tag, encrypted]).toString('base64');
|
|
116
|
+
},
|
|
117
|
+
async decrypt(ciphertext: string): Promise<string> {
|
|
118
|
+
const key = await getKey();
|
|
119
|
+
const raw = Buffer.from(ciphertext, 'base64');
|
|
120
|
+
const iv = raw.subarray(0, GCM_IV_BYTES);
|
|
121
|
+
const tag = raw.subarray(GCM_IV_BYTES, GCM_IV_BYTES + GCM_TAG_BYTES);
|
|
122
|
+
const data = raw.subarray(GCM_IV_BYTES + GCM_TAG_BYTES);
|
|
123
|
+
const decipher = createDecipheriv('aes-256-gcm', key, iv);
|
|
124
|
+
decipher.setAuthTag(tag);
|
|
125
|
+
const decrypted = Buffer.concat([decipher.update(data), decipher.final()]);
|
|
126
|
+
return decrypted.toString('utf-8');
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|