@athsra/cli 1.0.2 → 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.
@@ -0,0 +1,363 @@
1
+ /**
2
+ * org.ts — Phase 4 Slice 6a org 멀티테넌시 CLI (Slack/GitHub-org 식 "현재 org" 모델).
3
+ *
4
+ * athsra org create <name> — company org 생성 (생성자 = owner)
5
+ * athsra org ls — 내가 멤버인 org 목록 (* = 현재)
6
+ * athsra org members — 현재 org 멤버
7
+ * athsra org invite <identifier> [--role=R] — 현재 org 에 멤버 초대 (member|admin, JIT pending)
8
+ * athsra org remove <user_id> — 현재 org 에서 멤버 제거
9
+ * athsra org use <slug|id> — org 컨텍스트 전환 (token re-mint + config 기록)
10
+ * athsra org leave — 현재 org 탈퇴
11
+ *
12
+ * 관리(invite/remove)는 그 org 의 owner/admin role. switch 후 그 org 컨텍스트에서 동작한다.
13
+ */
14
+
15
+ import { loadAuthContext, type UserAuthContext } from '../lib/auth-context.ts';
16
+ import { saveConfig } from '../lib/config.ts';
17
+ import { setToken } from '../lib/keyring.ts';
18
+ import { grantOrgAccess, type RevokeRotateResult, rotateAfterRemoval } from '../lib/org-rewrap.ts';
19
+
20
+ const USAGE = [
21
+ 'usage:',
22
+ ' athsra org create <name> — company org 생성 (owner)',
23
+ ' athsra org ls — 내가 멤버인 org 목록 (* = 현재)',
24
+ ' athsra org members — 현재 org 멤버',
25
+ ' athsra org invite <identifier> [--role=member|admin] — 멤버 초대 (JIT pending)',
26
+ ' athsra org remove <user_id> — 멤버 제거 (owner 면 DEK 자동 회전)',
27
+ ' athsra org rotate-after-removal <user_id> — 제거 후 DEK 회전 재개 (부분 실패 복구, owner)',
28
+ ' athsra org grant-access <identifier|id> [--replace] — 멤버에게 공유 시크릿 re-wrap (--replace=key reset 복구)',
29
+ ' athsra org use <slug|id> — org 컨텍스트 전환',
30
+ ' athsra org leave — 현재 org 탈퇴',
31
+ ].join('\n');
32
+
33
+ async function userCtx(): Promise<UserAuthContext | null> {
34
+ const ctx = await loadAuthContext();
35
+ if (!ctx) return null;
36
+ if (ctx.kind !== 'user') {
37
+ console.error('org 명령은 user token (master pw) 가 필요합니다. service token 거부.');
38
+ return null;
39
+ }
40
+ return ctx;
41
+ }
42
+
43
+ /** 현재 org 에서 caller 의 role (DEK 회전은 owner 만 — master 재wrap 필요). */
44
+ async function currentOrgRole(ctx: UserAuthContext): Promise<'owner' | 'admin' | 'member' | null> {
45
+ const { orgs } = await ctx.client.listOrgs();
46
+ return orgs.find((o) => o.is_current)?.role ?? null;
47
+ }
48
+
49
+ /** DEK 회전 스윕 결과 출력 (removeCmd 자동 회전 + rotate-after-removal 복구 공용). */
50
+ function reportRotation(res: RevokeRotateResult): void {
51
+ const parts = [`rotated ${res.rotated.length}`, `skipped ${res.skipped.length}`];
52
+ if (res.legacyV1.length) parts.push(`v1 ${res.legacyV1.length}`);
53
+ if (res.failed.length) parts.push(`failed ${res.failed.length}`);
54
+ console.log(` DEK 회전 — ${parts.join(' · ')}`);
55
+ if (res.rotated.length) {
56
+ console.log(` 회전: ${res.rotated.join(', ')}`);
57
+ console.log(
58
+ ' ⓘ 제거 멤버가 이미 본 평문 값은 무효화되지 않습니다 — 민감 시크릿은 값 자체를 별도 로테이션 권고.',
59
+ );
60
+ }
61
+ for (const r of res.reissue) {
62
+ console.log(
63
+ ` ⚠ ${r.project}: service token 재발급 필요 (${r.serviceIds.join(', ')}) — DEK 회전으로 무효화됨`,
64
+ );
65
+ }
66
+ if (res.legacyV1.length) {
67
+ console.log(` v1 envelope(회전 불가 — recipient 모델 없음): ${res.legacyV1.join(', ')}`);
68
+ }
69
+ for (const f of res.failed) console.error(` ✗ ${f.project}: ${f.error}`);
70
+ }
71
+
72
+ async function createOrgCmd(args: string[]): Promise<number> {
73
+ const name = args
74
+ .filter((a) => !a.startsWith('-'))
75
+ .join(' ')
76
+ .trim();
77
+ if (!name) {
78
+ console.error('usage: athsra org create <name>');
79
+ return 2;
80
+ }
81
+ const ctx = await userCtx();
82
+ if (!ctx) return 1;
83
+ try {
84
+ const res = await ctx.client.createOrg(name);
85
+ console.log(`✓ org created: #${res.id} ${res.slug} (${res.type}) — 너는 owner`);
86
+ console.log(` 전환: athsra org use ${res.slug}`);
87
+ return 0;
88
+ } catch (err) {
89
+ console.error(`✗ create failed: ${(err as Error).message}`);
90
+ return 1;
91
+ }
92
+ }
93
+
94
+ async function lsOrgsCmd(): Promise<number> {
95
+ const ctx = await userCtx();
96
+ if (!ctx) return 1;
97
+ const { orgs } = await ctx.client.listOrgs();
98
+ if (orgs.length === 0) {
99
+ console.log('(no orgs)');
100
+ return 0;
101
+ }
102
+ for (const o of orgs) {
103
+ const cur = o.is_current ? '*' : ' ';
104
+ const pend = o.status === 'pending' ? ' (pending invite — `org use` 로 수락)' : '';
105
+ console.log(`${cur} ${o.slug} [${o.role}] #${o.id}${pend}`);
106
+ }
107
+ return 0;
108
+ }
109
+
110
+ async function membersCmd(): Promise<number> {
111
+ const ctx = await userCtx();
112
+ if (!ctx) return 1;
113
+ try {
114
+ const { members } = await ctx.client.listOrgMembers();
115
+ if (members.length === 0) {
116
+ console.log('(no members)');
117
+ return 0;
118
+ }
119
+ for (const m of members) {
120
+ const pend = m.status === 'pending' ? ' (pending)' : '';
121
+ console.log(` #${m.user_id} ${m.identifier} [${m.role}]${pend}`);
122
+ }
123
+ return 0;
124
+ } catch (err) {
125
+ console.error(`✗ members failed: ${(err as Error).message}`);
126
+ return 1;
127
+ }
128
+ }
129
+
130
+ async function inviteCmd(args: string[]): Promise<number> {
131
+ const identifier = args.filter((a) => !a.startsWith('-'))[0];
132
+ if (!identifier) {
133
+ console.error('usage: athsra org invite <identifier> [--role=member|admin]');
134
+ return 2;
135
+ }
136
+ let role: 'member' | 'admin' = 'member';
137
+ for (const a of args) {
138
+ if (a.startsWith('--role=')) {
139
+ const v = a.slice('--role='.length);
140
+ if (v !== 'member' && v !== 'admin') {
141
+ console.error('--role must be member|admin');
142
+ return 2;
143
+ }
144
+ role = v;
145
+ }
146
+ }
147
+ const ctx = await userCtx();
148
+ if (!ctx) return 1;
149
+ try {
150
+ const res = await ctx.client.inviteMember({ identifier, role });
151
+ const jit = res.jit_created ? ' — 신규 계정 생성' : '';
152
+ console.log(`✓ invited #${res.user_id} (${res.role}, ${res.status})${jit}`);
153
+ console.log(
154
+ ' 초대받은 사람: athsra login → `athsra org ls` 에 pending 표시 → `athsra org use` 로 수락',
155
+ );
156
+ return 0;
157
+ } catch (err) {
158
+ const msg = (err as Error).message;
159
+ if (msg.includes('limit_exceeded')) {
160
+ console.error('✗ seat 한도 초과 — owner 가 plan upgrade 필요');
161
+ } else if (msg.includes('disabled')) {
162
+ console.error('✗ 비활성 계정은 초대 불가');
163
+ } else if (msg.includes('already a member')) {
164
+ console.error('✗ 이미 멤버');
165
+ } else if (msg.includes('pending')) {
166
+ console.error('✗ 이미 초대 대기 중');
167
+ } else if (msg.includes('403')) {
168
+ console.error('✗ owner/admin 만 초대 가능');
169
+ } else {
170
+ console.error(`✗ invite failed: ${msg}`);
171
+ }
172
+ return 1;
173
+ }
174
+ }
175
+
176
+ async function removeCmd(args: string[]): Promise<number> {
177
+ const userId = Number(args[0]);
178
+ if (!Number.isInteger(userId) || userId <= 0) {
179
+ console.error('usage: athsra org remove <user_id> (athsra org members 로 user_id 확인)');
180
+ return 2;
181
+ }
182
+ const ctx = await userCtx();
183
+ if (!ctx) return 1;
184
+ try {
185
+ const res = await ctx.client.removeMember(userId);
186
+ console.log(`✓ removed #${res.user_id} (revoked, ${res.revoked_tokens} token(s) cleared)`);
187
+ // real revocation — DEK 회전(제거 멤버의 캐시 DEK 무효화). owner 만 가능(master 재wrap).
188
+ const role = await currentOrgRole(ctx);
189
+ if (role === 'owner') {
190
+ const rot = await rotateAfterRemoval(ctx.client, ctx.masterPw, userId);
191
+ reportRotation(rot);
192
+ return rot.failed.length > 0 ? 1 : 0;
193
+ }
194
+ console.log(
195
+ ` ⚠ DEK 회전은 owner 만 가능 — owner 가 \`athsra org rotate-after-removal ${userId}\` 실행 필요.`,
196
+ );
197
+ return 0;
198
+ } catch (err) {
199
+ const msg = (err as Error).message;
200
+ if (msg.includes('owner')) {
201
+ console.error('✗ org owner 는 제거 불가');
202
+ } else if (msg.includes('403')) {
203
+ console.error('✗ owner/admin 만 타인 제거 가능');
204
+ } else {
205
+ console.error(`✗ remove failed: ${msg}`);
206
+ }
207
+ return 1;
208
+ }
209
+ }
210
+
211
+ async function rotateAfterRemovalCmd(args: string[]): Promise<number> {
212
+ const userId = Number(args[0]);
213
+ if (!Number.isInteger(userId) || userId <= 0) {
214
+ console.error('usage: athsra org rotate-after-removal <user_id> (제거된 멤버의 user_id)');
215
+ return 2;
216
+ }
217
+ const ctx = await userCtx();
218
+ if (!ctx) return 1;
219
+ const role = await currentOrgRole(ctx);
220
+ if (role !== 'owner') {
221
+ console.error('✗ DEK 회전은 현재 org 의 owner 만 가능합니다 (master pw 로 재wrap).');
222
+ return 1;
223
+ }
224
+ try {
225
+ const rot = await rotateAfterRemoval(ctx.client, ctx.masterPw, userId);
226
+ console.log(`✓ rotate-after-removal #${userId}`);
227
+ reportRotation(rot);
228
+ return rot.failed.length > 0 ? 1 : 0;
229
+ } catch (err) {
230
+ console.error(`✗ rotate failed: ${(err as Error).message}`);
231
+ return 1;
232
+ }
233
+ }
234
+
235
+ async function useCmd(args: string[]): Promise<number> {
236
+ const target = args[0];
237
+ if (!target) {
238
+ console.error('usage: athsra org use <slug|id>');
239
+ return 2;
240
+ }
241
+ const ctx = await userCtx();
242
+ if (!ctx) return 1;
243
+ const { orgs } = await ctx.client.listOrgs();
244
+ const org = orgs.find((o) => o.slug === target || String(o.id) === target);
245
+ if (!org) {
246
+ console.error(`✗ '${target}' org 멤버 아님 (athsra org ls 로 확인)`);
247
+ return 1;
248
+ }
249
+ try {
250
+ const sw = await ctx.client.switchOrg(org.id);
251
+ setToken(ctx.config.machineId, sw.token);
252
+ saveConfig({ ...ctx.config, orgId: sw.org_id, orgSlug: org.slug });
253
+ console.log(`✓ now acting as org ${org.slug} (${sw.role})`);
254
+ return 0;
255
+ } catch (err) {
256
+ const msg = (err as Error).message;
257
+ if (msg.includes('limit_exceeded')) {
258
+ console.error('✗ seat 한도 초과 — owner 가 plan upgrade 필요');
259
+ } else {
260
+ console.error(`✗ switch failed: ${msg}`);
261
+ }
262
+ return 1;
263
+ }
264
+ }
265
+
266
+ async function leaveCmd(): Promise<number> {
267
+ const ctx = await userCtx();
268
+ if (!ctx) return 1;
269
+ const me = await ctx.client.whoami();
270
+ if (me.userId === undefined) {
271
+ console.error('✗ token 에 user_id 없음 — athsra login');
272
+ return 1;
273
+ }
274
+ try {
275
+ await ctx.client.removeMember(me.userId);
276
+ // self-leave 가 현재 org token 을 삭제 → config.orgId clear (다음 명령의 idle-refresh 가
277
+ // personal org 로 re-register). personal 컨텍스트 복귀.
278
+ saveConfig({ ...ctx.config, orgId: undefined, orgSlug: undefined });
279
+ console.log('✓ left the org — personal 컨텍스트로 복귀 (다음 명령에서 자동 재인증).');
280
+ return 0;
281
+ } catch (err) {
282
+ const msg = (err as Error).message;
283
+ if (msg.includes('owner')) {
284
+ console.error('✗ owner 는 탈퇴 불가 (소유 이양 / org 삭제 필요)');
285
+ } else {
286
+ console.error(`✗ leave failed: ${msg}`);
287
+ }
288
+ return 1;
289
+ }
290
+ }
291
+
292
+ async function grantAccessCmd(args: string[]): Promise<number> {
293
+ const target = args.filter((a) => !a.startsWith('-'))[0];
294
+ const replace = args.includes('--replace');
295
+ if (!target) {
296
+ console.error('usage: athsra org grant-access <identifier|user_id> [--replace]');
297
+ return 2;
298
+ }
299
+ const ctx = await userCtx();
300
+ if (!ctx) return 1;
301
+ // identifier → 현재 org 멤버에서 user_id 해석 (숫자면 그대로).
302
+ let userId = Number(target);
303
+ if (!Number.isInteger(userId) || userId <= 0) {
304
+ const { members } = await ctx.client.listOrgMembers();
305
+ const member = members.find((x) => x.identifier === target);
306
+ if (!member) {
307
+ console.error(`✗ '${target}' 는 현재 org 멤버 아님 (athsra org members 로 확인)`);
308
+ return 1;
309
+ }
310
+ userId = member.user_id;
311
+ }
312
+ try {
313
+ // --replace: key reset 후 stale recipient 교체 (제거 후 새 pubkey 로 재포장).
314
+ const res = await grantOrgAccess(ctx.client, ctx.masterPw, userId, { replace });
315
+ const parts = [`granted ${res.granted.length}`, `skipped ${res.skipped.length}`];
316
+ if (res.legacyV1.length) parts.push(`v1 미지원 ${res.legacyV1.length}`);
317
+ if (res.failed.length) parts.push(`failed ${res.failed.length}`);
318
+ console.log(`✓ #${userId} re-wrap — ${parts.join(' · ')}`);
319
+ if (res.granted.length) console.log(` 공유: ${res.granted.join(', ')}`);
320
+ if (res.legacyV1.length) {
321
+ console.log(` v1 envelope(먼저 \`athsra migrate-envelopes\`): ${res.legacyV1.join(', ')}`);
322
+ }
323
+ for (const f of res.failed) console.error(` ✗ ${f.project}: ${f.error}`);
324
+ return res.failed.length > 0 ? 1 : 0;
325
+ } catch (err) {
326
+ console.error(`✗ grant-access failed: ${(err as Error).message}`);
327
+ return 1;
328
+ }
329
+ }
330
+
331
+ export async function orgCmd(args: string[]): Promise<number> {
332
+ const action = args[0];
333
+ switch (action) {
334
+ case 'create':
335
+ return createOrgCmd(args.slice(1));
336
+ case 'ls':
337
+ case 'list':
338
+ return lsOrgsCmd();
339
+ case 'members':
340
+ return membersCmd();
341
+ case 'invite':
342
+ return inviteCmd(args.slice(1));
343
+ case 'remove':
344
+ return removeCmd(args.slice(1));
345
+ case 'rotate-after-removal':
346
+ return rotateAfterRemovalCmd(args.slice(1));
347
+ case 'grant-access':
348
+ return grantAccessCmd(args.slice(1));
349
+ case 'use':
350
+ case 'switch':
351
+ return useCmd(args.slice(1));
352
+ case 'leave':
353
+ return leaveCmd();
354
+ default:
355
+ if (!action || action === '--help' || action === '-h') {
356
+ console.log(USAGE);
357
+ return action ? 0 : 2;
358
+ }
359
+ console.error(`Unknown action: ${action}`);
360
+ console.error(USAGE);
361
+ return 2;
362
+ }
363
+ }
@@ -1,8 +1,16 @@
1
- import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
1
+ import {
2
+ deriveKey,
3
+ ENTERPRISE_KDF,
4
+ fromBase64,
5
+ isValidPhrase,
6
+ normalizePhrase,
7
+ toBase64,
8
+ wordCount,
9
+ } from '@athsra/crypto';
2
10
  import { loadAuthContext, type UserAuthContext } from '../lib/auth-context.ts';
