@athsra/cli 1.0.4 → 1.1.0

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.
Files changed (45) hide show
  1. package/README.md +34 -10
  2. package/package.json +3 -3
  3. package/src/commands/delete.ts +16 -13
  4. package/src/commands/get.ts +8 -5
  5. package/src/commands/handoff.ts +13 -3
  6. package/src/commands/login.ts +164 -59
  7. package/src/commands/logout.ts +3 -2
  8. package/src/commands/ls.ts +32 -10
  9. package/src/commands/manifest.ts +2 -2
  10. package/src/commands/mcp.ts +259 -7
  11. package/src/commands/migrate-envelopes.ts +55 -3
  12. package/src/commands/purge.ts +13 -10
  13. package/src/commands/restore.ts +10 -6
  14. package/src/commands/rollback.ts +12 -9
  15. package/src/commands/rotate-master.ts +13 -13
  16. package/src/commands/run.ts +6 -24
  17. package/src/commands/service-token.ts +15 -31
  18. package/src/commands/set.ts +7 -6
  19. package/src/commands/unset.ts +11 -8
  20. package/src/commands/versions.ts +7 -5
  21. package/src/index.ts +12 -8
  22. package/src/lib/auth-context.ts +74 -12
  23. package/src/lib/auth-proof.ts +10 -0
  24. package/src/lib/auto-project.ts +58 -14
  25. package/src/lib/client.ts +94 -17
  26. package/src/lib/config.ts +2 -0
  27. package/src/lib/device-login.ts +157 -0
  28. package/src/lib/env-format.ts +1 -1
  29. package/src/lib/envelope.ts +105 -15
  30. package/src/lib/identity-key.ts +21 -0
  31. package/src/lib/keyring.ts +25 -0
  32. package/src/lib/mcp-register.ts +223 -0
  33. package/src/lib/mcp-tools/admin.ts +267 -0
  34. package/src/lib/mcp-tools/args.ts +26 -0
  35. package/src/lib/mcp-tools/confirm.ts +21 -0
  36. package/src/lib/mcp-tools/defs.ts +388 -3
  37. package/src/lib/mcp-tools/login.ts +156 -0
  38. package/src/lib/mcp-tools/mask.ts +41 -0
  39. package/src/lib/mcp-tools/read.ts +115 -1
  40. package/src/lib/mcp-tools/result.ts +5 -5
  41. package/src/lib/mcp-tools/run.ts +101 -0
  42. package/src/lib/mcp-tools/write.ts +84 -5
  43. package/src/lib/oidc-flow.ts +43 -1
  44. package/src/lib/org-rewrap.ts +9 -3
  45. package/src/lib/service-tokens.ts +62 -0
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { loadAuthContext } from '../lib/auth-context.ts';
3
- import { resolveProject } from '../lib/auto-project.ts';
4
- import { partitionEnv } from '../lib/env-format.ts';
3
+ import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
4
+ import { buildChildEnv } from '../lib/env-format.ts';
5
5
  import { readPlain } from '../lib/envelope.ts';
6
6
 
7
7
  const USAGE = [
@@ -9,6 +9,7 @@ const USAGE = [
9
9
  '',
10
10
  '<project> 자동 감지 (cwd 기반): basename(cwd) > .athsra > package.json athsra.project',
11
11
  '23 sibling repo 안에서: cd ~/code/<repo> && athsra run -- bun run dev',
12
+ CONFIG_USAGE_HINT,
12
13
  ].join('\n');
13
14
 
14
15
  export async function runCmd(args: string[]): Promise<number> {
@@ -19,7 +20,7 @@ export async function runCmd(args: string[]): Promise<number> {
19
20
  }
20
21
  // -- 앞 영역에서 project 추출 (auto-detect 가능). -- 뒤는 cmd.
21
22
  const beforeSep = args.slice(0, sepIdx);
22
- const { project } = resolveProject(beforeSep);
23
+ const { project, config } = resolveProject(beforeSep);
23
24
  if (!project) {
24
25
  console.error(USAGE);
25
26
  return 2;
@@ -34,9 +35,9 @@ export async function runCmd(args: string[]): Promise<number> {
34
35
  const ctx = await loadAuthContext(project);
35
36
  if (!ctx) return 1;
36
37
 
37
- const plain = await readPlain(ctx, project);
38
+ const plain = await readPlain(ctx, project, config);
38
39
  if (!plain) {
39
- console.error(`project not found: ${project}`);
40
+ console.error(`project not found: ${project}${configTag(config)}`);
40
41
  return 1;
41
42
  }
42
43
 
@@ -64,22 +65,3 @@ export async function runCmd(args: string[]): Promise<number> {
64
65
  });
65
66
  });
66
67
  }
