@nomos-arc/arc 0.1.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 (160) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.nomos-config.json +5 -0
  3. package/CLAUDE.md +108 -0
  4. package/LICENSE +190 -0
  5. package/README.md +569 -0
  6. package/dist/cli.js +21120 -0
  7. package/docs/auth/googel_plan.yaml +1093 -0
  8. package/docs/auth/google_task.md +235 -0
  9. package/docs/auth/hardened_blueprint.yaml +1658 -0
  10. package/docs/auth/red_team_report.yaml +336 -0
  11. package/docs/auth/session_state.yaml +162 -0
  12. package/docs/certificate/cer_enhance_plan.md +605 -0
  13. package/docs/certificate/certificate_report.md +338 -0
  14. package/docs/dev_overview.md +419 -0
  15. package/docs/feature_assessment.md +156 -0
  16. package/docs/how_it_works.md +78 -0
  17. package/docs/infrastructure/map.md +867 -0
  18. package/docs/init/master_plan.md +3581 -0
  19. package/docs/init/red_team_report.md +215 -0
  20. package/docs/init/report_phase_1a.md +304 -0
  21. package/docs/integrity-gate/enhance_drift.md +703 -0
  22. package/docs/integrity-gate/overview.md +108 -0
  23. package/docs/management/manger-task.md +99 -0
  24. package/docs/management/scafffold.md +76 -0
  25. package/docs/map/ATOMIC_BLUEPRINT.md +1349 -0
  26. package/docs/map/RED_TEAM_REPORT.md +159 -0
  27. package/docs/map/map_task.md +147 -0
  28. package/docs/map/semantic_graph_task.md +792 -0
  29. package/docs/map/semantic_master_plan.md +705 -0
  30. package/docs/phase7/TEAM_RED.md +249 -0
  31. package/docs/phase7/plan.md +1682 -0
  32. package/docs/phase7/task.md +275 -0
  33. package/docs/prompts/USAGE.md +312 -0
  34. package/docs/prompts/architect.md +165 -0
  35. package/docs/prompts/executer.md +190 -0
  36. package/docs/prompts/hardener.md +190 -0
  37. package/docs/prompts/red_team.md +146 -0
  38. package/docs/verification/goveranance-overview.md +396 -0
  39. package/docs/verification/governance-overview.md +245 -0
  40. package/docs/verification/verification-arc-ar.md +560 -0
  41. package/docs/verification/verification-architecture.md +560 -0
  42. package/docs/very_next.md +52 -0
  43. package/docs/whitepaper.md +89 -0
  44. package/overview.md +1469 -0
  45. package/package.json +63 -0
  46. package/src/adapters/__tests__/git.test.ts +296 -0
  47. package/src/adapters/__tests__/stdio.test.ts +70 -0
  48. package/src/adapters/git.ts +226 -0
  49. package/src/adapters/pty.ts +159 -0
  50. package/src/adapters/stdio.ts +113 -0
  51. package/src/cli.ts +83 -0
  52. package/src/commands/apply.ts +47 -0
  53. package/src/commands/auth.ts +301 -0
  54. package/src/commands/certificate.ts +89 -0
  55. package/src/commands/discard.ts +24 -0
  56. package/src/commands/drift.ts +116 -0
  57. package/src/commands/index.ts +78 -0
  58. package/src/commands/init.ts +121 -0
  59. package/src/commands/list.ts +75 -0
  60. package/src/commands/map.ts +55 -0
  61. package/src/commands/plan.ts +30 -0
  62. package/src/commands/review.ts +58 -0
  63. package/src/commands/run.ts +63 -0
  64. package/src/commands/search.ts +147 -0
  65. package/src/commands/show.ts +63 -0
  66. package/src/commands/status.ts +59 -0
  67. package/src/core/__tests__/budget.test.ts +213 -0
  68. package/src/core/__tests__/certificate.test.ts +385 -0
  69. package/src/core/__tests__/config.test.ts +191 -0
  70. package/src/core/__tests__/preflight.test.ts +24 -0
  71. package/src/core/__tests__/prompt.test.ts +358 -0
  72. package/src/core/__tests__/review.test.ts +161 -0
  73. package/src/core/__tests__/state.test.ts +362 -0
  74. package/src/core/auth/__tests__/manager.test.ts +166 -0
  75. package/src/core/auth/__tests__/server.test.ts +220 -0
  76. package/src/core/auth/gcp-projects.ts +160 -0
  77. package/src/core/auth/manager.ts +114 -0
  78. package/src/core/auth/server.ts +141 -0
  79. package/src/core/budget.ts +119 -0
  80. package/src/core/certificate.ts +502 -0
  81. package/src/core/config.ts +212 -0
  82. package/src/core/errors.ts +54 -0
  83. package/src/core/factory.ts +49 -0
  84. package/src/core/graph/__tests__/builder.test.ts +272 -0
  85. package/src/core/graph/__tests__/contract-writer.test.ts +175 -0
  86. package/src/core/graph/__tests__/enricher.test.ts +299 -0
  87. package/src/core/graph/__tests__/parser.test.ts +200 -0
  88. package/src/core/graph/__tests__/pipeline.test.ts +202 -0
  89. package/src/core/graph/__tests__/renderer.test.ts +128 -0
  90. package/src/core/graph/__tests__/resolver.test.ts +185 -0
  91. package/src/core/graph/__tests__/scanner.test.ts +231 -0
  92. package/src/core/graph/__tests__/show.test.ts +134 -0
  93. package/src/core/graph/builder.ts +303 -0
  94. package/src/core/graph/constraints.ts +94 -0
  95. package/src/core/graph/contract-writer.ts +93 -0
  96. package/src/core/graph/drift/__tests__/classifier.test.ts +215 -0
  97. package/src/core/graph/drift/__tests__/comparator.test.ts +335 -0
  98. package/src/core/graph/drift/__tests__/drift.test.ts +453 -0
  99. package/src/core/graph/drift/__tests__/reporter.test.ts +203 -0
  100. package/src/core/graph/drift/classifier.ts +165 -0
  101. package/src/core/graph/drift/comparator.ts +205 -0
  102. package/src/core/graph/drift/reporter.ts +77 -0
  103. package/src/core/graph/enricher.ts +251 -0
  104. package/src/core/graph/grammar-paths.ts +30 -0
  105. package/src/core/graph/html-template.ts +493 -0
  106. package/src/core/graph/map-schema.ts +137 -0
  107. package/src/core/graph/parser.ts +336 -0
  108. package/src/core/graph/pipeline.ts +209 -0
  109. package/src/core/graph/renderer.ts +92 -0
  110. package/src/core/graph/resolver.ts +195 -0
  111. package/src/core/graph/scanner.ts +145 -0
  112. package/src/core/logger.ts +46 -0
  113. package/src/core/orchestrator.ts +792 -0
  114. package/src/core/plan-file-manager.ts +66 -0
  115. package/src/core/preflight.ts +64 -0
  116. package/src/core/prompt.ts +173 -0
  117. package/src/core/review.ts +95 -0
  118. package/src/core/state.ts +294 -0
  119. package/src/core/worktree-coordinator.ts +77 -0
  120. package/src/search/__tests__/chunk-extractor.test.ts +339 -0
  121. package/src/search/__tests__/embedder-auth.test.ts +124 -0
  122. package/src/search/__tests__/embedder.test.ts +267 -0
  123. package/src/search/__tests__/graph-enricher.test.ts +178 -0
  124. package/src/search/__tests__/indexer.test.ts +518 -0
  125. package/src/search/__tests__/integration.test.ts +649 -0
  126. package/src/search/__tests__/query-engine.test.ts +334 -0
  127. package/src/search/__tests__/similarity.test.ts +78 -0
  128. package/src/search/__tests__/vector-store.test.ts +281 -0
  129. package/src/search/chunk-extractor.ts +167 -0
  130. package/src/search/embedder.ts +209 -0
  131. package/src/search/graph-enricher.ts +95 -0
  132. package/src/search/indexer.ts +483 -0
  133. package/src/search/lexical-searcher.ts +190 -0
  134. package/src/search/query-engine.ts +225 -0
  135. package/src/search/vector-store.ts +311 -0
  136. package/src/types/index.ts +572 -0
  137. package/src/utils/__tests__/ansi.test.ts +54 -0
  138. package/src/utils/__tests__/frontmatter.test.ts +79 -0
  139. package/src/utils/__tests__/sanitize.test.ts +229 -0
  140. package/src/utils/ansi.ts +19 -0
  141. package/src/utils/context.ts +44 -0
  142. package/src/utils/frontmatter.ts +27 -0
  143. package/src/utils/sanitize.ts +78 -0
  144. package/test/e2e/lifecycle.test.ts +330 -0
  145. package/test/fixtures/mock-planner-hang.ts +5 -0
  146. package/test/fixtures/mock-planner.ts +26 -0
  147. package/test/fixtures/mock-reviewer-bad.ts +8 -0
  148. package/test/fixtures/mock-reviewer-retry.ts +34 -0
  149. package/test/fixtures/mock-reviewer.ts +18 -0
  150. package/test/fixtures/sample-project/src/circular-a.ts +6 -0
  151. package/test/fixtures/sample-project/src/circular-b.ts +6 -0
  152. package/test/fixtures/sample-project/src/config.ts +15 -0
  153. package/test/fixtures/sample-project/src/main.ts +19 -0
  154. package/test/fixtures/sample-project/src/services/product-service.ts +20 -0
  155. package/test/fixtures/sample-project/src/services/user-service.ts +18 -0
  156. package/test/fixtures/sample-project/src/types.ts +14 -0
  157. package/test/fixtures/sample-project/src/utils/index.ts +14 -0
  158. package/test/fixtures/sample-project/src/utils/validate.ts +12 -0
  159. package/tsconfig.json +20 -0
  160. package/vitest.config.ts +12 -0
