@vellumai/assistant 0.4.37 → 0.4.41

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.
Files changed (169) hide show
  1. package/ARCHITECTURE.md +3 -3
  2. package/README.md +13 -13
  3. package/bun.lock +80 -24
  4. package/docs/architecture/integrations.md +126 -128
  5. package/docs/runbook-trusted-contacts.md +1 -1
  6. package/docs/trusted-contact-access.md +12 -12
  7. package/package.json +3 -1
  8. package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +0 -14
  9. package/src/__tests__/app-bundler.test.ts +209 -0
  10. package/src/__tests__/app-compiler.test.ts +279 -0
  11. package/src/__tests__/app-executors.test.ts +293 -483
  12. package/src/__tests__/app-migration.test.ts +148 -0
  13. package/src/__tests__/app-routes-csp.test.ts +202 -0
  14. package/src/__tests__/avatar-e2e.test.ts +452 -0
  15. package/src/__tests__/avatar-generator.test.ts +193 -0
  16. package/src/__tests__/avatar-router.test.ts +186 -0
  17. package/src/__tests__/browser-download-timeout.test.ts +28 -0
  18. package/src/__tests__/bundled-skill-retrieval-guard.test.ts +9 -9
  19. package/src/__tests__/call-domain.test.ts +3 -7
  20. package/src/__tests__/credential-security-e2e.test.ts +19 -12
  21. package/src/__tests__/credentials-cli.test.ts +30 -4
  22. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +1 -1
  23. package/src/__tests__/handlers-slack-config.test.ts +0 -72
  24. package/src/__tests__/handlers-telegram-config.test.ts +19 -12
  25. package/src/__tests__/handlers-twitter-config.test.ts +105 -48
  26. package/src/__tests__/inbound-invite-redemption.test.ts +4 -4
  27. package/src/__tests__/integration-status.test.ts +15 -5
  28. package/src/__tests__/integrations-cli.test.ts +1 -1
  29. package/src/__tests__/invite-redemption-service.test.ts +62 -7
  30. package/src/__tests__/ipc-snapshot.test.ts +0 -8
  31. package/src/__tests__/managed-avatar-client.test.ts +280 -0
  32. package/src/__tests__/mcp-cli.test.ts +3 -3
  33. package/src/__tests__/oauth-cli.test.ts +203 -0
  34. package/src/__tests__/relay-server.test.ts +3 -3
  35. package/src/__tests__/secret-onetime-send.test.ts +19 -12
  36. package/src/__tests__/secure-keys.test.ts +78 -0
  37. package/src/__tests__/session-messaging-secret-redirect.test.ts +3 -0
  38. package/src/__tests__/slack-channel-config.test.ts +23 -16
  39. package/src/__tests__/slack-share-routes.test.ts +263 -0
  40. package/src/__tests__/sms-messaging-provider.test.ts +3 -1
  41. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +7 -7
  42. package/src/__tests__/trusted-contact-multichannel.test.ts +3 -3
  43. package/src/__tests__/trusted-contact-verification.test.ts +10 -10
  44. package/src/__tests__/twilio-config.test.ts +15 -36
  45. package/src/__tests__/twilio-provider.test.ts +4 -0
  46. package/src/__tests__/twitter-auth-handler.test.ts +27 -14
  47. package/src/__tests__/twitter-cli-error-shaping.test.ts +1 -1
  48. package/src/__tests__/twitter-cli-routing.test.ts +38 -53
  49. package/src/__tests__/twitter-oauth-client.test.ts +18 -47
  50. package/src/__tests__/voice-invite-redemption.test.ts +27 -3
  51. package/src/amazon/cart.ts +1 -1
  52. package/src/amazon/client.ts +89 -7
  53. package/src/approvals/guardian-request-resolvers.ts +2 -2
  54. package/src/bundler/app-bundler.ts +77 -32
  55. package/src/bundler/app-compiler.ts +195 -0
  56. package/src/bundler/manifest.ts +1 -1
  57. package/src/bundler/package-resolver.ts +185 -0
  58. package/src/calls/call-domain.ts +4 -14
  59. package/src/calls/relay-server.ts +2 -2
  60. package/src/calls/twilio-config.ts +5 -24
  61. package/src/calls/twilio-rest.ts +19 -5
  62. package/src/cli/amazon.ts +74 -249
  63. package/src/cli/audit.ts +2 -2
  64. package/src/cli/autonomy.ts +9 -9
  65. package/src/cli/channels.ts +5 -5
  66. package/src/cli/completions.ts +27 -27
  67. package/src/cli/config.ts +14 -14
  68. package/src/cli/contacts.ts +27 -27
  69. package/src/cli/credentials.ts +28 -28
  70. package/src/cli/dev.ts +2 -2
  71. package/src/cli/doctor.ts +2 -2
  72. package/src/cli/email.ts +82 -82
  73. package/src/cli/influencer.ts +13 -13
  74. package/src/cli/integrations.ts +19 -144
  75. package/src/cli/keys.ts +10 -10
  76. package/src/cli/map.ts +4 -4
  77. package/src/cli/mcp.ts +17 -17
  78. package/src/cli/memory.ts +18 -18
  79. package/src/cli/notifications.ts +13 -13
  80. package/src/cli/oauth.ts +77 -0
  81. package/src/cli/program.ts +2 -0
  82. package/src/cli/sequence.ts +27 -27
  83. package/src/cli/sessions.ts +12 -12
  84. package/src/cli/trust.ts +8 -8
  85. package/src/cli/twitter.ts +124 -70
  86. package/src/config/bundled-skills/_shared/CLI_RETRIEVAL_PATTERN.md +1 -1
  87. package/src/config/bundled-skills/agentmail/SKILL.md +34 -34
  88. package/src/config/bundled-skills/amazon/SKILL.md +54 -54
  89. package/src/config/bundled-skills/app-builder/SKILL.md +137 -3
  90. package/src/config/bundled-skills/app-builder/tools/app-create.ts +10 -4
  91. package/src/config/bundled-skills/configure-settings/SKILL.md +18 -18
  92. package/src/config/bundled-skills/contacts/SKILL.md +12 -12
  93. package/src/config/bundled-skills/doordash/lib/client.ts +7 -9
  94. package/src/config/bundled-skills/email-setup/SKILL.md +4 -4
  95. package/src/config/bundled-skills/frontend-design/icon.svg +16 -0
  96. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +143 -162
  97. package/src/config/bundled-skills/guardian-verify-setup/SKILL.md +4 -4
  98. package/src/config/bundled-skills/influencer/SKILL.md +13 -13
  99. package/src/config/bundled-skills/mcp-setup/SKILL.md +11 -11
  100. package/src/config/bundled-skills/phone-calls/SKILL.md +48 -54
  101. package/src/config/bundled-skills/public-ingress/SKILL.md +6 -6
  102. package/src/config/bundled-skills/slack-app-setup/SKILL.md +1 -1
  103. package/src/config/bundled-skills/sms-setup/SKILL.md +3 -3
  104. package/src/config/bundled-skills/telegram-setup/SKILL.md +2 -2
  105. package/src/config/bundled-skills/twilio-setup/SKILL.md +136 -225
  106. package/src/config/bundled-skills/twitter/SKILL.md +68 -44
  107. package/src/config/bundled-skills/voice-setup/SKILL.md +2 -2
  108. package/src/config/core-schema.ts +26 -0
  109. package/src/config/env.ts +4 -0
  110. package/src/config/feature-flag-registry.json +9 -1
  111. package/src/config/schema.ts +8 -0
  112. package/src/config/system-prompt.ts +6 -3
  113. package/src/config/templates/BOOTSTRAP.md +7 -5
  114. package/src/contacts/contacts-write.ts +5 -1
  115. package/src/daemon/handlers/apps.ts +31 -4
  116. package/src/daemon/handlers/config-ingress.ts +3 -3
  117. package/src/daemon/handlers/config-integrations.ts +120 -49
  118. package/src/daemon/handlers/config-slack-channel.ts +26 -7
  119. package/src/daemon/handlers/config-slack.ts +1 -54
  120. package/src/daemon/handlers/config-telegram.ts +28 -10
  121. package/src/daemon/handlers/config.ts +1 -4
  122. package/src/daemon/handlers/twitter-auth.ts +11 -4
  123. package/src/daemon/ipc-contract/apps.ts +0 -13
  124. package/src/daemon/ipc-contract-inventory.json +0 -2
  125. package/src/daemon/lifecycle.ts +8 -1
  126. package/src/daemon/session-messaging.ts +2 -2
  127. package/src/daemon/tool-side-effects.ts +30 -0
  128. package/src/email/providers/agentmail.ts +1 -1
  129. package/src/email/providers/index.ts +1 -1
  130. package/src/email/service.ts +1 -1
  131. package/src/gallery/default-gallery.ts +538 -0
  132. package/src/gallery/gallery-manifest.ts +5 -1
  133. package/src/influencer/client.ts +8 -6
  134. package/src/mcp/client.ts +1 -1
  135. package/src/media/avatar-router.ts +99 -0
  136. package/src/media/avatar-types.ts +60 -0
  137. package/src/media/managed-avatar-client.ts +189 -0
  138. package/src/memory/app-migration.ts +114 -0
  139. package/src/memory/app-store.ts +11 -0
  140. package/src/memory/qdrant-client.ts +1 -1
  141. package/src/messaging/providers/slack/client.ts +12 -2
  142. package/src/messaging/providers/sms/adapter.ts +6 -10
  143. package/src/migrations/data-layout.ts +8 -1
  144. package/src/oauth/token-persistence.ts +9 -6
  145. package/src/runtime/assistant-scope.ts +5 -0
  146. package/src/runtime/auth/route-policy.ts +4 -0
  147. package/src/runtime/channel-readiness-service.ts +9 -4
  148. package/src/runtime/gateway-internal-client.ts +11 -3
  149. package/src/runtime/http-server.ts +2 -0
  150. package/src/runtime/invite-redemption-service.ts +23 -13
  151. package/src/runtime/middleware/twilio-validation.ts +2 -2
  152. package/src/runtime/routes/app-routes.ts +131 -3
  153. package/src/runtime/routes/inbound-stages/verification-intercept.ts +3 -3
  154. package/src/runtime/routes/integration-routes.ts +2 -2
  155. package/src/runtime/routes/slack-share-routes.ts +235 -0
  156. package/src/runtime/routes/twilio-routes.ts +47 -34
  157. package/src/schedule/integration-status.ts +2 -3
  158. package/src/security/token-manager.ts +11 -3
  159. package/src/tools/apps/executors.ts +116 -8
  160. package/src/tools/browser/browser-manager.ts +30 -2
  161. package/src/tools/browser/chrome-cdp.ts +31 -3
  162. package/src/tools/credentials/vault.ts +9 -7
  163. package/src/tools/executor.ts +4 -0
  164. package/src/tools/system/avatar-generator.ts +55 -34
  165. package/src/twitter/client.ts +1 -1
  166. package/src/twitter/oauth-client.ts +31 -43
  167. package/src/twitter/router.ts +25 -23
  168. package/src/util/platform.ts +5 -0
  169. package/src/slack/slack-webhook.ts +0 -66
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  getPhoneNumberSid,
3
3
  getTollFreeVerificationStatus,
