@athsra/cli 1.0.4 → 1.1.1

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 (45) hide show
  1. package/README.md +34 -10
  2. package/package.json +3 -3
  3. package/src/commands/delete.ts +16 -13
  4. package/src/commands/get.ts +8 -5
  5. package/src/commands/handoff.ts +13 -3
  6. package/src/commands/login.ts +208 -61
  7. package/src/commands/logout.ts +3 -2
  8. package/src/commands/ls.ts +32 -10
  9. package/src/commands/manifest.ts +2 -2
  10. package/src/commands/mcp.ts +259 -7
  11. package/src/commands/migrate-envelopes.ts +55 -3
  12. package/src/commands/purge.ts +13 -10
  13. package/src/commands/restore.ts +10 -6
  14. package/src/commands/rollback.ts +12 -9
  15. package/src/commands/rotate-master.ts +13 -13
  16. package/src/commands/run.ts +6 -24
  17. package/src/commands/service-token.ts +15 -31
  18. package/src/commands/set.ts +7 -6
  19. package/src/commands/unset.ts +11 -8
  20. package/src/commands/versions.ts +7 -5
  21. package/src/index.ts +12 -8
  22. package/src/lib/auth-context.ts +77 -13
  23. package/src/lib/auth-proof.ts +26 -0
  24. package/src/lib/auto-project.ts +58 -14
  25. package/src/lib/client.ts +112 -19
  26. package/src/lib/config.ts +2 -0
  27. package/src/lib/device-login.ts +157 -0
  28. package/src/lib/env-format.ts +1 -1
  29. package/src/lib/envelope.ts +105 -15
  30. package/src/lib/identity-key.ts +21 -0
  31. package/src/lib/keyring.ts +25 -0
  32. package/src/lib/mcp-register.ts +223 -0
  33. package/src/lib/mcp-tools/admin.ts +267 -0
  34. package/src/lib/mcp-tools/args.ts +26 -0
  35. package/src/lib/mcp-tools/confirm.ts +21 -0
  36. package/src/lib/mcp-tools/defs.ts +388 -3
  37. package/src/lib/mcp-tools/login.ts +156 -0
  38. package/src/lib/mcp-tools/mask.ts +41 -0
  39. package/src/lib/mcp-tools/read.ts +115 -1
  40. package/src/lib/mcp-tools/result.ts +5 -5
  41. package/src/lib/mcp-tools/run.ts +101 -0
  42. package/src/lib/mcp-tools/write.ts +84 -5
  43. package/src/lib/oidc-flow.ts +43 -1
  44. package/src/lib/org-rewrap.ts +9 -3
  45. package/src/lib/service-tokens.ts +62 -0
@@ -24,12 +24,28 @@ import { basename, join } from 'node:path';
24
24
 
25
25
  export interface ProjectResolution {
26
26
  project: string | undefined;
27
+ /** 환경(config) — 기본 'default'. Doppler 식 dev/staging/prod. `--config=` > `project:config` > .athsra > default. */
28
+ config: string;
27
29
  /** project 명이 args 에서 빠진 나머지 args (KEY=value pairs 또는 다른 인자) */
28
30
  rest: string[];
29
31
  /** 어디서 감지했는가 — 디버깅 / 사용자 안내용 */
30
32
  source: 'flag' | 'positional' | 'athsra-file' | 'package-json' | 'cwd' | 'none';
31
33
  }
32
34
 
