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
7 changes: 7 additions & 0 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,13 @@ API integration fixtures reset their tables with one ordered SQL block. Use `cle
than repeating per-table cleanup; it keeps foreign-key delete behavior and migration history
intact in PGlite and PostgreSQL.

The cached PGlite database template persists a UTC session default, independently of the
creating or consuming process's timezone. Increment `TEMPLATE_VERSION` in
`platform/packages/api/test/integration/db-template.ts` when changing template configuration
so existing local dumps are rebuilt. For database-clock assertions, compare timestamps in SQL;
JavaScript interprets timestamps without a timezone as local time. Keep explicit non-UTC
regressions for behavior that must work across timezones.

Platform CI runs the candidate's PostgreSQL concurrency cases in a separate job. The selector
finds API integration files that read `process.env.VOID_TEST_DATABASE_URL`, excluding mentions
in comments or strings. Each selected file runs against its own database clone, with two files
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/platform/installation/first-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ outline: deep

# First Deployment

Open your platform's API URL in a browser to see its welcome page, CLI connection instructions, and a link to the administrator dashboard. Share this URL with teammates so they can connect to your platform.

First verify the new platform and sign in with the administrator login method selected during setup:

```sh
Expand Down
Binary file added platform/meta/screenshots/platform-welcome.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions platform/packages/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ drizzle/

### Public API