4
+ getTwilioCredentials,
4
5
  hasTwilioCredentials,
5
6
  } from "../calls/twilio-rest.js";
6
7
  import { getChannelInvitePolicy } from "../channels/config.js";
@@ -99,9 +100,13 @@ const smsProbe: ChannelProbe = {
99
100
  async runRemoteChecks(): Promise<ReadinessCheckResult[]> {
100
101
  if (!hasTwilioCredentials()) return [];
101
102
 
102
- const accountSid = getSecureKey("credential:twilio:account_sid");
103
- const authToken = getSecureKey("credential:twilio:auth_token");
104
- if (!accountSid || !authToken) return [];
103
+ let accountSid: string;
104
+ let authToken: string;
105
+ try {
106
+ ({ accountSid, authToken } = getTwilioCredentials());
107
+ } catch {
108
+ return [];
109
+ }
105
110
 
106
111
  const phoneNumber = resolveSmsPhoneNumber();
107
112
  if (!phoneNumber) return [];
@@ -308,7 +313,7 @@ const emailProbe: ChannelProbe = {
308
313
  passed: hasInbox,
309
314
  message: hasInbox
310
315
  ? `Inbox address is configured (${address})`
311
- : "No inbox address configured — create one with: vellum email setup inboxes",
316
+ : "No inbox address configured — create one with: assistant email setup inboxes",
312
317
  },
313
318
  ];
314
319
  } catch (err) {
@@ -30,10 +30,18 @@ async function parseErrorResponse(
30
30
  let message = `Gateway request failed (${resp.status})`;
31
31
 
32
32
  try {
33
- const parsed = JSON.parse(body) as { error?: string };
33
+ const parsed = JSON.parse(body) as {
34
+ error?: string | { code?: string; message?: string };
35
+ };
34
36
  if (parsed.error) {
35
- gatewayError = parsed.error;
36
- message = parsed.error;
37
+ // Runtime httpError() returns { error: { code, message } } while
38
+ // gateway returns { error: "string" }. Handle both formats.
39
+ const errStr =
40
+ typeof parsed.error === "string"
41
+ ? parsed.error
42
+ : (parsed.error.message ?? JSON.stringify(parsed.error));
43
+ gatewayError = errStr;
44
+ message = errStr;
37
45
  }
38
46
  } catch {
39
47
  if (body) message = body;
@@ -126,6 +126,7 @@ import {
126
126
  pairingRouteDefinitions,
127
127
  } from "./routes/pairing-routes.js";
128
128
  import { secretRouteDefinitions } from "./routes/secret-routes.js";
129
+ import { slackShareRouteDefinitions } from "./routes/slack-share-routes.js";
129
130
  import { surfaceActionRouteDefinitions } from "./routes/surface-action-routes.js";
130
131
  import { surfaceContentRouteDefinitions } from "./routes/surface-content-routes.js";
131
132
  import { trustRulesRouteDefinitions } from "./routes/trust-rules-routes.js";
@@ -851,6 +852,7 @@ export class RuntimeHttpServer {
851
852
  ...contactCatchAllRouteDefinitions(),
852
853
 
853
854
  ...integrationRouteDefinitions(),
855
+ ...slackShareRouteDefinitions(),
854
856
  ...twilioRouteDefinitions(),
855
857
  ...channelReadinessRouteDefinitions(),
856
858
  ...attachmentRouteDefinitions(),
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { ChannelId } from "../channels/types.js";
11
11
  import { findContactChannel } from "../contacts/contact-store.js";
12
- import { upsertMember } from "../contacts/contacts-write.js";
12
+ import { upsertContactChannel } from "../contacts/contacts-write.js";
13
13
  import { getSqlite } from "../memory/db.js";
14
14
  import {
15
15
  findActiveVoiceInvites,
@@ -147,9 +147,9 @@ export function redeemInvite(params: {
147
147
  }
148
148
 
149
149
  // Inactive member reactivation: when the user already has a member record
150
- // in a non-active state (revoked/pending), reactivate it via upsertMember
150
+ // in a non-active state (revoked/pending), reactivate it via upsertContactChannel
151
151
  // and consume an invite use atomically. The fresh-member path below also
152
- // uses upsertMember to keep contacts in sync.
152
+ // uses upsertContactChannel to keep contacts in sync.
153
153
  if (existingChannel) {
154
154
  // Sentinel error used to trigger a transaction rollback when the invite
155
155
  // was concurrently revoked/expired between pre-validation and write time.
@@ -173,11 +173,11 @@ export function redeemInvite(params: {
173
173
  ? existingContact.displayName
174
174
  : displayName;
175
175
 
176
- let reactivated: ReturnType<typeof upsertMember> | undefined;
176
+ let reactivated: ReturnType<typeof upsertContactChannel> | undefined;
177
177
  try {
178
178
  getSqlite()
179
179
  .transaction(() => {
180
- reactivated = upsertMember({
180
+ reactivated = upsertContactChannel({
181
181
  sourceChannel,
182
182
  externalUserId,
183
183
  externalChatId,
@@ -187,6 +187,8 @@ export function redeemInvite(params: {
187
187
  status: "active",
188
188
  policy: "allow",
189
189
  inviteId: invite.id,
190
+ verifiedAt: Date.now(),
191
+ verifiedVia: "invite",
190
192
  });
191
193
 
192
194
  const recorded = recordInviteUse({
@@ -219,11 +221,11 @@ export function redeemInvite(params: {
219
221
  // Fresh member creation: upsert into contacts tables and consume an invite
220
222
  // use atomically, mirroring the reactivation path above.
221
223
  const STALE_INVITE_FRESH = Symbol("stale_invite_fresh");
222
- let freshResult: ReturnType<typeof upsertMember> | undefined;
224
+ let freshResult: ReturnType<typeof upsertContactChannel> | undefined;
223
225
  try {
224
226
  getSqlite()
225
227
  .transaction(() => {
226
- freshResult = upsertMember({
228
+ freshResult = upsertContactChannel({
227
229
  sourceChannel,
228
230
  externalUserId,
229
231
  externalChatId,
@@ -232,6 +234,8 @@ export function redeemInvite(params: {
232
234
  status: "active",
233
235
  policy: "allow",
234
236
  inviteId: invite.id,
237
+ verifiedAt: Date.now(),
238
+ verifiedVia: "invite",
235
239
  });
236
240
 
237
241
  const recorded = recordInviteUse({
@@ -362,7 +366,7 @@ export function redeemVoiceInviteCode(params: {
362
366
  try {
363
367
  getSqlite()
364
368
  .transaction(() => {
365
- const writeResult = upsertMember({
369
+ const writeResult = upsertContactChannel({
366
370
  sourceChannel: "voice",
367
371
  externalUserId: callerExternalUserId,
368
372
  externalChatId: callerExternalUserId,
@@ -370,6 +374,8 @@ export function redeemVoiceInviteCode(params: {
370
374
  status: "active",
371
375
  policy: "allow",
372
376
  inviteId: invite.id,
377
+ verifiedAt: Date.now(),
378
+ verifiedVia: "invite",
373
379
  });
374
380
  memberId = writeResult!.channel.id;
375
381
 
@@ -481,7 +487,7 @@ export function redeemInviteByCode(params: {
481
487
  return { ok: false, reason: "invalid_token" };
482
488
  }
483
489
 
484
- // Inactive member reactivation: reactivate via upsertMember and consume
490
+ // Inactive member reactivation: reactivate via upsertContactChannel and consume
485
491
  // an invite use atomically.
486
492
  if (existingChannel) {
487
493
  const STALE_INVITE_REACTIVATE = Symbol("stale_invite_reactivate");
@@ -504,11 +510,11 @@ export function redeemInviteByCode(params: {
504
510
  ? existingContact.displayName
505
511
  : displayName;
506
512
 
507
- let reactivated: ReturnType<typeof upsertMember> | undefined;
513
+ let reactivated: ReturnType<typeof upsertContactChannel> | undefined;
508
514
  try {
509
515
  getSqlite()
510
516
  .transaction(() => {
511
- reactivated = upsertMember({
517
+ reactivated = upsertContactChannel({
512
518
  sourceChannel,
513
519
  externalUserId,
514
520
  externalChatId,
@@ -517,6 +523,8 @@ export function redeemInviteByCode(params: {
517
523
  status: "active",
518
524
  policy: "allow",
519
525
  inviteId: invite.id,
526
+ verifiedAt: Date.now(),
527
+ verifiedVia: "invite",
520
528
  });
521
529
 
522
530
  const recorded = recordInviteUse({
@@ -546,11 +554,11 @@ export function redeemInviteByCode(params: {
546
554
  // Fresh member creation: upsert into contacts tables and consume an invite
547
555
  // use atomically.
548
556
  const STALE_INVITE_FRESH = Symbol("stale_invite_fresh");
549
- let freshResult: ReturnType<typeof upsertMember> | undefined;
557
+ let freshResult: ReturnType<typeof upsertContactChannel> | undefined;
550
558
  try {
551
559
  getSqlite()
552
560
  .transaction(() => {
553
- freshResult = upsertMember({
561
+ freshResult = upsertContactChannel({
554
562
  sourceChannel,
555
563
  externalUserId,
556
564
  externalChatId,
@@ -559,6 +567,8 @@ export function redeemInviteByCode(params: {
559
567
  status: "active",
560
568
  policy: "allow",
561
569
  inviteId: invite.id,
570
+ verifiedAt: Date.now(),
571
+ verifiedVia: "invite",
562
572
  });
563
573
 
564
574
  const recorded = recordInviteUse({
@@ -71,10 +71,10 @@ export async function validateTwilioWebhook(
71
71
 
72
72
  const authToken = TwilioConversationRelayProvider.getAuthToken();
73
73
 
74
- // Fail-closed: reject if no auth token is configured
75
74
  if (!authToken) {
76
75
  log.error(
77
- "Twilio auth token not configuredrejecting webhook request (fail-closed)",
76
+ "Twilio auth token not found in secure key store cannot verify webhook HMAC signature. " +
77
+ "Rejecting request. Set credential:twilio:auth_token via the credential_store tool.",
78
78
  );
79
79
  return httpError("FORBIDDEN", "Forbidden", 403);
80
80
  }
@@ -2,13 +2,13 @@
2
2
  * Route handlers for shareable app pages and cloud sharing.
3
3
  */
4
4
  import { randomBytes } from "node:crypto";
5
- import { readFileSync } from "node:fs";
6
- import { join } from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { extname, join } from "node:path";
7
7
 
8
8
  import JSZip from "jszip";
9
9
 
10
10
  import type { AppManifest } from "../../bundler/manifest.js";
11
- import { getApp } from "../../memory/app-store.js";
11
+ import { getApp, getAppsDir, isMultifileApp } from "../../memory/app-store.js";
12
12
  import * as sharedAppLinksStore from "../../memory/shared-app-links-store.js";
13
13
  import { getLogger } from "../../util/logger.js";
14
14
  import { httpError } from "../http-errors.js";
@@ -46,6 +46,11 @@ export function handleServePage(appId: string): Response {
46
46
  return httpError("NOT_FOUND", "App not found", 404);
47
47
  }
48
48
 
49
+ // Multifile apps serve the compiled dist/index.html directly.
50
+ if (isMultifileApp(app)) {
51
+ return serveMultifileApp(appId, app.name);
52
+ }
53
+
49
54
  const css = loadDesignSystemCss();
50
55
  const escapedName = app.name.replace(
51
56
  /[<>&"]/g,
@@ -99,6 +104,122 @@ ${noncedHtml}
99
104
  });
100
105
  }
101
106
 
107
+ /**
108
+ * Serve compiled output for multifile TSX apps.
109
+ * Falls back to a "not compiled yet" message if dist/index.html is missing.
110
+ */
111
+ function serveMultifileApp(appId: string, appName: string): Response {
112
+ const distDir = join(getAppsDir(), appId, "dist");
113
+ const indexPath = join(distDir, "index.html");
114
+
115
+ if (!existsSync(indexPath)) {
116
+ const escapedName = appName.replace(
117
+ /[<>&"]/g,
118
+ (c) => HTML_ESCAPE_MAP[c] ?? c,
119
+ );
120
+ return new Response(
121
+ `<!DOCTYPE html><html><head><title>${escapedName}</title></head>` +
122
+ `<body><p>App has not been compiled yet. Edit a source file to trigger a build.</p></body></html>`,
123
+ {
124
+ headers: { "Content-Type": "text/html; charset=utf-8" },
125
+ },
126
+ );
127
+ }
128
+
129
+ // Rewrite relative asset paths to absolute HTTP routes so browsers and
130
+ // HTTP-based consumers (e.g. /pages/:appId) can resolve them. The macOS
131
+ // WebView uses the vellumapp:// scheme handler which resolves on disk,
132
+ // but HTTP clients need the /v1/apps/:appId/dist/ route.
133
+ let html = readFileSync(indexPath, "utf-8");
134
+ html = html.replace(
135
+ /(?:src|href)="(\.?\/?main\.(js|css))"/g,
136
+ (_match, _filename, ext) => {
137
+ const attr = ext === "css" ? "href" : "src";
138
+ return `${attr}="/v1/apps/${appId}/dist/main.${ext}"`;
139
+ },
140
+ );
141
+
142
+ // Compiled apps use external scripts so 'unsafe-inline' is not needed for
143
+ // script-src; however we keep it for style-src since the app HTML may use
144
+ // inline styles.
145
+ const csp = [
146
+ "default-src 'self'",
147
+ "style-src 'self' 'unsafe-inline'",
148
+ "script-src 'self'",
149
+ "img-src 'self' data: https:",
150
+ "font-src 'self' data: https:",
151
+ "object-src 'none'",
152
+ "base-uri 'self'",
153
+ "form-action 'self'",
154
+ "frame-ancestors 'self'",
155
+ ].join("; ");
156
+
157
+ return new Response(html, {
158
+ headers: {
159
+ "Content-Type": "text/html; charset=utf-8",
160
+ "Content-Security-Policy": csp,
161
+ },
162
+ });
163
+ }
164
+
165
+ /** Content-Type map for static dist/ assets. */
166
+ const DIST_CONTENT_TYPES: Record<string, string> = {
167
+ ".js": "application/javascript",
168
+ ".css": "text/css",
169
+ ".html": "text/html",
170
+ ".json": "application/json",
171
+ ".svg": "image/svg+xml",
172
+ ".png": "image/png",
173
+ ".jpg": "image/jpeg",
174
+ ".jpeg": "image/jpeg",
175
+ ".woff2": "font/woff2",
176
+ ".woff": "font/woff",
177
+ };
178
+
179
+ /**
180
+ * Serve a static file from an app's dist/ directory.
181
+ * Validates the filename to prevent path traversal.
182
+ */
183
+ export function handleServeDistFile(appId: string, filename: string): Response {
184
+ // Reject any traversal attempts on appId
185
+ if (
186
+ !appId ||
187
+ appId.includes("..") ||
188
+ appId.includes("/") ||
189
+ appId.includes("\\") ||
190
+ appId !== appId.trim()
191
+ ) {
192
+ return httpError("BAD_REQUEST", "Invalid appId", 400);
193
+ }
194
+
195
+ // Reject any traversal attempts on filename
196
+ if (
197
+ !filename ||
198
+ filename.includes("..") ||
199
+ filename.includes("/") ||
200
+ filename.includes("\\") ||
201
+ filename !== filename.trim()
202
+ ) {
203
+ return httpError("BAD_REQUEST", "Invalid filename", 400);
204
+ }
205
+
206
+ const filePath = join(getAppsDir(), appId, "dist", filename);
207
+ if (!existsSync(filePath)) {
208
+ return httpError("NOT_FOUND", "File not found", 404);
209
+ }
210
+
211
+ const ext = extname(filename).toLowerCase();
212
+ const contentType = DIST_CONTENT_TYPES[ext] ?? "application/octet-stream";
213
+ const content = readFileSync(filePath);
214
+
215
+ return new Response(content, {
216
+ headers: {
217
+ "Content-Type": contentType,
218
+ "Cache-Control": "no-cache",
219
+ },
220
+ });
221
+ }
222
+
102
223
  /** 50 MB — generous cap for zip app bundles. */
103
224
  const MAX_SHARE_BODY_BYTES = 50 * 1024 * 1024;
104
225
 
@@ -207,6 +328,13 @@ export function handleDeleteSharedApp(shareToken: string): Response {
207
328
 
208
329
  export function appRouteDefinitions(): RouteDefinition[] {
209
330
  return [
331
+ {
332
+ endpoint: "apps/:appId/dist/:filename",
333
+ method: "GET",
334
+ policyKey: "apps/dist",
335
+ handler: ({ params }) =>
336
+ handleServeDistFile(params.appId, params.filename),
337
+ },
210
338
  {
211
339
  endpoint: "apps/share",
212
340
  method: "POST",
@@ -18,7 +18,7 @@ import { findContactChannel } from "../../../contacts/contact-store.js";
18
18
  import {
19
19
  createGuardianBinding,
20
20
  revokeGuardianBinding,
21
- upsertMember,
21
+ upsertContactChannel,
22
22
  } from "../../../contacts/contacts-write.js";
23
23
  import * as channelDeliveryStore from "../../../memory/channel-delivery-store.js";
24
24
  import { emitNotificationSignal } from "../../../notifications/emit-signal.js";
@@ -143,7 +143,7 @@ export async function handleVerificationIntercept(
143
143
  ? existingContact.displayName
144
144
  : actorDisplayName;
145
145
 
146
- upsertMember({
146
+ upsertContactChannel({
147
147
  sourceChannel,
148
148
  externalUserId: canonicalSenderId ?? rawSenderId,
149
149
  externalChatId: conversationExternalId,
@@ -169,7 +169,7 @@ export async function handleVerificationIntercept(
169
169
  (canonicalSenderId ?? rawSenderId)
170
170
  ) {
171
171
  // Edge case: another user already bound. Log and skip binding creation.
172
- // The upsertMember above already succeeded, so the sender is a known contact,
172
+ // The upsertContactChannel above already succeeded, so the sender is a known contact,
173
173
  // but they won't get guardian role.
174
174
  log.warn(
175
175
  {
@@ -139,8 +139,8 @@ export async function handleSetSlackChannelConfig(
139
139
  /**
140
140
  * DELETE /v1/integrations/slack/channel/config
141
141
  */
142
- export function handleClearSlackChannelConfig(): Response {
143
- const result = clearSlackChannelConfig();
142
+ export async function handleClearSlackChannelConfig(): Promise<Response> {
143
+ const result = await clearSlackChannelConfig();
144
144
  return Response.json(result);
145
145
  }
146
146
 
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Route handlers for Slack channel listing and direct sharing.
3
+ *
4
+ * These endpoints let the UI post app links directly to Slack channels
5
+ * without going through the legacy IPC-based Slack share flow.
6
+ */
7
+
8
+ import { getApp } from "../../memory/app-store.js";
9
+ import {
10
+ listConversations,
11
+ postMessage,
12
+ userInfo,
13
+ } from "../../messaging/providers/slack/client.js";
14
+ import type { SlackConversation } from "../../messaging/providers/slack/types.js";
15
+ import { getSecureKey } from "../../security/secure-keys.js";
16
+ import { getLogger } from "../../util/logger.js";
17
+ import { httpError } from "../http-errors.js";
18
+ import type { RouteDefinition } from "../http-router.js";
19
+
20
+ const log = getLogger("slack-share-routes");
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Shared helpers
24
+ // ---------------------------------------------------------------------------
25
+
26
+ /**
27
+ * Resolve the Slack bot token from secure storage.
28
+ * Prefers the OAuth integration token, falls back to the legacy channel token.
29
+ */
30
+ function resolveSlackToken(): string | undefined {
31
+ return (
32
+ getSecureKey("credential:integration:slack:access_token") ??
33
+ getSecureKey("credential:slack_channel:bot_token")
34
+ );
35
+ }
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // GET /v1/slack/channels
39
+ // ---------------------------------------------------------------------------
40
+
41
+ interface NormalizedChannel {
42
+ id: string;
43
+ name: string;
44
+ type: "channel" | "group" | "dm";
45
+ isPrivate: boolean;
46
+ }
47
+
48
+ function classifyConversation(
49
+ conv: SlackConversation,
50
+ ): "channel" | "group" | "dm" {
51
+ if (conv.is_im) return "dm";
52
+ if (conv.is_mpim) return "group";
53
+ if (conv.is_group) return "group";
54
+ return "channel";
55
+ }
56
+
57
+ const TYPE_SORT_ORDER: Record<string, number> = {
58
+ channel: 0,
59
+ group: 1,
60
+ dm: 2,
61
+ };
62
+
63
+ export async function handleListSlackChannels(): Promise<Response> {
64
+ const token = resolveSlackToken();
65
+ if (!token) {
66
+ return httpError("SERVICE_UNAVAILABLE", "No Slack token configured", 503);
67
+ }
68
+
69
+ // Paginate through all results (follows the pattern in adapter.ts)
70
+ const allChannels: SlackConversation[] = [];
71
+ let cursor: string | undefined;
72
+ do {
73
+ const resp = await listConversations(
74
+ token,
75
+ "public_channel,private_channel,mpim,im",
76
+ true,
77
+ 200,
78
+ cursor,
79
+ );
80
+ allChannels.push(...resp.channels);
81
+ cursor = resp.response_metadata?.next_cursor || undefined;
82
+ } while (cursor);
83
+
84
+ // Resolve DM display names in parallel, tolerating individual failures.
85
+ const dmUserIds = allChannels
86
+ .filter((c) => c.is_im && c.user)
87
+ .map((c) => c.user!);
88
+ const uniqueUserIds = [...new Set(dmUserIds)];
89
+ const nameResults = await Promise.allSettled(
90
+ uniqueUserIds.map((uid) =>
91
+ userInfo(token, uid).then((r) => ({
92
+ uid,
93
+ name:
94
+ r.user.profile?.display_name ||
95
+ r.user.profile?.real_name ||
96
+ r.user.real_name ||
97
+ r.user.name,
98
+ })),
99
+ ),
100
+ );
101
+ const nameMap = new Map<string, string>();
102
+ for (const r of nameResults) {
103
+ if (r.status === "fulfilled") {
104
+ nameMap.set(r.value.uid, r.value.name);
105
+ }
106
+ }
107
+
108
+ const channels: NormalizedChannel[] = allChannels.map((c) => {
109
+ const type = classifyConversation(c);
110
+ let name = c.name ?? c.id;
111
+ if (type === "dm" && c.user) {
112
+ name = nameMap.get(c.user) ?? c.user;
113
+ }
114
+ return {
115
+ id: c.id,
116
+ name,
117
+ type,
118
+ isPrivate: c.is_private ?? c.is_group ?? false,
119
+ };
120
+ });
121
+
122
+ // Sort: channels first, then groups, then DMs — alphabetical within each.
123
+ channels.sort((a, b) => {
124
+ const typeOrder =
125
+ (TYPE_SORT_ORDER[a.type] ?? 9) - (TYPE_SORT_ORDER[b.type] ?? 9);
126
+ if (typeOrder !== 0) return typeOrder;
127
+ return a.name.localeCompare(b.name);
128
+ });
129
+
130
+ return Response.json({ channels });
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // POST /v1/slack/share
135
+ // ---------------------------------------------------------------------------
136
+
137
+ export async function handleShareToSlackChannel(
138
+ req: Request,
139
+ ): Promise<Response> {
140
+ const token = resolveSlackToken();
141
+ if (!token) {
142
+ return httpError("SERVICE_UNAVAILABLE", "No Slack token configured", 503);
143
+ }
144
+
145
+ let body: Record<string, unknown>;
146
+ try {
147
+ body = (await req.json()) as Record<string, unknown>;
148
+ } catch {
149
+ return httpError("BAD_REQUEST", "Malformed JSON body", 400);
150
+ }
151
+
152
+ const appId = body.appId;
153
+ const channelId = body.channelId;
154
+ const message = body.message;
155
+
156
+ if (!appId || !channelId) {
157
+ return httpError(
158
+ "BAD_REQUEST",
159
+ "Missing required fields: appId, channelId",
160
+ 400,
161
+ );
162
+ }
163
+
164
+ if (typeof appId !== "string" || typeof channelId !== "string") {
165
+ return httpError(
166
+ "BAD_REQUEST",
167
+ "Fields appId and channelId must be strings",
168
+ 400,
169
+ );
170
+ }
171
+
172
+ if (message !== undefined && typeof message !== "string") {
173
+ return httpError("BAD_REQUEST", "Field message must be a string", 400);
174
+ }
175
+
176
+ const app = getApp(appId);
177
+ if (!app) {
178
+ return httpError("NOT_FOUND", "App not found", 404);
179
+ }
180
+
181
+ // Build a Block Kit message with a deterministic fallback text.
182
+ const fallbackText = message
183
+ ? `${message} — ${app.name}`
184
+ : `Shared app: ${app.name}`;
185
+
186
+ const blocks: unknown[] = [
187
+ {
188
+ type: "section",
189
+ text: {
190
+ type: "mrkdwn",
191
+ text: message ? `${message}\n\n*${app.name}*` : `*${app.name}*`,
192
+ },
193
+ },
194
+ ];
195
+
196
+ if (app.description) {
197
+ blocks.push({
198
+ type: "context",
199
+ elements: [{ type: "mrkdwn", text: app.description }],
200
+ });
201
+ }
202
+
203
+ try {
204
+ const result = await postMessage(token, channelId, fallbackText, {
205
+ blocks,
206
+ });
207
+ return Response.json({
208
+ ok: true,
209
+ ts: result.ts,
210
+ channel: result.channel,
211
+ });
212
+ } catch (err) {
213
+ log.error({ err, appId, channelId }, "Failed to share app to Slack");
214
+ return httpError("INTERNAL_ERROR", "Failed to post message to Slack", 500);
215
+ }
216
+ }
217
+
218
+ // ---------------------------------------------------------------------------
219
+ // Route definitions
220
+ // ---------------------------------------------------------------------------
221
+
222
+ export function slackShareRouteDefinitions(): RouteDefinition[] {
223
+ return [
224
+ {
225
+ endpoint: "slack/channels",
226
+ method: "GET",
227
+ handler: () => handleListSlackChannels(),
228
+ },
229
+ {
230
+ endpoint: "slack/share",
231
+ method: "POST",
232
+ handler: async ({ req }) => handleShareToSlackChannel(req),
233
+ },
234
+ ];
235
+ }