@athsra/cli 1.0.2 → 1.0.4
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 +2 -3
- package/src/commands/admin.ts +11 -33
- package/src/commands/adopt.ts +1 -5
- package/src/commands/dr.ts +217 -0
- package/src/commands/login.ts +223 -179
- package/src/commands/manifest.ts +3 -10
- package/src/commands/mcp.ts +69 -485
- package/src/commands/new-phrase.ts +1 -1
- package/src/commands/org.ts +363 -0
- package/src/commands/rotate-master.ts +26 -7
- package/src/commands/run.ts +2 -1
- package/src/commands/service-token.ts +17 -6
- package/src/index.ts +7 -0
- package/src/lib/auth-context.ts +77 -18
- package/src/lib/client.ts +396 -31
- package/src/lib/colors.ts +17 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/env-format.ts +5 -53
- package/src/lib/envelope.ts +89 -3
- package/src/lib/identity-key.ts +59 -0
- package/src/lib/keyring.ts +26 -0
- package/src/lib/mcp-tools/args.ts +60 -0
- package/src/lib/mcp-tools/defs.ts +179 -0
- package/src/lib/mcp-tools/read.ts +156 -0
- package/src/lib/mcp-tools/result.ts +46 -0
- package/src/lib/mcp-tools/write.ts +127 -0
- package/src/lib/oidc-flow.ts +200 -0
- package/src/lib/org-rewrap.ts +230 -0
- package/src/lib/secrets-manifest.ts +1 -4
- package/src/lib/wrangler-scan.ts +2 -8
- package/src/lib/bip39.ts +0 -45
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@athsra/cli",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
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",
|
|
@@ -43,10 +43,9 @@
|
|
|
43
43
|
"typecheck": "tsc --noEmit"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@athsra/crypto": "^1.0.
|
|
46
|
+
"@athsra/crypto": "^1.0.1",
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
48
|
"@napi-rs/keyring": "^1.1.6",
|
|
49
|
-
"@scure/bip39": "^2.2.0",
|
|
50
49
|
"prompts": "^2.4.2"
|
|
51
50
|
},
|
|
52
51
|
"devDependencies": {
|
package/src/commands/admin.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* 명령:
|
|
7
7
|
* athsra users list — 모든 user + role 표시
|
|
8
|
-
* athsra users invite <identifier> [--role=dev] — 신규 user invite (
|
|
8
|
+
* athsra users invite <identifier> [--role=dev] — 신규 user invite (신원·접근만)
|
|
9
9
|
* athsra role grant <user_id> <role> — role grant
|
|
10
10
|
* athsra role revoke <user_id> <role> — role revoke
|
|
11
11
|
* athsra project share <project> <user_id> --perms=<read|write>
|
|
@@ -14,9 +14,7 @@
|
|
|
14
14
|
* role 종류: admin / dev / viewer / auditor / sa
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
|
|
18
17
|
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
19
|
-
import { promptPassword } from '../lib/prompt.ts';
|
|
20
18
|
|
|
21
19
|
const VALID_ROLES = ['admin', 'dev', 'viewer', 'auditor', 'sa'] as const;
|
|
22
20
|
type RoleName = (typeof VALID_ROLES)[number];
|
|
@@ -98,43 +96,23 @@ async function inviteUserCmd(args: string[]): Promise<number> {
|
|
|
98
96
|
const ctx = await getUserClient();
|
|
99
97
|
if (!ctx) return 1;
|
|
100
98
|
|
|
101
|
-
//
|
|
102
|
-
|
|
103
|
-
console.log(' (이 값은 자체적으로 저장되지 않음 — Argon2id 후 worker 에 PROOF 만 전달)');
|
|
104
|
-
const masterPw = await promptPassword('new master password: ');
|
|
105
|
-
if (!masterPw || masterPw.length === 0) {
|
|
106
|
-
console.error('master password 비어있음 — 중단');
|
|
107
|
-
return 2;
|
|
108
|
-
}
|
|
109
|
-
const masterPwConfirm = await promptPassword('confirm master password: ');
|
|
110
|
-
if (masterPw !== masterPwConfirm) {
|
|
111
|
-
console.error('confirm mismatch — 중단');
|
|
112
|
-
return 2;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
// proof = Argon2id(masterPw + GLOBAL_SALT)
|
|
116
|
-
const info = await ctx.client.info();
|
|
117
|
-
const proofBytes = deriveKey(masterPw, fromBase64(info.global_salt));
|
|
118
|
-
const proof = toBase64(proofBytes);
|
|
119
|
-
|
|
99
|
+
// Phase 3a: invite 는 신원·접근(user + role) 부여만. master pw 는 받지 않는다 —
|
|
100
|
+
// zero-knowledge 라 admin 은 유저 master pw 를 모르며, 유저가 첫 로그인 후 본인이 설정한다.
|
|
120
101
|
try {
|
|
121
|
-
const res = await ctx.client.inviteUser({
|
|
122
|
-
identifier,
|
|
123
|
-
proof,
|
|
124
|
-
globalSaltVersion: info.global_salt_version,
|
|
125
|
-
initialRole: role,
|
|
126
|
-
});
|
|
102
|
+
const res = await ctx.client.inviteUser({ identifier, initialRole: role });
|
|
127
103
|
console.log('✓ user invited');
|
|
128
104
|
console.log(` id: #${res.id}`);
|
|
129
105
|
console.log(` identifier: ${res.identifier}`);
|
|
130
106
|
console.log(` status: ${res.status}`);
|
|
131
107
|
console.log(` role: ${res.role}`);
|
|
132
108
|
console.log('');
|
|
133
|
-
console.log('다음
|
|
134
|
-
console.log(
|
|
135
|
-
console.log(
|
|
136
|
-
|
|
137
|
-
|
|
109
|
+
console.log('다음 단계 (신규 user 본인이 수행):');
|
|
110
|
+
console.log(' 1. `athsra login --sso` (또는 athsra.com 로그인) — 본인 신원으로 인증');
|
|
111
|
+
console.log(
|
|
112
|
+
' 2. master password 설정 — 첫 로그인 시 본인 pw 로 자동 bootstrap (POST /auth/proof)',
|
|
113
|
+
);
|
|
114
|
+
console.log(` 3. 추가 role 부여(admin): athsra role grant ${res.id} <role>`);
|
|
115
|
+
console.log(` 4. project ACL: athsra project share <project> ${res.id} --perms=<read|write>`);
|
|
138
116
|
return 0;
|
|
139
117
|
} catch (err) {
|
|
140
118
|
const msg = (err as Error).message;
|
package/src/commands/adopt.ts
CHANGED
|
@@ -40,11 +40,6 @@ import { loadAuthContext } from '../lib/auth-context.ts';
|
|
|
40
40
|
import { resolveProject } from '../lib/auto-project.ts';
|
|
41
41
|
import { readPlain } from '../lib/envelope.ts';
|
|
42
42
|
import { createManifest, loadManifest, saveManifest } from '../lib/secrets-manifest.ts';
|
|
43
|
-
import {
|
|
44
|
-
describeConflicts,
|
|
45
|
-
findConflicts,
|
|
46
|
-
scanWranglerConfig,
|
|
47
|
-
} from '../lib/wrangler-scan.ts';
|
|
48
43
|
import {
|
|
49
44
|
ensureRepoConnection,
|
|
50
45
|
getLatestBuildToken,
|
|
@@ -53,6 +48,7 @@ import {
|
|
|
53
48
|
triggerManualBuild,
|
|
54
49
|
upsertTrigger,
|
|
55
50
|
} from '../lib/workers-builds.ts';
|
|
51
|
+
import { describeConflicts, findConflicts, scanWranglerConfig } from '../lib/wrangler-scan.ts';
|
|
56
52
|
import {
|
|
57
53
|
ManifestInvalidError,
|
|
58
54
|
ManifestRequiredError,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dr.ts — `athsra dr` (Disaster Recovery operations).
|
|
3
|
+
*
|
|
4
|
+
* Phase 3 DR (2026-06-02). 기존 `athsra restore`(envelope tombstone 복원)와 다른 도메인 —
|
|
5
|
+
* 운영자가 catastrophic loss 후 BACKUP_STORE/암호화 dump 로부터 복원. worker `/v1/admin/restore/*`
|
|
6
|
+
* 호출 (admin role + webauthn 24h + content-bound confirm 3중 게이트).
|
|
7
|
+
*
|
|
8
|
+
* 안전 (정공법): **dry-run 기본**. `--execute` 만 주면 dry-run 먼저 돌려 confirm_token 출력 후
|
|
9
|
+
* 거부 — `--confirm=<token>` 명시해야 실제 복원. env escape (ATHSRA_*_CONFIRMED) 미제공 (restore
|
|
10
|
+
* 는 너무 위험 — token 항상 명시).
|
|
11
|
+
*
|
|
12
|
+
* athsra dr restore-r2 --date=YYYY-MM-DD [--project=owner/name | --key=secrets/...] [--execute] [--confirm=<t>]
|
|
13
|
+
* athsra dr restore-d1 --dump=d1-dumps/<ISO>.json.enc [--tables=t1,t2] [--include-optin]
|
|
14
|
+
* [--policy=upsert|replace-all] [--execute] [--confirm=<t>]
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { loadAuthContext } from '../lib/auth-context.ts';
|
|
18
|
+
import type { D1RestoreResult, R2RestoreResult, RestoreR2ScopeArg } from '../lib/client.ts';
|
|
19
|
+
import { bold, dim, green, red } from '../lib/colors.ts';
|
|
20
|
+
|
|
21
|
+
const DR_USAGE = [
|
|
22
|
+
'usage: athsra dr <restore-r2 | restore-d1> [opts]',
|
|
23
|
+
'',
|
|
24
|
+
'DR (Disaster Recovery) — backup 으로부터 복원. dry-run 기본, --execute --confirm 필요.',
|
|
25
|
+
'',
|
|
26
|
+
'restore-r2 — BACKUP_STORE 의 envelope 를 STORE 로 복원:',
|
|
27
|
+
' --date=YYYY-MM-DD backup day prefix (필수)',
|
|
28
|
+
' --project=owner/name 단일 project scope (owner=숫자 namespace)',
|
|
29
|
+
' --key=secrets/.../current.json 단일 key scope',
|
|
30
|
+
' (scope 미지정 = full)',
|
|
31
|
+
'',
|
|
32
|
+
'restore-d1 — 암호화 D1 dump 복원:',
|
|
33
|
+
' --dump=d1-dumps/<ISO>.json.enc dump key (필수)',
|
|
34
|
+
' --tables=auth_users,auth_roles 복원 테이블 (미지정 = SAFE 전체)',
|
|
35
|
+
' --include-optin auth_tokens/auth_audit_log 허용 (위험)',
|
|
36
|
+
' --policy=upsert|replace-all 기본 upsert (병합)',
|
|
37
|
+
'',
|
|
38
|
+
'공통: --execute (실복원), --confirm=<token> (dry-run 이 발급).',
|
|
39
|
+
].join('\n');
|
|
40
|
+
|
|
41
|
+
function flagVal(args: string[], name: string): string | undefined {
|
|
42
|
+
const pre = `--${name}=`;
|
|
43
|
+
const hit = args.find((a) => a.startsWith(pre));
|
|
44
|
+
return hit ? hit.slice(pre.length) : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasFlag(args: string[], name: string): boolean {
|
|
48
|
+
return args.includes(`--${name}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** dry-run 후 안내할 execute 명령 줄 재구성 (--execute/--confirm 제거 후 token 부착). */
|
|
52
|
+
function rebuildExecLine(sub: string, args: string[], token: string): string {
|
|
53
|
+
const base = args.filter((a) => a !== '--execute' && !a.startsWith('--confirm='));
|
|
54
|
+
return `athsra dr ${sub} ${base.join(' ')} --execute --confirm=${token}`
|
|
55
|
+
.replace(/\s+/g, ' ')
|
|
56
|
+
.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function userClient() {
|
|
60
|
+
const ctx = await loadAuthContext();
|
|
61
|
+
if (!ctx) {
|
|
62
|
+
console.error('not logged in — run `athsra login` first');
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (ctx.kind !== 'user') {
|
|
66
|
+
console.error('dr 명령은 user token (master pw) 가 필요합니다 — service token 거부.');
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
return ctx;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function printR2DryRun(dr: R2RestoreResult, args: string[], scopeKind: string): void {
|
|
73
|
+
console.log(bold(`DRY-RUN restore-r2 — ${dr.date} (scope: ${scopeKind})`));
|
|
74
|
+
console.log(` would restore: ${dr.would_restore ?? 0} object(s)`);
|
|
75
|
+
for (const k of (dr.sample_keys ?? []).slice(0, 10)) console.log(dim(` ${k}`));
|
|
76
|
+
if (dr.confirm_token) {
|
|
77
|
+
console.log(`\n to execute:\n ${rebuildExecLine('restore-r2', args, dr.confirm_token)}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function restoreR2Cmd(args: string[]): Promise<number> {
|
|
82
|
+
const date = flagVal(args, 'date');
|
|
83
|
+
if (!date) {
|
|
84
|
+
console.error('--date=YYYY-MM-DD required');
|
|
85
|
+
return 2;
|
|
86
|
+
}
|
|
87
|
+
const projectFlag = flagVal(args, 'project');
|
|
88
|
+
const keyFlag = flagVal(args, 'key');
|
|
89
|
+
if (projectFlag && keyFlag) {
|
|
90
|
+
console.error('--project 와 --key 는 동시 사용 불가');
|
|
91
|
+
return 2;
|
|
92
|
+
}
|
|
93
|
+
let scope: RestoreR2ScopeArg;
|
|
94
|
+
if (projectFlag) {
|
|
95
|
+
const [owner, project] = projectFlag.split('/');
|
|
96
|
+
if (!owner || !project) {
|
|
97
|
+
console.error('--project 는 owner/name 형식 (예: 1/myapp)');
|
|
98
|
+
return 2;
|
|
99
|
+
}
|
|
100
|
+
scope = { kind: 'project', owner, project };
|
|
101
|
+
} else if (keyFlag) {
|
|
102
|
+
scope = { kind: 'key', key: keyFlag };
|
|
103
|
+
} else {
|
|
104
|
+
scope = { kind: 'full' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const execute = hasFlag(args, 'execute');
|
|
108
|
+
const confirm = flagVal(args, 'confirm');
|
|
109
|
+
const ctx = await userClient();
|
|
110
|
+
if (!ctx) return 1;
|
|
111
|
+
|
|
112
|
+
if (!execute || !confirm) {
|
|
113
|
+
const dr = await ctx.client.restoreR2({ date, scope, execute: false });
|
|
114
|
+
printR2DryRun(dr, args, scope.kind);
|
|
115
|
+
if (execute && !confirm) {
|
|
116
|
+
console.error(red('\n✗ --execute 에는 --confirm=<token> 필요 — 위 명령으로 재실행.'));
|
|
117
|
+
return 2;
|
|
118
|
+
}
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const res = await ctx.client.restoreR2({ date, scope, execute: true, confirm });
|
|
123
|
+
console.log(
|
|
124
|
+
`${res.ok ? green('✓') : red('✗')} restore-r2 execute — restored ${res.restored ?? 0}/${res.scanned ?? 0}, skipped ${res.skipped ?? 0}, errors ${res.errors ?? 0}`,
|
|
125
|
+
);
|
|
126
|
+
for (const f of res.failures ?? []) console.log(red(` ✗ ${f.store_key}: ${f.error}`));
|
|
127
|
+
return res.ok ? 0 : 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function printD1DryRun(dr: D1RestoreResult, args: string[]): void {
|
|
131
|
+
console.log(bold(`DRY-RUN restore-d1 — ${dr.dump_key} (policy: ${dr.policy})`));
|
|
132
|
+
for (const [name, t] of Object.entries(dr.tables)) {
|
|
133
|
+
const filtered = t.filtered ? dim(` (filtered ${t.filtered})`) : '';
|
|
134
|
+
console.log(` ${name}: dump ${t.dump_rows ?? 0} → current ${t.current_rows ?? 0}${filtered}`);
|
|
135
|
+
}
|
|
136
|
+
if (dr.security_warning) console.log(red(`\n ⚠ ${dr.security_warning}`));
|
|
137
|
+
if (dr.confirm_token) {
|
|
138
|
+
console.log(`\n to execute:\n ${rebuildExecLine('restore-d1', args, dr.confirm_token)}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function restoreD1Cmd(args: string[]): Promise<number> {
|
|
143
|
+
const dumpKey = flagVal(args, 'dump');
|
|
144
|
+
if (!dumpKey) {
|
|
145
|
+
console.error('--dump=d1-dumps/<ISO>.json.enc required');
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
const tablesFlag = flagVal(args, 'tables');
|
|
149
|
+
const tables = tablesFlag
|
|
150
|
+
? tablesFlag
|
|
151
|
+
.split(',')
|
|
152
|
+
.map((t) => t.trim())
|
|
153
|
+
.filter(Boolean)
|
|
154
|
+
: undefined;
|
|
155
|
+
const includeOptin = hasFlag(args, 'include-optin');
|
|
156
|
+
const policy: 'upsert' | 'replace-all' =
|
|
157
|
+
flagVal(args, 'policy') === 'replace-all' ? 'replace-all' : 'upsert';
|
|
158
|
+
const execute = hasFlag(args, 'execute');
|
|
159
|
+
const confirm = flagVal(args, 'confirm');
|
|
160
|
+
const ctx = await userClient();
|
|
161
|
+
if (!ctx) return 1;
|
|
162
|
+
|
|
163
|
+
if (!execute || !confirm) {
|
|
164
|
+
const dr = await ctx.client.restoreD1({
|
|
165
|
+
dumpKey,
|
|
166
|
+
tables,
|
|
167
|
+
includeOptin,
|
|
168
|
+
policy,
|
|
169
|
+
execute: false,
|
|
170
|
+
});
|
|
171
|
+
printD1DryRun(dr, args);
|
|
172
|
+
if (execute && !confirm) {
|
|
173
|
+
console.error(red('\n✗ --execute 에는 --confirm=<token> 필요 — 위 명령으로 재실행.'));
|
|
174
|
+
return 2;
|
|
175
|
+
}
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const res = await ctx.client.restoreD1({
|
|
180
|
+
dumpKey,
|
|
181
|
+
tables,
|
|
182
|
+
includeOptin,
|
|
183
|
+
policy,
|
|
184
|
+
execute: true,
|
|
185
|
+
confirm,
|
|
186
|
+
});
|
|
187
|
+
console.log(
|
|
188
|
+
`${res.ok ? green('✓') : red('✗')} restore-d1 execute — policy ${res.policy}, errors ${res.errors ?? 0}`,
|
|
189
|
+
);
|
|
190
|
+
for (const [name, t] of Object.entries(res.tables)) {
|
|
191
|
+
const status = t.error
|
|
192
|
+
? red(`✗ ${t.error}`)
|
|
193
|
+
: green(`✓ written ${t.written ?? 0}${t.filtered ? `, filtered ${t.filtered}` : ''}`);
|
|
194
|
+
console.log(` ${name}: ${status}`);
|
|
195
|
+
}
|
|
196
|
+
if (res.security_warning) console.log(red(`\n⚠ ${res.security_warning}`));
|
|
197
|
+
return res.ok ? 0 : 1;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export async function drCmd(args: string[]): Promise<number> {
|
|
201
|
+
const sub = args[0];
|
|
202
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
203
|
+
console.log(DR_USAGE);
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
const rest = args.slice(1);
|
|
207
|
+
switch (sub) {
|
|
208
|
+
case 'restore-r2':
|
|
209
|
+
return restoreR2Cmd(rest);
|
|
210
|
+
case 'restore-d1':
|
|
211
|
+
return restoreD1Cmd(rest);
|
|
212
|
+
default:
|
|
213
|
+
console.error(`unknown dr subcommand: ${sub}`);
|
|
214
|
+
console.log(DR_USAGE);
|
|
215
|
+
return 2;
|
|
216
|
+
}
|
|
217
|
+
}
|