From b2c59e34c2876a2f044c007a4d2a217fbf5367a7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 13:50:48 +0000 Subject: [PATCH 1/3] feat(api): ship directory API and GitHub Packages client 1.1.0 Add LinkedIn-comparable people/organization/jobs directory endpoints with directory:read, prepaid credits, and an oauthApi rate-limit tier. Expand @theworker02/vouchnet-api-client for GitHub Packages publishing. Co-authored-by: Matthew --- CHANGELOG.md | 10 ++ .../oauth-authorization-server/route.ts | 8 +- apps/web/app/api/developer/clients/route.ts | 4 +- apps/web/app/api/developer/discovery/route.ts | 14 ++ apps/web/app/api/oauth/userinfo/route.ts | 4 +- apps/web/app/api/v1/jobs/route.ts | 37 ++++ .../app/api/v1/oauth/applicant-data/route.ts | 4 +- .../app/api/v1/organizations/[slug]/route.ts | 31 ++++ .../app/api/v1/people/[slug]/route.test.ts | 163 ++++++++++++++++++ apps/web/app/api/v1/people/[slug]/route.ts | 32 ++++ apps/web/app/components/developer-clients.tsx | 5 + .../app/lib/api-credit-entry-points.test.ts | 15 +- apps/web/app/lib/api-credits-config.ts | 3 + apps/web/app/lib/apply-oauth.ts | 62 ++++++- apps/web/app/lib/oauth-api.ts | 54 ++++++ .../app/lib/security/rate-limit-response.ts | 17 +- apps/web/app/lib/security/rate-limit.ts | 5 + docs/API.md | 66 +++++++ docs/API_CLIENT.md | 37 ++-- docs/API_CREDITS.md | 1 + docs/DEVELOPER_PORTAL.md | 10 +- docs/IA_COMPARISON.md | 1 + docs/IMPLEMENTATION_MATRIX.md | 1 + docs/OAUTH.md | 9 +- docs/SECURITY.md | 11 +- packages/api-client/README.md | 18 +- packages/api-client/package.json | 4 +- packages/api-client/src/index.test.ts | 30 ++++ packages/api-client/src/index.ts | 106 ++++++++++++ 29 files changed, 722 insertions(+), 40 deletions(-) create mode 100644 apps/web/app/api/v1/jobs/route.ts create mode 100644 apps/web/app/api/v1/organizations/[slug]/route.ts create mode 100644 apps/web/app/api/v1/people/[slug]/route.test.ts create mode 100644 apps/web/app/api/v1/people/[slug]/route.ts create mode 100644 apps/web/app/lib/oauth-api.ts create mode 100644 docs/API.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d78ab..7782247 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/apps/web/app/.well-known/oauth-authorization-server/route.ts b/apps/web/app/.well-known/oauth-authorization-server/route.ts index 85bbf44..d53894d 100644 --- a/apps/web/app/.well-known/oauth-authorization-server/route.ts +++ b/apps/web/app/.well-known/oauth-authorization-server/route.ts @@ -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'], diff --git a/apps/web/app/api/developer/clients/route.ts b/apps/web/app/api/developer/clients/route.ts index 71d7f75..89de4d6 100644 --- a/apps/web/app/api/developer/clients/route.ts +++ b/apps/web/app/api/developer/clients/route.ts @@ -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), }) diff --git a/apps/web/app/api/developer/discovery/route.ts b/apps/web/app/api/developer/discovery/route.ts index 4b5a84a..c8aa3c5 100644 --- a/apps/web/app/api/developer/discovery/route.ts +++ b/apps/web/app/api/developer/discovery/route.ts @@ -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' }, diff --git a/apps/web/app/api/oauth/userinfo/route.ts b/apps/web/app/api/oauth/userinfo/route.ts index 48bba09..60365a5 100644 --- a/apps/web/app/api/oauth/userinfo/route.ts +++ b/apps/web/app/api/oauth/userinfo/route.ts @@ -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 }); diff --git a/apps/web/app/api/v1/jobs/route.ts b/apps/web/app/api/v1/jobs/route.ts new file mode 100644 index 0000000..6dd9b3c --- /dev/null +++ b/apps/web/app/api/v1/jobs/route.ts @@ -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}`, + })), + }; + }); +} diff --git a/apps/web/app/api/v1/oauth/applicant-data/route.ts b/apps/web/app/api/v1/oauth/applicant-data/route.ts index 46b51e0..6947f1d 100644 --- a/apps/web/app/api/v1/oauth/applicant-data/route.ts +++ b/apps/web/app/api/v1/oauth/applicant-data/route.ts @@ -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 }); diff --git a/apps/web/app/api/v1/organizations/[slug]/route.ts b/apps/web/app/api/v1/organizations/[slug]/route.ts new file mode 100644 index 0000000..a2547c5 --- /dev/null +++ b/apps/web/app/api/v1/organizations/[slug]/route.ts @@ -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}`, + }; + }); +} diff --git a/apps/web/app/api/v1/people/[slug]/route.test.ts b/apps/web/app/api/v1/people/[slug]/route.test.ts new file mode 100644 index 0000000..7e3d901 --- /dev/null +++ b/apps/web/app/api/v1/people/[slug]/route.test.ts @@ -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); + }); +}); diff --git a/apps/web/app/api/v1/people/[slug]/route.ts b/apps/web/app/api/v1/people/[slug]/route.ts new file mode 100644 index 0000000..7aeec48 --- /dev/null +++ b/apps/web/app/api/v1/people/[slug]/route.ts @@ -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, + }; + }); +} diff --git a/apps/web/app/components/developer-clients.tsx b/apps/web/app/components/developer-clients.tsx index 4846694..c535843 100644 --- a/apps/web/app/components/developer-clients.tsx +++ b/apps/web/app/components/developer-clients.tsx @@ -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'] : []), ], }), }); @@ -105,6 +106,10 @@ export function DeveloperClients() { + diff --git a/apps/web/app/lib/api-credit-entry-points.test.ts b/apps/web/app/lib/api-credit-entry-points.test.ts index c516ee0..d5c5f38 100644 --- a/apps/web/app/lib/api-credit-entry-points.test.ts +++ b/apps/web/app/lib/api-credit-entry-points.test.ts @@ -34,13 +34,24 @@ const gate = /insufficientCreditsResponse|chargeApiCall|spendCredits/; function isGated(file: string, seen = new Set()): boolean { if (seen.has(file)) return false; seen.add(file); + if (!existsSync(file)) return false; const source = readFileSync(file, 'utf8'); if (gate.test(source)) return true; // Aliases such as candidate-profile and v1/oauth/token delegate to a gated route. - const delegates = [...source.matchAll(/from '(\.[^']+\/route)'/g)].map((match) => + const routeDelegates = [...source.matchAll(/from '(\.[^']+\/route)'/g)].map((match) => join(file, '..', `${match[1]}.ts`), ); - return delegates.length > 0 && delegates.every((target) => isGated(target, seen)); + if (routeDelegates.length > 0 && routeDelegates.every((target) => isGated(target, seen))) + return true; + // Shared helpers such as oauth-api.ts own the chargeApiCall / spendCredits call site. + const helpers = [...source.matchAll(/from '(\.[^']+)'/g)] + .map((match) => match[1]) + .filter((specifier) => !specifier.endsWith('/route')) + .flatMap((specifier) => { + const base = join(file, '..', specifier); + return [`${base}.ts`, join(base, 'index.ts')]; + }); + return helpers.some((target) => isGated(target, seen)); } describe('public API and MCP entry points', () => { diff --git a/apps/web/app/lib/api-credits-config.ts b/apps/web/app/lib/api-credits-config.ts index 958f663..84b812d 100644 --- a/apps/web/app/lib/api-credits-config.ts +++ b/apps/web/app/lib/api-credits-config.ts @@ -35,6 +35,8 @@ export const apiCallCosts = { 'oauth.userinfo': 1, /** GET /api/v1/oauth/applicant-data and its /api/v1/oauth/candidate-profile alias. */ 'v1.applicant_data': 2, + /** Public directory reads: people, organizations, jobs under /api/v1/*. */ + 'v1.directory_read': 1, /** Reserved for each MCP tool call once the MCP gateway transport ships. */ 'mcp.tool_call': 1, } as const satisfies Record; @@ -45,6 +47,7 @@ export const apiOperationLabels: Record = { 'oauth.token': 'Token exchange', 'oauth.userinfo': 'User info', 'v1.applicant_data': 'Applicant data', + 'v1.directory_read': 'Directory read', 'mcp.tool_call': 'MCP tool call', }; diff --git a/apps/web/app/lib/apply-oauth.ts b/apps/web/app/lib/apply-oauth.ts index 3f8c57a..c1dac73 100644 --- a/apps/web/app/lib/apply-oauth.ts +++ b/apps/web/app/lib/apply-oauth.ts @@ -3,8 +3,15 @@ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from import { createSqlClient } from '@nexus/db'; import { spendCredits } from './api-credits-ledger'; -const availableScopes = ['profile:read', 'profile:email', 'resume:read', 'skills:verify'] as const; +const availableScopes = [ + 'profile:read', + 'profile:email', + 'resume:read', + 'skills:verify', + 'directory:read', +] as const; export type ApplyScope = (typeof availableScopes)[number]; +export const applyScopes = availableScopes; const authorizationCodeLifetimeMs = 5 * 60 * 1000; const accessTokenLifetimeMs = 60 * 60 * 1000; @@ -327,6 +334,59 @@ export async function exchangeAuthorizationCode(input: { * owner's prepaid credits for the call. The charge commits in the same transaction as the read, so * invalid tokens, missing scopes, and server errors are never charged. */ +export type AccessTokenActor = { + tokenId: string; + userId: string; + ownerId: string; + developerClientId: string; + scopes: ApplyScope[]; +}; + +/** + * Resolves a third-party bearer token without charging credits. Callers charge only after the + * requested work succeeds so invalid tokens, missing scopes, and not-found reads stay free. + */ +export async function resolveAccessToken( + token: string, + requiredScope: ApplyScope, +): Promise { + const sql = database(); + try { + const rows = await sql< + { + token_id: string; + scopes: unknown; + user_id: string; + developer_client_id: string; + owner_id: string; + }[] + >` + SELECT token.id AS token_id,token.scopes,token.user_id,client.id AS developer_client_id,client.owner_id + FROM oauth_access_tokens token + JOIN developer_clients client ON client.id=token.client_id AND client.revoked_at IS NULL + WHERE token.token_hash=${opaqueHash(token)} AND token.revoked_at IS NULL AND token.expires_at>now() + `; + const record = rows[0]; + if (record === undefined) throw new ApplyOAuthError('INVALID_TOKEN'); + const scopes = Array.isArray(record.scopes) + ? record.scopes.filter((scope): scope is ApplyScope => + availableScopes.includes(scope as ApplyScope), + ) + : []; + if (!scopes.includes(requiredScope)) throw new ApplyOAuthError('INVALID_TOKEN'); + await sql`UPDATE oauth_access_tokens SET last_used_at=now() WHERE id=${record.token_id}`; + return { + tokenId: record.token_id, + userId: record.user_id, + ownerId: record.owner_id, + developerClientId: record.developer_client_id, + scopes, + }; + } finally { + await sql.end({ timeout: 1 }); + } +} + export async function profileForAccessToken( token: string, operation: 'oauth.userinfo' | 'v1.applicant_data', diff --git a/apps/web/app/lib/oauth-api.ts b/apps/web/app/lib/oauth-api.ts new file mode 100644 index 0000000..8aa63b1 --- /dev/null +++ b/apps/web/app/lib/oauth-api.ts @@ -0,0 +1,54 @@ +import 'server-only'; + +import { NextRequest, NextResponse } from 'next/server'; +import { chargeApiCall } from './api-credits'; +import { InsufficientCreditsError } from './api-credits-ledger'; +import { insufficientCreditsResponse } from './api-credits-response'; +import { + ApplyOAuthError, + resolveAccessToken, + type AccessTokenActor, + type ApplyScope, +} from './apply-oauth'; +import { enforceRateLimit } from './security/rate-limit'; +import { rateLimitHeaders, rateLimitResponse } from './security/rate-limit-response'; + +function bearerToken(request: NextRequest): string { + const header = request.headers.get('authorization'); + return header?.startsWith('Bearer ') ? header.slice(7) : ''; +} + +/** + * Shared gate for third-party OAuth resource APIs: rate limit → bearer auth → scope check → + * execute work → charge credits only on success. + */ +export async function withOAuthApi( + request: NextRequest, + requiredScope: ApplyScope, + work: (actor: AccessTokenActor) => Promise, +): Promise { + const rateLimit = await enforceRateLimit(request, 'oauthApi'); + if (!rateLimit.allowed) return rateLimitResponse(rateLimit, 'oauthApi'); + const token = bearerToken(request); + if (token.length === 0) return NextResponse.json({ error: 'invalid_token' }, { status: 401 }); + try { + const actor = await resolveAccessToken(token, requiredScope); + const payload = await work(actor); + await chargeApiCall({ + userId: actor.ownerId, + operation: 'v1.directory_read', + developerClientId: actor.developerClientId, + }); + return NextResponse.json(payload, { + headers: { 'cache-control': 'no-store', ...rateLimitHeaders('oauthApi') }, + }); + } catch (error) { + if (error instanceof InsufficientCreditsError) + return insufficientCreditsResponse(error, request.url); + if (error instanceof ApplyOAuthError) + return NextResponse.json({ error: 'invalid_token' }, { status: 401 }); + if (error instanceof Error && error.message === 'NOT_FOUND') + return NextResponse.json({ error: 'not_found' }, { status: 404 }); + return NextResponse.json({ error: 'server_error' }, { status: 503 }); + } +} diff --git a/apps/web/app/lib/security/rate-limit-response.ts b/apps/web/app/lib/security/rate-limit-response.ts index 7f233ab..4c2c96a 100644 --- a/apps/web/app/lib/security/rate-limit-response.ts +++ b/apps/web/app/lib/security/rate-limit-response.ts @@ -1,16 +1,29 @@ import type { NextResponse } from 'next/server'; import { NextResponse as Response } from 'next/server'; -import type { RateLimitDecision } from './rate-limit'; +import { rateLimitTiers, type RateLimitDecision, type RateLimitTier } from './rate-limit'; + +/** LinkedIn-style rate-limit headers for third-party API responses. */ +export function rateLimitHeaders(tier: RateLimitTier): Record { + const policy = rateLimitTiers[tier]; + return { + 'X-RateLimit-Limit': String(policy.maxRequests), + 'X-RateLimit-Policy': `${policy.maxRequests};w=${policy.windowSeconds}`, + }; +} export function rateLimitResponse( decision: Exclude, + tier: RateLimitTier = 'generalApi', ): NextResponse { const code = decision.unavailable ? 'RATE_LIMIT_UNAVAILABLE' : 'RATE_LIMITED'; return Response.json( { success: false, error: { code, retryAfterSeconds: decision.retryAfterSeconds } }, { status: decision.unavailable ? 503 : 429, - headers: { 'Retry-After': String(decision.retryAfterSeconds) }, + headers: { + 'Retry-After': String(decision.retryAfterSeconds), + ...rateLimitHeaders(tier), + }, }, ); } diff --git a/apps/web/app/lib/security/rate-limit.ts b/apps/web/app/lib/security/rate-limit.ts index 41dff61..60899bc 100644 --- a/apps/web/app/lib/security/rate-limit.ts +++ b/apps/web/app/lib/security/rate-limit.ts @@ -11,6 +11,11 @@ export const rateLimitTiers = { socialWrite: { maxRequests: 20, windowSeconds: 60 }, /** Desktop Bearer reads (search/jobs/opportunities) — tighter than general API. */ desktopRead: { maxRequests: 60, windowSeconds: 60 }, + /** + * Third-party OAuth resource APIs (userinfo, applicant data, directory). Keeps abusive + * integration clients from exhausting the general API budget shared with first-party routes. + */ + oauthApi: { maxRequests: 60, windowSeconds: 60 }, } as const; export type RateLimitTier = keyof typeof rateLimitTiers; diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..63a03ef --- /dev/null +++ b/docs/API.md @@ -0,0 +1,66 @@ +# VouchNet public API + +VouchNet exposes a LinkedIn-comparable developer API for authorized integration clients: OAuth 2.0 +member consent, prepaid credits, and Redis-backed rate limits. It is **not** an open scrape surface +and it does not clone LinkedIn’s brand, endpoints, or partner program. + +## GitHub Packages client + +Install the typed client from GitHub Packages: + +```text +@theworker02/vouchnet-api-client +``` + +See [API_CLIENT.md](API_CLIENT.md) for `.npmrc`, PKCE, and error handling. Publishing uses the +`Publish VouchNet API Client` workflow on tags `api-client-v*`. + +## Side-by-side with LinkedIn’s API model + +| Capability | LinkedIn (typical partner/API) | VouchNet | +| -------------------------------------- | --------------------------------- | ----------------------------------------------------- | +| Authorization | OAuth 2.0 + product scopes | OAuth 2.0 + PKCE, exact redirect URIs | +| Member profile of the authorizing user | `/v2/userinfo`, profile products | `GET /api/oauth/userinfo` (`profile:read`) | +| Applicant / hiring payload | Partner Apply / profile products | `GET /api/v1/oauth/applicant-data` | +| People directory lookup | Profile API (restricted) | `GET /api/v1/people/{slug}` (`directory:read`) | +| Organization pages | Organization APIs | `GET /api/v1/organizations/{slug}` (`directory:read`) | +| Jobs | Job Posting / search partner APIs | `GET /api/v1/jobs` (`directory:read`) | +| Abuse controls | App + member rate limits | `oauthApi` 60/min + prepaid credits + 402 | +| Installable SDK | Official SDKs | `@theworker02/vouchnet-api-client` on GitHub Packages | + +Deliberate non-goals vs LinkedIn: no unrestricted connection graph export, no message spam APIs, +no reaction inflation, no scrape-friendly unauthenticated bulk dump. + +## Scopes + +| Scope | Purpose | +| ---------------- | ----------------------------------------------------- | +| `profile:read` | Name, headline, profile URL of the authorizing member | +| `profile:email` | Verified email of the authorizing member | +| `resume:read` | Résumé when a protected download path exists | +| `skills:verify` | Verified skills when available | +| `directory:read` | Public people, companies, and jobs directory | + +Members approve scopes at `/oauth/authorize`. Clients register allowed scopes under +Settings → Developer center → Integration clients. + +## Rate limits + +| Tier | Limit | Applies to | +| ---------- | ------------------ | --------------------------------------------- | +| `auth` | 5 requests/minute | Token exchange | +| `oauthApi` | 60 requests/minute | userinfo, applicant data, directory endpoints | + +Responses include `Retry-After`, `X-RateLimit-Limit`, and `X-RateLimit-Policy`. Exceeded quotas +return HTTP `429`. Production fails closed with `503` if Redis is unavailable. + +## Credits + +Every successful third-party call spends the **client owner’s** prepaid credits. See +[API_CREDITS.md](API_CREDITS.md). Directory reads cost `v1.directory_read` (1 credit). Not-found +and auth failures are not charged. + +## Machine-readable discovery + +`GET /api/developer/discovery` lists authorize/token/userinfo URLs, directory endpoints, package +coordinates, and MCP status. diff --git a/docs/API_CLIENT.md b/docs/API_CLIENT.md index e6dea6f..d192d3c 100644 --- a/docs/API_CLIENT.md +++ b/docs/API_CLIENT.md @@ -1,12 +1,14 @@ # VouchNet API client -VouchNet publishes an installable, typed OAuth client as a public GitHub Package: +VouchNet publishes an installable, typed OAuth + directory client as a public GitHub Package: ```text @theworker02/vouchnet-api-client ``` -The package is intentionally thin. It calls the same public OAuth endpoints used by direct integrations; it does **not** proxy requests, grant additional scopes, or bypass VouchNet's API-credit and rate-limit enforcement. +The package is intentionally thin. It calls the same public OAuth and `/api/v1` endpoints used by +direct integrations; it does **not** proxy requests, grant additional scopes, or bypass VouchNet's +API-credit and rate-limit enforcement. ## Supported calls @@ -14,8 +16,15 @@ The package is intentionally thin. It calls the same public OAuth endpoints used - Exchange a one-time authorization code from a trusted backend. - Fetch scope-authorized profile data from `/api/oauth/userinfo`. - Fetch the scope-minimized applicant payload from `/api/v1/oauth/applicant-data`. +- Read the public directory with `directory:read`: + - `getPerson(accessToken, slug)` → `/api/v1/people/{slug}` + - `getOrganization(accessToken, slug)` → `/api/v1/organizations/{slug}` + - `listJobs(accessToken, { query, limit })` → `/api/v1/jobs` -Current profile data availability is documented by the runtime API responses. In particular, resume, work-history, and skill graph fields remain empty until VouchNet has a verified, consented persistence and protected-download implementation for those fields. Integrations must handle the documented `[]` and `null` values rather than infer missing data. +Current profile data availability is documented by the runtime API responses. In particular, +resume, work-history, and skill graph fields remain empty until VouchNet has a verified, consented +persistence and protected-download implementation for those fields. Integrations must handle the +documented `[]` and `null` values rather than infer missing data. ## Install @@ -29,20 +38,26 @@ Current profile data availability is documented by the runtime API responses. In npm install @theworker02/vouchnet-api-client ``` -Use a GitHub personal access token with package-read permissions. Do not commit that token. +Use a GitHub personal access token with `read:packages` (and SSO authorization if required). Do +not commit that token. ## Enforcement remains server-side The VouchNet service, not the package, enforces these controls: -| Endpoint class | Limit | Behavior | -| ---------------------------- | ------------------------: | -------------------------- | -| OAuth token exchange | 5 requests/minute | `429` with `Retry-After` | -| OAuth resource API | 100 requests/minute | `429` with `Retry-After` | -| Metered developer operations | depends on credit balance | `402 INSUFFICIENT_CREDITS` | +| Endpoint class | Limit | Behavior | +| ------------------------------ | ------------------------: | -------------------------- | +| OAuth token exchange | 5 requests/minute | `429` with `Retry-After` | +| OAuth / directory resource API | 60 requests/minute | `429` + `X-RateLimit-*` | +| Metered developer operations | depends on credit balance | `402 INSUFFICIENT_CREDITS` | -The client turns non-successful HTTP responses into `VouchNetApiError`, including `status`, `code`, the parsed response body, and `retryAfterSeconds` when the server supplies it. Consumers should wait until that time instead of retrying concurrently. +The client turns non-successful HTTP responses into `VouchNetApiError`, including `status`, `code`, +the parsed response body, and `retryAfterSeconds` when the server supplies it. Consumers should +wait until that time instead of retrying concurrently. ## Publishing -The package is built from `packages/api-client` and is configured for the `https://npm.pkg.github.com` registry. Publishing remains an explicit release operation; see the repository release workflow before publishing a version. +The package is built from `packages/api-client` and is configured for the +`https://npm.pkg.github.com` registry. Publish by tagging `api-client-vX.Y.Z` (matching +`packages/api-client/package.json`) or by running the **Publish VouchNet API Client** workflow +manually with the expected version. See [API.md](API.md) for the full API surface. diff --git a/docs/API_CREDITS.md b/docs/API_CREDITS.md index 79a96a4..9cdfe25 100644 --- a/docs/API_CREDITS.md +++ b/docs/API_CREDITS.md @@ -22,6 +22,7 @@ The settings UI reads the same module, so the page always shows what the server | `oauth.token` | `POST /api/oauth/token`, `POST /api/v1/oauth/token` | 1 | | `oauth.userinfo` | `GET /api/oauth/userinfo` | 1 | | `v1.applicant_data` | `GET /api/v1/oauth/applicant-data`, `GET /api/v1/oauth/candidate-profile` | 2 | +| `v1.directory_read` | `GET /api/v1/people/{slug}`, `/api/v1/organizations/{slug}`, `/api/v1/jobs` | 1 | | `mcp.tool_call` | Each MCP tool call (reserved; the MCP transport is not live yet, see below) | 1 | To change a price, edit `apiCallCosts` or `creditTopUpPackages` in that file. Costs must be diff --git a/docs/DEVELOPER_PORTAL.md b/docs/DEVELOPER_PORTAL.md index f3c54ca..3e2265f 100644 --- a/docs/DEVELOPER_PORTAL.md +++ b/docs/DEVELOPER_PORTAL.md @@ -21,6 +21,10 @@ The discovery endpoint intentionally reports MCP client registration as unavaila updated only when MCP credential issuance, scope enforcement, audit events, revocation, and human approval flows are truly live. -Third-party calls made with an integration client's credentials (token exchange, `userinfo`, and -applicant data) spend the client owner's prepaid credits. The Developer center shows the balance, -recent usage, and add-credit buttons; see [Prepaid API and MCP credits](API_CREDITS.md). +Third-party calls made with an integration client's credentials (token exchange, `userinfo`, +applicant data, and `directory:read` people/company/jobs lookups) spend the client owner's prepaid +credits. The Developer center shows the balance, recent usage, and add-credit buttons; see +[Prepaid API and MCP credits](API_CREDITS.md). + +The typed client ships on GitHub Packages as `@theworker02/vouchnet-api-client`. Full surface and +LinkedIn-comparable notes live in [API.md](API.md) and [API_CLIENT.md](API_CLIENT.md). diff --git a/docs/IA_COMPARISON.md b/docs/IA_COMPARISON.md index daa1c6c..0e08d7f 100644 --- a/docs/IA_COMPARISON.md +++ b/docs/IA_COMPARISON.md @@ -66,6 +66,7 @@ trust semantics. | Find freelance / project work | Services / Open to work fragments | `/opportunities`, Desktop Opportunities | Yes | | Prove credibility | Endorsements / Premium badges | Signed vouches, identity verification, DOMAIN_VERIFIED orgs | Stronger | | Keep a desktop workspace | LinkedIn Desktop / mobile apps | VouchNet Desktop (Tauri, PKCE device auth) | Yes | +| Integrate via a developer API | LinkedIn partner / Profile APIs | OAuth + directory API + GitHub Packages client | Yes | Deliberate non-goals: no reaction inflation, no follower leaderboards, no infinite-scroll-bait layout, no promoted content, no LinkedIn brand or API mimicry. Anything that exists only to raise diff --git a/docs/IMPLEMENTATION_MATRIX.md b/docs/IMPLEMENTATION_MATRIX.md index 036e722..bbe8874 100644 --- a/docs/IMPLEMENTATION_MATRIX.md +++ b/docs/IMPLEMENTATION_MATRIX.md @@ -22,6 +22,7 @@ path. Prefer this matrix over older Phase 5 notes when they disagree. | Desktop client | device auth (`0027`) | feed, messages, activity, network, search, jobs, opportunities, saved | Tauri Windows client (1.6) | package build + API unit | PARTIAL | | Volunteer moderation | complete (`0024`) | apply/report/queue | moderation + admin | none | PARTIAL | | Prepaid API credits | complete (`0029`) | Stripe Checkout + metering | account + developer center | inventory test | PARTIAL | +| Public OAuth + directory API | complete | userinfo, applicant, people/orgs/jobs + `oauthApi` limits | Developer center + GitHub Packages client | API unit | PARTIAL | | Privacy / visibility settings | complete | settings APIs | `/settings/visibility` | partial | PARTIAL | | MCP gateway / approval protocol | contracts only | not publicly live | developer docs only | unit contracts | MISSING | | Audit / trust runtime | partial schema | partial contracts | admin/error telemetry only | partial | PARTIAL | diff --git a/docs/OAUTH.md b/docs/OAUTH.md index 79e7225..3aef6d2 100644 --- a/docs/OAUTH.md +++ b/docs/OAUTH.md @@ -13,11 +13,12 @@ Client secrets are shown once and retained only as HMAC hashes. Use the followin - User info: `GET /api/oauth/userinfo` with `Authorization: Bearer `. - Candidate profile: `GET /api/v1/oauth/candidate-profile` with `Authorization: Bearer `. -Supported scopes are `profile:read` (profile URL, name, and headline) and `profile:email` -(verified email). Each member sees and approves the exact requested access before VouchNet sends a +Supported scopes are `profile:read` (profile URL, name, and headline), `profile:email` (verified +email), `resume:read`, `skills:verify`, and `directory:read` (public people, companies, and jobs +directory). Each member sees and approves the exact requested access before VouchNet sends a short-lived authorization code. Access tokens expire after one hour; revoking the integration -invalidates all of its tokens. Resumes, private messages, passwords, browser sessions, and social -actions are never shared through this flow. +invalidates all of its tokens. Private messages, passwords, browser sessions, and social actions +are never shared through this flow. VouchNet uses server-side OAuth authorization-code flows with state validation and PKCE. Provider secrets stay only in the deployment environment; do not expose them in browser code, GitHub, diff --git a/docs/SECURITY.md b/docs/SECURITY.md index e3f8fc4..35a5865 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -50,11 +50,12 @@ used to report a suspected account, authorization, credential, or data-exposure separately from database-backed services. - High-risk authentication/OAuth operations use a Redis-backed atomic sliding window (5 per minute per hashed request subject). Social writes use 20 per minute. Desktop Bearer reads - (search/jobs/opportunities) use 60 per minute under the `desktopRead` tier. Production fails - closed with a 503 if Redis is unavailable; development permits local work without Redis. A - rejected quota returns a JSON error envelope, HTTP 429, and `Retry-After`. Desktop connection and - saved-post writes reuse the social-write tier and authenticate with the device Bearer token - instead of cookie CSRF. + (search/jobs/opportunities) use 60 per minute under the `desktopRead` tier. Third-party OAuth + resource APIs (userinfo, applicant data, directory) use the `oauthApi` tier at 60 per minute and + emit `X-RateLimit-Limit` / `X-RateLimit-Policy`. Production fails closed with a 503 if Redis is + unavailable; development permits local work without Redis. A rejected quota returns a JSON error + envelope, HTTP 429, and `Retry-After`. Desktop connection and saved-post writes reuse the + social-write tier and authenticate with the device Bearer token instead of cookie CSRF. - The implementation stores only a hash of the actor-or-anonymous subject, IP address, and a bounded user-agent portion in the Redis key. It does not collect TLS JA3/JA4 fingerprints because those are not reliably exposed to a Netlify application runtime. Cloudflare is the appropriate diff --git a/packages/api-client/README.md b/packages/api-client/README.md index edb0dcc..5a37381 100644 --- a/packages/api-client/README.md +++ b/packages/api-client/README.md @@ -1,6 +1,6 @@ # VouchNet API client -`@theworker02/vouchnet-api-client` is the typed JavaScript client for the public VouchNet OAuth 2.0 API. It supports the authorization-code flow with PKCE, token exchange, OpenID-style user information, and the scope-minimized applicant-data response. +`@theworker02/vouchnet-api-client` is the typed JavaScript client for the public VouchNet OAuth 2.0 and directory API. It supports the authorization-code flow with PKCE, token exchange, OpenID-style user information, applicant data, and `directory:read` lookups for people, organizations, and jobs. It is a client, not a bypass: VouchNet continues to enforce exact redirect-URI matching, PKCE, OAuth scopes, prepaid API credits, and server-side rate limits. A `429` is represented by `VouchNetApiError` and includes the server's retry interval. @@ -52,12 +52,22 @@ const profile = await client.getUserInfo(token.accessToken); Never put a VouchNet client secret in browser code, a mobile binary, a public repository, or an issue. Use a backend-for-frontend to perform the token exchange. +## Directory API (`directory:read`) + +```ts +const person = await client.getPerson(token.accessToken, 'ada'); +const organization = await client.getOrganization(token.accessToken, 'vouchnet-labs'); +const { jobs } = await client.listJobs(token.accessToken, { query: 'typescript', limit: 20 }); +``` + +Request the `directory:read` scope when creating the authorization URL and when registering the integration client. + ## Rate limits and billing - OAuth token requests: **5 requests/minute** per request fingerprint. -- API resource reads: **100 requests/minute** per request fingerprint. -- Every client request is additionally subject to the developer account's API-credit balance. +- OAuth and directory resource reads: **60 requests/minute** (`oauthApi` tier) with `X-RateLimit-Limit` / `X-RateLimit-Policy`. +- Every successful client request is additionally subject to the developer account's API-credit balance. On `429`, read `error.retryAfterSeconds` and wait before retrying. Do not implement parallel retry loops. On `402`, ask the developer account owner to add API credits in VouchNet. -See the hosted integration guide at [vouchnet.dev/developers/docs/apply-with-vouch](https://vouchnet.dev/developers/docs/apply-with-vouch). +See [docs/API.md](../../docs/API.md) and the Developer Center at [vouchnet.dev/developers](https://vouchnet.dev/developers). diff --git a/packages/api-client/package.json b/packages/api-client/package.json index ae1177d..c2cec51 100644 --- a/packages/api-client/package.json +++ b/packages/api-client/package.json @@ -1,7 +1,7 @@ { "name": "@theworker02/vouchnet-api-client", - "version": "1.0.0", - "description": "Typed OAuth 2.0 client for the VouchNet API.", + "version": "1.1.0", + "description": "Typed OAuth 2.0 and directory API client for VouchNet (GitHub Packages).", "license": "MIT", "type": "module", "sideEffects": false, diff --git a/packages/api-client/src/index.test.ts b/packages/api-client/src/index.test.ts index e62b08c..3dec428 100644 --- a/packages/api-client/src/index.test.ts +++ b/packages/api-client/src/index.test.ts @@ -48,4 +48,34 @@ describe('VouchNet API client', () => { status: 502, } satisfies Partial); }); + + it('calls the public people directory with a bearer token', async () => { + const client = createVouchNetClient({ + fetch: async (input) => { + expect(String(input)).toContain('/api/v1/people/ada'); + return new Response( + JSON.stringify({ + id: '11111111-1111-1111-1111-111111111111', + slug: 'ada', + first_name: 'Ada', + last_name: 'Lovelace', + name: 'Ada Lovelace', + headline: 'Analyst', + location: 'London', + about: null, + languages: [], + early_member: false, + is_plus: false, + profile_url: 'https://vouchnet.dev/vouch/ada', + visibility: 'PUBLIC', + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }, + }); + await expect(client.getPerson('vnat_example', 'ada')).resolves.toMatchObject({ + slug: 'ada', + name: 'Ada Lovelace', + }); + }); }); diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 564c710..01ed343 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -7,6 +7,7 @@ export const vouchNetScopes = [ 'profile:email', 'resume:read', 'skills:verify', + 'directory:read', ] as const; export type VouchNetScope = (typeof vouchNetScopes)[number]; @@ -36,6 +37,55 @@ export type VouchNetApplicantData = { resume: null; }; +export type VouchNetPerson = { + id: string; + slug: string; + first_name: string; + last_name: string; + name: string; + headline: string | null; + location: string | null; + about: string | null; + languages: string[]; + early_member: boolean; + is_plus: boolean; + profile_url: string; + visibility: string; +}; + +export type VouchNetOrganization = { + slug: string; + name: string; + tagline: string | null; + description: string; + website_url: string; + careers_url: string | null; + engineering_url: string | null; + repository_url: string | null; + headquarters: string | null; + verification_status: string; + technologies: Array<{ name: string; category: string; sourceUrl: string }>; + organization_url: string; +}; + +export type VouchNetJob = { + slug: string; + title: string; + summary: string; + location: string; + workplace_type: string; + employment_type: string; + salary_min: number | null; + salary_max: number | null; + salary_currency: string; + skill_tags: string[]; + source_url: string; + source_status: string; + organization_name: string; + organization_slug: string; + job_url: string; +}; + export type VouchNetApiErrorCode = | 'INVALID_REQUEST' | 'INVALID_TOKEN' @@ -262,6 +312,62 @@ export function createVouchNetClient(options: VouchNetClientOptions = {}) { resume: null, }; }, + + /** Public person directory lookup. Requires the `directory:read` scope. */ + async getPerson(accessToken: string, slug: string): Promise { + assertNonBlank(accessToken, 'accessToken'); + assertNonBlank(slug, 'slug'); + const payload = await request(`/api/v1/people/${encodeURIComponent(slug)}`, { + headers: { authorization: `Bearer ${accessToken}` }, + cache: 'no-store', + }); + if ( + !isRecord(payload) || + typeof payload.id !== 'string' || + typeof payload.slug !== 'string' || + typeof payload.name !== 'string' + ) + throw new VouchNetApiError('INVALID_RESPONSE', 502, undefined, payload); + return payload as VouchNetPerson; + }, + + /** Public organization directory lookup. Requires the `directory:read` scope. */ + async getOrganization(accessToken: string, slug: string): Promise { + assertNonBlank(accessToken, 'accessToken'); + assertNonBlank(slug, 'slug'); + const payload = await request(`/api/v1/organizations/${encodeURIComponent(slug)}`, { + headers: { authorization: `Bearer ${accessToken}` }, + cache: 'no-store', + }); + if ( + !isRecord(payload) || + typeof payload.slug !== 'string' || + typeof payload.name !== 'string' + ) + throw new VouchNetApiError('INVALID_RESPONSE', 502, undefined, payload); + return payload as VouchNetOrganization; + }, + + /** Salary-forward public jobs directory. Requires the `directory:read` scope. */ + async listJobs( + accessToken: string, + input: { query?: string; limit?: number } = {}, + ): Promise<{ query: string | null; jobs: VouchNetJob[] }> { + assertNonBlank(accessToken, 'accessToken'); + const url = new URL('/api/v1/jobs', origin); + if (input.query) url.searchParams.set('q', input.query); + if (input.limit !== undefined) url.searchParams.set('limit', String(input.limit)); + const payload = await request(`${url.pathname}${url.search}`, { + headers: { authorization: `Bearer ${accessToken}` }, + cache: 'no-store', + }); + if (!isRecord(payload) || !Array.isArray(payload.jobs)) + throw new VouchNetApiError('INVALID_RESPONSE', 502, undefined, payload); + return { + query: typeof payload.query === 'string' ? payload.query : null, + jobs: payload.jobs as VouchNetJob[], + }; + }, }; } From 946b2625c13501afbfdfda1cfb97a428cb7c82b6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 13:51:08 +0000 Subject: [PATCH 2/3] docs: point README at directory API and GitHub Packages client Co-authored-by: Matthew --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ea61598..4fd68fa 100644 --- a/README.md +++ b/README.md @@ -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 From 36dc4fb46870476a91217138176f5234aa42df68 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 13:53:08 +0000 Subject: [PATCH 3/3] fix: tighten credit inventory helper typechecks Co-authored-by: Matthew --- apps/web/app/lib/api-credit-entry-points.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/lib/api-credit-entry-points.test.ts b/apps/web/app/lib/api-credit-entry-points.test.ts index d5c5f38..56e7356 100644 --- a/apps/web/app/lib/api-credit-entry-points.test.ts +++ b/apps/web/app/lib/api-credit-entry-points.test.ts @@ -46,6 +46,7 @@ function isGated(file: string, seen = new Set()): boolean { // Shared helpers such as oauth-api.ts own the chargeApiCall / spendCredits call site. const helpers = [...source.matchAll(/from '(\.[^']+)'/g)] .map((match) => match[1]) + .filter((specifier): specifier is string => typeof specifier === 'string') .filter((specifier) => !specifier.endsWith('/route')) .flatMap((specifier) => { const base = join(file, '..', specifier);