@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.
@@ -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 {