@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.
- package/README.md +34 -10
- package/package.json +3 -3
- package/src/commands/delete.ts +16 -13
- package/src/commands/get.ts +8 -5
- package/src/commands/handoff.ts +13 -3
- package/src/commands/login.ts +208 -61
- package/src/commands/logout.ts +3 -2
- package/src/commands/ls.ts +32 -10
- package/src/commands/manifest.ts +2 -2
- package/src/commands/mcp.ts +259 -7
- package/src/commands/migrate-envelopes.ts +55 -3
- package/src/commands/purge.ts +13 -10
- package/src/commands/restore.ts +10 -6
- package/src/commands/rollback.ts +12 -9
- package/src/commands/rotate-master.ts +13 -13
- package/src/commands/run.ts +6 -24
- package/src/commands/service-token.ts +15 -31
- package/src/commands/set.ts +7 -6
- package/src/commands/unset.ts +11 -8
- package/src/commands/versions.ts +7 -5
- package/src/index.ts +12 -8
- package/src/lib/auth-context.ts +77 -13
- package/src/lib/auth-proof.ts +26 -0
- package/src/lib/auto-project.ts +58 -14
- package/src/lib/client.ts +112 -19
- package/src/lib/config.ts +2 -0
- package/src/lib/device-login.ts +157 -0
- package/src/lib/env-format.ts +1 -1
- package/src/lib/envelope.ts +105 -15
- package/src/lib/identity-key.ts +21 -0
- package/src/lib/keyring.ts +25 -0
- package/src/lib/mcp-register.ts +223 -0
- package/src/lib/mcp-tools/admin.ts +267 -0
- package/src/lib/mcp-tools/args.ts +26 -0
- package/src/lib/mcp-tools/confirm.ts +21 -0
- package/src/lib/mcp-tools/defs.ts +388 -3
- package/src/lib/mcp-tools/login.ts +156 -0
- package/src/lib/mcp-tools/mask.ts +41 -0
- package/src/lib/mcp-tools/read.ts +115 -1
- package/src/lib/mcp-tools/result.ts +5 -5
- package/src/lib/mcp-tools/run.ts +101 -0
- package/src/lib/mcp-tools/write.ts +84 -5
- package/src/lib/oidc-flow.ts +43 -1
- package/src/lib/org-rewrap.ts +9 -3
- package/src/lib/service-tokens.ts +62 -0
package/src/lib/auto-project.ts
CHANGED
|
@@ -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 읽기 —
|
|
78
|
+
* cwd 의 .athsra file 에서 키 값 읽기 — `<key>=<value>` 한 줄 (단순 KEY=value). project/config 공용.
|
|
52
79
|
*/
|
|
53
|
-
function
|
|
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 ===
|
|
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(
|
|
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
|
|
117
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
576
|
-
|
|
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(
|
|
583
|
-
|
|
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 =
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
@@ -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
|
+
}
|
package/src/lib/env-format.ts
CHANGED