35
+ /** 사용자 출력용 config 라벨 — default 는 표시 안 함, 그 외는 ` [<config>]` (명령 결과·에러 공용). */
36
+ export function configTag(config: string): string {
37
+ return config === 'default' ? '' : ` [${config}]`;
38
+ }
39
+
40
+ /** 사용자가 그대로 입력 가능한 project 참조 — default 는 project, 그 외는 project:config (명령 안내문 공용). */
41
+ export function projectRef(project: string, config: string): string {
42
+ return config === 'default' ? project : `${project}:${config}`;
43
+ }
44
+
45
+ /** 모든 secret 명령 USAGE 하단에 붙는 통일 환경 안내 문구. */
46
+ export const CONFIG_USAGE_HINT =
47
+ '환경(config): [--config=<env>] 또는 <project>:<env> 또는 .athsra 의 config= 줄 (기본 default)';
48
+
33
49
  /**
34
50
  * args 에서 --project=<x> flag 추출 + 나머지 args 반환.
35
51
  */
@@ -47,10 +63,21 @@ function extractProjectFlag(args: string[]): { project?: string; rest: string[]
47
63
  return project ? { project, rest } : { rest };
48
64
  }
49
65
 
66
+ /** args 에서 --config=<x> flag 추출 + 나머지. */
67
+ function extractConfigFlag(args: string[]): { config?: string; rest: string[] } {
68
+ const rest: string[] = [];
69
+ let config: string | undefined;
70
+ for (const arg of args) {
71
+ if (arg.startsWith('--config=')) config = arg.slice('--config='.length);
72
+ else rest.push(arg);
73
+ }
74
+ return config !== undefined ? { config, rest } : { rest };
75
+ }
76
+
50
77
  /**
51
- * cwd 의 .athsra file 읽기 — `project=<name>` 한 줄 (다른 키 무시, 단순 KEY=value).
78
+ * cwd 의 .athsra file 에서 키 값 읽기 — `<key>=<value>` 한 줄 (단순 KEY=value). project/config 공용.
52
79
  */
