@athsra/cli 1.0.2 → 1.0.4

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/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
- private readonly token: string | null = null,
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 fetch(this.url('/auth/whoami'), { headers: this.headers() });
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({ old_proof: oldProof, new_proof: newProof }),
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 fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}`), {
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 fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}`), {
583
+ const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}`, {
214
584
  method: 'PUT',
215
- headers: this.headers({ 'content-type': 'application/json' }),
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 fetch(this.url('/v1/secrets'), { headers: this.headers() });
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 url =
238
- this.url(`/v1/secrets/${encodeURIComponent(project)}`) + (opts?.hard ? '?hard=true' : '');
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 fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}/versions`), {
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 fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}/rollback`), {
640
+ const res = await this.authedFetch(`/v1/secrets/${encodeURIComponent(project)}/rollback`, {
277
641
  method: 'POST',
278
- headers: this.headers({ 'content-type': 'application/json' }),
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 fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}/restore`), {
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 url = this.url('/v1/secrets') + (opts?.includeDeleted ? '?include_deleted=true' : '');
313
- const res = await fetch(url, { headers: this.headers() });
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: { project: string; userId: number }): Promise<{ ok: true }> {
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 {
@@ -1,56 +1,8 @@
1
1
  /**
2
- * .env 형식 parser/serializer (KEY=value, # comment, blank lines).
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
- * athsra 키를 "미설정" 으로 취급한다 `run` 은 inject 를 건너뛰어
37
- * 부모 환경 변수를 보존하고, `ls`/`doctor` `(empty)` 로 노출한다. 키 이름만
38
- * 등록되고 값이 누락된 산출물 (migration scaffolding) 이 유효한 부모 env 를
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 function partitionEnv(plain: Record<string, string>): {
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';