Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,24 @@ npm install -g @crypt.fyi/cli

```bash
cfyi --help
cfyi encrypt --help
```

### Encrypt with a webhook

Use the same webhook options as the website's advanced settings. The server receives `POST` notifications at your URL when the configured events occur.

```bash
cfyi encrypt "your secret" \
--wh-url https://example.com/hooks/crypt-fyi \
--wh-events read,burn \
--wh-name "CI token"
```

Available events for `--wh-events`:
- `read` - When the secret is read successfully (default)
- `burn` - When the secret is burned
- `failed-key` - When decryption fails (wrong key or password)
- `failed-ip` - When the viewer IP fails the IP allow-list

Multiple events can be specified as a comma-separated list (e.g., `read,burn,failed-key`).
24 changes: 24 additions & 0 deletions packages/cli/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest/presets/default-esm',
testEnvironment: 'node',
testMatch: ['<rootDir>/src/**/*.test.ts'],
moduleFileExtensions: ['ts', 'js', 'json'],
extensionsToTreatAsEsm: ['.ts'],
moduleNameMapper: {
'^(\\.{1,2}/.*)\\.js$': '$1',
},
transform: {
'^.+\\.tsx?$': [
'ts-jest',
{
useESM: true,
tsconfig: {
module: 'nodenext',
moduleResolution: 'nodenext',
target: 'es2020',
},
},
],
},
};
6 changes: 5 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"build": "ncc build src/index.ts -o dist",
"lint": "eslint \"src/**/*.ts\"",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
"clean": "rm -rf dist",
"test": "jest --verbose"
},
"dependencies": {
"@crypt.fyi/core": "*",
Expand All @@ -29,11 +30,14 @@
"zod": "^4.1.5"
},
"devDependencies": {
"@types/jest": "^30.0.0",
"@types/node": "^22.18.0",
"@typescript-eslint/eslint-plugin": "^8.41.0",
"@typescript-eslint/parser": "^8.41.0",
"@vercel/ncc": "^0.38.3",
"eslint": "^9.34.0",
"jest": "^30.1.1",
"ts-jest": "^29.4.1",
"typescript": "^5.9.2"
}
}
38 changes: 36 additions & 2 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { readFileSync, writeFileSync } from 'fs';
import { Client } from '@crypt.fyi/core';
import { Client, vaultValueSchema } from '@crypt.fyi/core';
import chalk from 'chalk';
import ora from 'ora';
import { config } from './config';
import { config } from './config.js';
import { parseWhEvents, trimmedWhName } from './webhook.js';

const program = new Command();

Expand Down Expand Up @@ -32,12 +33,29 @@ program
.option('-b, --burn', 'Burn after reading', true)
.option('--ip <ip>', 'Restrict access to specific IP address')
.option('-r, --reads <count>', 'Number of times the secret can be read', undefined)
.option('--wh-url <url>', 'Webhook URL for notifications')
.option('--wh-events <list>', 'Comma-separated events: read, burn, failed-key, failed-ip', 'read')
.option('--wh-name <name>', 'Optional label for this secret in webhook payloads (max 50 chars)')
.action(async (content, options) => {
if (options.file && content) {
console.error(chalk.red('Cannot provide both content and file'));
process.exit(1);
}

const whUrl = options.whUrl?.trim();

// Webhook options only apply with a non-empty `--wh-url`; reject dangling options
if (!whUrl) {
if (options.whName) {
console.error(chalk.red('--wh-name requires --wh-url'));
process.exit(1);
}
if (options.whEvents !== 'read') {
console.error(chalk.red('--wh-events requires --wh-url'));
process.exit(1);
}
}

if (options.file) {
try {
content = readFileSync(options.file, 'utf-8');
Expand All @@ -47,6 +65,21 @@ program
}
}

let whConfig;
try {
if (whUrl) {
const eventFlags = parseWhEvents(options.whEvents);
whConfig = vaultValueSchema.shape.wh.unwrap().parse({
u: whUrl,
n: trimmedWhName(options.whName),
...eventFlags,
});
}
} catch (e) {
console.error(chalk.red(e instanceof Error ? e.message : 'Unknown error'));
process.exit(1);
}

const spinner = ora('Encrypting content...').start();
try {
spinner.text = 'Creating vault...';
Expand All @@ -57,6 +90,7 @@ program
ttl: parseDuration(options.ttl),
ips: options.ip,
rc: options.reads ? parseInt(options.reads, 10) : undefined,
...(whConfig ? { wh: whConfig } : {}),
});

spinner.succeed('Secret encrypted and stored successfully!');
Expand Down
77 changes: 77 additions & 0 deletions packages/cli/src/webhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect } from '@jest/globals';
import { parseWhEvents, trimmedWhName } from './webhook.js';

