thinkwork-cli 0.12.16 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/cli.js +517 -10403
  2. package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
  3. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
  4. package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
  5. package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
  6. package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
  7. package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
  8. package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
  9. package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
  10. package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
  11. package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
  12. package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
  13. package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
  14. package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
  15. package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
  16. package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
  17. package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
  18. package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
  19. package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
  20. package/dist/drizzle/0203_threads_mode_override.sql +61 -0
  21. package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
  22. package/dist/drizzle/0205_routines_git_python.sql +185 -0
  23. package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
  24. package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
  25. package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
  26. package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
  27. package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
  28. package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
  29. package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
  30. package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
  31. package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
  32. package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
  33. package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
  34. package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
  35. package/dist/drizzle/0218_document_plates.sql +30 -0
  36. package/dist/drizzle/0219_document_section_waivers.sql +29 -0
  37. package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
  38. package/dist/drizzle/0221_artifact_shares.sql +29 -0
  39. package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
  40. package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
  41. package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
  42. package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
  43. package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
  44. package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
  45. package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
  46. package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
  47. package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
  48. package/dist/drizzle/0230_analyst_rls.sql +1613 -0
  49. package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
  50. package/dist/plugins/catalog/package.json +0 -1
  51. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
  52. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
  53. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
  54. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
  55. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
  56. package/dist/plugins/catalog/src/contracts.ts +112 -0
  57. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
  58. package/dist/plugins/n8n/README.md +4 -36
  59. package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
  60. package/dist/plugins/n8n/src/manifest.ts +0 -5
  61. package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
  62. package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
  63. package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
  64. package/dist/plugins/n8n/test/manifest.test.ts +0 -1
  65. package/dist/plugins/twenty/package.json +5 -0
  66. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
  67. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
  68. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
  69. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
  70. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
  71. package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
  72. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
  73. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
  74. package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
  75. package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
  76. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
  77. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
  78. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
  79. package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
  80. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
  81. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
  82. package/dist/plugins/twenty/src/manifest.ts +99 -0
  83. package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
  84. package/dist/plugins/twenty/test/manifest.test.ts +12 -2
  85. package/dist/terraform/examples/greenfield/main.tf +90 -380
  86. package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
  87. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  88. package/dist/terraform/modules/app/agentcore-pi/main.tf +29 -38
  89. package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
  90. package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
  91. package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
  92. package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
  93. package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
  94. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
  95. package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
  96. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
  97. package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
  98. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
  99. package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
  100. package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
  101. package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
  102. package/dist/terraform/modules/app/ses-email/main.tf +14 -2
  103. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
  104. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
  105. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
  106. package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
  107. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
  108. package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
  109. package/dist/terraform/modules/thinkwork/README.md +0 -1
  110. package/dist/terraform/modules/thinkwork/main.tf +167 -446
  111. package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
  112. package/dist/terraform/modules/thinkwork/variables.tf +55 -309
  113. package/dist/terraform/schema.graphql +4 -0
  114. package/dist/workspace-defaults/files/AGENTS.md +45 -31
  115. package/dist/workspace-defaults/files/CONTEXT.md +48 -4
  116. package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
  117. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
  118. package/dist/workspace-defaults/files/SPACE.md +34 -19
  119. package/dist/workspace-defaults/files/TOOLS.md +30 -0
  120. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
  121. package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
  122. package/package.json +1 -1
  123. package/dist/plugins/company-brain/README.md +0 -37
  124. package/dist/plugins/company-brain/package.json +0 -33
  125. package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
  126. package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
  127. package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
  128. package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
  129. package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
  130. package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
  131. package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
  132. package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
  133. package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
  134. package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
  135. package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
  136. package/dist/plugins/company-brain/src/api/migration.ts +0 -665
  137. package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
  138. package/dist/plugins/company-brain/src/index.ts +0 -62
  139. package/dist/plugins/company-brain/src/manifest.ts +0 -112
  140. package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
  141. package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
  142. package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
  143. package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
  144. package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
  145. package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
  146. package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
  147. package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
  148. package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
  149. package/dist/plugins/company-brain/tsconfig.json +0 -8
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Workspace-member provisioning via the invite-token flow (plan U4, KTD4).
3
+ *
4
+ * As the admin user: sendInvitations(emails) → read the personal invite tokens
5
+ * back → signUpInWorkspace(email, password, workspaceId, token) per rep with
6
+ * the shared test password (R5, value from env — never in the repo). Email
7
+ * delivery is irrelevant, which neutralizes TEI's SES sandbox.
8
+ *
9
+ * The auth/invitation mutations live on Twenty's core schema, which requires a
10
+ * logged-in user token (UserAuthGuard) — the workspace API key is not enough.
11
+ */
12
+
13
+ import { TwentyClient, TwentyGraphqlError } from "./twenty-client";
14
+
15
+ export interface RepToProvision {
16
+ /** LastMile sales_rep.id — the value owner refs on CRM rows point at. */
17
+ repId: string;
18
+ email: string | null;
19
+ firstName: string | null;
20
+ lastName: string | null;
21
+ archived: boolean;
22
+ }
23
+
24
+ export interface WorkspaceMember {
25
+ id: string;
26
+ userEmail: string;
27
+ }
28
+
29
+ const WORKSPACE_MEMBERS_QUERY = `
30
+ query MigrationWorkspaceMembers($after: String) {
31
+ workspaceMembers(first: 200, after: $after) {
32
+ edges { node { id userEmail } cursor }
33
+ pageInfo { hasNextPage endCursor }
34
+ }
35
+ }
36
+ `;
37
+
38
+ export async function listWorkspaceMembers(
39
+ client: TwentyClient,
40
+ ): Promise<WorkspaceMember[]> {
41
+ const members: WorkspaceMember[] = [];
42
+ let after: string | null = null;
43
+ for (;;) {
44
+ const data: {
45
+ workspaceMembers: {
46
+ edges: Array<{ node: WorkspaceMember }>;
47
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
48
+ };
49
+ } = await client.requestWithRetry(
50
+ "/graphql",
51
+ WORKSPACE_MEMBERS_QUERY,
52
+ after ? { after } : {},
53
+ );
54
+ members.push(...data.workspaceMembers.edges.map((edge) => edge.node));
55
+ if (!data.workspaceMembers.pageInfo.hasNextPage) break;
56
+ after = data.workspaceMembers.pageInfo.endCursor;
57
+ }
58
+ return members;
59
+ }
60
+
61
+ /**
62
+ * Obtain an admin user access token via password sign-in on the core schema.
63
+ * Twenty's flow is getLoginTokenFromCredentials → getAuthTokensFromLoginToken.
64
+ */
65
+ export async function adminSignIn(options: {
66
+ baseUrl: string;
67
+ email: string;
68
+ password: string;
69
+ fetchImpl?: typeof fetch;
70
+ }): Promise<{ accessToken: string; workspaceId: string | null }> {
71
+ const fetchImpl = options.fetchImpl ?? fetch;
72
+ const origin = options.baseUrl;
73
+
74
+ async function core<T>(
75
+ query: string,
76
+ variables: Record<string, unknown>,
77
+ ): Promise<T> {
78
+ const response = await fetchImpl(`${options.baseUrl}/graphql`, {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify({ query, variables }),
82
+ });
83
+ const body = (await response.json()) as {
84
+ data?: T;
85
+ errors?: Array<{ message: string }>;
86
+ };
87
+ if (body.errors?.length) {
88
+ throw new TwentyGraphqlError(
89
+ `Twenty core auth errors: ${JSON.stringify(body.errors)}`,
90
+ {
91
+ errors: body.errors,
92
+ },
93
+ );
94
+ }
95
+ if (!body.data)
96
+ throw new TwentyGraphqlError("Twenty core auth returned no data.");
97
+ return body.data;
98
+ }
99
+
100
+ const loginData = await core<{
101
+ getLoginTokenFromCredentials: { loginToken: { token: string } };
102
+ }>(
103
+ `mutation MigrationLogin($email: String!, $password: String!, $origin: String!) {
104
+ getLoginTokenFromCredentials(email: $email, password: $password, origin: $origin) {
105
+ loginToken { token }
106
+ }
107
+ }`,
108
+ { email: options.email, password: options.password, origin },
109
+ );
110
+
111
+ const tokensData = await core<{
112
+ getAuthTokensFromLoginToken: {
113
+ tokens: { accessOrWorkspaceAgnosticToken: { token: string } };
114
+ };
115
+ }>(
116
+ `mutation MigrationAuthTokens($loginToken: String!, $origin: String!) {
117
+ getAuthTokensFromLoginToken(loginToken: $loginToken, origin: $origin) {
118
+ tokens { accessOrWorkspaceAgnosticToken { token } }
119
+ }
120
+ }`,
121
+ {
122
+ loginToken: loginData.getLoginTokenFromCredentials.loginToken.token,
123
+ origin,
124
+ },
125
+ );
126
+
127
+ const accessToken =
128
+ tokensData.getAuthTokensFromLoginToken.tokens.accessOrWorkspaceAgnosticToken
129
+ .token;
130
+ // The workspaceId claim rides inside the JWT payload.
131
+ let workspaceId: string | null = null;
132
+ try {
133
+ const payload = JSON.parse(
134
+ Buffer.from(accessToken.split(".")[1], "base64url").toString("utf8"),
135
+ ) as { workspaceId?: string };
136
+ workspaceId = payload.workspaceId ?? null;
137
+ } catch {
138
+ workspaceId = null;
139
+ }
140
+ return { accessToken, workspaceId };
141
+ }
142
+
143
+ export interface MemberProvisionReportRow {
144
+ repId: string;
145
+ email: string | null;
146
+ action:
147
+ | "existing"
148
+ | "invited+signedUp"
149
+ | "unprovisionable"
150
+ | "failed"
151
+ | "planned"
152
+ | "merged-duplicate-email";
153
+ error?: string;
154
+ }
155
+
156
+ export interface MembersEnsureResult {
157
+ /** LastMile rep id → Twenty workspaceMemberId, for owner resolution (R6). */
158
+ ownerMap: Map<string, string>;
159
+ report: MemberProvisionReportRow[];
160
+ /** True when any rep failed to provision (partial-failure exit code, U4). */
161
+ hadFailures: boolean;
162
+ }
163
+
164
+ export interface MembersEnsureOptions {
165
+ dataClient: TwentyClient;
166
+ /** Client authed with the ADMIN USER token; required only when applying. */
167
+ adminClient: TwentyClient | null;
168
+ reps: RepToProvision[];
169
+ repPassword: string;
170
+ workspaceId: string | null;
171
+ dryRun: boolean;
172
+ }
173
+
174
+ export async function ensureMembers(
175
+ options: MembersEnsureOptions,
176
+ ): Promise<MembersEnsureResult> {
177
+ const { dataClient, adminClient, reps, repPassword, workspaceId, dryRun } =
178
+ options;
179
+ const report: MemberProvisionReportRow[] = [];
180
+ const ownerMap = new Map<string, string>();
181
+ let hadFailures = false;
182
+
183
+ // Duplicate emails across reps collapse into ONE login: email is the login
184
+ // identity (Twenty cannot hold two members with one email), so every rep id
185
+ // sharing the email maps to the same workspace member. Observed live: a
186
+ // "house" sales_rep row reusing a real rep's email. The merge is reported
187
+ // per secondary rep so it is visible, never silent.
188
+ const primaryRepByEmail = new Map<string, RepToProvision>();
189
+ const mergedReps: RepToProvision[] = [];
190
+ for (const rep of reps) {
191
+ if (!rep.email) continue;
192
+ const primary = primaryRepByEmail.get(rep.email);
193
+ if (!primary) primaryRepByEmail.set(rep.email, rep);
194
+ else mergedReps.push(rep);
195
+ }
196
+
197
+ const existingMembers = await listWorkspaceMembers(dataClient);
198
+ const membersByEmail = new Map(
199
+ existingMembers.map((member) => [member.userEmail.toLowerCase(), member]),
200
+ );
201
+
202
+ const mergedRepIds = new Set(mergedReps.map((rep) => rep.repId));
203
+ const toInvite: RepToProvision[] = [];
204
+ for (const rep of reps) {
205
+ if (!rep.email) {
206
+ report.push({ repId: rep.repId, email: null, action: "unprovisionable" });
207
+ continue;
208
+ }
209
+ if (mergedRepIds.has(rep.repId)) continue; // resolved after primaries below
210
+ const existing = membersByEmail.get(rep.email);
211
+ if (existing) {
212
+ ownerMap.set(rep.repId, existing.id);
213
+ report.push({ repId: rep.repId, email: rep.email, action: "existing" });
214
+ continue;
215
+ }
216
+ toInvite.push(rep);
217
+ }
218
+
219
+ const resolveMergedReps = () => {
220
+ for (const rep of mergedReps) {
221
+ const primary = primaryRepByEmail.get(
222
+ rep.email as string,
223
+ ) as RepToProvision;
224
+ const memberId = ownerMap.get(primary.repId);
225
+ if (memberId) ownerMap.set(rep.repId, memberId);
226
+ report.push({
227
+ repId: rep.repId,
228
+ email: rep.email,
229
+ action: "merged-duplicate-email",
230
+ });
231
+ }
232
+ };
233
+
234
+ if (dryRun) {
235
+ for (const rep of toInvite) {
236
+ report.push({ repId: rep.repId, email: rep.email, action: "planned" });
237
+ }
238
+ resolveMergedReps();
239
+ return { ownerMap, report, hadFailures };
240
+ }
241
+
242
+ if (toInvite.length > 0) {
243
+ if (!adminClient) {
244
+ throw new Error(
245
+ "Provisioning new members requires TWENTY_ADMIN_EMAIL/TWENTY_ADMIN_PASSWORD (admin user token).",
246
+ );
247
+ }
248
+ if (!workspaceId) {
249
+ throw new Error(
250
+ "workspaceId could not be resolved from the admin session; set TWENTY_WORKSPACE_ID.",
251
+ );
252
+ }
253
+
254
+ await adminClient.requestOnce(
255
+ "/graphql",
256
+ `mutation MigrationInvite($emails: [String!]!) {
257
+ sendInvitations(emails: $emails) {
258
+ success
259
+ result { id email }
260
+ }
261
+ }`,
262
+ { emails: toInvite.map((rep) => rep.email) },
263
+ );
264
+
265
+ const invitationsData = await adminClient.requestWithRetry<{
266
+ findWorkspaceInvitations: Array<{ id: string; email: string }>;
267
+ }>(
268
+ "/graphql",
269
+ `query MigrationInvitations {
270
+ findWorkspaceInvitations { id email }
271
+ }`,
272
+ );
273
+
274
+ // The personal invite token is read back per invitation.
275
+ for (const rep of toInvite) {
276
+ try {
277
+ const invitation = invitationsData.findWorkspaceInvitations.find(
278
+ (candidate) => candidate.email.toLowerCase() === rep.email,
279
+ );
280
+ if (!invitation) {
281
+ throw new Error(
282
+ `no invitation found for ${rep.email} after sendInvitations`,
283
+ );
284
+ }
285
+ const tokenData = await adminClient.requestWithRetry<{
286
+ getWorkspaceInvitationToken: { token: string } | string;
287
+ }>(
288
+ "/graphql",
289
+ `query MigrationInviteToken($invitationId: String!) {
290
+ getWorkspaceInvitationToken(invitationId: $invitationId)
291
+ }`,
292
+ { invitationId: invitation.id },
293
+ );
294
+ const rawToken = tokenData.getWorkspaceInvitationToken;
295
+ const inviteToken =
296
+ typeof rawToken === "string" ? rawToken : rawToken.token;
297
+
298
+ await signUpRepInWorkspace({
299
+ baseUrl: dataClient.endpoint("/graphql").replace(/\/graphql$/, ""),
300
+ email: rep.email as string,
301
+ password: repPassword,
302
+ workspaceId,
303
+ inviteToken,
304
+ });
305
+ // Membership materializes after signup; resolve the member id.
306
+ const refreshed = await listWorkspaceMembers(dataClient);
307
+ const member = refreshed.find(
308
+ (candidate) => candidate.userEmail.toLowerCase() === rep.email,
309
+ );
310
+ if (!member)
311
+ throw new Error(`member for ${rep.email} not visible after signup`);
312
+ ownerMap.set(rep.repId, member.id);
313
+ report.push({
314
+ repId: rep.repId,
315
+ email: rep.email,
316
+ action: "invited+signedUp",
317
+ });
318
+ } catch (error) {
319
+ hadFailures = true;
320
+ report.push({
321
+ repId: rep.repId,
322
+ email: rep.email,
323
+ action: "failed",
324
+ error: error instanceof Error ? error.message : String(error),
325
+ });
326
+ }
327
+ }
328
+ }
329
+
330
+ resolveMergedReps();
331
+ return { ownerMap, report, hadFailures };
332
+ }
333
+
334
+ async function signUpRepInWorkspace(options: {
335
+ baseUrl: string;
336
+ email: string;
337
+ password: string;
338
+ workspaceId: string;
339
+ inviteToken: string;
340
+ fetchImpl?: typeof fetch;
341
+ }): Promise<void> {
342
+ const fetchImpl = options.fetchImpl ?? fetch;
343
+ const response = await fetchImpl(`${options.baseUrl}/graphql`, {
344
+ method: "POST",
345
+ headers: { "content-type": "application/json" },
346
+ body: JSON.stringify({
347
+ query: `mutation MigrationSignUp(
348
+ $email: String!
349
+ $password: String!
350
+ $workspaceId: String!
351
+ $workspacePersonalInviteToken: String
352
+ ) {
353
+ signUpInWorkspace(
354
+ email: $email
355
+ password: $password
356
+ workspaceId: { id: $workspaceId }
357
+ workspacePersonalInviteToken: $workspacePersonalInviteToken
358
+ ) {
359
+ loginToken { token }
360
+ }
361
+ }`,
362
+ variables: {
363
+ email: options.email,
364
+ password: options.password,
365
+ workspaceId: options.workspaceId,
366
+ workspacePersonalInviteToken: options.inviteToken,
367
+ },
368
+ }),
369
+ });
370
+ const body = (await response.json()) as {
371
+ errors?: Array<{ message: string }>;
372
+ };
373
+ if (body.errors?.length) {
374
+ throw new TwentyGraphqlError(
375
+ `signUpInWorkspace failed: ${JSON.stringify(body.errors)}`,
376
+ {
377
+ errors: body.errors,
378
+ },
379
+ );
380
+ }
381
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Direct-Postgres workspace-member provisioning for TEI's Twenty.
3
+ *
4
+ * WHY THIS EXISTS (2026-07-10): TEI's Twenty deployment does not serve the
5
+ * auth GraphQL schema over its ALB — `sendInvitations`, `signUpInWorkspace`,
6
+ * and `getLoginTokenFromCredentials` all return "Cannot query field" on
7
+ * `/graphql` and `/metadata`, on every header/origin combination, and `/rest`
8
+ * exposes object CRUD only ("object 'auth' not found"). The workspace API key
9
+ * reaches only the workspace-object schema. There is therefore NO API path to
10
+ * create a user. The migration plan names direct Twenty-Postgres writes a stop
11
+ * condition; Eric authorized this route explicitly after that was surfaced.
12
+ *
13
+ * SAFETY POSTURE
14
+ * - INSERT-only. Never updates or deletes an existing row.
15
+ * - Idempotent: a rep whose email already exists in `core.user` is skipped.
16
+ * - One transaction per rep; a failure rolls that rep back and the run
17
+ * continues, reporting the failure.
18
+ * - Row shapes are copied from the two users the running Twenty created
19
+ * itself (bcrypt `$2b$10$`, Admin/Member role via `core.roleTarget`,
20
+ * `workspaceMember` in the workspace schema).
21
+ * - Removal is a plain `DELETE FROM core."user" WHERE email = ...` — the
22
+ * userWorkspace/roleTarget/workspaceMember rows cascade or are deleted here.
23
+ */
24
+
25
+ import bcrypt from "bcryptjs";
26
+ import pg from "pg";
27
+
28
+ export interface ProvisionRep {
29
+ repId: string;
30
+ email: string;
31
+ firstName: string | null;
32
+ lastName: string | null;
33
+ }
34
+
35
+ export interface ProvisionRow {
36
+ email: string;
37
+ action: "created" | "exists" | "failed";
38
+ workspaceMemberId?: string;
39
+ error?: string;
40
+ }
41
+
42
+ export interface ProvisionResult {
43
+ rows: ProvisionRow[];
44
+ created: number;
45
+ existing: number;
46
+ failed: number;
47
+ }
48
+
49
+ /**
50
+ * Twenty stores bcrypt `$2b$` hashes. bcryptjs emits the `$2a$` prefix; the
51
+ * two are the same algorithm for passwords under 256 bytes, and bcryptjs
52
+ * itself verifies the rewritten hash. Rewriting keeps the stored shape
53
+ * byte-identical to what Twenty's own signup writes.
54
+ */
55
+ export function hashPassword(password: string, rounds = 10): string {
56
+ if (Buffer.byteLength(password, "utf8") > 72) {
57
+ // bcrypt silently truncates past 72 bytes — refuse rather than create a
58
+ // login whose password is not what the operator thinks it is.
59
+ throw new Error("Password exceeds bcrypt's 72-byte limit.");
60
+ }
61
+ const hash = bcrypt.hashSync(password, rounds);
62
+ const rewritten = hash.replace(/^\$2a\$/, "$2b$");
63
+ if (!bcrypt.compareSync(password, rewritten)) {
64
+ throw new Error("bcrypt self-check failed — refusing to write the hash.");
65
+ }
66
+ return rewritten;
67
+ }
68
+
69
+ export interface ProvisionOptions {
70
+ databaseUrl: string;
71
+ workspaceId: string;
72
+ /** Role granted to every provisioned rep — the workspace's default role. */
73
+ roleId: string;
74
+ /** applicationId reused from the workspace's existing roleTarget rows. */
75
+ applicationId: string;
76
+ /** Workspace data schema, e.g. workspace_brl3ypdat40udm5gtn95sozcg. */
77
+ workspaceSchema: string;
78
+ password: string;
79
+ reps: ProvisionRep[];
80
+ dryRun: boolean;
81
+ log?: (message: string) => void;
82
+ }
83
+
84
+ export async function provisionMembers(
85
+ options: ProvisionOptions,
86
+ ): Promise<ProvisionResult> {
87
+ const { databaseUrl, workspaceId, roleId, applicationId, workspaceSchema } =
88
+ options;
89
+ const log = options.log ?? (() => {});
90
+ const rows: ProvisionRow[] = [];
91
+
92
+ // Hash once: every rep shares the validation-window password, and bcrypt at
93
+ // cost 10 is ~100ms — 91 reps would otherwise cost ~10s for no benefit.
94
+ // Distinct salts per rep are not a security gain here because the password
95
+ // is shared and slated for rotation at cutover.
96
+ const passwordHash = hashPassword(options.password);
97
+
98
+ const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 });
99
+ try {
100
+ const existing = await pool.query<{ email: string }>(
101
+ `select lower(email) as email from core."user" where "deletedAt" is null`,
102
+ );
103
+ const existingEmails = new Set(existing.rows.map((row) => row.email));
104
+
105
+ for (const rep of options.reps) {
106
+ const email = rep.email.toLowerCase();
107
+ if (existingEmails.has(email)) {
108
+ rows.push({ email, action: "exists" });
109
+ continue;
110
+ }
111
+ if (options.dryRun) {
112
+ rows.push({ email, action: "created" });
113
+ continue;
114
+ }
115
+
116
+ const client = await pool.connect();
117
+ try {
118
+ await client.query("begin");
119
+ const user = await client.query<{ id: string }>(
120
+ `insert into core."user" ("firstName", "lastName", email, "passwordHash", "isEmailVerified")
121
+ values ($1, $2, $3, $4, true)
122
+ returning id`,
123
+ [rep.firstName ?? "", rep.lastName ?? "", email, passwordHash],
124
+ );
125
+ const userId = user.rows[0].id;
126
+
127
+ const userWorkspace = await client.query<{ id: string }>(
128
+ `insert into core."userWorkspace" ("userId", "workspaceId")
129
+ values ($1, $2)
130
+ returning id`,
131
+ [userId, workspaceId],
132
+ );
133
+ const userWorkspaceId = userWorkspace.rows[0].id;
134
+
135
+ await client.query(
136
+ `insert into core."roleTarget"
137
+ (id, "workspaceId", "roleId", "userWorkspaceId", "createdAt", "updatedAt", "universalIdentifier", "applicationId")
138
+ values (uuid_generate_v4(), $1, $2, $3, now(), now(), uuid_generate_v4(), $4)`,
139
+ [workspaceId, roleId, userWorkspaceId, applicationId],
140
+ );
141
+
142
+ const member = await client.query<{ id: string }>(
143
+ `insert into ${quoteIdent(workspaceSchema)}."workspaceMember"
144
+ ("nameFirstName", "nameLastName", "userEmail", "userId")
145
+ values ($1, $2, $3, $4)
146
+ returning id`,
147
+ [rep.firstName ?? "", rep.lastName ?? "", email, userId],
148
+ );
149
+
150
+ await client.query("commit");
151
+ existingEmails.add(email);
152
+ rows.push({
153
+ email,
154
+ action: "created",
155
+ workspaceMemberId: member.rows[0].id,
156
+ });
157
+ log(`provisioned ${email}`);
158
+ } catch (error) {
159
+ await client.query("rollback").catch(() => {});
160
+ rows.push({
161
+ email,
162
+ action: "failed",
163
+ error:
164
+ error instanceof Error
165
+ ? error.message.slice(0, 300)
166
+ : String(error),
167
+ });
168
+ } finally {
169
+ client.release();
170
+ }
171
+ }
172
+ } finally {
173
+ await pool.end();
174
+ }
175
+
176
+ return {
177
+ rows,
178
+ created: rows.filter((row) => row.action === "created").length,
179
+ existing: rows.filter((row) => row.action === "exists").length,
180
+ failed: rows.filter((row) => row.action === "failed").length,
181
+ };
182
+ }
183
+
184
+ /** Workspace schema names are `workspace_<id>`; reject anything else rather
185
+ * than interpolate an arbitrary identifier into SQL. */
186
+ export function quoteIdent(schema: string): string {
187
+ if (!/^workspace_[a-z0-9]+$/.test(schema)) {
188
+ throw new Error(`Refusing unexpected workspace schema name: ${schema}`);
189
+ }
190
+ return `"${schema}"`;
191
+ }