Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ BETTER_AUTH_SECRET=""
# Subdomains count, so "acme.com" also admits "you@mail.acme.com".
ALLOWED_SIGN_IN=""

# Google is the sign-in method a clone starts with, and the same client is what
# reads Gmail and Calendar. Set both or neither — half a pair is a sign-in
# button that fails at Google.
#
# Leave them empty only if you sign in with your own identity provider, added
# on Settings → SSO. Then there is no Google button and no mail sync, and the
# sign-in page says as much rather than showing you nothing.
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""

Expand All @@ -40,7 +47,9 @@ GOOGLE_CLIENT_SECRET=""
# session cookie covers both.
# AUTH_COOKIE_DOMAIN=""

# The research agent, which is its own deployment.
# The research agent, which is its own deployment. The API reads this too, to
# tell the agent a logo or a photograph is waiting rather than letting it find
# out on its next minute.
# AGENT_URL="http://127.0.0.1:2000"

# Lets a signed-in rep talk to the agent from the contact sheet.
Expand All @@ -50,8 +59,12 @@ GOOGLE_CLIENT_SECRET=""
# secret; the agent verifies it and learns *which rep* is asking. Set the same
# value for both processes. openssl rand -base64 32
#
# Leave it unset and the Agent tab reports that it is not configured. Nothing
# else changes: the agent still runs on its own schedule.
# It also authorises the API's dispatch poke, which is what makes a new
# company's logo appear as it is added instead of on the next minute's tick.
#
# Leave it unset and the Agent tab reports that it is not configured, and the
# poke is skipped rather than sent unauthenticated. Nothing else changes: the
# agent still runs on its own schedule.
# AGENT_BRIDGE_SECRET=""

# PORT="3001"
Expand Down
3 changes: 3 additions & 0 deletions .github/.release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
".": "0.0.0"
}
31 changes: 31 additions & 0 deletions .github/release-please-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
"packages": {
".": {
"release-type": "node",
"changelog-path": "CHANGELOG.md",
"bump-minor-pre-major": true,
"draft": false,
"prerelease": false,
"include-component-in-tag": false,
"changelog-sections": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "refactor", "section": "Refactors" },
{ "type": "docs", "section": "Documentation" },
{ "type": "revert", "section": "Reverts" },
{ "type": "deps", "section": "Dependencies" },
{ "type": "chore", "hidden": true },
{ "type": "test", "hidden": true },
{ "type": "ci", "hidden": true },
{ "type": "build", "hidden": true },
{ "type": "style", "hidden": true }
]
}
},
"bootstrap-sha": "64f154b086cbad6cac232357dc21c954543ef217",
"separate-pull-requests": false,
"pull-request-title-pattern": "chore(release): ${version}",
"pull-request-header": "The changelog below is what will be published as the release notes. Edit the commit subjects, not this PR body — the body is regenerated on every push to main."
}
65 changes: 65 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
name: CI

on:
pull_request:
push:
branches: [main]

concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

permissions:
contents: read

env:
TURBO_TELEMETRY_DISABLED: 1
DO_NOT_TRACK: 1

jobs:
check:
name: check-types, lint, test
runs-on: ubuntu-24.04
timeout-minutes: 20

services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: crm
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d crm"
--health-interval 3s
--health-timeout 5s
--health-retries 20

env:
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/crm?schema=public
ALLOWED_SIGN_IN: example.com
BETTER_AUTH_SECRET: ci-only-secret-regenerate-for-any-real-deployment
API_URL: http://localhost:3001
APP_URL: http://localhost:3000
GOOGLE_CLIENT_ID: ci-only-google-client-id
GOOGLE_CLIENT_SECRET: ci-only-google-client-secret

steps:
- uses: actions/checkout@v5

- uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json

- run: bun install --frozen-lockfile

- name: Apply migrations
run: bun run db:deploy

- run: bun run check-types

- run: bun run lint

- run: bun run test
29 changes: 29 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Release

on:
push:
branches: [main]

concurrency:
group: release
cancel-in-progress: false

permissions:
contents: write
pull-requests: write

jobs:
release-please:
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
released: ${{ steps.release.outputs.release_created }}
tag: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- uses: googleapis/release-please-action@v4
id: release
with:
config-file: .github/release-please-config.json
manifest-file: .github/.release-please-manifest.json
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
11 changes: 3 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ You should always check and see if there are any relevant skill files you should

Please check below, if you're working on anything related review the rules and let the user know you've read them:

## Code Comments
Do not add code comments to the code you write, ever.

## Design
Read @docs/design.md

