Skip to content

Phase 3: approve from anywhere, and team workspaces - #8

Merged
Taskmaster-1 merged 12 commits into
mainfrom
feat/phase3
Oct 28, 2026
Merged

Taskmaster-1 merged 12 commits into
mainfrom
feat/phase3

Conversation

@Taskmaster-1

Copy link
Copy Markdown
Owner

Phase 3 of making SynapseAI stronger: decisions reach you wherever you are, and teams get shared rules and admin sign-off. I laid a shared foundation first, three parallel agents built the features on it, and I integrated and verified them here.

Foundation

  • One decision path for every channel. decisions.ts handles web, push and Slack the same way:
    • edits are re-checked against your rules;
    • undo windows apply;
    • the run is woken or parked;
    • where the decision was made is recorded (tool_calls.decided_via).
  • A notifier. notify/ announces each new approval exactly once (a compare-and-set on tool_calls.notified_at) to its recipients, and reports decisions back to every channel.

Approve from your phone

  • Installable app: a manifest, icons and a service worker.
  • Web Push (VAPID): push notifications with Approve and Deny buttons.
    • Lock-screen approvals still go through the undo window. If your undo window is off, sends and deletes only offer Deny.
    • Device endpoints are limited to known push services, and devices that have gone away are removed.
  • Email alerts (SMTP, opt-in): they link to the task only. There are no one-click approve links, because mail scanners click links.
  • Settings → Notifications: devices, test push, the email toggle, and clear explanations for iPhone, unsupported browsers and an unconfigured server.

Approve from Slack

  • Connection: per-user OAuth; the bot token is sealed at rest.
  • The message: a Block Kit DM with the action, why it's asking, a warning when the task read outside content, and Approve / Deny / Open in Synapse.
  • Button presses: the signature is checked before anything is parsed (HMAC, 5-minute window, constant-time compare). Only the person the message was sent to can act. The message updates wherever the decision is made.

Team workspaces

  • Strictest answer wins: team rules and personal rules both apply, and block > ask an admin > ask > allow. A team block can't be overridden; plan grants can't skip a team block or an admin requirement.
  • "Needs an admin": these calls go to the team's owners and admins (inbox, push and Slack). The member sees "Waiting for an admin".
  • Joining: invite links, single use, with an expiry.
  • Roles: owner, admin and member. A team always keeps at least one owner.
  • Audit export: CSV per RFC 4180, guarded against formula injection.

Fixed while integrating

  • Leaving the team was a loophole. A member could leave to approve their own admin-only request. Leaving or being removed now withdraws those requests and re-queues the run so the agent can respond.
  • Admins and Slack: a team admin can approve a member's request from Slack. There's a test for it (decided_by is the admin, decided_via is slack).
  • Signed Slack requests survive the web proxy. Checked live: a forged request gets 401 and a genuine one 200.
  • Inbox header: it no longer counts "waiting for an admin" items as signed.

Schema

One new migration, 0006_reach_and_teams, plus 0005 for the foundation columns.

Testing

  • 179 tests pass, plus lint, typecheck and the web build. New tests cover:
    • the notifier and decision service;
    • push: fake sender, payloads, removing dead devices, via: push;
    • email: fake transport, no approve links;
    • Slack: fake API, OAuth, signatures, clicks, message updates, admin clicks;
    • teams: composition, invites, roles, admin flow, leaving, audit CSV.
  • Clicked through in the browser as an admin and a member, with fake Gmail and fake Slack: admin inbox, member waiting state, Team settings, read-only team rules, Notifications settings, Slack card. No console errors.

Not verified against the real thing

  • Real Slack workspace delivery.
  • Real push to a device.
  • Real SMTP.

All three are covered by fakes, and each needs credentials to try (see docs/slack.md and docs/notifications.md).

🤖 Generated with Claude Code

…ifier

- decisions.ts: decide()/undo() used by the web routes now, and by push and Slack next.
  Edited inputs are re-checked against block rules; every decision records where it
  was made (tool_calls.decided_via)