67
-
68
- /**
69
- * 자식 프로세스 env 를 만든다. 빈 값 secret 키는 inject 대상에서 제외해
70
- * 부모 환경 변수를 보존한다.
71
- *
72
- * 키 이름만 등록되고 값이 빈 secret (migration scaffolding 산출물) 을
73
- * `{ ...process.env, ...plain }` 로 합치면 부모 env 의 유효한 값 (예:
74
- * ~/.zshrc 의 CLOUDFLARE_API_TOKEN) 이 빈 문자열로 덮어써져 배포 인증이
75
- * 깨진다. 빈 값 키를 제외하면 부모 env 값이 그대로 살아남는다.
76
- *
77
- * @returns env — 자식 프로세스 env, skipped — 제외된 빈 값 키 (정렬됨)
78
- */
79
- export function buildChildEnv(
80
- parentEnv: Record<string, string | undefined>,
81
- plain: Record<string, string>,
82
- ): { env: Record<string, string | undefined>; skipped: string[] } {
83
- const { filled, emptyKeys } = partitionEnv(plain);
84
- return { env: { ...parentEnv, ...filled }, skipped: emptyKeys };
85
- }
@@ -1,7 +1,10 @@
1
- import { addServiceRecipient, migrateV1ToV2, type SecretEnvelopeV2 } from '@athsra/crypto';
2
1
  import { loadAuthContext } from '../lib/auth-context.ts';
3
2
  import { resolveProject } from '../lib/auto-project.ts';
4
3
  import { red, yellow } from '../lib/colors.ts';
4
+ import {
5
+ type CreatedServiceToken,
6
+ createServiceTokenWithRecipient,
7
+ } from '../lib/service-tokens.ts';
5
8
 
6
9
  /** worker expiry-notify (apps/worker/src/queue/expiry-notify.ts) 의 최임박 threshold 와 동기. */
7
10
  const EXPIRING_SOON_DAYS = 14;
@@ -58,40 +61,21 @@ async function createCmd(args: string[]): Promise<number> {
58
61
  console.error('athsra service-token create 은 user token (master pw) 가 필요합니다.');
59
62
  return 1;
60
63
  }
61
- const { masterPw, client } = ctx;
62
64
 
