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
40 changes: 40 additions & 0 deletions .github/workflows/deploy-dev2.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,46 @@ jobs:
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts

# app.env on the box is the runtime's only env and nothing else writes it, so a
# secret a new feature needs ships as a repo secret and lands here, reviewable
# and repeatable, instead of somebody editing dev2 by hand. Additive: an unset
# secret is skipped, an unchanged value writes nothing, and a change keeps a
# numbered backup of app.env first. Values travel on stdin, never in argv.
- name: Sync secrets into app.env
env:
DEV2_USER: ${{ secrets.DEV2_USER }}
DEV2_HOST: ${{ secrets.DEV2_HOST }}
VALUESERP_API_KEY: ${{ secrets.VALUESERP_API_KEY }}
run: |
lines=""
for key in VALUESERP_API_KEY; do
value=$(printenv "$key" || true)
[ -n "$value" ] && lines="$lines$key=$value"$'\n'
done
if [ -z "$lines" ]; then echo "no secrets to sync"; exit 0; fi
printf '%s' "$lines" | ssh -o BatchMode=yes "$DEV2_USER@$DEV2_HOST" '
set -eu
env_file=/home/anthony/www/outreachgraph.com/app.env
tmp=$(mktemp); cp "$env_file" "$tmp"; changed=""
while IFS= read -r line; do
[ -n "$line" ] || continue
key=${line%%=*}
if grep -qxF -- "$line" "$tmp"; then continue; fi
grep -v "^$key=" "$tmp" > "$tmp.next" || true
printf "%s\n" "$line" >> "$tmp.next"
mv "$tmp.next" "$tmp"; changed="$changed $key"
done
if [ -n "$changed" ]; then
n=1; while [ -e "$env_file.bak-$(printf %03d $n)" ]; do n=$((n+1)); done
cp -p "$env_file" "$env_file.bak-$(printf %03d $n)"
cat "$tmp" > "$env_file"; chmod 600 "$env_file"
echo "app.env updated:$changed"
else
echo "app.env already current"
fi
rm -f "$tmp"
'

