@athsra/cli 1.1.0 → 1.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@athsra/cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "athsra CLI — E2EE secret manager on Cloudflare edge. Doppler-style dev UX + zero-knowledge encryption + soft-delete + version history. MIT.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import type { UserAuthContext } from '../lib/auth-context.ts';
3
3
  import { AthsraClient } from '../lib/client.ts';
4
- import { CONFIG_FILE, loadConfig, SESSION_FILE } from '../lib/config.ts';
4
+ import { configFile, loadConfig, sessionFile } from '../lib/config.ts';
5
5
  import { partitionEnv } from '../lib/env-format.ts';
6
6
  import { readPlain } from '../lib/envelope.ts';
7
7
  import { getMasterPw, getToken, probeKeyring } from '../lib/keyring.ts';
@@ -23,7 +23,8 @@ export async function doctorCmd(args: string[]): Promise<number> {
23
23
  );
24
24
 
25
25
  const config = loadConfig();
26
- console.log(` config: ${existsSync(CONFIG_FILE) ? '✓' : '✗'} ${CONFIG_FILE}`);
26
+ const cfgFile = configFile();
27
+ console.log(` config: ${existsSync(cfgFile) ? '✓' : '✗'} ${cfgFile}`);
27
28
  if (!config) {
28
29
  console.log(' (run `athsra login` first)');
29
30
  return kr.ok ? 0 : 1;
@@ -32,8 +33,9 @@ export async function doctorCmd(args: string[]): Promise<number> {
32
33
  console.log(` machine: ${config.machineId}`);
33
34
  console.log(` created: ${config.createdAt}`);
34
35
 
35
- if (existsSync(SESSION_FILE)) {
36
- console.log(` legacy: ${SESSION_FILE} 잔존 (next \`athsra login\` 시 자동 제거).`);
36
+ const sessFile = sessionFile();
37
+ if (existsSync(sessFile)) {
38
+ console.log(` ⚠ legacy: ${sessFile} 잔존 (next \`athsra login\` 시 자동 제거).`);
37
39
  }
38
40
 
39
41
  const masterPw = kr.ok ? getMasterPw(config.machineId) : null;
@@ -1,6 +1,7 @@
1
1
  import { hostname } from 'node:os';
2
2
  import { isValidPhrase, normalizePhrase, wordCount } from '@athsra/crypto';
3
- import { deriveMasterProof } from '../lib/auth-proof.ts';
3
+ import type { UserAuthContext } from '../lib/auth-context.ts';
4
+ import { deriveLegacyMasterProofs, deriveMasterProof } from '../lib/auth-proof.ts';
4
5
  import { resolveProject } from '../lib/auto-project.ts';
5
6
  import { AthsraClient } from '../lib/client.ts';
6
7
  import { type Config, loadConfig, saveConfig } from '../lib/config.ts';
@@ -11,6 +12,7 @@ import {
11
12
  runDevicePollLoop,
12
13
  startIdentityFlow,
13
14
  } from '../lib/device-login.ts';
15
+ import { readPlain } from '../lib/envelope.ts';
14
16
  import { ensureKeypair } from '../lib/identity-key.ts';
15
17
  import { probeKeyring, setDeviceToken, setMasterPw, setToken } from '../lib/keyring.ts';
16
18
  import { consumeLegacySession } from '../lib/legacy-session.ts';
@@ -135,10 +137,14 @@ async function ssoLoginCmd(): Promise<number> {
135
137
  // G-1: per-user salt 로 같은 pw 두 user 도 proof 가 유일. 첫 SSO = bootstrap, 재로그인 = 검증.
136
138
  const info = await tempClient.info();
137
139
  const proof = deriveMasterProof(masterPw, ssoBody.user_id, info.global_salt);
140
+ // 버전 업그레이드 무중단: 저장 proof 가 옛 스킴(gen-0)이면 worker 가 이 후보로 검증 후 G-1 자동
141
+ // 재작성. legacy proof 는 per-user salt 없는 gen-0 이라 userId 불필요 — 그대로 사용.
142
+ const legacyProofs = deriveLegacyMasterProofs(masterPw, info.global_salt);
138
143
  try {
139
144
  const pr = await new AthsraClient(workerUrl, ssoBody.token).setProof(
140
145
  proof,
141
146
  info.global_salt_version,
147
+ legacyProofs,
142
148
  );
143
149
  if (pr.userId !== ssoBody.user_id) {
144
150
  console.error(`✗ proof user mismatch (${pr.userId} ≠ ${ssoBody.user_id}) — 재로그인 필요.`);
@@ -146,6 +152,10 @@ async function ssoLoginCmd(): Promise<number> {
146
152
  }
147
153
  if (pr.version_reset) {
148
154
  console.log('• Master password re-registered (GLOBAL_SALT changed).');
155
+ } else if (pr.migrated) {
156
+ console.log(
157
+ '• Master password 옛 스킴 → 현 스킴 자동 마이그레이션 ✓ (버전 업그레이드 무중단).',
158
+ );
149
159
  } else if (pr.bootstrap) {
150
160
  console.log('• Master password set for this account ✓ (worker stores one-way proof only).');
151
161
  } else {
@@ -551,11 +561,13 @@ async function passwordLoginCmd(): Promise<number> {
551
561
  // 다중 사용자 self-serve password register 는 out-of-scope(SSO-gated 유지).
552
562
  const FOUNDING_USER_ID = 1;
553
563
  const proofBase64 = deriveMasterProof(masterPw, FOUNDING_USER_ID, info.global_salt);
564
+ // 버전 업그레이드 무중단: 저장 proof 가 옛 스킴(≤1.0.2)이면 worker 가 이 후보로 검증 후 G-1 자동 재작성.
565
+ const legacyProofs = deriveLegacyMasterProofs(masterPw, info.global_salt);
554
566
 
555
567
  // 8. register → token
556
568
  let reg: Awaited<ReturnType<typeof tempClient.register>>;
557
569
  try {
558
- reg = await tempClient.register(proofBase64, machineId);
570
+ reg = await tempClient.register(proofBase64, machineId, legacyProofs);
559
571
  if (reg.userId !== undefined && reg.userId !== FOUNDING_USER_ID) {
560
572
  console.error(
561
573
  `✗ register user mismatch (${reg.userId} ≠ ${FOUNDING_USER_ID}) — worker 확인 필요.`,
@@ -583,12 +595,50 @@ async function passwordLoginCmd(): Promise<number> {
583
595
  saveConfig(config);
584
596
  setMasterPw(machineId, masterPw);
585
597
  setToken(machineId, reg.token);
586
- await provisionIdentityKey(new AthsraClient(workerUrl, reg.token), masterPw);
598
+ const userClient = new AthsraClient(workerUrl, reg.token);
599
+ await provisionIdentityKey(userClient, masterPw);
600
+
601
+ // bootstrap typo-guard — worker 는 proof 만 저장(평문 pw·키 모름)하므로 오타 pw 도 bootstrap 된다.
602
+ // 기존 envelope 가 있으면 실제 복호를 시도해, 잘못된 pw 로 인한 "조용한 lockout" 을 즉시 경고로 전환.
603
+ // (proof 검증 경로[verify/migrate]는 pw 가 이미 맞으므로 bootstrap 일 때만 검사 = 로그인 지연 최소.)
604
+ if (reg.bootstrap) {
605
+ try {
606
+ const projects = await userClient.listProjects();
607
+ const probe = projects[0];
608
+ if (probe) {
609
+ const probeCtx: UserAuthContext = {
610
+ kind: 'user',
611
+ config,
612
+ masterPw,
613
+ token: reg.token,
614
+ client: userClient,
615
+ };
616
+ await readPlain(probeCtx, probe); // 잘못된 pw 면 decrypt 에서 throw
617
+ }
618
+ } catch (err) {
619
+ const msg = (err as Error).message;
620
+ if (msg.includes('decrypt') || msg.includes('auth tag')) {
621
+ console.error(
622
+ '\n⚠ 경고: 방금 설정한 master password 가 기존 secret 을 복호화하지 못합니다 (오타 가능성 높음).',
623
+ );
624
+ console.error(
625
+ ' secret 데이터는 안전합니다 — 올바른 password 로 `athsra login --password` 를 다시 실행하세요.',
626
+ );
627
+ console.error(
628
+ ' (worker 는 proof 만 보관 → password 자체는 검증 불가. 이 복호 검사가 유일한 typo 방어선입니다.)',
629
+ );
630
+ }
631
+ // 그 외(네트워크 등)는 무시 — 로그인 자체는 성공.
632
+ }
633
+ }
587
634
 
588
635
  console.log(`\n✓ logged in (machine: ${machineId})`);
589
636
  console.log(` worker: ${workerUrl}`);
590
637
  console.log(` config: ~/.athsra/config.json`);
591
638
  console.log(' keyring: master-pw + token saved (OS keyring, 무기한)');
639
+ if (reg.migrated) {
640
+ console.log(' proof: 옛 스킴 → 현 스킴 자동 마이그레이션 ✓ (CLI 버전 정합, 무중단)');
641
+ }
592
642
  if (legacy) {
593
643
  console.log(' legacy: ~/.athsra/session migrated and removed.');
594
644
  }
@@ -0,0 +1,56 @@
1
+ import { loadAuthContext } from '../lib/auth-context.ts';
2
+ import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
3
+
4
+ const USAGE = [
5
+ 'usage: athsra recipients <project>[:<env>]',
6
+ '',
7
+ 'envelope 의 recipient 목록(읽기 전용) — master / member / service token 분포 감사.',
8
+ '값·평문 미노출 (recipient kind·id 만 표시). service recipient_id 는 비밀 아님',
9
+ '(whoami / service-token list 가 이미 노출).',
10
+ '',
11
+ CONFIG_USAGE_HINT,
12
+ ].join('\n');
13
+
14
+ /**
15
+ * 인시던트 후속(2026-06-14) — envelope recipient 구조 운영 가시성. founding sweep / rotate-master /
16
+ * 멤버 removal 후 service:* recipient 가 보존/탈락됐는지 `athsra service-token list` 와 대조 감사.
17
+ * getEnvelope 가 이미 recipients[] 를 주므로 복호 불필요 → 모든 인증 모드(user/identity/service)에서 동작.
18
+ */
19
+ export async function recipientsCmd(args: string[]): Promise<number> {
20
+ if (args.includes('--help') || args.includes('-h')) {
21
+ console.log(USAGE);
22
+ return 0;
23
+ }
24
+ const { project, config } = resolveProject(args, { requirePositional: true });
25
+ if (!project) {
26
+ console.error(USAGE);
27
+ return 2;
28
+ }
29
+
30
+ const ctx = await loadAuthContext(project);
31
+ if (!ctx) return 1;
32
+
33
+ const env = await ctx.client.getEnvelope(project, config);
34
+ if (!env) {
35
+ console.error(`project not found: ${project}${configTag(config)}`);
36
+ return 1;
37
+ }
38
+
39
+ const tag = configTag(config);
40
+ if (env.version !== 2) {
41
+ console.log(
42
+ `${project}${tag}: v1 envelope — single master recipient (run \`athsra rotate-master\` → v2)`,
43
+ );
44
+ return 0;
45
+ }
46
+
47
+ console.log(`${project}${tag} — ${env.recipients.length} recipient(s):`);
48
+ for (const r of env.recipients) {
49
+ console.log(` ${r.kind.padEnd(8)} ${r.id}`);
50
+ }
51
+ const master = env.recipients.filter((r) => r.kind === 'master').length;
52
+ const service = env.recipients.filter((r) => r.kind === 'service').length;
53
+ const member = env.recipients.filter((r) => r.kind === 'member').length;
54
+ console.log(`\n요약: master ${master} · service ${service} · member ${member}`);
55
+ return 0;
56
+ }
package/src/index.ts CHANGED
@@ -18,6 +18,7 @@ import { migrateEnvelopesCmd } from './commands/migrate-envelopes.ts';
18
18
  import { newPhraseCmd } from './commands/new-phrase.ts';
19
19
  import { orgCmd } from './commands/org.ts';
20
20
  import { purgeCmd } from './commands/purge.ts';
21
+ import { recipientsCmd } from './commands/recipients.ts';
21
22
  import { restoreCmd } from './commands/restore.ts';
22
23
  import { revokeCmd } from './commands/revoke.ts';
23
24
  import { rollbackCmd } from './commands/rollback.ts';
@@ -28,7 +29,7 @@ import { setCmd } from './commands/set.ts';
28
29
  import { unsetCmd } from './commands/unset.ts';
29
30
  import { versionsCmd } from './commands/versions.ts';
30
31
 
31
- const VERSION = '1.1.0';
32
+ const VERSION = '1.1.2';
32
33
 
33
34
  const commands: Record<string, (args: string[]) => Promise<number>> = {
34
35
  login: loginCmd,
@@ -45,6 +46,7 @@ const commands: Record<string, (args: string[]) => Promise<number>> = {
45
46
  doctor: doctorCmd,
46
47
  'service-token': serviceTokenCmd,
47
48
  audit: auditCmd,
49
+ recipients: recipientsCmd,
48
50
  dr: drCmd,
49
51
  users: usersCmd,
50
52
  role: roleCmd,
@@ -86,6 +88,7 @@ Usage:
86
88
  athsra service-token create <p> --label=<l> scoped service token 발급 (master pw 없이도 복호, headless 용)
87
89
  athsra service-token list [<project>] 발급한 service token 메타 (revoke·만료 점검용)
88
90
  athsra service-token revoke <token> service token 무효화
91
+ athsra recipients <project>[:<env>] envelope recipient 목록 감사 (master/member/service, 읽기 전용)
89
92
  athsra audit [--actor=...] [--action=...] audit log 조회 (--all=cursor follow, --format=table|json|jsonl)
90
93
  athsra users {list|invite <id> [--role=R]} RBAC: user 목록 / 신규 invite (admin role 필요)
91
94
  athsra role {grant|revoke} <user_id> <R> role 부여/제거 (admin/dev/viewer/auditor/sa)
@@ -1,5 +1,5 @@
1
1
  import { fromBase64 } from '@athsra/crypto';
2
- import { deriveMasterProof } from './auth-proof.ts';
2
+ import { deriveLegacyMasterProofs, deriveMasterProof } from './auth-proof.ts';
3
3
  import { AthsraClient } from './client.ts';
4
4
  import { type Config, loadConfig, saveConfig } from './config.ts';
5
5
  import { getDeviceToken, getIdentityKey, getMasterPw, getToken, setToken } from './keyring.ts';
@@ -125,7 +125,9 @@ async function loadUserContext(): Promise<UserAuthContext | null> {
125
125
  if (config.userId !== 1) return null;
126
126
  const info = await client.info();
127
127
  const proof = deriveMasterProof(masterPw, config.userId, info.global_salt);
128
- const reg = await client.register(proof, config.machineId);
128
+ // 버전 업그레이드 무중단: 저장 proof 가 옛 스킴이면 worker 가 자동 G-1 마이그레이션.
129
+ const legacyProofs = deriveLegacyMasterProofs(masterPw, info.global_salt);
130
+ const reg = await client.register(proof, config.machineId, legacyProofs);
129
131
  if (reg.userId !== undefined && reg.userId !== config.userId) return null;
130
132
  let token = reg.token;
131
133
  // Phase 4 Slice 6a — re-register 는 자기 personal org 로 token 발급. switch 상태였으면
@@ -1,4 +1,4 @@
1
- import { deriveProof, fromBase64, toBase64 } from '@athsra/crypto';
1
+ import { deriveKey, deriveProof, fromBase64, toBase64 } from '@athsra/crypto';
2
2
 
3
3
  /** G-1 auth proof — per-user salt + GLOBAL_SALT pepper, base64 wire format. */
4
4
  export function deriveMasterProof(
@@ -8,3 +8,19 @@ export function deriveMasterProof(
8
8
  ): string {
9
9
  return toBase64(deriveProof(masterPw, userId, fromBase64(globalSaltBase64)));
10
10
  }
11
+
12
+ /**
13
+ * 레거시 proof (pre-G-1, ≤1.0.2): `deriveKey(masterPw, GLOBAL_SALT)` — userId/도메인 prefix 없음.
14
+ *
15
+ * **버전 업그레이드 무중단 마이그레이션 전용.** 현 스킴(G-1) proof 가 mismatch 일 때 CLI 가 이 레거시
16
+ * proof 를 함께 보내고, worker 가 그것으로 검증 성공 시 저장 proof 를 G-1 으로 자동 재작성한다 →
17
+ * CLI 버전이 바뀌어도(옛 스킴 stored ↔ 새 스킴 client) 로그인이 깨지지 않는다. 정공법: 스킴 전환을
18
+ * 게이트가 아니라 자동 마이그레이션으로 흡수. 신규 스킴 추가 시 이 배열에 한 줄만 더하면 된다.
19
+ */
20
+ export function deriveLegacyMasterProofs(masterPw: string, globalSaltBase64: string): string[] {
21
+ const salt = fromBase64(globalSaltBase64);
22
+ return [
23
+ // gen-0 (≤1.0.2): Argon2id(masterPw, GLOBAL_SALT) — per-user salt 도입 전.
24
+ toBase64(deriveKey(masterPw, salt)),
25
+ ];
26
+ }
package/src/lib/client.ts CHANGED
@@ -18,6 +18,10 @@ export interface RegisterResponse {
18
18
  machineId: string;
19
19
  createdAt: string;
20
20
  userId?: number;
21
+ /** 첫 proof 설정(검증 불가) — typo-guard 대상. */
22
+ bootstrap?: boolean;
23
+ /** 옛 스킴 proof → 현 스킴 자동 마이그레이션됨 (버전 업그레이드 무중단). */
24
+ migrated?: boolean;
21
25
  }
22
26
 
23
27
  /** Phase 4 Slice 3 — auth_user_keys row (GET/POST /auth/keys). worker 는 base64 형식만 저장. */
@@ -235,11 +239,23 @@ export class AthsraClient {
235
239
  return (await res.json()) as WorkerInfo;
236
240
  }
237
241
 
238
- async register(masterPwProof: string, label: string): Promise<RegisterResponse> {
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> {
239
251
  const res = await fetch(this.url('/auth/register'), {
240
252
  method: 'POST',
241
253
  headers: { 'content-type': 'application/json' },
242
- body: JSON.stringify({ master_pw_proof: masterPwProof, label }),
254
+ body: JSON.stringify({
255
+ master_pw_proof: masterPwProof,
256
+ label,
257
+ ...(legacyProofs && legacyProofs.length > 0 ? { legacy_proofs: legacyProofs } : {}),
258
+ }),
243
259
  });
244
260
  if (!res.ok) throw new Error(`register ${res.status}: ${await res.text()}`);
245
261
  return (await res.json()) as RegisterResponse;
@@ -255,15 +271,30 @@ export class AthsraClient {
255
271
  * Phase 3a — 자기 master pw proof bootstrap-or-verify (POST /auth/proof, Bearer).
256
272
  * proof = deriveProof(masterPw, userId, GLOBAL_SALT) 단방향 해시 (평문 master pw 송신 X).
257
273
  * 첫 호출 = bootstrap, 이후 = verify (불일치 시 worker 409 → throw).
274
+ *
275
+ * @param legacyProofs - 옛 스킴(gen-0) proof 후보 (버전 업그레이드 무중단 마이그레이션). 현 proof
276
+ * 가 mismatch 일 때 worker 가 이 중 하나로 검증 성공 시 자기 row 를 현 스킴(G-1)으로 자동 재작성
277
+ * (migrated:true). register 의 legacy_proofs 와 동일 의미론.
258
278
  */
259
279
  async setProof(
260
280
  proof: string,
261
281
  globalSaltVersion?: string,
262
- ): Promise<{ ok: boolean; bootstrap: boolean; userId: number; version_reset?: boolean }> {
282
+ legacyProofs?: string[],
283
+ ): Promise<{
284
+ ok: boolean;
285
+ bootstrap: boolean;
286
+ userId: number;
287
+ version_reset?: boolean;
288
+ migrated?: boolean;
289
+ }> {
263
290
  const res = await fetch(this.url('/auth/proof'), {
264
291
  method: 'POST',
265
292
  headers: this.headers({ 'content-type': 'application/json' }),
266
- body: JSON.stringify({ proof, global_salt_version: globalSaltVersion }),
293
+ body: JSON.stringify({
294
+ proof,
295
+ global_salt_version: globalSaltVersion,
296
+ ...(legacyProofs && legacyProofs.length > 0 ? { legacy_proofs: legacyProofs } : {}),
297
+ }),
267
298
  });
268
299
  if (!res.ok) throw new Error(`set proof ${res.status}: ${await res.text()}`);
269
300
  return (await res.json()) as {
@@ -271,6 +302,7 @@ export class AthsraClient {
271
302
  bootstrap: boolean;
272
303
  userId: number;
273
304
  version_reset?: boolean;
305
+ migrated?: boolean;
274
306
  };
275
307
  }
276
308
 
package/src/lib/config.ts CHANGED
@@ -2,9 +2,20 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
 
5
- export const CONFIG_DIR = join(homedir(), '.athsra');
6
- export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
- export const SESSION_FILE = join(CONFIG_DIR, 'session');
5
+ /**
6
+ * config 디렉터리 — 호출 시점 해석. `ATHSRA_CONFIG_DIR` 로 override (테스트 격리 / 개발자 개인
7
+ * 분리). 미설정 = `~/.athsra`. 경로를 모듈 로드 시점 상수가 아닌 함수로 둬 env override 가
8
+ * 호출마다 반영된다(테스트가 tmp 디렉터리로 안전히 격리 가능 — 실 config 오염 차단).
9
+ */
10
+ export function configDir(): string {
11
+ return process.env.ATHSRA_CONFIG_DIR ?? join(homedir(), '.athsra');
12
+ }
13
+ export function configFile(): string {
14
+ return join(configDir(), 'config.json');
15
+ }
16
+ export function sessionFile(): string {
17
+ return join(configDir(), 'session');
18
+ }
8
19
 
9
20
  export interface Config {
10
21
  workerUrl: string;
@@ -21,17 +32,19 @@ export interface Config {
21
32
  }
22
33
 
23
34
  export function ensureConfigDir(): void {
24
- if (!existsSync(CONFIG_DIR)) {
25
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
35
+ const dir = configDir();
36
+ if (!existsSync(dir)) {
37
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
26
38
  }
27
39
  }
28
40
 
29
41
  export function loadConfig(): Config | null {
30
- if (!existsSync(CONFIG_FILE)) return null;
31
- return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) as Config;
42
+ const file = configFile();
43
+ if (!existsSync(file)) return null;
44
+ return JSON.parse(readFileSync(file, 'utf-8')) as Config;
32
45
  }
33
46
 
34
47
  export function saveConfig(config: Config): void {
35
48
  ensureConfigDir();
36
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
49
+ writeFileSync(configFile(), JSON.stringify(config, null, 2), { mode: 0o600 });
37
50
  }
@@ -13,7 +13,7 @@ import { deviceKeyFingerprint, generateDeviceKeypair } from '@athsra/crypto/devi
13
13
  import { AthsraClient, type DevicePollResult } from './client.ts';
14
14
  import { loadConfig, saveConfig } from './config.ts';
15
15
  import { unsealIdentityKey } from './identity-key.ts';
16
- import { probeKeyring, setIdentityKey, setToken } from './keyring.ts';
16
+ import { clearMasterPw, probeKeyring, setIdentityKey, setToken } from './keyring.ts';
17
17
 
18
18
  /** 비-인터랙티브 agent 기본 worker (config/env 없을 때). production athsra worker. */
19
19
  export const DEFAULT_WORKER_URL = 'https://athsra-worker.winterermod.workers.dev';
@@ -147,6 +147,10 @@ export async function completeIdentityLogin(args: {
147
147
  );
148
148
  setIdentityKey(args.machineId, identityPrivB64);
149
149
  setToken(args.machineId, args.result.token);
150
+ // identity 모드는 정의상 이 기기에 master pw 없음 (D-2). 과거 password/SSO 로그인이 남긴 stale
151
+ // master-pw 를 제거 — 안 그러면 loadAuthContext 가 user-mode(캐시 master-pw)를 identity-mode
152
+ // 보다 먼저 반환해 identity 의도를 가린다(우선순위 버그 근본 차단). clearMasterPw 는 멱등.
153
+ clearMasterPw(args.machineId);
150
154
  saveConfig({
151
155
  workerUrl: args.workerUrl,
152
156
  machineId: args.machineId,
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync, unlinkSync } from 'node:fs';
2
- import { SESSION_FILE } from './config.ts';
2
+ import { sessionFile } from './config.ts';
3
3
 
4
4
  interface LegacySession {
5
5
  masterPw: string;
@@ -14,10 +14,11 @@ interface LegacySession {
14
14
  * 기존 사용자 1회 migration 후 영구 deprecated.
15
15
  */
16
16
  export function consumeLegacySession(): { masterPw: string } | null {
17
- if (!existsSync(SESSION_FILE)) return null;
17
+ const file = sessionFile();
18
+ if (!existsSync(file)) return null;
18
19
  let parsed: LegacySession | null = null;
19
20
  try {
20
- const raw = readFileSync(SESSION_FILE, 'utf-8');
21
+ const raw = readFileSync(file, 'utf-8');
21
22
  if (raw) {
22
23
  const json = JSON.parse(raw) as LegacySession;
23
24
  if (typeof json.masterPw === 'string' && json.masterPw.length > 0) {
@@ -28,7 +29,7 @@ export function consumeLegacySession(): { masterPw: string } | null {
28
29
  /* fall through to delete */
29
30
  }
30
31
  try {
31
- unlinkSync(SESSION_FILE);
32
+ unlinkSync(file);
32
33
  } catch {
33
34
  /* ignore */
34
35
  }