63
- // envelope 확인 없으면 service token 발급 무의미
64
- const envelope = await client.getEnvelope(project);
65
- if (!envelope) {
66
- console.error(
67
- `project ${project} envelope 없음 — 먼저 \`athsra set ${project} KEY=value\` 실행.`,
68
- );
65
+ // 발급 + recipient 추가 (lib/service-tokens.ts MCP athsra_service_token_create 와 공용 경로)
66
+ let created: CreatedServiceToken;
67
+ try {
68
+ created = await createServiceTokenWithRecipient(ctx, {
69
+ project,
70
+ label,
71
+ perms,
72
+ expiresInDays: expiresDays,
73
+ });
74
+ } catch (err) {
75
+ console.error((err as Error).message);
69
76
  return 1;
70
77
  }
71
78
 
72
- // worker 에 service token 발급
73
- const created = await client.createServiceToken({
74
- project,
75
- label,
76
- perms,
77
- expiresInDays: expiresDays,
78
- });
79
-
80
- // envelope 에 recipient 추가 — v1 이면 v2 로 자동 migrate
81
- let v2Envelope: SecretEnvelopeV2;
82
- if (envelope.version === 1) {
83
- v2Envelope = await migrateV1ToV2(envelope, masterPw);
84
- } else {
85
- v2Envelope = envelope;
86
- }
87
- const updated = await addServiceRecipient(
88
- v2Envelope,
89
- masterPw,
90
- created.token,
91
- created.recipient_id,
92
- );
93
- await client.putEnvelope(project, updated);
94
-
95
79
  console.log('\n✓ service token 발급 완료\n');
96
80
  console.log(` token: ${created.token}`);
97
81
  console.log(` project: ${created.project}`);
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { loadAuthContext } from '../lib/auth-context.ts';
3
- import { resolveProject } from '../lib/auto-project.ts';
3
+ import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
4
4
  import { parseEnv, partitionEnv } from '../lib/env-format.ts';
5
5
  import { readPlain, writePlain } from '../lib/envelope.ts';
6
6
 
@@ -12,6 +12,7 @@ const USAGE = [
12
12
  '<project> 자동 감지 (cwd 기반):',
13
13
  ' --project=<x> > positional > .athsra file > package.json athsra.project > basename(cwd)',
14
14
  '23 sibling repo 안에서는 cd 한 후 project 인자 생략 가능 (basename 자동 사용).',
15
+ CONFIG_USAGE_HINT,
15
16
  ].join('\n');
16
17
 
17
18
  async function readStdin(): Promise<string> {
@@ -36,14 +37,14 @@ function parsePairsArg(pairs: string[]): Record<string, string> | null {
36
37
  }
37
38
 
38
39
  export async function setCmd(args: string[]): Promise<number> {
39
- const { project, rest, source } = resolveProject(args);
40
+ const { project, config, rest, source } = resolveProject(args);
40
41
  if (!project) {
41
42
  console.error(USAGE);
42
43
  return 2;
43
44
  }
44
45
  // mutating 명령 — auto-detect 시점 source 안내 (실수 방지). positional/flag 면 silent.
45
46
  if (source !== 'positional' && source !== 'flag') {
46
- console.log(`(project=${project} auto-detected from ${source})`);
47
+ console.log(`(project=${project}${configTag(config)} auto-detected from ${source})`);
47
48
  }
48
49
 
49
50
  let updates: Record<string, string>;
@@ -93,14 +94,14 @@ export async function setCmd(args: string[]): Promise<number> {
93
94
  }
94
95
 
95
96
  // read-modify-write — v1/v2 dispatcher 가 read 처리, write 는 항상 v2.
96
- const plain: Record<string, string> = (await readPlain(ctx, project)) ?? {};
97
+ const plain: Record<string, string> = (await readPlain(ctx, project, config)) ?? {};
97
98
  for (const [k, v] of Object.entries(updates)) {
98
99
  plain[k] = v;
99
100
  }
100
- await writePlain(ctx, project, plain);
101
+ await writePlain(ctx, project, plain, { config });
101
102
 
102
103
  console.log(
103
- `✓ ${project}: ${updateCount} key${updateCount > 1 ? 's' : ''} set (${Object.keys(plain).length} total)`,
104
+ `✓ ${project}${configTag(config)}: ${updateCount} key${updateCount > 1 ? 's' : ''} set (${Object.keys(plain).length} total)`,
104
105
  );
105
106
  return 0;
106
107
  }
@@ -1,16 +1,19 @@
1
1
  import { loadAuthContext } from '../lib/auth-context.ts';
2
+ import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
2
3
  import { readPlain, writePlain } from '../lib/envelope.ts';
3
4
 
4
- const USAGE =
5
- 'usage: athsra unset <project> KEY1 [KEY2 ...] # 해당 keys 만 제거 (envelope 그대로, 다른 keys 유지)';
5
+ const USAGE = [
6
+ 'usage: athsra unset <project>[:<env>] KEY1 [KEY2 ...] # 해당 keys 만 제거 (envelope 그대로, 다른 keys 유지)',
7
+ CONFIG_USAGE_HINT,
8
+ ].join('\n');
6
9
 
7
10
  export async function unsetCmd(args: string[]): Promise<number> {
8
- const project = args[0];
11
+ const { project, config, rest } = resolveProject(args, { requirePositional: true });
9
12
  if (!project) {
10
13
  console.error(USAGE);
11
14
  return 2;
12
15
  }
13
- const keys = args.slice(1);
16
+ const keys = rest;
14
17
  if (keys.length === 0) {
15
18
  console.error('No keys to unset');
16
19
  return 2;
@@ -23,9 +26,9 @@ export async function unsetCmd(args: string[]): Promise<number> {
23
26
  return 1;
24
27
  }
25
28
 
26
- const plain = await readPlain(ctx, project);
29
+ const plain = await readPlain(ctx, project, config);
27
30
  if (!plain) {
28
- console.error(`project not found: ${project}`);
31
+ console.error(`project not found: ${project}${configTag(config)}`);
29
32
  return 1;
30
33
  }
31
34
 
@@ -45,9 +48,9 @@ export async function unsetCmd(args: string[]): Promise<number> {
45
48
  return 1;
46
49
  }
47
50
 
48
- await writePlain(ctx, project, plain);
51
+ await writePlain(ctx, project, plain, { config });
49
52
  console.log(
50
- `✓ ${project}: ${removed.length} key${removed.length > 1 ? 's' : ''} removed (${Object.keys(plain).length} remaining)`,
53
+ `✓ ${project}${configTag(config)}: ${removed.length} key${removed.length > 1 ? 's' : ''} removed (${Object.keys(plain).length} remaining)`,
51
54
  );
52
55
  return 0;
53
56
  }
@@ -1,10 +1,11 @@
1
1
  import { loadAuthContext } from '../lib/auth-context.ts';
2
- import { resolveProject } from '../lib/auto-project.ts';
2
+ import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
3
3
 
4
4
  const USAGE = [
5
5
  'usage: athsra versions [<project>]',
6
6
  '',
7
7
  '<project> 자동 감지 (cwd 기반): basename(cwd) > .athsra > package.json athsra.project',
8
+ CONFIG_USAGE_HINT,
8
9
  ].join('\n');
9
10
 
10
11
  /**
@@ -15,7 +16,7 @@ const USAGE = [
15
16
  * - <project> 생략 시 cwd 기반 자동 감지
16
17
  */
17
18
  export async function versionsCmd(args: string[]): Promise<number> {
18
- const { project } = resolveProject(args);
19
+ const { project, config } = resolveProject(args);
19
20
  if (!project) {
20
21
  console.error(USAGE);
21
22
  return 2;
@@ -24,20 +25,21 @@ export async function versionsCmd(args: string[]): Promise<number> {
24
25
  if (!ctx) return 1;
25
26
  const { client } = ctx;
26
27
 
27
- const data = await client.listVersions(project);
28
+ const data = await client.listVersions(project, config);
29
+ const tag = configTag(config);
28
30
  if (data.tombstone) {
29
31
  console.log(
30
32
  `(deleted ${data.tombstone.deleted_at} by ${data.tombstone.deleted_by} — ${data.versions.length} version(s) recoverable)`,
31
33
  );
32
34
  }
33
35
  if (data.count === 0) {
34
- console.log('(no versions — project never existed or hard-deleted)');
36
+ console.log(`(no versions${tag} — project never existed or hard-deleted)`);
35
37
  return 0;
36
38
  }
37
39
  for (const v of data.versions) {
38
40
  const marker = v.version_id === data.current_version ? '*' : ' ';
39
41
  console.log(`${marker} ${v.version_id} ${v.updated_at} (${v.size}B)`);
40
42
  }
41
- console.log(`(${data.count} version${data.count > 1 ? 's' : ''})`);
43
+ console.log(`(${data.count} version${data.count > 1 ? 's' : ''}${tag})`);
42
44
  return 0;
43
45
  }
package/src/index.ts CHANGED
@@ -28,7 +28,7 @@ import { setCmd } from './commands/set.ts';
28
28
  import { unsetCmd } from './commands/unset.ts';
29
29
  import { versionsCmd } from './commands/versions.ts';
30
30
 
31
- const VERSION = '1.0.0';
31
+ const VERSION = '1.1.0';
32
32
 
33
33
  const commands: Record<string, (args: string[]) => Promise<number>> = {
34
34
  login: loginCmd,
@@ -70,14 +70,15 @@ if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
70
70
  console.log(`athsra v${VERSION} — E2EE secret manager on Cloudflare edge
71
71
 
72
72
  Usage:
73
- athsra login master pw 입력 + 머신 등록 (Bearer token 발급)
73
+ athsra login 브라우저 identity 로그인 (master pw 기기 미보관)
74
+ athsra login --password founding master pw 등록/재로그인 (OS keyring 저장)
74
75
  athsra logout [--full] keyring clear (worker token 유지). --full 시 config 도 삭제
75
76
  athsra init <project> 신규 project 안내
76
77
  athsra adopt [<project>] [opts] sibling repo envelope ↔ CF Worker + Workers Builds 1줄 onboarding
77
- athsra set <project> KEY=value secret 추가/수정 (다건 가능)
78
- athsra unset <project> KEY [...] 특정 key 제거 (envelope 유지)
79
- athsra get <project> [KEY] 값 출력 (single 또는 dump)
80
- athsra ls [project] [--all] project 또는 key 목록 (--all=deleted 포함)
78
+ athsra set <project>[:<env>] KEY=value secret 추가/수정 (다건 가능)
79
+ athsra unset <project>[:<env>] KEY [...] 특정 key 제거 (envelope 유지)
80
+ athsra get <project>[:<env>] [KEY] 값 출력 (single 또는 dump)
81
+ athsra ls [project][:<env>] [--all|--configs] project / key / 환경 목록
81
82
  athsra manifest {init|show|validate|add|remove} sibling worker 의 secrets opt-in manifest (Option γ)
82
83
  athsra mcp Model Context Protocol stdio server (AI agent 통합)
83
84
  athsra run <project> -- <cmd> env inject 후 명령 실행 (Doppler-style)
@@ -98,6 +99,9 @@ Usage:
98
99
  athsra restore <project> tombstone 제거 + 최신 version 활성화
99
100
  athsra purge <project> hard-delete (delete --hard 별칭, double-confirm)
100
101
 
102
+ 환경(config): <project>:<env> 또는 --config=<env> 로 dev/staging/prod 분리 (기본 default).
103
+ 모든 secret 명령 공통. 'athsra ls <project> --configs' 로 환경 목록 조회.
104
+
101
105
  athsra dr {restore-r2|restore-d1} DR 복원 (backup→STORE / 암호화 D1 dump). dry-run 기본, --execute --confirm
102
106
 
103
107
  athsra rotate-master master pw 변경 (모든 projects re-encrypt)
@@ -108,7 +112,7 @@ Usage:
108
112
 
109
113
  Files / Storage:
110
114
  ~/.athsra/config.json worker URL + machine_id
111
- OS keyring (libsecret/Keychain/Cred Manager) master pw + Bearer token
115
+ OS keyring (libsecret/Keychain/Cred Manager) identity key 또는 master pw + Bearer token
112
116
 
113
117
  Env vars (CI):
114
118
  ATHSRA_MASTER_PW non-interactive login
@@ -120,7 +124,7 @@ Env vars (CI):
120
124
  Headless (service token — master pw 없이 scoped 복호):
121
125
  ATHSRA_TOKEN=ats_... ATHSRA_WORKER_URL=https://... athsra run <project> -- <cmd>
122
126
 
123
- Phase 1.x.8 = envelope v2 (DEK + multi-recipient) + service tokens for headless hosts.
127
+ Phase 5 = identity device-login + MCP 3-tier tools + envelope member/self recipients.
124
128
  docs: github.com/modfolio/athsra
125
129
  `);
126
130
  process.exit(0);
@@ -1,9 +1,10 @@
1
- import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
1
+ import { fromBase64 } from '@athsra/crypto';
2
+ import { deriveMasterProof } from './auth-proof.ts';
2
3
  import { AthsraClient } from './client.ts';
3
- import { type Config, loadConfig } from './config.ts';
4
- import { getDeviceToken, getMasterPw, getToken, setToken } from './keyring.ts';
4
+ import { type Config, loadConfig, saveConfig } from './config.ts';
5
+ import { getDeviceToken, getIdentityKey, getMasterPw, getToken, setToken } from './keyring.ts';
5
6
 
6
- /** User token mode — keyring 의 master pw + Bearer token 보유, full access. */
7
+ /** User token mode — keyring 의 master pw + Bearer token 보유. */
7
8
  export interface UserAuthContext {
8
9
  kind: 'user';
9
10
  config: Config;
@@ -27,7 +28,22 @@ export interface ServiceAuthContext {
27
28
  workerUrl: string;
28
29
  }
29
30
 
30
- export type AuthContext = UserAuthContext | ServiceAuthContext;
31
+ /**
32
+ * Identity 디바이스 모드 (Phase 5) — keyring 의 identity X25519 private key + token. master pw 가
33
+ * 이 머신에 없어도 멤버 경로(member:me recipient)로 envelope 복호·재포장. token 은 atk_*(90일
34
+ * expiry, idle 면제) — 401 시 idle-refresh 불가(master pw 없음)이므로 `athsra login` 재온보딩.
35
+ */
36
+ export interface IdentityAuthContext {
37
+ kind: 'identity';
38
+ config: Config;
39
+ /** keyring identity-priv → fromBase64. 멤버 복호/재포장에 사용 (worker 미노출). */
40
+ identityPrivateKey: Uint8Array;
41
+ userId: number;
42
+ token: string;
43
+ client: AthsraClient;
44
+ }
45
+
46
+ export type AuthContext = UserAuthContext | ServiceAuthContext | IdentityAuthContext;
31
47
 
32
48
  /**
33
49
  * 모든 인증 요구 commands 의 공통 진입점. 두 모드 자동 dispatch:
@@ -49,9 +65,12 @@ export async function loadAuthContext(project?: string): Promise<AuthContext | n
49
65
  return loadServiceContext(envToken);
50
66
  }
51
67
  // 2. user context (master pw + token, full access) — 보유 시 우선
52
- const userCtx = loadUserContext();
68
+ const userCtx = await loadUserContext();
53
69
  if (userCtx) return userCtx;
54
- // 3. project-scoped device token (device-login agent) — master pw 없는 머신
70
+ // 3. identity 디바이스 모드 (Phase 5) — master pw 없는 머신, identity privkey + atk_* 보유.
71
+ const identityCtx = loadIdentityContext();
72
+ if (identityCtx) return identityCtx;
73
+ // 4. project-scoped device token (device-login agent) — master pw 없는 머신
55
74
  // Phase 3 P3: `athsra login --device --project <p>` 가 keyring 에 저장한 ats_*.
56
75
  if (project) {
57
76
  const deviceToken = getDeviceToken(project);
@@ -76,21 +95,38 @@ export async function loadAuthContext(project?: string): Promise<AuthContext | n
76
95
  }
77
96
 
78
97
  /** master pw + token 보유 시 user context. 미보유 시 조용히 null (호출자가 device/안내 처리). */
79
- function loadUserContext(): UserAuthContext | null {
80
- const config = loadConfig();
98
+ async function loadUserContext(): Promise<UserAuthContext | null> {
99
+ let config = loadConfig();
81
100
  if (!config) return null;
82
101
  const masterPw = getMasterPw(config.machineId);
83
102
  const token = getToken(config.machineId);
84
103
  if (!masterPw || !token) return null;
85
104
  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 저장.
105
+
106
+ // Legacy config migration: older CLI versions did not persist userId. When the current token is
107
+ // still valid, backfill it from whoami so later proof operations use the authenticated user.
108
+ if (config.userId === undefined) {
109
+ try {
110
+ const me = await client.whoami();
111
+ if (me.userId !== undefined) {
112
+ config = { ...config, userId: me.userId };
113
+ saveConfig(config);
114
+ }
115
+ } catch {
116
+ // Token may already be idle-revoked. Keep fail-closed idle refresh below instead of guessing.
117
+ }
118
+ }
119
+
120
+ // Phase 3 — idle timeout 자동 재인증. founding password-login token 만 keyring master pw
121
+ // 로 silent re-register 가능. SSO/non-founding user 는 /auth/register 대상이 아니라 재로그인 필요.
88
122
  // 기기 잠금 (OS keyring 차단) 이 방어선이므로 작업 중엔 끊김 없이, 잠금 시엔 idle 유효.
89
123
  client.setIdleRefresh(async () => {
90
124
  try {
125
+ if (config.userId !== 1) return null;
91
126
  const info = await client.info();
92
- const proof = toBase64(deriveKey(masterPw, fromBase64(info.global_salt)));
127
+ const proof = deriveMasterProof(masterPw, config.userId, info.global_salt);
93
128
  const reg = await client.register(proof, config.machineId);
129
+ if (reg.userId !== undefined && reg.userId !== config.userId) return null;
94
130
  let token = reg.token;
95
131
  // Phase 4 Slice 6a — re-register 는 자기 personal org 로 token 발급. switch 상태였으면
96
132
  // (config.orgId) 다시 그 org 로 전환 — 안 하면 idle 후 사용자 모르게 personal 컨텍스트로 reset.
@@ -117,6 +153,32 @@ function loadUserContext(): UserAuthContext | null {
117
153
  };
118
154
  }
119
155
 
156
+ /**
157
+ * identity privkey + token 보유 시 identity context (master pw 없는 머신). config.userId 필수
158
+ * (member recipient 매칭). 미보유/손상 시 조용히 null (호출자가 device/안내 처리).
159
+ */
160
+ function loadIdentityContext(): IdentityAuthContext | null {
161
+ const config = loadConfig();
162
+ if (!config || config.userId === undefined) return null;
163
+ const privB64 = getIdentityKey(config.machineId);
164
+ const token = getToken(config.machineId);
165
+ if (!privB64 || !token) return null;
166
+ let identityPrivateKey: Uint8Array;
167
+ try {
168
+ identityPrivateKey = fromBase64(privB64);
169
+ } catch {
170
+ return null;
171
+ }
172
+ return {
173
+ kind: 'identity',
174
+ config,
175
+ identityPrivateKey,
176
+ userId: config.userId,
177
+ token,
178
+ client: new AthsraClient(config.workerUrl, token),
179
+ };
180
+ }
181
+
120
182
  async function loadServiceContext(
121
183
  token: string,
122
184
  opts?: { silent?: boolean },
@@ -0,0 +1,10 @@
1
+ import { deriveProof, fromBase64, toBase64 } from '@athsra/crypto';
2
+
3
+ /** G-1 auth proof — per-user salt + GLOBAL_SALT pepper, base64 wire format. */
4
+ export function deriveMasterProof(
5
+ masterPw: string,
6
+ userId: number,
7
+ globalSaltBase64: string,
8
+ ): string {
9
+ return toBase64(deriveProof(masterPw, userId, fromBase64(globalSaltBase64)));
10
+ }
@@ -24,12 +24,28 @@ import { basename, join } from 'node:path';
24
24
 
25
25
  export interface ProjectResolution {
26
26
  project: string | undefined;
27
+ /** 환경(config) — 기본 'default'. Doppler 식 dev/staging/prod. `--config=` > `project:config` > .athsra > default. */
28
+ config: string;
27
29
  /** project 명이 args 에서 빠진 나머지 args (KEY=value pairs 또는 다른 인자) */
28
30
  rest: string[];
29
31
  /** 어디서 감지했는가 — 디버깅 / 사용자 안내용 */
30
32
  source: 'flag' | 'positional' | 'athsra-file' | 'package-json' | 'cwd' | 'none';
31
33
  }
32
34
 
35
+ /** 사용자 출력용 config 라벨 — default 는 표시 안 함, 그 외는 ` [<config>]` (명령 결과·에러 공용). */
36
+ export function configTag(config: string): string {
37
+ return config === 'default' ? '' : ` [${config}]`;
38
+ }
39
+
40
+ /** 사용자가 그대로 입력 가능한 project 참조 — default 는 project, 그 외는 project:config (명령 안내문 공용). */
41
+ export function projectRef(project: string, config: string): string {
42
+ return config === 'default' ? project : `${project}:${config}`;
43
+ }
44
+
45
+ /** 모든 secret 명령 USAGE 하단에 붙는 통일 환경 안내 문구. */
46
+ export const CONFIG_USAGE_HINT =
47
+ '환경(config): [--config=<env>] 또는 <project>:<env> 또는 .athsra 의 config= 줄 (기본 default)';
48
+
33
49
  /**
34
50
  * args 에서 --project=<x> flag 추출 + 나머지 args 반환.
35
51
  */
@@ -47,10 +63,21 @@ function extractProjectFlag(args: string[]): { project?: string; rest: string[]
47
63
  return project ? { project, rest } : { rest };
48
64
  }
49
65
 
66
+ /** args 에서 --config=<x> flag 추출 + 나머지. */
67
+ function extractConfigFlag(args: string[]): { config?: string; rest: string[] } {
68
+ const rest: string[] = [];
69
+ let config: string | undefined;
70
+ for (const arg of args) {
71
+ if (arg.startsWith('--config=')) config = arg.slice('--config='.length);
72
+ else rest.push(arg);
73
+ }
74
+ return config !== undefined ? { config, rest } : { rest };
75
+ }
76
+
50
77
  /**
51
- * cwd 의 .athsra file 읽기 — `project=<name>` 한 줄 (다른 키 무시, 단순 KEY=value).
78
+ * cwd 의 .athsra file 에서 키 값 읽기 — `<key>=<value>` 한 줄 (단순 KEY=value). project/config 공용.
52
79
  */
53
- function readAthsraFile(cwd: string): string | undefined {
80
+ function readAthsraKey(cwd: string, wantKey: string): string | undefined {
54
81
  const path = join(cwd, '.athsra');
55
82
  if (!existsSync(path)) return undefined;
56
83
  try {
@@ -62,7 +89,7 @@ function readAthsraFile(cwd: string): string | undefined {
62
89
  if (eq < 0) continue;
63
90
  const key = trimmed.slice(0, eq).trim();
64
91
  const value = trimmed.slice(eq + 1).trim();
65
- if (key === 'project' && value) return value;
92
+ if (key === wantKey && value) return value;
66
93
  }
67
94
  } catch {
68
95
  /* ignore */
@@ -95,37 +122,54 @@ export function resolveProject(
95
122
  args: string[],
96
123
  opts?: { cwd?: string; requirePositional?: boolean },
97
124
  ): ProjectResolution {
125
+ const cwd = opts?.cwd ?? process.cwd();
126
+ // 0. --config=<x> flag 추출 (project 해석 전에 args 에서 제거)
127
+ const cfgFlag = extractConfigFlag(args);
128
+ let config = cfgFlag.config;
129
+
130
+ // config 통합 — project:config syntax(flag 없을 때) > .athsra config > default
131
+ const withConfig = (r: Omit<ProjectResolution, 'config'>): ProjectResolution => {
132
+ let project = r.project;
133
+ if (project?.includes(':')) {
134
+ const idx = project.indexOf(':');
135
+ const c = project.slice(idx + 1);
136
+ project = project.slice(0, idx);
137
+ if (config === undefined && c) config = c;
138
+ }
139
+ if (config === undefined) config = readAthsraKey(cwd, 'config');
140
+ return { ...r, project, config: config ?? 'default' };
141
+ };
142
+
98
143
  // 1. --project=<x> flag 우선
99
- const flagResult = extractProjectFlag(args);
144
+ const flagResult = extractProjectFlag(cfgFlag.rest);
100
145
  if (flagResult.project) {
101
- return { project: flagResult.project, rest: flagResult.rest, source: 'flag' };
146
+ return withConfig({ project: flagResult.project, rest: flagResult.rest, source: 'flag' });
102
147
  }
103
148
 
104
149
  const remaining = flagResult.rest;
105
150
 
106
- // 2. positional <project> arg — 첫 자리 + `=` 없으면 project 로 인식
151
+ // 2. positional <project> arg — 첫 자리 + `=` 없으면 project 로 인식 (project:config 포함)
107
152
  if (remaining.length > 0 && remaining[0] !== undefined && !remaining[0].includes('=')) {
108
- return { project: remaining[0], rest: remaining.slice(1), source: 'positional' };
153
+ return withConfig({ project: remaining[0], rest: remaining.slice(1), source: 'positional' });
109
154
  }
110
155
 
111
156
  if (opts?.requirePositional) {
112
- return { project: undefined, rest: remaining, source: 'none' };
157
+ return withConfig({ project: undefined, rest: remaining, source: 'none' });
113
158
  }
114
159
 
115
160
  // 3. .athsra file (auto-detect)
116
- const cwd = opts?.cwd ?? process.cwd();
117
- const fromFile = readAthsraFile(cwd);
118
- if (fromFile) return { project: fromFile, rest: remaining, source: 'athsra-file' };
161
+ const fromFile = readAthsraKey(cwd, 'project');
162
+ if (fromFile) return withConfig({ project: fromFile, rest: remaining, source: 'athsra-file' });
119
163
 
120
164
  // 4. package.json athsra.project
121
165
  const fromPkg = readPackageJsonProject(cwd);
122
- if (fromPkg) return { project: fromPkg, rest: remaining, source: 'package-json' };
166
+ if (fromPkg) return withConfig({ project: fromPkg, rest: remaining, source: 'package-json' });
123
167
 
124
168
  // 5. basename(cwd) — 23 sibling 표준 = repo 명 = project 명
125
169
  const base = basename(cwd);
126
170
  if (base && base !== '/' && base !== '~' && base !== '.') {
127
- return { project: base, rest: remaining, source: 'cwd' };
171
+ return withConfig({ project: base, rest: remaining, source: 'cwd' });
128
172
  }
129
173
 
130
- return { project: undefined, rest: remaining, source: 'none' };
174
+ return withConfig({ project: undefined, rest: remaining, source: 'none' });
131
175
  }