dondo-donuts 0.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.
@@ -0,0 +1,24 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+ import { vaultKey } from './secret.ts';
3
+
4
+ const VERSION_PREFIX = 'enc:v1:';
5
+ const IV_BYTES = 12;
6
+ const AUTH_TAG_BYTES = 16;
7
+
8
+ export const seal = async (text: string) => {
9
+ const iv = randomBytes(IV_BYTES);
10
+ const cipher = createCipheriv('aes-256-gcm', await vaultKey(), iv);
11
+ const body = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
12
+ return `${VERSION_PREFIX}${Buffer.concat([iv, cipher.getAuthTag(), body]).toString('base64')}`;
13
+ };
14
+
15
+ export const open = async (text: string) => {
16
+ if (!text.startsWith(VERSION_PREFIX)) {
17
+ return text;
18
+ }
19
+
20
+ const raw = Buffer.from(text.slice(VERSION_PREFIX.length), 'base64');
21
+ const decipher = createDecipheriv('aes-256-gcm', await vaultKey(), raw.subarray(0, IV_BYTES));
22
+ decipher.setAuthTag(raw.subarray(IV_BYTES, IV_BYTES + AUTH_TAG_BYTES));
23
+ return Buffer.concat([decipher.update(raw.subarray(IV_BYTES + AUTH_TAG_BYTES)), decipher.final()]).toString('utf8');
24
+ };
@@ -0,0 +1,23 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, mkdir, open, rename, rm } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+
5
+ export const writePrivateFile = async (path: string, text: string) => {
6
+ await mkdir(dirname(path), { recursive: true });
7
+ const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
8
+ let handle: Awaited<ReturnType<typeof open>> | undefined;
9
+
10
+ try {
11
+ handle = await open(tempPath, 'w', 0o600);
12
+ await handle.writeFile(text, 'utf8');
13
+ await handle.sync();
14
+ await handle.close();
15
+ handle = undefined;
16
+ await rename(tempPath, path);
17
+ await chmod(path, 0o600);
18
+ } catch (error) {
19
+ await handle?.close().catch(() => {});
20
+ await rm(tempPath, { force: true }).catch(() => {});
21
+ throw error;
22
+ }
23
+ };
@@ -0,0 +1,50 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { VAULT_KEY_ACCOUNT, VAULT_KEY_SERVICE } from '../config.ts';
3
+ import { isRunError, run } from '../shell.ts';
4
+
5
+ let cachedKey: Buffer | undefined;
6
+
7
+ const digestSecret = (secret: string) => createHash('sha256').update(secret).digest();
8
+
9
+ const readVaultSecret = async () => {
10
+ return await run('security', ['find-generic-password', '-s', VAULT_KEY_SERVICE, '-a', VAULT_KEY_ACCOUNT, '-w'])
11
+ .then(({ stdout }) => stdout.trim())
12
+ .catch((error) => {
13
+ if (isRunError(error) && error.code === 44) {
14
+ return '';
15
+ }
16
+ throw error;
17
+ });
18
+ };
19
+
20
+ export const vaultKey = async () => {
21
+ if (cachedKey) {
22
+ return cachedKey;
23
+ }
24
+
25
+ const found = await readVaultSecret();
26
+ if (found) {
27
+ cachedKey = digestSecret(found);
28
+ return cachedKey;
29
+ }
30
+
31
+ const secret = randomBytes(32).toString('base64');
32
+ await run('security', [
33
+ 'add-generic-password',
34
+ '-s',
35
+ VAULT_KEY_SERVICE,
36
+ '-a',
37
+ VAULT_KEY_ACCOUNT,
38
+ '-w',
39
+ secret,
40
+ ]).catch(async (error) => {
41
+ const racedSecret = await readVaultSecret();
42
+ if (racedSecret) {
43
+ return;
44
+ }
45
+ throw error;
46
+ });
47
+
48
+ cachedKey = digestSecret((await readVaultSecret()) || secret);
49
+ return cachedKey;
50
+ };
@@ -0,0 +1,72 @@
1
+ import { expect, it } from 'bun:test';
2
+ import { mkdtemp, rm, stat } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { readVault, updateVault, writeVault } from './vault.ts';
6
+
7
+ const tempVault = async () => {
8
+ const dir = await mkdtemp(join(tmpdir(), 'dondo-vault-test-'));
9
+ return { dir, path: join(dir, 'vault.json') };
10
+ };
11
+
12
+ it('should read a missing vault as nested empty platform sections', async () => {
13
+ const { dir, path } = await tempVault();
14
+ try {
15
+ await expect(readVault(path)).resolves.toEqual({
16
+ antigravity: { data: {}, limits: {} },
17
+ codex: { data: {}, limits: {} },
18
+ });
19
+ } finally {
20
+ await rm(dir, { force: true, recursive: true });
21
+ }
22
+ });
23
+
24
+ it('should write the vault with private file permissions', async () => {
25
+ const { dir, path } = await tempVault();
26
+ try {
27
+ await writeVault({ antigravity: { data: {}, limits: {} }, codex: { data: {}, limits: {} } }, path);
28
+
29
+ expect((await stat(path)).mode & 0o777).toBe(0o600);
30
+ } finally {
31
+ await rm(dir, { force: true, recursive: true });
32
+ }
33
+ });
34
+
35
+ it('should include the vault path in corrupt JSON errors', async () => {
36
+ const { dir, path } = await tempVault();
37
+ try {
38
+ await Bun.write(path, '{');
39
+
40
+ await expect(readVault(path)).rejects.toThrow(`Vault file is not valid JSON: ${path}`);
41
+ } finally {
42
+ await rm(dir, { force: true, recursive: true });
43
+ }
44
+ });
45
+
46
+ it('should serialize queued vault updates', async () => {
47
+ const { dir, path } = await tempVault();
48
+ try {
49
+ await Promise.all([
50
+ updateVault(async (vault) => {
51
+ vault.antigravity.limits.a = {
52
+ fetchedAt: 'a',
53
+ quota: { error: 'a', ok: false },
54
+ };
55
+ return { result: undefined };
56
+ }, path),
57
+ updateVault(async (vault) => {
58
+ vault.codex.limits.b = {
59
+ fetchedAt: 'b',
60
+ quota: { error: 'b', ok: false },
61
+ };
62
+ return { result: undefined };
63
+ }, path),
64
+ ]);
65
+
66
+ const vault = await readVault(path);
67
+ expect(vault.antigravity.limits.a?.fetchedAt).toBe('a');
68
+ expect(vault.codex.limits.b?.fetchedAt).toBe('b');
69
+ } finally {
70
+ await rm(dir, { force: true, recursive: true });
71
+ }
72
+ });
@@ -0,0 +1,130 @@
1
+ import { VAULT_PATH } from '../config.ts';
2
+ import { publicError } from '../errors.ts';
3
+ import type { AppVault, CodexSnapshot, CodexVault, PlatformVault, Snapshot, VaultSection } from '../types.ts';
4
+ import { open, seal } from './crypto.ts';
5
+ import { writePrivateFile } from './file.ts';
6
+
7
+ type VaultUpdate<T> = {
8
+ result: T;
9
+ write?: boolean;
10
+ };
11
+
12
+ const emptySection = <T>(): VaultSection<T> => ({ data: {}, limits: {} });
13
+
14
+ const isRecord = (value: unknown): value is Record<string, unknown> => {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ };
17
+
18
+ let vaultQueue = Promise.resolve();
19
+
20
+ const normalizeVault = (raw: unknown): AppVault => {
21
+ const input = isRecord(raw) ? raw : {};
22
+ const antigravity = isRecord(input.antigravity) ? input.antigravity : {};
23
+ const codex = isRecord(input.codex) ? input.codex : {};
24
+
25
+ return {
26
+ antigravity: {
27
+ data: isRecord(antigravity.data) ? (antigravity.data as Record<string, Snapshot>) : {},
28
+ limits: isRecord(antigravity.limits) ? (antigravity.limits as PlatformVault['limits']) : {},
29
+ },
30
+ codex: {
31
+ data: isRecord(codex.data) ? (codex.data as Record<string, CodexSnapshot>) : {},
32
+ limits: isRecord(codex.limits) ? (codex.limits as CodexVault['limits']) : {},
33
+ },
34
+ };
35
+ };
36
+
37
+ const decryptPlatform = async (platform: PlatformVault): Promise<PlatformVault> => ({
38
+ data: Object.fromEntries(
39
+ await Promise.all(
40
+ Object.entries(platform.data ?? {}).map(async ([key, snap]) => [
41
+ key,
42
+ { ...snap, password: await open(snap.password) },
43
+ ]),
44
+ ),
45
+ ),
46
+ limits: platform.limits ?? {},
47
+ });
48
+
49
+ const encryptPlatform = async (platform: PlatformVault): Promise<PlatformVault> => ({
50
+ data: Object.fromEntries(
51
+ await Promise.all(
52
+ Object.entries(platform.data ?? {}).map(async ([key, snap]) => [
53
+ key,
54
+ { ...snap, password: await seal(snap.password) },
55
+ ]),
56
+ ),
57
+ ),
58
+ limits: platform.limits ?? {},
59
+ });
60
+
61
+ const decryptCodex = async (codex: CodexVault): Promise<CodexVault> => ({
62
+ data: Object.fromEntries(
63
+ await Promise.all(
64
+ Object.entries(codex.data ?? {}).map(async ([key, snap]) => [
65
+ key,
66
+ { ...snap, auth: await open(snap.auth) },
67
+ ]),
68
+ ),
69
+ ),
70
+ limits: codex.limits ?? {},
71
+ });
72
+
73
+ const encryptCodex = async (codex: CodexVault): Promise<CodexVault> => ({
74
+ data: Object.fromEntries(
75
+ await Promise.all(
76
+ Object.entries(codex.data ?? {}).map(async ([key, snap]) => [
77
+ key,
78
+ { ...snap, auth: await seal(snap.auth) },
79
+ ]),
80
+ ),
81
+ ),
82
+ limits: codex.limits ?? {},
83
+ });
84
+
85
+ const readVaultFile = async (path: string): Promise<AppVault> => {
86
+ const text = await Bun.file(path).text();
87
+ let parsed: unknown = {};
88
+ try {
89
+ parsed = text.trim() ? JSON.parse(text) : {};
90
+ } catch {
91
+ throw publicError(500, `Vault file is not valid JSON: ${path}`);
92
+ }
93
+ const vault = normalizeVault(parsed);
94
+ return { antigravity: await decryptPlatform(vault.antigravity), codex: await decryptCodex(vault.codex) };
95
+ };
96
+
97
+ export const writeVault = async (vault: AppVault, path = VAULT_PATH) => {
98
+ await writePrivateFile(
99
+ path,
100
+ `${JSON.stringify(
101
+ { antigravity: await encryptPlatform(vault.antigravity), codex: await encryptCodex(vault.codex) },
102
+ null,
103
+ 2,
104
+ )}\n`,
105
+ );
106
+ };
107
+
108
+ export const readVault = async (path = VAULT_PATH): Promise<AppVault> => {
109
+ const file = Bun.file(path);
110
+ if (!(await file.exists())) {
111
+ return { antigravity: emptySection<Snapshot>(), codex: emptySection<CodexSnapshot>() };
112
+ }
113
+ return readVaultFile(path);
114
+ };
115
+
116
+ export const updateVault = async <T>(operation: (vault: AppVault) => Promise<VaultUpdate<T>>, path = VAULT_PATH) => {
117
+ const queued = vaultQueue.then(async () => {
118
+ const vault = await readVault(path);
119
+ const update = await operation(vault);
120
+ if (update.write !== false) {
121
+ await writeVault(vault, path);
122
+ }
123
+ return update.result;
124
+ });
125
+ vaultQueue = queued.then(
126
+ () => undefined,
127
+ () => undefined,
128
+ );
129
+ return queued;
130
+ };
package/src/types.ts ADDED
@@ -0,0 +1,54 @@
1
+ export type Snapshot = {
2
+ service: string;
3
+ account: string;
4
+ label: string;
5
+ kind: string;
6
+ password: string;
7
+ createdAt: string;
8
+ updatedAt: string;
9
+ };
10
+
11
+ export type TokenPayload = {
12
+ token?: {
13
+ access_token?: string;
14
+ refresh_token?: string;
15
+ expiry?: string;
16
+ token_type?: string;
17
+ };
18
+ auth_method?: string;
19
+ };
20
+
21
+ export type ModelLimit = {
22
+ percentage: number;
23
+ resetTime: string;
24
+ displayName: string;
25
+ };
26
+
27
+ export type LimitResult =
28
+ | { ok: true; tier: string; expires: string; models: Record<string, ModelLimit> }
29
+ | { ok: false; error: string };
30
+
31
+ export type LimitCache = {
32
+ fetchedAt: string;
33
+ quota: LimitResult;
34
+ };
35
+
36
+ export type VaultSection<T> = {
37
+ data: Record<string, T>;
38
+ limits: Record<string, LimitCache>;
39
+ };
40
+
41
+ export type PlatformVault = VaultSection<Snapshot>;
42
+
43
+ export type CodexSnapshot = {
44
+ auth: string;
45
+ createdAt: string;
46
+ updatedAt: string;
47
+ };
48
+
49
+ export type CodexVault = VaultSection<CodexSnapshot>;
50
+
51
+ export type AppVault = {
52
+ antigravity: PlatformVault;
53
+ codex: CodexVault;
54
+ };