dxcomplete 0.2.1 → 0.3.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 (89) hide show
  1. package/.env.example +0 -7
  2. package/README.md +68 -103
  3. package/dist/cli.js +2 -24
  4. package/dist/validate.js +10 -26
  5. package/docs/cost-model.md +2 -2
  6. package/docs/decision-basis.md +5 -11
  7. package/docs/diagrams.md +3 -3
  8. package/docs/index.md +25 -39
  9. package/docs/model.md +15 -23
  10. package/docs/open-questions.md +1 -1
  11. package/docs/taxonomy.md +7 -8
  12. package/docs/workflows.md +3 -3
  13. package/package.json +24 -24
  14. package/templates/process/README.md +11 -11
  15. package/templates/process/controls.yml +19 -19
  16. package/templates/process/cost-model.yml +3 -3
  17. package/templates/process/decision-basis.yml +4 -4
  18. package/templates/process/diagrams/00-decision-basis.mmd +1 -1
  19. package/templates/process/diagrams/00-overview.mmd +1 -1
  20. package/templates/process/diagrams/01-intake-triage.mmd +4 -4
  21. package/templates/process/diagrams/02-product-definition.mmd +3 -3
  22. package/templates/process/diagrams/03-engineering-execution.mmd +1 -1
  23. package/templates/process/diagrams/04-qa-verification.mmd +1 -1
  24. package/templates/process/diagrams/05-product-validation.mmd +1 -1
  25. package/templates/process/diagrams/06-change-release-control.mmd +1 -1
  26. package/templates/process/diagrams/07-deployment-operations.mmd +1 -1
  27. package/templates/process/diagrams/08-support-incident-management.mmd +1 -1
  28. package/templates/process/diagrams/09-problem-improvement.mmd +1 -1
  29. package/templates/process/diagrams/10-risk-control-management.mmd +1 -1
  30. package/templates/process/diagrams/11-audit-evidence-capture.mmd +1 -1
  31. package/templates/process/roles.yml +6 -6
  32. package/templates/process/taxonomy.yml +46 -46
  33. package/templates/process/workflows.yml +29 -29
  34. package/website/account.html +57 -0
  35. package/website/app.js +177 -0
  36. package/website/flow.html +4 -0
  37. package/website/glossary.html +4 -0
  38. package/website/index.html +4 -0
  39. package/website/objects.html +4 -0
  40. package/website/operating-guide.html +4 -0
  41. package/website/outcomes.html +4 -0
  42. package/website/phase-build.html +4 -0
  43. package/website/phase-elicit.html +4 -0
  44. package/website/phase-go-live.html +4 -0
  45. package/website/phase-measure.html +4 -0
  46. package/website/phase-operate.html +4 -0
  47. package/website/phase-orient.html +4 -0
  48. package/website/phase-weigh.html +4 -0
  49. package/website/roles.html +4 -0
  50. package/website/styles.css +217 -1
  51. package/dist/http/service.d.ts +0 -7
  52. package/dist/http/service.js +0 -725
  53. package/dist/mcp/docs.d.ts +0 -114
  54. package/dist/mcp/docs.js +0 -626
  55. package/dist/mcp/server.d.ts +0 -20
  56. package/dist/mcp/server.js +0 -3059
  57. package/dist/runtime/auth.d.ts +0 -162
  58. package/dist/runtime/auth.js +0 -394
  59. package/dist/runtime/check.d.ts +0 -7
  60. package/dist/runtime/check.js +0 -16
  61. package/dist/runtime/config.d.ts +0 -17
  62. package/dist/runtime/config.js +0 -93
  63. package/dist/runtime/mongo.d.ts +0 -9
  64. package/dist/runtime/mongo.js +0 -56
  65. package/dist/runtime/records.d.ts +0 -427
  66. package/dist/runtime/records.js +0 -2092
  67. package/scripts/check-env-surface.mjs +0 -136
  68. package/scripts/check-public-copy.mjs +0 -263
  69. package/scripts/check-service-boundary.mjs +0 -63
  70. package/scripts/runtime-work-order.mjs +0 -506
  71. package/scripts/smoke-mcp-http.mjs +0 -4026
  72. package/src/cli.ts +0 -268
  73. package/src/http/server.ts +0 -314
  74. package/src/http/service.ts +0 -934
  75. package/src/init.ts +0 -262
  76. package/src/install-manifest.ts +0 -144
  77. package/src/mcp/docs.ts +0 -777
  78. package/src/mcp/server.ts +0 -4580
  79. package/src/package-root.ts +0 -31
  80. package/src/runtime/actor.ts +0 -61
  81. package/src/runtime/auth.ts +0 -673
  82. package/src/runtime/check.ts +0 -18
  83. package/src/runtime/config.ts +0 -128
  84. package/src/runtime/mongo.ts +0 -89
  85. package/src/runtime/records.ts +0 -3205
  86. package/src/runtime/workspace.ts +0 -155
  87. package/src/upgrade.ts +0 -356
  88. package/src/validate.ts +0 -141
  89. package/src/version.ts +0 -16
