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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions platform/packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,14 @@ HMAC. See [Platform Development](../../../docs/guide/platform/development/runtim
- `POST /internal/revalidate/:projectId`
- `POST /internal/resolve-resource/:projectId`
- `POST /internal/resolve-worker/:projectId` — resolve the active deployment's versioned worker; static active deployments report that no worker exists
- `POST /internal/verify-prerender-build/:projectId` — live-check that a prerender JWT's user, build, and deploy credential are still active
- `POST /internal/verify-owner/:projectId` — legacy URL that live-checks the caller's project deploy capability

`resolve-resource`, `resolve-worker` and `verify-prerender-build` authenticate
the proxy with its own key (`x-void-proxy-internal`); a request without it is
checked like a project token. `revalidate` checks the project token forwarded
by the proxy against the project's accept floor (see the proxy README).

### Operator API

`/operator/v1` exposes administrative operations as JSON. `POST /session`
Expand Down
1 change: 1 addition & 0 deletions platform/packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"./reserved-slugs": "./src/reserved-slugs.ts",
"./routing": "./src/routing.ts",
"./usage-auth": "./src/usage-auth.ts",
"./proxy-token": "./src/proxy-token.ts",
"./admin-jsx/jsx-runtime": "./src/admin/react-jsx/jsx-runtime.ts",
"./admin-jsx/jsx-dev-runtime": "./src/admin/react-jsx/jsx-dev-runtime.ts"
},
Expand Down
3 changes: 2 additions & 1 deletion platform/packages/api/src/deploy/upload-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
provisionSandboxController,
sandboxControllerWorkerName,
} from '../provisioning/sandbox-controller.ts';
import { issueProxyToken } from '../proxy-token.ts';
import { RESERVED_BINDING_NAMES } from '../reserved-bindings.ts';
import { deployments } from '../schema.ts';
import { type DeployManifest, type DeployEvent, type Deployment, type Project } from '../types.ts';
Expand Down Expand Up @@ -113,10 +114,10 @@ export async function uploadDeploymentWorker(input: {
context.env.CF_API_TOKEN,
projectId,
manifest,
context.env.JWT_SECRET,
context.env.VOID_PROXY_SERVICE ?? 'void-proxy',
{
projectSlug: project.slug,
proxyToken: await issueProxyToken(context.env.ROUTING, context.env.JWT_SECRET, projectId),
emailSenderDomain: context.env.EMAIL_SENDER_DOMAIN,
deploymentId: deployment.id,
},
Expand Down
79 changes: 79 additions & 0 deletions platform/packages/api/src/internal-proxy-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { Context } from 'hono';
import type { ApiEnv } from './env.ts';
import { isProjectId } from './id.ts';
import {
PROXY_INTERNAL_AUTH_HEADER,
PROXY_TOKEN_KV_CACHE_TTL_SECONDS,
type ProxyInternalRoute,
ProxyTokenStateError,
readProxyTokenState,
verifyProxyInternalAuth,
verifyProxyToken,
} from './proxy-token.ts';

/**
* Checks a tenant project token forwarded by the proxy against the project's
* accept floor. A floor that cannot be read or is malformed is
* `'unavailable'`, never accepted.
*/
export async function checkTenantProxyToken(
env: Pick<ApiEnv['Bindings'], 'ROUTING' | 'JWT_SECRET'>,
projectId: string,
token: string,
): Promise<'ok' | 'invalid' | 'unavailable'> {
try {
const valid = await verifyProxyToken({
jwtSecret: env.JWT_SECRET,
projectId,
token,
readState: () =>
readProxyTokenState(env.ROUTING, projectId, {
cacheTtl: PROXY_TOKEN_KV_CACHE_TTL_SECONDS,
}),
});
return valid ? 'ok' : 'invalid';
} catch (error) {
if (error instanceof ProxyTokenStateError) {
console.error('[proxy-token] state unavailable', { projectId, error });
return 'unavailable';
}
throw error;
}
}

/**
* Authenticates the proxy on an internal metadata route. The proxy signs
* `x-void-proxy-internal` with its own key, bound to the route and project.
* A request without that header is checked like a tenant project token in
* `x-void-internal`, so a token below the project's accept floor is refused
* here as it is at the proxy.
*
* Returns null when the call may proceed, else the error response.
*/
export async function requireProxyInternalAuth(
context: Context<ApiEnv>,
route: ProxyInternalRoute,
projectId: string,
): Promise<Response | null> {
const proxyAuth = context.req.header(PROXY_INTERNAL_AUTH_HEADER);
const token = context.req.header('x-void-internal');
if (!proxyAuth && !token) {
return context.json({ error: 'missing internal auth' }, 401);
}
if (!isProjectId(projectId)) {
return context.json({ error: 'invalid internal auth' }, 403);
}
if (proxyAuth) {
return (await verifyProxyInternalAuth(context.env.JWT_SECRET, route, projectId, proxyAuth))
? null
: context.json({ error: 'invalid internal auth' }, 403);
}
const tokenCheck = await checkTenantProxyToken(context.env, projectId, token ?? '');
Comment thread
Brooooooklyn marked this conversation as resolved.
if (tokenCheck === 'unavailable') {
return context.json({ error: 'project token state unavailable' }, 503);
}
if (tokenCheck === 'invalid') {
return context.json({ error: 'invalid internal auth' }, 403);
}
return null;
}
22 changes: 21 additions & 1 deletion platform/packages/api/src/provisioning/cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,19 @@ async function ignoreMissingProviderObject(operation: () => Promise<void>): Prom
}
}

