bootstrap-managed-user.mjs 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. import 'dotenv/config';
  2. import { randomUUID } from 'node:crypto';
  3. const required = ['PARSE_SERVER_URL', 'PARSE_APP_ID', 'PARSE_MASTER_KEY'];
  4. for (const name of required) if (!process.env[name]?.trim()) throw new Error(`${name} is required`);
  5. const serverUrl = process.env.PARSE_SERVER_URL.replace(/\/+$/, '');
  6. const appId = process.env.PARSE_APP_ID.trim();
  7. const masterKey = process.env.PARSE_MASTER_KEY.trim();
  8. const username = (process.env.SAAS_BOOTSTRAP_USERNAME || '').trim();
  9. const password = process.env.SAAS_BOOTSTRAP_PASSWORD || '';
  10. const workspaceId = (process.env.SAAS_DEFAULT_WORKSPACE_ID || 'demashi').trim();
  11. const email = (process.env.SAAS_BOOTSTRAP_USER_EMAIL || '').trim();
  12. const displayName = (process.env.SAAS_BOOTSTRAP_USER_NAME || username).trim();
  13. const role = (process.env.SAAS_BOOTSTRAP_USER_ROLE || 'owner').trim();
  14. if (username.length < 2) throw new Error('SAAS_BOOTSTRAP_USERNAME must contain at least 2 characters');
  15. if (password.length < 12) throw new Error('SAAS_BOOTSTRAP_PASSWORD must contain at least 12 characters');
  16. if (!['owner', 'admin', 'editor', 'viewer'].includes(role)) throw new Error('SAAS_BOOTSTRAP_USER_ROLE is invalid');
  17. const masterHeaders = {
  18. 'Content-Type': 'application/json',
  19. 'X-Parse-Application-Id': appId,
  20. 'X-Parse-Master-Key': masterKey,
  21. };
  22. async function jsonRequest(url, options = {}) {
  23. const response = await fetch(url, options);
  24. const body = await response.json().catch(() => ({}));
  25. if (!response.ok || body.error) {
  26. throw new Error(`${options.method || 'GET'} ${new URL(url).pathname} failed: HTTP ${response.status}, code ${body.code || 'unknown'}`);
  27. }
  28. return body;
  29. }
  30. const userQuery = new URL(`${serverUrl}/users`);
  31. userQuery.searchParams.set('where', JSON.stringify({ username }));
  32. userQuery.searchParams.set('limit', '1');
  33. let user = (await jsonRequest(userQuery, { headers: masterHeaders })).results?.[0];
  34. let userCreated = false;
  35. if (!user) {
  36. let created;
  37. try {
  38. created = await jsonRequest(`${serverUrl}/users`, {
  39. method: 'POST',
  40. headers: masterHeaders,
  41. body: JSON.stringify({ username, password, ...(email ? { email } : {}) }),
  42. });
  43. } catch (error) {
  44. // Some managed Fmode Parse mounts return a generic error from REST user
  45. // creation while Parse.User.signUp remains available inside Functions.
  46. // Use a random, one-shot Function and remove it in a finally block.
  47. created = await createUserThroughEphemeralFunction();
  48. }
  49. user = { objectId: created.objectId };
  50. userCreated = true;
  51. }
  52. const naturalKey = `${workspaceId}:${user.objectId}`;
  53. const memberQuery = new URL(`${serverUrl}/classes/VocWorkspaceMember`);
  54. memberQuery.searchParams.set('where', JSON.stringify({ naturalKey }));
  55. memberQuery.searchParams.set('limit', '1');
  56. const existingMember = (await jsonRequest(memberQuery, { headers: masterHeaders })).results?.[0];
  57. const memberBody = {
  58. naturalKey,
  59. workspaceId,
  60. userId: user.objectId,
  61. ...(email ? { email } : {}),
  62. displayName,
  63. role,
  64. status: 'active',
  65. };
  66. const memberResult = await jsonRequest(
  67. existingMember
  68. ? `${serverUrl}/classes/VocWorkspaceMember/${existingMember.objectId}`
  69. : `${serverUrl}/classes/VocWorkspaceMember`,
  70. {
  71. method: existingMember ? 'PUT' : 'POST',
  72. headers: masterHeaders,
  73. body: JSON.stringify(memberBody),
  74. },
  75. );
  76. const login = await jsonRequest(`${serverUrl}/login`, {
  77. method: 'POST',
  78. headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': appId },
  79. body: JSON.stringify({ username, password }),
  80. });
  81. const functionUrl = new URL(`${serverUrl}/../api/functions`).href;
  82. const acceptance = await jsonRequest(functionUrl, {
  83. method: 'POST',
  84. headers: {
  85. 'Content-Type': 'application/json',
  86. 'X-Parse-Application-Id': appId,
  87. 'X-Parse-Session-Token': login.sessionToken,
  88. },
  89. body: JSON.stringify({
  90. path: '/saas-voc-gateway',
  91. params: { action: 'context.get', workspaceId },
  92. _ApplicationId: appId,
  93. _SessionToken: login.sessionToken,
  94. }),
  95. });
  96. if (acceptance.success !== true || !acceptance.data?.workspaces?.length) {
  97. throw new Error('Managed Function authenticated acceptance failed');
  98. }
  99. console.log(JSON.stringify({
  100. ok: true,
  101. username,
  102. userId: user.objectId,
  103. userCreated,
  104. membershipId: existingMember?.objectId || memberResult.objectId,
  105. workspaceId,
  106. role,
  107. cloudFunctionAuthenticated: true,
  108. }, null, 2));
  109. async function createUserThroughEphemeralFunction() {
  110. const registryServerUrl = (process.env.FUNCTION_REGISTRY_SERVER_URL || serverUrl).replace(/\/+$/, '');
  111. const registryAppId = (process.env.FUNCTION_REGISTRY_APP_ID || appId).trim();
  112. const registryMasterKey = (process.env.FUNCTION_REGISTRY_MASTER_KEY || masterKey).trim();
  113. const registryHeaders = {
  114. 'Content-Type': 'application/json',
  115. 'X-Parse-Application-Id': registryAppId,
  116. 'X-Parse-Master-Key': registryMasterKey,
  117. };
  118. const nonce = randomUUID();
  119. const path = `/saas-voc-user-bootstrap-${randomUUID()}`;
  120. const code = `
  121. async function handler(request, response) {
  122. const input = request.body && request.body.params || {};
  123. if (input.nonce !== ${JSON.stringify(nonce)}) return response.status(404).json({ error: 'not_found' });
  124. const query = new Parse.Query(Parse.User);
  125. query.equalTo('username', input.username);
  126. let user = await query.first({ useMasterKey: true });
  127. if (!user) {
  128. user = new Parse.User();
  129. user.set('username', input.username);
  130. user.set('password', input.password);
  131. if (input.email) user.set('email', input.email);
  132. await user.signUp(null, { useMasterKey: true });
  133. }
  134. return response.json({ success: true, data: { objectId: user.id } });
  135. }`;
  136. const record = await jsonRequest(`${registryServerUrl}/classes/Function`, {
  137. method: 'POST',
  138. headers: registryHeaders,
  139. body: JSON.stringify({
  140. name: `saasVocUserBootstrap${Date.now()}`,
  141. desc: 'Ephemeral first-user bootstrap; delete after one invocation',
  142. type: 'standalone',
  143. path,
  144. paramList: [{ name: 'params', type: 'Object', required: true }],
  145. code,
  146. respType: 'json',
  147. respJson: { success: true, data: null },
  148. isDeleted: false,
  149. }),
  150. });
  151. try {
  152. const functionUrl = new URL(`${registryServerUrl}/../api/functions`).href;
  153. const result = await jsonRequest(functionUrl, {
  154. method: 'POST',
  155. headers: { 'Content-Type': 'application/json', 'X-Parse-Application-Id': registryAppId },
  156. body: JSON.stringify({
  157. path,
  158. params: { nonce, username, password, ...(email ? { email } : {}) },
  159. _ApplicationId: registryAppId,
  160. }),
  161. });
  162. if (result.success !== true || !result.data?.objectId) throw new Error('Ephemeral user bootstrap did not return a user');
  163. return result.data;
  164. } finally {
  165. await fetch(`${registryServerUrl}/classes/Function/${record.objectId}`, { method: 'DELETE', headers: registryHeaders }).catch(() => undefined);
  166. }
  167. }