@athsra/cli 1.0.1 → 1.0.3
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/package.json +1 -2
- package/src/commands/admin.ts +11 -33
- package/src/commands/adopt.ts +19 -1
- package/src/commands/dr.ts +217 -0
- package/src/commands/login.ts +223 -179
- package/src/commands/manifest.ts +66 -9
- package/src/commands/mcp.ts +69 -485
- package/src/commands/new-phrase.ts +1 -1
- package/src/commands/org.ts +363 -0
- package/src/commands/rotate-master.ts +26 -7
- package/src/commands/run.ts +2 -1
- package/src/commands/service-token.ts +17 -6
- package/src/index.ts +7 -0
- package/src/lib/adopt-context.ts +1 -43
- package/src/lib/auth-context.ts +77 -18
- package/src/lib/client.ts +396 -31
- package/src/lib/colors.ts +17 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/env-format.ts +5 -53
- package/src/lib/envelope.ts +89 -3
- package/src/lib/identity-key.ts +59 -0
- package/src/lib/jsonc.ts +48 -0
- package/src/lib/keyring.ts +26 -0
- package/src/lib/mcp-tools/args.ts +60 -0
- package/src/lib/mcp-tools/defs.ts +179 -0
- package/src/lib/mcp-tools/read.ts +156 -0
- package/src/lib/mcp-tools/result.ts +46 -0
- package/src/lib/mcp-tools/write.ts +127 -0
- package/src/lib/oidc-flow.ts +200 -0
- package/src/lib/org-rewrap.ts +230 -0
- package/src/lib/secrets-manifest.ts +1 -4
- package/src/lib/wrangler-scan.ts +218 -0
- package/src/lib/bip39.ts +0 -45
package/src/lib/client.ts
CHANGED
|
@@ -19,6 +19,23 @@ export interface RegisterResponse {
|
|
|
19
19
|
createdAt: string;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** Phase 4 Slice 3 — auth_user_keys row (GET/POST /auth/keys). worker 는 base64 형식만 저장. */
|
|
23
|
+
export interface IdentityKeyRow {
|
|
24
|
+
public_key: string;
|
|
25
|
+
wrapped_private_key: string;
|
|
26
|
+
key_salt: string;
|
|
27
|
+
wrap_nonce: string;
|
|
28
|
+
kdf_params: { m: number; t: number; p: number };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Phase 4 Slice 3 — rotate-master 의 privkey 재포장 재료 (pubkey 불변이므로 미포함). */
|
|
32
|
+
export interface IdentityKeyRewrap {
|
|
33
|
+
wrapped_private_key: string;
|
|
34
|
+
key_salt: string;
|
|
35
|
+
wrap_nonce: string;
|
|
36
|
+
kdf_params: { m: number; t: number; p: number };
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
export interface WhoamiResponse {
|
|
23
40
|
machineId: string;
|
|
24
41
|
label: string;
|
|
@@ -29,23 +46,131 @@ export interface WhoamiResponse {
|
|
|
29
46
|
scopeProject?: string;
|
|
30
47
|
scopePerms?: 'read' | 'write';
|
|
31
48
|
recipientId?: string;
|
|
49
|
+
/** Phase 2.2 — user token 의 RBAC user FK. */
|
|
50
|
+
userId?: number;
|
|
51
|
+
/** Phase 4 Slice 2 — token 이 행위하는 org. */
|
|
52
|
+
orgId?: number;
|
|
32
53
|
}
|
|
33
54
|
|
|
55
|
+
/** Phase 3 P3 — POST /auth/device/code 응답. */
|
|
56
|
+
export interface DeviceCodeResponse {
|
|
57
|
+
device_code: string;
|
|
58
|
+
user_code: string;
|
|
59
|
+
verification_uri: string;
|
|
60
|
+
verification_uri_complete: string;
|
|
61
|
+
expires_in: number;
|
|
62
|
+
interval: number;
|
|
63
|
+
project: string;
|
|
64
|
+
perms: 'read' | 'write';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Phase 3 P3 — POST /auth/device/token (poll) 결과. */
|
|
68
|
+
export type DevicePollResult =
|
|
69
|
+
| {
|
|
70
|
+
status: 'token';
|
|
71
|
+
token: string;
|
|
72
|
+
recipientId: string;
|
|
73
|
+
project: string;
|
|
74
|
+
perms: 'read' | 'write';
|
|
75
|
+
}
|
|
76
|
+
| { status: 'pending'; error: string; interval?: number };
|
|
77
|
+
|
|
34
78
|
/**
|
|
35
79
|
* Phase 1+: 모든 /v1/* + /auth/whoami + /auth/revoke 호출에
|
|
36
80
|
* Authorization: Bearer <token> 자동 첨부.
|
|
37
81
|
* /healthz, /, /auth/register 는 unauth public.
|
|
38
82
|
*/
|
|
83
|
+
export type RestoreR2ScopeArg =
|
|
84
|
+
| { kind: 'full' }
|
|
85
|
+
| { kind: 'project'; owner: string; project: string }
|
|
86
|
+
| { kind: 'key'; key: string };
|
|
87
|
+
|
|
88
|
+
export interface R2RestoreResult {
|
|
89
|
+
ok?: boolean;
|
|
90
|
+
mode: 'dry-run' | 'execute';
|
|
91
|
+
date: string;
|
|
92
|
+
would_restore?: number;
|
|
93
|
+
sample_keys?: string[];
|
|
94
|
+
confirm_token?: string;
|
|
95
|
+
scanned?: number;
|
|
96
|
+
restored?: number;
|
|
97
|
+
skipped?: number;
|
|
98
|
+
errors?: number;
|
|
99
|
+
failures?: { backup_key: string; store_key: string; error: string }[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface D1RestoreResult {
|
|
103
|
+
ok?: boolean;
|
|
104
|
+
mode: 'dry-run' | 'execute';
|
|
105
|
+
dump_key: string;
|
|
106
|
+
policy: string;
|
|
107
|
+
tables: Record<
|
|
108
|
+
string,
|
|
109
|
+
{
|
|
110
|
+
dump_rows?: number;
|
|
111
|
+
current_rows?: number;
|
|
112
|
+
written?: number;
|
|
113
|
+
filtered?: number;
|
|
114
|
+
error?: string;
|
|
115
|
+
}
|
|
116
|
+
>;
|
|
117
|
+
confirm_token?: string;
|
|
118
|
+
security_warning?: string;
|
|
119
|
+
errors?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Phase 4 Slice 6a — GET /v1/orgs 의 org 항목. */
|
|
123
|
+
export interface OrgSummary {
|
|
124
|
+
id: number;
|
|
125
|
+
slug: string;
|
|
126
|
+
name: string;
|
|
127
|
+
type: string;
|
|
128
|
+
role: 'owner' | 'admin' | 'member';
|
|
129
|
+
status: 'active' | 'pending';
|
|
130
|
+
is_current: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Phase 4 Slice 6a — GET /v1/orgs/current/members 의 멤버 항목. */
|
|
134
|
+
export interface OrgMember {
|
|
135
|
+
user_id: number;
|
|
136
|
+
identifier: string;
|
|
137
|
+
role: 'owner' | 'admin' | 'member';
|
|
138
|
+
status: 'active' | 'pending';
|
|
139
|
+
joined_at: string | null;
|
|
140
|
+
/** Phase 4 Slice 6b — keypair 보유 여부 (re-wrap 가능 시점 게이팅). */
|
|
141
|
+
has_identity_key?: boolean;
|
|
142
|
+
}
|
|
143
|
+
|
|
39
144
|
export class AthsraClient {
|
|
145
|
+
private token: string | null;
|
|
146
|
+
/**
|
|
147
|
+
* Phase 3 — idle timeout 자동 재인증 콜백 (auth-context 가 주입).
|
|
148
|
+
* 401 session_idle_timeout 시 keyring master pw 로 silent re-register → 새 token.
|
|
149
|
+
* 기기 잠금 (OS keyring 접근 차단) 이 진짜 방어선 — 작업 중엔 끊김 없음.
|
|
150
|
+
*/
|
|
151
|
+
private onIdleRefresh?: () => Promise<string | null>;
|
|
152
|
+
|
|
40
153
|
constructor(
|
|
41
154
|
private readonly _workerUrl: string,
|
|
42
|
-
|
|
43
|
-
) {
|
|
155
|
+
token: string | null = null,
|
|
156
|
+
) {
|
|
157
|
+
this.token = token;
|
|
158
|
+
}
|
|
44
159
|
|
|
45
160
|
get workerUrl(): string {
|
|
46
161
|
return this._workerUrl;
|
|
47
162
|
}
|
|
48
163
|
|
|
164
|
+
/** auth-context 가 idle 자동 재인증 콜백 주입. */
|
|
165
|
+
setIdleRefresh(fn: () => Promise<string | null>): void {
|
|
166
|
+
this.onIdleRefresh = fn;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Phase 4 Slice 6a — in-memory token 교체 (org switch / re-register 후). */
|
|
170
|
+
setToken(token: string): void {
|
|
171
|
+
this.token = token;
|
|
172
|
+
}
|
|
173
|
+
|
|
49
174
|
private url(path: string): string {
|
|
50
175
|
return `${this._workerUrl.replace(/\/$/, '')}${path}`;
|
|
51
176
|
}
|
|
@@ -56,6 +181,32 @@ export class AthsraClient {
|
|
|
56
181
|
return h;
|
|
57
182
|
}
|
|
58
183
|
|
|
184
|
+
/**
|
|
185
|
+
* 인증 fetch + 자동 재인증. 401 (idle timeout / token revoke / unknown) 시 onIdleRefresh
|
|
186
|
+
* (keyring master pw 재인증) 호출 → token 갱신 → 1회 retry. 그 외 응답은 그대로 반환.
|
|
187
|
+
*
|
|
188
|
+
* keyring master pw 보유 = 재발급 권한 (athsra 인증 모델). idle 로 D1 에서 revoke 된
|
|
189
|
+
* token (이후 unknown) 도 살린다 — session_idle_timeout 만 잡으면 revoke 후 못 살림.
|
|
190
|
+
* pw 틀림/keyring 부재 시 register 가 실패 → onIdleRefresh 가 null → 원 401 그대로.
|
|
191
|
+
* retry 는 1회 (재발급 후에도 401 이면 그 응답 반환 — 무한 루프 방지).
|
|
192
|
+
*/
|
|
193
|
+
private async authedFetch(path: string, init: RequestInit = {}): Promise<Response> {
|
|
194
|
+
const doFetch = (): Promise<Response> =>
|
|
195
|
+
fetch(this.url(path), {
|
|
196
|
+
...init,
|
|
197
|
+
headers: this.headers(init.headers as Record<string, string> | undefined),
|
|
198
|
+
});
|
|
199
|
+
const res = await doFetch();
|
|
200
|
+
if (res.status === 401 && this.onIdleRefresh) {
|
|
201
|
+
const newToken = await this.onIdleRefresh();
|
|
202
|
+
if (newToken) {
|
|
203
|
+
this.token = newToken;
|
|
204
|
+
return doFetch();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return res;
|
|
208
|
+
}
|
|
209
|
+
|
|
59
210
|
async health(): Promise<boolean> {
|
|
60
211
|
try {
|
|
61
212
|
const res = await fetch(this.url('/healthz'));
|
|
@@ -82,11 +233,139 @@ export class AthsraClient {
|
|
|
82
233
|
}
|
|
83
234
|
|
|
84
235
|
async whoami(): Promise<WhoamiResponse> {
|
|
85
|
-
const res = await
|
|
236
|
+
const res = await this.authedFetch('/auth/whoami');
|
|
86
237
|
if (!res.ok) throw new Error(`whoami ${res.status}: ${await res.text()}`);
|
|
87
238
|
return (await res.json()) as WhoamiResponse;
|
|
88
239
|
}
|
|
89
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Phase 3a — 자기 master pw proof bootstrap-or-verify (POST /auth/proof, Bearer).
|
|
243
|
+
* proof = Argon2id(masterPw + GLOBAL_SALT) 단방향 해시 (평문 master pw 송신 X).
|
|
244
|
+
* 첫 호출 = bootstrap, 이후 = verify (불일치 시 worker 409 → throw).
|
|
245
|
+
*/
|
|
246
|
+
async setProof(
|
|
247
|
+
proof: string,
|
|
248
|
+
globalSaltVersion?: string,
|
|
249
|
+
): Promise<{ ok: boolean; bootstrap: boolean; userId: number; version_reset?: boolean }> {
|
|
250
|
+
const res = await fetch(this.url('/auth/proof'), {
|
|
251
|
+
method: 'POST',
|
|
252
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
253
|
+
body: JSON.stringify({ proof, global_salt_version: globalSaltVersion }),
|
|
254
|
+
});
|
|
255
|
+
if (!res.ok) throw new Error(`set proof ${res.status}: ${await res.text()}`);
|
|
256
|
+
return (await res.json()) as {
|
|
257
|
+
ok: boolean;
|
|
258
|
+
bootstrap: boolean;
|
|
259
|
+
userId: number;
|
|
260
|
+
version_reset?: boolean;
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Phase 4 Slice 3 — caller 의 X25519 identity 키 row 조회 (GET /auth/keys). 없으면 null(404).
|
|
266
|
+
* client 가 master pw 로 wrapped_private_key 를 unwrap 해 org 공유 시크릿 복호에 사용.
|
|
267
|
+
*/
|
|
268
|
+
async getKeys(): Promise<IdentityKeyRow | null> {
|
|
269
|
+
const res = await this.authedFetch('/auth/keys');
|
|
270
|
+
if (res.status === 404) return null;
|
|
271
|
+
if (!res.ok) throw new Error(`get keys ${res.status}: ${await res.text()}`);
|
|
272
|
+
return (await res.json()) as IdentityKeyRow;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Phase 4 Slice 3 — identity 키쌍 bootstrap (POST /auth/keys). 같은 pubkey 멱등, 다른 pubkey 409. */
|
|
276
|
+
async setKeys(row: IdentityKeyRow): Promise<{ ok: boolean; bootstrap: boolean }> {
|
|
277
|
+
const res = await fetch(this.url('/auth/keys'), {
|
|
278
|
+
method: 'POST',
|
|
279
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
280
|
+
body: JSON.stringify(row),
|
|
281
|
+
});
|
|
282
|
+
if (!res.ok) throw new Error(`set keys ${res.status}: ${await res.text()}`);
|
|
283
|
+
return (await res.json()) as { ok: boolean; bootstrap: boolean };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Phase 4 Slice 3 — 타 user 의 public key 조회 (멤버에게 DEK ECDH-wrap 용). 없으면 null(404). */
|
|
287
|
+
async getPublicKey(userId: number): Promise<string | null> {
|
|
288
|
+
const res = await this.authedFetch(`/auth/keys/${userId}/public`);
|
|
289
|
+
if (res.status === 404) return null;
|
|
290
|
+
if (!res.ok) throw new Error(`get pubkey ${res.status}: ${await res.text()}`);
|
|
291
|
+
return ((await res.json()) as { public_key: string }).public_key;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── Phase 4 Slice 6a — org 멀티테넌시 관리 ───
|
|
295
|
+
|
|
296
|
+
/** company org 생성 (생성자 = owner). */
|
|
297
|
+
async createOrg(
|
|
298
|
+
name: string,
|
|
299
|
+
): Promise<{ id: number; slug: string; name: string; type: string; role: string }> {
|
|
300
|
+
const res = await this.authedFetch('/v1/orgs', {
|
|
301
|
+
method: 'POST',
|
|
302
|
+
headers: { 'content-type': 'application/json' },
|
|
303
|
+
body: JSON.stringify({ name }),
|
|
304
|
+
});
|
|
305
|
+
if (!res.ok) throw new Error(`create org ${res.status}: ${await res.text()}`);
|
|
306
|
+
return (await res.json()) as {
|
|
307
|
+
id: number;
|
|
308
|
+
slug: string;
|
|
309
|
+
name: string;
|
|
310
|
+
type: string;
|
|
311
|
+
role: string;
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** 내가 멤버(active|pending)인 org 목록 (switcher 용). */
|
|
316
|
+
async listOrgs(): Promise<{ current_org_id: number | null; orgs: OrgSummary[] }> {
|
|
317
|
+
const res = await this.authedFetch('/v1/orgs');
|
|
318
|
+
if (!res.ok) throw new Error(`list orgs ${res.status}: ${await res.text()}`);
|
|
319
|
+
return (await res.json()) as { current_org_id: number | null; orgs: OrgSummary[] };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** 현재 org 의 멤버 목록. */
|
|
323
|
+
async listOrgMembers(): Promise<{ org_id: number; members: OrgMember[] }> {
|
|
324
|
+
const res = await this.authedFetch('/v1/orgs/current/members');
|
|
325
|
+
if (!res.ok) throw new Error(`list members ${res.status}: ${await res.text()}`);
|
|
326
|
+
return (await res.json()) as { org_id: number; members: OrgMember[] };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** 현재 org 에 멤버 초대 (owner/admin). 미존재 identifier → JIT pending user. */
|
|
330
|
+
async inviteMember(args: {
|
|
331
|
+
identifier: string;
|
|
332
|
+
role?: 'admin' | 'member';
|
|
333
|
+
}): Promise<{ user_id: number; role: string; status: string; jit_created: boolean }> {
|
|
334
|
+
const res = await this.authedFetch('/v1/orgs/current/members', {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: { 'content-type': 'application/json' },
|
|
337
|
+
body: JSON.stringify({ identifier: args.identifier, role: args.role }),
|
|
338
|
+
});
|
|
339
|
+
if (!res.ok) throw new Error(`invite ${res.status}: ${await res.text()}`);
|
|
340
|
+
return (await res.json()) as {
|
|
341
|
+
user_id: number;
|
|
342
|
+
role: string;
|
|
343
|
+
status: string;
|
|
344
|
+
jit_created: boolean;
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** 현재 org 에서 멤버 제거(owner/admin) 또는 self-leave. */
|
|
349
|
+
async removeMember(
|
|
350
|
+
userId: number,
|
|
351
|
+
): Promise<{ ok: boolean; user_id: number; status: string; revoked_tokens: number }> {
|
|
352
|
+
const res = await this.authedFetch(`/v1/orgs/current/members/${userId}`, { method: 'DELETE' });
|
|
353
|
+
if (!res.ok) throw new Error(`remove member ${res.status}: ${await res.text()}`);
|
|
354
|
+
return (await res.json()) as {
|
|
355
|
+
ok: boolean;
|
|
356
|
+
user_id: number;
|
|
357
|
+
status: string;
|
|
358
|
+
revoked_tokens: number;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** org 컨텍스트 전환 — 새 token(orgId=target) re-mint + 호출 token revoke. */
|
|
363
|
+
async switchOrg(orgId: number): Promise<{ token: string; org_id: number; role: string }> {
|
|
364
|
+
const res = await this.authedFetch(`/v1/orgs/${orgId}/switch`, { method: 'POST' });
|
|
365
|
+
if (!res.ok) throw new Error(`switch org ${res.status}: ${await res.text()}`);
|
|
366
|
+
return (await res.json()) as { token: string; org_id: number; role: string };
|
|
367
|
+
}
|
|
368
|
+
|
|
90
369
|
async revoke(targetToken?: string): Promise<{ ok: boolean; revoked: string; self?: boolean }> {
|
|
91
370
|
const res = await fetch(this.url('/auth/revoke'), {
|
|
92
371
|
method: 'POST',
|
|
@@ -97,6 +376,48 @@ export class AthsraClient {
|
|
|
97
376
|
return (await res.json()) as { ok: boolean; revoked: string; self?: boolean };
|
|
98
377
|
}
|
|
99
378
|
|
|
379
|
+
/** DR — BACKUP_STORE → STORE 복원. dry_run=!execute. execute 는 content-bound confirm 필수. */
|
|
380
|
+
async restoreR2(args: {
|
|
381
|
+
date: string;
|
|
382
|
+
scope: RestoreR2ScopeArg;
|
|
383
|
+
execute: boolean;
|
|
384
|
+
confirm?: string;
|
|
385
|
+
}): Promise<R2RestoreResult> {
|
|
386
|
+
const res = await this.authedFetch(`/v1/admin/restore/r2?dry_run=${!args.execute}`, {
|
|
387
|
+
method: 'POST',
|
|
388
|
+
headers: { 'content-type': 'application/json' },
|
|
389
|
+
body: JSON.stringify({ date: args.date, scope: args.scope, confirm: args.confirm }),
|
|
390
|
+
});
|
|
391
|
+
const body = (await res.json()) as R2RestoreResult & { error?: string };
|
|
392
|
+
if (!res.ok) throw new Error(`restore r2 ${res.status}: ${body.error ?? JSON.stringify(body)}`);
|
|
393
|
+
return body;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** DR — 암호화 D1 dump 복원. SAFE 기본 / tokens·audit 는 includeOptin + tables 명시. */
|
|
397
|
+
async restoreD1(args: {
|
|
398
|
+
dumpKey: string;
|
|
399
|
+
tables?: string[];
|
|
400
|
+
includeOptin: boolean;
|
|
401
|
+
policy: 'upsert' | 'replace-all';
|
|
402
|
+
execute: boolean;
|
|
403
|
+
confirm?: string;
|
|
404
|
+
}): Promise<D1RestoreResult> {
|
|
405
|
+
const res = await this.authedFetch(`/v1/admin/restore/d1?dry_run=${!args.execute}`, {
|
|
406
|
+
method: 'POST',
|
|
407
|
+
headers: { 'content-type': 'application/json' },
|
|
408
|
+
body: JSON.stringify({
|
|
409
|
+
dump_key: args.dumpKey,
|
|
410
|
+
tables: args.tables,
|
|
411
|
+
include_optin: args.includeOptin,
|
|
412
|
+
policy: args.policy,
|
|
413
|
+
confirm: args.confirm,
|
|
414
|
+
}),
|
|
415
|
+
});
|
|
416
|
+
const body = (await res.json()) as D1RestoreResult & { error?: string };
|
|
417
|
+
if (!res.ok) throw new Error(`restore d1 ${res.status}: ${body.error ?? JSON.stringify(body)}`);
|
|
418
|
+
return body;
|
|
419
|
+
}
|
|
420
|
+
|
|
100
421
|
async createServiceToken(args: {
|
|
101
422
|
project: string;
|
|
102
423
|
label: string;
|
|
@@ -162,14 +483,65 @@ export class AthsraClient {
|
|
|
162
483
|
};
|
|
163
484
|
}
|
|
164
485
|
|
|
486
|
+
/** Phase 3 P3 — device-login 시작 (unauth). device_code + user_code + verification_uri. */
|
|
487
|
+
async deviceCode(args: {
|
|
488
|
+
project: string;
|
|
489
|
+
perms?: 'read' | 'write';
|
|
490
|
+
label: string;
|
|
491
|
+
}): Promise<DeviceCodeResponse> {
|
|
492
|
+
const res = await fetch(this.url('/auth/device/code'), {
|
|
493
|
+
method: 'POST',
|
|
494
|
+
headers: { 'content-type': 'application/json' },
|
|
495
|
+
body: JSON.stringify({ project: args.project, perms: args.perms, label: args.label }),
|
|
496
|
+
});
|
|
497
|
+
if (!res.ok) throw new Error(`device code ${res.status}: ${await res.text()}`);
|
|
498
|
+
return (await res.json()) as DeviceCodeResponse;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Phase 3 P3 — device-login poll (unauth). 200 = token 수령, 400 = pending/terminal error. */
|
|
502
|
+
async devicePollToken(deviceCode: string): Promise<DevicePollResult> {
|
|
503
|
+
const res = await fetch(this.url('/auth/device/token'), {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
headers: { 'content-type': 'application/json' },
|
|
506
|
+
body: JSON.stringify({ device_code: deviceCode }),
|
|
507
|
+
});
|
|
508
|
+
const body = (await res.json()) as Record<string, unknown>;
|
|
509
|
+
if (res.ok) {
|
|
510
|
+
return {
|
|
511
|
+
status: 'token',
|
|
512
|
+
token: typeof body.token === 'string' ? body.token : '',
|
|
513
|
+
recipientId: typeof body.recipient_id === 'string' ? body.recipient_id : '',
|
|
514
|
+
project: typeof body.project === 'string' ? body.project : '',
|
|
515
|
+
perms: body.perms === 'write' ? 'write' : 'read',
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
status: 'pending',
|
|
520
|
+
error: typeof body.error === 'string' ? body.error : `http_${res.status}`,
|
|
521
|
+
interval: typeof body.interval === 'number' ? body.interval : undefined,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Phase 4 Slice 3 — rewrap 가 있으면 identity privkey 를 새 pw 재포장 재료를 같은 요청에 실어
|
|
527
|
+
* 원자적 갱신(별도 PUT 의 부분실패 = privkey 고아화 제거). identity 키 보유 user 는 필수.
|
|
528
|
+
*/
|
|
165
529
|
async rotateMaster(
|
|
166
530
|
oldProof: string,
|
|
167
531
|
newProof: string,
|
|
532
|
+
rewrap?: IdentityKeyRewrap,
|
|
168
533
|
): Promise<{ token: string; machineId: string; rotatedAt: string }> {
|
|
534
|
+
const body: Record<string, unknown> = { old_proof: oldProof, new_proof: newProof };
|
|
535
|
+
if (rewrap) {
|
|
536
|
+
body.new_wrapped_private_key = rewrap.wrapped_private_key;
|
|
537
|
+
body.new_key_salt = rewrap.key_salt;
|
|
538
|
+
body.new_wrap_nonce = rewrap.wrap_nonce;
|
|
539
|
+
body.new_kdf_params = rewrap.kdf_params;
|
|
540
|
+
}
|
|
169
541
|
const res = await fetch(this.url('/auth/rotate-master'), {
|
|
170
542
|
method: 'POST',
|
|
171
543
|
headers: this.headers({ 'content-type': 'application/json' }),
|
|
172
|
-
body: JSON.stringify(
|
|
544
|
+
body: JSON.stringify(body),
|
|
173
545
|
});
|
|
174
546
|
if (!res.ok) throw new Error(`rotate-master ${res.status}: ${await res.text()}`);
|
|
175
547
|
return (await res.json()) as { token: string; machineId: string; rotatedAt: string };
|
|
@@ -201,25 +573,23 @@ export class AthsraClient {
|
|
|
201
573
|
}
|
|
202
574
|
|
|
203
575
|
async getEnvelope(project: string): Promise<SecretEnvelopeAny | null> {
|
|
204
|
-
const res = await
|
|
205
|
-
headers: this.headers(),
|
|
206
|
-
});
|
|
576
|
+
const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}`);
|
|
207
577
|
if (res.status === 404) return null;
|
|
208
578
|
if (!res.ok) throw new Error(`fetch ${res.status}: ${await res.text()}`);
|
|
209
579
|
return (await res.json()) as SecretEnvelopeAny;
|
|
210
580
|
}
|
|
211
581
|
|
|
212
582
|
async putEnvelope(project: string, envelope: SecretEnvelopeAny): Promise<void> {
|
|
213
|
-
const res = await
|
|
583
|
+
const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}`, {
|
|
214
584
|
method: 'PUT',
|
|
215
|
-
headers:
|
|
585
|
+
headers: { 'content-type': 'application/json' },
|
|
216
586
|
body: JSON.stringify(envelope),
|
|
217
587
|
});
|
|
218
588
|
if (!res.ok) throw new Error(`put ${res.status}: ${await res.text()}`);
|
|
219
589
|
}
|
|
220
590
|
|
|
221
591
|
async listProjects(): Promise<string[]> {
|
|
222
|
-
const res = await
|
|
592
|
+
const res = await this.authedFetch('/v1/secrets');
|
|
223
593
|
if (!res.ok) throw new Error(`list ${res.status}: ${await res.text()}`);
|
|
224
594
|
const data = (await res.json()) as { projects: string[] };
|
|
225
595
|
return data.projects;
|
|
@@ -234,12 +604,8 @@ export class AthsraClient {
|
|
|
234
604
|
recoverable_versions?: number;
|
|
235
605
|
removed_versions?: number;
|
|
236
606
|
}> {
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
const res = await fetch(url, {
|
|
240
|
-
method: 'DELETE',
|
|
241
|
-
headers: this.headers(),
|
|
242
|
-
});
|
|
607
|
+
const path = `/v1/secrets/${encodeURIComponent(project)}${opts?.hard ? '?hard=true' : ''}`;
|
|
608
|
+
const res = await this.authedFetch(path, { method: 'DELETE' });
|
|
243
609
|
if (!res.ok) throw new Error(`delete ${res.status}: ${await res.text()}`);
|
|
244
610
|
return (await res.json()) as {
|
|
245
611
|
soft?: boolean;
|
|
@@ -256,9 +622,7 @@ export class AthsraClient {
|
|
|
256
622
|
versions: { version_id: string; updated_at: string; size: number }[];
|
|
257
623
|
count: number;
|
|
258
624
|
}> {
|
|
259
|
-
const res = await
|
|
260
|
-
headers: this.headers(),
|
|
261
|
-
});
|
|
625
|
+
const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/versions`);
|
|
262
626
|
if (!res.ok) throw new Error(`versions ${res.status}: ${await res.text()}`);
|
|
263
627
|
return (await res.json()) as {
|
|
264
628
|
project: string;
|
|
@@ -273,9 +637,9 @@ export class AthsraClient {
|
|
|
273
637
|
project: string,
|
|
274
638
|
versionId: string,
|
|
275
639
|
): Promise<{ ok: boolean; project: string; current_version: string }> {
|
|
276
|
-
const res = await
|
|
640
|
+
const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/rollback`, {
|
|
277
641
|
method: 'POST',
|
|
278
|
-
headers:
|
|
642
|
+
headers: { 'content-type': 'application/json' },
|
|
279
643
|
body: JSON.stringify({ version_id: versionId }),
|
|
280
644
|
});
|
|
281
645
|
if (!res.ok) throw new Error(`rollback ${res.status}: ${await res.text()}`);
|
|
@@ -289,9 +653,8 @@ export class AthsraClient {
|
|
|
289
653
|
deleted_at: string;
|
|
290
654
|
deleted_by: string;
|
|
291
655
|
}> {
|
|
292
|
-
const res = await
|
|
656
|
+
const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/restore`, {
|
|
293
657
|
method: 'POST',
|
|
294
|
-
headers: this.headers(),
|
|
295
658
|
});
|
|
296
659
|
if (!res.ok) throw new Error(`restore ${res.status}: ${await res.text()}`);
|
|
297
660
|
return (await res.json()) as {
|
|
@@ -309,8 +672,8 @@ export class AthsraClient {
|
|
|
309
672
|
| { projects: string[]; count: number }
|
|
310
673
|
| { projects: { project: string; active: boolean; deleted: boolean }[]; count: number }
|
|
311
674
|
> {
|
|
312
|
-
const
|
|
313
|
-
const res = await
|
|
675
|
+
const path = `/v1/secrets${opts?.includeDeleted ? '?include_deleted=true' : ''}`;
|
|
676
|
+
const res = await this.authedFetch(path);
|
|
314
677
|
if (!res.ok) throw new Error(`list ${res.status}: ${await res.text()}`);
|
|
315
678
|
return (await res.json()) as
|
|
316
679
|
| { projects: string[]; count: number }
|
|
@@ -335,17 +698,14 @@ export class AthsraClient {
|
|
|
335
698
|
|
|
336
699
|
async inviteUser(args: {
|
|
337
700
|
identifier: string;
|
|
338
|
-
proof: string;
|
|
339
|
-
globalSaltVersion?: string;
|
|
340
701
|
initialRole?: 'admin' | 'dev' | 'viewer' | 'auditor' | 'sa';
|
|
341
702
|
}): Promise<{ ok: true; id: number; identifier: string; status: string; role: string }> {
|
|
703
|
+
// Phase 3a: invite 는 신원·접근 부여만 — master pw proof 는 유저가 직접 bootstrap.
|
|
342
704
|
const res = await fetch(this.url('/v1/admin/users'), {
|
|
343
705
|
method: 'POST',
|
|
344
706
|
headers: this.headers({ 'content-type': 'application/json' }),
|
|
345
707
|
body: JSON.stringify({
|
|
346
708
|
identifier: args.identifier,
|
|
347
|
-
proof: args.proof,
|
|
348
|
-
global_salt_version: args.globalSaltVersion,
|
|
349
709
|
initial_role: args.initialRole,
|
|
350
710
|
}),
|
|
351
711
|
});
|
|
@@ -400,19 +760,23 @@ export class AthsraClient {
|
|
|
400
760
|
if (!res.ok) throw new Error(`admin grant acl ${res.status}: ${await res.text()}`);
|
|
401
761
|
return (await res.json()) as {
|
|
402
762
|
ok: true;
|
|
763
|
+
org_id: number;
|
|
403
764
|
user_id: number;
|
|
404
765
|
project: string;
|
|
405
766
|
perms: string;
|
|
406
767
|
};
|
|
407
768
|
}
|
|
408
769
|
|
|
409
|
-
async revokeAcl(args: {
|
|
770
|
+
async revokeAcl(args: {
|
|
771
|
+
project: string;
|
|
772
|
+
userId: number;
|
|
773
|
+
}): Promise<{ ok: true; org_id: number }> {
|
|
410
774
|
const res = await fetch(
|
|
411
775
|
this.url(`/v1/admin/projects/${encodeURIComponent(args.project)}/acl/${args.userId}`),
|
|
412
776
|
{ method: 'DELETE', headers: this.headers() },
|
|
413
777
|
);
|
|
414
778
|
if (!res.ok) throw new Error(`admin revoke acl ${res.status}: ${await res.text()}`);
|
|
415
|
-
return (await res.json()) as { ok: true };
|
|
779
|
+
return (await res.json()) as { ok: true; org_id: number };
|
|
416
780
|
}
|
|
417
781
|
|
|
418
782
|
/**
|
|
@@ -539,6 +903,7 @@ export class AthsraClient {
|
|
|
539
903
|
*/
|
|
540
904
|
export interface CwWorkerRow {
|
|
541
905
|
id: number;
|
|
906
|
+
orgId: number;
|
|
542
907
|
project: string;
|
|
543
908
|
workerName: string;
|
|
544
909
|
accountId: string;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* colors.ts — 최소 ANSI 색상 (terminal 출력 강조). NO_COLOR + non-TTY 존중.
|
|
3
|
+
*
|
|
4
|
+
* 2026-06-02 — dr (DR restore) + service-token list (만료 임박 강조) 공용.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const ENABLED = process.env.NO_COLOR === undefined && process.stdout.isTTY !== false;
|
|
8
|
+
|
|
9
|
+
function wrap(code: string, s: string): string {
|
|
10
|
+
return ENABLED ? `\x1b[${code}m${s}\x1b[0m` : s;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const red = (s: string): string => wrap('31', s);
|
|
14
|
+
export const yellow = (s: string): string => wrap('33', s);
|
|
15
|
+
export const green = (s: string): string => wrap('32', s);
|
|
16
|
+
export const dim = (s: string): string => wrap('2', s);
|
|
17
|
+
export const bold = (s: string): string => wrap('1', s);
|
package/src/lib/config.ts
CHANGED
|
@@ -10,6 +10,12 @@ export interface Config {
|
|
|
10
10
|
workerUrl: string;
|
|
11
11
|
machineId: string;
|
|
12
12
|
createdAt: string;
|
|
13
|
+
/**
|
|
14
|
+
* Phase 4 Slice 6a — 현재 활성 org (org switch 시 기록). 미설정 = 자기 personal org(기본).
|
|
15
|
+
* idle-refresh 가 re-register 후 이 값으로 다시 switch (안 그러면 personal org 로 reset).
|
|
16
|
+
*/
|
|
17
|
+
orgId?: number;
|
|
18
|
+
orgSlug?: string;
|
|
13
19
|
}
|
|
14
20
|
|
|
15
21
|
export function ensureConfigDir(): void {
|
package/src/lib/env-format.ts
CHANGED
|
@@ -1,56 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* .env 형식 parser/serializer (
|
|
3
|
-
* Phase 0 minimal — escape, multiline, $expansion 미지원.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export function parseEnv(text: string): Record<string, string> {
|
|
7
|
-
const out: Record<string, string> = {};
|
|
8
|
-
for (const line of text.split('\n')) {
|
|
9
|
-
const trimmed = line.trim();
|
|
10
|
-
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
11
|
-
const eqIdx = trimmed.indexOf('=');
|
|
12
|
-
if (eqIdx < 0) continue;
|
|
13
|
-
const key = trimmed.slice(0, eqIdx).trim();
|
|
14
|
-
let value = trimmed.slice(eqIdx + 1).trim();
|
|
15
|
-
// strip surrounding quotes
|
|
16
|
-
if (
|
|
17
|
-
(value.startsWith('"') && value.endsWith('"')) ||
|
|
18
|
-
(value.startsWith("'") && value.endsWith("'"))
|
|
19
|
-
) {
|
|
20
|
-
value = value.slice(1, -1);
|
|
21
|
-
}
|
|
22
|
-
out[key] = value;
|
|
23
|
-
}
|
|
24
|
-
return out;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function serializeEnv(plain: Record<string, string>): string {
|
|
28
|
-
return Object.entries(plain)
|
|
29
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
30
|
-
.join('\n');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* 빈 값 키 (value === '') 를 정상 키와 분리한다.
|
|
2
|
+
* .env 형식 parser/serializer — @athsra/crypto 로 이동 (CLI + dashboard 단일 source).
|
|
35
3
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
* silently 덮어써 배포 인증을 깨뜨리는 사고를 차단하기 위한 "빈 값" 의 단일 정의.
|
|
4
|
+
* envelope plaintext 형식 (dotenv KEY=value) 은 암호화 envelope 의 내용 규격이므로
|
|
5
|
+
* crypto 패키지가 정본. 이 파일은 CLI 내부 사용처 (get/ls/doctor/run/set/envelope) 의
|
|
6
|
+
* 기존 import 경로 호환을 위한 re-export.
|
|
40
7
|
*/
|
|
41
|
-
export
|
|
42
|
-
filled: Record<string, string>;
|
|
43
|
-
emptyKeys: string[];
|
|
44
|
-
} {
|
|
45
|
-
const filled: Record<string, string> = {};
|
|
46
|
-
const emptyKeys: string[] = [];
|
|
47
|
-
for (const [key, value] of Object.entries(plain)) {
|
|
48
|
-
if (value === '') {
|
|
49
|
-
emptyKeys.push(key);
|
|
50
|
-
} else {
|
|
51
|
-
filled[key] = value;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
emptyKeys.sort();
|
|
55
|
-
return { filled, emptyKeys };
|
|
56
|
-
}
|
|
8
|
+
export { parseEnv, partitionEnv, serializeEnv } from '@athsra/crypto';
|