| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178 |
- 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);
- }
- }
|