@@ -1,673 +0,0 @@
1
- import { createHash, randomBytes, randomUUID } from "node:crypto";
2
- import type { Db } from "mongodb";
3
- import { createGoogleActorContext, normalizeEmail, type ActorContext } from "./actor.js";
4
- import type { RuntimeConfig } from "./config.js";
5
- import type { DxcRecord } from "./records.js";
6
- import type { WorkspaceConfig } from "./workspace.js";
7
-
8
- export const WORKSPACE_MEMBERSHIPS_COLLECTION = "workspace_memberships";
9
- export const OAUTH_CLIENTS_COLLECTION = "oauth_clients";
10
- export const OAUTH_AUTH_REQUESTS_COLLECTION = "oauth_authorization_requests";
11
- export const OAUTH_CODES_COLLECTION = "oauth_authorization_codes";
12
- export const OAUTH_TOKENS_COLLECTION = "oauth_tokens";
13
- export const WORKSPACE_SERVICE_CLIENTS_COLLECTION = "workspace_service_clients";
14
-
15
- export type WorkspaceRole = "owner" | "engineer" | "tester" | "operator" | "support_agent" | "end_user";
16
-
17
- export type WorkspaceMembership = {
18
- _id: string;
19
- workspaceId: string;
20
- email: string;
21
- roles: WorkspaceRole[];
22
- role?: "owner" | "member";
23
- provider?: "google";
24
- providerSubject?: string;
25
- createdAt: string;
26
- updatedAt: string;
27
- updatedBy: string;
28
- };
29
-
30
- export type WorkspaceServiceClientRecord = {
31
- _id: string;
32
- workspaceId: string;
33
- clientId: string;
34
- secretHash: string;
35
- name: string;
36
- createdAt: string;
37
- createdBy: string;
38
- updatedAt: string;
39
- updatedBy: string;
40
- revokedAt?: string;
41
- revokedBy?: string;
42
- };
43
-
44
- export type OAuthClientRecord = {
45
- _id: string;
46
- clientId: string;
47
- clientName?: string;
48
- redirectUris: string[];
49
- grantTypes: string[];
50
- responseTypes: string[];
51
- createdAt: string;
52
- updatedAt: string;
53
- };
54
-
55
- export type OAuthAuthorizationRequest = {
56
- _id: string;
57
- clientId: string;
58
- redirectUri: string;
59
- codeChallenge: string;
60
- codeChallengeMethod: "S256";
61
- state?: string;
62
- scope: string;
63
- resource: string;
64
- workspaceId: string;
65
- createdAt: string;
66
- expiresAt: Date;
67
- };
68
-
69
- export type OAuthAuthorizationCode = OAuthAuthorizationRequest & {
70
- actor: ActorContext;
71
- };
72
-
73
- export type OAuthTokenRecord = {
74
- _id: string;
75
- kind: "access" | "refresh";
76
- clientId: string;
77
- workspaceId: string;
78
- actor: ActorContext;
79
- scope: string;
80
- resource: string;
81
- createdAt: string;
82
- expiresAt: Date;
83
- revokedAt?: string;
84
- };
85
-
86
- export type GoogleProfile = {
87
- email: string;
88
- subject: string;
89
- displayName?: string;
90
- };
91
-
92
- const MCP_SCOPE = "mcp:tools";
93
- const ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
94
- const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
95
- const AUTH_REQUEST_TTL_SECONDS = 10 * 60;
96
- const SERVICE_CLIENT_SECRET_PREFIX = "dxc_service_secret_";
97
- const SERVICE_CLIENT_HASH_PREFIX = "sha256:";
98
-
99
- export async function ensureWorkspaceBootstrap(db: Db, config: WorkspaceConfig, actorId: string): Promise<void> {
100
- const now = new Date().toISOString();
101
-
102
- await db.collection<DxcRecord>("workspaces").updateOne(
103
- { _id: config.workspaceId },
104
- {
105
- $set: {
106
- recordType: "workspaces",
107
- title: config.name,
108
- fields: {
109
- name: config.name,
110
- ...(config.mode ? { mode: config.mode } : {})
111
- },
112
- updatedAt: now,
113
- updatedBy: actorId
114
- },
115
- $setOnInsert: {
116
- _id: config.workspaceId,
117
- links: [],
118
- createdAt: now,
119
- createdBy: actorId
120
- }
121
- },
122
- { upsert: true }
123
- );
124
-
125
- await Promise.all(
126
- config.bootstrapMembers.map((member) =>
127
- upsertWorkspaceMembership(db, {
128
- workspaceId: config.workspaceId,
129
- email: member.email,
130
- roles: member.roles,
131
- actorId
132
- })
133
- )
134
- );
135
- }
136
-
137
- export async function upsertWorkspaceMembership(
138
- db: Db,
139
- input: {
140
- workspaceId: string;
141
- email: string;
142
- role?: WorkspaceRole | "member";
143
- roles?: WorkspaceRole[];
144
- actorId: string;
145
- provider?: "google";
146
- providerSubject?: string;
147
- }
148
- ): Promise<WorkspaceMembership> {
149
- const email = normalizeEmail(input.email);
150
- const now = new Date().toISOString();
151
- const id = membershipId(input.workspaceId, email);
152
- const roles = normalizeWorkspaceRoles(input.roles ?? (input.role ? [input.role] : []));
153
-
154
- await db.collection<WorkspaceMembership>(WORKSPACE_MEMBERSHIPS_COLLECTION).updateOne(
155
- { _id: id },
156
- {
157
- $set: {
158
- workspaceId: input.workspaceId,
159
- email,
160
- roles,
161
- ...(input.provider ? { provider: input.provider } : {}),
162
- ...(input.providerSubject ? { providerSubject: input.providerSubject } : {}),
163
- updatedAt: now,
164
- updatedBy: input.actorId
165
- },
166
- $unset: {
167
- role: ""
168
- },
169
- $setOnInsert: {
170
- _id: id,
171
- createdAt: now
172
- }
173
- },
174
- { upsert: true }
175
- );
176
-
177
- const record = await db.collection<WorkspaceMembership>(WORKSPACE_MEMBERSHIPS_COLLECTION).findOne({ _id: id });
178
- if (!record) {
179
- throw new Error("Workspace membership was not written.");
180
- }
181
-
182
- return record;
183
- }
184
-
185
- export async function assertWorkspaceMembership(
186
- db: Db,
187
- workspaceId: string,
188
- actor: ActorContext
189
- ): Promise<WorkspaceMembership> {
190
- if (!actor.email) {
191
- throw new Error("Authenticated actor email is required for workspace authorization.");
192
- }
193
-
194
- const email = normalizeEmail(actor.email);
195
- const membership = await db
196
- .collection<WorkspaceMembership>(WORKSPACE_MEMBERSHIPS_COLLECTION)
197
- .findOne({ _id: membershipId(workspaceId, email) });
198
-
199
- if (!membership) {
200
- throw new Error(`Workspace access denied for ${email}.`);
201
- }
202
-
203
- if (membership.providerSubject && actor.providerSubject && membership.providerSubject !== actor.providerSubject) {
204
- throw new Error(`Workspace access denied for ${email}.`);
205
- }
206
-
207
- if (actor.provider === "google" && actor.providerSubject && !membership.providerSubject) {
208
- await upsertWorkspaceMembership(db, {
209
- workspaceId,
210
- email,
211
- roles: membershipRoles(membership),
212
- actorId: actor.actorId,
213
- provider: "google",
214
- providerSubject: actor.providerSubject
215
- });
216
- }
217
-
218
- if (!Array.isArray(membership.roles) || membership.roles.length === 0 || membership.role) {
219
- return upsertWorkspaceMembership(db, {
220
- workspaceId,
221
- email,
222
- roles: membershipRoles(membership),
223
- actorId: actor.actorId,
224
- ...(membership.provider ? { provider: membership.provider } : {}),
225
- ...(membership.providerSubject ? { providerSubject: membership.providerSubject } : {})
226
- });
227
- }
228
-
229
- return membership;
230
- }
231
-
232
- export async function createWorkspaceServiceClient(
233
- db: Db,
234
- input: {
235
- workspaceId: string;
236
- name: string;
237
- actorId: string;
238
- }
239
- ): Promise<{ record: WorkspaceServiceClientRecord; secret: string }> {
240
- const now = new Date().toISOString();
241
- const clientId = `dxc_service_client_${randomUUID()}`;
242
- const secret = `${SERVICE_CLIENT_SECRET_PREFIX}${randomBytes(32).toString("base64url")}`;
243
- const record: WorkspaceServiceClientRecord = {
244
- _id: clientId,
245
- workspaceId: input.workspaceId,
246
- clientId,
247
- secretHash: hashServiceClientSecret(secret),
248
- name: input.name,
249
- createdAt: now,
250
- createdBy: input.actorId,
251
- updatedAt: now,
252
- updatedBy: input.actorId
253
- };
254
-
255
- await db.collection<WorkspaceServiceClientRecord>(WORKSPACE_SERVICE_CLIENTS_COLLECTION).insertOne(record);
256
- return { record, secret };
257
- }
258
-
259
- export async function verifyWorkspaceServiceClient(
260
- db: Db,
261
- input: {
262
- clientId?: string;
263
- secret?: string;
264
- workspaceId?: string;
265
- }
266
- ): Promise<WorkspaceServiceClientRecord> {
267
- if (!input.clientId || !input.secret) {
268
- throw new Error("DX Complete workspace service credentials are required.");
269
- }
270
-
271
- const record = await db
272
- .collection<WorkspaceServiceClientRecord>(WORKSPACE_SERVICE_CLIENTS_COLLECTION)
273
- .findOne({ _id: input.clientId });
274
-
275
- if (!record || record.revokedAt) {
276
- throw new Error("DX Complete workspace service client was not found or is revoked.");
277
- }
278
-
279
- if (input.workspaceId && record.workspaceId !== input.workspaceId) {
280
- throw new Error("DX Complete workspace service client is not bound to this workspace.");
281
- }
282
-
283
- if (record.secretHash !== hashServiceClientSecret(input.secret)) {
284
- throw new Error("DX Complete workspace service client secret is invalid.");
285
- }
286
-
287
- return record;
288
- }
289
-
290
- export async function registerOAuthClient(
291
- db: Db,
292
- input: {
293
- clientName?: string;
294
- redirectUris: string[];
295
- grantTypes?: string[];
296
- responseTypes?: string[];
297
- }
298
- ): Promise<OAuthClientRecord> {
299
- if (input.redirectUris.length === 0) {
300
- throw new Error("redirect_uris must contain at least one URI.");
301
- }
302
-
303
- const now = new Date().toISOString();
304
- const clientId = `dxc_client_${randomUUID()}`;
305
- const record: OAuthClientRecord = {
306
- _id: clientId,
307
- clientId,
308
- clientName: input.clientName,
309
- redirectUris: input.redirectUris,
310
- grantTypes: input.grantTypes?.length ? input.grantTypes : ["authorization_code", "refresh_token"],
311
- responseTypes: input.responseTypes?.length ? input.responseTypes : ["code"],
312
- createdAt: now,
313
- updatedAt: now
314
- };
315
-
316
- await db.collection<OAuthClientRecord>(OAUTH_CLIENTS_COLLECTION).insertOne(record);
317
- return record;
318
- }
319
-
320
- export async function getOAuthClient(db: Db, clientId: string): Promise<OAuthClientRecord | null> {
321
- return db.collection<OAuthClientRecord>(OAUTH_CLIENTS_COLLECTION).findOne({ _id: clientId });
322
- }
323
-
324
- export async function createOAuthAuthorizationRequest(
325
- db: Db,
326
- input: Omit<OAuthAuthorizationRequest, "_id" | "createdAt" | "expiresAt">
327
- ): Promise<OAuthAuthorizationRequest> {
328
- const now = new Date();
329
- const record: OAuthAuthorizationRequest = {
330
- _id: `dxc_state_${randomBytes(24).toString("base64url")}`,
331
- ...input,
332
- createdAt: now.toISOString(),
333
- expiresAt: new Date(now.getTime() + AUTH_REQUEST_TTL_SECONDS * 1000)
334
- };
335
-
336
- await db.collection<OAuthAuthorizationRequest>(OAUTH_AUTH_REQUESTS_COLLECTION).insertOne(record);
337
- return record;
338
- }
339
-
340
- export async function consumeOAuthAuthorizationRequest(
341
- db: Db,
342
- state: string
343
- ): Promise<OAuthAuthorizationRequest> {
344
- const collection = db.collection<OAuthAuthorizationRequest>(OAUTH_AUTH_REQUESTS_COLLECTION);
345
- const record = await collection.findOne({ _id: state });
346
-
347
- if (!record || isExpired(record.expiresAt)) {
348
- throw new Error("OAuth authorization request expired or was not found.");
349
- }
350
-
351
- await collection.deleteOne({ _id: state });
352
- return record;
353
- }
354
-
355
- export async function createOAuthAuthorizationCode(
356
- db: Db,
357
- request: OAuthAuthorizationRequest,
358
- actor: ActorContext
359
- ): Promise<string> {
360
- const code = `dxc_code_${randomBytes(32).toString("base64url")}`;
361
- const record: OAuthAuthorizationCode = {
362
- ...request,
363
- _id: code,
364
- actor
365
- };
366
-
367
- await db.collection<OAuthAuthorizationCode>(OAUTH_CODES_COLLECTION).insertOne(record);
368
- return code;
369
- }
370
-
371
- export async function exchangeOAuthAuthorizationCode(
372
- db: Db,
373
- input: {
374
- code: string;
375
- clientId: string;
376
- redirectUri: string;
377
- codeVerifier: string;
378
- resource: string;
379
- }
380
- ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number; scope: string }> {
381
- const collection = db.collection<OAuthAuthorizationCode>(OAUTH_CODES_COLLECTION);
382
- const record = await collection.findOne({ _id: input.code });
383
-
384
- if (!record || isExpired(record.expiresAt)) {
385
- throw new Error("Authorization code expired or was not found.");
386
- }
387
-
388
- if (
389
- record.clientId !== input.clientId ||
390
- record.redirectUri !== input.redirectUri ||
391
- record.resource !== input.resource
392
- ) {
393
- throw new Error("Authorization code request does not match the original authorization.");
394
- }
395
-
396
- if (!verifyPkce(input.codeVerifier, record.codeChallenge)) {
397
- throw new Error("Invalid PKCE verifier.");
398
- }
399
-
400
- await collection.deleteOne({ _id: input.code });
401
- return issueMcpTokenPair(db, record);
402
- }
403
-
404
- export async function exchangeOAuthRefreshToken(
405
- db: Db,
406
- input: {
407
- refreshToken: string;
408
- clientId: string;
409
- resource: string;
410
- }
411
- ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number; scope: string }> {
412
- const existing = await findUsableToken(db, input.refreshToken, "refresh");
413
- if (existing.clientId !== input.clientId || existing.resource !== input.resource) {
414
- throw new Error("Refresh token does not match this client or resource.");
415
- }
416
-
417
- await db.collection<OAuthTokenRecord>(OAUTH_TOKENS_COLLECTION).updateOne(
418
- { _id: existing._id },
419
- {
420
- $set: {
421
- revokedAt: new Date().toISOString()
422
- }
423
- }
424
- );
425
-
426
- return issueMcpTokenPair(db, {
427
- clientId: existing.clientId,
428
- scope: existing.scope,
429
- resource: existing.resource,
430
- workspaceId: existing.workspaceId,
431
- actor: existing.actor
432
- });
433
- }
434
-
435
- export async function issueMcpTokenPair(
436
- db: Db,
437
- input: {
438
- clientId: string;
439
- scope: string;
440
- resource: string;
441
- workspaceId: string;
442
- actor: ActorContext;
443
- }
444
- ): Promise<{ accessToken: string; refreshToken: string; expiresIn: number; scope: string }> {
445
- const accessToken = `dxc_access_${randomBytes(32).toString("base64url")}`;
446
- const refreshToken = `dxc_refresh_${randomBytes(32).toString("base64url")}`;
447
- const now = new Date();
448
-
449
- await db.collection<OAuthTokenRecord>(OAUTH_TOKENS_COLLECTION).insertMany([
450
- {
451
- _id: hashToken(accessToken),
452
- kind: "access",
453
- clientId: input.clientId,
454
- workspaceId: input.workspaceId,
455
- actor: input.actor,
456
- scope: input.scope || MCP_SCOPE,
457
- resource: input.resource,
458
- createdAt: now.toISOString(),
459
- expiresAt: new Date(now.getTime() + ACCESS_TOKEN_TTL_SECONDS * 1000)
460
- },
461
- {
462
- _id: hashToken(refreshToken),
463
- kind: "refresh",
464
- clientId: input.clientId,
465
- workspaceId: input.workspaceId,
466
- actor: input.actor,
467
- scope: input.scope || MCP_SCOPE,
468
- resource: input.resource,
469
- createdAt: now.toISOString(),
470
- expiresAt: new Date(now.getTime() + REFRESH_TOKEN_TTL_SECONDS * 1000)
471
- }
472
- ]);
473
-
474
- return {
475
- accessToken,
476
- refreshToken,
477
- expiresIn: ACCESS_TOKEN_TTL_SECONDS,
478
- scope: input.scope || MCP_SCOPE
479
- };
480
- }
481
-
482
- export async function verifyMcpAccessToken(
483
- db: Db,
484
- input: {
485
- token: string;
486
- workspaceId: string;
487
- resource: string;
488
- }
489
- ): Promise<OAuthTokenRecord> {
490
- const record = await findUsableToken(db, input.token, "access");
491
-
492
- if (record.workspaceId !== input.workspaceId || record.resource !== input.resource) {
493
- throw new Error("Access token is not valid for this workspace resource.");
494
- }
495
-
496
- await assertWorkspaceMembership(db, input.workspaceId, record.actor);
497
- return record;
498
- }
499
-
500
- export async function exchangeGoogleCodeForProfile(
501
- config: RuntimeConfig,
502
- input: {
503
- code: string;
504
- redirectUri: string;
505
- }
506
- ): Promise<GoogleProfile> {
507
- if (!config.googleClientId || !config.googleClientSecret) {
508
- throw new Error("Google OAuth is not configured.");
509
- }
510
-
511
- const response = await fetch("https://oauth2.googleapis.com/token", {
512
- method: "POST",
513
- headers: {
514
- "content-type": "application/x-www-form-urlencoded"
515
- },
516
- body: new URLSearchParams({
517
- code: input.code,
518
- client_id: config.googleClientId,
519
- client_secret: config.googleClientSecret,
520
- redirect_uri: input.redirectUri,
521
- grant_type: "authorization_code"
522
- })
523
- });
524
-
525
- if (!response.ok) {
526
- throw new Error(`Google token exchange failed: ${await response.text()}`);
527
- }
528
-
529
- const tokens = (await response.json()) as { id_token?: string };
530
- if (!tokens.id_token) {
531
- throw new Error("Google token exchange did not return an id_token.");
532
- }
533
-
534
- const profileResponse = await fetch(
535
- `https://oauth2.googleapis.com/tokeninfo?id_token=${encodeURIComponent(tokens.id_token)}`
536
- );
537
-
538
- if (!profileResponse.ok) {
539
- throw new Error(`Google token verification failed: ${await profileResponse.text()}`);
540
- }
541
-
542
- const profile = (await profileResponse.json()) as {
543
- aud?: string;
544
- sub?: string;
545
- email?: string;
546
- email_verified?: string | boolean;
547
- name?: string;
548
- };
549
-
550
- if (profile.aud !== config.googleClientId) {
551
- throw new Error("Google token audience did not match this DX Complete deployment.");
552
- }
553
-
554
- if (profile.email_verified !== true && profile.email_verified !== "true") {
555
- throw new Error("Google email is not verified.");
556
- }
557
-
558
- if (!profile.email || !profile.sub) {
559
- throw new Error("Google profile did not include email and subject.");
560
- }
561
-
562
- return {
563
- email: normalizeEmail(profile.email),
564
- subject: profile.sub,
565
- displayName: profile.name
566
- };
567
- }
568
-
569
- export function googleAuthorizationUrl(
570
- config: RuntimeConfig,
571
- input: {
572
- redirectUri: string;
573
- state: string;
574
- }
575
- ): string {
576
- if (!config.googleClientId) {
577
- throw new Error("Google OAuth is not configured.");
578
- }
579
-
580
- const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
581
- url.searchParams.set("client_id", config.googleClientId);
582
- url.searchParams.set("redirect_uri", input.redirectUri);
583
- url.searchParams.set("response_type", "code");
584
- url.searchParams.set("scope", "openid email profile");
585
- url.searchParams.set("state", input.state);
586
- url.searchParams.set("prompt", "select_account");
587
- return url.href;
588
- }
589
-
590
- export function createGoogleActor(profile: GoogleProfile): ActorContext {
591
- return createGoogleActorContext({
592
- email: profile.email,
593
- subject: profile.subject,
594
- displayName: profile.displayName
595
- });
596
- }
597
-
598
- export function membershipRoles(membership: WorkspaceMembership): WorkspaceRole[] {
599
- return normalizeWorkspaceRoles(
600
- Array.isArray(membership.roles) && membership.roles.length > 0
601
- ? membership.roles
602
- : membership.role
603
- ? [membership.role]
604
- : []
605
- );
606
- }
607
-
608
- async function findUsableToken(db: Db, token: string, kind: "access" | "refresh"): Promise<OAuthTokenRecord> {
609
- const record = await db.collection<OAuthTokenRecord>(OAUTH_TOKENS_COLLECTION).findOne({
610
- _id: hashToken(token),
611
- kind,
612
- revokedAt: { $exists: false }
613
- });
614
-
615
- if (!record || isExpired(record.expiresAt)) {
616
- throw new Error("Token expired or was not found.");
617
- }
618
-
619
- return record;
620
- }
621
-
622
- function verifyPkce(verifier: string, challenge: string): boolean {
623
- const actual = createHash("sha256").update(verifier).digest("base64url");
624
- return actual === challenge;
625
- }
626
-
627
- function hashToken(token: string): string {
628
- return createHash("sha256").update(token).digest("hex");
629
- }
630
-
631
- function hashServiceClientSecret(secret: string): string {
632
- return `${SERVICE_CLIENT_HASH_PREFIX}${createHash("sha256").update(secret).digest("hex")}`;
633
- }
634
-
635
- function isExpired(value: string | Date): boolean {
636
- return new Date(value).getTime() <= Date.now();
637
- }
638
-
639
- function membershipId(workspaceId: string, email: string): string {
640
- return `${workspaceId}:${normalizeEmail(email)}`;
641
- }
642
-
643
- function normalizeWorkspaceRoles(values: Array<WorkspaceRole | "member">): WorkspaceRole[] {
644
- const roles = new Set<WorkspaceRole>();
645
-
646
- for (const value of values) {
647
- if (value === "member") {
648
- roles.add("end_user");
649
- continue;
650
- }
651
-
652
- if (isWorkspaceRole(value)) {
653
- roles.add(value);
654
- }
655
- }
656
-
657
- if (roles.size === 0) {
658
- roles.add("end_user");
659
- }
660
-
661
- return [...roles].sort();
662
- }
663
-
664
- function isWorkspaceRole(value: string): value is WorkspaceRole {
665
- return (
666
- value === "owner" ||
667
- value === "engineer" ||
668
- value === "tester" ||
669
- value === "operator" ||
670
- value === "support_agent" ||
671
- value === "end_user"
672
- );
673
- }
@@ -1,18 +0,0 @@
1
- import type { RuntimeOptions } from "./config.js";
2
- import { connectRuntime } from "./mongo.js";
3
- import { COLLECTION_NAMES } from "./records.js";
4
-
5
- export async function checkRuntime(options: RuntimeOptions = {}) {
6
- const runtime = await connectRuntime(options);
7
-
8
- try {
9
- return {
10
- ok: true,
11
- databaseName: runtime.config.databaseName,
12
- envFilePath: runtime.config.envFilePath,
13
- collections: COLLECTION_NAMES
14
- };
15
- } finally {
16
- await runtime.close();
17
- }
18
- }