@@ -0,0 +1,220 @@
1
+ import * as http from 'node:http';
2
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
3
+ import type { Logger } from 'winston';
4
+
5
+ // ─── Mocks ────────────────────────────────────────────────────────────────────
6
+
7
+ const FIXED_STATE = 'deadbeef'.repeat(8); // 64-char hex — deterministic state for tests
8
+
9
+ vi.mock('node:crypto', async (importOriginal) => {
10
+ const actual = await importOriginal<typeof import('node:crypto')>();
11
+ return {
12
+ ...actual,
13
+ randomBytes: vi.fn((size: number) => {
14
+ // Return a buffer whose .toString('hex') yields FIXED_STATE
15
+ return Buffer.from(FIXED_STATE.slice(0, size * 2), 'hex');
16
+ }),
17
+ };
18
+ });
19
+
20
+ // Prevent actual browser launch in tests
21
+ vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) }));
22
+
23
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
24
+
25
+ function makeLogger(): Logger {
26
+ return {
27
+ info: vi.fn(),
28
+ warn: vi.fn(),
29
+ error: vi.fn(),
30
+ debug: vi.fn(),
31
+ } as unknown as Logger;
32
+ }
33
+
34
+ function makeMockOAuth2Client(tokenOverrides = {}) {
35
+ return {
36
+ generateAuthUrl: vi.fn().mockReturnValue('https://accounts.google.com/o/oauth2/auth?test=1'),
37
+ getToken: vi.fn().mockResolvedValue({
38
+ tokens: {
39
+ access_token: 'mock-access-token',
40
+ refresh_token: 'mock-refresh-token',
41
+ expiry_date: Date.now() + 3600_000,
42
+ token_type: 'Bearer',
43
+ scope: 'https://www.googleapis.com/auth/generative-language.retriever',
44
+ ...tokenOverrides,
45
+ },
46
+ }),
47
+ setCredentials: vi.fn(),
48
+ };
49
+ }
50
+
51
+ /** Make an HTTP GET request to the loopback server. Returns status + body. */
52
+ async function httpGet(port: number, queryString: string): Promise<{ status: number; body: string }> {
53
+ return new Promise((resolve, reject) => {
54
+ const req = http.get(`http://127.0.0.1:${port}/?${queryString}`, (res) => {
55
+ let body = '';
56
+ res.on('data', (chunk: Buffer) => { body += chunk.toString(); });
57
+ res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
58
+ });
59
+ req.on('error', reject);
60
+ });
61
+ }
62
+
63
+ /** Find a free port by letting the OS assign one. */
64
+ function getFreePort(): Promise<number> {
65
+ return new Promise((resolve, reject) => {
66
+ const srv = http.createServer();
67
+ srv.listen(0, '127.0.0.1', () => {
68
+ const addr = srv.address() as { port: number };
69
+ srv.close(() => resolve(addr.port));
70
+ });
71
+ srv.on('error', reject);
72
+ });
73
+ }
74
+
75
+ /** Wait a short tick for the server's listen callback to fire. */
76
+ const tick = (ms = 50) => new Promise<void>(resolve => setTimeout(resolve, ms));
77
+
78
+ // ─── Tests ────────────────────────────────────────────────────────────────────
79
+
80
+ describe('startLoopbackServer', () => {
81
+ let startLoopbackServer: (typeof import('../server.js'))['startLoopbackServer'];
82
+
83
+ beforeEach(async () => {
84
+ vi.clearAllMocks();
85
+ ({ startLoopbackServer } = await import('../server.js'));
86
+ });
87
+
88
+ afterEach(() => {
89
+ vi.useRealTimers();
90
+ });
91
+
92
+ // ─── 1. Port binding ──────────────────────────────────────────────────────
93
+
94
+ it('binds to the specified port and resolves with credentials', async () => {
95
+ const port = await getFreePort();
96
+ const oauth2Client = makeMockOAuth2Client();
97
+
98
+ const serverPromise = startLoopbackServer(
99
+ oauth2Client as never,
100
+ ['scope1'],
101
+ port,
102
+ makeLogger(),
103
+ );
104
+
105
+ await tick();
106
+ await httpGet(port, `code=auth-code-123&state=${FIXED_STATE}`);
107
+ const credentials = await serverPromise;
108
+
109
+ expect(credentials.access_token).toBe('mock-access-token');
110
+ expect(credentials.refresh_token).toBe('mock-refresh-token');
111
+ });
112
+
113
+ // ─── 2. Timeout ──────────────────────────────────────────────────────────
114
+
115
+ it('rejects after 120s timeout', async () => {
116
+ // getFreePort must be called before useFakeTimers (it uses real async I/O)
117
+ const port = await getFreePort();
118
+ vi.useFakeTimers();
119
+
120
+ const oauth2Client = makeMockOAuth2Client();
121
+ const serverPromise = startLoopbackServer(
122
+ oauth2Client as never,
123
+ ['scope1'],
124
+ port,
125
+ makeLogger(),
126
+ );
127
+
128
+ // Attach a rejection sink BEFORE advancing timers to avoid unhandled-rejection warning.
129
+ // The actual error is captured via `caught` below.
130
+ const caught = serverPromise.catch((e: unknown) => e);
131
+
132
+ // Advance past the 120s timeout — use async variant to flush the promise chain
133
+ await vi.advanceTimersByTimeAsync(120_001);
134
+
135
+ const err = await caught;
136
+ expect(err).toSatisfy(
137
+ (e: unknown) => e instanceof Error && /timed out/i.test((e as Error).message),
138
+ );
139
+ });
140
+
141
+ // ─── 3. Callback handling — valid code + state ────────────────────────────
142
+
143
+ it('resolves with credentials when code and state are valid', async () => {
144
+ const port = await getFreePort();
145
+ const oauth2Client = makeMockOAuth2Client({ access_token: 'correct-token' });
146
+
147
+ const serverPromise = startLoopbackServer(
148
+ oauth2Client as never,
149
+ ['scope1'],
150
+ port,
151
+ makeLogger(),
152
+ );
153
+
154
+ await tick();
155
+ const { status } = await httpGet(port, `code=valid-code&state=${FIXED_STATE}`);
156
+
157
+ expect(status).toBe(200);
158
+ const credentials = await serverPromise;
159
+ expect(credentials.access_token).toBe('correct-token');
160
+ expect(oauth2Client.getToken).toHaveBeenCalledWith('valid-code');
161
+ });
162
+
163
+ // ─── 4. CSRF state validation (F-002) ─────────────────────────────────────
164
+
165
+ it('returns 403 and does NOT exchange the code when state is wrong', async () => {
166
+ const port = await getFreePort();
167
+ const oauth2Client = makeMockOAuth2Client();
168
+
169
+ const serverPromise = startLoopbackServer(
170
+ oauth2Client as never,
171
+ ['scope1'],
172
+ port,
173
+ makeLogger(),
174
+ );
175
+
176
+ await tick();
177
+
178
+ const { status, body } = await httpGet(port, `code=stolen-code&state=wrong-state`);
179
+
180
+ expect(status).toBe(403);
181
+ expect(body).toMatch(/state mismatch/i);
182
+ // The code must NOT have been exchanged (F-002 validation)
183
+ expect(oauth2Client.getToken).not.toHaveBeenCalled();
184
+
185
+ // Server is still waiting for a valid callback — verify it hasn't resolved
186
+ const raceResult = await Promise.race([
187
+ serverPromise.then(() => 'resolved' as const),
188
+ tick(100).then(() => 'still-pending' as const),
189
+ ]);
190
+ expect(raceResult).toBe('still-pending');
191
+
192
+ // Clean up: send a valid request so the server can shut down gracefully
193
+ await httpGet(port, `code=cleanup-code&state=${FIXED_STATE}`);
194
+ await serverPromise;
195
+ });
196
+
197
+ // ─── 5. Cleanup — server is closed after successful callback ──────────────
198
+
199
+ it('closes the server after a successful callback', async () => {
200
+ const port = await getFreePort();
201
+ const oauth2Client = makeMockOAuth2Client();
202
+
203
+ const serverPromise = startLoopbackServer(
204
+ oauth2Client as never,
205
+ ['scope1'],
206
+ port,
207
+ makeLogger(),
208
+ );
209
+
210
+ await tick();
211
+ await httpGet(port, `code=cleanup-code&state=${FIXED_STATE}`);
212
+ await serverPromise;
213
+
214
+ // Give the server time to fully close
215
+ await tick(100);
216
+
217
+ // After resolution the port should be free — connecting should fail
218
+ await expect(httpGet(port, 'test=1')).rejects.toThrow();
219
+ });
220
+ });
@@ -0,0 +1,160 @@
1
+ import * as readline from 'node:readline';
2
+ import { NomosError } from '../errors.js';
3
+
4
+ // ─── Types ───────────────────────────────────────────────────────────────────
5
+
6
+ interface GcpProject {
7
+ projectId: string;
8
+ displayName: string;
9
+ state: string;
10
+ }
11
+
12
+ interface ProjectsListResponse {
13
+ projects?: Array<{
14
+ projectId: string;
15
+ displayName: string;
16
+ name: string;
17
+ state: string;
18
+ }>;
19
+ nextPageToken?: string;
20
+ }
21
+
22
+ // ─── List GCP Projects ───────────────────────────────────────────────────────
23
+
24
+ /**
25
+ * Fetches all ACTIVE GCP projects accessible to the authenticated user.
26
+ * Uses the Cloud Resource Manager REST API directly (no googleapis dependency).
27
+ */
28
+ export async function listGcpProjects(accessToken: string): Promise<GcpProject[]> {
29
+ const allProjects: GcpProject[] = [];
30
+ let pageToken: string | undefined;
31
+ const MAX_PAGES = 10; // safety cap
32
+
33
+ for (let page = 0; page < MAX_PAGES; page++) {
34
+ const url = new URL('https://cloudresourcemanager.googleapis.com/v3/projects');
35
+ if (pageToken) url.searchParams.set('pageToken', pageToken);
36
+
37
+ const res = await fetch(url.toString(), {
38
+ headers: { 'Authorization': `Bearer ${accessToken}` },
39
+ });
40
+
41
+ if (!res.ok) {
42
+ if (res.status === 403 || res.status === 401) {
43
+ throw new NomosError(
44
+ 'auth_gcp_projects_failed',
45
+ `Failed to list GCP projects (${res.status}). ` +
46
+ `Ensure your account has access to Google Cloud projects and the cloud-platform.read-only scope was granted.`,
47
+ );
48
+ }
49
+ throw new NomosError(
50
+ 'auth_gcp_projects_failed',
51
+ `Failed to list GCP projects: ${res.status} ${res.statusText}`,
52
+ );
53
+ }
54
+
55
+ const data = (await res.json()) as ProjectsListResponse;
56
+ if (data.projects) {
57
+ for (const p of data.projects) {
58
+ if (p.state === 'ACTIVE') {
59
+ allProjects.push({
60
+ projectId: p.projectId,
61
+ displayName: p.displayName || p.projectId,
62
+ state: p.state,
63
+ });
64
+ }
65
+ }
66
+ }
67
+
68
+ if (!data.nextPageToken) break;
69
+ pageToken = data.nextPageToken;
70
+ }
71
+
72
+ // Sort alphabetically by display name
73
+ allProjects.sort((a, b) => a.displayName.localeCompare(b.displayName));
74
+ return allProjects;
75
+ }
76
+
77
+ // ─── Interactive Project Picker ──────────────────────────────────────────────
78
+
79
+ /**
80
+ * Displays a numbered list of GCP projects and prompts the user to select one.
81
+ * Returns the selected projectId.
82
+ */
83
+ export async function promptProjectSelection(
84
+ projects: GcpProject[],
85
+ ): Promise<string> {
86
+ console.log('\n # Project ID Display Name');
87
+ console.log(' ─── ──────────────────────────────── ────────────────────────');
88
+ for (let i = 0; i < projects.length; i++) {
89
+ const p = projects[i]!;
90
+ const num = String(i + 1).padStart(3);
91
+ const id = p.projectId.padEnd(36);
92
+ console.log(` ${num} ${id} ${p.displayName}`);
93
+ }
94
+ console.log('');
95
+
96
+ const MAX_ATTEMPTS = 3;
97
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
98
+ const answer = await askQuestion(`Select project [1-${projects.length}]: `);
99
+ const index = parseInt(answer.trim(), 10);
100
+ if (!isNaN(index) && index >= 1 && index <= projects.length) {
101
+ return projects[index - 1]!.projectId;
102
+ }
103
+ console.log(` Invalid selection. Enter a number between 1 and ${projects.length}.`);
104
+ }
105
+
106
+ throw new NomosError(
107
+ 'auth_gcp_projects_failed',
108
+ 'Project selection failed after 3 invalid attempts.',
109
+ );
110
+ }
111
+
112
+ // ─── Gemini API Health Check ─────────────────────────────────────────────────
113
+
114
+ /**
115
+ * Verifies that the selected GCP project can be used for Gemini API billing.
116
+ * Makes a lightweight models.list call with the x-goog-user-project header.
117
+ * Throws a descriptive error on 403 (API not enabled) with a direct enable link.
118
+ */
119
+ export async function verifyGeminiAccess(
120
+ accessToken: string,
121
+ quotaProjectId: string,
122
+ ): Promise<void> {
123
+ const res = await fetch(
124
+ 'https://generativelanguage.googleapis.com/v1beta/models',
125
+ {
126
+ headers: {
127
+ 'Authorization': `Bearer ${accessToken}`,
128
+ 'x-goog-user-project': quotaProjectId,
129
+ },
130
+ },
131
+ );
132
+
133
+ if (res.ok) return;
134
+
135
+ if (res.status === 403) {
136
+ throw new NomosError(
137
+ 'auth_gemini_not_enabled',
138
+ `Generative Language API is not enabled on project "${quotaProjectId}".\n` +
139
+ ` Enable it: https://console.cloud.google.com/apis/library/generativelanguage.googleapis.com?project=${quotaProjectId}`,
140
+ );
141
+ }
142
+
143
+ // Non-403 errors are warnings, not blockers
144
+ // (the project may still work for billing even if this endpoint has issues)
145
+ }
146
+
147
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
148
+
149
+ function askQuestion(prompt: string): Promise<string> {
150
+ const rl = readline.createInterface({
151
+ input: process.stdin,
152
+ output: process.stdout,
153
+ });
154
+ return new Promise((resolve) => {
155
+ rl.question(prompt, (answer) => {
156
+ rl.close();
157
+ resolve(answer);
158
+ });
159
+ });
160
+ }
@@ -0,0 +1,114 @@
1
+ import * as fs from 'node:fs';
2
+ import * as fsPromises from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { OAuth2Client } from 'google-auth-library';
5
+ import type { Logger } from 'winston';
6
+ import { NomosError } from '../errors.js';
7
+ import type { AuthCredentials, NomosConfig } from '../../types/index.js';
8
+
9
+ export class AuthManager {
10
+ constructor(
11
+ private readonly authConfig: NomosConfig['auth'],
12
+ private readonly logger: Logger,
13
+ ) {}
14
+
15
+ async saveCredentials(tokens: AuthCredentials): Promise<void> {
16
+ const dir = path.dirname(this.authConfig.credentials_path);
17
+ await fsPromises.mkdir(dir, { recursive: true });
18
+ await fsPromises.writeFile(
19
+ this.authConfig.credentials_path,
20
+ JSON.stringify(tokens, null, 2),
21
+ { encoding: 'utf8' },
22
+ );
23
+ await fsPromises.chmod(this.authConfig.credentials_path, 0o600);
24
+ this.logger.info(`[nomos:auth:info] Credentials saved to ${this.authConfig.credentials_path}`);
25
+ }
26
+
27
+ // CONSTRAINT: DO NOT use readFileSync in any method that could be called in a loop
28
+ // or hot path. loadCredentials() is acceptable because it is only called during CLI
29
+ // startup (non-hot-path). (ref: F-008)
30
+ loadCredentials(): AuthCredentials | null {
31
+ try {
32
+ const raw = fs.readFileSync(this.authConfig.credentials_path, 'utf8');
33
+ const parsed = JSON.parse(raw) as unknown;
34
+ if (
35
+ typeof parsed === 'object' &&
36
+ parsed !== null &&
37
+ 'access_token' in parsed &&
38
+ 'refresh_token' in parsed &&
39
+ 'expiry_date' in parsed &&
40
+ 'token_type' in parsed &&
41
+ 'scope' in parsed
42
+ ) {
43
+ return parsed as AuthCredentials;
44
+ }
45
+ return null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ async getAuthenticatedClient(): Promise<OAuth2Client> {
52
+ const credentials = this.loadCredentials();
53
+ if (!credentials) {
54
+ throw new NomosError('auth_not_logged_in', 'Not logged in. Run `arc auth login` first.');
55
+ }
56
+
57
+ // SECURITY: client_id/secret are read from the credentials file (~/.nomos/credentials.json),
58
+ // NOT from project config (.nomos-config.json). They are saved there during `arc auth login`.
59
+ const oauth2Client = new OAuth2Client({
60
+ clientId: credentials.client_id ?? this.authConfig.client_id,
61
+ clientSecret: credentials.client_secret,
62
+ });
63
+
64
+ oauth2Client.setCredentials({
65
+ access_token: credentials.access_token,
66
+ refresh_token: credentials.refresh_token,
67
+ expiry_date: credentials.expiry_date,
68
+ token_type: credentials.token_type,
69
+ scope: credentials.scope,
70
+ });
71
+
72
+ if (credentials.expiry_date < Date.now()) {
73
+ const { credentials: refreshed } = await oauth2Client.refreshAccessToken();
74
+ const updatedTokens: AuthCredentials = {
75
+ access_token: refreshed.access_token ?? credentials.access_token,
76
+ refresh_token: refreshed.refresh_token ?? credentials.refresh_token,
77
+ expiry_date: refreshed.expiry_date ?? credentials.expiry_date,
78
+ token_type: refreshed.token_type ?? credentials.token_type,
79
+ scope: refreshed.scope ?? credentials.scope,
80
+ };
81
+ await this.saveCredentials(updatedTokens);
82
+ oauth2Client.setCredentials(updatedTokens);
83
+ }
84
+
85
+ return oauth2Client;
86
+ }
87
+
88
+ isLoggedIn(): boolean {
89
+ const credentials = this.loadCredentials();
90
+ return credentials !== null && typeof credentials.refresh_token === 'string' && credentials.refresh_token.length > 0;
91
+ }
92
+
93
+ async clearCredentials(): Promise<void> {
94
+ try {
95
+ await fsPromises.unlink(this.authConfig.credentials_path);
96
+ this.logger.info(`[nomos:auth:info] Credentials removed from ${this.authConfig.credentials_path}`);
97
+ } catch (err: unknown) {
98
+ if (typeof err === 'object' && err !== null && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT') {
99
+ this.logger.info('[nomos:auth:info] No credentials file found — already logged out.');
100
+ } else {
101
+ throw err;
102
+ }
103
+ }
104
+ }
105
+
106
+ async getAccessToken(): Promise<string> {
107
+ const client = await this.getAuthenticatedClient();
108
+ const tokenResponse = await client.getAccessToken();
109
+ if (!tokenResponse.token) {
110
+ throw new NomosError('auth_token_expired', 'Failed to retrieve access token from OAuth client.');
111
+ }
112
+ return tokenResponse.token;
113
+ }
114
+ }
@@ -0,0 +1,141 @@
1
+ import * as http from 'node:http';
2
+ import * as net from 'node:net';
3
+ import * as crypto from 'node:crypto';
4
+ import { URL } from 'node:url';
5
+ import open from 'open';
6
+ import { OAuth2Client } from 'google-auth-library';
7
+ import type { Logger } from 'winston';
8
+ import { NomosError } from '../errors.js';
9
+ import type { AuthCredentials } from '../../types/index.js';
10
+
11
+ export async function startLoopbackServer(
12
+ oauth2Client: OAuth2Client,
13
+ scopes: string[],
14
+ port: number,
15
+ logger: Logger,
16
+ ): Promise<AuthCredentials> {
17
+ return new Promise((resolve, reject) => {
18
+ const sockets = new Set<net.Socket>();
19
+
20
+ // CSRF state parameter — CONSTRAINT: DO NOT exchange auth codes without
21
+ // validating state parameter (ref: F-002)
22
+ const state = crypto.randomBytes(32).toString('hex');
23
+
24
+ let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
25
+
26
+ const cleanup = (): void => {
27
+ if (timeoutHandle !== null) {
28
+ clearTimeout(timeoutHandle);
29
+ timeoutHandle = null;
30
+ }
31
+ for (const socket of sockets) {
32
+ socket.destroy();
33
+ }
34
+ sockets.clear();
35
+ server.close();
36
+ };
37
+
38
+ const server = http.createServer((req, res) => {
39
+ if (!req.url) {
40
+ res.writeHead(400);
41
+ res.end('Bad Request');
42
+ return;
43
+ }
44
+
45
+ let parsedUrl: URL;
46
+ try {
47
+ parsedUrl = new URL(req.url, 'http://localhost');
48
+ } catch {
49
+ res.writeHead(400);
50
+ res.end('Bad Request');
51
+ return;
52
+ }
53
+
54
+ const receivedState = parsedUrl.searchParams.get('state');
55
+ const code = parsedUrl.searchParams.get('code');
56
+
57
+ // Validate CSRF state parameter
58
+ if (receivedState !== state) {
59
+ res.writeHead(403);
60
+ res.end('Authentication failed: state mismatch (possible CSRF attack).');
61
+ logger.warn('[nomos:auth:warn] OAuth callback rejected — state parameter mismatch.');
62
+ return;
63
+ }
64
+
65
+ if (!code) {
66
+ res.writeHead(400);
67
+ res.end('Missing authorization code.');
68
+ return;
69
+ }
70
+
71
+ oauth2Client.getToken(code)
72
+ .then(({ tokens }) => {
73
+ // CONSTRAINT: DO NOT log access_token, refresh_token, or client_secret (ref: audit)
74
+ const credentials: AuthCredentials = {
75
+ access_token: tokens.access_token ?? '',
76
+ refresh_token: tokens.refresh_token ?? '',
77
+ expiry_date: tokens.expiry_date ?? 0,
78
+ token_type: tokens.token_type ?? 'Bearer',
79
+ scope: tokens.scope ?? scopes.join(' '),
80
+ };
81
+
82
+ res.writeHead(200, {
83
+ 'Content-Type': 'text/html',
84
+ 'Connection': 'close',
85
+ });
86
+ res.end('<html><body><h1>Login successful!</h1><p>You can close this tab.</p></body></html>', () => {
87
+ cleanup();
88
+ });
89
+
90
+ resolve(credentials);
91
+ })
92
+ .catch((err: unknown) => {
93
+ res.writeHead(500);
94
+ res.end('Token exchange failed.');
95
+ cleanup();
96
+ reject(new NomosError('auth_login_failed', `OAuth token exchange failed: ${String(err)}`));
97
+ });
98
+ });
99
+
100
+ server.on('connection', (socket: net.Socket) => {
101
+ sockets.add(socket);
102
+ socket.once('close', () => sockets.delete(socket));
103
+ });
104
+
105
+ // Attempt to listen on requested port; fall back to random port on EADDRINUSE
106
+ server.listen(port, '127.0.0.1', () => {
107
+ const address = server.address() as net.AddressInfo;
108
+ const actualPort = address.port;
109
+ logger.info(`[nomos:auth:info] OAuth loopback server listening on port ${actualPort}`);
110
+
111
+ const redirectUri = `http://localhost:${actualPort}`;
112
+
113
+ const authUrl = oauth2Client.generateAuthUrl({
114
+ access_type: 'offline',
115
+ scope: scopes,
116
+ redirect_uri: redirectUri,
117
+ state, // CSRF protection per RFC 6749 Section 10.12
118
+ });
119
+
120
+ // 120-second timeout
121
+ timeoutHandle = setTimeout(() => {
122
+ cleanup();
123
+ reject(new NomosError('auth_login_failed', 'Login timed out after 120 seconds.'));
124
+ }, 120_000);
125
+
126
+ open(authUrl).catch(() => {
127
+ logger.info(`[nomos:auth:info] Open this URL in your browser: ${authUrl}`);
128
+ });
129
+ });
130
+
131
+ server.on('error', (err: NodeJS.ErrnoException) => {
132
+ if (err.code === 'EADDRINUSE') {
133
+ // Retry on a random port
134
+ server.listen(0, '127.0.0.1');
135
+ } else {
136
+ cleanup();
137
+ reject(new NomosError('auth_login_failed', `OAuth server error: ${err.message}`));
138
+ }
139
+ });
140
+ });
141
+ }