@bugmole/cli 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bugmole.env.example +20 -0
- package/LICENSE +7 -0
- package/README.md +293 -0
- package/TESTING.md +117 -0
- package/bugmole.config.yaml +39 -0
- package/package.json +85 -0
- package/scripts/billing/paypal-setup.mjs +121 -0
- package/scripts/bugmole-continue.ts +318 -0
- package/scripts/bugmole-init.ts +188 -0
- package/scripts/bugmole.cjs +17 -0
- package/scripts/bugmole.test.ts +344 -0
- package/scripts/bugmole.ts +657 -0
- package/scripts/ensure-maestro.cjs +79 -0
- package/scripts/ios-tunnel-keeper.sh +45 -0
- package/scripts/ios-wda-keeper.sh +66 -0
- package/scripts/sync-plan-catalog.d.mts +3 -0
- package/scripts/sync-plan-catalog.mjs +16 -0
- package/scripts/ui-parity-diff.py +65 -0
- package/scripts/ui-parity-requirements.txt +1 -0
- package/scripts/verify-manage-to-plans.mts +194 -0
- package/spec/app-ui-audit.schema.json +176 -0
- package/spec/blockers.yaml +79 -0
- package/spec/bugs.index.json +42 -0
- package/spec/design-dna.schema.json +38 -0
- package/spec/domain_rules.yaml +24 -0
- package/spec/flows/flow_manage-to-plans-64c3c61b.yaml +8 -0
- package/spec/flows/flow_screen-to-evidence-6c3ab309.yaml +7 -0
- package/spec/journey_graph.yaml +126 -0
- package/spec/journeys.graph.json +2618 -0
- package/spec/plans/dashboard-smoke.flow.yaml +20 -0
- package/spec/plans/example.flow.yaml +99 -0
- package/spec/plans/flow_api-keys-to-logout-7396cd5d.flow.yaml +33 -0
- package/spec/plans/flow_api-keys-to-screen-157e10a6.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-audits-176c1eb8.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-blockers-633282c8.flow.yaml +112 -0
- package/spec/plans/flow_manage-to-devices-ad57e202.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-logout-86c54656.flow.yaml +33 -0
- package/spec/plans/flow_manage-to-manage-resource-action-6325f99a.flow.yaml +185 -0
- package/spec/plans/flow_manage-to-parity-issues-01fa2579.flow.yaml +34 -0
- package/spec/plans/flow_manage-to-plans-64c3c61b.flow.yaml +123 -0
- package/spec/plans/flow_manage-to-screen-6857f915.flow.yaml +106 -0
- package/spec/plans/flow_manage-to-tasks-c5791c95.flow.yaml +33 -0
- package/spec/plans/flow_profile-to-screen-6045aa3d.flow.yaml +33 -0
- package/spec/plans/flow_register-to-api-auth-login-460a9cc8.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-audits-07666356.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-blockers-091d258b.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-devices-cad72f75.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-evidence-6c3ab309.flow.yaml +126 -0
- package/spec/plans/flow_screen-to-parity-issues-2218e3ad.flow.yaml +34 -0
- package/spec/plans/flow_screen-to-plans-8232a94f.flow.yaml +33 -0
- package/spec/plans/flow_screen-to-tasks-540671d1.flow.yaml +33 -0
- package/spec/plans/login.flow.yaml +22 -0
- package/spec/plans/owner-operations.flow.yaml +20 -0
- package/spec/project_config.yaml +55 -0
- package/spec/roles.yaml +30 -0
- package/spec/schema.md +394 -0
- package/spec/test-case-results.schema.json +62 -0
- package/spec/test-cases.schema.json +85 -0
- package/spec/ui-parity-audit.schema.json +194 -0
- package/spec/ui-reverse-engineering.schema.json +94 -0
- package/src/billing/plan-catalog.test.ts +46 -0
- package/src/billing/plan-catalog.ts +199 -0
- package/src/integrations/aws-sigv4.test.ts +42 -0
- package/src/integrations/aws-sigv4.ts +72 -0
- package/src/integrations/device-farm.ts +155 -0
- package/src/integrations/github-app.test.ts +57 -0
- package/src/integrations/github-app.ts +143 -0
- package/src/integrations/gitlab.ts +81 -0
- package/src/integrations/temp-email.test.ts +123 -0
- package/src/integrations/temp-email.ts +175 -0
- package/src/integrations/testflight-feedback.test.ts +51 -0
- package/src/integrations/testflight-feedback.ts +173 -0
- package/src/integrations/webdriver-client.ts +131 -0
- package/src/mcp/server.test.ts +1220 -0
- package/src/mcp/server.ts +3064 -0
- package/src/mcp/write-test-cases.test.ts +287 -0
- package/src/registry/api-key-client.ts +39 -0
- package/src/registry/control-plane-client.ts +212 -0
- package/src/registry/migrations/0001_registry.sql +47 -0
- package/src/registry/migrations/0002_device_authorizations.sql +23 -0
- package/src/registry/migrations/0003_project_environments.sql +25 -0
- package/src/registry/migrations/0004_device_authorization_email.sql +1 -0
- package/src/registry/migrations/0005_testing_control_plane.sql +55 -0
- package/src/registry/migrations/0006_workspaces.sql +36 -0
- package/src/registry/migrations/0007_project_apps.sql +26 -0
- package/src/registry/migrations/0008_agent_tasks.sql +30 -0
- package/src/registry/migrations/0009_journey_revisions.sql +17 -0
- package/src/registry/migrations/0010_agent_task_journey.sql +2 -0
- package/src/registry/migrations/0011_run_journey_revision.sql +1 -0
- package/src/registry/migrations/0012_canonical_flow_execution.sql +10 -0
- package/src/registry/migrations/0013_device_sessions.sql +22 -0
- package/src/registry/migrations/0014_agent_task_step.sql +1 -0
- package/src/registry/migrations/0015_agent_task_attempts.sql +6 -0
- package/src/registry/migrations/0016_github_issue_tracker.sql +54 -0
- package/src/registry/migrations/0017_run_targets.sql +7 -0
- package/src/registry/migrations/0018_run_fix_from.sql +4 -0
- package/src/registry/migrations/0019_workspace_flags.sql +9 -0
- package/src/registry/migrations/0020_orgs.sql +40 -0
- package/src/registry/migrations/0021_billing_core.sql +58 -0
- package/src/registry/migrations/0022_cloud_runners.sql +19 -0
- package/src/registry/migrations/0023_signup.sql +4 -0
- package/src/registry/migrations/0024_billing.sql +67 -0
- package/src/registry/migrations/0025_notifications.sql +47 -0
- package/src/registry/migrations/0026_repo_bindings.sql +28 -0
- package/src/registry/migrations/0027_feedback.sql +29 -0
- package/src/registry/migrations/0028_devices.sql +48 -0
- package/src/registry/migrations/0029_sso.sql +31 -0
- package/src/registry/migrations/0030_workspace_domains.sql +18 -0
- package/src/registry/migrations/0031_gitlab_and_teams.sql +45 -0
- package/src/registry/migrations/0032_personas.sql +15 -0
- package/src/registry/migrations/0033_bugmole_rename.sql +11 -0
- package/src/registry/task-scheduling.test.ts +100 -0
- package/src/registry/task-scheduling.ts +80 -0
- package/src/registry-worker/ai/platform-model.ts +77 -0
- package/src/registry-worker/ai/routes.test.ts +88 -0
- package/src/registry-worker/ai/routes.ts +90 -0
- package/src/registry-worker/artifacts.test.ts +98 -0
- package/src/registry-worker/artifacts.ts +85 -0
- package/src/registry-worker/billing/billing-core.test.ts +175 -0
- package/src/registry-worker/billing/checkout-routes.ts +209 -0
- package/src/registry-worker/billing/enforcement.ts +69 -0
- package/src/registry-worker/billing/entitlements.ts +108 -0
- package/src/registry-worker/billing/ledger.ts +186 -0
- package/src/registry-worker/billing/paypal/api.ts +259 -0
- package/src/registry-worker/billing/paypal/client.ts +91 -0
- package/src/registry-worker/billing/paypal/provider.ts +143 -0
- package/src/registry-worker/billing/paypal.test.ts +466 -0
- package/src/registry-worker/billing/provider.ts +114 -0
- package/src/registry-worker/billing/routes.ts +66 -0
- package/src/registry-worker/billing/subscriptions.ts +780 -0
- package/src/registry-worker/billing/thresholds.ts +107 -0
- package/src/registry-worker/core.ts +308 -0
- package/src/registry-worker/devices/devices.test.ts +185 -0
- package/src/registry-worker/devices/policy.ts +71 -0
- package/src/registry-worker/devices/routes.ts +453 -0
- package/src/registry-worker/domains/domains.test.ts +210 -0
- package/src/registry-worker/domains/routes.ts +139 -0
- package/src/registry-worker/domains.ts +88 -0
- package/src/registry-worker/email/sender.ts +75 -0
- package/src/registry-worker/env.d.ts +14716 -0
- package/src/registry-worker/features.ts +20 -0
- package/src/registry-worker/feedback/feedback.test.ts +230 -0
- package/src/registry-worker/feedback/format.ts +148 -0
- package/src/registry-worker/feedback/routes.ts +386 -0
- package/src/registry-worker/flags.ts +39 -0
- package/src/registry-worker/github/checks.test.ts +177 -0
- package/src/registry-worker/github/checks.ts +374 -0
- package/src/registry-worker/gitlab/checks.test.ts +141 -0
- package/src/registry-worker/gitlab/checks.ts +349 -0
- package/src/registry-worker/hooks.ts +54 -0
- package/src/registry-worker/index.ts +2077 -0
- package/src/registry-worker/jobs/index.ts +29 -0
- package/src/registry-worker/jobs/retention.ts +68 -0
- package/src/registry-worker/mcp/mcp.test.ts +355 -0
- package/src/registry-worker/mcp/routes.ts +215 -0
- package/src/registry-worker/mcp/token.ts +126 -0
- package/src/registry-worker/mcp/tools.ts +563 -0
- package/src/registry-worker/notifications/alerts.ts +212 -0
- package/src/registry-worker/notifications/notifications.test.ts +298 -0
- package/src/registry-worker/notifications/outbox.ts +83 -0
- package/src/registry-worker/notifications/routes.ts +280 -0
- package/src/registry-worker/notifications/secrets.ts +49 -0
- package/src/registry-worker/notifications/slack.ts +96 -0
- package/src/registry-worker/notifications/teams.ts +46 -0
- package/src/registry-worker/org/audit.ts +116 -0
- package/src/registry-worker/org/routes.test.ts +163 -0
- package/src/registry-worker/org/routes.ts +302 -0
- package/src/registry-worker/personas/personas.test.ts +78 -0
- package/src/registry-worker/personas/routes.ts +100 -0
- package/src/registry-worker/repo-triggers.ts +20 -0
- package/src/registry-worker/routes/index.ts +74 -0
- package/src/registry-worker/run-events.ts +24 -0
- package/src/registry-worker/runner/dispatch.ts +219 -0
- package/src/registry-worker/runner/jobs.ts +43 -0
- package/src/registry-worker/runner/metering.ts +82 -0
- package/src/registry-worker/runner/policy.ts +59 -0
- package/src/registry-worker/runner/routes.ts +171 -0
- package/src/registry-worker/runner/runner.test.ts +358 -0
- package/src/registry-worker/runner/tokens.ts +93 -0
- package/src/registry-worker/runs.test.ts +60 -0
- package/src/registry-worker/signup/policy.ts +57 -0
- package/src/registry-worker/signup/routes.ts +106 -0
- package/src/registry-worker/signup/signup.test.ts +81 -0
- package/src/registry-worker/sso/aegis.ts +141 -0
- package/src/registry-worker/sso/membership.ts +157 -0
- package/src/registry-worker/sso/routes.ts +458 -0
- package/src/registry-worker/sso/sso.test.ts +344 -0
- package/src/registry-worker/testing/d1-shim.ts +180 -0
- package/src/registry-worker/testing/harness.ts +137 -0
- package/src/runner-worker/index.ts +108 -0
- package/src/runtime/ai-analysis.ts +97 -0
- package/src/runtime/ai-exploration.test.ts +32 -0
- package/src/runtime/ai-exploration.ts +69 -0
- package/src/runtime/ai-repair.ts +74 -0
- package/src/runtime/ai-work.test.ts +99 -0
- package/src/runtime/android-screen-record.test.ts +75 -0
- package/src/runtime/android-screen-record.ts +192 -0
- package/src/runtime/app-understanding.test.ts +123 -0
- package/src/runtime/app-understanding.ts +201 -0
- package/src/runtime/appium-driver.test.ts +179 -0
- package/src/runtime/appium-driver.ts +295 -0
- package/src/runtime/blocker-resolution.test.ts +113 -0
- package/src/runtime/blocker-resolution.ts +111 -0
- package/src/runtime/browser-matrix.integration.test.ts +212 -0
- package/src/runtime/browser-matrix.test.ts +143 -0
- package/src/runtime/browser-matrix.ts +200 -0
- package/src/runtime/canonical-flow.test.ts +52 -0
- package/src/runtime/config-validate.ts +185 -0
- package/src/runtime/continuous-execution.ts +291 -0
- package/src/runtime/cursor-applescript.ts +573 -0
- package/src/runtime/cursor-cli-driver.test.ts +78 -0
- package/src/runtime/cursor-cli-driver.ts +156 -0
- package/src/runtime/cursor-driver-example.ts +117 -0
- package/src/runtime/cursor-driver-index.ts +65 -0
- package/src/runtime/cursor-driver-init.ts +277 -0
- package/src/runtime/cursor-driver-run.test.ts +15 -0
- package/src/runtime/cursor-driver-run.ts +323 -0
- package/src/runtime/cursor-driver.ts +332 -0
- package/src/runtime/cursor-llm-example.ts +90 -0
- package/src/runtime/cursor-llm.ts +206 -0
- package/src/runtime/cursor-mcp-monitor.ts +386 -0
- package/src/runtime/device-clouds/browserstack.ts +73 -0
- package/src/runtime/device-clouds/device-farm.ts +52 -0
- package/src/runtime/device-clouds/index.ts +92 -0
- package/src/runtime/device-clouds/kobiton.ts +70 -0
- package/src/runtime/device-clouds/targets.ts +44 -0
- package/src/runtime/device-clouds/types.ts +62 -0
- package/src/runtime/diff-proposal.ts +84 -0
- package/src/runtime/discovery-task.test.ts +29 -0
- package/src/runtime/discovery-task.ts +284 -0
- package/src/runtime/driver-recovery.ts +69 -0
- package/src/runtime/driver.ts +79 -0
- package/src/runtime/environment.test.ts +104 -0
- package/src/runtime/environment.ts +137 -0
- package/src/runtime/executor.test.ts +509 -0
- package/src/runtime/executor.ts +921 -0
- package/src/runtime/explorer.test.ts +101 -0
- package/src/runtime/explorer.ts +1013 -0
- package/src/runtime/failure-analysis.test.ts +111 -0
- package/src/runtime/failure-analysis.ts +272 -0
- package/src/runtime/fixtures/fake-maestro.sh +36 -0
- package/src/runtime/flow-language.test.ts +268 -0
- package/src/runtime/flow-language.ts +414 -0
- package/src/runtime/init-wizard.ts +354 -0
- package/src/runtime/ios-screen-record.test.ts +68 -0
- package/src/runtime/ios-screen-record.ts +155 -0
- package/src/runtime/journey-editor.ts +452 -0
- package/src/runtime/journey-evidence.test.ts +161 -0
- package/src/runtime/journey-evidence.ts +180 -0
- package/src/runtime/journey-graph.test.ts +257 -0
- package/src/runtime/journey-graph.ts +170 -0
- package/src/runtime/legacy-names.ts +32 -0
- package/src/runtime/llm-example.ts +105 -0
- package/src/runtime/llm.ts +527 -0
- package/src/runtime/local-browser.test.ts +45 -0
- package/src/runtime/local-browser.ts +48 -0
- package/src/runtime/local-registry-stub.test.ts +325 -0
- package/src/runtime/local-registry-stub.ts +803 -0
- package/src/runtime/maestro-driver.test.ts +84 -0
- package/src/runtime/maestro-driver.ts +209 -0
- package/src/runtime/mole-voice.ts +21 -0
- package/src/runtime/nav-crawl.test.ts +100 -0
- package/src/runtime/nav-crawl.ts +153 -0
- package/src/runtime/pipeline.test.ts +405 -0
- package/src/runtime/pipeline.ts +833 -0
- package/src/runtime/planner.test.ts +37 -0
- package/src/runtime/planner.ts +274 -0
- package/src/runtime/platform-ai.ts +76 -0
- package/src/runtime/playwright-driver.test.ts +93 -0
- package/src/runtime/playwright-driver.ts +620 -0
- package/src/runtime/project-spec.ts +140 -0
- package/src/runtime/record-run-verdicts.ts +68 -0
- package/src/runtime/reporter.test.ts +56 -0
- package/src/runtime/reporter.ts +158 -0
- package/src/runtime/reset.test.ts +44 -0
- package/src/runtime/reset.ts +61 -0
- package/src/runtime/reviewer.test.ts +73 -0
- package/src/runtime/reviewer.ts +158 -0
- package/src/runtime/run-job.ts +136 -0
- package/src/runtime/run-once.test.ts +207 -0
- package/src/runtime/run-once.ts +168 -0
- package/src/runtime/run.ts +132 -0
- package/src/runtime/screen-recording.ts +34 -0
- package/src/runtime/serve-gateway.test.ts +74 -0
- package/src/runtime/serve-gateway.ts +164 -0
- package/src/runtime/serve-worker.test.ts +23 -0
- package/src/runtime/serve-worker.ts +278 -0
- package/src/runtime/site-discovery.test.ts +168 -0
- package/src/runtime/site-discovery.ts +308 -0
- package/src/runtime/target-runner.ts +144 -0
- package/src/runtime/test-case-verdicts.test.ts +94 -0
- package/src/runtime/test-case-verdicts.ts +120 -0
- package/src/runtime/ui-reverse-engineering/coordinator.test.ts +59 -0
- package/src/runtime/ui-reverse-engineering/coordinator.ts +391 -0
- package/src/runtime/ui-reverse-engineering/types.ts +197 -0
- package/src/runtime/web-suite.test.ts +97 -0
- package/src/runtime/web-suite.ts +176 -0
- package/src/storage/create-object-store.ts +144 -0
- package/src/storage/keys.ts +34 -0
- package/src/storage/local-artifact-server.test.ts +314 -0
- package/src/storage/local-artifact-server.ts +357 -0
- package/src/storage/object-store.test.ts +28 -0
- package/src/storage/object-store.ts +101 -0
- package/src/storage/registry-object-store.ts +88 -0
- package/src/storage/remote-object-store.ts +104 -0
- package/src/storage/storage-directory.test.ts +43 -0
- package/src/storage/storage-directory.ts +24 -0
- package/tsconfig.json +24 -0
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// Routes for connecting Slack and choosing which alerts go where.
|
|
2
|
+
import type { Env, Principal } from "../core.js";
|
|
3
|
+
import { memberOfWorkspace, newId, requestJson, response } from "../core.js";
|
|
4
|
+
import { getEntitlements } from "../billing/entitlements.js";
|
|
5
|
+
import { flagOn } from "../flags.js";
|
|
6
|
+
import { actorOf, recordAudit } from "../org/audit.js";
|
|
7
|
+
import { matchPath, registerRoutes, type AuthedRouteContext } from "../routes/index.js";
|
|
8
|
+
import { ALERT_EVENTS, isAlertEvent, slackBlocks } from "./alerts.js";
|
|
9
|
+
import { drainSoon, enqueue } from "./outbox.js";
|
|
10
|
+
import { canStoreSecrets, sealSecret, signState, verifyState } from "./secrets.js";
|
|
11
|
+
import { exchangeSlackCode, slackAuthorizeUrl, slackConfigured } from "./slack.js";
|
|
12
|
+
import { postTeamsMessage, teamsFetchEnv, validTeamsWebhookUrl } from "./teams.js";
|
|
13
|
+
|
|
14
|
+
async function canManage(env: Env, principal: Principal, workspaceId: string): Promise<boolean> {
|
|
15
|
+
return memberOfWorkspace(env, principal, workspaceId, "workspace.manage");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function slackAvailability(env: Env, workspaceId: string): Promise<{ available: boolean; reason?: string }> {
|
|
19
|
+
if (!(await flagOn(env, workspaceId, "slack")) || !slackConfigured(env) || !canStoreSecrets(env)) {
|
|
20
|
+
return { available: false, reason: "Slack isn't available yet." };
|
|
21
|
+
}
|
|
22
|
+
const { features } = await getEntitlements(env, workspaceId);
|
|
23
|
+
return features.slack ? { available: true } : { available: false, reason: "Slack alerts are included in Team and Business." };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function teamsAvailability(env: Env, workspaceId: string): Promise<{ available: boolean; reason?: string }> {
|
|
27
|
+
if (!(await flagOn(env, workspaceId, "teams")) || !canStoreSecrets(env)) {
|
|
28
|
+
return { available: false, reason: "Microsoft Teams isn't available yet." };
|
|
29
|
+
}
|
|
30
|
+
const { features } = await getEntitlements(env, workspaceId);
|
|
31
|
+
return features.teams ? { available: true } : { available: false, reason: "Microsoft Teams alerts are included in Team and Business." };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function overview({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
35
|
+
if (!(await memberOfWorkspace(env, principal, workspaceId))) return response({ error: "Workspace scope is not allowed" }, 403);
|
|
36
|
+
const installations = await env.DB.prepare(`
|
|
37
|
+
SELECT id, team_name AS teamName, channel_name AS channelName, created_at AS createdAt, revoked_at AS revokedAt, revoked_reason AS revokedReason
|
|
38
|
+
FROM slack_installations WHERE workspace_id = ? ORDER BY created_at DESC
|
|
39
|
+
`).bind(workspaceId).all();
|
|
40
|
+
const teamsInstallations = await env.DB.prepare(`
|
|
41
|
+
SELECT id, name, created_at AS createdAt, revoked_at AS revokedAt, revoked_reason AS revokedReason
|
|
42
|
+
FROM teams_installations WHERE workspace_id = ? ORDER BY created_at DESC
|
|
43
|
+
`).bind(workspaceId).all();
|
|
44
|
+
const rules = await env.DB.prepare(`
|
|
45
|
+
SELECT r.id, r.project_id AS projectId, p.name AS projectName, r.event, r.channel, r.target, r.enabled,
|
|
46
|
+
s.channel_name AS slackChannel, s.revoked_at AS slackRevokedAt,
|
|
47
|
+
t.name AS teamsName, t.revoked_at AS teamsRevokedAt
|
|
48
|
+
FROM alert_rules r
|
|
49
|
+
LEFT JOIN projects p ON p.id = r.project_id
|
|
50
|
+
LEFT JOIN slack_installations s ON r.channel = 'slack' AND s.id = r.target
|
|
51
|
+
LEFT JOIN teams_installations t ON r.channel = 'teams' AND t.id = r.target
|
|
52
|
+
WHERE r.workspace_id = ? ORDER BY r.created_at ASC
|
|
53
|
+
`).bind(workspaceId).all<Record<string, unknown>>();
|
|
54
|
+
const manager = await canManage(env, principal, workspaceId);
|
|
55
|
+
return response({
|
|
56
|
+
canManage: manager,
|
|
57
|
+
emailTargets: manager ? [...(await workspaceEmails(env, workspaceId))].sort() : [],
|
|
58
|
+
slack: { ...(await slackAvailability(env, workspaceId)), installations: installations.results },
|
|
59
|
+
teams: { ...(await teamsAvailability(env, workspaceId)), installations: teamsInstallations.results },
|
|
60
|
+
events: Object.entries(ALERT_EVENTS).map(([id, label]) => ({ id, label })),
|
|
61
|
+
rules: rules.results.map((rule) => ({ ...rule, enabled: Boolean(rule.enabled) })),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function startSlackInstall({ env, principal }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
66
|
+
if (!(await canManage(env, principal, workspaceId))) return response({ error: "Only owners and admins can connect Slack" }, 403);
|
|
67
|
+
const slack = await slackAvailability(env, workspaceId);
|
|
68
|
+
if (!slack.available) return response({ error: slack.reason, code: "slack_unavailable" }, 402);
|
|
69
|
+
const state = await signState(env, "slack-install", { workspaceId, aegisId: principal.aegisId, nonce: newId("n") });
|
|
70
|
+
return response({ authorizeUrl: slackAuthorizeUrl(env, state) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function completeSlackInstall({ env, principal, request, ctx }: AuthedRouteContext): Promise<Response> {
|
|
74
|
+
const body = await requestJson(request);
|
|
75
|
+
const code = typeof body?.code === "string" ? body.code : "";
|
|
76
|
+
const state = typeof body?.state === "string" ? await verifyState<{ workspaceId: string; aegisId: string }>(env, "slack-install", body.state) : null;
|
|
77
|
+
// The person finishing the install must be the one who started it.
|
|
78
|
+
if (!code || !state || state.aegisId !== principal.aegisId) return response({ error: "This Slack connection link is invalid or has expired. Start again." }, 400);
|
|
79
|
+
const { workspaceId } = state;
|
|
80
|
+
if (!(await canManage(env, principal, workspaceId))) return response({ error: "Only owners and admins can connect Slack" }, 403);
|
|
81
|
+
let install;
|
|
82
|
+
try {
|
|
83
|
+
install = await exchangeSlackCode(env, code);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
return response({ error: error instanceof Error ? error.message : "Slack didn't complete the install." }, 502);
|
|
86
|
+
}
|
|
87
|
+
const sealed = await sealSecret(env, install.accessToken);
|
|
88
|
+
const installationId = newId("slk");
|
|
89
|
+
await env.DB.prepare(`
|
|
90
|
+
INSERT INTO slack_installations (id, workspace_id, team_id, team_name, channel_id, channel_name, bot_token_ciphertext, bot_token_iv, installed_by)
|
|
91
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
92
|
+
`).bind(installationId, workspaceId, install.teamId, install.teamName, install.channelId, install.channelName, sealed.ciphertext, sealed.iv, principal.aegisId).run();
|
|
93
|
+
// Failures and fixes are what most teams want in Slack; they can change it after.
|
|
94
|
+
for (const event of ["run_failed", "run_fixed"] as const) {
|
|
95
|
+
await env.DB.prepare(`
|
|
96
|
+
INSERT INTO alert_rules (id, workspace_id, project_id, event, channel, target, created_by) VALUES (?, ?, NULL, ?, 'slack', ?, ?)
|
|
97
|
+
ON CONFLICT DO NOTHING
|
|
98
|
+
`).bind(newId("rule"), workspaceId, event, installationId, principal.aegisId).run();
|
|
99
|
+
}
|
|
100
|
+
await enqueue(env, "slack.message", {
|
|
101
|
+
installationId,
|
|
102
|
+
text: "Bugmole is connected",
|
|
103
|
+
blocks: slackBlocks({ title: "Bugmole is connected", text: "", tone: "good", lines: ["This channel will hear when a test run fails, and when a failing plan passes again."] }),
|
|
104
|
+
});
|
|
105
|
+
drainSoon(env, ctx);
|
|
106
|
+
await recordAudit(env, actorOf(principal), {
|
|
107
|
+
workspaceId, action: "integration.slack_connected", targetType: "slack_installation", targetId: installationId,
|
|
108
|
+
data: { team: install.teamName, channel: install.channelName },
|
|
109
|
+
}, request);
|
|
110
|
+
return response({ workspaceId, installation: { id: installationId, teamName: install.teamName, channelName: install.channelName } }, 201);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function disconnectSlack({ env, principal, request }: AuthedRouteContext, workspaceId: string, installationId: string): Promise<Response> {
|
|
114
|
+
if (!(await canManage(env, principal, workspaceId))) return response({ error: "Only owners and admins can disconnect Slack" }, 403);
|
|
115
|
+
const updated = await env.DB.prepare(`
|
|
116
|
+
UPDATE slack_installations SET revoked_at = ?, revoked_reason = 'disconnected' WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL
|
|
117
|
+
`).bind(new Date().toISOString(), installationId, workspaceId).run();
|
|
118
|
+
if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Slack connection not found" }, 404);
|
|
119
|
+
await env.DB.prepare("DELETE FROM alert_rules WHERE workspace_id = ? AND channel = 'slack' AND target = ?").bind(workspaceId, installationId).run();
|
|
120
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "integration.slack_disconnected", targetType: "slack_installation", targetId: installationId }, request);
|
|
121
|
+
return response({ disconnected: true });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function testSlack({ env, principal, ctx }: AuthedRouteContext, workspaceId: string, installationId: string): Promise<Response> {
|
|
125
|
+
if (!(await canManage(env, principal, workspaceId))) return response({ error: "Only owners and admins can send a test" }, 403);
|
|
126
|
+
const install = await env.DB.prepare("SELECT id FROM slack_installations WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL")
|
|
127
|
+
.bind(installationId, workspaceId).first();
|
|
128
|
+
if (!install) return response({ error: "Slack connection not found" }, 404);
|
|
129
|
+
await enqueue(env, "slack.message", {
|
|
130
|
+
installationId,
|
|
131
|
+
text: "Test message from Bugmole",
|
|
132
|
+
blocks: slackBlocks({ title: "Test message from Bugmole", text: "", tone: "info", lines: ["Alerts for this workspace arrive here."] }),
|
|
133
|
+
});
|
|
134
|
+
drainSoon(env, ctx);
|
|
135
|
+
return response({ queued: true }, 202);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function connectTeams({ env, principal, request, ctx }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
139
|
+
if (!(await canManage(env, principal, workspaceId))) return response({ error: "Only owners and admins can connect Microsoft Teams" }, 403);
|
|
140
|
+
const teams = await teamsAvailability(env, workspaceId);
|
|
141
|
+
if (!teams.available) return response({ error: teams.reason, code: "teams_unavailable" }, 402);
|
|
142
|
+
const body = await requestJson(request);
|
|
143
|
+
const webhookUrl = typeof body?.webhookUrl === "string" ? body.webhookUrl.trim() : "";
|
|
144
|
+
const name = typeof body?.name === "string" && body.name.trim() ? body.name.trim().slice(0, 100) : null;
|
|
145
|
+
if (!validTeamsWebhookUrl(webhookUrl)) {
|
|
146
|
+
return response({ error: "That doesn't look like a Teams Workflows webhook URL. Create one from the Workflows app's \"Post to a channel when a webhook request is received\" template." }, 422);
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
await postTeamsMessage(teamsFetchEnv(env), webhookUrl, "✅ Bugmole is connected. This channel will hear when a test run fails, and when a failing plan passes again.");
|
|
150
|
+
} catch (error) {
|
|
151
|
+
return response({ error: error instanceof Error ? error.message : "Teams didn't accept a test message on that webhook." }, 502);
|
|
152
|
+
}
|
|
153
|
+
const sealed = await sealSecret(env, webhookUrl);
|
|
154
|
+
const installationId = newId("tms");
|
|
155
|
+
await env.DB.prepare(`
|
|
156
|
+
INSERT INTO teams_installations (id, workspace_id, name, webhook_url_ciphertext, webhook_url_iv, installed_by) VALUES (?, ?, ?, ?, ?, ?)
|
|
157
|
+
`).bind(installationId, workspaceId, name, sealed.ciphertext, sealed.iv, principal.aegisId).run();
|
|
158
|
+
for (const event of ["run_failed", "run_fixed"] as const) {
|
|
159
|
+
await env.DB.prepare(`
|
|
160
|
+
INSERT INTO alert_rules (id, workspace_id, project_id, event, channel, target, created_by) VALUES (?, ?, NULL, ?, 'teams', ?, ?)
|
|
161
|
+
ON CONFLICT DO NOTHING
|
|
162
|
+
`).bind(newId("rule"), workspaceId, event, installationId, principal.aegisId).run();
|
|
163
|
+
}
|
|
164
|
+
drainSoon(env, ctx);
|
|
165
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "integration.teams_connected", targetType: "teams_installation", targetId: installationId, data: { name } }, request);
|
|
166
|
+
return response({ workspaceId, installation: { id: installationId, name } }, 201);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function disconnectTeams({ env, principal, request }: AuthedRouteContext, workspaceId: string, installationId: string): Promise<Response> {
|
|
170
|
+
if (!(await canManage(env, principal, workspaceId))) return response({ error: "Only owners and admins can disconnect Microsoft Teams" }, 403);
|
|
171
|
+
const updated = await env.DB.prepare(`
|
|
172
|
+
UPDATE teams_installations SET revoked_at = ?, revoked_reason = 'disconnected' WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL
|
|
173
|
+
`).bind(new Date().toISOString(), installationId, workspaceId).run();
|
|
174
|
+
if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Teams connection not found" }, 404);
|
|
175
|
+
await env.DB.prepare("DELETE FROM alert_rules WHERE workspace_id = ? AND channel = 'teams' AND target = ?").bind(workspaceId, installationId).run();
|
|
176
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "integration.teams_disconnected", targetType: "teams_installation", targetId: installationId }, request);
|
|
177
|
+
return response({ disconnected: true });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function testTeams({ env, principal, ctx }: AuthedRouteContext, workspaceId: string, installationId: string): Promise<Response> {
|
|
181
|
+
if (!(await canManage(env, principal, workspaceId))) return response({ error: "Only owners and admins can send a test" }, 403);
|
|
182
|
+
const install = await env.DB.prepare("SELECT id FROM teams_installations WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL")
|
|
183
|
+
.bind(installationId, workspaceId).first();
|
|
184
|
+
if (!install) return response({ error: "Teams connection not found" }, 404);
|
|
185
|
+
await enqueue(env, "teams.message", { installationId, text: "ℹ️ Test message from Bugmole. Alerts for this workspace arrive here." });
|
|
186
|
+
drainSoon(env, ctx);
|
|
187
|
+
return response({ queued: true }, 202);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Email alerts only go to people in the workspace, so rules can't be used to send mail anywhere. */
|
|
191
|
+
async function workspaceEmails(env: Env, workspaceId: string): Promise<Set<string>> {
|
|
192
|
+
const rows = await env.DB.prepare(`
|
|
193
|
+
SELECT u.email FROM workspace_memberships m JOIN aegis_users u ON u.id = m.aegis_id
|
|
194
|
+
WHERE m.workspace_id = ? AND m.status = 'active' AND u.email IS NOT NULL
|
|
195
|
+
UNION SELECT billing_email FROM workspace_plans WHERE workspace_id = ? AND billing_email IS NOT NULL
|
|
196
|
+
`).bind(workspaceId, workspaceId).all<{ email: string }>();
|
|
197
|
+
return new Set(rows.results.map((row) => row.email.toLowerCase()));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function createRule({ env, principal, request }: AuthedRouteContext, workspaceId: string): Promise<Response> {
|
|
201
|
+
if (!(await canManage(env, principal, workspaceId))) return response({ error: "Only owners and admins can change alerts" }, 403);
|
|
202
|
+
const body = await requestJson(request);
|
|
203
|
+
const event = body?.event;
|
|
204
|
+
const channel = body?.channel;
|
|
205
|
+
const projectId = typeof body?.projectId === "string" && body.projectId ? body.projectId : null;
|
|
206
|
+
let target = typeof body?.target === "string" ? body.target.trim() : "";
|
|
207
|
+
if (!isAlertEvent(event)) return response({ error: "Unknown alert event" }, 422);
|
|
208
|
+
if (projectId) {
|
|
209
|
+
const project = await env.DB.prepare("SELECT id FROM projects WHERE id = ? AND workspace_id = ? AND deleted_at IS NULL").bind(projectId, workspaceId).first();
|
|
210
|
+
if (!project) return response({ error: "That project isn't in this workspace" }, 422);
|
|
211
|
+
}
|
|
212
|
+
if (channel === "slack") {
|
|
213
|
+
const slack = await slackAvailability(env, workspaceId);
|
|
214
|
+
if (!slack.available) return response({ error: slack.reason, code: "slack_unavailable" }, 402);
|
|
215
|
+
const install = await env.DB.prepare("SELECT id FROM slack_installations WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL").bind(target, workspaceId).first();
|
|
216
|
+
if (!install) return response({ error: "Connect that Slack channel first" }, 422);
|
|
217
|
+
} else if (channel === "teams") {
|
|
218
|
+
const teams = await teamsAvailability(env, workspaceId);
|
|
219
|
+
if (!teams.available) return response({ error: teams.reason, code: "teams_unavailable" }, 402);
|
|
220
|
+
const install = await env.DB.prepare("SELECT id FROM teams_installations WHERE id = ? AND workspace_id = ? AND revoked_at IS NULL").bind(target, workspaceId).first();
|
|
221
|
+
if (!install) return response({ error: "Connect that Teams channel first" }, 422);
|
|
222
|
+
} else if (channel === "email") {
|
|
223
|
+
target = target.toLowerCase();
|
|
224
|
+
if (!(await workspaceEmails(env, workspaceId)).has(target)) {
|
|
225
|
+
return response({ error: "Email alerts can go to workspace members or the billing email" }, 422);
|
|
226
|
+
}
|
|
227
|
+
} else {
|
|
228
|
+
return response({ error: "channel must be slack, teams, or email" }, 422);
|
|
229
|
+
}
|
|
230
|
+
// The unique index can't see duplicates across "all projects" (a NULL project), so check first.
|
|
231
|
+
const existing = await env.DB.prepare(`
|
|
232
|
+
SELECT id FROM alert_rules WHERE workspace_id = ? AND project_id IS ? AND event = ? AND channel = ? AND target = ?
|
|
233
|
+
`).bind(workspaceId, projectId, event, channel, target).first();
|
|
234
|
+
if (existing) return response({ error: "That alert already exists" }, 409);
|
|
235
|
+
const id = newId("rule");
|
|
236
|
+
await env.DB.prepare(`
|
|
237
|
+
INSERT INTO alert_rules (id, workspace_id, project_id, event, channel, target, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
238
|
+
`).bind(id, workspaceId, projectId, event, channel, target, principal.aegisId).run();
|
|
239
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "alert_rule.created", targetType: "alert_rule", targetId: id, data: { event, channel, projectId } }, request);
|
|
240
|
+
return response({ rule: { id, event, channel, target, projectId, enabled: true } }, 201);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function updateRule({ env, principal, request }: AuthedRouteContext, workspaceId: string, ruleId: string): Promise<Response> {
|
|
244
|
+
if (!(await canManage(env, principal, workspaceId))) return response({ error: "Only owners and admins can change alerts" }, 403);
|
|
245
|
+
if (request.method === "DELETE") {
|
|
246
|
+
const deleted = await env.DB.prepare("DELETE FROM alert_rules WHERE id = ? AND workspace_id = ?").bind(ruleId, workspaceId).run();
|
|
247
|
+
if ((deleted.meta?.changes ?? 0) === 0) return response({ error: "Alert not found" }, 404);
|
|
248
|
+
await recordAudit(env, actorOf(principal), { workspaceId, action: "alert_rule.deleted", targetType: "alert_rule", targetId: ruleId }, request);
|
|
249
|
+
return response({ deleted: true });
|
|
250
|
+
}
|
|
251
|
+
const body = await requestJson(request);
|
|
252
|
+
if (typeof body?.enabled !== "boolean") return response({ error: "enabled must be true or false" }, 422);
|
|
253
|
+
const updated = await env.DB.prepare("UPDATE alert_rules SET enabled = ? WHERE id = ? AND workspace_id = ?").bind(body.enabled ? 1 : 0, ruleId, workspaceId).run();
|
|
254
|
+
if ((updated.meta?.changes ?? 0) === 0) return response({ error: "Alert not found" }, 404);
|
|
255
|
+
return response({ rule: { id: ruleId, enabled: body.enabled } });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
registerRoutes(async (context) => {
|
|
259
|
+
const { request, url } = context;
|
|
260
|
+
let params = matchPath(url.pathname, "/api/workspaces/:workspaceId/notifications");
|
|
261
|
+
if (params && request.method === "GET") return overview(context, params.workspaceId);
|
|
262
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/integrations/slack/install");
|
|
263
|
+
if (params && request.method === "POST") return startSlackInstall(context, params.workspaceId);
|
|
264
|
+
if (url.pathname === "/api/integrations/slack/complete" && request.method === "POST") return completeSlackInstall(context);
|
|
265
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/integrations/slack/:installationId");
|
|
266
|
+
if (params && request.method === "DELETE") return disconnectSlack(context, params.workspaceId, params.installationId);
|
|
267
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/integrations/slack/:installationId/test");
|
|
268
|
+
if (params && request.method === "POST") return testSlack(context, params.workspaceId, params.installationId);
|
|
269
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/integrations/teams");
|
|
270
|
+
if (params && request.method === "POST") return connectTeams(context, params.workspaceId);
|
|
271
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/integrations/teams/:installationId");
|
|
272
|
+
if (params && request.method === "DELETE") return disconnectTeams(context, params.workspaceId, params.installationId);
|
|
273
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/integrations/teams/:installationId/test");
|
|
274
|
+
if (params && request.method === "POST") return testTeams(context, params.workspaceId, params.installationId);
|
|
275
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/alert-rules");
|
|
276
|
+
if (params && request.method === "POST") return createRule(context, params.workspaceId);
|
|
277
|
+
params = matchPath(url.pathname, "/api/workspaces/:workspaceId/alert-rules/:ruleId");
|
|
278
|
+
if (params && (request.method === "PATCH" || request.method === "DELETE")) return updateRule(context, params.workspaceId, params.ruleId);
|
|
279
|
+
return null;
|
|
280
|
+
});
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Third-party tokens (Slack, device clouds) are stored encrypted with
|
|
2
|
+
// SECRETS_ENCRYPTION_KEY, never in plain text.
|
|
3
|
+
import type { Env } from "../core.js";
|
|
4
|
+
import { constantTimeEqual, decryptForSecret, encryptForSecret } from "../core.js";
|
|
5
|
+
|
|
6
|
+
export type SecretsEnv = { SECRETS_ENCRYPTION_KEY?: string };
|
|
7
|
+
|
|
8
|
+
function key(env: Env): string {
|
|
9
|
+
const value = (env as Env & SecretsEnv).SECRETS_ENCRYPTION_KEY;
|
|
10
|
+
if (!value) throw new Error("SECRETS_ENCRYPTION_KEY is not configured");
|
|
11
|
+
return value;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function canStoreSecrets(env: Env): boolean {
|
|
15
|
+
return Boolean((env as Env & SecretsEnv).SECRETS_ENCRYPTION_KEY);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function sealSecret(env: Env, value: string): Promise<{ ciphertext: string; iv: string }> {
|
|
19
|
+
return encryptForSecret(value, key(env));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function openSecret(env: Env, ciphertext: string, iv: string): Promise<string> {
|
|
23
|
+
return decryptForSecret(ciphertext, iv, key(env));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A short-lived, signed value (OAuth state) bound to what it was issued for. */
|
|
27
|
+
export async function signState(env: Env, purpose: string, payload: Record<string, unknown>, ttlMs = 10 * 60_000): Promise<string> {
|
|
28
|
+
const body = btoa(JSON.stringify({ ...payload, purpose, exp: Date.now() + ttlMs })).replace(/=+$/, "");
|
|
29
|
+
const mac = await hmac(env, body);
|
|
30
|
+
return `${body}.${mac}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function verifyState<T extends Record<string, unknown>>(env: Env, purpose: string, state: string): Promise<T | null> {
|
|
34
|
+
const [body, mac] = state.split(".");
|
|
35
|
+
if (!body || !mac || !constantTimeEqual(mac, await hmac(env, body))) return null;
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(atob(body)) as T & { purpose?: string; exp?: number };
|
|
38
|
+
if (parsed.purpose !== purpose || typeof parsed.exp !== "number" || parsed.exp < Date.now()) return null;
|
|
39
|
+
return parsed;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function hmac(env: Env, value: string): Promise<string> {
|
|
46
|
+
const material = await crypto.subtle.importKey("raw", new TextEncoder().encode(`state:${key(env)}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
47
|
+
const signature = await crypto.subtle.sign("HMAC", material, new TextEncoder().encode(value));
|
|
48
|
+
return [...new Uint8Array(signature)].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
49
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Slack: the OAuth install that picks a channel, and posting messages to it.
|
|
2
|
+
import type { Env } from "../core.js";
|
|
3
|
+
import { PermanentDeliveryError } from "./outbox.js";
|
|
4
|
+
|
|
5
|
+
export type SlackEnv = {
|
|
6
|
+
SLACK_CLIENT_ID?: string;
|
|
7
|
+
SLACK_CLIENT_SECRET?: string;
|
|
8
|
+
/** Test seam. */
|
|
9
|
+
SLACK_FETCH?: typeof fetch;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const SLACK_SCOPES = ["chat:write", "incoming-webhook"];
|
|
13
|
+
|
|
14
|
+
function slack(env: Env): SlackEnv {
|
|
15
|
+
return env as Env & SlackEnv;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function slackConfigured(env: Env): boolean {
|
|
19
|
+
return Boolean(slack(env).SLACK_CLIENT_ID && slack(env).SLACK_CLIENT_SECRET);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function slackRedirectUri(env: Env): string {
|
|
23
|
+
return `${(env.DASHBOARD_URL || "https://app.bugmole.com").replace(/\/$/, "")}/api/integrations/slack/callback`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function slackAuthorizeUrl(env: Env, state: string): string {
|
|
27
|
+
const url = new URL("https://slack.com/oauth/v2/authorize");
|
|
28
|
+
url.searchParams.set("client_id", slack(env).SLACK_CLIENT_ID ?? "");
|
|
29
|
+
url.searchParams.set("scope", SLACK_SCOPES.join(","));
|
|
30
|
+
url.searchParams.set("redirect_uri", slackRedirectUri(env));
|
|
31
|
+
url.searchParams.set("state", state);
|
|
32
|
+
return url.toString();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type SlackInstall = { accessToken: string; teamId: string; teamName: string | null; channelId: string; channelName: string | null };
|
|
36
|
+
|
|
37
|
+
export async function exchangeSlackCode(env: Env, code: string): Promise<SlackInstall> {
|
|
38
|
+
const response = await (slack(env).SLACK_FETCH ?? fetch)("https://slack.com/api/oauth.v2.access", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
41
|
+
body: new URLSearchParams({
|
|
42
|
+
client_id: slack(env).SLACK_CLIENT_ID ?? "",
|
|
43
|
+
client_secret: slack(env).SLACK_CLIENT_SECRET ?? "",
|
|
44
|
+
code,
|
|
45
|
+
redirect_uri: slackRedirectUri(env),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
const body = await response.json().catch(() => ({})) as {
|
|
49
|
+
ok?: boolean;
|
|
50
|
+
error?: string;
|
|
51
|
+
access_token?: string;
|
|
52
|
+
team?: { id?: string; name?: string };
|
|
53
|
+
incoming_webhook?: { channel?: string; channel_id?: string };
|
|
54
|
+
};
|
|
55
|
+
if (!body.ok || !body.access_token || !body.team?.id || !body.incoming_webhook?.channel_id) {
|
|
56
|
+
throw new Error(`Slack didn't complete the install${body.error ? ` (${body.error})` : ""}`);
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
accessToken: body.access_token,
|
|
60
|
+
teamId: body.team.id,
|
|
61
|
+
teamName: body.team.name ?? null,
|
|
62
|
+
channelId: body.incoming_webhook.channel_id,
|
|
63
|
+
channelName: body.incoming_webhook.channel ?? null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Errors that mean the install no longer works; retrying won't help. */
|
|
68
|
+
export const SLACK_REVOKED = new Set(["token_revoked", "invalid_auth", "account_inactive", "not_authed", "channel_not_found", "is_archived", "not_in_channel"]);
|
|
69
|
+
|
|
70
|
+
export class SlackRevokedError extends PermanentDeliveryError {
|
|
71
|
+
constructor(readonly code: string) {
|
|
72
|
+
super(`Slack refused the message (${code})`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type SlackBlock = Record<string, unknown>;
|
|
77
|
+
|
|
78
|
+
export async function postSlackMessage(env: Env, token: string, channel: string, message: { text: string; blocks?: SlackBlock[] }): Promise<void> {
|
|
79
|
+
const response = await (slack(env).SLACK_FETCH ?? fetch)("https://slack.com/api/chat.postMessage", {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json; charset=utf-8" },
|
|
82
|
+
body: JSON.stringify({ channel, text: message.text, blocks: message.blocks, unfurl_links: false }),
|
|
83
|
+
});
|
|
84
|
+
if (response.status === 429) throw new Error("Slack rate limit");
|
|
85
|
+
const body = await response.json().catch(() => ({})) as { ok?: boolean; error?: string };
|
|
86
|
+
if (body.ok) return;
|
|
87
|
+
const code = body.error ?? `http_${response.status}`;
|
|
88
|
+
if (SLACK_REVOKED.has(code)) throw new SlackRevokedError(code);
|
|
89
|
+
if (response.status >= 400 && response.status < 500 && code !== "ratelimited") throw new PermanentDeliveryError(`Slack refused the message (${code})`);
|
|
90
|
+
throw new Error(`Slack is unavailable (${code})`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Slack mrkdwn needs &, < and > escaped in text we didn't write. */
|
|
94
|
+
export function slackText(value: string): string {
|
|
95
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
96
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Microsoft Teams: posting to a channel through a Workflows webhook — the
|
|
2
|
+
// replacement for the retired Office 365 Connectors (fully retired as of
|
|
3
|
+
// their extended March 2026 deadline). There's no OAuth app to register:
|
|
4
|
+
// the customer creates their own webhook from the Workflows app template
|
|
5
|
+
// ("Post to a channel when a webhook request is received") and pastes the
|
|
6
|
+
// resulting URL in. Messages arrive from the Workflows "Flow bot" identity.
|
|
7
|
+
import { PermanentDeliveryError } from "./outbox.js";
|
|
8
|
+
|
|
9
|
+
export type TeamsFetchEnv = {
|
|
10
|
+
/** Test seam. */
|
|
11
|
+
TEAMS_FETCH?: typeof fetch;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function teamsFetchEnv(env: object): TeamsFetchEnv {
|
|
15
|
+
return env as TeamsFetchEnv;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A Workflows webhook is a plain HTTPS URL on Microsoft's Logic Apps or Power Automate domains. */
|
|
19
|
+
export function validTeamsWebhookUrl(value: string): boolean {
|
|
20
|
+
try {
|
|
21
|
+
const url = new URL(value);
|
|
22
|
+
return url.protocol === "https:" && /(^|\.)(logic\.azure\.com|azure-automation\.net|flow\.microsoft\.com)$/i.test(url.hostname);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The webhook was deleted or the workflow disabled; retrying won't help. */
|
|
29
|
+
export class TeamsRevokedError extends PermanentDeliveryError {}
|
|
30
|
+
|
|
31
|
+
export async function postTeamsMessage(env: TeamsFetchEnv, webhookUrl: string, text: string): Promise<void> {
|
|
32
|
+
const response = await (env.TEAMS_FETCH ?? fetch)(webhookUrl, {
|
|
33
|
+
method: "POST",
|
|
34
|
+
headers: { "content-type": "application/json" },
|
|
35
|
+
body: JSON.stringify({ text }),
|
|
36
|
+
});
|
|
37
|
+
if (response.status === 429) throw new Error("Microsoft Teams rate limit");
|
|
38
|
+
if (response.status === 404 || response.status === 410) {
|
|
39
|
+
throw new TeamsRevokedError(`The Teams webhook no longer exists (${response.status})`);
|
|
40
|
+
}
|
|
41
|
+
if (response.status >= 400 && response.status < 500) {
|
|
42
|
+
const body = await response.text().catch(() => "");
|
|
43
|
+
throw new PermanentDeliveryError(`Teams refused the message (${response.status}) ${body.slice(0, 200)}`);
|
|
44
|
+
}
|
|
45
|
+
if (!response.ok) throw new Error(`Teams is unavailable (${response.status})`);
|
|
46
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// Append-only record of who changed what in a workspace. Writes never fail
|
|
2
|
+
// the request they describe: a lost audit row is logged, not thrown.
|
|
3
|
+
import { type Env, type Principal, newId } from "../core.js";
|
|
4
|
+
|
|
5
|
+
export type AuditActor =
|
|
6
|
+
| { type: "user" | "api_key"; id: string; apiKeyId?: string }
|
|
7
|
+
| { type: "system" | "scim" | "runner" | "webhook"; id: string };
|
|
8
|
+
|
|
9
|
+
export type AuditEntry = {
|
|
10
|
+
workspaceId: string;
|
|
11
|
+
projectId?: string | null;
|
|
12
|
+
action: string;
|
|
13
|
+
targetType?: string;
|
|
14
|
+
targetId?: string;
|
|
15
|
+
data?: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function actorOf(principal: Principal): AuditActor {
|
|
19
|
+
if (principal.runner) return { type: "runner", id: `${principal.runner.kind}:${principal.runner.id}` };
|
|
20
|
+
return principal.apiKeyId
|
|
21
|
+
? { type: "api_key", id: principal.aegisId, apiKeyId: principal.apiKeyId }
|
|
22
|
+
: { type: "user", id: principal.aegisId };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function recordAudit(
|
|
26
|
+
env: Env,
|
|
27
|
+
actor: AuditActor,
|
|
28
|
+
entry: AuditEntry,
|
|
29
|
+
request?: Request,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const data = {
|
|
32
|
+
...(entry.data ?? {}),
|
|
33
|
+
...("apiKeyId" in actor && actor.apiKeyId ? { apiKeyId: actor.apiKeyId } : {}),
|
|
34
|
+
};
|
|
35
|
+
try {
|
|
36
|
+
await env.DB.prepare(`
|
|
37
|
+
INSERT INTO audit_log (id, workspace_id, project_id, actor_type, actor_id, action, target_type, target_id, ip, user_agent, data_json)
|
|
38
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
39
|
+
`).bind(
|
|
40
|
+
newId("aud"),
|
|
41
|
+
entry.workspaceId,
|
|
42
|
+
entry.projectId ?? null,
|
|
43
|
+
actor.type,
|
|
44
|
+
actor.id,
|
|
45
|
+
entry.action,
|
|
46
|
+
entry.targetType ?? null,
|
|
47
|
+
entry.targetId ?? null,
|
|
48
|
+
request?.headers.get("x-forwarded-for")?.split(",")[0]?.trim() || request?.headers.get("cf-connecting-ip") || null,
|
|
49
|
+
request?.headers.get("user-agent")?.slice(0, 300) ?? null,
|
|
50
|
+
Object.keys(data).length ? JSON.stringify(data) : null,
|
|
51
|
+
).run();
|
|
52
|
+
} catch (error) {
|
|
53
|
+
console.error("[audit] failed to record", entry.action, error);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Audits an action on a project, looking up the workspace it belongs to. */
|
|
58
|
+
export async function auditProject(
|
|
59
|
+
env: Env,
|
|
60
|
+
principal: Principal,
|
|
61
|
+
projectId: string,
|
|
62
|
+
entry: Omit<AuditEntry, "workspaceId" | "projectId">,
|
|
63
|
+
request?: Request,
|
|
64
|
+
): Promise<void> {
|
|
65
|
+
const project = await env.DB.prepare("SELECT workspace_id AS workspaceId FROM projects WHERE id = ?")
|
|
66
|
+
.bind(projectId).first<{ workspaceId: string | null }>();
|
|
67
|
+
if (!project?.workspaceId) return;
|
|
68
|
+
await recordAudit(env, actorOf(principal), { ...entry, workspaceId: project.workspaceId, projectId }, request);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type AuditRow = {
|
|
72
|
+
id: string;
|
|
73
|
+
workspaceId: string;
|
|
74
|
+
projectId: string | null;
|
|
75
|
+
actorType: string;
|
|
76
|
+
actorId: string;
|
|
77
|
+
action: string;
|
|
78
|
+
targetType: string | null;
|
|
79
|
+
targetId: string | null;
|
|
80
|
+
ip: string | null;
|
|
81
|
+
userAgent: string | null;
|
|
82
|
+
data: Record<string, unknown> | null;
|
|
83
|
+
createdAt: string;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export async function listAudit(
|
|
87
|
+
env: Env,
|
|
88
|
+
workspaceId: string,
|
|
89
|
+
{ since, before, limit }: { since?: string; before?: string; limit: number },
|
|
90
|
+
): Promise<AuditRow[]> {
|
|
91
|
+
const rows = await env.DB.prepare(`
|
|
92
|
+
SELECT id, workspace_id AS workspaceId, project_id AS projectId, actor_type AS actorType, actor_id AS actorId,
|
|
93
|
+
action, target_type AS targetType, target_id AS targetId, ip, user_agent AS userAgent,
|
|
94
|
+
data_json AS dataJson, created_at AS createdAt
|
|
95
|
+
FROM audit_log
|
|
96
|
+
WHERE workspace_id = ?
|
|
97
|
+
AND (? IS NULL OR created_at >= ?)
|
|
98
|
+
AND (? IS NULL OR created_at < ?)
|
|
99
|
+
ORDER BY created_at DESC, id DESC
|
|
100
|
+
LIMIT ?
|
|
101
|
+
`).bind(workspaceId, since ?? null, since ?? null, before ?? null, before ?? null, limit)
|
|
102
|
+
.all<Omit<AuditRow, "data"> & { dataJson: string | null }>();
|
|
103
|
+
return rows.results.map(({ dataJson, ...row }) => ({ ...row, data: dataJson ? JSON.parse(dataJson) : null }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const CSV_COLUMNS = ["createdAt", "actorType", "actorId", "action", "targetType", "targetId", "projectId", "ip", "userAgent", "data"] as const;
|
|
107
|
+
|
|
108
|
+
export function auditCsv(rows: AuditRow[]): string {
|
|
109
|
+
const cell = (value: unknown) => {
|
|
110
|
+
const text = value === null || value === undefined ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
|
111
|
+
// Quote everything and neutralise spreadsheet formulas.
|
|
112
|
+
const safe = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text;
|
|
113
|
+
return `"${safe.replaceAll('"', '""')}"`;
|
|
114
|
+
};
|
|
115
|
+
return [CSV_COLUMNS.join(","), ...rows.map((row) => CSV_COLUMNS.map((column) => cell(row[column])).join(","))].join("\r\n") + "\r\n";
|
|
116
|
+
}
|