describe('parseWhEvents', () => {
it('should parse a single event', () => {
const result = parseWhEvents('read');
expect(result).toEqual({ r: true, fpk: false, fip: false, b: false });
});

it('should parse multiple events', () => {
const result = parseWhEvents('read,burn');
expect(result).toEqual({ r: true, fpk: false, fip: false, b: true });
});

it('should parse all events', () => {
const result = parseWhEvents('read,failed-key,failed-ip,burn');
expect(result).toEqual({ r: true, fpk: true, fip: true, b: true });
});

it('should handle whitespace around events', () => {
const result = parseWhEvents(' read , burn , failed-key ');
expect(result).toEqual({ r: true, fpk: true, fip: false, b: true });
});

it('should handle empty string (default to no events)', () => {
const result = parseWhEvents('');
expect(result).toEqual({ r: false, fpk: false, fip: false, b: false });
});

it('should handle whitespace-only string (default to no events)', () => {
const result = parseWhEvents(' ');
expect(result).toEqual({ r: false, fpk: false, fip: false, b: false });
});

it('should handle empty segments between commas', () => {
const result = parseWhEvents('read,,burn');
expect(result).toEqual({ r: true, fpk: false, fip: false, b: true });
});

it('should handle duplicate events', () => {
const result = parseWhEvents('read,read,burn,burn');
expect(result).toEqual({ r: true, fpk: false, fip: false, b: true });
});

it('should throw error for unknown event', () => {
expect(() => parseWhEvents('read,unknown-event')).toThrow(
"Unknown webhook event: 'unknown-event'. Valid: read, failed-key, failed-ip, burn",
);
});

it('should handle events in different order', () => {
const result = parseWhEvents('burn,failed-ip,failed-key,read');
expect(result).toEqual({ r: true, fpk: true, fip: true, b: true });
});
});

describe('trimmedWhName', () => {
it('should return undefined for undefined input', () => {
expect(trimmedWhName(undefined)).toBeUndefined();
});

it('should return undefined for empty string', () => {
expect(trimmedWhName('')).toBeUndefined();
});

it('should return undefined for whitespace-only string', () => {
expect(trimmedWhName(' ')).toBeUndefined();
});

it('should trim whitespace from name', () => {
expect(trimmedWhName(' my webhook ')).toBe('my webhook');
});

it('should return name as-is when no whitespace', () => {
expect(trimmedWhName('my-webhook')).toBe('my-webhook');
});
});
35 changes: 35 additions & 0 deletions packages/cli/src/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export const VALID_EVENTS = ['read', 'failed-key', 'failed-ip', 'burn'] as const;

export const EVENT_TO_KEY: Record<(typeof VALID_EVENTS)[number], 'r' | 'fpk' | 'fip' | 'b'> = {
read: 'r',
'failed-key': 'fpk',
'failed-ip': 'fip',
burn: 'b',
};

export function parseWhEvents(raw: string): { r: boolean; fpk: boolean; fip: boolean; b: boolean } {
const events = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);

const flags = { r: false, fpk: false, fip: false, b: false };

for (const event of events) {
if (!(event in EVENT_TO_KEY)) {
throw new Error(`Unknown webhook event: '${event}'. Valid: ${VALID_EVENTS.join(', ')}`);
}
flags[EVENT_TO_KEY[event as keyof typeof EVENT_TO_KEY]] = true;
}

return flags;
}

export function trimmedWhName(name: string | undefined): string | undefined {
if (name === undefined) {
return undefined;
}

const trimmedName = name.trim();
return trimmedName === '' ? undefined : trimmedName;
}
Loading
Loading