diff --git a/TESTING.md b/TESTING.md index 701a10599..122ee50b8 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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 diff --git a/docs/guide/platform/installation/first-deployment.md b/docs/guide/platform/installation/first-deployment.md index 8507272af..c69b77f15 100644 --- a/docs/guide/platform/installation/first-deployment.md +++ b/docs/guide/platform/installation/first-deployment.md @@ -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 diff --git a/platform/meta/screenshots/platform-welcome.png b/platform/meta/screenshots/platform-welcome.png new file mode 100644 index 000000000..3396bffb2 Binary files /dev/null and b/platform/meta/screenshots/platform-welcome.png differ diff --git a/platform/packages/api/README.md b/platform/packages/api/README.md index 2d62ebecc..3855fdbdc 100644 --- a/platform/packages/api/README.md +++ b/platform/packages/api/README.md @@ -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` diff --git a/platform/packages/api/src/index.ts b/platform/packages/api/src/index.ts index e26dc239f..d2b588558 100644 --- a/platform/packages/api/src/index.ts +++ b/platform/packages/api/src/index.ts @@ -505,6 +505,33 @@ const app = new Hono() 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(` + + + +${escapeHtml(getPlatformPageTitle('Welcome', context.env, context.req.url))} + +
+

Welcome to ${name}

+

This is a Void platform. Connect the Void CLI to deploy and manage your applications here.

+

Connect to this platform

+

Install the Void CLI if you haven't already:

+
npm install -g void
+

From a new or unlinked Void app directory, connect to this platform:

+
${command}
+

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.

+

Ready to deploy? Run void deploy from your application's directory. To create a new application, follow the Void quickstart, then connect it to this platform before deploying.

+

Administration

+

Platform administrators can open the administrator dashboard to manage this instance. If you're setting up the first administrator, you'll be guided through setup.

+
+`); + }) .get('/signup-restricted', (context) => { const name = escapeHtml(getPlatformDisplayName(context.env, context.req.url)); return context.html(` diff --git a/platform/packages/api/test/integration/db-github-members.test.ts b/platform/packages/api/test/integration/db-github-members.test.ts index 374df3eb8..105ebe2b8 100644 --- a/platform/packages/api/test/integration/db-github-members.test.ts +++ b/platform/packages/api/test/integration/db-github-members.test.ts @@ -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`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 () => { diff --git a/platform/packages/api/test/integration/db-template.test.ts b/platform/packages/api/test/integration/db-template.test.ts new file mode 100644 index 000000000..bc1121cba --- /dev/null +++ b/platform/packages/api/test/integration/db-template.test.ts @@ -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); diff --git a/platform/packages/api/test/integration/db-template.ts b/platform/packages/api/test/integration/db-template.ts index a5a35d991..63a23933e 100644 --- a/platform/packages/api/test/integration/db-template.ts +++ b/platform/packages/api/test/integration/db-template.ts @@ -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 { const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => @@ -48,7 +48,9 @@ const TEMPLATE_BUNDLE_PATH = join(tmpdir(), `void-api-pglite-${getTemplateHash() async function buildTemplateBundle(path: string): Promise { 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 }); diff --git a/platform/packages/api/test/integration/operator-tenants.test.ts b/platform/packages/api/test/integration/operator-tenants.test.ts index bbb6b248e..e1f9e10ca 100644 --- a/platform/packages/api/test/integration/operator-tenants.test.ts +++ b/platform/packages/api/test/integration/operator-tenants.test.ts @@ -1,4 +1,4 @@ -import { eq } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { afterAll, afterEach, @@ -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`${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, diff --git a/platform/packages/api/test/integration/platform-access-protection.test.ts b/platform/packages/api/test/integration/platform-access-protection.test.ts index 79f70e85c..b3466d939 100644 --- a/platform/packages/api/test/integration/platform-access-protection.test.ts +++ b/platform/packages/api/test/integration/platform-access-protection.test.ts @@ -150,6 +150,14 @@ async function githubSignature(body: string): Promise { } 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, diff --git a/platform/packages/api/test/integration/platform-onboarding.test.ts b/platform/packages/api/test/integration/platform-onboarding.test.ts index a78fab4cb..3fb42700b 100644 --- a/platform/packages/api/test/integration/platform-onboarding.test.ts +++ b/platform/packages/api/test/integration/platform-onboarding.test.ts @@ -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'; @@ -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: '', + 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('

Welcome to <Example Platform>

'); + expect(html).toContain('Welcome · <Example Platform> · Void'); + expect(html).toContain('npm install -g void'); + expect(html).toContain('void connect 'https://api.example.com''); + expect(html).toContain('href="/admin/"'); + expect(html).toContain('href="https://void.cloud/guide/quickstart"'); + expect(html).not.toContain(''); + expect(html).not.toContain('attacker.example'); + expect(html).not.toContain('alias.example.com'); + expect(html).not.toContain(' { + 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', { @@ -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( @@ -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 'https://api.example.com''); + } }, );