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/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
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..56e7356 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,25 @@ 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 is string => typeof specifier === 'string')
+ .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[],
+ };
+ },
};
}