@pellux/goodvibes-tui 0.25.0 → 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 +5 -3
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +2419 -1040
- package/package.json +2 -2
- package/src/daemon/{calendar → handlers/calendar}/caldav-client.ts +28 -24
- 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/{channels → handlers}/drafts/draft-store.ts +114 -50
- 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/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/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/{channels → handlers}/inbox/cursor-store.ts +58 -66
- package/src/daemon/handlers/inbox/index.ts +210 -0
- package/src/daemon/{channels → handlers}/inbox/mapping.ts +8 -6
- package/src/daemon/{channels → handlers}/inbox/poller.ts +4 -4
- package/src/daemon/{channels → handlers}/inbox/provider-adapter.ts +14 -10
- package/src/daemon/{channels → handlers}/inbox/providers/discord.ts +8 -10
- package/src/daemon/{channels → handlers}/inbox/providers/imap-client.ts +1 -1
- package/src/daemon/{channels → handlers}/inbox/providers/route-util.ts +2 -2
- package/src/daemon/{channels → handlers}/inbox/providers/slack.ts +9 -11
- package/src/daemon/handlers/index.ts +107 -0
- package/src/daemon/handlers/register.ts +161 -0
- package/src/daemon/{remote → handlers/remote}/backends/cloud-terminal.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/docker.ts +2 -3
- package/src/daemon/handlers/remote/backends/index.ts +40 -0
- package/src/daemon/{remote → handlers/remote}/backends/process-runner.ts +16 -40
- package/src/daemon/{remote → handlers/remote}/backends/ssh.ts +8 -3
- package/src/daemon/{remote → handlers/remote}/backends/types.ts +29 -3
- package/src/daemon/{remote → handlers/remote}/dispatcher.ts +27 -6
- package/src/daemon/handlers/remote/index.ts +119 -0
- package/src/daemon/{remote → handlers/remote}/peer-registry.ts +47 -11
- package/src/daemon/handlers/remote/service.ts +191 -0
- package/src/daemon/{channels → handlers}/routing/inbox-bridge.ts +33 -20
- package/src/daemon/handlers/routing/index.ts +261 -0
- package/src/daemon/{channels → handlers}/routing/route-store.ts +57 -16
- package/src/daemon/{channels → handlers}/routing/routing-resolver.ts +1 -1
- package/src/daemon/{operator → handlers}/sqlite-store.ts +5 -5
- package/src/daemon/handlers/triage/index.ts +57 -0
- package/src/daemon/handlers/triage/integration.ts +212 -0
- package/src/daemon/{triage → handlers/triage}/pipeline.ts +58 -70
- package/src/daemon/{triage → handlers/triage}/scorer.ts +21 -21
- 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 -35
- package/src/version.ts +1 -1
- package/src/daemon/calendar/index.ts +0 -52
- package/src/daemon/calendar/register.ts +0 -527
- package/src/daemon/channels/drafts/index.ts +0 -22
- package/src/daemon/channels/drafts/register.ts +0 -449
- package/src/daemon/channels/inbox/index.ts +0 -58
- package/src/daemon/channels/inbox/register.ts +0 -247
- package/src/daemon/channels/routing/index.ts +0 -39
- package/src/daemon/channels/routing/register.ts +0 -296
- package/src/daemon/email/index.ts +0 -68
- package/src/daemon/email/register.ts +0 -715
- package/src/daemon/operator/index.ts +0 -43
- package/src/daemon/operator/register-helper.ts +0 -150
- package/src/daemon/operator/surfaces.ts +0 -137
- package/src/daemon/operator/types.ts +0 -207
- package/src/daemon/remote/backends/index.ts +0 -34
- package/src/daemon/remote/index.ts +0 -74
- package/src/daemon/remote/register.ts +0 -411
- package/src/daemon/triage/index.ts +0 -59
- package/src/daemon/triage/integration.ts +0 -179
- package/src/daemon/triage/register.ts +0 -231
- package/src/daemon/triage/tagger.ts +0 -777
- /package/src/daemon/{calendar → handlers/calendar}/ics.ts +0 -0
- /package/src/daemon/{operator/credential-store.ts → handlers/credentials.ts} +0 -0
- /package/src/daemon/{email → handlers/email}/imap-connector.ts +0 -0
- /package/src/daemon/{email → handlers/email}/imap-parsing.ts +0 -0
- /package/src/daemon/{email → handlers/email}/smtp-connector.ts +0 -0
- /package/src/daemon/{channels → handlers}/inbox/providers/email.ts +0 -0
- /package/src/daemon/{remote → handlers/remote}/backends/local-process.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -99,7 +99,7 @@
|
|
|
99
99
|
"@anthropic-ai/vertex-sdk": "^0.16.0",
|
|
100
100
|
"@ast-grep/napi": "^0.42.0",
|
|
101
101
|
"@aws/bedrock-token-generator": "^1.1.0",
|
|
102
|
-
"@pellux/goodvibes-sdk": "0.
|
|
102
|
+
"@pellux/goodvibes-sdk": "0.34.0",
|
|
103
103
|
"bash-language-server": "^5.6.0",
|
|
104
104
|
"fuse.js": "^7.1.0",
|
|
105
105
|
"graphql": "^16.13.2",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// CalDAV client for the calendar
|
|
1
|
+
// CalDAV client for the calendar handler surface.
|
|
2
2
|
//
|
|
3
3
|
// Authenticates to the user's CalDAV endpoint using credentials resolved from
|
|
4
4
|
// the daemon credential store (config keys under `surfaces.calendar.*`). It
|
|
5
|
-
// implements the CalDAV/WebDAV verbs the
|
|
5
|
+
// implements the CalDAV/WebDAV verbs the gateway methods need:
|
|
6
6
|
// - REPORT (calendar-query) -> list / get events
|
|
7
7
|
// - PUT -> create / import events
|
|
8
8
|
// - REPORT (multiget) / GET -> export a collection
|
|
@@ -16,9 +16,9 @@
|
|
|
16
16
|
// * Event hrefs are returned to callers as opaque relative identifiers, never
|
|
17
17
|
// as fully-qualified authenticated URLs.
|
|
18
18
|
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
19
|
+
import { HandlerError } from '../errors.ts';
|
|
20
|
+
import type { HandlerContext } from '../context.ts';
|
|
21
|
+
import type { DaemonCredentialStore } from '../credentials.ts';
|
|
22
22
|
import {
|
|
23
23
|
generateCalendar,
|
|
24
24
|
generateICS,
|
|
@@ -112,12 +112,15 @@ export interface CalDavClient {
|
|
|
112
112
|
exportIcs(opts: { calendarId?: string; from?: string; to?: string }): Promise<ExportResult>;
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/** Subset of the handler context the CalDAV config resolver needs. */
|
|
116
|
+
export type CalDavConfigContext = Pick<HandlerContext, 'configManager' | 'credentials'>;
|
|
117
|
+
|
|
115
118
|
// ---------------------------------------------------------------------------
|
|
116
119
|
// Config resolution
|
|
117
120
|
// ---------------------------------------------------------------------------
|
|
118
121
|
|
|
119
122
|
function readConfigString(
|
|
120
|
-
ctx: Pick<
|
|
123
|
+
ctx: Pick<HandlerContext, 'configManager'>,
|
|
121
124
|
key: string,
|
|
122
125
|
): string | undefined {
|
|
123
126
|
const value = ctx.configManager.get(key as never);
|
|
@@ -147,16 +150,17 @@ function parseCollectionMap(raw: string | undefined): Record<string, string> {
|
|
|
147
150
|
* Resolve the full CalDAV configuration from config + credential store.
|
|
148
151
|
* The password is resolved from the daemon credential store — either as a
|
|
149
152
|
* goodvibes://secrets/ reference stored in config, or the config-key-derived
|
|
150
|
-
* secret. Throws
|
|
153
|
+
* secret. Throws HandlerError when the surface is not configured. The resolved
|
|
154
|
+
* password is held only in memory and never returned to callers.
|
|
151
155
|
*/
|
|
152
156
|
export async function resolveCalDavConfig(
|
|
153
|
-
ctx:
|
|
157
|
+
ctx: CalDavConfigContext,
|
|
154
158
|
): Promise<CalDavConfig> {
|
|
155
|
-
const credentials =
|
|
159
|
+
const credentials: DaemonCredentialStore = ctx.credentials;
|
|
156
160
|
const baseUrl = readConfigString(ctx, CFG_URL);
|
|
157
161
|
const username = readConfigString(ctx, CFG_USER);
|
|
158
162
|
if (!baseUrl || !username) {
|
|
159
|
-
throw new
|
|
163
|
+
throw new HandlerError(
|
|
160
164
|
'CalDAV is not configured. Set surfaces.calendar.caldavUrl and surfaces.calendar.caldavUser.',
|
|
161
165
|
'CALENDAR_NOT_CONFIGURED',
|
|
162
166
|
412,
|
|
@@ -174,7 +178,7 @@ export async function resolveCalDavConfig(
|
|
|
174
178
|
password = await credentials.resolveConfigSecret(CFG_PASSWORD);
|
|
175
179
|
}
|
|
176
180
|
if (!password) {
|
|
177
|
-
throw new
|
|
181
|
+
throw new HandlerError(
|
|
178
182
|
'CalDAV password is not available in the credential store.',
|
|
179
183
|
'CALENDAR_CREDENTIALS_MISSING',
|
|
180
184
|
412,
|
|
@@ -204,7 +208,7 @@ function joinUrl(base: string, segment: string): string {
|
|
|
204
208
|
/** Extract the scheme+host origin from an absolute URL (no trailing slash). */
|
|
205
209
|
export function originOf(url: string): string {
|
|
206
210
|
const match = /^(https?:\/\/[^/]+)/i.exec(url.trim());
|
|
207
|
-
return match ? match[1] : stripTrailingSlash(url);
|
|
211
|
+
return match ? match[1]! : stripTrailingSlash(url);
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
/**
|
|
@@ -227,7 +231,7 @@ function resolveCollectionUrl(baseUrl: string, path: string): string {
|
|
|
227
231
|
export function toRelativeHref(href: string): string {
|
|
228
232
|
const trimmed = href.trim();
|
|
229
233
|
const schemeMatch = /^https?:\/\/[^/]+(\/.*)$/i.exec(trimmed);
|
|
230
|
-
if (schemeMatch) return schemeMatch[1]
|
|
234
|
+
if (schemeMatch) return schemeMatch[1]!;
|
|
231
235
|
return trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
|
232
236
|
}
|
|
233
237
|
|
|
@@ -302,7 +306,7 @@ function propfindCalendarsBody(): string {
|
|
|
302
306
|
function toCalDavStamp(iso: string): string {
|
|
303
307
|
const date = new Date(iso);
|
|
304
308
|
if (Number.isNaN(date.getTime())) {
|
|
305
|
-
throw new
|
|
309
|
+
throw new HandlerError(`Invalid date range value: ${iso}`, 'CALENDAR_BAD_RANGE', 400);
|
|
306
310
|
}
|
|
307
311
|
const pad = (n: number): string => (n < 10 ? `0${n}` : String(n));
|
|
308
312
|
return (
|
|
@@ -353,7 +357,7 @@ export function parseMultiStatus(xml: string): MultiStatusEntry[] {
|
|
|
353
357
|
const responseRe = /<(?:[a-zA-Z0-9]+:)?response(?:\s[^>]*)?>([\s\S]*?)<\/(?:[a-zA-Z0-9]+:)?response>/gi;
|
|
354
358
|
let match: RegExpExecArray | null;
|
|
355
359
|
while ((match = responseRe.exec(xml)) !== null) {
|
|
356
|
-
const block = match[1]
|
|
360
|
+
const block = match[1]!;
|
|
357
361
|
const hrefRaw = tagContent(block, 'href');
|
|
358
362
|
if (!hrefRaw) continue;
|
|
359
363
|
const href = decodeXmlEntities(hrefRaw.trim());
|
|
@@ -425,18 +429,18 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
|
|
|
425
429
|
// (e.g. "getaddrinfo ENOTFOUND cal.example.com") leaks the CalDAV
|
|
426
430
|
// hostname/URL into caller-visible output (importIcs errors[]).
|
|
427
431
|
// Never include creds/URL/host or raw fetch detail.
|
|
428
|
-
throw new
|
|
432
|
+
throw new HandlerError('CalDAV request failed: network error.', 'CALENDAR_NETWORK_ERROR', 502);
|
|
429
433
|
}
|
|
430
434
|
const text = await response.text();
|
|
431
435
|
if (!response.ok) {
|
|
432
|
-
// Map auth/permission/not-found to
|
|
436
|
+
// Map auth/permission/not-found to handler errors. Never include creds/URL.
|
|
433
437
|
const status = response.status;
|
|
434
438
|
const code = status === 401 || status === 403
|
|
435
439
|
? 'CALENDAR_AUTH_FAILED'
|
|
436
440
|
: status === 404
|
|
437
441
|
? 'CALENDAR_NOT_FOUND'
|
|
438
442
|
: 'CALENDAR_REQUEST_FAILED';
|
|
439
|
-
throw new
|
|
443
|
+
throw new HandlerError(
|
|
440
444
|
`CalDAV server returned HTTP ${status}.`,
|
|
441
445
|
code,
|
|
442
446
|
status >= 400 && status < 500 ? status : 502,
|
|
@@ -472,7 +476,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
|
|
|
472
476
|
const rel = toRelativeHref(entry.href);
|
|
473
477
|
// Derive a stable logical id from the last path segment.
|
|
474
478
|
const segments = rel.split('/').filter((s) => s.length > 0);
|
|
475
|
-
const logical = segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]) : config.defaultCalendarId;
|
|
479
|
+
const logical = segments.length > 0 ? decodeURIComponent(segments[segments.length - 1]!) : config.defaultCalendarId;
|
|
476
480
|
if (seen.has(logical)) continue;
|
|
477
481
|
seen.add(logical);
|
|
478
482
|
calendars.push({ calendarId: logical, displayName: entry.displayName ?? logical });
|
|
@@ -509,7 +513,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
|
|
|
509
513
|
single = await request(resourceUrl, 'GET', undefined, { Depth: '0' });
|
|
510
514
|
} catch (error) {
|
|
511
515
|
// A 404 maps to "not found"; any other failure propagates.
|
|
512
|
-
if (error instanceof
|
|
516
|
+
if (error instanceof HandlerError && error.code === 'CALENDAR_NOT_FOUND') {
|
|
513
517
|
return null;
|
|
514
518
|
}
|
|
515
519
|
throw error;
|
|
@@ -517,7 +521,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
|
|
|
517
521
|
const parsed = parseICS(single.text);
|
|
518
522
|
if (parsed.length === 0) return null;
|
|
519
523
|
const relHref = toRelativeHref(eventId);
|
|
520
|
-
return { ...parsed[0]
|
|
524
|
+
return { ...parsed[0]!, href: relHref, calendarId: id };
|
|
521
525
|
}
|
|
522
526
|
|
|
523
527
|
// Strategy 2 — bare UID: ask the server for ONLY the matching resource via
|
|
@@ -566,7 +570,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
|
|
|
566
570
|
const id = calendarId && calendarId.length > 0 ? calendarId : config.defaultCalendarId;
|
|
567
571
|
const parsed = parseICS(icsContent);
|
|
568
572
|
if (parsed.length === 0) {
|
|
569
|
-
throw new
|
|
573
|
+
throw new HandlerError('No VEVENT components found in .ics content.', 'CALENDAR_EMPTY_ICS', 400);
|
|
570
574
|
}
|
|
571
575
|
const collectionUrl = collectionUrlFor(id);
|
|
572
576
|
const eventIds: string[] = [];
|
|
@@ -582,7 +586,7 @@ export function createCalDavClient(options: CreateCalDavClientOptions): CalDavCl
|
|
|
582
586
|
eventIds.push(toRelativeHref(joinUrl(collectionPathOrRoot(collectionUrl, config.baseUrl), resourcePath)));
|
|
583
587
|
imported += 1;
|
|
584
588
|
} catch (error) {
|
|
585
|
-
const message = error instanceof
|
|
589
|
+
const message = error instanceof HandlerError ? error.message : error instanceof Error ? error.message : String(error);
|
|
586
590
|
errors.push(`${uid}: ${message}`);
|
|
587
591
|
}
|
|
588
592
|
}
|
|
@@ -646,7 +650,7 @@ function parsedToGenerateInput(event: ParsedICalEvent, uid: string): GenerateICa
|
|
|
646
650
|
description: event.description,
|
|
647
651
|
location: event.location,
|
|
648
652
|
// Re-emit attendees by their raw value so import preserves the original
|
|
649
|
-
// addressing; display-name-only redaction applies to
|
|
653
|
+
// addressing; display-name-only redaction applies to handler responses,
|
|
650
654
|
// not to server-side payloads.
|
|
651
655
|
attendees: event.attendees.map((a) => a.rawValue),
|
|
652
656
|
organizer: event.organizerRaw,
|
|
@@ -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
|
+
}
|