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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to VouchNet are documented here. This project follows

## Unreleased

### Public API and GitHub Packages client 1.1.0

- Added LinkedIn-comparable directory API under `/api/v1/people/{slug}`,
`/api/v1/organizations/{slug}`, and `/api/v1/jobs` with `directory:read`, prepaid
`v1.directory_read` credits, and the dedicated `oauthApi` rate-limit tier (60/min) plus
`X-RateLimit-*` headers.
- Expanded `@theworker02/vouchnet-api-client` to **1.1.0** with `getPerson`, `getOrganization`, and
`listJobs`; publish via `api-client-v1.1.0` / the Publish VouchNet API Client workflow.
- Documented the API surface and LinkedIn JTBD comparison in `docs/API.md`.

## 1.6.0 - 2026-10-28

### Desktop expansion
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,12 @@ and service; Desktop does not introduce another backend or a separate registrati

## API client package

External employers and ATS integrations can use the published typed OAuth client,
[`@theworker02/vouchnet-api-client`](docs/API_CLIENT.md). It only calls the public VouchNet API;
it does not bypass OAuth scopes, API credits, or service-side rate limits. Package publishing is
an explicit, versioned GitHub Actions release operation.
External employers and ATS integrations can use the published typed OAuth + directory client,
[`@theworker02/vouchnet-api-client`](docs/API_CLIENT.md) on GitHub Packages. It supports Apply with
VouchNet (userinfo / applicant data) and the `directory:read` people, organizations, and jobs APIs
documented in [docs/API.md](docs/API.md). It does not bypass OAuth scopes, prepaid API credits, or
the `oauthApi` rate-limit tier. Package publishing is an explicit `api-client-v*` tag / workflow
operation.

## Security principles