- name: Deploy
env:
DEV2_USER: ${{ secrets.DEV2_USER }}
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"@outreachgraph/scoring": "workspace:*",
"@outreachgraph/secrets": "workspace:*",
"@outreachgraph/signals": "workspace:*",
"@profullstack/form-guard": "^0.1.1",
"@profullstack/openprofile": "^0.1.2",
"hono": "^4.6.14",
"zod": "^3.24.1"
Expand Down
45 changes: 45 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ import {
import type {
FetchLike as ProviderFetchLike,
HostLookup,
JobReaderOptions,
WebSearcher,
XOAuthClient,
} from '@outreachgraph/providers';
import {
Expand Down Expand Up @@ -242,6 +244,8 @@ import { autogtmRoutes } from './autogtm';
import { inboxRoutes } from './inbox';
import { crmRoutes, webhookRoutes } from './webhooks';
import { audienceRoutes } from './audience';
import { jobPostRoutes } from './job-posts';
import { clientIp, createThrottles, isQuotedUserAgent, type ThrottleConfig } from './throttle';
import { llmsText, openApiDocument } from './autogtm-docs';
import {
actorFromApiKey,
Expand Down Expand Up @@ -316,6 +320,15 @@ export interface AppOptions {
*/
readonly xFetch?: ProviderFetchLike | undefined;
readonly blueskyFetch?: ProviderFetchLike | undefined;
/**
* Google search for the job-post intake (ValueSERP): postings by keyword,
* people by company. Absent without a key, which leaves pasting URLs.
*/
readonly jobSearcher?: WebSearcher | undefined;
/** Test seam for the job boards and company sites a posting is read from. */
readonly jobReader?: JobReaderOptions | undefined;
/** Overrides for the abuse throttles (`./throttle`); tests shrink them. */
readonly throttles?: ThrottleConfig | undefined;
/**
* Suggests the communities a campaign should listen to.
*
Expand Down Expand Up @@ -655,8 +668,21 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
// ------------------------------------------------------------------- auth
// These sit before the auth guard, since they are how you get a session.
const auth = new Hono<AppEnv>();
const throttles = createThrottles(options.throttles);

/**
* The first thing every public, mail-sending auth route does: refuse the
* known bot signature, then count the attempt against the caller's address.
*/
async function guardPublic(request: Request, name: 'register' | 'forgotByIp' | 'login') {
if (isQuotedUserAgent(request)) {
throw new ApiError(403, 'forbidden', 'request refused');
}
await throttles.take(name, clientIp(request));
}

auth.post('/register', async (c) => {
await guardPublic(c.req.raw, 'register');
const body = await parseBody(c.req.raw, registerSchema);
const result = await registerUser(options.db, body);

Expand Down Expand Up @@ -686,6 +712,7 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
auth.post('/verify/resend', async (c) => {
const actor = await resolveActor(c.req.raw);
if (!actor) throw ApiError.unauthorized();
await throttles.take('verifyResend', actor.userId);

const user = await queryOne<{ email: string; email_verified_at: string | null }>(
options.db,
Expand All @@ -700,6 +727,7 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
});

auth.post('/login', async (c) => {
await guardPublic(c.req.raw, 'login');
const body = await parseBody(c.req.raw, loginSchema);
const session = await login(options.db, body.email, body.password, c.req.header('user-agent'));

Expand Down Expand Up @@ -733,7 +761,11 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
* does not become a way to probe which addresses are registered.
*/
auth.post('/password/forgot', async (c) => {
await guardPublic(c.req.raw, 'forgotByIp');
const body = await parseBody(c.req.raw, forgotPasswordSchema);
// Keyed on the target too: rotating addresses must not buy a stranger
// a fourth reset mail this hour.
await throttles.take('forgotByEmail', body.email.trim().toLowerCase());
const minted = await mintPasswordResetToken(options.db, body.email);

if (minted) {
Expand Down Expand Up @@ -1377,6 +1409,14 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
}),
);

// ------------------------------------------------------------ job posts
//
// Postings by URL or by keyword, and the people behind each one.
api.route(
'/job-posts',
jobPostRoutes({ searcher: options.jobSearcher, reader: options.jobReader, throttles }),
);

// ----------------------------------------------------------------- team
//
// An organization has been a party of one since the schema was written:
Expand Down Expand Up @@ -5515,6 +5555,11 @@ export function createApp(options: AppOptions): Hono<AppEnv> {
// Single error shape for every failure (PRD §24).
app.onError((error, c) => {
if (error instanceof ApiError) {
const retryAfter = (error.details as { retryAfterSeconds?: unknown } | undefined)
?.retryAfterSeconds;
if (error.status === 429 && typeof retryAfter === 'number') {
c.header('retry-after', String(retryAfter));
}
return c.json(
{
error: {
Expand Down
175 changes: 175 additions & 0 deletions apps/api/src/job-posts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
/**
* `/api/v1/job-posts` through the real app and a real temp database.
*
* What matters here: the list is CRUD a viewer can read but not change; a
* keyword search needs a configured searcher and says so when there is none;
* resolving now returns the people with their evidence; and a contact is
* promoted into a named campaign only.
*/

import { afterEach, describe, expect, test } from 'bun:test';
import type { Hono } from 'hono';
import type { WebResult, WebSearcher } from '@outreachgraph/providers';
import { createApp } from './app';
import type { AppEnv, RequestActor } from './context';
import { seedDatabase, SEED, type SeededDatabase } from './test-seed';

let active: SeededDatabase | undefined;

afterEach(() => {
active?.cleanup();
active = undefined;
});

const OWNER: RequestActor = {
userId: SEED.userId,
workspaceId: SEED.workspaceId,
organizationId: SEED.organizationId,
role: 'owner',
};
const VIEWER: RequestActor = { ...OWNER, role: 'viewer' };

const RAYDAR = 'https://apply.workable.com/raydar/j/C39C58F585/';

const searcher: WebSearcher = {
async search(query): Promise<readonly WebResult[]> {
if (query.startsWith('site:apply.workable.com')) {
return [{ link: RAYDAR, title: 'Senior Software Engineer - Raydar' }];
}
if (query.startsWith('site:linkedin.com/in "Raydar"')) {
return [
{
link: 'https://www.linkedin.com/in/noah-kingsdale-9391aa166',
title: 'Noah Kingsdale - VP of Technology at Raydar',
snippet: 'At Raydar we help companies in emerging technologies find talent',
},
];
}
return [];
},
};

const fetchImpl = (async (input: string | URL | Request) => {
const url = String(input instanceof Request ? input.url : input);
if (url.endsWith('/api/v1/accounts/raydar')) {
return Response.json({ name: 'Raydar', url: 'http://raydar.xyz' });
}
if (url.endsWith('/jobs/C39C58F585')) {
return Response.json({ title: 'Senior Software Engineer', remote: true, description: '' });
}
return new Response('', { status: 404 });
}) as typeof fetch;

async function harness(
label: string,
options: { actor?: RequestActor; search?: boolean } = {},
): Promise<Hono<AppEnv>> {
const seeded = await seedDatabase(label);
active = seeded;
return createApp({
db: seeded.db,
authenticate: async () => options.actor ?? OWNER,
...(options.search === false ? {} : { jobSearcher: searcher }),
jobReader: { fetchImpl },
});
}

function send(app: Hono<AppEnv>, method: string, path: string, body?: unknown) {
return app.request(`/api/v1${path}`, {
method,
headers: { 'content-type': 'application/json' },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
}

describe('the list', () => {
test('add, read, edit and remove a posting', async () => {
const app = await harness('job-posts-api-crud');

const created = await send(app, 'POST', '/job-posts', { urls: [RAYDAR, 'nope'] });
expect(created.status).toBe(201);
const { saved, rejected } = (await created.json()) as {
saved: { id: string }[];
rejected: unknown[];
};
expect(saved).toHaveLength(1);
expect(rejected).toHaveLength(1);
const id = saved[0]!.id;

const listed = (await (await send(app, 'GET', '/job-posts')).json()) as {
jobPosts: { id: string }[];
searchEnabled: boolean;
};
expect(listed.jobPosts.map((p) => p.id)).toEqual([id]);
expect(listed.searchEnabled).toBe(true);

const patched = await send(app, 'PATCH', `/job-posts/${id}`, { status: 'applied' });
expect(((await patched.json()) as { jobPost: { status: string } }).jobPost.status).toBe(
'applied',
);
expect((await send(app, 'PATCH', `/job-posts/${id}`, { status: 'bogus' })).status).toBe(400);

expect((await send(app, 'DELETE', `/job-posts/${id}`)).status).toBe(200);
expect((await send(app, 'GET', `/job-posts/${id}`)).status).toBe(404);
});

test('a viewer can read the list but not change it', async () => {
const app = await harness('job-posts-api-viewer', { actor: VIEWER });
expect((await send(app, 'GET', '/job-posts')).status).toBe(200);
expect((await send(app, 'POST', '/job-posts', { url: RAYDAR })).status).toBe(403);
});
});

describe('search, resolve, promote', () => {
test('keyword search adds the postings it finds', async () => {
const app = await harness('job-posts-api-search');
const response = await send(app, 'POST', '/job-posts/search', {
keyword: 'senior software engineer (remote)',
boards: ['workable'],
});
expect(response.status).toBe(200);
const result = (await response.json()) as { saved: { url: string }[]; queries: string[] };
expect(result.saved.map((p) => p.url)).toEqual([RAYDAR]);
expect(result.queries).toEqual(['site:apply.workable.com "senior software engineer" remote']);
});

test('without a search key, keyword search says what is missing', async () => {
const keyless = await harness('job-posts-api-search-keyless', { search: false });
const refused = await send(keyless, 'POST', '/job-posts/search', { keyword: 'engineer' });
expect(refused.status).toBe(400);
expect(await refused.text()).toContain('VALUESERP_API_KEY');
});

test('resolve finds the people, and promote needs a campaign', async () => {
const app = await harness('job-posts-api-resolve');
const { saved } = (await (await send(app, 'POST', '/job-posts', { url: RAYDAR })).json()) as {
saved: { id: string }[];
};
const id = saved[0]!.id;

const resolved = await send(app, 'POST', `/job-posts/${id}/resolve`);
expect(resolved.status).toBe(200);
const { jobPost } = (await resolved.json()) as {
jobPost: { company: string; contacts: { id: string; name: string; role: string }[] };
};
expect(jobPost.company).toBe('Raydar');
expect(jobPost.contacts).toEqual([
expect.objectContaining({ name: 'Noah Kingsdale', role: 'engineering_leader' }),
]);
const contactId = jobPost.contacts[0]!.id;

const noCampaign = await send(
app,
'POST',
`/job-posts/${id}/contacts/${contactId}/promote`,
{},
);
expect(noCampaign.status).toBe(400);

const promoted = await send(app, 'POST', `/job-posts/${id}/contacts/${contactId}/promote`, {
campaignId: SEED.campaignId,
});
expect(promoted.status).toBe(200);
expect(await promoted.json()).toMatchObject({ campaignId: SEED.campaignId, created: true });
});
});
Loading
Loading