- `GET /` — server-rendered welcome page with the platform name, CLI installation and connection instructions, and links to the administrator dashboard and Void quickstart.
- `GET /health`
- `GET /auth/login`
- `GET /auth/callback`
Expand Down
27 changes: 27 additions & 0 deletions platform/packages/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,33 @@ const app = new Hono<ApiEnv>()
supportedClassifierVersions: [1],
}),
)
.get('/', (context) => {
const name = escapeHtml(getPlatformDisplayName(context.env, context.req.url));
const command = escapeHtml(
platformConnectCommand(getPlatformApiUrl(context.env, context.req.url)),
);
context.header('Cache-Control', 'no-store');
return context.html(`<!DOCTYPE html>
<html lang="en"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(getPlatformPageTitle('Welcome', context.env, context.req.url))}</title>
</head><body>
<main style="font-family:system-ui;max-width:40rem;margin:4rem auto;padding:1rem">
<h1>Welcome to ${name}</h1>
<p>This is a Void platform. Connect the Void CLI to deploy and manage your applications here.</p>
<h2>Connect to this platform</h2>
<p>Install the Void CLI if you haven't already:</p>
<pre style="overflow:auto"><code>npm install -g void</code></pre>
<p>From a new or unlinked Void app directory, connect to this platform:</p>
<pre style="overflow:auto"><code>${command}</code></pre>
<p>The CLI opens your browser to sign in when needed. If access is restricted, ask your platform administrator for an invitation and sign in with the invited email address.</p>
<p>Ready to deploy? Run <code>void deploy</code> from your application's directory. To create a new application, follow the <a href="https://void.cloud/guide/quickstart">Void quickstart</a>, then connect it to this platform before deploying.</p>
<h2>Administration</h2>
<p>Platform administrators can <a href="/admin/">open the administrator dashboard</a> to manage this instance. If you're setting up the first administrator, you'll be guided through setup.</p>
</main>
</body></html>`);
})
.get('/signup-restricted', (context) => {
const name = escapeHtml(getPlatformDisplayName(context.env, context.req.url));
return context.html(`<!DOCTYPE html>
Expand Down
13 changes: 9 additions & 4 deletions platform/packages/api/test/integration/db-github-members.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,15 @@ describe('github_installation_members', () => {
expect(row.github_account_id).toBe('gh-acct-1');
expect(row.verified_at).not.toBeNull();

// expires_at ≈ now + TTL (allow a generous window for test clock jitter).
const skewSeconds = (new Date(row.expires_at).getTime() - Date.now()) / 1000;
expect(skewSeconds).toBeGreaterThan(MEMBERSHIP_TTL_SECONDS - 120);
expect(skewSeconds).toBeLessThan(MEMBERSHIP_TTL_SECONDS + 120);
// Both timestamps use the DB clock. Compare there instead of interpreting
// a timestamp without timezone as the JavaScript process's local time.
const [lifetime] = await env.db
.select({
seconds: sql<string>`extract(epoch from (${githubInstallationMembers.expires_at} - ${githubInstallationMembers.verified_at}))`,
})
.from(githubInstallationMembers)
.where(eq(githubInstallationMembers.id, row.id));
expect(Number(lifetime!.seconds)).toBe(MEMBERSHIP_TTL_SECONDS);
});

it('upsert on the same (installation, user) refreshes in place and keeps count == 1', async () => {
Expand Down
46 changes: 46 additions & 0 deletions platform/packages/api/test/integration/db-template.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { execFile } from 'node:child_process';
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { expect, it } from 'vite-plus/test';

const exec = promisify(execFile);

it('reuses a UTC database template across processes with different local timezones', async () => {
const directory = await mkdtemp(join(tmpdir(), 'void-api-template-timezone-'));
const script = `
import { PGlite } from ${JSON.stringify(import.meta.resolve('@electric-sql/pglite'))};
import {
ensurePGliteTemplateBundlePath,
loadPGliteTemplateBundle,
} from ${JSON.stringify(new URL('./db-template.ts', import.meta.url).href)};
const path = await ensurePGliteTemplateBundlePath();
if (process.argv[1] === 'create') {
console.log(JSON.stringify({ path }));
} else {
const db = new PGlite({ loadDataDir: await loadPGliteTemplateBundle() });
try {
const { rows } = await db.query('SHOW TimeZone');
console.log(JSON.stringify({ path, timezone: rows[0].TimeZone }));
} finally {
await db.close();
}
}
`;
try {
const run = async (timezone: string, mode: 'create' | 'restore') => {
const { stdout } = await exec(process.execPath, ['--input-type=module', '-e', script, mode], {
env: { ...process.env, TMPDIR: directory, TMP: directory, TEMP: directory, TZ: timezone },
timeout: 10_000,
});
return JSON.parse(stdout) as { path: string; timezone: string };
};
const created = await run('Australia/Melbourne', 'create');
const reused = await run('America/Los_Angeles', 'restore');
expect(reused.timezone).toBe('UTC');
expect(reused.path).toBe(created.path);
} finally {
await rm(directory, { recursive: true, force: true });
}
}, 15_000);
6 changes: 4 additions & 2 deletions platform/packages/api/test/integration/db-template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { migrate } from 'drizzle-orm/pglite/migrator';
import * as schema from '../../src/schema.ts';

const MIGRATIONS_DIR = fileURLToPath(new URL('../../drizzle', import.meta.url));
const TEMPLATE_VERSION = '2';
const TEMPLATE_VERSION = '3';

function walkFiles(dir: string): Array<string> {
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
Expand Down Expand Up @@ -48,7 +48,9 @@ const TEMPLATE_BUNDLE_PATH = join(tmpdir(), `void-api-pglite-${getTemplateHash()

async function buildTemplateBundle(path: string): Promise<void> {
const tempPath = `${path}.${process.pid}.${Date.now().toString(36)}.tmp`;
const db = new PGlite({ relaxedDurability: true });
// Persist a timezone-independent default in the dump, rather than capturing
// the creating process's local timezone (including its DST offset).
const db = new PGlite({ postgresqlconf: "timezone = 'UTC'", relaxedDurability: true });

try {
await migrate(drizzle(db, { schema }), { migrationsFolder: MIGRATIONS_DIR });
Expand Down
10 changes: 7 additions & 3 deletions platform/packages/api/test/integration/operator-tenants.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { eq } from 'drizzle-orm';
import { eq, sql } from 'drizzle-orm';
import {
afterAll,
afterEach,
Expand Down Expand Up @@ -490,10 +490,14 @@ describe('operator tenant mutations', () => {
expect(former.ends_at).toBe(current.starts_at);
expect(current).toMatchObject({ ends_at: null, changed_by_user_id: operatorAdminId });
const [revokedCredential] = await env.db
.select()
.select({
// Ownership periods use timestamp, credentials use timestamptz. Let
// PostgreSQL interpret its own session timezone when comparing them.
sameTransferInstant: sql<boolean>`${projectDeployCredentials.revoked_at} = ${current.starts_at}::timestamp::timestamptz`,
})
.from(projectDeployCredentials)
.where(eq(projectDeployCredentials.id, 'transfer-deploy-credential'));
expect(Date.parse(revokedCredential!.revoked_at!)).toBe(Date.parse(current.starts_at));
expect(revokedCredential!.sameTransferInstant).toBe(true);
expect(counterCalls).toEqual([
{
ownerId: owner.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ async function githubSignature(body: string): Promise<string> {
}

describe('Access-protected platform requests', () => {
it('requires human Access identity to view the welcome page', async () => {
expect((await request('/')).status).toBe(403);
expect((await request('/', await assertion({ service: true }))).status).toBe(403);
const response = await request('/', await assertion());
expect(response.status).toBe(200);
expect(await response.text()).toContain('Welcome to Void Platform');
});

it('keeps maintenance status behind human Access and a matching, revocable administrator session', async () => {
const session = await createHumanSession(env.db, env.JWT_SECRET, {
userId: user.id,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE } from '@void/platform';
import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test';
import app from '../../src/index.ts';
import { setupEnv, teardownEnv, type TestEnv } from './helpers.ts';
Expand All @@ -11,6 +12,48 @@ afterAll(async () => {
});

describe('platform onboarding pages', () => {
it('welcomes visitors at the API root with this platform’s connection instructions', async () => {
const response = await app.fetch(
new Request('https://alias.example.com/', {
headers: { 'X-Forwarded-Host': 'attacker.example', 'X-Forwarded-Proto': 'http' },
}),
{
...env,
AUTH_REDIRECT_URI: 'https://api.example.com/auth/callback',
PLATFORM_DISPLAY_NAME: '<Example Platform>',
PLATFORM_INSTALLATION_ID: 'example-installation',
},
);
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toContain('text/html');
expect(response.headers.get('Cache-Control')).toBe('no-store');
const html = await response.text();
expect(html).toContain('<h1>Welcome to &lt;Example Platform&gt;</h1>');
expect(html).toContain('<title>Welcome · &lt;Example Platform&gt; · Void</title>');
expect(html).toContain('npm install -g void');
expect(html).toContain('void connect &#39;https://api.example.com&#39;');
expect(html).toContain('href="/admin/"');
expect(html).toContain('href="https://void.cloud/guide/quickstart"');
expect(html).not.toContain('<Example Platform>');
expect(html).not.toContain('attacker.example');
expect(html).not.toContain('alias.example.com');
expect(html).not.toContain('<script');
});

it('keeps the welcome page behind the installation traffic gate', async () => {
await env.ROUTING.put(PLATFORM_DISABLED_KEY, PLATFORM_DISABLED_VALUE);
try {
const response = await app.fetch(new Request('https://api.example.com/'), {
...env,
PLATFORM_INSTALLATION_ID: 'example-installation',
});
expect(response.status).toBe(503);
expect(await response.json()).toEqual({ error: 'Platform disabled' });
} finally {
await env.ROUTING.delete(PLATFORM_DISABLED_KEY);
}
});

it('shows the configured platform identity and connection, ignoring forwarded origins', async () => {
const response = await app.fetch(
new Request('https://alias.example.com/invite', {
Expand Down Expand Up @@ -98,7 +141,7 @@ describe('platform onboarding pages', () => {
expect(html).not.toContain('evil.example');
});

it.each(['/invite', '/signup-restricted'])(
it.each(['/', '/invite', '/signup-restricted'])(
'uses a generic name for source deployments at %s',
async (path) => {
const response = await app.fetch(
Expand All @@ -111,6 +154,9 @@ describe('platform onboarding pages', () => {
const html = await response.text();
expect(html).toContain('Void Platform');
expect(html).not.toContain('Void Cloud');
if (path === '/') {
expect(html).toContain('void connect &#39;https://api.example.com&#39;');
}
},
);

Expand Down
Loading