- notify/: NotificationChannel interface and a Notifier that announces each new
  approval once (tool_calls.notified_at, compare-and-set) and reports decisions back
- The worker announces approvals when a run starts waiting
- Connect Slack per user (OAuth v2, bot scopes chat:write, im:write, users:read); state is
  sealed and bound to the user; the bot token is sealed; disconnect revokes and deletes
- Slack notification channel: Block Kit DM per recipient with Approve / Deny / Open in
  Synapse, input summary, policy reason and a warning for tainted tasks; message ts stored
  in slack_messages and rewritten when the action is decided anywhere
- POST /api/slack/interactions: signature (v0 HMAC, 5 min skew, constant time) checked over
  the raw body first; only the connected Slack user can act, only on actions sent to them;
  acks at once and decides via decide(..., via: "slack"); double clicks show the current state
- Connections page Slack card (connect, test DM, disconnect, setup guidance), docs/slack.md
  with an app manifest, .env.example, docs/api.md, README
- PWA: app/manifest.ts, icons rasterized from the brand glyph (paper #F4F1EA),
  apple-icon, appleWebApp metadata; public/sw.js registered in production builds
  (or NEXT_PUBLIC_ENABLE_SW=true), no caching
- Web Push (web-push 3.6.7, VAPID): push_subscriptions table with sealed keys;
  /api/push config, subscriptions (list/upsert/delete) and test; endpoints limited
  to known push services; 404/410 forgets the device. Dev generates VAPID keys once
  into .data/vapid.json, production never does
- PushChannel: one notification per approval (tag = toolCallId, Approve/Deny
  actions), one summary per person when several arrive at once, delivered to every
  recipient's devices; skips anything already decided; no lock-screen Approve for
  sends/deletes when the undo window is off
- Service worker Approve/Deny posts the decision with via: "push" (the decision
  route now accepts via: web|push), shows "Approved · sending in Ns" with Undo,
  and opens the task if anything fails
- Email alerts (nodemailer 10.1.0) when SMTP_URL and EMAIL_FROM are set, opt-in via
  user_settings.email_alerts; preview, why, task link, no approve links
- Settings → Notifications: device permission flow with iOS Home Screen guidance,
  device list, send a test, email toggle; graceful unsupported/denied/unconfigured
- docs/notifications.md, docs/api.md, .env.example; tests with a fake push sender
  and fake SMTP transport
# Conflicts:
#	.env.example
#	apps/server/src/app.ts
#	apps/server/src/config.ts
#	apps/server/src/services.ts
#	docs/api.md
#	packages/db/drizzle/meta/0006_snapshot.json
#	packages/db/drizzle/meta/_journal.json
#	packages/db/src/schema.ts
…port

A team's rules are evaluated alongside each member's own and the strictest
answer wins (block > ask an admin decides > ask > allow); a team block can't
be overridden and plan grants can't stand in for an admin. Calls a team rule
reserves for an admin go to the team's owners and admins (decide path,
notifier recipients, admin inbox); the member sees them waiting. Invite by
single-use link, roles with a last-owner guard, team rules editor, and a
formula-safe RFC 4180 audit CSV. Tasks, connections and memory stay personal.
# Conflicts:
#	apps/server/src/app.ts
#	apps/web/app/(app)/settings/page.tsx
#	apps/web/lib/api.ts
#	docs/api.md
#	packages/db/drizzle/meta/0006_snapshot.json
#	packages/db/drizzle/meta/_journal.json
…approve from Slack

- A member who leaves (or is removed) can't approve, alone, what needed an admin: their
  pending admin-only calls are denied and the run re-queued so the agent can respond
- Test: a member's admin-only request reaches the admin's Slack, and the admin approves
  it there (decided_by = admin, decided_via = slack)
@Taskmaster-1
Taskmaster-1 merged commit d3155b1 into main Oct 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant