@athsra/cli 1.0.3 → 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.
- package/README.md +34 -10
- package/package.json +3 -3
- package/src/commands/delete.ts +16 -13
- package/src/commands/get.ts +8 -5
- package/src/commands/handoff.ts +13 -3
- package/src/commands/login.ts +164 -59
- package/src/commands/logout.ts +3 -2
- package/src/commands/ls.ts +32 -10
- package/src/commands/manifest.ts +2 -2
- package/src/commands/mcp.ts +259 -7
- package/src/commands/migrate-envelopes.ts +55 -3
- package/src/commands/purge.ts +13 -10
- package/src/commands/restore.ts +10 -6
- package/src/commands/rollback.ts +12 -9
- package/src/commands/rotate-master.ts +13 -13
- package/src/commands/run.ts +6 -24
- package/src/commands/service-token.ts +15 -31
- package/src/commands/set.ts +7 -6
- package/src/commands/unset.ts +11 -8
- package/src/commands/versions.ts +7 -5
- package/src/index.ts +12 -8
- package/src/lib/auth-context.ts +74 -12
- package/src/lib/auth-proof.ts +10 -0
- package/src/lib/auto-project.ts +58 -14
- package/src/lib/client.ts +94 -17
- package/src/lib/config.ts +2 -0
- package/src/lib/device-login.ts +157 -0
- package/src/lib/env-format.ts +1 -1
- package/src/lib/envelope.ts +105 -15
- package/src/lib/identity-key.ts +21 -0
- package/src/lib/keyring.ts +25 -0
- package/src/lib/mcp-register.ts +223 -0
- package/src/lib/mcp-tools/admin.ts +267 -0
- package/src/lib/mcp-tools/args.ts +26 -0
- package/src/lib/mcp-tools/confirm.ts +21 -0
- package/src/lib/mcp-tools/defs.ts +388 -3
- package/src/lib/mcp-tools/login.ts +156 -0
- package/src/lib/mcp-tools/mask.ts +41 -0
- package/src/lib/mcp-tools/read.ts +115 -1
- package/src/lib/mcp-tools/result.ts +5 -5
- package/src/lib/mcp-tools/run.ts +101 -0
- package/src/lib/mcp-tools/write.ts +84 -5
- package/src/lib/oidc-flow.ts +43 -1
- package/src/lib/org-rewrap.ts +9 -3
- package/src/lib/service-tokens.ts +62 -0
package/src/commands/run.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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}`);
|
package/src/commands/set.ts
CHANGED
|
@@ -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
|
}
|
package/src/commands/unset.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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
|
}
|
package/src/commands/versions.ts
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
|
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
|
|
78
|
-
athsra unset <project> KEY [...]
|
|
79
|
-
athsra get <project> [KEY]
|
|
80
|
-
athsra ls [project] [--all]
|
|
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
|
|
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);
|
package/src/lib/auth-context.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
//
|
|
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 =
|
|
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
|
+
}
|
package/src/lib/auto-project.ts
CHANGED
|
@@ -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 읽기 —
|
|
78
|
+
* cwd 의 .athsra file 에서 키 값 읽기 — `<key>=<value>` 한 줄 (단순 KEY=value). project/config 공용.
|
|
52
79
|
*/
|
|
53
|
-
function
|
|
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 ===
|
|
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(
|
|
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
|
|
117
|
-
|
|
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
|
}
|