Expand Down
8 changes: 7 additions & 1 deletion apps/web/app/.well-known/oauth-authorization-server/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,13 @@ export function GET(request: Request) {
issuer,
authorization_endpoint: `${issuer}/oauth/authorize`,
token_endpoint: `${issuer}/api/v1/oauth/token`,
scopes_supported: ['profile:read', 'profile:email', 'resume:read', 'skills:verify'],
scopes_supported: [
'profile:read',
'profile:email',
'resume:read',
'skills:verify',
'directory:read',
],
response_types_supported: ['code'],
grant_types_supported: ['authorization_code'],
code_challenge_methods_supported: ['S256'],
Expand Down
4 changes: 3 additions & 1 deletion apps/web/app/api/developer/clients/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ const clientSchema = z
name: z.string().trim().min(2).max(120),
redirectUris: z.array(z.string().max(2048)).min(1).max(10),
scopes: z
.array(z.enum(['profile:read', 'profile:email', 'resume:read', 'skills:verify']))
.array(
z.enum(['profile:read', 'profile:email', 'resume:read', 'skills:verify', 'directory:read']),
)
.min(1)
.max(4),
})
Expand Down
14 changes: 14 additions & 0 deletions apps/web/app/api/developer/discovery/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,27 @@ export function GET(request: Request) {
documentation: `${baseUrl}/developers`,
integrationGuide: `${baseUrl}/developers/integrations`,
serverIntegration: `${baseUrl}/developers/servers`,
apiClientPackage: {
name: '@theworker02/vouchnet-api-client',
registry: 'https://npm.pkg.github.com',
docs: 'https://github.com/theworker02/VouchNet/blob/main/docs/API_CLIENT.md',
},
applyWithVouchNet: {
status: 'available',
authorize: `${baseUrl}/oauth/authorize`,
token: `${baseUrl}/api/oauth/token`,
userinfo: `${baseUrl}/api/oauth/userinfo`,
applicantData: `${baseUrl}/api/v1/oauth/applicant-data`,
registration: `${baseUrl}/settings/developers/clients`,
},
directoryApi: {
status: 'available',
scope: 'directory:read',
people: `${baseUrl}/api/v1/people/{slug}`,
organizations: `${baseUrl}/api/v1/organizations/{slug}`,
jobs: `${baseUrl}/api/v1/jobs`,
rateLimit: { maxRequests: 60, windowSeconds: 60 },
},
mcp: { status: 'planned', documentation: `${baseUrl}/developers/mcp` },
publicProfileTemplate: `${baseUrl}/vouch/{username}`,
authentication: { providerOAuth: 'available', clientRegistration: 'member_managed' },
Expand Down
4 changes: 2 additions & 2 deletions apps/web/app/api/oauth/userinfo/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { rateLimitResponse } from '../../../lib/security/rate-limit-response';
export const runtime = 'nodejs';

export async function GET(request: NextRequest) {
const rateLimit = await enforceRateLimit(request, 'generalApi');
if (!rateLimit.allowed) return rateLimitResponse(rateLimit);
const rateLimit = await enforceRateLimit(request, 'oauthApi');
if (!rateLimit.allowed) return rateLimitResponse(rateLimit, 'oauthApi');
const header = request.headers.get('authorization');
const token = header?.startsWith('Bearer ') ? header.slice(7) : '';
if (token.length === 0) return NextResponse.json({ error: 'invalid_token' }, { status: 401 });
Expand Down
37 changes: 37 additions & 0 deletions apps/web/app/api/v1/jobs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextRequest } from 'next/server';
import { listPublicJobs } from '../../../lib/directory';
import { withOAuthApi } from '../../../lib/oauth-api';

export const runtime = 'nodejs';

/**
* Salary-forward public jobs directory for authorized clients. Source URL remains the application
* authority — VouchNet does not invent ATS credentials for third parties.
*/
export async function GET(request: NextRequest) {
const query = (request.nextUrl.searchParams.get('q') ?? '').trim().slice(0, 80);
const limit = Number(request.nextUrl.searchParams.get('limit') ?? '20');
return withOAuthApi(request, 'directory:read', async () => {
const jobs = await listPublicJobs(query || undefined, Number.isFinite(limit) ? limit : 20);
return {
query: query || null,
jobs: jobs.map((job) => ({
slug: job.slug,
title: job.title,
summary: job.summary,
location: job.location,
workplace_type: job.workplaceType,
employment_type: job.employmentType,
salary_min: job.salaryMin,
salary_max: job.salaryMax,
salary_currency: job.salaryCurrency,
skill_tags: job.skillTags,
source_url: job.sourceUrl,
source_status: job.sourceStatus,
organization_name: job.organizationName,
organization_slug: job.organizationSlug,
job_url: `${process.env.APP_URL ?? 'https://vouchnet.dev'}/jobs/${job.slug}`,
})),
};
});
}
4 changes: 2 additions & 2 deletions apps/web/app/api/v1/oauth/applicant-data/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ export const runtime = 'nodejs';

/** Candidate data remains scope-minimized: resumes require an actual protected-download service. */
export async function GET(request: NextRequest) {
const rateLimit = await enforceRateLimit(request, 'generalApi');
if (!rateLimit.allowed) return rateLimitResponse(rateLimit);
const rateLimit = await enforceRateLimit(request, 'oauthApi');
if (!rateLimit.allowed) return rateLimitResponse(rateLimit, 'oauthApi');
const header = request.headers.get('authorization');
const token = header?.startsWith('Bearer ') ? header.slice(7) : '';
if (token.length === 0) return NextResponse.json({ error: 'invalid_token' }, { status: 401 });
Expand Down
31 changes: 31 additions & 0 deletions apps/web/app/api/v1/organizations/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { NextRequest } from 'next/server';
import { getOrganization } from '../../../../lib/directory';
import { withOAuthApi } from '../../../../lib/oauth-api';

export const runtime = 'nodejs';

/**
* Public organization directory record for authorized clients. Matches the source-reviewed
* company page fields exposed on the web, never claim or membership internals.
*/
export async function GET(request: NextRequest, context: { params: Promise<{ slug: string }> }) {
const { slug } = await context.params;
return withOAuthApi(request, 'directory:read', async () => {
const organization = await getOrganization(slug.trim().slice(0, 80));
if (organization === null) throw new Error('NOT_FOUND');
return {
slug: organization.slug,
name: organization.name,
tagline: organization.tagline,
description: organization.description,
website_url: organization.websiteUrl,
careers_url: organization.careersUrl,
engineering_url: organization.engineeringUrl,
repository_url: organization.repositoryUrl,
headquarters: organization.headquarters,
verification_status: organization.verificationStatus,
technologies: organization.technologies,
organization_url: `${process.env.APP_URL ?? 'https://vouchnet.dev'}/company/${organization.slug}`,
};
});
}
163 changes: 163 additions & 0 deletions apps/web/app/api/v1/people/[slug]/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { NextRequest } from 'next/server';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { GET } from './route';

const { state, ApplyOAuthError } = vi.hoisted(() => {
class ApplyOAuthError extends Error {
constructor(readonly code: string) {
super(code);
}
}
return {
ApplyOAuthError,
state: {
actor: {
tokenId: 'token-1',
userId: 'user-1',
ownerId: 'owner-1',
developerClientId: 'client-1',
scopes: ['directory:read'] as string[],
} as {
tokenId: string;
userId: string;
ownerId: string;
developerClientId: string;
scopes: string[];
} | null,
profile: null as null | {
userId: string;
slug: string;
firstName: string;
lastName: string;
headline: string | null;
location: string | null;
about: string | null;
languages: string[];
earlyMember: boolean;
isPlus: boolean;
visibility: string;
},
charged: false,
rateLimited: false,
},
};
});

vi.mock('../../../../lib/security/rate-limit', () => ({
rateLimitTiers: {
auth: { maxRequests: 5, windowSeconds: 60 },
generalApi: { maxRequests: 100, windowSeconds: 60 },
socialWrite: { maxRequests: 20, windowSeconds: 60 },
desktopRead: { maxRequests: 60, windowSeconds: 60 },
oauthApi: { maxRequests: 60, windowSeconds: 60 },
},
enforceRateLimit: async () =>
state.rateLimited
? { allowed: false as const, retryAfterSeconds: 12 }
: { allowed: true as const },
}));

vi.mock('../../../../lib/apply-oauth', () => ({
ApplyOAuthError,
resolveAccessToken: async () => {
if (state.actor === null) throw new ApplyOAuthError('INVALID_TOKEN');
return state.actor;
},
}));

vi.mock('../../../../lib/people', () => ({
getVisibleProfile: async () => state.profile,
}));

vi.mock('../../../../lib/api-credits', () => ({
chargeApiCall: async () => {
state.charged = true;
return { balanceAfter: 10 };
},
}));

vi.mock('../../../../lib/api-credits-ledger', () => ({
InsufficientCreditsError: class InsufficientCreditsError extends Error {},
}));

vi.mock('../../../../lib/api-credits-response', () => ({
insufficientCreditsResponse: () =>
new Response(JSON.stringify({ error: 'INSUFFICIENT_CREDITS' }), { status: 402 }),
}));

describe('GET /api/v1/people/[slug]', () => {
beforeEach(() => {
state.actor = {
tokenId: 'token-1',
userId: 'user-1',
ownerId: 'owner-1',
developerClientId: 'client-1',
scopes: ['directory:read'],
};
state.profile = {
userId: '11111111-1111-1111-1111-111111111111',
slug: 'ada',
firstName: 'Ada',
lastName: 'Lovelace',
headline: 'Analyst',
location: 'London',
about: 'Notes on the engine',
languages: ['English'],
earlyMember: true,
isPlus: false,
visibility: 'PUBLIC',
};
state.charged = false;
state.rateLimited = false;
});

it('requires a bearer token', async () => {
const response = await GET(new NextRequest('https://vouchnet.dev/api/v1/people/ada'), {
params: Promise.resolve({ slug: 'ada' }),
});
expect(response.status).toBe(401);
expect(state.charged).toBe(false);
});

it('returns a public profile and charges directory credits', async () => {
const response = await GET(
new NextRequest('https://vouchnet.dev/api/v1/people/ada', {
headers: { authorization: 'Bearer vnat_example' },
}),
{ params: Promise.resolve({ slug: 'ada' }) },
);
expect(response.status).toBe(200);
expect(response.headers.get('x-ratelimit-limit')).toBe('60');
expect(await response.json()).toMatchObject({
slug: 'ada',
first_name: 'Ada',
last_name: 'Lovelace',
headline: 'Analyst',
});
expect(state.charged).toBe(true);
});

it('does not charge when the profile is not visible', async () => {
state.profile = null;
const response = await GET(
new NextRequest('https://vouchnet.dev/api/v1/people/missing', {
headers: { authorization: 'Bearer vnat_example' },
}),
{ params: Promise.resolve({ slug: 'missing' }) },
);
expect(response.status).toBe(404);
expect(state.charged).toBe(false);
});

it('enforces the oauthApi rate limit', async () => {
state.rateLimited = true;
const response = await GET(
new NextRequest('https://vouchnet.dev/api/v1/people/ada', {
headers: { authorization: 'Bearer vnat_example' },
}),
{ params: Promise.resolve({ slug: 'ada' }) },
);
expect(response.status).toBe(429);
expect(state.charged).toBe(false);
});
});
32 changes: 32 additions & 0 deletions apps/web/app/api/v1/people/[slug]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { NextRequest } from 'next/server';
import { withOAuthApi } from '../../../../lib/oauth-api';
import { getVisibleProfile } from '../../../../lib/people';

export const runtime = 'nodejs';

/**
* LinkedIn-style people lookup for authorized integration clients. Returns only PUBLIC profile
* fields (or the authorizing member's own profile). Requires the `directory:read` scope.
*/
export async function GET(request: NextRequest, context: { params: Promise<{ slug: string }> }) {
const { slug } = await context.params;
return withOAuthApi(request, 'directory:read', async (actor) => {
const profile = await getVisibleProfile(actor.userId, slug.trim().slice(0, 80));
if (profile === null) throw new Error('NOT_FOUND');
return {
id: profile.userId,
slug: profile.slug,
first_name: profile.firstName,
last_name: profile.lastName,
name: `${profile.firstName} ${profile.lastName}`.trim(),
headline: profile.headline,
location: profile.location,
about: profile.about,
languages: profile.languages,
early_member: profile.earlyMember,
is_plus: profile.isPlus,
profile_url: `${process.env.APP_URL ?? 'https://vouchnet.dev'}/vouch/${profile.slug}`,
visibility: profile.visibility,
};
});
}
5 changes: 5 additions & 0 deletions apps/web/app/components/developer-clients.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function DeveloperClients() {
...(data.get('profileEmail') === 'on' ? ['profile:email'] : []),
...(data.get('skills') === 'on' ? ['skills:verify'] : []),
...(data.get('resume') === 'on' ? ['resume:read'] : []),
...(data.get('directory') === 'on' ? ['directory:read'] : []),
],
}),
});
Expand Down Expand Up @@ -105,6 +106,10 @@ export function DeveloperClients() {
<label className="setting-toggle">
<input name="resume" type="checkbox" /> Request a PDF résumé when available
</label>
<label className="setting-toggle">
<input name="directory" type="checkbox" /> Read the public people, company, and jobs
directory
</label>
<button className="primary" disabled={status === 'saving'} type="submit">
{status === 'saving' ? 'Creating…' : 'Create integration client'}
</button>
Expand Down
Loading
Loading