@jagit/shared 0.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/dist/approval-bridge.d.ts +12 -0
- package/dist/approval-bridge.js +35 -0
- package/dist/branch.d.ts +10 -0
- package/dist/branch.js +20 -0
- package/dist/branch.test.d.ts +1 -0
- package/dist/branch.test.js +32 -0
- package/dist/config.d.ts +19 -0
- package/dist/config.js +36 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +31 -0
- package/dist/credentials.d.ts +82 -0
- package/dist/credentials.js +94 -0
- package/dist/credentials.test.d.ts +1 -0
- package/dist/credentials.test.js +206 -0
- package/dist/crypto.d.ts +12 -0
- package/dist/crypto.js +29 -0
- package/dist/crypto.test.d.ts +1 -0
- package/dist/crypto.test.js +20 -0
- package/dist/events.d.ts +13 -0
- package/dist/events.js +24 -0
- package/dist/git-worktree.d.ts +2 -0
- package/dist/git-worktree.js +14 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +14 -0
- package/dist/mcp-config.d.ts +77 -0
- package/dist/mcp-config.js +71 -0
- package/dist/mcp-servers.d.ts +54 -0
- package/dist/mcp-servers.js +73 -0
- package/dist/prisma.d.ts +3 -0
- package/dist/prisma.js +20 -0
- package/dist/prisma.test.d.ts +1 -0
- package/dist/prisma.test.js +16 -0
- package/dist/queue.d.ts +10 -0
- package/dist/queue.js +7 -0
- package/dist/retry.d.ts +5 -0
- package/dist/retry.js +16 -0
- package/dist/retry.test.d.ts +1 -0
- package/dist/retry.test.js +24 -0
- package/dist/seed.d.ts +99 -0
- package/dist/seed.js +123 -0
- package/dist/seed.test.d.ts +1 -0
- package/dist/seed.test.js +126 -0
- package/dist/types.d.ts +16 -0
- package/dist/types.js +5 -0
- package/package.json +29 -0
- package/prisma/migrations/20260615000000_init/migration.sql +157 -0
- package/prisma/migrations/20260615000001_add_updated_at_agent_template/migration.sql +2 -0
- package/prisma/migrations/20260616000000_mcp_review/migration.sql +23 -0
- package/prisma/migrations/20260616100000_mcp_http_transport/migration.sql +5 -0
- package/prisma/migrations/20260616120000_review_default_false/migration.sql +1 -0
- package/prisma/migrations/20260620040203_add_usage_models/migration.sql +28 -0
- package/prisma/migrations/20260620120000_add_agent_session/migration.sql +35 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +257 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface WaitForApprovalDecisionOpts {
|
|
2
|
+
redisUrl: string;
|
|
3
|
+
jobId: string;
|
|
4
|
+
approvalId: string;
|
|
5
|
+
timeoutMs: number;
|
|
6
|
+
denyOptionId: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Subscribes to the job control channel and waits for an approval signal
|
|
10
|
+
* matching `approvalId`. On timeout returns `denyOptionId`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function waitForApprovalDecision(opts: WaitForApprovalDecisionOpts): Promise<string>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { makeRedis, controlChannel } from "./events.js";
|
|
2
|
+
/**
|
|
3
|
+
* Subscribes to the job control channel and waits for an approval signal
|
|
4
|
+
* matching `approvalId`. On timeout returns `denyOptionId`.
|
|
5
|
+
*/
|
|
6
|
+
export function waitForApprovalDecision(opts) {
|
|
7
|
+
const redis = makeRedis(opts.redisUrl);
|
|
8
|
+
const channel = controlChannel(opts.jobId);
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
let resolved = false;
|
|
11
|
+
const finish = (optionId) => {
|
|
12
|
+
if (resolved)
|
|
13
|
+
return;
|
|
14
|
+
resolved = true;
|
|
15
|
+
clearTimeout(timer);
|
|
16
|
+
redis.quit().catch(() => { });
|
|
17
|
+
resolve(optionId);
|
|
18
|
+
};
|
|
19
|
+
const timer = setTimeout(() => finish(opts.denyOptionId), opts.timeoutMs);
|
|
20
|
+
redis.subscribe(channel).catch(() => finish(opts.denyOptionId));
|
|
21
|
+
redis.on("message", (_ch, msg) => {
|
|
22
|
+
try {
|
|
23
|
+
const signal = JSON.parse(msg);
|
|
24
|
+
if (signal.type === "approval" &&
|
|
25
|
+
signal.approvalId === opts.approvalId &&
|
|
26
|
+
signal.chosenOptionId) {
|
|
27
|
+
finish(signal.chosenOptionId);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* ignore malformed */
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
package/dist/branch.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface IssueRef {
|
|
2
|
+
key: string;
|
|
3
|
+
type: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
}
|
|
6
|
+
export type BranchRules = Record<string, string> & {
|
|
7
|
+
default?: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function deriveBranchName(issue: IssueRef, rules: BranchRules): string;
|
|
10
|
+
export declare function extractIssueKey(branch: string): string | null;
|
package/dist/branch.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function slugify(s) {
|
|
2
|
+
return s
|
|
3
|
+
.normalize("NFKD")
|
|
4
|
+
.replace(/\p{M}/gu, "") // strip combining diacritics (Unicode property escape)
|
|
5
|
+
.replace(/[^\w\s-]/g, "") // strip non-word chars
|
|
6
|
+
.trim()
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/[\s_]+/g, "-")
|
|
9
|
+
.replace(/-+/g, "-")
|
|
10
|
+
.slice(0, 40)
|
|
11
|
+
.replace(/-$/, "");
|
|
12
|
+
}
|
|
13
|
+
export function deriveBranchName(issue, rules) {
|
|
14
|
+
const prefix = rules[issue.type] ?? rules.default ?? "feature/";
|
|
15
|
+
return `${prefix}${issue.key}-${slugify(issue.summary)}`;
|
|
16
|
+
}
|
|
17
|
+
export function extractIssueKey(branch) {
|
|
18
|
+
const match = branch.match(/([A-Z][A-Z0-9]+-\d+)/);
|
|
19
|
+
return match ? match[1] : null;
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { deriveBranchName, extractIssueKey } from "./branch.js";
|
|
3
|
+
const RULES = { Bug: "bugfix/", Story: "feature/", Task: "feature/", default: "feature/" };
|
|
4
|
+
describe("deriveBranchName", () => {
|
|
5
|
+
it("uses type-specific prefix for Bug", () => {
|
|
6
|
+
expect(deriveBranchName({ key: "JAGIT-12", type: "Bug", summary: "Fix Login!" }, RULES)).toBe("bugfix/JAGIT-12-fix-login");
|
|
7
|
+
});
|
|
8
|
+
it("falls back to default prefix for unknown type", () => {
|
|
9
|
+
expect(deriveBranchName({ key: "JAGIT-9", type: "Spike", summary: "Explore caching" }, RULES)).toBe("feature/JAGIT-9-explore-caching");
|
|
10
|
+
});
|
|
11
|
+
it("truncates long summaries to 40 chars in the slug", () => {
|
|
12
|
+
const summary = "This is a very long summary that should be truncated at forty chars";
|
|
13
|
+
const branch = deriveBranchName({ key: "X-1", type: "Bug", summary }, RULES);
|
|
14
|
+
// slug portion (after "bugfix/X-1-") must be ≤ 40 chars
|
|
15
|
+
const slug = branch.replace("bugfix/X-1-", "");
|
|
16
|
+
expect(slug.length).toBeLessThanOrEqual(40);
|
|
17
|
+
});
|
|
18
|
+
it("strips non-ASCII and special characters", () => {
|
|
19
|
+
expect(deriveBranchName({ key: "X-2", type: "Bug", summary: "Ünîcödé & special!!" }, RULES)).toBe("bugfix/X-2-unicode-special");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
describe("extractIssueKey", () => {
|
|
23
|
+
it("extracts a key from a feature branch", () => {
|
|
24
|
+
expect(extractIssueKey("feature/JAGIT-12-fix-login")).toBe("JAGIT-12");
|
|
25
|
+
});
|
|
26
|
+
it("extracts a key from a bugfix branch", () => {
|
|
27
|
+
expect(extractIssueKey("bugfix/PROJ-99-some-bug")).toBe("PROJ-99");
|
|
28
|
+
});
|
|
29
|
+
it("returns null when no key is present", () => {
|
|
30
|
+
expect(extractIssueKey("main")).toBeNull();
|
|
31
|
+
});
|
|
32
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type RawEnv = NodeJS.ProcessEnv | Record<string, string | undefined>;
|
|
2
|
+
export declare function parseConfig(env: RawEnv): {
|
|
3
|
+
databaseUrl: string;
|
|
4
|
+
redisUrl: string;
|
|
5
|
+
encryptionKey: string;
|
|
6
|
+
maxConcurrentAgents: number;
|
|
7
|
+
maxRetries: number;
|
|
8
|
+
approvalTimeoutMs: number;
|
|
9
|
+
acpRequestTimeoutMs: number;
|
|
10
|
+
anthropicApiKey: string;
|
|
11
|
+
telegramBotToken: string;
|
|
12
|
+
publicBaseUrl: string;
|
|
13
|
+
apiPort: number;
|
|
14
|
+
webhookSecret: string;
|
|
15
|
+
dashboardApiToken: string;
|
|
16
|
+
};
|
|
17
|
+
export type AppConfig = ReturnType<typeof parseConfig>;
|
|
18
|
+
/** Loads config from `process.env`. Throws if any required var is missing. */
|
|
19
|
+
export declare const loadConfig: () => AppConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
const Schema = z.object({
|
|
3
|
+
DATABASE_URL: z.string().min(1),
|
|
4
|
+
REDIS_URL: z.string().min(1),
|
|
5
|
+
APP_ENCRYPTION_KEY: z.string().min(1),
|
|
6
|
+
MAX_CONCURRENT_AGENTS: z.coerce.number().int().positive(),
|
|
7
|
+
MAX_RETRIES: z.coerce.number().int().nonnegative(),
|
|
8
|
+
APPROVAL_TIMEOUT_MS: z.coerce.number().int().positive(),
|
|
9
|
+
ACP_REQUEST_TIMEOUT_MS: z.coerce.number().int().positive().default(600_000),
|
|
10
|
+
ANTHROPIC_API_KEY: z.string().min(1),
|
|
11
|
+
TELEGRAM_BOT_TOKEN: z.string().min(1),
|
|
12
|
+
PUBLIC_BASE_URL: z.string().url(),
|
|
13
|
+
API_PORT: z.coerce.number().int().positive().default(3000),
|
|
14
|
+
API_WEBHOOK_SECRET: z.string().min(1),
|
|
15
|
+
DASHBOARD_API_TOKEN: z.string().min(1),
|
|
16
|
+
});
|
|
17
|
+
export function parseConfig(env) {
|
|
18
|
+
const p = Schema.parse(env);
|
|
19
|
+
return {
|
|
20
|
+
databaseUrl: p.DATABASE_URL,
|
|
21
|
+
redisUrl: p.REDIS_URL,
|
|
22
|
+
encryptionKey: p.APP_ENCRYPTION_KEY,
|
|
23
|
+
maxConcurrentAgents: p.MAX_CONCURRENT_AGENTS,
|
|
24
|
+
maxRetries: p.MAX_RETRIES,
|
|
25
|
+
approvalTimeoutMs: p.APPROVAL_TIMEOUT_MS,
|
|
26
|
+
acpRequestTimeoutMs: p.ACP_REQUEST_TIMEOUT_MS,
|
|
27
|
+
anthropicApiKey: p.ANTHROPIC_API_KEY,
|
|
28
|
+
telegramBotToken: p.TELEGRAM_BOT_TOKEN,
|
|
29
|
+
publicBaseUrl: p.PUBLIC_BASE_URL,
|
|
30
|
+
apiPort: p.API_PORT,
|
|
31
|
+
webhookSecret: p.API_WEBHOOK_SECRET,
|
|
32
|
+
dashboardApiToken: p.DASHBOARD_API_TOKEN,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Loads config from `process.env`. Throws if any required var is missing. */
|
|
36
|
+
export const loadConfig = () => parseConfig(process.env);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseConfig } from "./config.js";
|
|
3
|
+
const FULL_ENV = {
|
|
4
|
+
DATABASE_URL: "postgresql://jagit:jagit@localhost:5432/jagit",
|
|
5
|
+
REDIS_URL: "redis://localhost:6379",
|
|
6
|
+
APP_ENCRYPTION_KEY: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa==",
|
|
7
|
+
MAX_CONCURRENT_AGENTS: "3",
|
|
8
|
+
MAX_RETRIES: "3",
|
|
9
|
+
APPROVAL_TIMEOUT_MS: "1800000",
|
|
10
|
+
ANTHROPIC_API_KEY: "sk-ant-test",
|
|
11
|
+
TELEGRAM_BOT_TOKEN: "1234567890:AAAA",
|
|
12
|
+
PUBLIC_BASE_URL: "http://localhost:3000",
|
|
13
|
+
API_PORT: "3000",
|
|
14
|
+
API_WEBHOOK_SECRET: "webhook-secret",
|
|
15
|
+
DASHBOARD_API_TOKEN: "dash-token",
|
|
16
|
+
};
|
|
17
|
+
describe("parseConfig", () => {
|
|
18
|
+
it("rejects an empty env (missing keys)", () => {
|
|
19
|
+
expect(() => parseConfig({})).toThrow();
|
|
20
|
+
});
|
|
21
|
+
it("parses a complete env and coerces numbers", () => {
|
|
22
|
+
const cfg = parseConfig(FULL_ENV);
|
|
23
|
+
expect(cfg.maxConcurrentAgents).toBe(3);
|
|
24
|
+
expect(cfg.approvalTimeoutMs).toBe(1800000);
|
|
25
|
+
expect(cfg.apiPort).toBe(3000);
|
|
26
|
+
expect(cfg.dashboardApiToken).toBe("dash-token");
|
|
27
|
+
});
|
|
28
|
+
it("rejects a non-URL PUBLIC_BASE_URL", () => {
|
|
29
|
+
expect(() => parseConfig({ ...FULL_ENV, PUBLIC_BASE_URL: "not-a-url" })).toThrow();
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const CredentialKindSchema: z.ZodEnum<{
|
|
3
|
+
jira: "jira";
|
|
4
|
+
gitlab: "gitlab";
|
|
5
|
+
telegram: "telegram";
|
|
6
|
+
anthropic: "anthropic";
|
|
7
|
+
}>;
|
|
8
|
+
export type CredentialKind = z.infer<typeof CredentialKindSchema>;
|
|
9
|
+
export declare const JiraCredentialSchema: z.ZodObject<{
|
|
10
|
+
meta: z.ZodObject<{
|
|
11
|
+
baseUrl: z.ZodString;
|
|
12
|
+
botAccountId: z.ZodString;
|
|
13
|
+
}, z.core.$catchall<z.ZodString>>;
|
|
14
|
+
secrets: z.ZodObject<{
|
|
15
|
+
email: z.ZodString;
|
|
16
|
+
token: z.ZodString;
|
|
17
|
+
}, z.core.$strip>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
export declare const GitLabCredentialSchema: z.ZodObject<{
|
|
20
|
+
meta: z.ZodObject<{
|
|
21
|
+
baseUrl: z.ZodString;
|
|
22
|
+
}, z.core.$catchall<z.ZodString>>;
|
|
23
|
+
secrets: z.ZodObject<{
|
|
24
|
+
token: z.ZodString;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
}, z.core.$strip>;
|
|
27
|
+
export declare const AnthropicCredentialSchema: z.ZodObject<{
|
|
28
|
+
meta: z.ZodDefault<z.ZodOptional<z.ZodObject<{
|
|
29
|
+
baseUrl: z.ZodOptional<z.ZodString>;
|
|
30
|
+
}, z.core.$catchall<z.ZodString>>>>;
|
|
31
|
+
secrets: z.ZodObject<{
|
|
32
|
+
apiKey: z.ZodString;
|
|
33
|
+
}, z.core.$strip>;
|
|
34
|
+
}, z.core.$strip>;
|
|
35
|
+
export declare const TelegramCredentialSchema: z.ZodObject<{
|
|
36
|
+
meta: z.ZodObject<{
|
|
37
|
+
chatId: z.ZodString;
|
|
38
|
+
}, z.core.$catchall<z.ZodString>>;
|
|
39
|
+
secrets: z.ZodObject<{
|
|
40
|
+
botToken: z.ZodString;
|
|
41
|
+
}, z.core.$strip>;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
/**
|
|
44
|
+
* Returns the required secret keys for a given credential kind.
|
|
45
|
+
*/
|
|
46
|
+
export declare function credentialSecretKeys(kind: CredentialKind): string[];
|
|
47
|
+
/**
|
|
48
|
+
* Validates a full credential input against the appropriate kind schema.
|
|
49
|
+
*/
|
|
50
|
+
export declare function validateCredential(kind: CredentialKind, input: unknown): {
|
|
51
|
+
meta: {
|
|
52
|
+
[x: string]: string;
|
|
53
|
+
baseUrl: string;
|
|
54
|
+
};
|
|
55
|
+
secrets: {
|
|
56
|
+
token: string;
|
|
57
|
+
};
|
|
58
|
+
} | {
|
|
59
|
+
meta: {
|
|
60
|
+
[x: string]: string;
|
|
61
|
+
baseUrl?: string | undefined;
|
|
62
|
+
};
|
|
63
|
+
secrets: {
|
|
64
|
+
apiKey: string;
|
|
65
|
+
};
|
|
66
|
+
} | {
|
|
67
|
+
meta: {
|
|
68
|
+
[x: string]: string;
|
|
69
|
+
chatId: string;
|
|
70
|
+
};
|
|
71
|
+
secrets: {
|
|
72
|
+
botToken: string;
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Merges provided secrets into existing encrypted secrets.
|
|
77
|
+
* - If existingEncrypted is null, encrypts only the provided secrets.
|
|
78
|
+
* - Non-empty provided fields overwrite existing ones.
|
|
79
|
+
* - Omitted or blank/empty string fields keep existing values.
|
|
80
|
+
* - Returns a new encrypted blob (never plaintext).
|
|
81
|
+
*/
|
|
82
|
+
export declare function mergeSecrets(existingEncrypted: string | null, provided: Record<string, string | undefined>, keyB64: string): string;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { encrypt, decrypt } from "./crypto.js";
|
|
3
|
+
export const CredentialKindSchema = z.enum(["jira", "gitlab", "telegram", "anthropic"]);
|
|
4
|
+
export const JiraCredentialSchema = z.object({
|
|
5
|
+
meta: z
|
|
6
|
+
.object({
|
|
7
|
+
baseUrl: z.string().url(),
|
|
8
|
+
botAccountId: z.string().min(1),
|
|
9
|
+
})
|
|
10
|
+
.catchall(z.string()),
|
|
11
|
+
secrets: z.object({
|
|
12
|
+
email: z.string().min(1),
|
|
13
|
+
token: z.string().min(1),
|
|
14
|
+
}),
|
|
15
|
+
});
|
|
16
|
+
export const GitLabCredentialSchema = z.object({
|
|
17
|
+
meta: z
|
|
18
|
+
.object({
|
|
19
|
+
baseUrl: z.string().url(),
|
|
20
|
+
})
|
|
21
|
+
.catchall(z.string()),
|
|
22
|
+
secrets: z.object({
|
|
23
|
+
token: z.string().min(1),
|
|
24
|
+
}),
|
|
25
|
+
});
|
|
26
|
+
export const AnthropicCredentialSchema = z.object({
|
|
27
|
+
meta: z
|
|
28
|
+
.object({
|
|
29
|
+
baseUrl: z.string().url().optional(),
|
|
30
|
+
})
|
|
31
|
+
.catchall(z.string())
|
|
32
|
+
.optional()
|
|
33
|
+
.default({}),
|
|
34
|
+
secrets: z.object({
|
|
35
|
+
apiKey: z.string().min(1),
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
export const TelegramCredentialSchema = z.object({
|
|
39
|
+
meta: z
|
|
40
|
+
.object({
|
|
41
|
+
chatId: z.string().min(1),
|
|
42
|
+
})
|
|
43
|
+
.catchall(z.string()),
|
|
44
|
+
secrets: z.object({
|
|
45
|
+
botToken: z.string().min(1),
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Returns the required secret keys for a given credential kind.
|
|
50
|
+
*/
|
|
51
|
+
export function credentialSecretKeys(kind) {
|
|
52
|
+
switch (kind) {
|
|
53
|
+
case "jira":
|
|
54
|
+
return ["email", "token"];
|
|
55
|
+
case "gitlab":
|
|
56
|
+
return ["token"];
|
|
57
|
+
case "anthropic":
|
|
58
|
+
return ["apiKey"];
|
|
59
|
+
case "telegram":
|
|
60
|
+
return ["botToken"];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const KindToSchema = {
|
|
64
|
+
jira: JiraCredentialSchema,
|
|
65
|
+
gitlab: GitLabCredentialSchema,
|
|
66
|
+
anthropic: AnthropicCredentialSchema,
|
|
67
|
+
telegram: TelegramCredentialSchema,
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Validates a full credential input against the appropriate kind schema.
|
|
71
|
+
*/
|
|
72
|
+
export function validateCredential(kind, input) {
|
|
73
|
+
return KindToSchema[kind].parse(input);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Merges provided secrets into existing encrypted secrets.
|
|
77
|
+
* - If existingEncrypted is null, encrypts only the provided secrets.
|
|
78
|
+
* - Non-empty provided fields overwrite existing ones.
|
|
79
|
+
* - Omitted or blank/empty string fields keep existing values.
|
|
80
|
+
* - Returns a new encrypted blob (never plaintext).
|
|
81
|
+
*/
|
|
82
|
+
export function mergeSecrets(existingEncrypted, provided, keyB64) {
|
|
83
|
+
const existing = existingEncrypted ? JSON.parse(decrypt(existingEncrypted, keyB64)) : {};
|
|
84
|
+
const merged = {};
|
|
85
|
+
for (const [key, value] of Object.entries(existing)) {
|
|
86
|
+
merged[key] = value;
|
|
87
|
+
}
|
|
88
|
+
for (const [key, value] of Object.entries(provided)) {
|
|
89
|
+
if (value !== undefined && value.trim() !== "") {
|
|
90
|
+
merged[key] = value.trim();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return encrypt(JSON.stringify(merged), keyB64);
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { CredentialKindSchema, JiraCredentialSchema, GitLabCredentialSchema, AnthropicCredentialSchema, TelegramCredentialSchema, credentialSecretKeys, validateCredential, mergeSecrets, } from "./credentials.js";
|
|
3
|
+
import { decrypt } from "./crypto.js";
|
|
4
|
+
const KEY = Buffer.alloc(32, 11).toString("base64");
|
|
5
|
+
describe("CredentialKindSchema", () => {
|
|
6
|
+
it("accepts valid kinds", () => {
|
|
7
|
+
expect(() => CredentialKindSchema.parse("jira")).not.toThrow();
|
|
8
|
+
expect(() => CredentialKindSchema.parse("gitlab")).not.toThrow();
|
|
9
|
+
expect(() => CredentialKindSchema.parse("anthropic")).not.toThrow();
|
|
10
|
+
expect(() => CredentialKindSchema.parse("telegram")).not.toThrow();
|
|
11
|
+
});
|
|
12
|
+
it("rejects invalid kinds", () => {
|
|
13
|
+
expect(() => CredentialKindSchema.parse("unknown")).toThrow();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
describe("JiraCredentialSchema", () => {
|
|
17
|
+
it("accepts valid jira credentials", () => {
|
|
18
|
+
const result = JiraCredentialSchema.parse({
|
|
19
|
+
meta: { baseUrl: "https://jira.example.com", botAccountId: "123" },
|
|
20
|
+
secrets: { email: "bot@example.com", token: "tok" },
|
|
21
|
+
});
|
|
22
|
+
expect(result.meta.baseUrl).toBe("https://jira.example.com");
|
|
23
|
+
expect(result.secrets.email).toBe("bot@example.com");
|
|
24
|
+
});
|
|
25
|
+
it("rejects missing required meta fields", () => {
|
|
26
|
+
expect(() => JiraCredentialSchema.parse({
|
|
27
|
+
meta: { baseUrl: "https://jira.example.com" },
|
|
28
|
+
secrets: { email: "bot@example.com", token: "tok" },
|
|
29
|
+
})).toThrow();
|
|
30
|
+
});
|
|
31
|
+
it("rejects missing required secret fields", () => {
|
|
32
|
+
expect(() => JiraCredentialSchema.parse({
|
|
33
|
+
meta: { baseUrl: "https://jira.example.com", botAccountId: "123" },
|
|
34
|
+
secrets: { email: "bot@example.com" },
|
|
35
|
+
})).toThrow();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe("GitLabCredentialSchema", () => {
|
|
39
|
+
it("accepts valid gitlab credentials", () => {
|
|
40
|
+
const result = GitLabCredentialSchema.parse({
|
|
41
|
+
meta: { baseUrl: "https://gitlab.com" },
|
|
42
|
+
secrets: { token: "glpat-xxx" },
|
|
43
|
+
});
|
|
44
|
+
expect(result.meta.baseUrl).toBe("https://gitlab.com");
|
|
45
|
+
expect(result.secrets.token).toBe("glpat-xxx");
|
|
46
|
+
});
|
|
47
|
+
it("rejects missing required secret fields", () => {
|
|
48
|
+
expect(() => GitLabCredentialSchema.parse({
|
|
49
|
+
meta: { baseUrl: "https://gitlab.com" },
|
|
50
|
+
secrets: {},
|
|
51
|
+
})).toThrow();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
describe("AnthropicCredentialSchema", () => {
|
|
55
|
+
it("accepts valid anthropic credentials", () => {
|
|
56
|
+
const result = AnthropicCredentialSchema.parse({
|
|
57
|
+
meta: {},
|
|
58
|
+
secrets: { apiKey: "sk-ant-xxx" },
|
|
59
|
+
});
|
|
60
|
+
expect(result.secrets.apiKey).toBe("sk-ant-xxx");
|
|
61
|
+
});
|
|
62
|
+
it("rejects missing required secret fields", () => {
|
|
63
|
+
expect(() => AnthropicCredentialSchema.parse({
|
|
64
|
+
meta: {},
|
|
65
|
+
secrets: {},
|
|
66
|
+
})).toThrow();
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
describe("TelegramCredentialSchema", () => {
|
|
70
|
+
it("accepts valid telegram credentials", () => {
|
|
71
|
+
const result = TelegramCredentialSchema.parse({
|
|
72
|
+
meta: { chatId: "123456" },
|
|
73
|
+
secrets: { botToken: "123:abc" },
|
|
74
|
+
});
|
|
75
|
+
expect(result.meta.chatId).toBe("123456");
|
|
76
|
+
expect(result.secrets.botToken).toBe("123:abc");
|
|
77
|
+
});
|
|
78
|
+
it("rejects missing required meta fields", () => {
|
|
79
|
+
expect(() => TelegramCredentialSchema.parse({
|
|
80
|
+
meta: {},
|
|
81
|
+
secrets: { botToken: "123:abc" },
|
|
82
|
+
})).toThrow();
|
|
83
|
+
});
|
|
84
|
+
it("rejects missing required secret fields", () => {
|
|
85
|
+
expect(() => TelegramCredentialSchema.parse({
|
|
86
|
+
meta: { chatId: "123456" },
|
|
87
|
+
secrets: {},
|
|
88
|
+
})).toThrow();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("URL validation", () => {
|
|
92
|
+
it("rejects malformed baseUrl in jira", () => {
|
|
93
|
+
expect(() => JiraCredentialSchema.parse({
|
|
94
|
+
meta: { baseUrl: "not-a-url", botAccountId: "123" },
|
|
95
|
+
secrets: { email: "bot@example.com", token: "tok" },
|
|
96
|
+
})).toThrow();
|
|
97
|
+
});
|
|
98
|
+
it("rejects malformed baseUrl in gitlab", () => {
|
|
99
|
+
expect(() => GitLabCredentialSchema.parse({
|
|
100
|
+
meta: { baseUrl: "not-a-url" },
|
|
101
|
+
secrets: { token: "glpat-xxx" },
|
|
102
|
+
})).toThrow();
|
|
103
|
+
});
|
|
104
|
+
it("rejects malformed baseUrl in anthropic meta", () => {
|
|
105
|
+
expect(() => AnthropicCredentialSchema.parse({
|
|
106
|
+
meta: { baseUrl: "not-a-url" },
|
|
107
|
+
secrets: { apiKey: "sk-ant-xxx" },
|
|
108
|
+
})).toThrow();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe("credentialSecretKeys", () => {
|
|
112
|
+
it("returns correct keys for jira", () => {
|
|
113
|
+
expect(credentialSecretKeys("jira")).toEqual(["email", "token"]);
|
|
114
|
+
});
|
|
115
|
+
it("returns correct keys for gitlab", () => {
|
|
116
|
+
expect(credentialSecretKeys("gitlab")).toEqual(["token"]);
|
|
117
|
+
});
|
|
118
|
+
it("returns correct keys for anthropic", () => {
|
|
119
|
+
expect(credentialSecretKeys("anthropic")).toEqual(["apiKey"]);
|
|
120
|
+
});
|
|
121
|
+
it("returns correct keys for telegram", () => {
|
|
122
|
+
expect(credentialSecretKeys("telegram")).toEqual(["botToken"]);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
describe("validateCredential", () => {
|
|
126
|
+
it("validates jira credentials", () => {
|
|
127
|
+
const result = validateCredential("jira", {
|
|
128
|
+
meta: { baseUrl: "https://jira.example.com", botAccountId: "123" },
|
|
129
|
+
secrets: { email: "bot@example.com", token: "tok" },
|
|
130
|
+
});
|
|
131
|
+
expect(result.meta.baseUrl).toBe("https://jira.example.com");
|
|
132
|
+
});
|
|
133
|
+
it("validates gitlab credentials", () => {
|
|
134
|
+
const result = validateCredential("gitlab", {
|
|
135
|
+
meta: { baseUrl: "https://gitlab.com" },
|
|
136
|
+
secrets: { token: "glpat-xxx" },
|
|
137
|
+
});
|
|
138
|
+
expect(result.meta.baseUrl).toBe("https://gitlab.com");
|
|
139
|
+
});
|
|
140
|
+
it("validates anthropic credentials", () => {
|
|
141
|
+
const result = validateCredential("anthropic", {
|
|
142
|
+
meta: {},
|
|
143
|
+
secrets: { apiKey: "sk-ant-xxx" },
|
|
144
|
+
});
|
|
145
|
+
expect(result.secrets.apiKey).toBe("sk-ant-xxx");
|
|
146
|
+
});
|
|
147
|
+
it("validates telegram credentials", () => {
|
|
148
|
+
const result = validateCredential("telegram", {
|
|
149
|
+
meta: { chatId: "123456" },
|
|
150
|
+
secrets: { botToken: "123:abc" },
|
|
151
|
+
});
|
|
152
|
+
expect(result.meta.chatId).toBe("123456");
|
|
153
|
+
});
|
|
154
|
+
it("rejects invalid credentials", () => {
|
|
155
|
+
expect(() => validateCredential("jira", {
|
|
156
|
+
meta: { baseUrl: "not-a-url", botAccountId: "123" },
|
|
157
|
+
secrets: { email: "bot@example.com", token: "tok" },
|
|
158
|
+
})).toThrow();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
describe("mergeSecrets", () => {
|
|
162
|
+
it("encrypts provided secrets when no existing blob", () => {
|
|
163
|
+
const provided = { apiKey: "sk-ant-new" };
|
|
164
|
+
const result = mergeSecrets(null, provided, KEY);
|
|
165
|
+
expect(typeof result).toBe("string");
|
|
166
|
+
expect(result.split(".")).toHaveLength(3);
|
|
167
|
+
const decrypted = JSON.parse(decrypt(result, KEY));
|
|
168
|
+
expect(decrypted).toEqual({ apiKey: "sk-ant-new" });
|
|
169
|
+
});
|
|
170
|
+
it("overwrites existing secrets with provided non-empty values", () => {
|
|
171
|
+
const existing = mergeSecrets(null, { apiKey: "old-key", orgId: "org-1" }, KEY);
|
|
172
|
+
const result = mergeSecrets(existing, { apiKey: "new-key" }, KEY);
|
|
173
|
+
const decrypted = JSON.parse(decrypt(result, KEY));
|
|
174
|
+
expect(decrypted).toEqual({ apiKey: "new-key", orgId: "org-1" });
|
|
175
|
+
});
|
|
176
|
+
it("keeps existing secrets when provided fields are omitted", () => {
|
|
177
|
+
const existing = mergeSecrets(null, { token: "old-token" }, KEY);
|
|
178
|
+
const result = mergeSecrets(existing, {}, KEY);
|
|
179
|
+
const decrypted = JSON.parse(decrypt(result, KEY));
|
|
180
|
+
expect(decrypted).toEqual({ token: "old-token" });
|
|
181
|
+
});
|
|
182
|
+
it("keeps existing secrets when provided fields are blank", () => {
|
|
183
|
+
const existing = mergeSecrets(null, { token: "old-token", email: "a@b.com" }, KEY);
|
|
184
|
+
const result = mergeSecrets(existing, { token: "", email: undefined }, KEY);
|
|
185
|
+
const decrypted = JSON.parse(decrypt(result, KEY));
|
|
186
|
+
expect(decrypted).toEqual({ token: "old-token", email: "a@b.com" });
|
|
187
|
+
});
|
|
188
|
+
it("trims whitespace-only values before storing", () => {
|
|
189
|
+
const existing = mergeSecrets(null, { token: "old-token" }, KEY);
|
|
190
|
+
const result = mergeSecrets(existing, { token: " new-token " }, KEY);
|
|
191
|
+
const decrypted = JSON.parse(decrypt(result, KEY));
|
|
192
|
+
expect(decrypted).toEqual({ token: "new-token" });
|
|
193
|
+
});
|
|
194
|
+
it("returns a new ciphertext (re-encrypts)", () => {
|
|
195
|
+
const existing = mergeSecrets(null, { token: "old" }, KEY);
|
|
196
|
+
const result = mergeSecrets(existing, { token: "new" }, KEY);
|
|
197
|
+
expect(result).not.toBe(existing);
|
|
198
|
+
const decrypted = JSON.parse(decrypt(result, KEY));
|
|
199
|
+
expect(decrypted).toEqual({ token: "new" });
|
|
200
|
+
});
|
|
201
|
+
it("never returns plaintext", () => {
|
|
202
|
+
const result = mergeSecrets(null, { key: "val" }, KEY);
|
|
203
|
+
expect(result).not.toContain("val");
|
|
204
|
+
expect(result).not.toContain("key");
|
|
205
|
+
});
|
|
206
|
+
});
|
package/dist/crypto.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Encrypts `plain` with AES-256-GCM.
|
|
3
|
+
* Output format: "<iv_b64url>.<tag_b64url>.<ciphertext_b64url>"
|
|
4
|
+
* Uses base64url (no padding) so that appending characters to a segment
|
|
5
|
+
* always changes the decoded bytes — enabling reliable tamper detection.
|
|
6
|
+
*/
|
|
7
|
+
export declare function encrypt(plain: string, keyB64: string): string;
|
|
8
|
+
/**
|
|
9
|
+
* Decrypts a payload produced by `encrypt`.
|
|
10
|
+
* Throws if the key is wrong or the ciphertext was tampered with.
|
|
11
|
+
*/
|
|
12
|
+
export declare function decrypt(payload: string, keyB64: string): string;
|
package/dist/crypto.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Encrypts `plain` with AES-256-GCM.
|
|
4
|
+
* Output format: "<iv_b64url>.<tag_b64url>.<ciphertext_b64url>"
|
|
5
|
+
* Uses base64url (no padding) so that appending characters to a segment
|
|
6
|
+
* always changes the decoded bytes — enabling reliable tamper detection.
|
|
7
|
+
*/
|
|
8
|
+
export function encrypt(plain, keyB64) {
|
|
9
|
+
const key = Buffer.from(keyB64, "base64");
|
|
10
|
+
const iv = randomBytes(12);
|
|
11
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
12
|
+
const enc = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
|
|
13
|
+
const tag = cipher.getAuthTag();
|
|
14
|
+
return [iv, tag, enc].map((b) => b.toString("base64url")).join(".");
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Decrypts a payload produced by `encrypt`.
|
|
18
|
+
* Throws if the key is wrong or the ciphertext was tampered with.
|
|
19
|
+
*/
|
|
20
|
+
export function decrypt(payload, keyB64) {
|
|
21
|
+
const key = Buffer.from(keyB64, "base64");
|
|
22
|
+
const parts = payload.split(".");
|
|
23
|
+
if (parts.length !== 3)
|
|
24
|
+
throw new Error("Invalid ciphertext format");
|
|
25
|
+
const [iv, tag, enc] = parts.map((s) => Buffer.from(s, "base64url"));
|
|
26
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
27
|
+
decipher.setAuthTag(tag);
|
|
28
|
+
return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8");
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { encrypt, decrypt } from "./crypto.js";
|
|
3
|
+
const KEY = Buffer.alloc(32, 7).toString("base64");
|
|
4
|
+
describe("crypto", () => {
|
|
5
|
+
it("round-trips plaintext", () => {
|
|
6
|
+
const ciphertext = encrypt("super-secret", KEY);
|
|
7
|
+
expect(ciphertext).not.toBe("super-secret");
|
|
8
|
+
expect(decrypt(ciphertext, KEY)).toBe("super-secret");
|
|
9
|
+
});
|
|
10
|
+
it("uses a unique IV each call (ciphertexts differ)", () => {
|
|
11
|
+
const a = encrypt("x", KEY);
|
|
12
|
+
const b = encrypt("x", KEY);
|
|
13
|
+
expect(a).not.toBe(b);
|
|
14
|
+
});
|
|
15
|
+
it("throws on tampered ciphertext", () => {
|
|
16
|
+
const c = encrypt("data", KEY);
|
|
17
|
+
const [iv, tag, enc] = c.split(".");
|
|
18
|
+
expect(() => decrypt([iv, tag, enc + "XX"].join("."), KEY)).toThrow();
|
|
19
|
+
});
|
|
20
|
+
});
|