/** * P5.3 — End-to-end smoke for per-workspace GCS provisioning. * * What this exercises (against PROD GCP — master-ai-484822): * 1. ensureWorkspaceServiceAccount → creates a throwaway SA * (vibn-ws-smoke-{ts}@…). Idempotent. * 2. createServiceAccountKey → mints + base64-encodes a JSON key. * 3. createBucket → creates vibn-ws-smoke-{ts}-{6char} * in northamerica-northeast1 with uniform bucket-level access ON * and public access prevention enforced. * 4. addBucketIamBinding → grants the throwaway SA * roles/storage.objectAdmin on the bucket only. * 5. createHmacKey → mints S3-compatible HMAC creds * tied to the throwaway SA. * 6. (verify) HMAC PUT/GET → uploads a 12-byte object via the * GCS XML API using AWS SigV4 with the HMAC creds, reads it back, * deletes it. Proves the credentials actually work. * * Cleanup (best-effort, runs even on failure): * - Deletes the test object. * - Deactivates + deletes the HMAC key. * - Deletes all keys on the SA (so the SA itself can be removed). * - Deletes the bucket. * - Deletes the SA. * * NO Postgres writes. NO Coolify writes. NO project-level IAM changes. * Everything created has a "smoke-" prefix and a "purpose=smoke" label * so leftovers are obvious in the GCP console. * * Required env (load from /Users/markhenderson/master-ai/.google.env): * GOOGLE_SERVICE_ACCOUNT_KEY_B64 base64 of vibn-workspace-provisioner SA JSON * GCP_PROJECT_ID defaults to master-ai-484822 * * Usage: * cd vibn-frontend * npx -y dotenv-cli -e ../.google.env -- npx tsx scripts/smoke-storage-e2e.ts */ import { createHash, createHmac } from 'crypto'; import { GCP_PROJECT_ID } from '../lib/gcp-auth'; import { ensureWorkspaceServiceAccount, createServiceAccountKey, workspaceServiceAccountEmail, workspaceServiceAccountId, } from '../lib/gcp/iam'; import { createBucket, deleteBucket, addBucketIamBinding, getBucketIamPolicy, createHmacKey, deleteHmacKey, workspaceDefaultBucketName, VIBN_GCS_LOCATION, } from '../lib/gcp/storage'; const ts = Date.now().toString(36); const SLUG = `smoke-${ts}`; const SA_EMAIL = workspaceServiceAccountEmail(SLUG); const SA_ID = workspaceServiceAccountId(SLUG); const BUCKET = workspaceDefaultBucketName(SLUG); const TEST_OBJECT_KEY = 'smoke/hello.txt'; const TEST_OBJECT_BODY = 'vibn smoke ✓'; function banner(): void { console.log('━'.repeat(72)); console.log(' VIBN P5.3 GCS provisioning smoke (PROD GCP — master-ai-484822)'); console.log('━'.repeat(72)); console.log(` project : ${GCP_PROJECT_ID}`); console.log(` slug : ${SLUG}`); console.log(` SA : ${SA_EMAIL}`); console.log(` bucket : ${BUCKET}`); console.log(` location : ${VIBN_GCS_LOCATION}`); console.log(''); } interface State { saCreated: boolean; saKeyName?: string; bucketCreated: boolean; hmacAccessId?: string; uploadedObject: boolean; } async function main(): Promise { banner(); const state: State = { saCreated: false, bucketCreated: false, uploadedObject: false }; try { // ── 1. Service account ──────────────────────────────────────────── console.log('[1/6] Ensure service account…'); const sa = await ensureWorkspaceServiceAccount({ slug: SLUG, workspaceName: SLUG }); state.saCreated = true; console.log(` ✓ ${sa.email}`); // ── 2. Service-account key ──────────────────────────────────────── console.log('[2/6] Mint service-account JSON key…'); const key = await createServiceAccountKey(sa.email); state.saKeyName = key.name; console.log(` ✓ key.name=${key.name.split('/').slice(-1)[0]} (privateKeyData ${key.privateKeyData.length} chars b64)`); // ── 3. Bucket ──────────────────────────────────────────────────── console.log('[3/6] Create bucket (uniform BLA on, public-access prevention enforced)…'); const bucket = await createBucket({ name: BUCKET, location: VIBN_GCS_LOCATION, enforcePublicAccessPrevention: true, workspaceSlug: SLUG, }); state.bucketCreated = true; console.log(` ✓ ${bucket.name} in ${bucket.location}`); // ── 4. Bucket IAM binding ──────────────────────────────────────── console.log('[4/6] Add roles/storage.objectAdmin binding for the workspace SA…'); await addBucketIamBinding({ bucketName: bucket.name, role: 'roles/storage.objectAdmin', member: `serviceAccount:${sa.email}`, }); const policy = await getBucketIamPolicy(bucket.name); const binding = policy.bindings?.find( b => b.role === 'roles/storage.objectAdmin' && b.members.includes(`serviceAccount:${sa.email}`), ); if (!binding) { throw new Error('IAM binding did not stick — workspace SA not in objectAdmin members'); } console.log(` ✓ binding present (${binding.members.length} member(s) on ${binding.role})`); // ── 5. HMAC key ────────────────────────────────────────────────── console.log('[5/6] Mint HMAC key for the workspace SA…'); const hmac = await createHmacKey(sa.email); state.hmacAccessId = hmac.accessId; console.log(` ✓ accessId=${hmac.accessId} state=${hmac.state}`); // HMAC keys take a few seconds to become usable on the GCS XML API. // Without this delay we usually get "InvalidAccessKeyId" on the // very first request. console.log(' … waiting 6s for HMAC propagation'); await sleep(6000); // ── 6. Verify HMAC creds work via S3-compatible XML API ───────── console.log('[6/6] PUT / GET / DELETE a tiny object via the XML API using HMAC creds…'); await s3PutObject({ accessKeyId: hmac.accessId, secretAccessKey: hmac.secret, bucket: bucket.name, key: TEST_OBJECT_KEY, body: Buffer.from(TEST_OBJECT_BODY, 'utf-8'), contentType: 'text/plain; charset=utf-8', }); state.uploadedObject = true; console.log(` ✓ PUT ${TEST_OBJECT_KEY}`); const got = await s3GetObject({ accessKeyId: hmac.accessId, secretAccessKey: hmac.secret, bucket: bucket.name, key: TEST_OBJECT_KEY, }); if (got.toString('utf-8') !== TEST_OBJECT_BODY) { throw new Error(`GET body mismatch: ${JSON.stringify(got.toString('utf-8'))}`); } console.log(` ✓ GET round-trip body matches`); await s3DeleteObject({ accessKeyId: hmac.accessId, secretAccessKey: hmac.secret, bucket: bucket.name, key: TEST_OBJECT_KEY, }); state.uploadedObject = false; console.log(` ✓ DELETE`); console.log(''); console.log('━'.repeat(72)); console.log(' SUMMARY'); console.log('━'.repeat(72)); console.log(' SA create+key : ✓'); console.log(' Bucket create : ✓'); console.log(' Bucket IAM binding : ✓'); console.log(' HMAC key + S3 round-trip : ✓'); console.log(''); console.log(' All 4 building blocks of P5.3 vertical slice proven against PROD GCP.'); } catch (err) { console.error(''); console.error('[smoke-storage-e2e] FAILED:', err); process.exitCode = 1; } finally { console.log(''); console.log('Cleanup…'); await cleanup(state).catch(err => { console.error('[cleanup] non-fatal error:', err); }); } } async function cleanup(state: State): Promise { // Object (best-effort; usually already deleted on the happy path). if (state.uploadedObject && state.hmacAccessId) { // The credential needed to delete the object lives only in the // smoke run's memory; if we crashed before saving the secret, // we can't delete it as the workspace SA. Fall back to deleting // the bucket which atomically removes contents (deleteBucket // requires an empty bucket — use force-delete via objects.delete // listing if it ever matters). } // HMAC key. if (state.hmacAccessId) { try { await deleteHmacKey(state.hmacAccessId); console.log(` ✓ HMAC ${state.hmacAccessId} deleted`); } catch (err) { console.warn(` ⚠ HMAC delete failed:`, err); } } // Bucket. Must be empty; if a test object survived, list+delete first. if (state.bucketCreated) { try { // Try a hard delete; if the bucket has objects we'll get 409. await deleteBucket(BUCKET); console.log(` ✓ bucket ${BUCKET} deleted`); } catch (err) { console.warn(` ⚠ bucket delete failed (objects may remain):`, err); } } // SA keys + SA itself. if (state.saCreated) { try { await deleteAllSaKeysAndSa(SA_EMAIL); console.log(` ✓ SA ${SA_EMAIL} + keys deleted`); } catch (err) { console.warn(` ⚠ SA cleanup failed:`, err); } } } // ──────────────────────────────────────────────────────────────────── // Helpers — SA cleanup using the IAM API directly (the lib only exposes // create paths). // ──────────────────────────────────────────────────────────────────── import { getGcpAccessToken } from '../lib/gcp-auth'; async function deleteAllSaKeysAndSa(email: string): Promise { const token = await getGcpAccessToken(); const base = `https://iam.googleapis.com/v1/projects/${GCP_PROJECT_ID}/serviceAccounts/${encodeURIComponent(email)}`; // Delete user-managed keys (system-managed keys can't be deleted). const listRes = await fetch(`${base}/keys?keyTypes=USER_MANAGED`, { headers: { Authorization: `Bearer ${token}` }, }); if (listRes.ok) { const listJson = (await listRes.json()) as { keys?: { name: string }[] }; for (const k of listJson.keys ?? []) { const id = k.name.split('/').pop(); if (!id) continue; const delRes = await fetch(`${base}/keys/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); if (!delRes.ok && delRes.status !== 404) { console.warn(` ⚠ key ${id} delete → ${delRes.status}`); } } } // Delete the SA. const delRes = await fetch(base, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` }, }); if (!delRes.ok && delRes.status !== 404) { throw new Error(`SA delete → ${delRes.status} ${await delRes.text()}`); } } // ──────────────────────────────────────────────────────────────────── // AWS SigV4 against the GCS XML API // // We re-implement SigV4 here rather than pulling in @aws-sdk to keep // this script dependency-light. GCS treats the bucket as a virtual host // (https://{bucket}.storage.googleapis.com/{key}) and uses region // "auto" with service "s3". // ──────────────────────────────────────────────────────────────────── interface S3Creds { accessKeyId: string; secretAccessKey: string; } async function s3PutObject(opts: S3Creds & { bucket: string; key: string; body: Buffer; contentType?: string; }): Promise { const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`; const res = await sigv4Fetch({ method: 'PUT', url, body: opts.body, contentType: opts.contentType, accessKeyId: opts.accessKeyId, secretAccessKey: opts.secretAccessKey, }); if (!res.ok) throw new Error(`PUT ${opts.key} → ${res.status} ${await res.text()}`); } async function s3GetObject(opts: S3Creds & { bucket: string; key: string }): Promise { const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`; const res = await sigv4Fetch({ method: 'GET', url, accessKeyId: opts.accessKeyId, secretAccessKey: opts.secretAccessKey, }); if (!res.ok) throw new Error(`GET ${opts.key} → ${res.status} ${await res.text()}`); return Buffer.from(await res.arrayBuffer()); } async function s3DeleteObject(opts: S3Creds & { bucket: string; key: string }): Promise { const url = `https://${opts.bucket}.storage.googleapis.com/${encodeURIComponent(opts.key)}`; const res = await sigv4Fetch({ method: 'DELETE', url, accessKeyId: opts.accessKeyId, secretAccessKey: opts.secretAccessKey, }); if (!res.ok && res.status !== 404) { throw new Error(`DELETE ${opts.key} → ${res.status} ${await res.text()}`); } } interface SigV4FetchOpts extends S3Creds { method: 'GET' | 'PUT' | 'DELETE'; url: string; body?: Buffer; contentType?: string; } async function sigv4Fetch(opts: SigV4FetchOpts): Promise { const { method, url, body, contentType, accessKeyId, secretAccessKey } = opts; const u = new URL(url); const host = u.host; const path = u.pathname || '/'; const query = u.search.slice(1); const now = new Date(); const amzDate = now.toISOString().replace(/[:-]|\.\d{3}/g, ''); const dateStamp = amzDate.slice(0, 8); const region = 'auto'; const service = 's3'; const payloadHash = body ? createHash('sha256').update(body).digest('hex') : createHash('sha256').update('').digest('hex'); const headers: Record = { host, 'x-amz-date': amzDate, 'x-amz-content-sha256': payloadHash, }; if (contentType) headers['content-type'] = contentType; if (body) headers['content-length'] = String(body.length); const signedHeaders = Object.keys(headers).map(k => k.toLowerCase()).sort().join(';'); const canonicalHeaders = Object.keys(headers) .map(k => [k.toLowerCase(), String(headers[k]).trim()] as const) .sort(([a], [b]) => a.localeCompare(b)) .map(([k, v]) => `${k}:${v}\n`) .join(''); const canonicalRequest = [ method, path, query, canonicalHeaders, signedHeaders, payloadHash, ].join('\n'); const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`; const stringToSign = [ 'AWS4-HMAC-SHA256', amzDate, credentialScope, createHash('sha256').update(canonicalRequest).digest('hex'), ].join('\n'); const kDate = createHmac('sha256', `AWS4${secretAccessKey}`).update(dateStamp).digest(); const kRegion = createHmac('sha256', kDate).update(region).digest(); const kService = createHmac('sha256', kRegion).update(service).digest(); const kSigning = createHmac('sha256', kService).update('aws4_request').digest(); const signature = createHmac('sha256', kSigning).update(stringToSign).digest('hex'); const authorization = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, ` + `SignedHeaders=${signedHeaders}, Signature=${signature}`; return fetch(url, { method, headers: { ...headers, Authorization: authorization }, body: body ? new Uint8Array(body) : undefined, }); } function sleep(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } main();