53
- function readAthsraFile(cwd: string): string | undefined {
80
+ function readAthsraKey(cwd: string, wantKey: string): string | undefined {
54
81
  const path = join(cwd, '.athsra');
55
82
  if (!existsSync(path)) return undefined;
56
83
  try {
@@ -62,7 +89,7 @@ function readAthsraFile(cwd: string): string | undefined {
62
89
  if (eq < 0) continue;
63
90
  const key = trimmed.slice(0, eq).trim();
64
91
  const value = trimmed.slice(eq + 1).trim();
65
- if (key === 'project' && value) return value;
92
+ if (key === wantKey && value) return value;
66
93
  }
67
94
  } catch {
68
95
  /* ignore */
@@ -95,37 +122,54 @@ export function resolveProject(
95
122
  args: string[],
96
123
  opts?: { cwd?: string; requirePositional?: boolean },
97
124
  ): ProjectResolution {
125
+ const cwd = opts?.cwd ?? process.cwd();
126
+ // 0. --config=<x> flag 추출 (project 해석 전에 args 에서 제거)
127
+ const cfgFlag = extractConfigFlag(args);
128
+ let config = cfgFlag.config;
129
+
130
+ // config 통합 — project:config syntax(flag 없을 때) > .athsra config > default
131
+ const withConfig = (r: Omit<ProjectResolution, 'config'>): ProjectResolution => {
132
+ let project = r.project;
133
+ if (project?.includes(':')) {
134
+ const idx = project.indexOf(':');
135
+ const c = project.slice(idx + 1);
136
+ project = project.slice(0, idx);
137
+ if (config === undefined && c) config = c;
138
+ }
139
+ if (config === undefined) config = readAthsraKey(cwd, 'config');
140
+ return { ...r, project, config: config ?? 'default' };
141
+ };
142
+
98
143
  // 1. --project=<x> flag 우선
99
- const flagResult = extractProjectFlag(args);
144
+ const flagResult = extractProjectFlag(cfgFlag.rest);
100
145
  if (flagResult.project) {
101
- return { project: flagResult.project, rest: flagResult.rest, source: 'flag' };
146
+ return withConfig({ project: flagResult.project, rest: flagResult.rest, source: 'flag' });
102
147
  }
103
148
 
104
149
  const remaining = flagResult.rest;
105
150
 
106
- // 2. positional <project> arg — 첫 자리 + `=` 없으면 project 로 인식
151
+ // 2. positional <project> arg — 첫 자리 + `=` 없으면 project 로 인식 (project:config 포함)
107
152
  if (remaining.length > 0 && remaining[0] !== undefined && !remaining[0].includes('=')) {
108
- return { project: remaining[0], rest: remaining.slice(1), source: 'positional' };
153
+ return withConfig({ project: remaining[0], rest: remaining.slice(1), source: 'positional' });
109
154
  }
110
155
 
111
156
  if (opts?.requirePositional) {
112
- return { project: undefined, rest: remaining, source: 'none' };
157
+ return withConfig({ project: undefined, rest: remaining, source: 'none' });
113
158
  }
114
159
 
115
160
  // 3. .athsra file (auto-detect)
116
- const cwd = opts?.cwd ?? process.cwd();
117
- const fromFile = readAthsraFile(cwd);
118
- if (fromFile) return { project: fromFile, rest: remaining, source: 'athsra-file' };
161
+ const fromFile = readAthsraKey(cwd, 'project');
162
+ if (fromFile) return withConfig({ project: fromFile, rest: remaining, source: 'athsra-file' });
119
163
 
120
164
  // 4. package.json athsra.project
121
165
  const fromPkg = readPackageJsonProject(cwd);
122
- if (fromPkg) return { project: fromPkg, rest: remaining, source: 'package-json' };
166
+ if (fromPkg) return withConfig({ project: fromPkg, rest: remaining, source: 'package-json' });
123
167
 
124
168
  // 5. basename(cwd) — 23 sibling 표준 = repo 명 = project 명
125
169
  const base = basename(cwd);
126
170
  if (base && base !== '/' && base !== '~' && base !== '.') {
127
- return { project: base, rest: remaining, source: 'cwd' };
171
+ return withConfig({ project: base, rest: remaining, source: 'cwd' });
128
172
  }
129
173
 
130
- return { project: undefined, rest: remaining, source: 'none' };
174
+ return withConfig({ project: undefined, rest: remaining, source: 'none' });
131
175
  }
package/src/lib/client.ts CHANGED
@@ -17,6 +17,11 @@ export interface RegisterResponse {
17
17
  token: string;
18
18
  machineId: string;
19
19
  createdAt: string;
20
+ userId?: number;
21
+ /** 첫 proof 설정(검증 불가) — typo-guard 대상. */
22
+ bootstrap?: boolean;
23
+ /** 옛 스킴 proof → 현 스킴 자동 마이그레이션됨 (버전 업그레이드 무중단). */
24
+ migrated?: boolean;
20
25
  }
21
26
 
22
27
  /** Phase 4 Slice 3 — auth_user_keys row (GET/POST /auth/keys). worker 는 base64 형식만 저장. */
@@ -60,19 +65,31 @@ export interface DeviceCodeResponse {
60
65
  verification_uri_complete: string;
61
66
  expires_in: number;
62
67
  interval: number;
63
- project: string;
64
- perms: 'read' | 'write';
68
+ /** Phase 5 — kind:'user' 면 project/perms 는 null (identity 디바이스는 envelope-scope 없음). */
69
+ kind: 'service' | 'user';
70
+ project: string | null;
71
+ perms: 'read' | 'write' | null;
65
72
  }
66
73
 
67
- /** Phase 3 P3 — POST /auth/device/token (poll) 결과. */
74
+ /** Phase 3 P3 / Phase 5 — POST /auth/device/token (poll) 결과 (service | user kind). */
68
75
  export type DevicePollResult =
69
76
  | {
70
77
  status: 'token';
78
+ tokenType: 'service';
71
79
  token: string;
72
80
  recipientId: string;
73
81
  project: string;
74
82
  perms: 'read' | 'write';
75
83
  }
84
+ | {
85
+ status: 'token';
86
+ tokenType: 'user';
87
+ token: string;
88
+ /** SealedBox JSON — 디바이스 privkey 로 unseal → identity privkey. */
89
+ sealedIdentityKey: string;
90
+ userId: number;
91
+ keyVersion: number;
92
+ }
76
93
  | { status: 'pending'; error: string; interval?: number };
77
94
 
78
95
  /**
@@ -222,11 +239,23 @@ export class AthsraClient {
222
239
  return (await res.json()) as WorkerInfo;
223
240
  }
224
241
 
225
- async register(masterPwProof: string, label: string): Promise<RegisterResponse> {
242
+ /**
243
+ * @param legacyProofs - 옛 스킴 proof 후보 (버전 업그레이드 무중단 마이그레이션). 현 proof 가
244
+ * mismatch 일 때 worker 가 이 중 하나로 검증 성공 시 저장 proof 를 현 스킴으로 자동 재작성.
245
+ */
246
+ async register(
247
+ masterPwProof: string,
248
+ label: string,
249
+ legacyProofs?: string[],
250
+ ): Promise<RegisterResponse> {
226
251
  const res = await fetch(this.url('/auth/register'), {
227
252
  method: 'POST',
228
253
  headers: { 'content-type': 'application/json' },
229
- body: JSON.stringify({ master_pw_proof: masterPwProof, label }),
254
+ body: JSON.stringify({
255
+ master_pw_proof: masterPwProof,
256
+ label,
257
+ ...(legacyProofs && legacyProofs.length > 0 ? { legacy_proofs: legacyProofs } : {}),
258
+ }),
230
259
  });
231
260
  if (!res.ok) throw new Error(`register ${res.status}: ${await res.text()}`);
232
261
  return (await res.json()) as RegisterResponse;
@@ -240,7 +269,7 @@ export class AthsraClient {
240
269
 
241
270
  /**
242
271
  * Phase 3a — 자기 master pw proof bootstrap-or-verify (POST /auth/proof, Bearer).
243
- * proof = Argon2id(masterPw + GLOBAL_SALT) 단방향 해시 (평문 master pw 송신 X).
272
+ * proof = deriveProof(masterPw, userId, GLOBAL_SALT) 단방향 해시 (평문 master pw 송신 X).
244
273
  * 첫 호출 = bootstrap, 이후 = verify (불일치 시 worker 409 → throw).
245
274
  */
246
275
  async setProof(
@@ -485,14 +514,21 @@ export class AthsraClient {
485
514
 
486
515
  /** Phase 3 P3 — device-login 시작 (unauth). device_code + user_code + verification_uri. */
487
516
  async deviceCode(args: {
488
- project: string;
517
+ kind?: 'service' | 'user';
518
+ project?: string;
489
519
  perms?: 'read' | 'write';
490
520
  label: string;
521
+ /** kind:'user' — base64 32B 디바이스 X25519 public key. */
522
+ devicePublicKey?: string;
491
523
  }): Promise<DeviceCodeResponse> {
524
+ const body: Record<string, unknown> =
525
+ args.kind === 'user'
526
+ ? { kind: 'user', device_pub_key: args.devicePublicKey, label: args.label }
527
+ : { project: args.project, perms: args.perms, label: args.label };
492
528
  const res = await fetch(this.url('/auth/device/code'), {
493
529
  method: 'POST',
494
530
  headers: { 'content-type': 'application/json' },
495
- body: JSON.stringify({ project: args.project, perms: args.perms, label: args.label }),
531
+ body: JSON.stringify(body),
496
532
  });
497
533
  if (!res.ok) throw new Error(`device code ${res.status}: ${await res.text()}`);
498
534
  return (await res.json()) as DeviceCodeResponse;
@@ -507,8 +543,20 @@ export class AthsraClient {
507
543
  });
508
544
  const body = (await res.json()) as Record<string, unknown>;
509
545
  if (res.ok) {
546
+ if (body.token_type === 'user') {
547
+ return {
548
+ status: 'token',
549
+ tokenType: 'user',
550
+ token: typeof body.token === 'string' ? body.token : '',
551
+ sealedIdentityKey:
552
+ typeof body.sealed_identity_key === 'string' ? body.sealed_identity_key : '',
553
+ userId: typeof body.user_id === 'number' ? body.user_id : 0,
554
+ keyVersion: typeof body.key_version === 'number' ? body.key_version : 1,
555
+ };
556
+ }
510
557
  return {
511
558
  status: 'token',
559
+ tokenType: 'service',
512
560
  token: typeof body.token === 'string' ? body.token : '',
513
561
  recipientId: typeof body.recipient_id === 'string' ? body.recipient_id : '',
514
562
  project: typeof body.project === 'string' ? body.project : '',
@@ -572,15 +620,33 @@ export class AthsraClient {
572
620
  };
573
621
  }
574
622
 
575
- async getEnvelope(project: string): Promise<SecretEnvelopeAny | null> {
576
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}`);
623
+ /** secret route URL — config!=='default' 시에만 ?config= 추가 (default 생략, 하위호환·무중단). */
624
+ private secretPath(
625
+ project: string,
626
+ config: string,
627
+ sub = '',
628
+ extra?: Record<string, string>,
629
+ ): string {
630
+ const params = new URLSearchParams();
631
+ if (config && config !== 'default') params.set('config', config);
632
+ if (extra) for (const [k, v] of Object.entries(extra)) params.set(k, v);
633
+ const q = params.toString();
634
+ return `/v1/secrets/${encodeURIComponent(project)}${sub}${q ? `?${q}` : ''}`;
635
+ }
636
+
637
+ async getEnvelope(project: string, config = 'default'): Promise<SecretEnvelopeAny | null> {
638
+ const res = await this.authedFetch(this.secretPath(project, config));
577
639
  if (res.status === 404) return null;
578
640
  if (!res.ok) throw new Error(`fetch ${res.status}: ${await res.text()}`);
579
641
  return (await res.json()) as SecretEnvelopeAny;
580
642
  }
581
643
 
582
- async putEnvelope(project: string, envelope: SecretEnvelopeAny): Promise<void> {
583
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}`, {
644
+ async putEnvelope(
645
+ project: string,
646
+ envelope: SecretEnvelopeAny,
647
+ config = 'default',
648
+ ): Promise<void> {
649
+ const res = await this.authedFetch(this.secretPath(project, config), {
584
650
  method: 'PUT',
585
651
  headers: { 'content-type': 'application/json' },
586
652
  body: JSON.stringify(envelope),
@@ -597,14 +663,19 @@ export class AthsraClient {
597
663
 
598
664
  async deleteProject(
599
665
  project: string,
600
- opts?: { hard?: boolean },
666
+ opts?: { hard?: boolean; config?: string },
601
667
  ): Promise<{
602
668
  soft?: boolean;
603
669
  hard?: boolean;
604
670
  recoverable_versions?: number;
605
671
  removed_versions?: number;
606
672
  }> {
607
- const path = `/v1/secrets/${encodeURIComponent(project)}${opts?.hard ? '?hard=true' : ''}`;
673
+ const path = this.secretPath(
674
+ project,
675
+ opts?.config ?? 'default',
676
+ '',
677
+ opts?.hard ? { hard: 'true' } : undefined,
678
+ );
608
679
  const res = await this.authedFetch(path, { method: 'DELETE' });
609
680
  if (!res.ok) throw new Error(`delete ${res.status}: ${await res.text()}`);
610
681
  return (await res.json()) as {
@@ -615,14 +686,17 @@ export class AthsraClient {
615
686
  };
616
687
  }
617
688
 
618
- async listVersions(project: string): Promise<{
689
+ async listVersions(
690
+ project: string,
691
+ config = 'default',
692
+ ): Promise<{
619
693
  project: string;
620
694
  current_version: string | null;
621
695
  tombstone: { deleted_at: string; deleted_by: string } | null;
622
696
  versions: { version_id: string; updated_at: string; size: number }[];
623
697
  count: number;
624
698
  }> {
625
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/versions`);
699
+ const res = await this.authedFetch(this.secretPath(project, config, '/versions'));
626
700
  if (!res.ok) throw new Error(`versions ${res.status}: ${await res.text()}`);
627
701
  return (await res.json()) as {
628
702
  project: string;
@@ -636,8 +710,9 @@ export class AthsraClient {
636
710
  async rollbackProject(
637
711
  project: string,
638
712
  versionId: string,
713
+ config = 'default',
639
714
  ): Promise<{ ok: boolean; project: string; current_version: string }> {
640
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/rollback`, {
715
+ const res = await this.authedFetch(this.secretPath(project, config, '/rollback'), {
641
716
  method: 'POST',
642
717
  headers: { 'content-type': 'application/json' },
643
718
  body: JSON.stringify({ version_id: versionId }),
@@ -646,14 +721,17 @@ export class AthsraClient {
646
721
  return (await res.json()) as { ok: boolean; project: string; current_version: string };
647
722
  }
648
723
 
649
- async restoreProject(project: string): Promise<{
724
+ async restoreProject(
725
+ project: string,
726
+ config = 'default',
727
+ ): Promise<{
650
728
  ok: boolean;
651
729
  project: string;
652
730
  restored_version: string;
653
731
  deleted_at: string;
654
732
  deleted_by: string;
655
733
  }> {
656
- const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/restore`, {
734
+ const res = await this.authedFetch(this.secretPath(project, config, '/restore'), {
657
735
  method: 'POST',
658
736
  });
659
737
  if (!res.ok) throw new Error(`restore ${res.status}: ${await res.text()}`);
@@ -666,6 +744,21 @@ export class AthsraClient {
666
744
  };
667
745
  }
668
746
 
747
+ /** GET /:project/configs — 환경(config) 목록 + active/deleted. */
748
+ async listConfigs(project: string): Promise<{
749
+ project: string;
750
+ configs: { config: string; active: boolean; deleted: boolean }[];
751
+ count: number;
752
+ }> {
753
+ const res = await this.authedFetch(this.secretPath(project, 'default', '/configs'));
754
+ if (!res.ok) throw new Error(`configs ${res.status}: ${await res.text()}`);
755
+ return (await res.json()) as {
756
+ project: string;
757
+ configs: { config: string; active: boolean; deleted: boolean }[];
758
+ count: number;
759
+ };
760
+ }
761
+
669
762
  async listProjectsExtended(opts?: {
670
763
  includeDeleted?: boolean;
671
764
  }): Promise<
package/src/lib/config.ts CHANGED
@@ -16,6 +16,8 @@ export interface Config {
16
16
  */
17
17
  orgId?: number;
18
18
  orgSlug?: string;
19
+ /** Phase 5 — identity 디바이스 모드 로그인 시 기록 (member 경로 복호의 per-user salt·recipient 매칭). */
20
+ userId?: number;
19
21
  }
20
22
 
21
23
  export function ensureConfigDir(): void {
@@ -0,0 +1,157 @@
1
+ /**
2
+ * device-login.ts — Phase 5 B5. RFC 8628 device flow 의 공용 코어.
3
+ *
4
+ * `commands/login.ts` 의 identityLoginCmd/deviceLoginCmd poll 루프에서 추출 — CLI(블로킹 루프)와
5
+ * MCP in-chat 로그인(athsra_login_start/status 의 단발 step)이 같은 기계 부분을 소비한다.
6
+ *
7
+ * 보안 불변식: `IdentityFlow.deviceCode` 는 **프로세스 메모리 전용** — MCP 응답·로그·콘솔 어디에도
8
+ * 출력 금지 (탈취 시 토큰 가로채기 가능). user 에게 보여줄 것은 user_code/URL/fingerprint 뿐.
9
+ */
10
+ import { hostname } from 'node:os';
11
+ import { toBase64 } from '@athsra/crypto';
12
+ import { deviceKeyFingerprint, generateDeviceKeypair } from '@athsra/crypto/device';
13
+ import { AthsraClient, type DevicePollResult } from './client.ts';
14
+ import { loadConfig, saveConfig } from './config.ts';
15
+ import { unsealIdentityKey } from './identity-key.ts';
16
+ import { probeKeyring, setIdentityKey, setToken } from './keyring.ts';
17
+
18
+ /** 비-인터랙티브 agent 기본 worker (config/env 없을 때). production athsra worker. */
19
+ export const DEFAULT_WORKER_URL = 'https://athsra-worker.winterermod.workers.dev';
20
+
21
+ export type UserTokenResult = Extract<DevicePollResult, { status: 'token'; tokenType: 'user' }>;
22
+
23
+ export interface IdentityFlow {
24
+ client: AthsraClient;
25
+ workerUrl: string;
26
+ machineId: string;
27
+ existingCreatedAt?: string;
28
+ /** 디바이스 X25519 privkey — sealed identity key unseal 용 (이 머신에만). */
29
+ devicePrivateKey: Uint8Array;
30
+ /** 디바이스 pubkey 지문 — 브라우저 화면과 대조 (phishing 가드). 공개 정보. */
31
+ fingerprint: string;
32
+ /** RFC 8628 device_code — 메모리 전용. 어떤 응답에도 미포함. */
33
+ deviceCode: string;
34
+ userCode: string;
35
+ verificationUriComplete: string;
36
+ expiresAt: number; // epoch ms
37
+ intervalMs: number;
38
+ }
39
+
40
+ /**
41
+ * identity device flow 시작: keyring probe → config/worker 해석 → 디바이스 키쌍 생성 →
42
+ * deviceCode(kind:user). 실패는 actionable Error throw (호출자가 ✗/MCP error 로 환원).
43
+ */
44
+ export async function startIdentityFlow(): Promise<IdentityFlow> {
45
+ const probe = probeKeyring();
46
+ if (!probe.ok) {
47
+ throw new Error(
48
+ `keyring backend unavailable: ${probe.error ?? 'unknown'} — run \`athsra doctor\` for setup instructions (apt install + dbus).`,
49
+ );
50
+ }
51
+ const existing = loadConfig();
52
+ const workerUrl = process.env.ATHSRA_WORKER_URL ?? existing?.workerUrl ?? DEFAULT_WORKER_URL;
53
+ const machineId = existing?.machineId ?? `${hostname()}-${Date.now().toString(36)}`;
54
+
55
+ const client = new AthsraClient(workerUrl);
56
+ if (!(await client.health())) {
57
+ throw new Error(`worker unreachable: ${workerUrl}`);
58
+ }
59
+
60
+ const deviceKp = generateDeviceKeypair();
61
+ const fingerprint = deviceKeyFingerprint(deviceKp.publicKey);
62
+ const dc = await client.deviceCode({
63
+ kind: 'user',
64
+ devicePublicKey: toBase64(deviceKp.publicKey),
65
+ label: machineId,
66
+ });
67
+
68
+ return {
69
+ client,
70
+ workerUrl,
71
+ machineId,
72
+ existingCreatedAt: existing?.createdAt,
73
+ devicePrivateKey: deviceKp.privateKey,
74
+ fingerprint,
75
+ deviceCode: dc.device_code,
76
+ userCode: dc.user_code,
77
+ verificationUriComplete: dc.verification_uri_complete,
78
+ expiresAt: Date.now() + dc.expires_in * 1000,
79
+ intervalMs: Math.max(1, dc.interval) * 1000,
80
+ };
81
+ }
82
+
83
+ export type PollStep =
84
+ | { step: 'token'; result: Extract<DevicePollResult, { status: 'token' }> }
85
+ | { step: 'pending' }
86
+ | { step: 'slow_down' }
87
+ | { step: 'denied' }
88
+ | { step: 'expired' }
89
+ /** 네트워크/일시 오류 — pending 취급 (다음 tick 재시도). */
90
+ | { step: 'network' };
91
+
92
+ /** poll 1회 — MCP login_status 의 단발 step. 블로킹 루프는 runDevicePollLoop. */
93
+ export async function pollDeviceTokenOnce(
94
+ client: AthsraClient,
95
+ deviceCode: string,
96
+ ): Promise<PollStep> {
97
+ let result: DevicePollResult;
98
+ try {
99
+ result = await client.devicePollToken(deviceCode);
100
+ } catch {
101
+ return { step: 'network' };
102
+ }
103
+ if (result.status === 'token') return { step: 'token', result };
104
+ if (result.error === 'access_denied') return { step: 'denied' };
105
+ if (result.error === 'expired_token') return { step: 'expired' };
106
+ if (result.error === 'slow_down') return { step: 'slow_down' };
107
+ return { step: 'pending' };
108
+ }
109
+
110
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
111
+
112
+ /**
113
+ * 블로킹 poll 루프 (CLI) — token/denied/expired 또는 deadline 까지 반복.
114
+ * slow_down 시 interval +5s (RFC 8628). onTick 은 진행 표시('.') 용.
115
+ */
116
+ export async function runDevicePollLoop(
117
+ client: AthsraClient,
118
+ deviceCode: string,
119
+ opts: { intervalMs: number; expiresAt: number; onTick?: () => void },
120
+ ): Promise<Extract<PollStep, { step: 'token' | 'denied' | 'expired' }> | { step: 'timeout' }> {
121
+ let interval = opts.intervalMs;
122
+ while (Date.now() < opts.expiresAt) {
123
+ await sleep(interval);
124
+ const r = await pollDeviceTokenOnce(client, deviceCode);
125
+ if (r.step === 'token' || r.step === 'denied' || r.step === 'expired') return r;
126
+ if (r.step === 'slow_down') interval += 5000;
127
+ opts.onTick?.();
128
+ }
129
+ return { step: 'timeout' };
130
+ }
131
+
132
+ /**
133
+ * identity 승인 완료 처리: sealed unseal(user_id 대조 — 키 혼선 차단) → keyring
134
+ * identity-priv + token → config.userId. master pw 는 이 기기에 절대 없음 (D-2).
135
+ */
136
+ export async function completeIdentityLogin(args: {
137
+ result: UserTokenResult;
138
+ devicePrivateKey: Uint8Array;
139
+ machineId: string;
140
+ workerUrl: string;
141
+ existingCreatedAt?: string;
142
+ }): Promise<{ userId: number }> {
143
+ const identityPrivB64 = await unsealIdentityKey(
144
+ args.result.sealedIdentityKey,
145
+ args.devicePrivateKey,
146
+ args.result.userId,
147
+ );
148
+ setIdentityKey(args.machineId, identityPrivB64);
149
+ setToken(args.machineId, args.result.token);
150
+ saveConfig({
151
+ workerUrl: args.workerUrl,
152
+ machineId: args.machineId,
153
+ createdAt: args.existingCreatedAt ?? new Date().toISOString(),
154
+ userId: args.result.userId,
155
+ });
156
+ return { userId: args.result.userId };
157
+ }
@@ -5,4 +5,4 @@
5
5
  * crypto 패키지가 정본. 이 파일은 CLI 내부 사용처 (get/ls/doctor/run/set/envelope) 의
6
6
  * 기존 import 경로 호환을 위한 re-export.
7
7
  */
8
- export { parseEnv, partitionEnv, serializeEnv } from '@athsra/crypto';
8
+ export { buildChildEnv, parseEnv, partitionEnv, serializeEnv } from '@athsra/crypto';