@athsra/cli 0.1.0 → 1.0.1
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 +10 -5
- package/src/commands/admin.ts +308 -0
- package/src/commands/adopt.ts +490 -0
- package/src/commands/audit.ts +156 -0
- package/src/commands/completion.ts +101 -0
- package/src/commands/delete.ts +1 -1
- package/src/commands/doctor.ts +86 -2
- package/src/commands/get.ts +10 -14
- package/src/commands/handoff.ts +20 -16
- package/src/commands/login.ts +306 -1
- package/src/commands/logout.ts +50 -0
- package/src/commands/ls.ts +24 -14
- package/src/commands/manifest.ts +354 -0
- package/src/commands/mcp.ts +595 -0
- package/src/commands/migrate-envelopes.ts +113 -0
- package/src/commands/purge.ts +1 -1
- package/src/commands/restore.ts +1 -1
- package/src/commands/revoke.ts +10 -7
- package/src/commands/rollback.ts +1 -1
- package/src/commands/rotate-master.ts +42 -48
- package/src/commands/run.ts +49 -16
- package/src/commands/service-token.ts +200 -0
- package/src/commands/set.ts +33 -47
- package/src/commands/unset.ts +9 -38
- package/src/commands/versions.ts +10 -4
- package/src/index.ts +79 -4
- package/src/lib/adopt-context.ts +183 -0
- package/src/lib/auth-context.ts +77 -4
- package/src/lib/auto-project.ts +131 -0
- package/src/lib/client.ts +359 -4
- package/src/lib/env-format.ts +25 -0
- package/src/lib/envelope.ts +76 -0
- package/src/lib/secrets-manifest.ts +309 -0
- package/src/lib/workers-builds.ts +274 -0
- package/src/lib/wrangler-sync.ts +202 -0
package/src/lib/client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SecretEnvelopeAny } from '@athsra/crypto';
|
|
2
2
|
|
|
3
3
|
export interface WorkerInfo {
|
|
4
4
|
name: string;
|
|
@@ -7,6 +7,10 @@ export interface WorkerInfo {
|
|
|
7
7
|
description?: string;
|
|
8
8
|
global_salt: string;
|
|
9
9
|
global_salt_version: string;
|
|
10
|
+
/** Phase 1.x.5: PROOF row 의 globalSaltVersion (기록된 값). null = PROOF 미bootstrap. */
|
|
11
|
+
proof_global_salt_version?: string | null;
|
|
12
|
+
/** Phase 1.x.5: GLOBAL_SALT_VERSION 변경 감지. true = 다음 register 시 auto invalidate. */
|
|
13
|
+
proof_invalidated?: boolean;
|
|
10
14
|
}
|
|
11
15
|
|
|
12
16
|
export interface RegisterResponse {
|
|
@@ -20,6 +24,11 @@ export interface WhoamiResponse {
|
|
|
20
24
|
label: string;
|
|
21
25
|
createdAt: string;
|
|
22
26
|
lastSeenAt: string;
|
|
27
|
+
/** Phase 1.x.8 — service token 일 때만 set. */
|
|
28
|
+
kind?: 'user' | 'service';
|
|
29
|
+
scopeProject?: string;
|
|
30
|
+
scopePerms?: 'read' | 'write';
|
|
31
|
+
recipientId?: string;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
34
|
/**
|
|
@@ -88,6 +97,71 @@ export class AthsraClient {
|
|
|
88
97
|
return (await res.json()) as { ok: boolean; revoked: string; self?: boolean };
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
async createServiceToken(args: {
|
|
101
|
+
project: string;
|
|
102
|
+
label: string;
|
|
103
|
+
perms?: 'read' | 'write';
|
|
104
|
+
expiresInDays?: number;
|
|
105
|
+
}): Promise<{
|
|
106
|
+
token: string;
|
|
107
|
+
recipient_id: string;
|
|
108
|
+
project: string;
|
|
109
|
+
perms: string;
|
|
110
|
+
expires_at: string | null;
|
|
111
|
+
}> {
|
|
112
|
+
const res = await fetch(this.url('/auth/service-tokens'), {
|
|
113
|
+
method: 'POST',
|
|
114
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
project: args.project,
|
|
117
|
+
label: args.label,
|
|
118
|
+
perms: args.perms,
|
|
119
|
+
expires_in_days: args.expiresInDays,
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
if (!res.ok) throw new Error(`service-token create ${res.status}: ${await res.text()}`);
|
|
123
|
+
return (await res.json()) as {
|
|
124
|
+
token: string;
|
|
125
|
+
recipient_id: string;
|
|
126
|
+
project: string;
|
|
127
|
+
perms: string;
|
|
128
|
+
expires_at: string | null;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async listServiceTokens(opts?: { project?: string }): Promise<{
|
|
133
|
+
tokens: {
|
|
134
|
+
hash: string;
|
|
135
|
+
label: string;
|
|
136
|
+
project: string;
|
|
137
|
+
perms: 'read' | 'write';
|
|
138
|
+
recipient_id: string;
|
|
139
|
+
machine_id: string;
|
|
140
|
+
created_at: string;
|
|
141
|
+
last_seen_at: string;
|
|
142
|
+
expires_at: string | null;
|
|
143
|
+
}[];
|
|
144
|
+
count: number;
|
|
145
|
+
}> {
|
|
146
|
+
const qs = opts?.project ? `?project=${encodeURIComponent(opts.project)}` : '';
|
|
147
|
+
const res = await fetch(this.url(`/auth/service-tokens${qs}`), { headers: this.headers() });
|
|
148
|
+
if (!res.ok) throw new Error(`service-token list ${res.status}: ${await res.text()}`);
|
|
149
|
+
return (await res.json()) as {
|
|
150
|
+
tokens: {
|
|
151
|
+
hash: string;
|
|
152
|
+
label: string;
|
|
153
|
+
project: string;
|
|
154
|
+
perms: 'read' | 'write';
|
|
155
|
+
recipient_id: string;
|
|
156
|
+
machine_id: string;
|
|
157
|
+
created_at: string;
|
|
158
|
+
last_seen_at: string;
|
|
159
|
+
expires_at: string | null;
|
|
160
|
+
}[];
|
|
161
|
+
count: number;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
91
165
|
async rotateMaster(
|
|
92
166
|
oldProof: string,
|
|
93
167
|
newProof: string,
|
|
@@ -126,16 +200,16 @@ export class AthsraClient {
|
|
|
126
200
|
};
|
|
127
201
|
}
|
|
128
202
|
|
|
129
|
-
async getEnvelope(project: string): Promise<
|
|
203
|
+
async getEnvelope(project: string): Promise<SecretEnvelopeAny | null> {
|
|
130
204
|
const res = await fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}`), {
|
|
131
205
|
headers: this.headers(),
|
|
132
206
|
});
|
|
133
207
|
if (res.status === 404) return null;
|
|
134
208
|
if (!res.ok) throw new Error(`fetch ${res.status}: ${await res.text()}`);
|
|
135
|
-
return (await res.json()) as
|
|
209
|
+
return (await res.json()) as SecretEnvelopeAny;
|
|
136
210
|
}
|
|
137
211
|
|
|
138
|
-
async putEnvelope(project: string, envelope:
|
|
212
|
+
async putEnvelope(project: string, envelope: SecretEnvelopeAny): Promise<void> {
|
|
139
213
|
const res = await fetch(this.url(`/v1/secrets/${encodeURIComponent(project)}`), {
|
|
140
214
|
method: 'PUT',
|
|
141
215
|
headers: this.headers({ 'content-type': 'application/json' }),
|
|
@@ -245,4 +319,285 @@ export class AthsraClient {
|
|
|
245
319
|
count: number;
|
|
246
320
|
};
|
|
247
321
|
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Phase 1.x.9 — GET /v1/audit (audit log query).
|
|
325
|
+
*
|
|
326
|
+
* 권한: user token full / service token with scope_perms='audit:read' / 그 외 403.
|
|
327
|
+
* 페이지네이션: cursor 기반 (ts DESC + id DESC), nextCursor 가 null 이면 끝.
|
|
328
|
+
*/
|
|
329
|
+
/** Phase 2.2 — RBAC admin endpoints. user token + admin role 필요. */
|
|
330
|
+
async listUsers(): Promise<{ users: RbacUser[]; count: number }> {
|
|
331
|
+
const res = await fetch(this.url('/v1/admin/users'), { headers: this.headers() });
|
|
332
|
+
if (!res.ok) throw new Error(`admin list users ${res.status}: ${await res.text()}`);
|
|
333
|
+
return (await res.json()) as { users: RbacUser[]; count: number };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async inviteUser(args: {
|
|
337
|
+
identifier: string;
|
|
338
|
+
proof: string;
|
|
339
|
+
globalSaltVersion?: string;
|
|
340
|
+
initialRole?: 'admin' | 'dev' | 'viewer' | 'auditor' | 'sa';
|
|
341
|
+
}): Promise<{ ok: true; id: number; identifier: string; status: string; role: string }> {
|
|
342
|
+
const res = await fetch(this.url('/v1/admin/users'), {
|
|
343
|
+
method: 'POST',
|
|
344
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
345
|
+
body: JSON.stringify({
|
|
346
|
+
identifier: args.identifier,
|
|
347
|
+
proof: args.proof,
|
|
348
|
+
global_salt_version: args.globalSaltVersion,
|
|
349
|
+
initial_role: args.initialRole,
|
|
350
|
+
}),
|
|
351
|
+
});
|
|
352
|
+
if (!res.ok) throw new Error(`admin invite ${res.status}: ${await res.text()}`);
|
|
353
|
+
return (await res.json()) as {
|
|
354
|
+
ok: true;
|
|
355
|
+
id: number;
|
|
356
|
+
identifier: string;
|
|
357
|
+
status: string;
|
|
358
|
+
role: string;
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async grantRole(args: {
|
|
363
|
+
userId: number;
|
|
364
|
+
role: 'admin' | 'dev' | 'viewer' | 'auditor' | 'sa';
|
|
365
|
+
}): Promise<{ ok: true; user_id: number; role: string }> {
|
|
366
|
+
const res = await fetch(this.url(`/v1/admin/users/${args.userId}/roles`), {
|
|
367
|
+
method: 'POST',
|
|
368
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
369
|
+
body: JSON.stringify({ role: args.role }),
|
|
370
|
+
});
|
|
371
|
+
if (!res.ok) throw new Error(`admin grant role ${res.status}: ${await res.text()}`);
|
|
372
|
+
return (await res.json()) as { ok: true; user_id: number; role: string };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async revokeRole(args: {
|
|
376
|
+
userId: number;
|
|
377
|
+
role: 'admin' | 'dev' | 'viewer' | 'auditor' | 'sa';
|
|
378
|
+
}): Promise<{ ok: true; user_id: number; role: string }> {
|
|
379
|
+
const res = await fetch(
|
|
380
|
+
this.url(`/v1/admin/users/${args.userId}/roles/${encodeURIComponent(args.role)}`),
|
|
381
|
+
{ method: 'DELETE', headers: this.headers() },
|
|
382
|
+
);
|
|
383
|
+
if (!res.ok) throw new Error(`admin revoke role ${res.status}: ${await res.text()}`);
|
|
384
|
+
return (await res.json()) as { ok: true; user_id: number; role: string };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async grantAcl(args: {
|
|
388
|
+
project: string;
|
|
389
|
+
userId: number;
|
|
390
|
+
perms: 'read' | 'write';
|
|
391
|
+
}): Promise<{ ok: true; user_id: number; project: string; perms: string }> {
|
|
392
|
+
const res = await fetch(
|
|
393
|
+
this.url(`/v1/admin/projects/${encodeURIComponent(args.project)}/acl`),
|
|
394
|
+
{
|
|
395
|
+
method: 'POST',
|
|
396
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
397
|
+
body: JSON.stringify({ user_id: args.userId, perms: args.perms }),
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
if (!res.ok) throw new Error(`admin grant acl ${res.status}: ${await res.text()}`);
|
|
401
|
+
return (await res.json()) as {
|
|
402
|
+
ok: true;
|
|
403
|
+
user_id: number;
|
|
404
|
+
project: string;
|
|
405
|
+
perms: string;
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async revokeAcl(args: { project: string; userId: number }): Promise<{ ok: true }> {
|
|
410
|
+
const res = await fetch(
|
|
411
|
+
this.url(`/v1/admin/projects/${encodeURIComponent(args.project)}/acl/${args.userId}`),
|
|
412
|
+
{ method: 'DELETE', headers: this.headers() },
|
|
413
|
+
);
|
|
414
|
+
if (!res.ok) throw new Error(`admin revoke acl ${res.status}: ${await res.text()}`);
|
|
415
|
+
return (await res.json()) as { ok: true };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Phase 2.5 — project 의 CF Worker mapping 목록 조회 (dashboard 용).
|
|
420
|
+
* 404 시 null — caller silent 처리.
|
|
421
|
+
*/
|
|
422
|
+
async listCwWorkers(project: string): Promise<CwWorkerRow[] | null> {
|
|
423
|
+
const res = await fetch(this.url(`/v1/workers/${encodeURIComponent(project)}`), {
|
|
424
|
+
headers: this.headers(),
|
|
425
|
+
});
|
|
426
|
+
if (res.status === 404) return null;
|
|
427
|
+
if (!res.ok) throw new Error(`listCwWorkers ${res.status}: ${await res.text()}`);
|
|
428
|
+
const json = (await res.json()) as { workers: CwWorkerRow[] };
|
|
429
|
+
return json.workers;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Phase 2.5 — project ↔ CF Worker mapping 제거.
|
|
434
|
+
*/
|
|
435
|
+
async deleteCwWorker(project: string, workerName: string): Promise<{ ok: true } | null> {
|
|
436
|
+
const res = await fetch(
|
|
437
|
+
this.url(`/v1/workers/${encodeURIComponent(project)}/${encodeURIComponent(workerName)}`),
|
|
438
|
+
{
|
|
439
|
+
method: 'DELETE',
|
|
440
|
+
headers: this.headers(),
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
if (res.status === 404) return null;
|
|
444
|
+
if (!res.ok) throw new Error(`deleteCwWorker ${res.status}: ${await res.text()}`);
|
|
445
|
+
return (await res.json()) as { ok: true };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Phase 2.5 — project ↔ CF Worker mapping 등록 (idempotent upsert).
|
|
450
|
+
* 404 (athsra worker 미 deploy 또는 endpoint 미 등록) 시 null 반환 — caller 가 silent 처리.
|
|
451
|
+
*/
|
|
452
|
+
async registerCwWorker(
|
|
453
|
+
project: string,
|
|
454
|
+
body: {
|
|
455
|
+
workerName: string;
|
|
456
|
+
accountId: string;
|
|
457
|
+
rootDirectory?: string;
|
|
458
|
+
branch?: string;
|
|
459
|
+
triggerUuid?: string;
|
|
460
|
+
},
|
|
461
|
+
): Promise<{ id: number; created: boolean } | null> {
|
|
462
|
+
const res = await fetch(this.url(`/v1/workers/${encodeURIComponent(project)}`), {
|
|
463
|
+
method: 'POST',
|
|
464
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
465
|
+
body: JSON.stringify(body),
|
|
466
|
+
});
|
|
467
|
+
if (res.status === 404) return null;
|
|
468
|
+
if (!res.ok) throw new Error(`registerCwWorker ${res.status}: ${await res.text()}`);
|
|
469
|
+
return (await res.json()) as { id: number; created: boolean };
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Phase 2.5 — project ↔ CF Worker snapshot 갱신 (sync/build 결과 기록).
|
|
474
|
+
* 404 (athsra worker 미 deploy 또는 mapping 미존재) 시 null — caller 가 silent 처리.
|
|
475
|
+
*/
|
|
476
|
+
async updateCwWorker(
|
|
477
|
+
project: string,
|
|
478
|
+
workerName: string,
|
|
479
|
+
body: {
|
|
480
|
+
rootDirectory?: string;
|
|
481
|
+
branch?: string;
|
|
482
|
+
triggerUuid?: string;
|
|
483
|
+
lastSyncedAt?: string;
|
|
484
|
+
lastSyncOutcome?: 'success' | 'fail';
|
|
485
|
+
lastSyncKeyCount?: number;
|
|
486
|
+
lastBuildUuid?: string;
|
|
487
|
+
lastBuildOutcome?: 'success' | 'fail';
|
|
488
|
+
lastBuildAt?: string;
|
|
489
|
+
},
|
|
490
|
+
): Promise<{ ok: true; id: number } | null> {
|
|
491
|
+
const res = await fetch(
|
|
492
|
+
this.url(`/v1/workers/${encodeURIComponent(project)}/${encodeURIComponent(workerName)}`),
|
|
493
|
+
{
|
|
494
|
+
method: 'PATCH',
|
|
495
|
+
headers: this.headers({ 'content-type': 'application/json' }),
|
|
496
|
+
body: JSON.stringify(body),
|
|
497
|
+
},
|
|
498
|
+
);
|
|
499
|
+
if (res.status === 404) return null;
|
|
500
|
+
if (!res.ok) throw new Error(`updateCwWorker ${res.status}: ${await res.text()}`);
|
|
501
|
+
return (await res.json()) as { ok: true; id: number };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async queryAudit(opts?: {
|
|
505
|
+
actor?: string;
|
|
506
|
+
action?: string;
|
|
507
|
+
status?: number;
|
|
508
|
+
from?: string;
|
|
509
|
+
to?: string;
|
|
510
|
+
limit?: number;
|
|
511
|
+
cursor?: string;
|
|
512
|
+
}): Promise<{
|
|
513
|
+
entries: AuditEntry[];
|
|
514
|
+
count: number;
|
|
515
|
+
nextCursor: string | null;
|
|
516
|
+
}> {
|
|
517
|
+
const qs = new URLSearchParams();
|
|
518
|
+
if (opts?.actor) qs.set('actor', opts.actor);
|
|
519
|
+
if (opts?.action) qs.set('action', opts.action);
|
|
520
|
+
if (opts?.status !== undefined) qs.set('status', String(opts.status));
|
|
521
|
+
if (opts?.from) qs.set('from', opts.from);
|
|
522
|
+
if (opts?.to) qs.set('to', opts.to);
|
|
523
|
+
if (opts?.limit !== undefined) qs.set('limit', String(opts.limit));
|
|
524
|
+
if (opts?.cursor) qs.set('cursor', opts.cursor);
|
|
525
|
+
const url = this.url(`/v1/audit${qs.toString() ? `?${qs.toString()}` : ''}`);
|
|
526
|
+
const res = await fetch(url, { headers: this.headers() });
|
|
527
|
+
if (!res.ok) throw new Error(`audit query ${res.status}: ${await res.text()}`);
|
|
528
|
+
return (await res.json()) as {
|
|
529
|
+
entries: AuditEntry[];
|
|
530
|
+
count: number;
|
|
531
|
+
nextCursor: string | null;
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Phase 1.x.9 — audit log row 형식. worker route `/v1/audit` 의 응답 매핑.
|
|
538
|
+
* D1 의 raw row 가 아닌 worker side enriched (meta_json parsed) entry.
|
|
539
|
+
*/
|
|
540
|
+
export interface CwWorkerRow {
|
|
541
|
+
id: number;
|
|
542
|
+
project: string;
|
|
543
|
+
workerName: string;
|
|
544
|
+
accountId: string;
|
|
545
|
+
rootDirectory: string | null;
|
|
546
|
+
branch: string | null;
|
|
547
|
+
lastSyncedAt: string | null;
|
|
548
|
+
lastSyncOutcome: 'success' | 'fail' | null;
|
|
549
|
+
lastSyncKeyCount: number | null;
|
|
550
|
+
triggerUuid: string | null;
|
|
551
|
+
lastBuildUuid: string | null;
|
|
552
|
+
lastBuildOutcome: 'success' | 'fail' | null;
|
|
553
|
+
lastBuildAt: string | null;
|
|
554
|
+
createdAt: string;
|
|
555
|
+
updatedAt: string;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export interface AuditEntry {
|
|
559
|
+
id: number;
|
|
560
|
+
ts: string;
|
|
561
|
+
type: string;
|
|
562
|
+
actor: string;
|
|
563
|
+
action: string;
|
|
564
|
+
request_method: string | null;
|
|
565
|
+
request_path: string | null;
|
|
566
|
+
status: number;
|
|
567
|
+
meta: unknown;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** Phase 2.2 — RBAC user row (admin endpoint 응답). */
|
|
571
|
+
export interface RbacUser {
|
|
572
|
+
id: number;
|
|
573
|
+
identifier: string;
|
|
574
|
+
status: 'active' | 'disabled';
|
|
575
|
+
created_at: string;
|
|
576
|
+
updated_at: string;
|
|
577
|
+
roles: string[];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Phase 2.2 — admin endpoint 호출 helper (client extension via prototype 식). */
|
|
581
|
+
export interface AdminClient {
|
|
582
|
+
listUsers(): Promise<{ users: RbacUser[]; count: number }>;
|
|
583
|
+
inviteUser(args: {
|
|
584
|
+
identifier: string;
|
|
585
|
+
proof: string;
|
|
586
|
+
globalSaltVersion?: string;
|
|
587
|
+
initialRole?: 'admin' | 'dev' | 'viewer' | 'auditor' | 'sa';
|
|
588
|
+
}): Promise<{ ok: true; id: number; identifier: string; status: string; role: string }>;
|
|
589
|
+
grantRole(args: {
|
|
590
|
+
userId: number;
|
|
591
|
+
role: 'admin' | 'dev' | 'viewer' | 'auditor' | 'sa';
|
|
592
|
+
}): Promise<{ ok: true; user_id: number; role: string }>;
|
|
593
|
+
revokeRole(args: {
|
|
594
|
+
userId: number;
|
|
595
|
+
role: 'admin' | 'dev' | 'viewer' | 'auditor' | 'sa';
|
|
596
|
+
}): Promise<{ ok: true; user_id: number; role: string }>;
|
|
597
|
+
grantAcl(args: {
|
|
598
|
+
project: string;
|
|
599
|
+
userId: number;
|
|
600
|
+
perms: 'read' | 'write';
|
|
601
|
+
}): Promise<{ ok: true; user_id: number; project: string; perms: string }>;
|
|
602
|
+
revokeAcl(args: { project: string; userId: number }): Promise<{ ok: true }>;
|
|
248
603
|
}
|
package/src/lib/env-format.ts
CHANGED
|
@@ -29,3 +29,28 @@ export function serializeEnv(plain: Record<string, string>): string {
|
|
|
29
29
|
.map(([k, v]) => `${k}=${v}`)
|
|
30
30
|
.join('\n');
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 빈 값 키 (value === '') 를 정상 키와 분리한다.
|
|
35
|
+
*
|
|
36
|
+
* athsra 는 빈 값 키를 "미설정" 으로 취급한다 — `run` 은 inject 를 건너뛰어
|
|
37
|
+
* 부모 환경 변수를 보존하고, `ls`/`doctor` 는 `(empty)` 로 노출한다. 키 이름만
|
|
38
|
+
* 등록되고 값이 누락된 산출물 (migration scaffolding) 이 유효한 부모 env 를
|
|
39
|
+
* silently 덮어써 배포 인증을 깨뜨리는 사고를 차단하기 위한 "빈 값" 의 단일 정의.
|
|
40
|
+
*/
|
|
41
|
+
export function partitionEnv(plain: Record<string, string>): {
|
|
42
|
+
filled: Record<string, string>;
|
|
43
|
+
emptyKeys: string[];
|
|
44
|
+
} {
|
|
45
|
+
const filled: Record<string, string> = {};
|
|
46
|
+
const emptyKeys: string[] = [];
|
|
47
|
+
for (const [key, value] of Object.entries(plain)) {
|
|
48
|
+
if (value === '') {
|
|
49
|
+
emptyKeys.push(key);
|
|
50
|
+
} else {
|
|
51
|
+
filled[key] = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
emptyKeys.sort();
|
|
55
|
+
return { filled, emptyKeys };
|
|
56
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* envelope dispatcher — v1/v2 read 통합 + v2-only write, user/service mode 모두.
|
|
3
|
+
*
|
|
4
|
+
* 모든 CLI command 가 사용. user mode 는 master pw 로, service mode 는 ats_*
|
|
5
|
+
* token 자체를 Argon2id wrap secret 으로 사용해 recipient 의 wrapped DEK 를 풀어
|
|
6
|
+
* 본문 복호 (master pw 없이도 E2EE 유지). v1 envelope (legacy) 도 user mode 에서
|
|
7
|
+
* 자동 처리 — service mode 는 v2 만 가능 (v1 은 recipients[] 없음).
|
|
8
|
+
*
|
|
9
|
+
* write 는 항상 v2 (DEK + master recipient 만). service mode 는 write 시도 시
|
|
10
|
+
* 에러 (worker 가 scope_perms='read' 거부할 뿐 아니라 client 측에서 사전 차단).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createEnvelopeV2,
|
|
15
|
+
decrypt,
|
|
16
|
+
decryptEnvelopeV2,
|
|
17
|
+
deriveKey,
|
|
18
|
+
fromBase64,
|
|
19
|
+
type SecretEnvelopeAny,
|
|
20
|
+
type SecretEnvelopeV2,
|
|
21
|
+
} from '@athsra/crypto';
|
|
22
|
+
import type { AuthContext } from './auth-context.ts';
|
|
23
|
+
import { parseEnv, serializeEnv } from './env-format.ts';
|
|
24
|
+
|
|
25
|
+
async function decryptEnvelope(env: SecretEnvelopeAny, ctx: AuthContext): Promise<string> {
|
|
26
|
+
if (ctx.kind === 'user') {
|
|
27
|
+
if (env.version === 2) {
|
|
28
|
+
return decryptEnvelopeV2(env, ctx.masterPw, 'master');
|
|
29
|
+
}
|
|
30
|
+
const key = deriveKey(ctx.masterPw, fromBase64(env.salt), env.kdf_params);
|
|
31
|
+
return decrypt(key, {
|
|
32
|
+
ciphertext: fromBase64(env.ciphertext),
|
|
33
|
+
nonce: fromBase64(env.nonce),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
// service mode: v2 만 가능 (recipient unwrap)
|
|
37
|
+
if (env.version !== 2) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'service token requires envelope v2 — run `athsra rotate-master` (or any `athsra set`) ' +
|
|
40
|
+
'on a user-token machine to migrate v1 → v2 first',
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
return decryptEnvelopeV2(env, ctx.tokenSecret, ctx.recipientId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Fetch + decrypt project envelope (v1 또는 v2, user 또는 service mode), 파싱된
|
|
48
|
+
* env map 반환. envelope 없으면 null.
|
|
49
|
+
*/
|
|
50
|
+
export async function readPlain(
|
|
51
|
+
ctx: AuthContext,
|
|
52
|
+
project: string,
|
|
53
|
+
): Promise<Record<string, string> | null> {
|
|
54
|
+
const env = await ctx.client.getEnvelope(project);
|
|
55
|
+
if (!env) return null;
|
|
56
|
+
const text = await decryptEnvelope(env, ctx);
|
|
57
|
+
return parseEnv(text);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Encrypt + write project envelope as v2. user mode 만 — service mode 는 에러
|
|
62
|
+
* (master pw 없으므로 fresh v2 envelope 를 만들 수 없음; worker 도 scope_perms
|
|
63
|
+
* 검사로 거부함, 여기선 client 측에서 사전 차단).
|
|
64
|
+
*/
|
|
65
|
+
export async function writePlain(
|
|
66
|
+
ctx: AuthContext,
|
|
67
|
+
project: string,
|
|
68
|
+
plain: Record<string, string>,
|
|
69
|
+
): Promise<SecretEnvelopeV2> {
|
|
70
|
+
if (ctx.kind !== 'user') {
|
|
71
|
+
throw new Error('service token cannot write envelopes — use a user token (master pw)');
|
|
72
|
+
}
|
|
73
|
+
const env = await createEnvelopeV2(serializeEnv(plain), ctx.masterPw);
|
|
74
|
+
await ctx.client.putEnvelope(project, env);
|
|
75
|
+
return env;
|
|
76
|
+
}
|