3
- import { isValidPhrase, normalizePhrase, wordCount } from '../lib/bip39.ts';
4
11
  import { AthsraClient } from '../lib/client.ts';
5
12
  import { readPlain, writePlain } from '../lib/envelope.ts';
13
+ import { rewrapForRotation } from '../lib/identity-key.ts';
6
14
  import { setMasterPw, setToken } from '../lib/keyring.ts';
7
15
  import { promptConfirm, promptPassword } from '../lib/prompt.ts';
8
16
 
@@ -97,13 +105,20 @@ export async function rotateMasterCmd(_args: string[]): Promise<number> {
97
105
  console.log(` ✓ ${p} (${Object.keys(plain).length} keys)`);
98
106
  }
99
107
 
100
- // 2. POST /auth/rotate-master
108
+ // 2. identity 키쌍 재포장 재료 계산 (있으면) — old pw 로 unwrap → new pw 로 wrap. proof 회전
109
+ // 요청에 실어 원자적 갱신(별도 PUT 의 부분실패 = privkey 고아화 제거). 키 보유 시 server 가 필수.
110
+ const keyRewrap = await rewrapForRotation(client, oldPw, newPw);
111
+ if (keyRewrap) {
112
+ console.log('• Re-wrapping identity keypair with new master pw (atomic with rotation)...');
113
+ }
114
+
115
+ // POST /auth/rotate-master
101
116
  console.log('\n• Rotating server-side proof + invalidating all tokens...');
