diff --git a/.env.example b/.env.example index a767904753..9dbd6f5ae9 100644 --- a/.env.example +++ b/.env.example @@ -259,19 +259,6 @@ ALLOW_PLAINTEXT_SECRETS=1 # schema only (`artifacts.*` FKs into public.tenant/principal). Nothing to # configure here; `bun run dev` mounts Library automatically. -# Slack tag ingress (@corbits/slack-tag, docs/slack.md) — talk to a -# workbench agent from Slack. Leave SLACK_BOT_TOKEN/SLACK_SIGNING_SECRET -# unset to run with no Slack app mounted (the default); set both from your -# Slack app's "OAuth & Permissions" and "Basic Information" pages to turn -# it on. SLACK_WORKBENCH_TENANT_SLUG and SLACK_DEFAULT_AGENT_DEFINITION_ID -# are then also required — see docs/slack.md — since there is no honest -# default for which bench a Slack workspace's messages land in or which -# deployed agent answers them. -# SLACK_BOT_TOKEN= -# SLACK_SIGNING_SECRET= -# SLACK_WORKBENCH_TENANT_SLUG= -# SLACK_DEFAULT_AGENT_DEFINITION_ID= - # Exclusive per-workbench sidecar placement (CL-6096, CL-6283, CL-6534). # Leave unset (the default) and the hub registers the "process" backend # alone: each exclusive sidecar runs as one apps/sidecar child process on diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 09d5203d5e..9dfc9cbeb6 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,8 +16,8 @@ for the concrete stack. Packages own product rules. Apps stay generic — they compose packages rather than own domain logic — with one tracked exception: `apps/hub` carries a small, explicitly-listed set of mounts (`artifacts-mount.ts`, -`memory-mount.ts`, `skills-mount.ts`, `slack-tag-mount.ts`, -`tenant-create-guard.ts`) pending extraction into packages (tracked as +`memory-mount.ts`, `skills-mount.ts`, `tenant-create-guard.ts`) pending +extraction into packages (tracked as CL-6127). Outside that list, a product rule that lives inside `apps/*` is a defect — it belongs in a package. diff --git a/apps/hub/README.md b/apps/hub/README.md index 2d34dad658..c8f1609552 100644 --- a/apps/hub/README.md +++ b/apps/hub/README.md @@ -14,7 +14,7 @@ native tenant middleware. app never re-implements them. - Every `@corbits/*` and `@workbench/*` package route (chat, approvals, bench, insights, access-policy, mailbox, memory, skills, artifacts, - routines, tasks, webhooks, Slack tag, and more) mounts here as one + routines, tasks, webhooks, and more) mounts here as one explicit import plus one `app.route(...)` call, inside or outside the tenant prefix as the extension requires. `src/index.ts` is the single file where all of this comes together. diff --git a/apps/hub/package.json b/apps/hub/package.json index f6d02cc57e..73d8ee9adf 100644 --- a/apps/hub/package.json +++ b/apps/hub/package.json @@ -54,7 +54,6 @@ "@corbits/run-key-history": "workspace:*", "@corbits/run-scope": "workspace:*", "@corbits/skills": "workspace:*", - "@corbits/slack-tag": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", "@corbits/turn-artifacts": "workspace:*", "@corbits/url-path": "workspace:*", diff --git a/apps/hub/src/index.ts b/apps/hub/src/index.ts index facaa01409..9f6ea38684 100644 --- a/apps/hub/src/index.ts +++ b/apps/hub/src/index.ts @@ -2555,9 +2555,8 @@ export async function createHub(config: HubConfig) { // querying `chat.workbench_launch` on a timer this function is // about to end — including, once `close()` below tears down the // db pool, querying a pool that's already shut down. In a test - // suite that boots many hubs back to back (e.g. - // slack-tag-mount.test.ts, CL-7453) those leaked timers pile up - // across the whole `bun test` process and contend with later + // suite that boots many hubs back to back (CL-7453) those leaked + // timers pile up across the whole `bun test` process and contend with later // tests' own boots for Postgres connections, which is what // surfaced as `chat·relaunch-sweep: relaunch sweep pass failed: // Failed query: select ... from chat.workbench_launch` and an diff --git a/apps/hub/test/relaunch-sweep-close.test.ts b/apps/hub/test/relaunch-sweep-close.test.ts index 8cfe3256b6..f3d956790e 100644 --- a/apps/hub/test/relaunch-sweep-close.test.ts +++ b/apps/hub/test/relaunch-sweep-close.test.ts @@ -8,8 +8,7 @@ // kept firing — and logging `relaunch sweep pass failed: ...` when it // queried a pool `close()` had already shut down — for the rest of the // `bun test` process. A suite that boots several hubs back to back -// (slack-tag-mount.test.ts's env-gate suite among them) accumulated -// these leaked loops, contending with later tests' own boots for +// accumulated these leaked loops, contending with later tests' own boots for // Postgres connections and surfacing as an intermittent test timeout. import { afterAll, expect, test } from "bun:test"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; diff --git a/apps/web/src/deployment-capabilities-api.test.ts b/apps/web/src/deployment-capabilities-api.test.ts deleted file mode 100644 index 0c4b3d3285..0000000000 --- a/apps/web/src/deployment-capabilities-api.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test"; - -import { - fetchDeploymentCapabilities, - slackTriggerOffered, -} from "./deployment-capabilities-api"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -function stubFetch( - respond: (path: string) => Response | Promise, -): void { - globalThis.fetch = ((input: RequestInfo | URL, _init?: RequestInit) => { - const path = - typeof input === "string" ? input : new URL(String(input)).pathname; - return Promise.resolve(respond(path)); - }) as typeof fetch; -} - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -describe("fetchDeploymentCapabilities", () => { - test("a genuine slackConfigured:false is ready, not unavailable", async () => { - stubFetch(() => json({ slackConfigured: false })); - const result = await fetchDeploymentCapabilities(); - expect(result).toEqual({ kind: "ready", slackConfigured: false }); - }); - - test("a configured deployment is ready with slackConfigured:true", async () => { - stubFetch(() => json({ slackConfigured: true })); - const result = await fetchDeploymentCapabilities(); - expect(result).toEqual({ kind: "ready", slackConfigured: true }); - }); - - test("a non-2xx response is unavailable, not slackConfigured:false", async () => { - stubFetch(() => json({ message: "boom" }, 500)); - const result = await fetchDeploymentCapabilities(); - expect(result.kind).toBe("unavailable"); - }); - - test("a body that fails the schema is unavailable", async () => { - stubFetch(() => json({ slackConfigured: "yes" })); - const result = await fetchDeploymentCapabilities(); - expect(result.kind).toBe("unavailable"); - }); - - test("a network failure is unavailable", async () => { - globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => - Promise.reject(new Error("network down"))) as typeof fetch; - const result = await fetchDeploymentCapabilities(); - expect(result).toEqual({ - kind: "unavailable", - message: "network down", - }); - }); -}); - -describe("slackTriggerOffered", () => { - test("hides Slack while the probe is still loading", () => { - expect(slackTriggerOffered(null)).toBe(false); - }); - - test("follows the hub when the probe answered", () => { - expect(slackTriggerOffered({ kind: "ready", slackConfigured: false })).toBe( - false, - ); - expect(slackTriggerOffered({ kind: "ready", slackConfigured: true })).toBe( - true, - ); - }); - - test("keeps Slack offered when the probe failed — never hides solely on failure", () => { - expect( - slackTriggerOffered({ - kind: "unavailable", - message: "The server answered 500 when checking Slack.", - }), - ).toBe(true); - }); -}); diff --git a/apps/web/src/deployment-capabilities-api.ts b/apps/web/src/deployment-capabilities-api.ts deleted file mode 100644 index a6ad1a2cf1..0000000000 --- a/apps/web/src/deployment-capabilities-api.ts +++ /dev/null @@ -1,79 +0,0 @@ -// Whether this deployment has certain optional integrations configured — -// today, only whether Slack tag ingress is mounted (see -// apps/hub/src/index.ts's `/api/deployment-capabilities`, computed from the -// same `SLACK_BOT_TOKEN`/`SLACK_SIGNING_SECRET` env gate -// `slack-tag-mount.ts` checks). No session or tenant is required: the -// routine trigger popover (see `shell/routine-panel.tsx`) reads this to -// decide whether a Slack-bound webhook trigger is honestly offerable — -// the same probe pattern the sign-in screen used to follow before its -// OAuth buttons moved to client config (`SOCIAL_SIGN_IN_PROVIDERS`). -// -// Distinguishes "the hub answered and Slack is not configured" (`ready` -// with `slackConfigured: false`) from a network failure, non-2xx, or -// unparseable body (`unavailable`). Collapsing those into -// `{ slackConfigured: false }` was CL-6835 — the trigger affordance -// disappeared with no error whenever the probe missed. -import { type } from "arktype"; -import { useQuery } from "@tanstack/react-query"; - -const DeploymentCapabilitiesBody = type({ - slackConfigured: "boolean", -}); -export type DeploymentCapabilities = typeof DeploymentCapabilitiesBody.infer; - -export type DeploymentCapabilitiesResult = - | { readonly kind: "ready"; readonly slackConfigured: boolean } - | { readonly kind: "unavailable"; readonly message: string }; - -export async function fetchDeploymentCapabilities(): Promise { - try { - const response = await fetch("/api/deployment-capabilities", { - headers: { accept: "application/json" }, - }); - if (!response.ok) { - return { - kind: "unavailable", - message: `The server answered ${response.status} when checking Slack.`, - }; - } - const body: unknown = await response.json(); - const parsed = DeploymentCapabilitiesBody(body); - if (parsed instanceof type.errors) { - return { - kind: "unavailable", - message: `Unexpected Slack capabilities shape: ${parsed.summary}`, - }; - } - return { kind: "ready", slackConfigured: parsed.slackConfigured }; - } catch (cause) { - return { - kind: "unavailable", - message: cause instanceof Error ? cause.message : String(cause), - }; - } -} - -/** - * Whether the routine trigger popover should offer Slack. Still-loading - * hides it (don't claim configured); a ready answer follows the hub; a - * probe failure keeps it offered — disappearing solely because the probe - * failed is the CL-6835 bug. - */ -export function slackTriggerOffered( - result: DeploymentCapabilitiesResult | null, -): boolean { - if (result === null) return false; - if (result.kind === "unavailable") return true; - return result.slackConfigured; -} - -/** `null` while the probe is in flight — consumers must not treat that as - * "Slack is not configured". */ -export function useDeploymentCapabilities(): DeploymentCapabilitiesResult | null { - const { data } = useQuery({ - queryKey: ["deployment-capabilities"], - queryFn: fetchDeploymentCapabilities, - staleTime: Infinity, - }); - return data ?? null; -} diff --git a/apps/web/src/granola-plugin-availability.ts b/apps/web/src/granola-plugin-availability.ts index be926fb7c3..1e0cc31e24 100644 --- a/apps/web/src/granola-plugin-availability.ts +++ b/apps/web/src/granola-plugin-availability.ts @@ -1,9 +1,8 @@ // Whether this tenant has Granola connected — the routine trigger popover // (see `shell/routine-panel.tsx`) reads this so an unconnected bench never // offers "Granola call notes" as if it were a working trigger (CL-6759). -// Same honesty rule as Slack via `deployment-capabilities-api.ts`, but -// tenant-scoped: Granola's credential resolves through the ancestor chain -// (`GET /credentials/resolve/Granola`), not a deployment env gate. +// Tenant-scoped: Granola's credential resolves through the ancestor chain +// (`GET /credentials/resolve/Granola`). import { type } from "arktype"; import { useQuery } from "@tanstack/react-query"; diff --git a/bun.lock b/bun.lock index 7c3ac61eff..f7ecaf9489 100644 --- a/bun.lock +++ b/bun.lock @@ -83,7 +83,6 @@ "@corbits/run-key-history": "workspace:*", "@corbits/run-scope": "workspace:*", "@corbits/skills": "workspace:*", - "@corbits/slack-tag": "workspace:*", "@corbits/tool-registry-publish": "workspace:*", "@corbits/turn-artifacts": "workspace:*", "@corbits/url-path": "workspace:*", @@ -1346,26 +1345,6 @@ "typescript": "catalog:", }, }, - "packages/slack-tag": { - "name": "@corbits/slack-tag", - "version": "0.0.1", - "dependencies": { - "@chat-adapter/state-memory": "^4.35.0", - "@corbits/chat": "workspace:*", - "@intx/db": "workspace:*", - "@intx/log": "0.3.0", - "arktype": "catalog:", - "chat": "^4.34.0", - "corbits-tag": "github:corbitsdev/corbits-tag#92eabf4f7bf0a9663799c07db6c3f72a4275f894", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:", - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:", - }, - }, "packages/slug": { "name": "@corbits/slug", "version": "0.0.1", @@ -2121,10 +2100,6 @@ "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], - "@chat-adapter/shared": ["@chat-adapter/shared@4.40.0", "", { "dependencies": { "chat": "4.40.0" } }, "sha512-uTDl9p+PVkGtAw9dgvRdkydifvYVp98R26vGHhU4V5HuS7DBTedTdb1COUOh6vfP0SrC+xSozp4O/IyfbXZSKw=="], - - "@chat-adapter/slack": ["@chat-adapter/slack@4.40.0", "", { "dependencies": { "@chat-adapter/shared": "4.40.0", "@slack/socket-mode": "^2.0.5", "@slack/web-api": "^7.18.0", "chat": "4.40.0" } }, "sha512-aEkJ8OdQPZpIICbxHO08TOPrFVnwvy9e1S1J+AIqB9S1Tf5lE5DtF9AZXWCglRVXFoNCeZ8QcdJYIglxgVIaTg=="], - "@chat-adapter/state-memory": ["@chat-adapter/state-memory@4.40.0", "", { "dependencies": { "chat": "4.40.0" } }, "sha512-NmFFkvUfnDBDS/mkzrtKHdW757EGt0eX3OQH5IPxrKqhjSsNzpqUdkJ/uDB4BP0Rbex4oW/HgwhZ+JGONVbMBQ=="], "@connectrpc/connect": ["@connectrpc/connect@2.2.0", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-M63OQ1ZFcPUm85CuewJ9G9CMV8LjzGR9935xOnXNQGSazAbwMb3x2W7vXBYM1zqrP7XFD8uxp/dUAhirclgwnQ=="], @@ -2301,8 +2276,6 @@ "@corbits/skills-tools": ["@corbits/skills-tools@workspace:packages/skills-tools"], - "@corbits/slack-tag": ["@corbits/slack-tag@workspace:packages/slack-tag"], - "@corbits/slug": ["@corbits/slug@workspace:packages/slug"], "@corbits/text-diff": ["@corbits/text-diff@workspace:packages/text-diff"], @@ -2625,14 +2598,6 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.63.3", "", { "os": "win32", "cpu": "x64" }, "sha512-+lIKX7O0+IGe7WuhATaAMMeT7B76vfhXH/l9wLQL+nvyhbw2ohYCKIdWL56JfDu75CWt5oKRP4QFH/jkMtBquA=="], - "@slack/logger": ["@slack/logger@4.0.1", "", { "dependencies": { "@types/node": ">=18" } }, "sha512-6cmdPrV/RYfd2U0mDGiMK8S7OJqpCTm7enMLRR3edccsPX8j7zXTLnaEF4fhxxJJTAIOil6+qZrnUPTuaLvwrQ=="], - - "@slack/socket-mode": ["@slack/socket-mode@2.0.7", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/web-api": "^7.15.0", "@types/node": ">=18", "@types/ws": "^8", "eventemitter3": "^5", "ws": "^8" } }, "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ=="], - - "@slack/types": ["@slack/types@2.22.0", "", {}, "sha512-sZ9lIgJhPX2qft/tKWiklFlc0o1FWeI7QtciZJfW1+ErH1eGGHvOZ8e73sleTCFEFJp1q/R0WeS8Oa7AsiDprg=="], - - "@slack/web-api": ["@slack/web-api@7.19.0", "", { "dependencies": { "@slack/logger": "^4.0.1", "@slack/types": "^2.21.0", "@types/node": ">=18", "@types/retry": "0.12.0", "axios": "^1.16.0", "eventemitter3": "^5.0.1", "form-data": "^4.0.4", "is-electron": "2.2.2", "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" } }, "sha512-ItjyjEZml+LDH8CjcCLRLJHh7VZtevPKExrRN3l5KWyBliyDnGAeoO4Y+K+fFBmRpKLYVPgqWMX4THldv2HVtA=="], - "@standard-community/standard-json": ["@standard-community/standard-json@0.3.5", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.16.8", "quansync": "^0.2.11", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-4+ZPorwDRt47i+O7RjyuaxHRK/37QY/LmgxlGrRrSTLYoFatEOzvqIc85GTlM18SFZ5E91C+v0o/M37wZPpUHA=="], "@standard-community/standard-openapi": ["@standard-community/standard-openapi@0.2.9", "", { "peerDependencies": { "@standard-community/standard-json": "^0.3.5", "@standard-schema/spec": "^1.0.0", "arktype": "^2.1.20", "effect": "^3.17.14", "openapi-types": "^12.1.3", "sury": "^10.0.0", "typebox": "^1.0.0", "valibot": "^1.1.0", "zod": "^3.25.0 || ^4.0.0", "zod-openapi": "^4" }, "optionalPeers": ["arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-openapi"] }, "sha512-htj+yldvN1XncyZi4rehbf9kLbu8os2Ke/rfqoZHCMHuw34kiF3LP/yQPdA0tQ940y8nDq3Iou8R3wG+AGGyvg=="], @@ -2701,8 +2666,6 @@ "@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="], - "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], - "@types/semver": ["@types/semver@7.8.0", "", {}, "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ=="], "@types/ssri": ["@types/ssri@7.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-odD/56S3B51liILSk5aXJlnYt99S6Rt9EFDDqGtJM26rKHApHcwyU/UoYHrzKkdkHMAIquGWCuHtQTbes+FRQw=="], @@ -2791,12 +2754,8 @@ "async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], - "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], - "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="], - "bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="], "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], @@ -2857,8 +2816,6 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], - "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "compare-versions": ["compare-versions@6.1.1", "", {}, "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg=="], "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], @@ -2871,8 +2828,6 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - "corbits-tag": ["corbits-tag@github:corbitsdev/corbits-tag#92eabf4", { "dependencies": { "@chat-adapter/slack": "^4.34.0", "@intx/db": "0.2.2", "arktype": "^2.2.3", "chat": "^4.34.0", "drizzle-orm": "0.45.2", "hono": "^4.12.31" } }, "corbitsdev-corbits-tag-92eabf4", "sha512-YdiBjGvGskmoKurpo76sz+0jMuieZ497/EbGEoxoPM8JAdIg3L/kVUl9V1dnBLqfVzRk1qSDZWpFevMOd3t0hQ=="], - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], @@ -2895,8 +2850,6 @@ "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], - "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], - "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], @@ -2939,8 +2892,6 @@ "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], - "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -2969,8 +2920,6 @@ "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], - "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], - "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], @@ -3005,12 +2954,8 @@ "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], - "follow-redirects": ["follow-redirects@1.16.0", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], - "for-each": ["for-each@0.3.5", "", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="], - "form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], @@ -3069,7 +3014,7 @@ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], - "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], @@ -3087,8 +3032,6 @@ "is-callable": ["is-callable@1.2.7", "", {}, "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="], - "is-electron": ["is-electron@2.2.2", "", {}, "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="], - "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], @@ -3097,8 +3040,6 @@ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], - "is-typed-array": ["is-typed-array@1.1.15", "", { "dependencies": { "which-typed-array": "^1.1.16" } }, "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ=="], "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], @@ -3331,20 +3272,12 @@ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], - "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], - "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], "p-map": ["p-map@7.0.8", "", {}, "sha512-MitaVsCuCFIvOLLPIU7NnfrZvS9H9h7kwMUkDo+T2pEISaJD48IV9S8iIdXB7PsvvdxyYcsSTTrr90XKsbulNw=="], - "p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="], - - "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], - - "p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], - "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -3383,8 +3316,6 @@ "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], - "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], - "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "puppeteer-core": ["puppeteer-core@25.11.0", "", { "dependencies": { "@puppeteer/browsers": "3.2.2", "chromium-bidi": "17.0.2", "devtools-protocol": "0.0.1680125", "typed-query-selector": "^2.12.2", "webdriver-bidi-protocol": "0.4.3", "ws": "^8.21.3" } }, "sha512-Ujym7JbxoAqanaxGcHgmTomfhC01HfDCSuGZ8Hs9boHrDjzbNVMnFRJo2KtbDT9LCRgeJoFnkn9JQZ41eEUrLw=="], @@ -3425,8 +3356,6 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], - "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], - "rollup": ["rollup@4.63.3", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.63.3", "@rollup/rollup-android-arm64": "4.63.3", "@rollup/rollup-darwin-arm64": "4.63.3", "@rollup/rollup-darwin-x64": "4.63.3", "@rollup/rollup-freebsd-arm64": "4.63.3", "@rollup/rollup-freebsd-x64": "4.63.3", "@rollup/rollup-linux-arm-gnueabihf": "4.63.3", "@rollup/rollup-linux-arm-musleabihf": "4.63.3", "@rollup/rollup-linux-arm64-gnu": "4.63.3", "@rollup/rollup-linux-arm64-musl": "4.63.3", "@rollup/rollup-linux-loong64-gnu": "4.63.3", "@rollup/rollup-linux-loong64-musl": "4.63.3", "@rollup/rollup-linux-ppc64-gnu": "4.63.3", "@rollup/rollup-linux-ppc64-musl": "4.63.3", "@rollup/rollup-linux-riscv64-gnu": "4.63.3", "@rollup/rollup-linux-riscv64-musl": "4.63.3", "@rollup/rollup-linux-s390x-gnu": "4.63.3", "@rollup/rollup-linux-x64-gnu": "4.63.3", "@rollup/rollup-linux-x64-musl": "4.63.3", "@rollup/rollup-openbsd-x64": "4.63.3", "@rollup/rollup-openharmony-arm64": "4.63.3", "@rollup/rollup-win32-arm64-msvc": "4.63.3", "@rollup/rollup-win32-ia32-msvc": "4.63.3", "@rollup/rollup-win32-x64-gnu": "4.63.3", "@rollup/rollup-win32-x64-msvc": "4.63.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-1i2XreiAoMMXuPGD6Msj2xWrMMkHojNRKivInxGQcg7/1KuPuYlfUutLyh4drnOxUTHX9cHI4wFoat8D/NKaBw=="], "rou3": ["rou3@0.9.2", "", {}, "sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ=="], @@ -3611,16 +3540,24 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@corbits/artifacts-hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@corbits/chat-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/context-menu/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@corbits/mailbox/hono-openapi": ["hono-openapi@1.3.1", "", { "peerDependencies": { "@hono/standard-validator": "^0.2.0", "@standard-community/standard-json": "^0.3.5", "@standard-community/standard-openapi": "^0.2.9", "@types/json-schema": "^7.0.15", "hono": "^4.11.2", "openapi-types": "^12.1.3" }, "optionalPeers": ["@hono/standard-validator", "hono"] }, "sha512-NLVeVkhKZ3drmQNEIPac8HX8Y54uf1hJAgIM/7MfDsaeVVmB+QILWQxx5x3R3NvRHgedcbEbOCGY2uR7WQYyMw=="], + "@corbits/plugins-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + + "@corbits/settings-ui/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], - "@npmcli/agent/https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -3635,6 +3572,12 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.9", "", {}, "sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw=="], + "@workbench/hub/@corbits/artifacts": ["@corbits/artifacts@github:corbitsdev/corbits-artifacts#dc435ba", { "dependencies": { "@hono/standard-validator": "^0.2.3" }, "peerDependencies": { "@intx/hub-api": "^0.2.2", "@intx/types": "^0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.2", "hono": "^4.12.32", "hono-openapi": "^1.2.0", "postgres": "^3.4.9" } }, "corbitsdev-corbits-artifacts-dc435ba", "sha512-VksIyrrJY9nRke0QyjgqGdW3gLD73Bi3+CzpJWG+l8vRJKdUxVi7ms2Mp95NmJXr4TpVX6i1z4odufX/QBxnSw=="], + + "@workbench/sidecar/@corbits/oauth-core": ["@corbits/oauth-core@github:corbitsdev/corbits-oauth-core#e1e69e6", { "dependencies": { "arktype": "2.2.3" } }, "corbitsdev-corbits-oauth-core-e1e69e6", "sha512-wUrD73iVyk/Dtb4yRn3hCh6N8syfsEvAkRz4XYqT0FIB3sYDSMYEWHcDMJx9nq64my/HBOeN1J6PHIOtpjhtPg=="], + + "@workbench/web/@corbits/react-ui": ["@corbits/react-ui@github:corbitsdev/react-ui#3b12281", { "dependencies": { "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.2", "lucide-react": "^0.545.0 || ^1.0.0", "react": "^18.2.0 || ^19.0.0", "react-dom": "^18.2.0 || ^19.0.0", "sonner": "^2.0.7" }, "optionalPeers": ["@tanstack/react-query"] }, "corbitsdev-react-ui-3b12281", "sha512-Abvm/DO0Gqg0ITHGT9355ZxyKRPMVJLSSQSjpd3a8qt4JPrSMOLIOS4sX8ZMNNaArIbnY9F+VKrOWkUJUyO4Nw=="], + "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "better-call/@better-auth/utils": ["@better-auth/utils@0.5.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA=="], @@ -3647,8 +3590,6 @@ "cliui/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - "hono-openapi/@hono/standard-validator": ["@hono/standard-validator@0.4.0", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "hono": ">=4.11.2" } }, "sha512-MxOm1asDh9j7c1D0KiWwppSbFgAQxTRO4YEhjMrJn3lF1BIcXenPdgjSGKDRfQmZdaTaMA8slQLETgVOKyJxFw=="], "hono-openapi/@standard-community/standard-json": ["@standard-community/standard-json@0.3.6", "", { "dependencies": { "quansync": "^0.2.11" }, "peerDependencies": { "@standard-schema/spec": "^1.0.0", "@types/json-schema": "^7.0.15", "@valibot/to-json-schema": "^1.3.0", "arktype": "^2.1.20", "effect": "^3.20.0", "sury": "^10.0.0", "typebox": "^1.0.17", "valibot": "^1.4.2", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.24.5" }, "optionalPeers": ["@valibot/to-json-schema", "arktype", "effect", "sury", "typebox", "valibot", "zod", "zod-to-json-schema"] }, "sha512-tuXKz1Ps4al0W4xhfwii7v4lskKtOv3QE7mcouRVThrBdOR6JzY4Vea3nsxcEnte84bOJvMvXGmOMWE3IBbHyA=="], @@ -3657,8 +3598,6 @@ "hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - "https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "make-fetch-happen/proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], "make-fetch-happen/ssri": ["ssri@13.0.1", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ=="], @@ -3675,8 +3614,6 @@ "npm-registry-fetch/proc-log": ["proc-log@6.1.0", "", {}, "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ=="], - "p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], - "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], "tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], @@ -3737,8 +3674,6 @@ "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], - "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - "minipass-flush/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "minipass-pipeline/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], diff --git a/docs/revendor-inventory.md b/docs/revendor-inventory.md index afddc0723e..9fff62c60f 100644 --- a/docs/revendor-inventory.md +++ b/docs/revendor-inventory.md @@ -103,7 +103,7 @@ that selector object instead of a bare string. - `packages/chat/src/platform-adapter.ts:340` **Cascades** (typecheck depends on the packages above) into -`@corbits/slack-tag`, `@corbits/chat`, `@corbits/agent-directory`. +`@corbits/chat`, `@corbits/agent-directory`. **Fix shape:** semantic. Each of the 3 call sites needs to produce a `wireHash` for the workflow JSON it just wrote (upstream's wire-definition diff --git a/docs/slack.md b/docs/slack.md deleted file mode 100644 index c5347af7c0..0000000000 --- a/docs/slack.md +++ /dev/null @@ -1,130 +0,0 @@ -# Slack - -Workbench can be mentioned in Slack. A Slack workbench gets bound, on first -contact, to one workbench workbench with one agent already in it; every -mention or thread message in that Slack workbench is posted into the bound -workbench workbench exactly the way a person's message from the web app -would be, and the agent's reply is relayed back to Slack. - -This is Phase 1 (CL-5288): one Slack workspace maps to one bench, and one -Slack workbench maps to one workbench workbench with one agent. There is no -per-thread routing, no slash commands, and no interactive buttons yet. - -## What "workbench binding" means - -The first time someone mentions the bot (or messages a thread it is -watching) in a given Slack workbench, workbench: - -1. Provisions an Interchange principal for that person, if one doesn't - already exist for their Slack email (see "Identity" below). -2. Creates a new workbench workbench, titled after the Slack workbench's name, - with the agent named by `SLACK_DEFAULT_AGENT_DEFINITION_ID` already - invited into it. -3. Records the binding: this Slack workbench now always resolves to that - same workbench workbench. - -Every later message in that Slack workbench reuses the same binding — no new -workbench is created. The binding is scoped per bench (see `SLACK_WORKBENCH_TENANT_SLUG` -below), so two Slack workspaces configured against two different benches -can never see each other's bindings even if their Slack workbench ids -happened to collide. - -## Identity - -Being in the Slack workbench is the authorization: the Slack app is only -installed into workbenches a workspace admin chose, so anyone who can reach -the bot there was already let in. Workbench resolves the sender's Slack -profile email to an existing Interchange account when one exists, and -auto-provisions one on first contact otherwise (a real account with no -password — if that person later signs into workbench directly with the -same email, they arrive with this history already attached). Slack bots -are never resolved to a principal, and Slack guest/shared-workbench accounts -are declined with an explanation posted back to the thread. - -## Expected behavior - -- Mention the bot, or reply in a thread it has joined, and it answers in - that thread — a "thinking…" placeholder appears immediately and is - replaced with the real answer once the agent responds, or quietly - removed if nothing comes back within the wait window. -- Every reply the agent posts into the bound workbench workbench through its - normal delivery path is also visible in the web app for that workbench — - Slack is one more way in, not a separate conversation. -- If nothing lands within the wait window, the placeholder is retracted; - the agent's reply (once it does complete) will still be visible in the - web app. - -## Operator setup - -1. **Create the Slack app from the manifest.** `@corbits/slack-tag` - exports `renderSlackAppManifest(origin)`, which fills in your - deployment's public origin (e.g. `https://bench.example.com`) against a - manifest declaring the bot scopes and event subscriptions workbench - needs: - - ```ts - import { renderSlackAppManifest } from "@corbits/slack-tag/manifest"; - console.log(renderSlackAppManifest("https://bench.example.com")); - ``` - - Paste the rendered YAML into - [api.slack.com/apps](https://api.slack.com/apps) → **Create New App** → - **From an app manifest**. A local dev origin and a deployed origin must - be two separate Slack apps — a manifest names exactly one request URL. - -2. **Install the app** to your workspace, then copy its **Bot User OAuth - Token** (starts `xoxb-`) from _OAuth & Permissions_, and its **Signing - Secret** from _Basic Information_. - -3. **Set the environment variables** (see `.env.example`): - - - `SLACK_BOT_TOKEN` — the bot token from step 2. - - `SLACK_SIGNING_SECRET` — the signing secret from step 2. - - `SLACK_WORKBENCH_TENANT_SLUG` — the slug of the bench this Slack - workspace's messages land in. - - `SLACK_DEFAULT_AGENT_DEFINITION_ID` — the id of an already-deployed - agent definition in that bench; this is who answers. - - Leaving `SLACK_BOT_TOKEN`/`SLACK_SIGNING_SECRET` unset is a valid - configuration — the hub runs with no Slack app mounted. Setting that - pair without also setting the tenant slug and definition id is a boot - error: there is no honest default for which bench or which agent a - Slack message should reach. - -4. **Invite the bot** into whichever Slack workbenches should talk to - workbench, and mention it. - -## Phase 1 limitations - -- **Thread state is in-memory and does not survive a hub restart.** - `corbits-tag/slack`'s own bookkeeping — in-flight thinking-indicator - placeholders, message dedup, and which threads the bot is subscribed to - — is held in a `createMemoryState()` adapter (see - `apps/hub/src/slack-tag-mount.ts` and `packages/slack-tag/src/thread-state.ts`), - not a durable one. A hub restart mid-flight drops any pending - thinking-indicator placeholders (they are simply never retracted or - replaced) and forgets ambient-thread subscriptions, so a Slack thread the - bot had joined stops receiving unmentioned follow-ups until it is - @-mentioned again. `SlackWorkbenchBindingStore` (the Slack-workbench-to- - workbench-workbench binding itself) is unaffected — that is a separate, - durably stored record. A Postgres-backed `StateAdapter` for this thread - state is Phase 2 follow-up work, not yet built. -- **Workbench ingress only — no DMs.** The Slack app requests no - `im:history`/`mpim:history` scopes and subscribes to no `message.im`/ - `message.mpim` events, since the package's whole authorization model - rests on the bench owner having chosen to install the app into a - workbench — a DM bypasses that gate entirely. `dispatchWorkbenchSlackEvent` - also declines any DM-sourced event defensively, in case one ever arrives. - -## Where this lives - -- `packages/slack-tag` — the mount composition: principal resolution, - workbench binding, and the dispatch flow. Nearly all of the behavior - described above lives here. -- `apps/hub/src/slack-tag-mount.ts` — the thin, env-gated glue that wires - this hub's already-running `@corbits/chat` platform into - `packages/slack-tag`'s injected dependencies. -- [corbits-tag](https://github.com/corbitsdev/corbits-tag) — the - underlying Slack ingress SDK (signature verification, event - normalization, thinking-indicator UX), consumed as a pinned GitHub - dependency, not vendored. diff --git a/docs/workbench-tenancy.md b/docs/workbench-tenancy.md deleted file mode 100644 index 3ebafd39f3..0000000000 --- a/docs/workbench-tenancy.md +++ /dev/null @@ -1,324 +0,0 @@ -# Workbench tenancy - -Every workbench `@corbits/chat` creates is minted as a native child tenant of -the bench it was created in, on Interchange's own parent/child tenant -hierarchy (`tenant.parentId`). This document describes the model, the seams -`@corbits/chat` owns because no native route covers them, and the gaps -still open upstream. - -> Cutover note (thread-native client-driver branch): this branch cut the -> web/`bench-ui` client usage of the tenancy-kind surface only — -> `classifyBenchMembership`/`filterBenchMemberships`/`TenancyKind` and the -> web kinds client (`meKeys.workbenchTenancyKinds`, -> `POST /api/workbench-tenancies/kinds` callers) are gone; bench selection, -> the routines roster, and the command palette now filter by -> human-assigned name (`isBenchMembership` in -> `apps/web/src/bench-context.tsx`, backed by `isRawIdentifier` from -> `@corbits/bench-ui`), with child-tenant exclusion living in the -> client-held workbench list (`apps/web/src/needs-list.ts`'s -> `childTenantStore`). The server-side surface this document describes — -> `WorkbenchTenancyStore`, the `workbench_tenancy` table, and -> `POST /api/workbench-tenancies/kinds` — is untouched on this branch; -> server removal lands separately (sibling chat branch). The body below is -> kept as the record of that design. -> -> Open question (no code change): named child tenants now flow into -> selection, the palette, and rosters, with exclusion only via the -> client-held list — whether that is the product behavior we want is -> still undecided. - -## Why workbenches are tenants - -A workbench needs its own membership and permissions — who can post, who can -invite an agent, who can move it — distinct from the bench it lives in. -Rather than invent a parallel, workbench-scoped permission system alongside -Interchange's native grants, a workbench is minted as an ordinary tenant: it -gets its own `owner`/`admin`/`member` roles and grants, seeded exactly as a -bench is, so a workbench's membership is enforced with the same `@intx/authz` -machinery — `evaluateGrants`, `requireGrant` — every other tenant boundary -in the platform already uses. The tenant hierarchy (`tenant.parentId`) then -gives a workbench a bench of origin without needing a second, chat-specific -notion of "which bench owns this workbench." - -## What creation does - -`POST /api/tenants/:tenantId/chat/workbenches` (`packages/chat/src/routes.ts`): - -1. Mints a new tenant via `WorkbenchTenancyStore.createWorkbenchTenant` - (`packages/chat/src/workbench-tenancy.ts`), parented under the calling - bench (`tenant.parentId = `). Native tenant/role/grant rows - are created through Interchange HTTP as the requesting user: - `POST /api/tenants` (name, slug, parentId) then - `POST /api/tenants/:id/grants` for the extra member-role `room:*` - read/write pair. Workbench never INSERT-s into `intx.grants`, - `intx.roles`, or `intx.tenants`. The native `POST /api/tenants` route - seeds the same `owner`/`admin`/`member` system roles and grant shapes - a bench created by hand through that route would get, and returns the - Interchange `tenantId` subsequent grant/repo calls thread in the URL - (`/api/tenants/:tenantId/...` — the hub-client equivalent of a tenant - header). -2. The creator becomes the child tenant's `owner` principal because - `POST /api/tenants` seeds the authenticated caller that way - (`principal.refId` is the creator's auth user id), so they are a - first-class native member of the workbench's own tenant, not just of - the parent bench. -3. Records the parent↔child link in `workbench_tenancy` - (`packages/chat/src/schema.ts`), the table this package owns for - exactly this purpose — the only SQL write on the mint path. -4. Launches the workbench host and stores `workbench_settings` / - `workbench_launch` unchanged from before this feature: both remain - scoped to the parent bench's tenant id, not the new child tenant. - The mint and the launch are two separate steps against separate - machinery, so ordering them alone does not prevent an orphaned - tenant if the launch fails: `POST .../chat/workbenches` wraps the - launch call and, on failure, calls - `WorkbenchTenancyStore.compensateWorkbenchTenant` to delete the - `workbench_tenancy` link (native Interchange tenant rows stay — - compensation no longer deletes them) before re-raising the error. - Both the failure and the compensation are logged loudly. Mint HTTP - failures themselves go through `reportError`. - - Compensation is itself a database write, and can itself fail — the - same outage that failed the launch, for instance, can just as - easily fail the cleanup. That double failure is caught separately: - a compensation error is logged loudly on its own, tagged with the - orphaned tenant id for an operator to clean up by hand, and the - route always re-raises the **original** launch error, never the - compensation error. A caller never sees "cleanup failed" in place - of "your workbench failed to launch," and a double failure never - produces a silently swallowed exception — the accepted cost of a - double failure is one privileged orphan tenant sitting in the - database with a loud log line pointing at it, not data loss or a - hung request. - -Point 4's tenant/launch split is a deliberate, documented limitation — -see "What still lives in the parent bench" below. - -## The listing seam - -No native hub-api route lists a tenant's children (`tenant.parentId` is -stored but never queried by any route in `vendor/intx/hub-api`). Rather -than leave "which workbenches are child tenancies of this bench" unanswerable, -`@corbits/chat` owns the answer from its own `workbench_tenancy` table via -`WorkbenchTenancyStore.listChildWorkbenchTenancies`. - -`GET .../chat/workbenches` annotates every row from `workbench_settings` (still -queried by the same `tenantId` scope as before) with its `workbench_tenancy` -link, when one exists: - -- `tenancy: { tenantId, parentTenantId, slug }`, `legacy: false` — a - workbench created after this rollout, with a real child tenant behind it. -- `tenancy: null`, `legacy: true` — a **legacy** workbench: one created - before workbench tenancy existed, with no tenant of its own. This branch - and the `legacy` field are filed for removal once every workbench in a - production database has been backfilled a tenancy; until then, dropping - it silently would be exactly the kind of fallback AGENTS.md forbids, so - it stays explicit in the route and in this document instead. The chat - sidebar (`packages/chat-ui/src/sidebar.tsx`) surfaces it too: a legacy - workbench's row carries a muted "Legacy" badge alongside its title, so - the marker is visible to a human, not just present on the wire. - - Backfilling every legacy workbench a tenancy — so this branch, the - `legacy` field, and the badge can all be deleted — is tracked as a - follow-up item on CL-5647's list, not scheduled here. - -## Movability - -`POST .../chat/workbenches/:id/move` re-parents a workbench's tenancy to a new -bench. There is no native `PATCH` for a tenant's `parentId`, so the move -updates two places, both owned by this package's own service code, in one -transaction (`WorkbenchTenancyStore.moveWorkbenchTenancy`): - -1. The `workbench_tenancy` link row (`parentTenantId`) — the source - `listChildWorkbenchTenancies` reads. -2. The native `tenant.parentId` column itself, directly, through - `@intx/db`'s published schema (`import { tenant } from "@intx/db/schema"`) - — this is ordinary consumption of a published table export, not a fork - of `vendor/intx/hub-api`'s tenant routes. - -A workbench with no `workbench_tenancy` link (a legacy workbench) cannot be -moved; the route returns `409 conflict` rather than silently no-op'ing. - -The destination is verified and the move is written by the same call, -inside the same transaction — `WorkbenchTenancyStore.moveWorkbenchTenancy` -takes the caller's `refId` alongside the workbench and destination ids, -and re-checks the destination from inside its own transaction rather -than trusting a decision an earlier, separate read made: - -- `newParentTenantId` must name a tenant that actually exists — a bogus - or fabricated id is rejected with `404 not_found` rather than being - written straight into `workbench_tenancy` and `tenant.parentId`. -- The caller must hold an active principal in that destination tenant - carrying a manage-level grant, evaluated with the same `@intx/authz` - `evaluateGrants` logic `requireGrant` itself resolves against, against - the destination tenant rather than the caller's own. A caller with - only standing in the workbench's current bench — including the bench - that currently owns the workbench — cannot move it into a tenant it has - no authority over; that request is rejected with `403 forbidden`. - -Both checks run under row locks (`SELECT ... FOR UPDATE`) taken on the -destination tenant row, the caller's principal row in that tenant, and -every grant row that could resolve the decision — all inside the -transaction that goes on to perform the two writes. This is not a -"check, then separately act" sequence: a grant revocation or a -destination-tenant deletion committed by another transaction blocks on -these locks until the move's transaction finishes, rather than landing -in a window between the check and the write and letting the move -proceed on since-revoked authority. `createDrizzleWorkbenchTenancyStore` -is the only implementation of this — the row locks, and the real -`@intx/authz` `evaluateGrants` call, only exist on the Postgres-backed -store, never on the in-memory test double — and `test/isolation`'s -"chat workbench move" suite drives it to a real `{ kind: "moved" }` -outcome end to end against Postgres, then re-reads both -`workbench_tenancy.parent_tenant_id` and `tenant.parentId` fresh to -confirm the writes actually landed, rather than trusting the response -body alone. - -A third check, structural rather than authorization-based, runs -alongside these two: `newParentTenantId` cannot be the workbench's own -tenant, or descend from it. `moveWorkbenchTenancy` walks the -destination's ancestor chain — under the same row locks, inside the -same transaction — looking for the workbench's own tenant id; finding it -means completing the move would make the workbench its own ancestor, so -the move is rejected with `{ kind: "cycle" }` (`409 conflict` over -HTTP) regardless of what grants the caller holds. `tenant.parentId` is -a plain self-referencing foreign key with no cycle constraint of its -own — Postgres will happily store a self-parent or a longer loop — so -this check is the only thing standing between a hierarchy this document -describes as a tree and one that, left unguarded, could become -cyclic. Nothing today walks `parentId` upward except this check itself, -so a cycle is not exploitable yet, but any future code that does -(breadcrumbs, an ancestor-scoped query, an admin tenant tree) would -infinite-loop on a cyclic tree with no way to detect how it got there. - -## Indexing - -`workbench_tenancy` carries a `PRIMARY KEY (workbench_id)` and a -`UNIQUE (tenant_id)`, but `listChildWorkbenchTenancies` filters on -`parent_tenant_id`, a column neither constraint indexes. Migration -`0006_workbench_tenancy_parent_index` adds -`workbench_tenancy_parent_tenant_id_idx` on `parent_tenant_id` so that -read stays an index scan as the table grows, rather than a sequential -scan. - -`GET .../chat/workbenches` itself reads tenancy by `workbench_id` (the -primary key) instead, one call per listed row: a bench's workbench -listing is scoped to `workbench_settings`, which keeps a workbench's row -forever in the bench it was created in even after a move, so annotating -those rows by "children of this bench" would go stale the moment a -workbench moved elsewhere and wrongly report it as legacy. - -## Scaling - -Minting a workbench tenant is not a single row. `createWorkbenchTenant` seeds a -tenant, its three system roles, one owner principal, one principal-role -assignment, and five system grants (one on `owner`, three on `admin`, one -on `member`), plus the `workbench_tenancy` link — a dozen native rows for -every workbench created, on top of the `workbench_settings` and -`workbench_launch` rows chat already wrote before this feature. A workspace -with heavy workbench churn multiplies its `tenant`/`role`/`principal`/ -`grant` row counts accordingly; nothing about the mint amortizes this -across workbenches, since each one needs its own independent grant surface. - -## Recovering an orphaned tenant - -A tenant can be left behind with no workbench pointing at it in exactly one -window: the mint's transaction commits, then the process dies before the -workbench host launch call returns — there is no automated sweep for this -case, only the loud `workbench-tenancy` log line `compensateWorkbenchTenant` -already emits for the double-failure case (mint succeeded, launch failed, -_and_ compensation itself failed). Either way, an orphaned tenant is a -`tenant` row with no matching `workbench_tenancy.tenant_id` and no -`workbench_settings` row referencing it — reachable by diffing -`workbench_tenancy` against `tenant.parentId` for the bench in question. It -carries no workbench state, only its own owner principal, roles, and grants, -so deleting it (cascading through `role`, `principal`, `principal_role`, -`grant`) is safe once confirmed orphaned; there is no data recovery -question, only cleanup. - -## Room access and visibility - -A workbench's timeline (`chat.workbench_messages`, CL-6327) is no longer -mail addressed to a run, so its routes no longer gate on a `workflow-run` -grant either (CL-6346): `GET`/`POST .../messages`, the thread-read and -delivery-thread routes, block responses, reactions, pins, read-state, -typing, presence, and the SSE stream all check `idResource("room", "id")` -instead — -a plain rename in the grant grammar, not a new grammar feature, since -`@intx/authz`'s pattern matching already treats a resource type as an -arbitrary `":"` string; the system `owner`/`admin`/`member` -grants every tenant (bench or workbench) is seeded with already match it -via their `resource: "*"` wildcard. Route-level lifecycle actions — -settings, invite, participant removal, move, shares — are unaffected and -still gate on `workflow-run`, since those remain about the workbench host's -own run, not the room. - -That coarse per-route grant check answers "can this principal act on -_some_ room in this tenant at all"; it says nothing about _which_ -workbench. `resolveWorkbenchAccess` (`packages/chat/src/routes.ts`) is -the finer-grained authz check every message route also runs, and as of -CL-6346 it folds in the workbench's own **visibility** setting -(`chat/visibility`, `"bench" | "members"`, defaulting to `"bench"`): - -- **`"bench"`** (the default) — every principal already authenticated in - the owning bench's tenant may open the workbench; reaching the check at - all already proves that. -- **`"members"`** — only a principal the workbench's own child tenant has - minted may open it. Membership is native principals, never a bespoke - `workbench_member` table (CL-6332): whoever is invited gets a real - principal row in the workbench's own tenant (the creator already does, - from `createWorkbenchTenant`'s owner mint), and - `WorkbenchTenancyStore.getTenantPrincipalByRefId` resolves whether the - caller's own auth identity (`principal.refId`, stable across every - tenant it holds a principal in) is one of them. - -A legacy workbench with no `workbench_tenancy` link has no child tenant to -check membership against, so it stays bench-visible regardless of its -`chat/visibility` setting — the same "no fallback, stay honest" treatment -`GET .../chat/workbenches`'s `legacy` field gives it elsewhere in this -document. Invite flow and `chat/participants` trimming onto this same -membership model are Phase 1.6's own slice (CL-6332), not this change's. - -## What still lives in the parent bench - -The workbench host's workflow run, its `workbench_settings` row, and its -`workbench_launch` row all remain scoped to the **parent bench's** tenant id, -even after the child tenant exists. Re-homing the launch itself into the -child tenant would mean passing the child tenant id through -`@corbits/folded-runs`' launch, session-resolution, and credential/catalog -resolution paths — machinery this rollout was explicitly scoped not to -reach into (`packages/folded-runs` internals are off-limits here). The -child tenant that exists today is therefore a real, native, correctly -parented tenant — with its own owner principal, roles, and grants — that -currently serves as the workbench's identity and listing anchor, while the -running instance continues to resolve against the parent bench's -inference/session/credential context. - -Moving the launch itself into the child tenant is future work, gated on -`@corbits/folded-runs` exposing a tenant override for -launch/session/catalog resolution without requiring changes to its -internals. - -## Upstream gaps (tracked, never faked) - -Carried over from the platform tenancy inventory, and specifically -relevant to this feature: - -- **No native child-tenant listing route.** `tenant.parentId` is stored - but no route ever queries it; `WorkbenchTenancyStore` exists because of - this gap. -- **No native `PATCH` for a tenant's `parentId`.** The move route updates - the column directly through `@intx/db`'s schema instead. -- **`CreateTenant.parentId` is unvalidated server-side** on the native - `POST /api/tenants` route — a caller can name any string as a parent, a - gap `workbench-tenancy.ts` does not introduce but also does not close, - since it mints the child tenant directly rather than going through that - HTTP route. -- **The move route does verify `newParentTenantId`, unlike the native - creation route.** `WorkbenchTenancyStore.moveWorkbenchTenancy` checks the - destination tenant exists and that the caller holds a manage-level - grant there, inside the same transaction that performs the move (see - "Movability" above) — a stricter contract than `POST /api/tenants`'s - unvalidated `parentId` enforces. Tightening the native route to match - is future work upstream, not something this package can do from the - outside. diff --git a/packages/slack-tag/LICENSE b/packages/slack-tag/LICENSE deleted file mode 100644 index c6487f4fdf..0000000000 --- a/packages/slack-tag/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ -GNU LESSER GENERAL PUBLIC LICENSE - -Version 2.1, February 1999 - -Copyright (C) 1991, 1999 Free Software Foundation, Inc. -51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. - -[This is the first released version of the Lesser GPL. It also counts as the successor of the GNU Library Public License, version 2, hence the version number 2.1.] - -Preamble - -The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public Licenses are intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. - -This license, the Lesser General Public License, applies to some specially designated software packages--typically libraries--of the Free Software Foundation and other authors who decide to use it. You can use it too, but we suggest you first think carefully about whether this license or the ordinary General Public License is the better strategy to use in any particular case, based on the explanations below. - -When we speak of free software, we are referring to freedom of use, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish); that you receive source code or can get it if you want it; that you can change the software and use pieces of it in new free programs; and that you are informed that you can do these things. - -To protect your rights, we need to make restrictions that forbid distributors to deny you these rights or to ask you to surrender these rights. These restrictions translate to certain responsibilities for you if you distribute copies of the library or if you modify it. - -For example, if you distribute copies of the library, whether gratis or for a fee, you must give the recipients all the rights that we gave you. You must make sure that they, too, receive or can get the source code. If you link other code with the library, you must provide complete object files to the recipients, so that they can relink them with the library after making changes to the library and recompiling it. And you must show them these terms so they know their rights. - -We protect your rights with a two-step method: (1) we copyright the library, and (2) we offer you this license, which gives you legal permission to copy, distribute and/or modify the library. - -To protect each distributor, we want to make it very clear that there is no warranty for the free library. Also, if the library is modified by someone else and passed on, the recipients should know that what they have is not the original version, so that the original author's reputation will not be affected by problems that might be introduced by others. - -Finally, software patents pose a constant threat to the existence of any free program. We wish to make sure that a company cannot effectively restrict the users of a free program by obtaining a restrictive license from a patent holder. Therefore, we insist that any patent license obtained for a version of the library must be consistent with the full freedom of use specified in this license. - -Most GNU software, including some libraries, is covered by the ordinary GNU General Public License. This license, the GNU Lesser General Public License, applies to certain designated libraries, and is quite different from the ordinary General Public License. We use this license for certain libraries in order to permit linking those libraries into non-free programs. - -When a program is linked with a library, whether statically or using a shared library, the combination of the two is legally speaking a combined work, a derivative of the original library. The ordinary General Public License therefore permits such linking only if the entire combination fits its criteria of freedom. The Lesser General Public License permits more lax criteria for linking other code with the library. - -We call this license the "Lesser" General Public License because it does Less to protect the user's freedom than the ordinary General Public License. It also provides other free software developers Less of an advantage over competing non-free programs. These disadvantages are the reason we use the ordinary General Public License for many libraries. However, the Lesser license provides advantages in certain special circumstances. - -For example, on rare occasions, there may be a special need to encourage the widest possible use of a certain library, so that it becomes a de-facto standard. To achieve this, non-free programs must be allowed to use the library. A more frequent case is that a free library does the same job as widely used non-free libraries. In this case, there is little to gain by limiting the free library to free software only, so we use the Lesser General Public License. - -In other cases, permission to use a particular library in non-free programs enables a greater number of people to use a large body of free software. For example, permission to use the GNU C Library in non-free programs enables many more people to use the whole GNU operating system, as well as its variant, the GNU/Linux operating system. - -Although the Lesser General Public License is Less protective of the users' freedom, it does ensure that the user of a program that is linked with the Library has the freedom and the wherewithal to run that program using a modified version of the Library. - -The precise terms and conditions for copying, distribution and modification follow. Pay close attention to the difference between a "work based on the library" and a "work that uses the library". The former contains code derived from the library, whereas the latter must be combined with the library in order to run. - -GNU LESSER GENERAL PUBLIC LICENSE -TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION - -0. This License Agreement applies to any software library or other program which contains a notice placed by the copyright holder or other authorized party saying it may be distributed under the terms of this Lesser General Public License (also called "this License"). Each licensee is addressed as "you". - -A "library" means a collection of software functions and/or data prepared so as to be conveniently linked with application programs (which use some of those functions and data) to form executables. - -The "Library", below, refers to any such software library or work which has been distributed under these terms. A "work based on the Library" means either the Library or any derivative work under copyright law: that is to say, a work containing the Library or a portion of it, either verbatim or with modifications and/or translated straightforwardly into another language. (Hereinafter, translation is included without limitation in the term "modification".) - -"Source code" for a work means the preferred form of the work for making modifications to it. For a library, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the library. - -Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running a program using the Library is not restricted, and output from such a program is covered only if its contents constitute a work based on the Library (independent of the use of the Library in a tool for writing it). Whether that is true depends on what the Library does and what the program that uses the Library does. - -1. You may copy and distribute verbatim copies of the Library's complete source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and distribute a copy of this License along with the Library. - -You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. - -2. You may modify your copy or copies of the Library or any portion of it, thus forming a work based on the Library, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: - - a) The modified work must itself be a software library. - - b) You must cause the files modified to carry prominent notices stating that you changed the files and the date of any change. - - c) You must cause the whole of the work to be licensed at no charge to all third parties under the terms of this License. - - d) If a facility in the modified Library refers to a function or a table of data to be supplied by an application program that uses the facility, other than as an argument passed when the facility is invoked, then you must make a good faith effort to ensure that, in the event an application does not supply such function or table, the facility still operates, and performs whatever part of its purpose remains meaningful. - -(For example, a function in a library to compute square roots has a purpose that is entirely well-defined independent of the application. Therefore, Subsection 2d requires that any application-supplied function or table used by this function must be optional: if the application does not supply it, the square root function must still compute square roots.) - -These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Library, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Library, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. - -Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Library. - -In addition, mere aggregation of another work not based on the Library with the Library (or with a work based on the Library) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. - -3. You may opt to apply the terms of the ordinary GNU General Public License instead of this License to a given copy of the Library. To do this, you must alter all the notices that refer to this License, so that they refer to the ordinary GNU General Public License, version 2, instead of to this License. (If a newer version than version 2 of the ordinary GNU General Public License has appeared, then you can specify that version instead if you wish.) Do not make any other change in these notices. - -Once this change is made in a given copy, it is irreversible for that copy, so the ordinary GNU General Public License applies to all subsequent copies and derivative works made from that copy. - -This option is useful when you wish to copy part of the code of the Library into a program that is not a library. - -4. You may copy and distribute the Library (or a portion or derivative of it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange. - -If distribution of object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place satisfies the requirement to distribute the source code, even though third parties are not compelled to copy the source along with the object code. - -5. A program that contains no derivative of any portion of the Library, but is designed to work with the Library by being compiled or linked with it, is called a "work that uses the Library". Such a work, in isolation, is not a derivative work of the Library, and therefore falls outside the scope of this License. - -However, linking a "work that uses the Library" with the Library creates an executable that is a derivative of the Library (because it contains portions of the Library), rather than a "work that uses the library". The executable is therefore covered by this License. Section 6 states terms for distribution of such executables. - -When a "work that uses the Library" uses material from a header file that is part of the Library, the object code for the work may be a derivative work of the Library even though the source code is not. Whether this is true is especially significant if the work can be linked without the Library, or if the work is itself a library. The threshold for this to be true is not precisely defined by law. - -If such an object file uses only numerical parameters, data structure layouts and accessors, and small macros and small inline functions (ten lines or less in length), then the use of the object file is unrestricted, regardless of whether it is legally a derivative work. (Executables containing this object code plus portions of the Library will still fall under Section 6.) - -Otherwise, if the work is a derivative of the Library, you may distribute the object code for the work under the terms of Section 6. Any executables containing that work also fall under Section 6, whether or not they are linked directly with the Library itself. - -6. As an exception to the Sections above, you may also combine or link a "work that uses the Library" with the Library to produce a work containing portions of the Library, and distribute that work under terms of your choice, provided that the terms permit modification of the work for the customer's own use and reverse engineering for debugging such modifications. - -You must give prominent notice with each copy of the work that the Library is used in it and that the Library and its use are covered by this License. You must supply a copy of this License. If the work during execution displays copyright notices, you must include the copyright notice for the Library among them, as well as a reference directing the user to the copy of this License. Also, you must do one of these things: - - a) Accompany the work with the complete corresponding machine-readable source code for the Library including whatever changes were used in the work (which must be distributed under Sections 1 and 2 above); and, if the work is an executable linked with the Library, with the complete machine-readable "work that uses the Library", as object code and/or source code, so that the user can modify the Library and then relink to produce a modified executable containing the modified Library. (It is understood that the user who changes the contents of definitions files in the Library will not necessarily be able to recompile the application to use the modified definitions.) - - b) Use a suitable shared library mechanism for linking with the Library. A suitable mechanism is one that (1) uses at run time a copy of the library already present on the user's computer system, rather than copying library functions into the executable, and (2) will operate properly with a modified version of the library, if the user installs one, as long as the modified version is interface-compatible with the version that the work was made with. - - c) Accompany the work with a written offer, valid for at least three years, to give the same user the materials specified in Subsection 6a, above, for a charge no more than the cost of performing this distribution. - - d) If distribution of the work is made by offering access to copy from a designated place, offer equivalent access to copy the above specified materials from the same place. - - e) Verify that the user has already received a copy of these materials or that you have already sent this user a copy. - -For an executable, the required form of the "work that uses the Library" must include any data and utility programs needed for reproducing the executable from it. However, as a special exception, the materials to be distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. - -It may happen that this requirement contradicts the license restrictions of other proprietary libraries that do not normally accompany the operating system. Such a contradiction means you cannot use both them and the Library together in an executable that you distribute. - -7. You may place library facilities that are a work based on the Library side-by-side in a single library together with other library facilities not covered by this License, and distribute such a combined library, provided that the separate distribution of the work based on the Library and of the other library facilities is otherwise permitted, and provided that you do these two things: - - a) Accompany the combined library with a copy of the same work based on the Library, uncombined with any other library facilities. This must be distributed under the terms of the Sections above. - - b) Give prominent notice with the combined library of the fact that part of it is a work based on the Library, and explaining where to find the accompanying uncombined form of the same work. - -8. You may not copy, modify, sublicense, link with, or distribute the Library except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense, link with, or distribute the Library is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. - -9. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Library or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Library (or any work based on the Library), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Library or works based on it. - -10. Each time you redistribute the Library (or any work based on the Library), the recipient automatically receives a license from the original licensor to copy, distribute, link with or modify the Library subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties with this License. - -11. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Library at all. For example, if a patent license would not permit royalty-free redistribution of the Library by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Library. - -If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply, and the section as a whole is intended to apply in other circumstances. - -It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. - -This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. - -12. If the distribution and/or use of the Library is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Library under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. - -13. The Free Software Foundation may publish revised and/or new versions of the Lesser General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. - -Each version is given a distinguishing version number. If the Library specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Library does not specify a license version number, you may choose any version ever published by the Free Software Foundation. - -14. If you wish to incorporate parts of the Library into other free programs whose distribution conditions are incompatible with these, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. - -NO WARRANTY - -15. BECAUSE THE LIBRARY IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE LIBRARY, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE LIBRARY "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE LIBRARY IS WITH YOU. SHOULD THE LIBRARY PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - -16. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE LIBRARY AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE LIBRARY (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE LIBRARY TO OPERATE WITH ANY OTHER SOFTWARE), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. - -END OF TERMS AND CONDITIONS - -How to Apply These Terms to Your New Libraries - -If you develop a new library, and you want it to be of the greatest possible use to the public, we recommend making it free software that everyone can redistribute and change. You can do so by permitting redistribution under these terms (or, alternatively, under the terms of the ordinary General Public License). - -To apply these terms, attach the following notices to the library. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. - - one line to give the library's name and an idea of what it does. - Copyright (C) year name of author - - This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. - - This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. - - You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Also add information on how to contact you by electronic and paper mail. - -You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the library, if necessary. Here is a sample; alter the names: - -Yoyodyne, Inc., hereby disclaims all copyright interest in -the library `Frob' (a library for tweaking knobs) written -by James Random Hacker. - -signature of Ty Coon, 1 April 1990 -Ty Coon, President of Vice -That's all there is to it! diff --git a/packages/slack-tag/README.md b/packages/slack-tag/README.md deleted file mode 100644 index fab380fd18..0000000000 --- a/packages/slack-tag/README.md +++ /dev/null @@ -1,53 +0,0 @@ -# @corbits/slack-tag - -Slack mount composition for workbench's channel/tenancy machinery: binds a -Slack channel to a workbench channel, auto-provisions Interchange -principals for Slack authors on first contact, and routes mentions and -thread messages through the existing chat platform — the same path a -human's message from the web UI takes. - -## How it composes with Interchange - -- `dispatch.ts`'s `mountWorkbenchSlack` wires the vendored - `corbits-tag/slack`'s `mountSlackTag` (signature verification, event - normalization) together with this package's own principal resolver and - channel binding into one dispatch flow — the Slack-specific mechanics - live in `corbits-tag`, never reimplemented here. -- `principal-resolver.ts`'s auto-provisioning lifts the - `createAutoProvisionPrincipalResolver` pattern from Scout's - `packages/agent-dock/src/tag-mount.ts`, riding `corbits-tag/interchange`'s - own product-agnostic `createPrincipalResolver`/`provisionPrincipal`. -- `thread-state.ts` resolves a Chat SDK `StateAdapter` (`chat`, - `@chat-adapter/state-memory`) for `corbits-tag/slack`'s own thread - subscription/dedup/lock state, falling back to an in-memory adapter if - the durable backend is unreachable at mount time. -- Channel provisioning and message delivery are injected - (`deps.provisionChannel`, `deps.sendMessage`) from the host's own - `@corbits/chat` composition — this package never launches a channel - host or drives inference itself. - -## Key modules - -- `dispatch.ts` — `mountWorkbenchSlack`, the composition root. -- `channel-binding.ts` — resolve-or-create the one workbench channel a - Slack channel is bound to. -- `principal-resolver.ts` — auto-provision an Interchange principal for a - first-contact Slack author; channel membership is itself the - authorization. -- `store.ts` / `schema.ts` / `migrations.ts` — the `slack_tag` Postgres - schema and its one table, `slack_channel_binding`, keyed by - `(tenant_id, slack_channel_id)`. -- `manifest.ts` — the Slack app manifest template, rendered per - deployment origin. -- `reply-wait.ts` — waits for the channel host's next reply so it can be - relayed back to Slack. - -## Running tests - -``` -cd packages/slack-tag && bun test -``` - -No live database required — `store.test.ts` runs against the in-memory -binding store; no test in this package uses drizzle against a real -Postgres instance. diff --git a/packages/slack-tag/package.json b/packages/slack-tag/package.json deleted file mode 100644 index 9fa6e16bf8..0000000000 --- a/packages/slack-tag/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "@corbits/slack-tag", - "private": true, - "description": "Slack mount composition for workbench's channel/tenancy machinery: binds a Slack channel to a workbench channel, auto-provisions Interchange principals for Slack authors, and routes mentions/thread messages through the existing chat platform.", - "version": "0.0.1", - "license": "LGPL-2.1-or-later", - "type": "module", - "exports": { - ".": "./src/index.ts", - "./migrations": "./src/migrations.ts", - "./manifest": "./src/manifest.ts" - }, - "scripts": { - "typecheck": "tsc --noEmit", - "test": "bun test" - }, - "dependencies": { - "@chat-adapter/state-memory": "^4.35.0", - "@corbits/chat": "workspace:*", - "@intx/db": "workspace:*", - "@intx/log": "0.3.0", - "arktype": "catalog:", - "chat": "^4.34.0", - "corbits-tag": "github:corbitsdev/corbits-tag#92eabf4f7bf0a9663799c07db6c3f72a4275f894", - "drizzle-orm": "catalog:", - "hono": "catalog:", - "postgres": "catalog:" - }, - "devDependencies": { - "@types/bun": "catalog:", - "typescript": "catalog:" - } -} diff --git a/packages/slack-tag/src/channel-binding.test.ts b/packages/slack-tag/src/channel-binding.test.ts deleted file mode 100644 index cbf817636a..0000000000 --- a/packages/slack-tag/src/channel-binding.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - resolveOrCreateChannelBinding, - slackChannelIdFromThreadId, -} from "./channel-binding"; -import { createInMemorySlackChannelBindingStore } from "./store"; - -describe("slackChannelIdFromThreadId", () => { - test("splits the Chat SDK Slack thread id at the first colon", () => { - expect(slackChannelIdFromThreadId("C1:1721800000.000100")).toBe("C1"); - }); - - test("returns undefined for a thread id with no separator", () => { - expect(slackChannelIdFromThreadId("no-separator-here")).toBeUndefined(); - }); - - test("returns undefined for a thread id starting with the separator", () => { - expect(slackChannelIdFromThreadId(":1721800000.000100")).toBeUndefined(); - }); -}); - -describe("resolveOrCreateChannelBinding", () => { - test("provisions a channel and records a binding on first contact", async () => { - const bindings = createInMemorySlackChannelBindingStore(); - let provisionCalls = 0; - const provisionChannel = async (input: { - tenantId: string; - name: string; - creatorPrincipalId: string; - }) => { - provisionCalls += 1; - expect(input.tenantId).toBe("tenant_1"); - expect(input.name).toBe("general"); - expect(input.creatorPrincipalId).toBe("prn_1"); - return { channelId: "workflowRun_abc" }; - }; - - const binding = await resolveOrCreateChannelBinding( - { bindings, provisionChannel }, - { - tenantId: "tenant_1", - slackChannelId: "C1", - slackChannelName: "general", - principalId: "prn_1", - }, - ); - - expect(binding.channelId).toBe("workflowRun_abc"); - expect(provisionCalls).toBe(1); - }); - - test("reuses an existing binding without provisioning again", async () => { - const bindings = createInMemorySlackChannelBindingStore(); - await bindings.createBinding({ - tenantId: "tenant_1", - slackChannelId: "C1", - channelId: "workflowRun_existing", - }); - - let provisionCalls = 0; - const provisionChannel = async () => { - provisionCalls += 1; - return { channelId: "workflowRun_should_not_be_used" }; - }; - - const binding = await resolveOrCreateChannelBinding( - { bindings, provisionChannel }, - { - tenantId: "tenant_1", - slackChannelId: "C1", - slackChannelName: "general", - principalId: "prn_1", - }, - ); - - expect(binding.channelId).toBe("workflowRun_existing"); - expect(provisionCalls).toBe(0); - }); -}); diff --git a/packages/slack-tag/src/channel-binding.ts b/packages/slack-tag/src/channel-binding.ts deleted file mode 100644 index 426a38b261..0000000000 --- a/packages/slack-tag/src/channel-binding.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Resolve-or-create the workbench channel a Slack channel is bound to. - * - * v1 is deliberately the simplest honest shape: one workbench channel - * per Slack channel, minted on first contact and titled after the - * Slack channel's own name. `provisionChannel` is injected — it is the - * host's real channel-creation path (the same one `POST /channels` - * uses: `ChannelTenancyStore.createChannelTenant` + - * `ChatPlatform.launchChannel` + `launchAndJoinAgent`, from - * `@corbits/chat`), never reimplemented here. - * - * A race between two concurrent first messages in the same Slack - * channel can provision two workbench channels before either binding - * commits; `SlackChannelBindingStore.createBinding` is idempotent, so - * only one binding wins — the loser's workbench channel is orphaned - * (unbound, but harmless) rather than corrupting the binding record. - * Closing that race is future work, not a v1 requirement. - */ -import type { - CreateSlackChannelBindingInput, - SlackChannelBinding, - SlackChannelBindingStore, -} from "./store"; - -export type ProvisionChannel = (input: { - readonly tenantId: string; - readonly name: string; - readonly creatorPrincipalId: string; -}) => Promise<{ readonly channelId: string }>; - -export type ResolveOrCreateChannelBindingDeps = { - readonly bindings: SlackChannelBindingStore; - readonly provisionChannel: ProvisionChannel; -}; - -export type ResolveOrCreateChannelBindingInput = { - readonly tenantId: string; - readonly slackChannelId: string; - readonly slackChannelName: string; - readonly principalId: string; -}; - -export async function resolveOrCreateChannelBinding( - deps: ResolveOrCreateChannelBindingDeps, - input: ResolveOrCreateChannelBindingInput, -): Promise { - const existing = await deps.bindings.getBinding( - input.tenantId, - input.slackChannelId, - ); - if (existing !== undefined) return existing; - - const provisioned = await deps.provisionChannel({ - tenantId: input.tenantId, - name: input.slackChannelName, - creatorPrincipalId: input.principalId, - }); - - const binding: CreateSlackChannelBindingInput = { - tenantId: input.tenantId, - slackChannelId: input.slackChannelId, - channelId: provisioned.channelId, - }; - return deps.bindings.createBinding(binding); -} - -/** - * Recovers the Slack channel id from a Chat SDK Slack thread id - * (`${channelId}:${ts}`, e.g. `"C1:1721800000.000100"` — see - * `corbits-tag/slack`'s wire layer). `TagEvent.threadId` carries no - * separate channel-id field, so this is the one place `@corbits/slack-tag` - * is coupled to that documented wire format. Returns `undefined` for a - * thread id with no separator — a malformed or foreign id must never - * be silently mistaken for a valid channel. - */ -export function slackChannelIdFromThreadId( - threadId: string, -): string | undefined { - const separatorIndex = threadId.indexOf(":"); - if (separatorIndex <= 0) return undefined; - return threadId.slice(0, separatorIndex); -} diff --git a/packages/slack-tag/src/config.test.ts b/packages/slack-tag/src/config.test.ts deleted file mode 100644 index 177b280daa..0000000000 --- a/packages/slack-tag/src/config.test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { parseSlackCredentials } from "./config"; - -describe("parseSlackCredentials", () => { - test("accepts a well-formed credential pair", () => { - expect( - parseSlackCredentials({ botToken: "xoxb-1", signingSecret: "shh" }), - ).toEqual({ botToken: "xoxb-1", signingSecret: "shh" }); - }); - - test("rejects an empty bot token", () => { - expect(() => - parseSlackCredentials({ botToken: "", signingSecret: "shh" }), - ).toThrow(); - }); - - test("rejects a missing signing secret", () => { - expect(() => parseSlackCredentials({ botToken: "xoxb-1" })).toThrow(); - }); -}); diff --git a/packages/slack-tag/src/config.ts b/packages/slack-tag/src/config.ts deleted file mode 100644 index 845295a3da..0000000000 --- a/packages/slack-tag/src/config.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Parsed at the trust boundary between the host's environment and this -// package: `mountWorkbenchSlack` never trusts a bare `{ botToken, -// signingSecret }` object shape-checked only by TypeScript — an empty -// string (a misconfigured `.env`) would otherwise reach `corbits-tag/slack` -// and fail confusingly deep inside signature verification instead of -// here, at the mount boundary, with a clear message. -import { type } from "arktype"; - -export const SlackCredentials = type({ - botToken: "string > 0", - signingSecret: "string > 0", -}); - -export type SlackCredentialsT = typeof SlackCredentials.infer; - -export function parseSlackCredentials(value: unknown): SlackCredentialsT { - const parsed = SlackCredentials(value); - if (parsed instanceof type.errors) { - throw new Error( - `@corbits/slack-tag: invalid Slack credentials: ${parsed.summary}`, - ); - } - return parsed; -} diff --git a/packages/slack-tag/src/dispatch.test.ts b/packages/slack-tag/src/dispatch.test.ts deleted file mode 100644 index 3c9f5982eb..0000000000 --- a/packages/slack-tag/src/dispatch.test.ts +++ /dev/null @@ -1,360 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createHmac } from "node:crypto"; -import { Hono } from "hono"; -import type { TagEvent, TagThread } from "corbits-tag/slack"; -import type { PrincipalResolution } from "corbits-tag/interchange"; - -import { dispatchWorkbenchSlackEvent, mountWorkbenchSlack } from "./dispatch"; -import { createInMemorySlackChannelBindingStore } from "./store"; -import type { MountWorkbenchSlackDeps } from "./dispatch"; - -/** Every method resolves to undefined — enough for Chat's lazy init path. - * Mirrors `corbits-tag/slack`'s own `noopState` test helper: a structurally - * valid `StateAdapter` is heavy to fake, and these tests only exercise the - * seams `@corbits/slack-tag` owns (signature verification is the SDK's, and - * is exercised here purely at the transport boundary). */ -const noopState = () => - new Proxy({}, { get: () => async () => undefined }) as never; - -function baseDeps( - overrides: Partial = {}, -): MountWorkbenchSlackDeps { - return { - tenantId: "tenant_1", - slack: { botToken: "xoxb-test", signingSecret: "shhh-real-secret" }, - state: noopState(), - bindings: createInMemorySlackChannelBindingStore(), - resolvePrincipal: async () => ({ - ok: true, - principal: { - principalId: "prn_1", - tenantId: "tenant_1", - userId: "usr_1", - email: "person@example.com", - }, - }), - provisionChannel: async () => ({ channelId: "workflowRun_new" }), - sendMessage: async () => ({ id: "mail_1" }), - subscribeToChannel: () => () => undefined, - // Fast default so a test that never fires a reply doesn't sit on the - // production 60s default and blow the test runner's own timeout. - replyWaitMs: 50, - ...overrides, - }; -} - -/** - * Signature verification (transport boundary): this exercises the REAL - * `corbits-tag/slack` mount over a real Hono app with a genuine Slack HMAC - * signature computed against the WRONG secret — nothing here is mocked, so - * a pass proves `@corbits/slack-tag` inherits real signature verification - * rather than bypassing it. - */ -describe("mountWorkbenchSlack signature verification", () => { - test("rejects a request signed with the wrong secret", async () => { - const app = new Hono(); - const deps = baseDeps(); - const { path } = mountWorkbenchSlack(app, deps); - - const body = JSON.stringify({ - type: "event_callback", - team_id: "T1", - event: { - type: "app_mention", - channel: "C1", - user: "U1", - text: "<@BOT1> hello", - ts: "1721800000.000100", - }, - }); - const timestamp = Math.floor(Date.now() / 1000).toString(); - const wrongSecretSignature = - "v0=" + - createHmac("sha256", "not-the-real-secret") - .update(`v0:${timestamp}:${body}`) - .digest("hex"); - - const res = await app.request(path, { - method: "POST", - body, - headers: { - "content-type": "application/json", - "x-slack-request-timestamp": timestamp, - "x-slack-signature": wrongSecretSignature, - }, - }); - - expect(res.status).toBe(401); - }); - - test("rejects a request with no signature headers at all", async () => { - const app = new Hono(); - const { path } = mountWorkbenchSlack(app, baseDeps()); - const res = await app.request(path, { method: "POST", body: "{}" }); - expect(res.status).toBe(401); - }); -}); - -function fakeAuthor(overrides: Partial = {}) { - return { - userId: "U1", - userName: "alice", - fullName: "Alice Example", - isBot: false as const, - email: "alice@example.com", - emailVerified: true as const, - isRestricted: false as const, - ...overrides, - }; -} - -function fakeEvent(overrides: Partial = {}): TagEvent { - return { - platform: "slack", - threadId: "C1:1721800000.000100", - text: "hello there", - author: fakeAuthor(), - isMention: true, - trigger: "mention", - ...overrides, - }; -} - -/** - * Waits for a condition the dispatch resolves before, not with — the reply - * arrives on a later microtask, and how many turns of the loop that takes - * is an implementation detail no assertion should encode. - */ -async function waitFor( - condition: () => boolean, - timeoutMs = 1000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (!condition()) { - if (Date.now() > deadline) { - throw new Error("timed out waiting for the condition"); - } - await new Promise((resolve) => setTimeout(resolve, 1)); - } -} - -function fakeThread(): TagThread & { posts: string[]; subscribed: boolean } { - const posts: string[] = []; - return { - id: "C1:1721800000.000100", - posts, - subscribed: false, - async post(text) { - posts.push(text); - }, - async subscribe() { - this.subscribed = true; - }, - }; -} - -describe("dispatchWorkbenchSlackEvent", () => { - test("a mention auto-provisions the principal, binds the channel, and lands the message in chat", async () => { - const bindings = createInMemorySlackChannelBindingStore(); - let resolvedAuthor: unknown; - let provisioned: - | { tenantId: string; name: string; creatorPrincipalId: string } - | undefined; - let sent: - | { - tenantId: string; - channelId: string; - principalId: string; - text: string; - } - | undefined; - - const deps = baseDeps({ - bindings, - resolvePrincipal: async (author) => { - resolvedAuthor = author; - return { - ok: true, - principal: { - principalId: "prn_1", - tenantId: "tenant_1", - userId: "usr_1", - email: "alice@example.com", - }, - } satisfies PrincipalResolution; - }, - provisionChannel: async (input) => { - provisioned = input; - return { channelId: "workflowRun_new" }; - }, - sendMessage: async (input) => { - sent = input; - return { id: "mail_1" }; - }, - resolveChannelName: async () => "general", - }); - - const thread = fakeThread(); - await dispatchWorkbenchSlackEvent(deps, fakeEvent(), thread); - - expect(resolvedAuthor).toEqual({ - userId: "U1", - email: "alice@example.com", - emailVerified: true, - isRestricted: false, - isBot: false, - }); - expect(provisioned).toEqual({ - tenantId: "tenant_1", - name: "general", - creatorPrincipalId: "prn_1", - }); - expect(sent).toEqual({ - tenantId: "tenant_1", - channelId: "workflowRun_new", - principalId: "prn_1", - text: "hello there", - }); - - const binding = await bindings.getBinding("tenant_1", "C1"); - expect(binding?.channelId).toBe("workflowRun_new"); - expect(thread.subscribed).toBe(true); - }); - - test("a second mention in the same Slack channel reuses the existing binding", async () => { - const bindings = createInMemorySlackChannelBindingStore(); - await bindings.createBinding({ - tenantId: "tenant_1", - slackChannelId: "C1", - channelId: "workflowRun_existing", - }); - - let provisionCalls = 0; - let sentChannelId: string | undefined; - const deps = baseDeps({ - bindings, - provisionChannel: async () => { - provisionCalls += 1; - return { channelId: "workflowRun_should_not_be_used" }; - }, - sendMessage: async (input) => { - sentChannelId = input.channelId; - return { id: "mail_2" }; - }, - }); - - await dispatchWorkbenchSlackEvent(deps, fakeEvent(), fakeThread()); - - expect(provisionCalls).toBe(0); - expect(sentChannelId).toBe("workflowRun_existing"); - }); - - test("an unresolved author is told why, and no channel is provisioned or messaged", async () => { - let provisionCalls = 0; - let sendCalls = 0; - const deps = baseDeps({ - resolvePrincipal: async () => ({ - ok: false, - reason: "restricted_author", - tenantId: undefined, - email: undefined, - }), - provisionChannel: async () => { - provisionCalls += 1; - return { channelId: "should_not_happen" }; - }, - sendMessage: async () => { - sendCalls += 1; - return { id: "should_not_happen" }; - }, - }); - - const thread = fakeThread(); - await dispatchWorkbenchSlackEvent(deps, fakeEvent(), thread); - - expect(provisionCalls).toBe(0); - expect(sendCalls).toBe(0); - expect(thread.posts).toHaveLength(1); - expect(thread.posts[0]).toMatch(/guest and shared-channel/); - }); - - test("relays the agent's reply back to the Slack thread once it lands on the channel stream", async () => { - let capturedOnEvent: - ((event: { type: string; data: unknown }) => void) | undefined; - const deps = baseDeps({ - subscribeToChannel: (_channelId, onEvent) => { - capturedOnEvent = onEvent; - return () => undefined; - }, - sendMessage: async () => { - // Fire the reply once the message "lands" — mirrors the real chat - // orchestrator turning a connector.reply sidecar event into a - // channel event asynchronously after sendMail resolves. - queueMicrotask(() => { - capturedOnEvent?.({ - type: "chat.agent", - data: { - type: "connector.reply", - data: { content: "Hi! How can I help?" }, - }, - }); - }); - return { id: "mail_1" }; - }, - }); - - const thread = fakeThread(); - await dispatchWorkbenchSlackEvent(deps, fakeEvent(), thread); - // The reply lands on a later microtask than the dispatch resolves, so - // wait for the post itself rather than assuming this turn of the loop - // already drained it. - await waitFor(() => thread.posts.length > 0); - - expect(thread.posts).toEqual(["Hi! How can I help?"]); - }); - - test("leaves no reply posted when the wait times out", async () => { - const deps = baseDeps({ replyWaitMs: 10 }); - const thread = fakeThread(); - await dispatchWorkbenchSlackEvent(deps, fakeEvent(), thread); - expect(thread.posts).toEqual([]); - }); - - test("declines a DM-sourced event before resolving a principal or touching chat", async () => { - let resolveCalls = 0; - let provisionCalls = 0; - let sendCalls = 0; - const deps = baseDeps({ - resolvePrincipal: async () => { - resolveCalls += 1; - return { - ok: true, - principal: { - principalId: "prn_1", - tenantId: "tenant_1", - userId: "usr_1", - email: "alice@example.com", - }, - }; - }, - provisionChannel: async () => { - provisionCalls += 1; - return { channelId: "should_not_happen" }; - }, - sendMessage: async () => { - sendCalls += 1; - return { id: "should_not_happen" }; - }, - }); - - const thread = fakeThread(); - const event = fakeEvent({ threadId: "D1:1721800000.000100" }); - await dispatchWorkbenchSlackEvent(deps, event, thread); - - expect(resolveCalls).toBe(0); - expect(provisionCalls).toBe(0); - expect(sendCalls).toBe(0); - expect(thread.posts).toHaveLength(1); - expect(thread.posts[0]).toMatch(/DM/); - }); -}); diff --git a/packages/slack-tag/src/dispatch.ts b/packages/slack-tag/src/dispatch.ts deleted file mode 100644 index f11724aae5..0000000000 --- a/packages/slack-tag/src/dispatch.ts +++ /dev/null @@ -1,234 +0,0 @@ -/** - * `mountWorkbenchSlack` — workbench's Slack mount composition. - * - * Wires `corbits-tag/slack`'s `mountSlackTag` together with this - * package's principal resolver (`./principal-resolver.ts`) and channel - * binding (`./channel-binding.ts`) into one dispatch flow: - * - * 1. A Slack mention or thread message arrives; `corbits-tag/slack` - * verifies the request signature and normalizes it to a `TagEvent`. - * 2. The author resolves to an Interchange principal (auto-provisioned - * on first contact — see `./principal-resolver.ts`). - * 3. The Slack channel resolves-or-creates its bound workbench channel - * (`./channel-binding.ts`) — the channel host and its agent are - * launched by `deps.provisionChannel`, injected from the host's own - * `@corbits/chat` composition, never reimplemented here. - * 4. The message is posted into that channel through `deps.sendMessage` - * — the SAME path a human's message from the web UI takes - * (`sendWorkbenchMessage` in `packages/chat/src/workbench-service.ts`). - * The channel's existing agent host generates the reply; this - * package never deploys or drives inference itself. - * 5. The reply is relayed back to Slack via `thread.post` once it - * lands on the channel's event stream (`./reply-wait.ts`), or the - * thinking-indicator placeholder is left in place to be retracted - * by `corbits-tag/slack` if no reply lands within the wait window. - * - * Security posture, unchanged from `corbits-tag/slack`: this route mounts - * OUTSIDE the host's session auth — Slack is not a principal. Signature - * verification (`SLACK_SIGNING_SECRET`) is the only thing authenticated - * before this dispatch runs; everything past that is this package's - * policy, starting with principal resolution. - */ -import type { Hono } from "hono"; -import { - mountSlackTag, - type MountedSlackTag, - type TagEvent, - type TagThread, -} from "corbits-tag/slack"; -import { - UNRESOLVED_MESSAGE, - type AuthorLookup, - type PrincipalResolver, -} from "corbits-tag/interchange"; -import type { StateAdapter } from "chat"; -import { getLogger } from "@intx/log"; - -import { parseSlackCredentials } from "./config"; -import { - resolveOrCreateChannelBinding, - slackChannelIdFromThreadId, - type ProvisionChannel, -} from "./channel-binding"; -import type { SlackChannelBindingStore } from "./store"; -import { waitForReply, type SubscribeToChannel } from "./reply-wait"; -import { - createSlackChannelNameResolver, - type ResolveSlackChannelName, -} from "./slack-channel-name"; - -const log = getLogger(["slack-tag", "dispatch"]); - -const DEFAULT_BOT_NAME = "workbench"; -const DEFAULT_REPLY_WAIT_MS = 60_000; - -/** - * Belt-and-suspenders against a DM ever reaching dispatch: the manifest no - * longer requests `im:history`/`mpim:history` or subscribes to - * `message.im`/`message.mpim` (channel trust is the whole authz model here, - * and a DM has no bench owner who chose to install the app there), but a - * misconfigured Slack app or a stale event from Slack must not be trusted - * to honor that. Slack channel ids are typed by their leading letter — `D` - * is always a direct message. - */ -const DM_DECLINED_MESSAGE = - "I only work in channels the bench owner has added me to — DMs aren't supported."; - -function isDirectMessageChannel(slackChannelId: string): boolean { - return slackChannelId.startsWith("D"); -} - -export type SendMessage = (input: { - readonly tenantId: string; - readonly channelId: string; - readonly principalId: string; - readonly text: string; -}) => Promise<{ readonly id: string }>; - -export type MountWorkbenchSlackDeps = { - /** The workbench tenant (bench) this Slack app's messages land in. One - * Slack app install maps to exactly one tenant — see `./schema.ts`'s - * header comment on why that is also the tenant-isolation boundary. */ - readonly tenantId: string; - readonly slack: { readonly botToken: string; readonly signingSecret: string }; - readonly botName?: string; - /** Chat SDK state backend (subscriptions/dedup/locks) — see - * `./thread-state.ts`'s `resolveThreadState` for the - * connect-or-fall-back helper hosts are expected to call before - * passing this in. */ - readonly state: StateAdapter; - readonly bindings: SlackChannelBindingStore; - /** Resolves a Slack author to an auto-provisioned Interchange - * principal — build with `createAutoProvisionPrincipalResolver`. */ - readonly resolvePrincipal: PrincipalResolver; - /** The host's real channel-creation path (tenancy mint + host launch + - * agent join) — never reimplemented in this package. */ - readonly provisionChannel: ProvisionChannel; - /** Posts a message into a channel through the existing chat platform — - * the same path a human's web-UI message takes. */ - readonly sendMessage: SendMessage; - /** Subscribes to a channel's live event stream — the same seam - * `packages/chat/src/channel-events.ts`'s SSE bridge uses. */ - readonly subscribeToChannel: SubscribeToChannel; - /** How long to wait for the agent's reply before leaving the - * thinking-indicator placeholder for `corbits-tag/slack` to retract. - * Default 60s. */ - readonly replyWaitMs?: number; - readonly path?: string; - /** Titles a freshly provisioned channel after the Slack channel's - * display name. Defaults to `createSlackChannelNameResolver(deps.slack.botToken)`; - * inject a fake in tests rather than mocking `fetch`. */ - readonly resolveChannelName?: ResolveSlackChannelName; -}; - -/** - * The Slack `TagAuthor` shape carries every field `corbits-tag/interchange`'s - * `AuthorIdentity` needs (`userId`, `email`, `emailVerified`, `isRestricted`, - * `isBot`) — see `corbits-tag/interchange`'s own `principal.ts` doc comment. - * `email` is merely optional (`email?: string`) on `TagAuthor` where - * `AuthorIdentity` requires the key present (`string | undefined`), so this - * copies the fields across explicitly rather than passing `event.author` - * through as-is. - */ -function authorIdentityFrom(author: TagEvent["author"]): AuthorLookup { - return { - userId: author.userId, - email: author.email, - emailVerified: author.emailVerified, - isRestricted: author.isRestricted, - isBot: author.isBot, - }; -} - -export async function dispatchWorkbenchSlackEvent( - deps: MountWorkbenchSlackDeps, - event: TagEvent, - thread: TagThread, -): Promise { - const slackChannelId = slackChannelIdFromThreadId(event.threadId); - if (slackChannelId === undefined) { - log.error( - "Could not recover a Slack channel id from thread id {threadId}", - { threadId: event.threadId }, - ); - return; - } - if (isDirectMessageChannel(slackChannelId)) { - await thread.post(DM_DECLINED_MESSAGE); - return; - } - - const resolution = await deps.resolvePrincipal( - authorIdentityFrom(event.author), - ); - if (!resolution.ok) { - await thread.post(UNRESOLVED_MESSAGE[resolution.reason]); - return; - } - - const existingBinding = await deps.bindings.getBinding( - deps.tenantId, - slackChannelId, - ); - const slackChannelName = - existingBinding !== undefined - ? slackChannelId - : await ( - deps.resolveChannelName ?? - createSlackChannelNameResolver(deps.slack.botToken) - )(slackChannelId); - - const binding = await resolveOrCreateChannelBinding( - { bindings: deps.bindings, provisionChannel: deps.provisionChannel }, - { - tenantId: deps.tenantId, - slackChannelId, - slackChannelName, - principalId: resolution.principal.principalId, - }, - ); - - await thread.subscribe(); - - const replyWaitMs = deps.replyWaitMs ?? DEFAULT_REPLY_WAIT_MS; - const pendingReply = waitForReply( - deps.subscribeToChannel, - binding.channelId, - replyWaitMs, - ); - - await deps.sendMessage({ - tenantId: deps.tenantId, - channelId: binding.channelId, - principalId: resolution.principal.principalId, - text: event.text, - }); - - const reply = await pendingReply; - if (reply !== undefined) { - await thread.post(reply); - } -} - -export function mountWorkbenchSlack( - app: Hono, - deps: MountWorkbenchSlackDeps, -): MountedSlackTag { - const credentials = parseSlackCredentials(deps.slack); - - const base = { - userName: deps.botName ?? DEFAULT_BOT_NAME, - state: deps.state, - slack: credentials, - }; - const withPath = - deps.path !== undefined ? { ...base, path: deps.path } : base; - - return mountSlackTag(app, { - ...withPath, - thinkingIndicator: true, - onTag: (event, thread) => dispatchWorkbenchSlackEvent(deps, event, thread), - onThreadMessage: (event, thread) => - dispatchWorkbenchSlackEvent(deps, event, thread), - }); -} diff --git a/packages/slack-tag/src/index.ts b/packages/slack-tag/src/index.ts deleted file mode 100644 index 9fb82ab7b9..0000000000 --- a/packages/slack-tag/src/index.ts +++ /dev/null @@ -1,41 +0,0 @@ -export { createAutoProvisionPrincipalResolver } from "./principal-resolver"; -export { resolveThreadState } from "./thread-state"; -export { - resolveOrCreateChannelBinding, - slackChannelIdFromThreadId, - type ProvisionChannel, - type ResolveOrCreateChannelBindingDeps, - type ResolveOrCreateChannelBindingInput, -} from "./channel-binding"; -export { - createDrizzleSlackChannelBindingStore, - createInMemorySlackChannelBindingStore, - type CreateSlackChannelBindingInput, - type SlackChannelBinding, - type SlackChannelBindingStore, - type SlackTagDb, -} from "./store"; -export { slackChannelBinding, slackTagSchema } from "./schema"; -export type { SlackChannelBindingRow } from "./schema"; -export { - applySlackTagMigrations, - slackTagMigrations, - type ApplySlackTagMigrationsReport, - type SlackTagMigration, -} from "./migrations"; -export { - mountWorkbenchSlack, - dispatchWorkbenchSlackEvent, - type MountWorkbenchSlackDeps, - type SendMessage, -} from "./dispatch"; -export { waitForReply, type SubscribeToChannel } from "./reply-wait"; -export { - createSlackChannelNameResolver, - type ResolveSlackChannelName, -} from "./slack-channel-name"; -export { - parseSlackCredentials, - SlackCredentials, - type SlackCredentialsT, -} from "./config"; diff --git a/packages/slack-tag/src/manifest.test.ts b/packages/slack-tag/src/manifest.test.ts deleted file mode 100644 index e669db07fe..0000000000 --- a/packages/slack-tag/src/manifest.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - renderSlackAppManifest, - validateWorkbenchPublicOrigin, -} from "./manifest"; - -describe("validateWorkbenchPublicOrigin", () => { - test("accepts a plain https origin", () => { - expect(validateWorkbenchPublicOrigin("https://bench.example.com")).toBe( - "https://bench.example.com", - ); - }); - - test("rejects http", () => { - expect(() => - validateWorkbenchPublicOrigin("http://bench.example.com"), - ).toThrow(/https/); - }); - - test("rejects a trailing slash", () => { - expect(() => - validateWorkbenchPublicOrigin("https://bench.example.com/"), - ).toThrow(/trailing slash|slash/); - }); - - test("rejects a path", () => { - expect(() => - validateWorkbenchPublicOrigin("https://bench.example.com/slack"), - ).toThrow(/no path/); - }); - - test("rejects an empty value", () => { - expect(() => validateWorkbenchPublicOrigin(" ")).toThrow(/empty/); - }); -}); - -describe("renderSlackAppManifest", () => { - test("substitutes the origin into both request URLs", () => { - const rendered = renderSlackAppManifest("https://bench.example.com"); - expect(rendered).toContain( - "https://bench.example.com/api/tag/slack/webhook", - ); - expect(rendered).not.toContain("${WORKBENCH_PUBLIC_ORIGIN}"); - }); - - test("declares the bot scopes and event subscriptions workbench needs", () => { - const rendered = renderSlackAppManifest("https://bench.example.com"); - expect(rendered).toContain("app_mention"); - expect(rendered).toContain("chat:write"); - }); -}); diff --git a/packages/slack-tag/src/manifest.ts b/packages/slack-tag/src/manifest.ts deleted file mode 100644 index 16cb6372c7..0000000000 --- a/packages/slack-tag/src/manifest.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Slack app manifest template, rendered for one deployment origin. - * - * Follows the `scout/slack/render-manifest.ts` pattern: the manifest - * keeps `${WORKBENCH_PUBLIC_ORIGIN}` as a placeholder so no deployment - * URL is committed, and `renderSlackAppManifest` is the only thing that - * resolves it. Render once per Slack app — a local app and a deployed - * app must be separate Slack apps, since a manifest names exactly one - * request URL. - * - * Lives in `@corbits/slack-tag` (not `apps/hub`) per AGENTS.md: apps - * stay generic, packages own the domain, and this template is entirely - * about what workbench's Slack app needs — no operator-specific detail. - */ -const WEBHOOK_PATH = "/api/tag/slack/webhook"; -const PLACEHOLDER = "${WORKBENCH_PUBLIC_ORIGIN}"; - -export const SLACK_APP_MANIFEST_TEMPLATE = ` -display_information: - name: Workbench - description: Talk to your workbench agents from Slack. - background_color: "#1a1a1a" -features: - bot_user: - display_name: workbench - always_online: true -oauth_config: - scopes: - bot: - - app_mentions:read - - channels:history - - chat:write - - groups:history - - users:read - - users:read.email -settings: - event_subscriptions: - request_url: ${PLACEHOLDER}${WEBHOOK_PATH} - bot_events: - - app_mention - - message.channels - - message.groups - interactivity: - is_enabled: true - request_url: ${PLACEHOLDER}${WEBHOOK_PATH} - org_deploy_enabled: false - socket_mode_enabled: false - token_rotation_enabled: false -`; - -/** - * Slack rejects a request URL that is not https, and silently - * misbehaves on a trailing slash (the rendered path would contain - * `//`). Fail loudly here rather than after a human has pasted a - * broken manifest into api.slack.com. - */ -export function validateWorkbenchPublicOrigin(raw: string): string { - const origin = raw.trim(); - if (origin.length === 0) { - throw new Error("WORKBENCH_PUBLIC_ORIGIN is empty"); - } - if (origin.endsWith("/")) { - throw new Error( - `WORKBENCH_PUBLIC_ORIGIN must not end with a slash (got ${origin}) — ` + - "the webhook path is appended directly", - ); - } - - let parsed: URL; - try { - parsed = new URL(origin); - } catch { - throw new Error(`WORKBENCH_PUBLIC_ORIGIN is not a valid URL: ${origin}`); - } - if (parsed.protocol !== "https:") { - throw new Error( - `WORKBENCH_PUBLIC_ORIGIN must be https (got ${parsed.protocol}) — ` + - "Slack refuses to deliver events over http", - ); - } - if (parsed.pathname !== "/") { - throw new Error( - `WORKBENCH_PUBLIC_ORIGIN must be an origin with no path (got path ${parsed.pathname})`, - ); - } - if (parsed.search !== "" || parsed.hash !== "") { - throw new Error( - `WORKBENCH_PUBLIC_ORIGIN must have no query or fragment (got ${parsed.search}${parsed.hash})`, - ); - } - return `${parsed.protocol}//${parsed.host}`; -} - -export function renderSlackAppManifest(origin: string): string { - const validated = validateWorkbenchPublicOrigin(origin); - return SLACK_APP_MANIFEST_TEMPLATE.replaceAll(PLACEHOLDER, validated).trim(); -} diff --git a/packages/slack-tag/src/migrations.ts b/packages/slack-tag/src/migrations.ts deleted file mode 100644 index 63ae25224b..0000000000 --- a/packages/slack-tag/src/migrations.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Package-owned migrations for @corbits/slack-tag's one product table. -// The platform's own schema is authored and applied by @intx/db (see -// scripts/db-setup.ts); this module is the package's half of the -// install story — mount + migrations is the entire install story, -// mirroring `@corbits/webhook-triggers`'s `migrations.ts`. Bookkeeping -// is deliberately its own table, never the platform's drizzle journal, -// so this package's migration history stays extractable on its own. -import postgres from "postgres"; - -export interface SlackTagMigration { - name: string; - sql: string; -} - -/** - * Ordered, explicit migration set for the table declared in - * `./schema.ts`. Kept as literal SQL (rather than generated by drizzle - * at apply-time) so the applied statements are exactly what is - * reviewed here — no hidden codegen step between this file and the - * database. - */ -export const slackTagMigrations: readonly SlackTagMigration[] = [ - { - name: "0001_slack_channel_binding", - sql: ` - CREATE TABLE IF NOT EXISTS "slack_tag"."slack_channel_binding" ( - "id" text PRIMARY KEY, - "tenant_id" text NOT NULL, - "slack_channel_id" text NOT NULL, - "channel_id" text NOT NULL, - "created_at" timestamptz NOT NULL DEFAULT now(), - CONSTRAINT "slack_channel_binding_tenant_channel_key" - UNIQUE ("tenant_id", "slack_channel_id") - ); - `, - }, -]; - -// Bookkeeping table for this package's own migrations. Named -// distinctly from the platform's setup ledger and from any drizzle -// journal, so extracting `@corbits/slack-tag` out of this repo never -// has to disentangle its history from the platform's. Lives in the -// package's own `slack_tag` schema, like the table it owns. -const SCHEMA = "slack_tag"; -const LEDGER_TABLE = "slack_tag_migrations"; - -function quoteIdentifier(name: string): string { - return `"${name.replace(/"/g, '""')}"`; -} - -function quoteQualified(schema: string, name: string): string { - return `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`; -} - -export interface ApplySlackTagMigrationsReport { - applied: string[]; - alreadyApplied: string[]; -} - -/** - * Apply `slackTagMigrations` against `databaseUrl`, idempotently: a - * migration already recorded in the ledger is skipped, never re-run. - * Failures are loud — the migration name and the underlying error are - * both surfaced, since a partial apply here would otherwise fail - * silently at the next `workbench setup`. - */ -export async function applySlackTagMigrations( - databaseUrl: string, -): Promise { - const sql = postgres(databaseUrl, { max: 1, onnotice: () => undefined }); - try { - await sql.unsafe(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(SCHEMA)}`); - - await sql.unsafe( - `CREATE TABLE IF NOT EXISTS ${quoteQualified(SCHEMA, LEDGER_TABLE)} (` + - `name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`, - ); - const rows = await sql.unsafe( - `SELECT name FROM ${quoteQualified(SCHEMA, LEDGER_TABLE)}`, - ); - const alreadyApplied = new Set(rows.map((row) => String(row["name"]))); - const applied: string[] = []; - for (const migration of slackTagMigrations) { - if (alreadyApplied.has(migration.name)) continue; - try { - await sql.begin(async (tx) => { - await tx.unsafe(migration.sql); - await tx.unsafe( - `INSERT INTO ${quoteQualified(SCHEMA, LEDGER_TABLE)} (name) VALUES ($1)`, - [migration.name], - ); - }); - applied.push(migration.name); - } catch (error) { - throw new Error( - `@corbits/slack-tag migration ${JSON.stringify(migration.name)} failed: ` + - `${error instanceof Error ? error.message : String(error)}`, - { cause: error }, - ); - } - } - return { applied, alreadyApplied: [...alreadyApplied] }; - } finally { - await sql.end(); - } -} diff --git a/packages/slack-tag/src/principal-resolver.test.ts b/packages/slack-tag/src/principal-resolver.test.ts deleted file mode 100644 index 769ad384a4..0000000000 --- a/packages/slack-tag/src/principal-resolver.test.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { describe, expect, test, beforeEach, mock } from "bun:test"; -import { UNRESOLVED_MESSAGE } from "corbits-tag/interchange"; -import type { UnresolvedReason } from "corbits-tag/interchange"; - -// createAutoProvisionPrincipalResolver's whole job is a policy decision on -// top of a read-only base resolver: which non-ok findings get a second -// chance via auto-provisioning, and which get declined outright. These -// tests drive that decision from the base resolver's every documented -// `UnresolvedReason`, mocking `corbits-tag/interchange` so each case is -// exercised without a real database. - -type StubResolution = - | { - ok: true; - principal: { - principalId: string; - tenantId: string; - userId: string; - email: string; - }; - } - | { - ok: false; - reason: UnresolvedReason; - tenantId: string | undefined; - email: string | undefined; - }; - -type ProvisionCall = { - tenantId: string; - email: string; - name: string; - roles?: readonly string[]; -}; - -let nextResolution: StubResolution; -let provisionCalls: ProvisionCall[]; -let provisionShouldThrow: boolean; -let tenantLookupResult: { id: string; slug: string } | undefined; - -// bun's mock.module replaces the module for EVERY later importer in the -// same test-run process, across files — so this mock must carry every -// export a sibling module under test reads (dispatch.ts imports -// UNRESOLVED_MESSAGE), not only the two these tests stub. The real value -// is captured by the import above before the mock takes effect. -mock.module("corbits-tag/interchange", () => ({ - UNRESOLVED_MESSAGE, - createPrincipalResolver: () => async () => nextResolution, - provisionPrincipal: async (_db: unknown, input: ProvisionCall) => { - provisionCalls.push(input); - if (provisionShouldThrow) throw new Error("provisioning boom"); - return { - principalId: "prn_auto", - tenantId: input.tenantId, - userId: "usr_auto", - email: input.email, - }; - }, -})); - -const fakeDb = { - query: { - tenant: { - findFirst: async () => tenantLookupResult, - }, - }, -} as never; - -beforeEach(() => { - provisionCalls = []; - provisionShouldThrow = false; - tenantLookupResult = { id: "tenant_1", slug: "acme" }; -}); - -async function resolver(roleNames: readonly string[] = ["member"]) { - const { createAutoProvisionPrincipalResolver } = - await import("./principal-resolver"); - return createAutoProvisionPrincipalResolver(fakeDb, "acme", roleNames); -} - -describe("createAutoProvisionPrincipalResolver", () => { - test("passes an already-ok resolution straight through", async () => { - nextResolution = { - ok: true, - principal: { - principalId: "prn_x", - tenantId: "tenant_1", - userId: "usr_x", - email: "known@example.com", - }, - }; - - const resolve = await resolver(); - const result = await resolve({ - userId: "U1", - email: "known@example.com", - emailVerified: true, - isRestricted: false, - isBot: false, - }); - - expect(result).toEqual(nextResolution); - expect(provisionCalls).toHaveLength(0); - }); - - test("declines a bot author without provisioning", async () => { - nextResolution = { - ok: false, - reason: "bot_author", - tenantId: "tenant_1", - email: undefined, - }; - - const resolve = await resolver(); - const result = await resolve({ - userId: "B1", - email: undefined, - emailVerified: "unknown", - isRestricted: false, - isBot: true, - }); - - expect(provisionCalls).toHaveLength(0); - expect(result).toEqual(nextResolution); - }); - - test("declines a restricted (guest / shared-channel) Slack author without provisioning", async () => { - nextResolution = { - ok: false, - reason: "restricted_author", - tenantId: "tenant_1", - email: "guest@example.com", - }; - - const resolve = await resolver(); - const result = await resolve({ - userId: "U_GUEST", - email: "guest@example.com", - emailVerified: true, - isRestricted: true, - isBot: false, - }); - - expect(provisionCalls).toHaveLength(0); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.reason).toBe("restricted_author"); - }); - - const declinedReasons: UnresolvedReason[] = [ - "lookup_failed", - "not_a_member", - "principal_inactive", - "tenant_not_found", - ]; - for (const reason of declinedReasons) { - test(`declines "${reason}" without provisioning`, async () => { - nextResolution = { - ok: false, - reason, - tenantId: "tenant_1", - email: "person@example.com", - }; - - const resolve = await resolver(); - const result = await resolve( - reason === "lookup_failed" - ? null - : { - userId: "U1", - email: "person@example.com", - emailVerified: true, - isRestricted: false, - isBot: false, - }, - ); - - expect(provisionCalls).toHaveLength(0); - expect(result).toEqual(nextResolution); - }); - } - - test("auto-provisions on first contact when the base resolver reports no_account", async () => { - nextResolution = { - ok: false, - reason: "no_account", - tenantId: "tenant_1", - email: "newperson@example.com", - }; - - const resolve = await resolver(["member"]); - const result = await resolve({ - userId: "U2", - email: "newperson@example.com", - emailVerified: true, - isRestricted: false, - isBot: false, - }); - - expect(provisionCalls).toEqual([ - { - tenantId: "tenant_1", - email: "newperson@example.com", - name: "U2", - roles: ["member"], - }, - ]); - expect(result.ok).toBe(true); - if (result.ok) expect(result.principal.email).toBe("newperson@example.com"); - }); - - test("auto-provisions with a synthetic email when the author has no readable email", async () => { - nextResolution = { - ok: false, - reason: "no_email", - tenantId: "tenant_1", - email: undefined, - }; - - const resolve = await resolver(); - const result = await resolve({ - userId: "U9", - email: undefined, - emailVerified: "unknown", - isRestricted: false, - isBot: false, - }); - - expect(provisionCalls).toHaveLength(1); - expect(provisionCalls[0]?.email).toBe("slack-u9@acme.localhost"); - expect(result.ok).toBe(true); - }); - - test("declines without provisioning when the wrapper's own tenant lookup comes up empty", async () => { - nextResolution = { - ok: false, - reason: "no_account", - tenantId: "tenant_1", - email: "person@example.com", - }; - tenantLookupResult = undefined; - - const resolve = await resolver(); - const result = await resolve({ - userId: "U1", - email: "person@example.com", - emailVerified: true, - isRestricted: false, - isBot: false, - }); - - expect(provisionCalls).toHaveLength(0); - expect(result).toEqual(nextResolution); - }); - - test("falls back to the original resolution when provisioning itself fails", async () => { - nextResolution = { - ok: false, - reason: "no_account", - tenantId: "tenant_1", - email: "person@example.com", - }; - provisionShouldThrow = true; - - const resolve = await resolver(); - const result = await resolve({ - userId: "U1", - email: "person@example.com", - emailVerified: true, - isRestricted: false, - isBot: false, - }); - - expect(provisionCalls).toHaveLength(1); - expect(result).toEqual(nextResolution); - }); -}); diff --git a/packages/slack-tag/src/principal-resolver.ts b/packages/slack-tag/src/principal-resolver.ts deleted file mode 100644 index acedc8b287..0000000000 --- a/packages/slack-tag/src/principal-resolver.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Auto-provisioning principal resolver for workbench's Slack mount. - * - * Lifted from the `createAutoProvisionPrincipalResolver` pattern in - * scout's `packages/agent-dock/src/tag-mount.ts` (CL-4973) — the - * composition, not the product: `roleNames` is caller-supplied here - * rather than a hardcoded Scout role, and every read/write below rides - * `corbits-tag/interchange`'s own `createPrincipalResolver`/ - * `provisionPrincipal`, which are already product-agnostic — this - * function only owns the auto-provision-on-first-contact POLICY on top - * of them. - * - * Channel trust: being in the Slack channel IS the authorization. The - * Slack app is only installed into channels the bench owner chooses, so - * anyone who can reach the bot was let in by a workspace admin. Resolve - * known authors normally; auto-provision a principal only for a true - * first contact — the base resolver found no readable email (`no_email`, - * covered by the synthetic-address fallback below) or no account at all - * (`no_account`). Every other non-ok reason is declined rather than - * auto-provisioned: `bot_author` and `restricted_author` (Slack guest / - * shared-channel accounts) are not eligible identities at all; - * `not_a_member` and `principal_inactive` mean a principal record already - * exists and something about it was deliberately withheld or deactivated, - * which a Slack mention must not silently override; `tenant_not_found` - * and `lookup_failed` are infrastructure/config problems, not something - * provisioning can fix. The role(s) named in `roleNames` must already - * exist in the tenant — `provisionPrincipal` throws on an unknown one, so - * callers pass roles only once boot has created them. - */ -import { eq } from "drizzle-orm"; -import type { DB } from "@intx/db"; -import { schema } from "@intx/db"; -import { - createPrincipalResolver, - provisionPrincipal, - type PrincipalResolution, - type PrincipalResolver, - type UnresolvedReason, -} from "corbits-tag/interchange"; -import { getLogger } from "@intx/log"; - -const log = getLogger(["slack-tag", "principal-resolver"]); - -const AUTO_PROVISIONABLE_REASONS: ReadonlySet = new Set([ - "no_email", - "no_account", -]); - -export function createAutoProvisionPrincipalResolver( - db: DB["db"], - tenantSlug: string, - roleNames: readonly string[] = [], -): PrincipalResolver { - const baseResolver = createPrincipalResolver({ db, tenantSlug }); - return async (author): Promise => { - const resolution = await baseResolver(author); - if (resolution.ok) return resolution; - if (!AUTO_PROVISIONABLE_REASONS.has(resolution.reason)) return resolution; - if (author === null) return resolution; - - const tenant = await db.query.tenant.findFirst({ - where: eq(schema.tenant.slug, tenantSlug), - }); - if (tenant === undefined) return resolution; - - // No readable email (restricted profile or missing users:read.email - // scope) must not lock a channel member out: fall back to a stable - // synthetic address derived from the Slack user id. - const email = - author.email !== undefined && author.email.trim() !== "" - ? author.email - : `slack-${author.userId.toLowerCase()}@${tenantSlug}.localhost`; - const name = author.userId; - - try { - const principal = await provisionPrincipal(db, { - tenantId: tenant.id, - email, - name, - roles: roleNames, - }); - log.info("Provisioned principal for {email} on first contact", { email }); - return { ok: true, principal }; - } catch (cause) { - log.error("Auto-provision failed for {email}: {error}", { - email, - error: cause instanceof Error ? cause.message : String(cause), - }); - return resolution; - } - }; -} diff --git a/packages/slack-tag/src/reply-wait.test.ts b/packages/slack-tag/src/reply-wait.test.ts deleted file mode 100644 index a79ad75b80..0000000000 --- a/packages/slack-tag/src/reply-wait.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { waitForReply, type SubscribeToChannel } from "./reply-wait"; - -describe("waitForReply", () => { - test("resolves with the reply text from a chat.agent connector.reply event", async () => { - let handler: ((event: unknown) => void) | undefined; - const subscribe: SubscribeToChannel = (_channelId, onEvent) => { - handler = onEvent as (event: unknown) => void; - return () => { - handler = undefined; - }; - }; - - const pending = waitForReply(subscribe, "channel_1", 1000); - handler?.({ - type: "chat.agent", - data: { - type: "connector.reply", - data: { content: "Hello from the agent" }, - }, - }); - - expect(await pending).toBe("Hello from the agent"); - }); - - test("ignores non-reply events on the same stream", async () => { - let handler: ((event: unknown) => void) | undefined; - const subscribe: SubscribeToChannel = (_channelId, onEvent) => { - handler = onEvent as (event: unknown) => void; - return () => undefined; - }; - - const pending = waitForReply(subscribe, "channel_1", 200); - handler?.({ type: "chat.agent", data: { type: "reactor.gate.blocked" } }); - handler?.({ type: "chat.settings", data: {} }); - - expect(await pending).toBeUndefined(); - }); - - test("resolves with undefined after the timeout when no reply lands", async () => { - const subscribe: SubscribeToChannel = () => () => undefined; - const result = await waitForReply(subscribe, "channel_1", 10); - expect(result).toBeUndefined(); - }); - - test("unsubscribes once a reply resolves the wait", async () => { - let unsubscribed = false; - let handler: ((event: unknown) => void) | undefined; - const subscribe: SubscribeToChannel = (_channelId, onEvent) => { - handler = onEvent as (event: unknown) => void; - return () => { - unsubscribed = true; - }; - }; - - const pending = waitForReply(subscribe, "channel_1", 1000); - handler?.({ - type: "chat.agent", - data: { type: "connector.reply", data: { content: "done" } }, - }); - await pending; - - expect(unsubscribed).toBe(true); - }); -}); diff --git a/packages/slack-tag/src/reply-wait.ts b/packages/slack-tag/src/reply-wait.ts deleted file mode 100644 index 0a5f9d1f77..0000000000 --- a/packages/slack-tag/src/reply-wait.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Waits for the channel host's next reply after a message is posted - * into a bound channel, so it can be relayed back to Slack via - * `TagThread.post`. - * - * Mirrors `connectorReplyContent` in `packages/chat/src/chat-orchestrator.ts` - * exactly (that function is not exported — this is the same recognized - * shape, re-checked here rather than duplicated by accident): the chat - * platform's `subscribeToChannel` (see `packages/chat/src/platform-adapter.ts`) - * wraps every sidecar event for the channel's agent as - * `{ type: "chat.agent", data: event }`, and a reply is the - * `"connector.reply"` event carrying `data.content`. Every other event - * on the stream (tool calls, gate-blocked, etc.) is ignored here — this - * is a reply *wait*, not a general event bridge. - */ -import type { ChatWorkbenchEvent } from "@corbits/chat"; - -export type SubscribeToChannel = ( - channelId: string, - onEvent: (event: ChatWorkbenchEvent) => void, -) => () => void; - -function connectorReplyContent(event: unknown): string | undefined { - if ( - typeof event !== "object" || - event === null || - (event as { type?: unknown }).type !== "connector.reply" - ) { - return undefined; - } - const content = (event as { data?: { content?: unknown } }).data?.content; - return typeof content === "string" && content !== "" ? content : undefined; -} - -function replyTextFrom(channelEvent: ChatWorkbenchEvent): string | undefined { - if (channelEvent.type !== "chat.agent") return undefined; - return connectorReplyContent(channelEvent.data); -} - -/** - * Resolves with the first reply text observed on `channelId` within - * `timeoutMs`, or `undefined` on timeout — a timeout is not an error: - * the agent may still reply later through the channel's own normal - * delivery, this call only owns relaying a reply back to Slack - * synchronously with the mention that triggered it. - */ -export function waitForReply( - subscribe: SubscribeToChannel, - channelId: string, - timeoutMs: number, -): Promise { - return new Promise((resolve) => { - let settled = false; - const finish = (result: string | undefined) => { - if (settled) return; - settled = true; - clearTimeout(timer); - unsubscribe(); - resolve(result); - }; - const timer = setTimeout(() => finish(undefined), timeoutMs); - const unsubscribe = subscribe(channelId, (event) => { - const text = replyTextFrom(event); - if (text !== undefined) finish(text); - }); - }); -} diff --git a/packages/slack-tag/src/schema.ts b/packages/slack-tag/src/schema.ts deleted file mode 100644 index 0bd0209552..0000000000 --- a/packages/slack-tag/src/schema.ts +++ /dev/null @@ -1,38 +0,0 @@ -// The one product table `@corbits/slack-tag` owns: which workbench -// channel a Slack channel is bound to. Lives in its own `slack_tag` -// Postgres schema, fully siloed from the platform's `public` schema — -// see docs/package-migrations.md. -// -// Keyed by (tenant_id, slack_channel_id), not by a Slack team/workspace -// id: `corbits-tag`'s `TagEvent` carries no team id (one Slack app -// install — one bot token — is mounted against exactly one workbench -// tenant, matching the "being in the channel IS the authorization" -// trust model documented in `../src/principal-resolver.ts`), so the -// tenant a mount is configured for is itself the isolation boundary -// between two Slack workspaces. Two mounts configured with different -// `tenantId`s can never resolve each other's bindings even if their -// Slack channel ids happened to collide. -import { pgSchema, text, timestamp, unique } from "drizzle-orm/pg-core"; - -export const slackTagSchema = pgSchema("slack_tag"); - -export const slackChannelBinding = slackTagSchema.table( - "slack_channel_binding", - { - id: text("id").primaryKey(), - tenantId: text("tenant_id").notNull(), - slackChannelId: text("slack_channel_id").notNull(), - channelId: text("channel_id").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (table) => [ - unique("slack_channel_binding_tenant_channel_key").on( - table.tenantId, - table.slackChannelId, - ), - ], -); - -export type SlackChannelBindingRow = typeof slackChannelBinding.$inferSelect; diff --git a/packages/slack-tag/src/slack-channel-name.ts b/packages/slack-tag/src/slack-channel-name.ts deleted file mode 100644 index db7433c8bd..0000000000 --- a/packages/slack-tag/src/slack-channel-name.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Resolves a Slack channel id to its display name via Slack's Web API - * `conversations.info`, so a freshly provisioned workbench channel can - * be titled after the Slack channel it is bound to. `TagEvent` carries - * no channel-name field (see `./channel-binding.ts`'s doc comment on - * `threadId`), so this is a small, direct Slack Web API call rather - * than something `corbits-tag/slack` already surfaces. - * - * Fails soft: a lookup failure (rate limit, missing scope, network - * error) falls back to the raw Slack channel id rather than blocking - * channel provisioning on a cosmetic title. - */ -import { getLogger } from "@intx/log"; - -const log = getLogger(["slack-tag", "channel-name"]); - -const CONVERSATIONS_INFO_URL = "https://slack.com/api/conversations.info"; - -export type ResolveSlackChannelName = ( - slackChannelId: string, -) => Promise; - -export function createSlackChannelNameResolver( - botToken: string, -): ResolveSlackChannelName { - return async (slackChannelId) => { - try { - const url = new URL(CONVERSATIONS_INFO_URL); - url.searchParams.set("channel", slackChannelId); - const response = await fetch(url, { - headers: { authorization: `Bearer ${botToken}` }, - }); - const body: unknown = await response.json(); - const name = (body as { ok?: boolean; channel?: { name?: string } }) - .channel?.name; - if ( - (body as { ok?: boolean }).ok !== true || - typeof name !== "string" || - name === "" - ) { - log.warn("conversations.info returned no usable name for {channel}", { - channel: slackChannelId, - }); - return slackChannelId; - } - return name; - } catch (cause) { - log.warn("conversations.info lookup failed for {channel}: {error}", { - channel: slackChannelId, - error: cause instanceof Error ? cause.message : String(cause), - }); - return slackChannelId; - } - }; -} diff --git a/packages/slack-tag/src/store.test.ts b/packages/slack-tag/src/store.test.ts deleted file mode 100644 index 2b743ab824..0000000000 --- a/packages/slack-tag/src/store.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { createInMemorySlackChannelBindingStore } from "./store"; - -describe("SlackChannelBindingStore tenant isolation", () => { - test("the same Slack channel id in two tenants resolves to two independent bindings", async () => { - const bindings = createInMemorySlackChannelBindingStore(); - - const workspaceA = await bindings.createBinding({ - tenantId: "tenant_a", - slackChannelId: "C_SHARED", - channelId: "workflowRun_a", - }); - const workspaceB = await bindings.createBinding({ - tenantId: "tenant_b", - slackChannelId: "C_SHARED", - channelId: "workflowRun_b", - }); - - expect(workspaceA.channelId).toBe("workflowRun_a"); - expect(workspaceB.channelId).toBe("workflowRun_b"); - - expect(await bindings.getBinding("tenant_a", "C_SHARED")).toEqual( - workspaceA, - ); - expect(await bindings.getBinding("tenant_b", "C_SHARED")).toEqual( - workspaceB, - ); - }); - - test("a tenant with no binding for a channel never sees another tenant's", async () => { - const bindings = createInMemorySlackChannelBindingStore(); - await bindings.createBinding({ - tenantId: "tenant_a", - slackChannelId: "C1", - channelId: "workflowRun_a", - }); - - expect(await bindings.getBinding("tenant_b", "C1")).toBeUndefined(); - }); - - test("createBinding is idempotent for the same (tenant, channel) pair", async () => { - const bindings = createInMemorySlackChannelBindingStore(); - const first = await bindings.createBinding({ - tenantId: "tenant_a", - slackChannelId: "C1", - channelId: "workflowRun_first", - }); - const second = await bindings.createBinding({ - tenantId: "tenant_a", - slackChannelId: "C1", - channelId: "workflowRun_second", - }); - - expect(second).toEqual(first); - expect(second.channelId).toBe("workflowRun_first"); - }); -}); diff --git a/packages/slack-tag/src/store.ts b/packages/slack-tag/src/store.ts deleted file mode 100644 index 7bff64b51a..0000000000 --- a/packages/slack-tag/src/store.ts +++ /dev/null @@ -1,139 +0,0 @@ -// The binding store: which workbench channel a Slack channel is bound -// to, scoped by tenant (see `./schema.ts`'s header comment for why -// `tenantId` — not a Slack team id — is the isolation boundary). -import { and, eq } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; -import { slackChannelBinding, type SlackChannelBindingRow } from "./schema"; - -function generateBindingId(): string { - return `scb_${crypto.randomUUID().replaceAll("-", "")}`; -} - -export type SlackTagDb< - TSchema extends Record = Record, -> = PostgresJsDatabase; - -export interface SlackChannelBinding { - readonly tenantId: string; - readonly slackChannelId: string; - readonly channelId: string; - readonly createdAt: Date; -} - -export interface CreateSlackChannelBindingInput { - readonly tenantId: string; - readonly slackChannelId: string; - readonly channelId: string; -} - -export interface SlackChannelBindingStore { - getBinding( - tenantId: string, - slackChannelId: string, - ): Promise; - - /** - * Idempotently records a binding: two concurrent first-contact events - * for the same Slack channel resolve to the same row rather than - * throwing on the unique constraint or creating a duplicate — the - * loser of the race gets the winner's row back. - */ - createBinding( - input: CreateSlackChannelBindingInput, - ): Promise; -} - -function toBinding(row: SlackChannelBindingRow): SlackChannelBinding { - return { - tenantId: row.tenantId, - slackChannelId: row.slackChannelId, - channelId: row.channelId, - createdAt: row.createdAt, - }; -} - -export function createDrizzleSlackChannelBindingStore< - TSchema extends Record, ->(db: SlackTagDb): SlackChannelBindingStore { - return { - async getBinding(tenantId, slackChannelId) { - const [row] = await db - .select() - .from(slackChannelBinding) - .where( - and( - eq(slackChannelBinding.tenantId, tenantId), - eq(slackChannelBinding.slackChannelId, slackChannelId), - ), - ) - .limit(1); - return row === undefined ? undefined : toBinding(row); - }, - - async createBinding(input) { - const [row] = await db - .insert(slackChannelBinding) - .values({ - id: generateBindingId(), - tenantId: input.tenantId, - slackChannelId: input.slackChannelId, - channelId: input.channelId, - }) - .onConflictDoNothing({ - target: [ - slackChannelBinding.tenantId, - slackChannelBinding.slackChannelId, - ], - }) - .returning(); - if (row !== undefined) return toBinding(row); - - // Lost the race: another request already inserted this - // (tenantId, slackChannelId) pair between our insert attempt and - // now. Read back the winner's row rather than surfacing a - // conflict to the caller — this call is documented as - // idempotent. - const existing = await this.getBinding( - input.tenantId, - input.slackChannelId, - ); - if (existing === undefined) { - throw new Error( - `slack-tag: binding for tenant "${input.tenantId}" / Slack channel ` + - `"${input.slackChannelId}" vanished between insert and read-back`, - ); - } - return existing; - }, - }; -} - -/** - * An in-memory `SlackChannelBindingStore`, for tests and any host - * wiring slack-tag without a database. Mints synthetic ids with the - * same `generateId` shape as the drizzle store. - */ -export function createInMemorySlackChannelBindingStore(): SlackChannelBindingStore { - const byKey = new Map(); - const key = (tenantId: string, slackChannelId: string) => - `${tenantId}::${slackChannelId}`; - - return { - async getBinding(tenantId, slackChannelId) { - return byKey.get(key(tenantId, slackChannelId)); - }, - async createBinding(input) { - const existingKey = key(input.tenantId, input.slackChannelId); - const existing = byKey.get(existingKey); - if (existing !== undefined) return existing; - const binding: SlackChannelBinding = { - tenantId: input.tenantId, - slackChannelId: input.slackChannelId, - channelId: input.channelId, - createdAt: new Date(), - }; - byKey.set(existingKey, binding); - return binding; - }, - }; -} diff --git a/packages/slack-tag/src/thread-state.ts b/packages/slack-tag/src/thread-state.ts deleted file mode 100644 index fb8458f0bd..0000000000 --- a/packages/slack-tag/src/thread-state.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Chat SDK `StateAdapter` resolution for workbench's Slack mount. - * - * Lifted from the `resolveThreadState` pattern in scout's - * `packages/agent-dock/src/tag-mount.ts` (CL-4973): try to connect the - * given adapter (typically a durable one); fall back to an in-memory - * adapter so a backend hiccup at mount time doesn't take down Slack tag - * ingress — it just means subscription/dedup/lock state won't survive - * the next restart until the backend comes back. Which adapter to try - * first is entirely the caller's business; this only owns the - * connect-or-fall-back policy. - * - * This is `corbits-tag/slack`'s own internal state (thread - * subscriptions, dedup, locks) — a different concern from - * `SlackChannelBindingStore` (`./store.ts`), which is this package's - * own durable "Slack channel -> workbench channel" record. - */ -import { createMemoryState } from "@chat-adapter/state-memory"; -import type { StateAdapter } from "chat"; -import { getLogger } from "@intx/log"; - -const log = getLogger(["slack-tag", "thread-state"]); - -export async function resolveThreadState( - adapter: StateAdapter, -): Promise { - try { - await adapter.connect(); - return adapter; - } catch (cause) { - log.error( - "Durable thread-state connect failed, falling back to in-memory state: {error}", - { error: cause instanceof Error ? cause.message : String(cause) }, - ); - return createMemoryState(); - } -} diff --git a/packages/slack-tag/tsconfig.json b/packages/slack-tag/tsconfig.json deleted file mode 100644 index d7611c1229..0000000000 --- a/packages/slack-tag/tsconfig.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "include": ["src", "test"], - "compilerOptions": { - "types": ["bun"], - "noEmit": true - } -} diff --git a/scripts/checks/no-product-tenancy.ts b/scripts/checks/no-product-tenancy.ts index dc13ea9de4..3d689e270b 100644 --- a/scripts/checks/no-product-tenancy.ts +++ b/scripts/checks/no-product-tenancy.ts @@ -150,11 +150,6 @@ const ALLOWLIST: readonly { maxOccurrences: 1, tables: ["snooze"], }, - { - relPath: "packages/slack-tag/src/schema.ts", - maxOccurrences: 1, - tables: ["slack_channel_binding"], - }, { relPath: "packages/preferences/src/schema.ts", maxOccurrences: 1, diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt index f6f291d3bb..1f73654d53 100644 --- a/scripts/checks/report-error-baseline.txt +++ b/scripts/checks/report-error-baseline.txt @@ -64,7 +64,6 @@ apps/web/src/command-palette-recents.ts 1 return null; apps/web/src/connect-service-actions.ts 1 const message = apps/web/src/connect-service-actions.ts 1 return { ok: false, message: "Couldn't connect. Try again." }; apps/web/src/connect-service-actions.ts 2 const message = -apps/web/src/deployment-capabilities-api.ts 1 return { apps/web/src/last-workbench.ts 1 // A private-browsing tab with storage disabled loses this signal — apps/web/src/last-workbench.ts 1 return null; apps/web/src/onboarding.ts 1 return { kind: "error" }; @@ -207,9 +206,6 @@ packages/skills/src/asset-history.ts 1 return []; packages/skills/src/hub-asset-store.ts 1 return null; packages/skills/src/registry.ts 1 contentErrorToRegistryError(cause); packages/skills/src/registry.ts 2 contentErrorToRegistryError(cause); -packages/slack-tag/src/principal-resolver.ts 1 log.error("Auto-provision failed for {email}: {error}", { -packages/slack-tag/src/slack-channel-name.ts 1 log.warn("conversations.info lookup failed for {channel}: {error}", { -packages/slack-tag/src/thread-state.ts 1 log.error( packages/tool-registry-publish/src/freshness-check.ts 1 log.error`${err instanceof Error ? err.message : String(err)}`; packages/tools-skills/src/tool.ts 1 return errorResult(call.id, err); packages/tools-skills/src/tool.ts 2 return errorResult(call.id, err); diff --git a/scripts/db-setup.ts b/scripts/db-setup.ts index 338488668e..132c9a68a5 100644 --- a/scripts/db-setup.ts +++ b/scripts/db-setup.ts @@ -544,7 +544,6 @@ const PACKAGE_SCHEMAS = [ "bench", "preferences", "skills", - "slack_tag", "tasks", "agent_directory", ] as const;