import 'dotenv/config'; import { randomUUID } from 'node:crypto'; const required = ['PARSE_SERVER_URL', 'PARSE_APP_ID', 'PARSE_MASTER_KEY']; for (const name of required) if (!process.env[name]?.trim()) throw new Error(`${name} is required`); const serverUrl = process.env.PARSE_SERVER_URL.replace(/\/+$/, ''); const appId = process.env.PARSE_APP_ID.trim(); const masterKey = process.env.PARSE_MASTER_KEY.trim(); const username = (process.env.SAAS_BOOTSTRAP_USERNAME || '').trim(); const password = process.env.SAAS_BOOTSTRAP_PASSWORD || ''; const workspaceId = (process.env.SAAS_DEFAULT_WORKSPACE_ID || 'demashi').trim(); const email = (process.env.SAAS_BOOTSTRAP_USER_EMAIL || '').trim(); const displayName = (process.env.SAAS_BOOTSTRAP_USER_NAME || username).trim(); const role = (process.env.SAAS_BOOTSTRAP_USER_ROLE || 'owner').trim(); if (username.length < 2) throw new Error('SAAS_BOOTSTRAP_USERNAME must contain at least 2 characters'); if (password.length < 12) throw new Error('SAAS_BOOTSTRAP_PASSWORD must contain at least 12 characters'); if (!['owner', 'admin', 'editor', 'viewer'].includes(role)) throw new Error('SAAS_BOOTSTRAP_USER_ROLE is invalid'); const masterHeaders = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId, 'X-Parse-Master-Key': masterKey, }; async function jsonRequest(url, options = {}) { const response = await fetch(url, options); const body = await response.json().catch(() => ({})); if (!response.ok || body.error) { throw new Error(`${options.method || 'GET'} ${new URL(url).pathname} failed: HTTP ${response.status}, code ${body.code || 'unknown'}`); } return body; } const userQuery = new URL(`${serverUrl}/users`); userQuery.searchParams.set('where', JSON.stringify({ username })); userQuery.searchParams.set('limit', '1'); let user = (await jsonRequest(userQuery, { headers: masterHeaders })).results?.[0]; let userCreated = false; if (!user) { let created; try { created = await jsonRequest(`${serverUrl}/users`, { method: 'POST', headers: masterHeaders, body: JSON.stringify({ username, password, ...(email ? { email } : {}) }), }); } catch (error) { // Some managed Fmode Parse mounts return a generic error from REST user // creation while Parse.User.signUp remains available inside Functions. // Use a random, one-shot Function and remove it in a finally block. created = await createUserThroughEphemeralFunction(); } user = { objectId: created.objectId }; userCreated = true; } const naturalKey = `${workspaceId}:${user.objectId}`; const memberQuery = new URL(`${serverUrl}/classes/VocWorkspaceMember`); memberQuery.searchParams.set('where', JSON.stringify({ naturalKey })); memberQuery.searchParams.set('limit', '1'); const existingMember = (await jsonRequest(memberQuery, { headers: masterHeaders })).results?.[0]; const memberBody = { naturalKey, workspaceId, userId: user.objectId, ...(email ? { email } : {}), displayName, role, status: 'active', }; const memberResult = await jsonRequest( existingMember ? `${serverUrl}/classes/VocWorkspaceMember/${existingMember.objectId}` : `${serverUrl}/classes/VocWorkspaceMember`, { method: existingMember ? 'PUT' : 'POST', headers: masterHeaders, body: JSON.stringify(memberBody), }, ); const login = await jsonRequest(`${serverUrl}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId }, body: JSON.stringify({ username, password }), }); const functionUrl = new URL(`${serverUrl}/../api/functions`).href; const acceptance = await jsonRequest(functionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId, 'X-Parse-Session-Token': login.sessionToken, }, body: JSON.stringify({ path: '/saas-voc-gateway', params: { action: 'context.get', workspaceId }, _ApplicationId: appId, _SessionToken: login.sessionToken, }), }); if (acceptance.success !== true || !acceptance.data?.workspaces?.length) { throw new Error('Managed Function authenticated acceptance failed'); } console.log(JSON.stringify({ ok: true, username, userId: user.objectId, userCreated, membershipId: existingMember?.objectId || memberResult.objectId, workspaceId, role, cloudFunctionAuthenticated: true, }, null, 2)); async function createUserThroughEphemeralFunction() { const registryServerUrl = (process.env.FUNCTION_REGISTRY_SERVER_URL || serverUrl).replace(/\/+$/, ''); const registryAppId = (process.env.FUNCTION_REGISTRY_APP_ID || appId).trim(); const registryMasterKey = (process.env.FUNCTION_REGISTRY_MASTER_KEY || masterKey).trim(); const registryHeaders = { 'Content-Type': 'application/json', 'X-Parse-Application-Id': registryAppId, 'X-Parse-Master-Key': registryMasterKey, }; const nonce = randomUUID(); const path = `/saas-voc-user-bootstrap-${randomUUID()}`; const code = ` async function handler(request, response) { const input = request.body && request.body.params || {}; if (input.nonce !== ${JSON.stringify(nonce)}) return response.status(404).json({ error: 'not_found' }); const query = new Parse.Query(Parse.User); query.equalTo('username', input.username); let user = await query.first({ useMasterKey: true }); if (!user) { user = new Parse.User(); user.set('username', input.username); user.set('password', input.password); if (input.email) user.set('email', input.email); await user.signUp(null, { useMasterKey: true }); } return response.json({ success: true, data: { objectId: user.id } }); }`; const record = await jsonRequest(`${registryServerUrl}/classes/Function`, { method: 'POST', headers: registryHeaders, body: JSON.stringify({ name: `saasVocUserBootstrap${Date.now()}`, desc: 'Ephemeral first-user bootstrap; delete after one invocation', type: 'standalone', path, paramList: [{ name: 'params', type: 'Object', required: true }], code, respType: 'json', respJson: { success: true, data: null }, isDeleted: false, }), }); try { const functionUrl = new URL(`${registryServerUrl}/../api/functions`).href; const result = await jsonRequest(functionUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': registryAppId }, body: JSON.stringify({ path, params: { nonce, username, password, ...(email ? { email } : {}) }, _ApplicationId: registryAppId, }), }); if (result.success !== true || !result.data?.objectId) throw new Error('Ephemeral user bootstrap did not return a user'); return result.data; } finally { await fetch(`${registryServerUrl}/classes/Function/${record.objectId}`, { method: 'DELETE', headers: registryHeaders }).catch(() => undefined); } }