102
117
  const info = await client.info();
103
118
  const globalSalt = fromBase64(info.global_salt);
104
119
  const oldProof = toBase64(deriveKey(oldPw, globalSalt));
105
120
  const newProof = toBase64(deriveKey(newPw, globalSalt));
106
- const rot = await client.rotateMaster(oldProof, newProof);
121
+ const rot = await client.rotateMaster(oldProof, newProof, keyRewrap ?? undefined);
107
122
  console.log(` ✓ new token issued (machineId=${rot.machineId})`);
108
123
 
109
124
  // 3. keyring 갱신 + 새 client
@@ -120,15 +135,19 @@ export async function rotateMasterCmd(_args: string[]): Promise<number> {
120
135
 
121
136
  // 4. re-encrypt + PUT (v2 envelope, 자동 마이그레이션)
122
137
  console.log(
123
- `\n• Re-encrypting ${Object.keys(plaintexts).length} projects with new master pw (as v2)...`,
138
+ `\n• Re-encrypting ${Object.keys(plaintexts).length} projects with new master pw ` +
139
+ '(v2, Argon2id m=256MB enterprise)...',
124
140
  );
125
141
  for (const [p, plain] of Object.entries(plaintexts)) {
126
- await writePlain(newCtx, p, plain);
142
+ // rotate-master = enterprise KDF 마이그레이션 지점 (m=256MB). 이후 routine set 은
143
+ // writePlain 의 보존 로직으로 256MB 유지 (silent downgrade 없음).
144
+ await writePlain(newCtx, p, plain, { kdfParams: ENTERPRISE_KDF });
127
145
  console.log(` ✓ ${p}`);
128
146
  }
129
147
 
130
148
  console.log(
131
- `\n✓ master password rotated. ${Object.keys(plaintexts).length} projects re-encrypted as v2.`,
149
+ `\n✓ master password rotated. ${Object.keys(plaintexts).length} projects re-encrypted ` +
150
+ 'as v2 (Argon2id m=256MB).',
132
151
  );
133
152
  console.log(
134
153
  ' service token recipients (있다면) 손실 — `athsra service-token create` 로 재발급 필요.',
@@ -30,7 +30,8 @@ export async function runCmd(args: string[]): Promise<number> {
30
30
  return 2;
31
31
  }
32
32
 
33
- const ctx = await loadAuthContext();
33
+ // project 전달 device-login agent (master pw 없는 머신) 의 project-scoped token 해석.
34
+ const ctx = await loadAuthContext(project);
34
35
  if (!ctx) return 1;
35
36
 
36
37
  const plain = await readPlain(ctx, project);
@@ -1,6 +1,10 @@
1
1
  import { addServiceRecipient, migrateV1ToV2, type SecretEnvelopeV2 } from '@athsra/crypto';
2
2
  import { loadAuthContext } from '../lib/auth-context.ts';
3
3
  import { resolveProject } from '../lib/auto-project.ts';
4
+ import { red, yellow } from '../lib/colors.ts';
5
+
6
+ /** worker expiry-notify (apps/worker/src/queue/expiry-notify.ts) 의 최임박 threshold 와 동기. */
7
+ const EXPIRING_SOON_DAYS = 14;
4
8
 
5
9
  const USAGE = [
6
10
  'usage:',
@@ -158,12 +162,19 @@ async function listCmd(args: string[]): Promise<number> {
158
162
  console.log('');
159
163
  const now = Date.now();
160
164
  for (const t of tokens) {
161
- const expired = t.expires_at && new Date(t.expires_at).getTime() < now;
162
- const expiresLabel = t.expires_at
163
- ? expired
164
- ? `expired ${t.expires_at}`
165
- : `expires ${t.expires_at}`
166
- : 'no expiry';
165
+ let expiresLabel: string;
166
+ if (!t.expires_at) {
167
+ expiresLabel = 'no expiry';
168
+ } else {
169
+ const daysLeft = Math.ceil((new Date(t.expires_at).getTime() - now) / 86_400_000);
170
+ if (daysLeft <= 0) {
171
+ expiresLabel = red(`expired ${t.expires_at}`);
172
+ } else if (daysLeft <= EXPIRING_SOON_DAYS) {
173
+ expiresLabel = yellow(`expires ${t.expires_at} (${daysLeft}일 남음)`);
174
+ } else {
175
+ expiresLabel = `expires ${t.expires_at} (${daysLeft}일 남음)`;
176
+ }
177
+ }
167
178
  console.log(` ${t.label}`);
168
179
  console.log(` project: ${t.project}`);
169
180
  console.log(` perms: ${t.perms}`);
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { auditCmd } from './commands/audit.ts';
5
5
  import { completionCmd } from './commands/completion.ts';
6
6
  import { deleteCmd } from './commands/delete.ts';
7
7
  import { doctorCmd } from './commands/doctor.ts';
8
+ import { drCmd } from './commands/dr.ts';
8
9
  import { getCmd } from './commands/get.ts';
9
10
  import { handoffCmd } from './commands/handoff.ts';
10
11
  import { initCmd } from './commands/init.ts';
@@ -15,6 +16,7 @@ import { manifestCmd } from './commands/manifest.ts';
15
16
  import { mcpCmd } from './commands/mcp.ts';
16
17
  import { migrateEnvelopesCmd } from './commands/migrate-envelopes.ts';
17
18
  import { newPhraseCmd } from './commands/new-phrase.ts';
19
+ import { orgCmd } from './commands/org.ts';
18
20
  import { purgeCmd } from './commands/purge.ts';
19
21
  import { restoreCmd } from './commands/restore.ts';
20
22
  import { revokeCmd } from './commands/revoke.ts';
@@ -43,9 +45,11 @@ const commands: Record<string, (args: string[]) => Promise<number>> = {
43
45
  doctor: doctorCmd,
44
46
  'service-token': serviceTokenCmd,
45
47
  audit: auditCmd,
48
+ dr: drCmd,
46
49
  users: usersCmd,
47
50
  role: roleCmd,
48
51
  project: projectCmd,
52
+ org: orgCmd,
49
53
  'migrate-envelopes': migrateEnvelopesCmd,
50
54
  'rotate-master': rotateMasterCmd,
51
55
  'new-phrase': newPhraseCmd,
@@ -85,6 +89,7 @@ Usage:
85
89
  athsra users {list|invite <id> [--role=R]} RBAC: user 목록 / 신규 invite (admin role 필요)
86
90
  athsra role {grant|revoke} <user_id> <R> role 부여/제거 (admin/dev/viewer/auditor/sa)
87
91
  athsra project {share|unshare} <p> <uid> per-project ACL share/unshare (--perms=read|write)
92
+ athsra org {create|ls|members|invite|remove|use|leave} 멀티테넌시 org 관리 + 컨텍스트 전환
88
93
  athsra migrate-envelopes [--apply] 모든 v1 envelope → v2 일괄 마이그레이션 (idempotent)
89
94
 
90
95
  athsra versions <project> 모든 version + tombstone 상태
@@ -93,6 +98,8 @@ Usage:
93
98
  athsra restore <project> tombstone 제거 + 최신 version 활성화
94
99
  athsra purge <project> hard-delete (delete --hard 별칭, double-confirm)
95
100
 
101
+ athsra dr {restore-r2|restore-d1} DR 복원 (backup→STORE / 암호화 D1 dump). dry-run 기본, --execute --confirm
102
+
96
103
  athsra rotate-master master pw 변경 (모든 projects re-encrypt)
97
104
  athsra new-phrase BIP-39 12-word phrase 생성 (master pw 권장)
98
105
  athsra handoff [--accept] 새 머신 추가 (issue / accept)
@@ -1,6 +1,7 @@
1
+ import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
1
2
  import { AthsraClient } from './client.ts';
2
3
  import { type Config, loadConfig } from './config.ts';
3
- import { getMasterPw, getToken } from './keyring.ts';
4
+ import { getDeviceToken, getMasterPw, getToken, setToken } from './keyring.ts';
4
5
 
5
6
  /** User token mode — keyring 의 master pw + Bearer token 보유, full access. */
6
7
  export interface UserAuthContext {
@@ -41,42 +42,94 @@ export type AuthContext = UserAuthContext | ServiceAuthContext;
41
42
  *
42
43
  * 실패 시 stderr 에 안내 + null 반환. 호출자는 `return 1` 만.
43
44
  */
44
- export async function loadAuthContext(): Promise<AuthContext | null> {
45
+ export async function loadAuthContext(project?: string): Promise<AuthContext | null> {
46
+ // 1. service token env (CI/headless 명시) — 최우선
45
47
  const envToken = process.env.ATHSRA_TOKEN;
46
48
  if (envToken?.startsWith('ats_')) {
47
49
  return loadServiceContext(envToken);
48
50
  }
49
- return loadUserContext();
51
+ // 2. user context (master pw + token, full access) — 보유 시 우선
52
+ const userCtx = loadUserContext();
53
+ if (userCtx) return userCtx;
54
+ // 3. project-scoped device token (device-login agent) — master pw 없는 머신
55
+ // Phase 3 P3: `athsra login --device --project <p>` 가 keyring 에 저장한 ats_*.
56
+ if (project) {
57
+ const deviceToken = getDeviceToken(project);
58
+ if (deviceToken?.startsWith('ats_')) {
59
+ const svc = await loadServiceContext(deviceToken, { silent: true });
60
+ if (svc) return svc;
61
+ console.error(
62
+ `device token for "${project}" 무효 (만료/revoke). ` +
63
+ `\`athsra login --device --project ${project}\` 로 재발급 (또는 \`athsra login\`).`,
64
+ );
65
+ return null;
66
+ }
67
+ }
68
+ // 4. 아무 자격증명 없음 — 안내
69
+ console.error(
70
+ project
71
+ ? `Not authenticated for "${project}". \`athsra login\` (full) 또는 ` +
72
+ `\`athsra login --device --project ${project}\` (agent, master pw 불필요).`
73
+ : 'Not logged in. Run `athsra login` first.',
74
+ );
75
+ return null;
50
76
  }
51
77
 
78
+ /** master pw + token 보유 시 user context. 미보유 시 조용히 null (호출자가 device/안내 처리). */
52
79
  function loadUserContext(): UserAuthContext | null {
53
80
  const config = loadConfig();
54
- if (!config) {
55
- console.error('Not logged in. Run `athsra login` first.');
56
- return null;
57
- }
81
+ if (!config) return null;
58
82
  const masterPw = getMasterPw(config.machineId);
59
83
  const token = getToken(config.machineId);
60
- if (!masterPw || !token) {
61
- console.error('keyring missing master pw / token. Run `athsra login` first.');
62
- return null;
63
- }
84
+ if (!masterPw || !token) return null;
85
+ const client = new AthsraClient(config.workerUrl, token);
86
+ // Phase 3 — idle timeout 자동 재인증. 401 session_idle_timeout 시 keyring master pw
87
+ // 로 silent re-register (proof = Argon2id(pw + GLOBAL_SALT)) → 새 token keyring 저장.
88
+ // 기기 잠금 (OS keyring 차단) 이 방어선이므로 작업 중엔 끊김 없이, 잠금 시엔 idle 유효.
89
+ client.setIdleRefresh(async () => {
90
+ try {
91
+ const info = await client.info();
92
+ const proof = toBase64(deriveKey(masterPw, fromBase64(info.global_salt)));
93
+ const reg = await client.register(proof, config.machineId);
94
+ let token = reg.token;
95
+ // Phase 4 Slice 6a — re-register 는 자기 personal org 로 token 발급. switch 상태였으면
96
+ // (config.orgId) 다시 그 org 로 전환 — 안 하면 idle 후 사용자 모르게 personal 컨텍스트로 reset.
97
+ if (config.orgId !== undefined) {
98
+ client.setToken(token);
99
+ try {
100
+ token = (await client.switchOrg(config.orgId)).token;
101
+ } catch {
102
+ // switch 실패 (멤버십 변경/org 삭제) — personal org token 유지 (다음 `org use` 로 복구).
103
+ }
104
+ }
105
+ setToken(config.machineId, token);
106
+ return token;
107
+ } catch {
108
+ return null; // refresh 실패 — 원 401 그대로 (athsra login 안내)
109
+ }
110
+ });
64
111
  return {
65
112
  kind: 'user',
66
113
  config,
67
114
  masterPw,
68
115
  token,
69
- client: new AthsraClient(config.workerUrl, token),
116
+ client,
70
117
  };
71
118
  }
72
119
 
73
- async function loadServiceContext(token: string): Promise<ServiceAuthContext | null> {
120
+ async function loadServiceContext(
121
+ token: string,
122
+ opts?: { silent?: boolean },
123
+ ): Promise<ServiceAuthContext | null> {
124
+ const silent = opts?.silent ?? false;
74
125
  const config = loadConfig(); // optional in service mode
75
126
  const workerUrl = process.env.ATHSRA_WORKER_URL ?? config?.workerUrl;
76
127
  if (!workerUrl) {
77
- console.error(
78
- 'service token 모드인데 worker URL 모름. `ATHSRA_WORKER_URL=https://...` 환경변수 또는 `~/.athsra/config.json` 필요.',
79
- );
128
+ if (!silent) {
129
+ console.error(
130
+ 'service token 모드인데 worker URL 모름. `ATHSRA_WORKER_URL=https://...` 환경변수 또는 `~/.athsra/config.json` 필요.',
131
+ );
132
+ }
80
133
  return null;
81
134
  }
82
135
  const client = new AthsraClient(workerUrl, token);
@@ -84,7 +137,9 @@ async function loadServiceContext(token: string): Promise<ServiceAuthContext | n
84
137
  try {
85
138
  me = await client.whoami();
86
139
  } catch (err) {
87
- console.error(`service token 검증 실패 (worker /auth/whoami): ${(err as Error).message}`);
140
+ if (!silent) {
141
+ console.error(`service token 검증 실패 (worker /auth/whoami): ${(err as Error).message}`);
142
+ }
88
143
  return null;
89
144
  }
90
145
  if (
@@ -93,7 +148,11 @@ async function loadServiceContext(token: string): Promise<ServiceAuthContext | n
93
148
  (me.scopePerms !== 'read' && me.scopePerms !== 'write') ||
94
149
  !me.recipientId
95
150
  ) {
96
- console.error('whoami 응답에 service token scope 정보 없음 — worker 갱신 필요 (Phase 1.x.8+).');
151
+ if (!silent) {
152
+ console.error(
153
+ 'whoami 응답에 service token scope 정보 없음 — worker 갱신 필요 (Phase 1.x.8+).',
154
+ );
155
+ }
97
156
  return null;
98
157
  }
99
158
  return {