// Kinds the scheduled retry runner executes. Rows of any other kind stay in the
// table untouched, so a job kind added by a newer platform version survives a
// rollback and never fills the retry batch.
const RETRYABLE_CLEANUP_JOB_KINDS = [
'cf-resource',
'worker',
'worker-bundle',
'project-assets',
'routing-key',
'cron',
'custom-hostname',
] as const satisfies ReadonlyArray<ProjectCleanupJobInput['kind']>;

async function executeCleanupJob(
env: ApiEnv['Bindings'],
job: ProjectCleanupJobInput,
Expand Down Expand Up @@ -172,6 +185,8 @@ async function executeCleanupJob(
),
);
return;
default:
throw new Error(`unknown cleanup job kind: ${String((job as { kind: string }).kind)}`);
}
}

Expand Down Expand Up @@ -750,7 +765,12 @@ export async function reconcileProjectCleanupJobs(db: Db, env: ApiEnv['Bindings'
const due = await db
.select()
.from(projectCleanupJobs)
.where(lte(projectCleanupJobs.next_attempt_at, drizzleSql`now()`))
.where(
and(
lte(projectCleanupJobs.next_attempt_at, drizzleSql`now()`),
inArray(projectCleanupJobs.kind, [...RETRYABLE_CLEANUP_JOB_KINDS]),
),
)
.orderBy(asc(projectCleanupJobs.created_at), asc(projectCleanupJobs.target))
.limit(50);
let next = 0;
Expand Down
7 changes: 3 additions & 4 deletions platform/packages/api/src/provisioning/provision.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { createDeployIntent } from '@void/deploy-core';
import { hmacSignState } from '../auth/hmac.ts';
import { getProjectResources, createResource, type Db } from '../db.ts';
import { getProjectQueueName } from '../naming.ts';
import type { DeployManifest, Resource } from '../types.ts';
Expand Down Expand Up @@ -36,7 +35,6 @@ export async function provisionBindings(
apiToken: string,
projectId: string,
manifest: DeployManifest,
jwtSecret: string,
aiProxyService: string,
/**
* Per-project platform identity injected into every worker. Grouped into
Expand All @@ -47,6 +45,8 @@ export async function provisionBindings(
platform: {
/** Project slug. Framework reads it as `__VOID_PROJECT_SLUG`. */
projectSlug: string;
/** Token the Worker presents to the proxy. Framework reads it as `__VOID_PROXY_TOKEN`. */
proxyToken: string;
/** Immutable deployment identity for signed inbound email receipts. */
deploymentId?: string;
/**
Expand Down Expand Up @@ -165,10 +165,9 @@ export async function provisionBindings(
// Always inject proxy bindings — used by AI, revalidation, email, and future
// platform features. The AI-specific usage limit check stays in the proxy
// (/ai/* middleware).
const proxyToken = await hmacSignState(projectId, jwtSecret);
bindings.push({ type: 'service', name: '__VOID_PROXY', service: aiProxyService });
bindings.push({ type: 'plain_text', name: '__PROJECT_ID', text: projectId });
bindings.push({ type: 'plain_text', name: '__VOID_PROXY_TOKEN', text: proxyToken });
bindings.push({ type: 'plain_text', name: '__VOID_PROXY_TOKEN', text: platform.proxyToken });
// Project slug — the SINGLE producer of `__VOID_PROJECT_SLUG`. `void/email`
// needs it on every worker to default an outbound `from` to
// `<slug>+noreply@<domain>`, and the sandbox runtime reads the same name.
Expand Down
Loading
Loading