Expand Down Expand Up @@ -31,11 +34,3 @@ what it does — and if the API reads it, declare it in
Anything a self-hoster might not have is optional, and the code must work
without it: a missing key removes a capability, it never throws. See
`apps/agent/agent/lib/capabilities.ts` for the pattern.

## Contributing / licence

This repository is public and MIT-licensed. Before writing anything that ships:
no real customer names, addresses or company data in fixtures, tests,
screenshots or docs — the seed in `packages/db/prisma/seed.ts` is the source of
demo data. See @CONTRIBUTING.md and @SECURITY.md.

37 changes: 37 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,43 @@ A few things that trip people up:
to `apps/api/src/config/env.validation.ts`. A variable that only exists in someone's shell is a
variable that breaks the next person's clone.

## Releases

Releases are cut by [release-please](https://github.com/googleapis/release-please), so **your commit
subject is the release note**. Write it for somebody reading the changelog six months from now, not
for the diff.

Subjects follow [Conventional Commits](https://www.conventionalcommits.org/), which the history
already does — `feat(api):`, `fix(db):`, `refactor(agent):`. The type decides both the version bump
and the heading it appears under:

| Subject | Bump | Appears under |
| --- | --- | --- |
| `feat(app): …` | minor | Features |
| `fix(db): …` | patch | Fixes |
| `perf:`, `refactor:`, `docs:`, `revert:` | patch | their own heading |
| `chore:`, `ci:`, `test:`, `build:`, `style:` | none | nothing — deliberately invisible |

Nothing is released by the merge itself. Merging to `main` opens or updates a single
`chore(release): 0.2.0` pull request that accumulates the changelog and bumps the version; **merging
that PR** is what tags `v0.2.0` and publishes the GitHub Release. So the notes are reviewable before
they are public, and a stack of merges is one release rather than five.

Two consequences worth knowing:

- **A release PR with nothing in it is not a bug.** A run of `chore:` and `test:` commits bumps
nothing, so no PR appears. That is the type doing its job.
- **The release PR does not run CI.** A PR opened by `GITHUB_TOKEN` cannot trigger workflows — that
is GitHub's own loop guard, not something to work around. It is safe because the PR only ever
touches `CHANGELOG.md`, the root `version`, and the release manifest, and because CI runs again on
`main` after it lands. Setting a `RELEASE_PLEASE_TOKEN` secret (a PAT or a GitHub App token) makes
the PR run CI like any other; the workflow already prefers it and falls back to `GITHUB_TOKEN`, so
it is an upgrade rather than a requirement.

`main` is expected to be green when a tag is cut, and the thing that guarantees that is **branch
protection requiring the `check-types, lint, test` check** — not the release workflow, which cannot
wait on a run in another workflow.

## House style

The repo has opinions, and they're written down where the work happens rather than in a style
Expand Down
44 changes: 35 additions & 9 deletions apps/agent/agent/channels/crm.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,50 @@
import { EnrichmentStatus } from "@crm/db";
import { defineChannel, POST } from "eve/channels";
import { brief, drainAll, taskAuth } from "../lib/dispatch";
import { settle } from "../lib/enrichment";
import { completeTask, taskSubject } from "../lib/tasks";

function taskToken(taskId: string): string {
return `crm:task:${taskId}`;
const TASK_MARKER = "task:";

function authorised(request: Request): boolean {
const secret = process.env.AGENT_BRIDGE_SECRET?.trim();
if (!secret) return false;

return request.headers.get("authorization") === `Bearer ${secret}`;
}

export function taskToken(taskId: string): string {
return `${TASK_MARKER}${taskId}`;
}

function taskFromToken(token: string | undefined): string | null {
export function taskFromToken(token: string | undefined): string | null {
if (!token) return null;
const prefix = "crm:task:";
return token.startsWith(prefix) ? token.slice(prefix.length) : null;

const marker = token.lastIndexOf(TASK_MARKER);
if (marker === -1) return null;

const id = token.slice(marker + TASK_MARKER.length);
return id.length > 0 ? id : null;
}

export default defineChannel({
routes: [
POST(
"/internal/crm/dispatch-only",
async () => new Response("Not found", { status: 404 }),
),
POST("/internal/crm/dispatch", async (request, { send, waitUntil }) => {
if (!authorised(request)) {
return new Response("Unauthorized", { status: 401 });
}

waitUntil(
drainAll((task) =>
send(brief(task), {
auth: taskAuth(task),
continuationToken: taskToken(task.id),
}),
),
);

return new Response(null, { status: 202 });
}),
],

events: {
Expand Down
Loading