mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 17:45:53 +01:00
feat(subscriptions): add BYO subscription credential storage and capture APIs
Add per-user encrypted credential storage for BYO Claude/Codex seats (PAPA-994). This is the foundation layer for the subscription worker feature — downstream runtime injection and UX work build on top of it. Changes: - packages/db: new `user_subscription_credentials` schema, drizzle export, migration 0111 (company+user+provider unique index, cascade deletes, encrypted material jsonb column) - packages/shared: SUBSCRIPTION_CREDENTIAL_* constants (providers, kinds, statuses), API path constant, zod validators (upsertSubscriptionCredentialSchema with provider/kind cross-validation and JSON-material enforcement), redacted read-model schema, full type and validator exports - server/routes: subscription-credential routes (list, get, upsert PUT, test-result POST, delete) with board-user-only authz, company-scoped access checks, and redacted audit log entries on every mutation - server/services: subscriptionCredentialService — upsert with local_encrypted provider, redacted metadata computation, delete, recordTestResult, and a narrow resolveDecryptedMaterial() surface for downstream runtime injection only (SHA-256 integrity check on decrypt) - tests: route unit tests (authz, redaction, validation) and shared validator tests (provider/kind cross-validation, JSON material, max length, status values) Hard constraints upheld: - Plaintext credential material is never stored, logged, or returned - Access is isolated per company+user; agents cannot manage user seats - Encryption reuses the existing local_encrypted secret provider Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
bac15ebd09
commit
d31c90f293
16 files changed
+1148
No files matched your search
@@ -0,0 +1,38 @@
|
||||
CREATE TABLE IF NOT EXISTS "user_subscription_credentials" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"provider" text NOT NULL,
|
||||
"credential_kind" text NOT NULL,
|
||||
"secret_provider" text DEFAULT 'local_encrypted' NOT NULL,
|
||||
"material" jsonb NOT NULL,
|
||||
"value_sha256" text NOT NULL,
|
||||
"redacted_metadata" jsonb,
|
||||
"status" text DEFAULT 'active' NOT NULL,
|
||||
"last_tested_at" timestamp with time zone,
|
||||
"last_test_status" text,
|
||||
"last_resolved_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "user_subscription_credentials" ADD CONSTRAINT "user_subscription_credentials_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "user_subscription_credentials" ADD CONSTRAINT "user_subscription_credentials_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_subscription_credentials_company_idx"
|
||||
ON "user_subscription_credentials" USING btree ("company_id");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "user_subscription_credentials_company_user_idx"
|
||||
ON "user_subscription_credentials" USING btree ("company_id","user_id");
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "user_subscription_credentials_company_user_provider_uq"
|
||||
ON "user_subscription_credentials" USING btree ("company_id","user_id","provider");
|
||||
@@ -778,6 +778,13 @@
|
||||
"when": 1781902400000,
|
||||
"tag": "0110_document_company_cascade",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 111,
|
||||
"version": "7",
|
||||
"when": 1782168000000,
|
||||
"tag": "0111_user_subscription_credentials",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export { companySecrets } from "./company_secrets.js";
|
||||
export { companySecretVersions } from "./company_secret_versions.js";
|
||||
export { companySecretBindings } from "./company_secret_bindings.js";
|
||||
export { secretAccessEvents } from "./secret_access_events.js";
|
||||
export { userSubscriptionCredentials } from "./user_subscription_credentials.js";
|
||||
export { companySkills, companySkillVersions, companySkillStars, companySkillComments } from "./company_skills.js";
|
||||
export { plugins } from "./plugins.js";
|
||||
export { pluginConfig } from "./plugin_config.js";
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { pgTable, uuid, text, timestamp, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { companies } from "./companies.js";
|
||||
import { authUsers } from "./auth.js";
|
||||
|
||||
/**
|
||||
* Per-user "bring your own" subscription credential storage for the official
|
||||
* Claude / Codex CLI binaries. Scoped by company + user + provider so a single
|
||||
* employee holds at most one credential record per provider per company.
|
||||
*
|
||||
* The encrypted credential payload lives in `material` (the same encrypted
|
||||
* envelope shape produced by the secret provider modules, e.g.
|
||||
* `{ scheme, iv, tag, ciphertext }` for `local_encrypted`). Plaintext credential
|
||||
* material is never stored here and is never returned through the API.
|
||||
*/
|
||||
export const userSubscriptionCredentials = pgTable(
|
||||
"user_subscription_credentials",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }),
|
||||
// "claude" | "codex"
|
||||
provider: text("provider").notNull(),
|
||||
// Provider-specific credential shape, e.g. "claude_oauth_token",
|
||||
// "claude_credentials_json", "codex_auth_json".
|
||||
credentialKind: text("credential_kind").notNull(),
|
||||
// Encryption provider that produced `material` (e.g. "local_encrypted").
|
||||
secretProvider: text("secret_provider").notNull().default("local_encrypted"),
|
||||
// Encrypted-at-rest credential envelope. Never plaintext.
|
||||
material: jsonb("material").$type<Record<string, unknown>>().notNull(),
|
||||
// SHA-256 of the plaintext credential, for change detection / audit only.
|
||||
valueSha256: text("value_sha256").notNull(),
|
||||
// Non-sensitive redacted preview metadata (e.g. token length, suffix hint).
|
||||
redactedMetadata: jsonb("redacted_metadata").$type<Record<string, unknown>>(),
|
||||
status: text("status").notNull().default("active"),
|
||||
lastTestedAt: timestamp("last_tested_at", { withTimezone: true }),
|
||||
lastTestStatus: text("last_test_status"),
|
||||
lastResolvedAt: timestamp("last_resolved_at", { withTimezone: true }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => ({
|
||||
companyIdx: index("user_subscription_credentials_company_idx").on(table.companyId),
|
||||
companyUserIdx: index("user_subscription_credentials_company_user_idx").on(
|
||||
table.companyId,
|
||||
table.userId,
|
||||
),
|
||||
companyUserProviderUq: uniqueIndex("user_subscription_credentials_company_user_provider_uq").on(
|
||||
table.companyId,
|
||||
table.userId,
|
||||
table.provider,
|
||||
),
|
||||
}),
|
||||
);
|
||||
@@ -24,4 +24,5 @@ export const API = {
|
||||
joinRequests: `${API_PREFIX}/join-requests`,
|
||||
members: `${API_PREFIX}/members`,
|
||||
admin: `${API_PREFIX}/admin`,
|
||||
subscriptionCredentials: `${API_PREFIX}/companies/:companyId/subscription-credentials`,
|
||||
} as const;
|
||||
@@ -602,6 +602,37 @@ export type SecretBindingTargetType = (typeof SECRET_BINDING_TARGET_TYPES)[numbe
|
||||
export const SECRET_ACCESS_OUTCOMES = ["success", "failure"] as const;
|
||||
export type SecretAccessOutcome = (typeof SECRET_ACCESS_OUTCOMES)[number];
|
||||
|
||||
// Per-user "bring your own" subscription credentials for the official
|
||||
// Claude / Codex CLI binaries. Only these two providers are supported.
|
||||
export const SUBSCRIPTION_CREDENTIAL_PROVIDERS = ["claude", "codex"] as const;
|
||||
export type SubscriptionCredentialProvider = (typeof SUBSCRIPTION_CREDENTIAL_PROVIDERS)[number];
|
||||
|
||||
// Provider-specific credential shapes that can be captured.
|
||||
// - claude_oauth_token: a raw CLAUDE_CODE_OAUTH_TOKEN string
|
||||
// - claude_credentials_json: a ~/.claude/.credentials.json-equivalent JSON document
|
||||
// - codex_auth_json: a ~/.codex/auth.json-equivalent JSON document
|
||||
export const SUBSCRIPTION_CREDENTIAL_KINDS = [
|
||||
"claude_oauth_token",
|
||||
"claude_credentials_json",
|
||||
"codex_auth_json",
|
||||
] as const;
|
||||
export type SubscriptionCredentialKind = (typeof SUBSCRIPTION_CREDENTIAL_KINDS)[number];
|
||||
|
||||
// Which credential kinds are valid for each provider.
|
||||
export const SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER: Record<
|
||||
SubscriptionCredentialProvider,
|
||||
readonly SubscriptionCredentialKind[]
|
||||
> = {
|
||||
claude: ["claude_oauth_token", "claude_credentials_json"],
|
||||
codex: ["codex_auth_json"],
|
||||
};
|
||||
|
||||
export const SUBSCRIPTION_CREDENTIAL_STATUSES = ["active", "disabled"] as const;
|
||||
export type SubscriptionCredentialStatus = (typeof SUBSCRIPTION_CREDENTIAL_STATUSES)[number];
|
||||
|
||||
export const SUBSCRIPTION_CREDENTIAL_TEST_STATUSES = ["untested", "ready", "failed"] as const;
|
||||
export type SubscriptionCredentialTestStatus = (typeof SUBSCRIPTION_CREDENTIAL_TEST_STATUSES)[number];
|
||||
|
||||
export const STORAGE_PROVIDERS = ["local_disk", "s3"] as const;
|
||||
export type StorageProvider = (typeof STORAGE_PROVIDERS)[number];
|
||||
|
||||
|
||||
@@ -129,6 +129,11 @@ export {
|
||||
SECRET_PROVIDERS,
|
||||
SECRET_PROVIDER_CONFIG_STATUSES,
|
||||
SECRET_PROVIDER_CONFIG_HEALTH_STATUSES,
|
||||
SUBSCRIPTION_CREDENTIAL_PROVIDERS,
|
||||
SUBSCRIPTION_CREDENTIAL_KINDS,
|
||||
SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER,
|
||||
SUBSCRIPTION_CREDENTIAL_STATUSES,
|
||||
SUBSCRIPTION_CREDENTIAL_TEST_STATUSES,
|
||||
STORAGE_PROVIDERS,
|
||||
BILLING_TYPES,
|
||||
FINANCE_EVENT_KINDS,
|
||||
@@ -258,6 +263,10 @@ export {
|
||||
type SecretProvider,
|
||||
type SecretProviderConfigStatus,
|
||||
type SecretProviderConfigHealthStatus,
|
||||
type SubscriptionCredentialProvider,
|
||||
type SubscriptionCredentialKind,
|
||||
type SubscriptionCredentialStatus,
|
||||
type SubscriptionCredentialTestStatus,
|
||||
type StorageProvider,
|
||||
type BillingType,
|
||||
type FinanceEventKind,
|
||||
@@ -1203,6 +1212,12 @@ export {
|
||||
rotateSecretSchema,
|
||||
secretBindingTargetSchema,
|
||||
updateSecretSchema,
|
||||
subscriptionCredentialProviderSchema,
|
||||
subscriptionCredentialKindSchema,
|
||||
subscriptionCredentialStatusSchema,
|
||||
subscriptionCredentialTestStatusSchema,
|
||||
upsertSubscriptionCredentialSchema,
|
||||
subscriptionCredentialReadModelSchema,
|
||||
createRoutineSchema,
|
||||
updateRoutineSchema,
|
||||
createRoutineTriggerSchema,
|
||||
@@ -1223,6 +1238,8 @@ export {
|
||||
type RemoteSecretImportSelection,
|
||||
type RotateSecret,
|
||||
type UpdateSecret,
|
||||
type UpsertSubscriptionCredential,
|
||||
type SubscriptionCredentialReadModel,
|
||||
type CreateRoutine,
|
||||
type UpdateRoutine,
|
||||
type CreateRoutineTrigger,
|
||||
|
||||
@@ -467,6 +467,17 @@ export {
|
||||
type UpdateSecret,
|
||||
} from "./secret.js";
|
||||
|
||||
export {
|
||||
subscriptionCredentialProviderSchema,
|
||||
subscriptionCredentialKindSchema,
|
||||
subscriptionCredentialStatusSchema,
|
||||
subscriptionCredentialTestStatusSchema,
|
||||
upsertSubscriptionCredentialSchema,
|
||||
subscriptionCredentialReadModelSchema,
|
||||
type UpsertSubscriptionCredential,
|
||||
type SubscriptionCredentialReadModel,
|
||||
} from "./subscription-credential.js";
|
||||
|
||||
export {
|
||||
createRoutineSchema,
|
||||
updateRoutineSchema,
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
subscriptionCredentialProviderSchema,
|
||||
subscriptionCredentialKindSchema,
|
||||
upsertSubscriptionCredentialSchema,
|
||||
} from "./subscription-credential.js";
|
||||
|
||||
describe("subscription credential validators", () => {
|
||||
describe("provider schema", () => {
|
||||
it("accepts valid providers", () => {
|
||||
expect(subscriptionCredentialProviderSchema.parse("claude")).toBe("claude");
|
||||
expect(subscriptionCredentialProviderSchema.parse("codex")).toBe("codex");
|
||||
});
|
||||
|
||||
it("rejects unknown providers", () => {
|
||||
expect(() => subscriptionCredentialProviderSchema.parse("openai")).toThrow();
|
||||
expect(() => subscriptionCredentialProviderSchema.parse("")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("kind schema", () => {
|
||||
it("accepts all valid credential kinds", () => {
|
||||
expect(subscriptionCredentialKindSchema.parse("claude_oauth_token")).toBe("claude_oauth_token");
|
||||
expect(subscriptionCredentialKindSchema.parse("claude_credentials_json")).toBe("claude_credentials_json");
|
||||
expect(subscriptionCredentialKindSchema.parse("codex_auth_json")).toBe("codex_auth_json");
|
||||
});
|
||||
|
||||
it("rejects unknown kinds", () => {
|
||||
expect(() => subscriptionCredentialKindSchema.parse("openai_api_key")).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("upsertSubscriptionCredentialSchema", () => {
|
||||
it("accepts a valid claude oauth token", () => {
|
||||
const input = {
|
||||
provider: "claude",
|
||||
credentialKind: "claude_oauth_token",
|
||||
material: "sk-ant-token-placeholder",
|
||||
};
|
||||
expect(() => upsertSubscriptionCredentialSchema.parse(input)).not.toThrow();
|
||||
const parsed = upsertSubscriptionCredentialSchema.parse(input);
|
||||
expect(parsed.provider).toBe("claude");
|
||||
expect(parsed.credentialKind).toBe("claude_oauth_token");
|
||||
});
|
||||
|
||||
it("accepts a valid claude_credentials_json with well-formed JSON", () => {
|
||||
const creds = JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "tok",
|
||||
refreshToken: "ref",
|
||||
expiresAt: Date.now() + 3600000,
|
||||
},
|
||||
});
|
||||
const input = {
|
||||
provider: "claude",
|
||||
credentialKind: "claude_credentials_json",
|
||||
material: creds,
|
||||
};
|
||||
expect(() => upsertSubscriptionCredentialSchema.parse(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects claude_credentials_json with non-JSON material", () => {
|
||||
const result = upsertSubscriptionCredentialSchema.safeParse({
|
||||
provider: "claude",
|
||||
credentialKind: "claude_credentials_json",
|
||||
material: "not-json",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((i) => i.path.join("."));
|
||||
expect(paths).toContain("material");
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a valid codex_auth_json with well-formed JSON", () => {
|
||||
const auth = JSON.stringify({ accessToken: "tok", refreshToken: "ref" });
|
||||
const input = {
|
||||
provider: "codex",
|
||||
credentialKind: "codex_auth_json",
|
||||
material: auth,
|
||||
};
|
||||
expect(() => upsertSubscriptionCredentialSchema.parse(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects codex_auth_json with non-JSON material", () => {
|
||||
const result = upsertSubscriptionCredentialSchema.safeParse({
|
||||
provider: "codex",
|
||||
credentialKind: "codex_auth_json",
|
||||
material: "not-valid-json",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a codex credential kind for the claude provider", () => {
|
||||
const result = upsertSubscriptionCredentialSchema.safeParse({
|
||||
provider: "claude",
|
||||
credentialKind: "codex_auth_json",
|
||||
material: "{}",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
const paths = result.error.issues.map((i) => i.path.join("."));
|
||||
expect(paths).toContain("credentialKind");
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a claude credential kind for the codex provider", () => {
|
||||
const result = upsertSubscriptionCredentialSchema.safeParse({
|
||||
provider: "codex",
|
||||
credentialKind: "claude_oauth_token",
|
||||
material: "some-token",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects empty material", () => {
|
||||
const result = upsertSubscriptionCredentialSchema.safeParse({
|
||||
provider: "claude",
|
||||
credentialKind: "claude_oauth_token",
|
||||
material: "",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts an optional disabled status", () => {
|
||||
const parsed = upsertSubscriptionCredentialSchema.parse({
|
||||
provider: "claude",
|
||||
credentialKind: "claude_oauth_token",
|
||||
material: "some-token",
|
||||
status: "disabled",
|
||||
});
|
||||
expect(parsed.status).toBe("disabled");
|
||||
});
|
||||
|
||||
it("rejects an invalid status value", () => {
|
||||
const result = upsertSubscriptionCredentialSchema.safeParse({
|
||||
provider: "claude",
|
||||
credentialKind: "claude_oauth_token",
|
||||
material: "some-token",
|
||||
status: "pending",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects material that exceeds the maximum length", () => {
|
||||
const result = upsertSubscriptionCredentialSchema.safeParse({
|
||||
provider: "claude",
|
||||
credentialKind: "claude_oauth_token",
|
||||
// 32769 chars > MAX_CREDENTIAL_MATERIAL_LENGTH
|
||||
material: "x".repeat(32_769),
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
SUBSCRIPTION_CREDENTIAL_KINDS,
|
||||
SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER,
|
||||
SUBSCRIPTION_CREDENTIAL_PROVIDERS,
|
||||
SUBSCRIPTION_CREDENTIAL_STATUSES,
|
||||
SUBSCRIPTION_CREDENTIAL_TEST_STATUSES,
|
||||
type SubscriptionCredentialProvider,
|
||||
} from "../constants.js";
|
||||
|
||||
export const subscriptionCredentialProviderSchema = z.enum(SUBSCRIPTION_CREDENTIAL_PROVIDERS);
|
||||
export const subscriptionCredentialKindSchema = z.enum(SUBSCRIPTION_CREDENTIAL_KINDS);
|
||||
export const subscriptionCredentialStatusSchema = z.enum(SUBSCRIPTION_CREDENTIAL_STATUSES);
|
||||
export const subscriptionCredentialTestStatusSchema = z.enum(SUBSCRIPTION_CREDENTIAL_TEST_STATUSES);
|
||||
|
||||
// Generous upper bound: OAuth tokens are short, but JSON credential documents
|
||||
// (claude/codex) can carry refresh tokens and account metadata.
|
||||
const MAX_CREDENTIAL_MATERIAL_LENGTH = 32_768;
|
||||
|
||||
const credentialMaterialSchema = z.string().min(1).max(MAX_CREDENTIAL_MATERIAL_LENGTH);
|
||||
|
||||
function assertKindMatchesProvider(
|
||||
provider: SubscriptionCredentialProvider,
|
||||
kind: (typeof SUBSCRIPTION_CREDENTIAL_KINDS)[number],
|
||||
ctx: z.RefinementCtx,
|
||||
) {
|
||||
const allowed = SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER[provider];
|
||||
if (!allowed.includes(kind)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["credentialKind"],
|
||||
message: `Credential kind "${kind}" is not valid for provider "${provider}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Link (create) or update a credential. Mutations are upserts keyed by
|
||||
// (company, user, provider): one credential record per provider per user.
|
||||
export const upsertSubscriptionCredentialSchema = z
|
||||
.object({
|
||||
provider: subscriptionCredentialProviderSchema,
|
||||
credentialKind: subscriptionCredentialKindSchema,
|
||||
// Raw credential material (token string or JSON document). Encrypted at
|
||||
// rest server-side; never echoed back in any API response.
|
||||
material: credentialMaterialSchema,
|
||||
status: subscriptionCredentialStatusSchema.optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
assertKindMatchesProvider(value.provider, value.credentialKind, ctx);
|
||||
if (value.credentialKind === "claude_credentials_json" || value.credentialKind === "codex_auth_json") {
|
||||
try {
|
||||
JSON.parse(value.material);
|
||||
} catch {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["material"],
|
||||
message: `Credential kind "${value.credentialKind}" requires valid JSON material`,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type UpsertSubscriptionCredential = z.infer<typeof upsertSubscriptionCredentialSchema>;
|
||||
|
||||
// Redacted read model returned by the API. Never includes credential material.
|
||||
export const subscriptionCredentialReadModelSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
companyId: z.string().uuid(),
|
||||
userId: z.string(),
|
||||
provider: subscriptionCredentialProviderSchema,
|
||||
credentialKind: subscriptionCredentialKindSchema,
|
||||
status: subscriptionCredentialStatusSchema,
|
||||
testStatus: subscriptionCredentialTestStatusSchema,
|
||||
// Non-sensitive hints only (e.g. material length, last 4 chars of a token).
|
||||
redactedMetadata: z.record(z.string(), z.unknown()).nullable(),
|
||||
lastTestedAt: z.string().datetime().nullable(),
|
||||
lastResolvedAt: z.string().datetime().nullable(),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
});
|
||||
|
||||
export type SubscriptionCredentialReadModel = z.infer<typeof subscriptionCredentialReadModelSchema>;
|
||||
@@ -0,0 +1,305 @@
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { subscriptionCredentialRoutes } from "../routes/subscription-credentials.js";
|
||||
import { errorHandler } from "../middleware/error-handler.js";
|
||||
import { HttpError } from "../errors.js";
|
||||
|
||||
const mockSvc = vi.hoisted(() => ({
|
||||
list: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
recordTestResult: vi.fn(),
|
||||
resolveDecryptedMaterial: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockLogActivity = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../services/index.js", () => ({
|
||||
subscriptionCredentialService: () => mockSvc,
|
||||
logActivity: mockLogActivity,
|
||||
}));
|
||||
|
||||
const BOARD_ACTOR = {
|
||||
type: "board",
|
||||
userId: "user-1",
|
||||
source: "session",
|
||||
companyIds: ["company-1"],
|
||||
memberships: [{ companyId: "company-1", status: "active", membershipRole: "admin" }],
|
||||
};
|
||||
|
||||
const AGENT_ACTOR = {
|
||||
type: "agent",
|
||||
agentId: "agent-1",
|
||||
companyId: "company-1",
|
||||
};
|
||||
|
||||
function createApp(actor: Record<string, unknown> = BOARD_ACTOR) {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = actor;
|
||||
next();
|
||||
});
|
||||
app.use("/api", subscriptionCredentialRoutes({} as any));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
const REDACTED_CREDENTIAL = {
|
||||
id: "cred-uuid-1",
|
||||
companyId: "company-1",
|
||||
userId: "user-1",
|
||||
provider: "claude",
|
||||
credentialKind: "claude_oauth_token",
|
||||
status: "active",
|
||||
testStatus: "untested",
|
||||
redactedMetadata: { kind: "claude_oauth_token", materialLength: 40, tokenSuffix: "abcd" },
|
||||
lastTestedAt: null,
|
||||
lastResolvedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
describe("subscription credential routes", () => {
|
||||
beforeEach(() => {
|
||||
for (const mock of Object.values(mockSvc)) {
|
||||
mock.mockReset();
|
||||
}
|
||||
mockLogActivity.mockReset();
|
||||
mockLogActivity.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
// ─── LIST ────────────────────────────────────────────────────────────────
|
||||
|
||||
it("lists redacted credentials for a board user", async () => {
|
||||
mockSvc.list.mockResolvedValue([REDACTED_CREDENTIAL]);
|
||||
|
||||
const res = await request(createApp())
|
||||
.get("/api/companies/company-1/subscription-credentials");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([REDACTED_CREDENTIAL]);
|
||||
expect(mockSvc.list).toHaveBeenCalledWith("company-1", "user-1");
|
||||
});
|
||||
|
||||
it("rejects list for agent actors (no board user context)", async () => {
|
||||
const res = await request(createApp(AGENT_ACTOR))
|
||||
.get("/api/companies/company-1/subscription-credentials");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSvc.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects list for cross-company board actors", async () => {
|
||||
const res = await request(createApp({
|
||||
...BOARD_ACTOR,
|
||||
companyIds: ["company-2"],
|
||||
memberships: [{ companyId: "company-2", status: "active", membershipRole: "admin" }],
|
||||
})).get("/api/companies/company-1/subscription-credentials");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSvc.list).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ─── GET BY ID ───────────────────────────────────────────────────────────
|
||||
|
||||
it("returns a single redacted credential", async () => {
|
||||
mockSvc.getById.mockResolvedValue(REDACTED_CREDENTIAL);
|
||||
|
||||
const res = await request(createApp())
|
||||
.get("/api/companies/company-1/subscription-credentials/cred-uuid-1");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(REDACTED_CREDENTIAL);
|
||||
expect(mockSvc.getById).toHaveBeenCalledWith("company-1", "user-1", "cred-uuid-1");
|
||||
});
|
||||
|
||||
it("returns 404 for unknown credentials", async () => {
|
||||
mockSvc.getById.mockRejectedValue(
|
||||
new HttpError(404, "Subscription credential not found"),
|
||||
);
|
||||
|
||||
const res = await request(createApp())
|
||||
.get("/api/companies/company-1/subscription-credentials/nonexistent");
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(mockSvc.getById).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ─── UPSERT (PUT) ────────────────────────────────────────────────────────
|
||||
|
||||
it("links a claude oauth token credential", async () => {
|
||||
mockSvc.upsert.mockResolvedValue(REDACTED_CREDENTIAL);
|
||||
|
||||
const res = await request(createApp())
|
||||
.put("/api/companies/company-1/subscription-credentials")
|
||||
.send({
|
||||
provider: "claude",
|
||||
credentialKind: "claude_oauth_token",
|
||||
material: "sk-ant-oauthtoken-very-long-placeholder-value-here",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(REDACTED_CREDENTIAL);
|
||||
expect(mockSvc.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
companyId: "company-1",
|
||||
userId: "user-1",
|
||||
provider: "claude",
|
||||
credentialKind: "claude_oauth_token",
|
||||
material: "sk-ant-oauthtoken-very-long-placeholder-value-here",
|
||||
}),
|
||||
);
|
||||
// Audit log must be called with no credential material
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "subscription_credential.linked",
|
||||
entityType: "subscription_credential",
|
||||
details: expect.not.objectContaining({ material: expect.anything() }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("links a codex_auth_json credential with valid JSON material", async () => {
|
||||
const codexCred = {
|
||||
...REDACTED_CREDENTIAL,
|
||||
provider: "codex",
|
||||
credentialKind: "codex_auth_json",
|
||||
};
|
||||
mockSvc.upsert.mockResolvedValue(codexCred);
|
||||
|
||||
const codexJson = JSON.stringify({ accessToken: "tok", refreshToken: "ref", expiresAt: 9999 });
|
||||
|
||||
const res = await request(createApp())
|
||||
.put("/api/companies/company-1/subscription-credentials")
|
||||
.send({
|
||||
provider: "codex",
|
||||
credentialKind: "codex_auth_json",
|
||||
material: codexJson,
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mockSvc.upsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: "codex", credentialKind: "codex_auth_json" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a credentials_json kind with non-JSON material", async () => {
|
||||
const res = await request(createApp())
|
||||
.put("/api/companies/company-1/subscription-credentials")
|
||||
.send({
|
||||
provider: "claude",
|
||||
credentialKind: "claude_credentials_json",
|
||||
material: "this-is-not-json",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockSvc.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects mismatched provider/kind (claude provider + codex kind)", async () => {
|
||||
const res = await request(createApp())
|
||||
.put("/api/companies/company-1/subscription-credentials")
|
||||
.send({
|
||||
provider: "claude",
|
||||
credentialKind: "codex_auth_json",
|
||||
material: "{}",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockSvc.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects upsert for agent actors", async () => {
|
||||
const res = await request(createApp(AGENT_ACTOR))
|
||||
.put("/api/companies/company-1/subscription-credentials")
|
||||
.send({
|
||||
provider: "claude",
|
||||
credentialKind: "claude_oauth_token",
|
||||
material: "sometoken",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSvc.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects upsert for unknown providers", async () => {
|
||||
const res = await request(createApp())
|
||||
.put("/api/companies/company-1/subscription-credentials")
|
||||
.send({
|
||||
provider: "openai",
|
||||
credentialKind: "claude_oauth_token",
|
||||
material: "sometoken",
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockSvc.upsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ─── TEST RESULT ─────────────────────────────────────────────────────────
|
||||
|
||||
it("records a test result for a credential", async () => {
|
||||
const tested = { ...REDACTED_CREDENTIAL, testStatus: "ready", lastTestedAt: "2026-01-02T00:00:00.000Z" };
|
||||
mockSvc.recordTestResult.mockResolvedValue(tested);
|
||||
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/subscription-credentials/cred-uuid-1/test-result")
|
||||
.send({ testStatus: "ready" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.testStatus).toBe("ready");
|
||||
expect(mockSvc.recordTestResult).toHaveBeenCalledWith(
|
||||
"company-1",
|
||||
"user-1",
|
||||
"cred-uuid-1",
|
||||
"ready",
|
||||
);
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ action: "subscription_credential.tested" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an invalid test status value", async () => {
|
||||
const res = await request(createApp())
|
||||
.post("/api/companies/company-1/subscription-credentials/cred-uuid-1/test-result")
|
||||
.send({ testStatus: "passing" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockSvc.recordTestResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ─── DELETE ───────────────────────────────────────────────────────────────
|
||||
|
||||
it("deletes a credential and emits a redacted audit log entry", async () => {
|
||||
mockSvc.getById.mockResolvedValue(REDACTED_CREDENTIAL);
|
||||
mockSvc.delete.mockResolvedValue(undefined);
|
||||
|
||||
const res = await request(createApp())
|
||||
.delete("/api/companies/company-1/subscription-credentials/cred-uuid-1");
|
||||
|
||||
expect(res.status).toBe(204);
|
||||
expect(mockSvc.delete).toHaveBeenCalledWith("company-1", "user-1", "cred-uuid-1");
|
||||
// Audit log must be called; must not contain material
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
action: "subscription_credential.deleted",
|
||||
entityId: "cred-uuid-1",
|
||||
details: expect.objectContaining({ provider: "claude" }),
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(mockLogActivity.mock.calls)).not.toMatch(/material/);
|
||||
});
|
||||
|
||||
it("rejects delete for agent actors", async () => {
|
||||
const res = await request(createApp(AGENT_ACTOR))
|
||||
.delete("/api/companies/company-1/subscription-credentials/cred-uuid-1");
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(mockSvc.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { userProfileRoutes } from "./routes/user-profiles.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||
import { subscriptionCredentialRoutes } from "./routes/subscription-credentials.js";
|
||||
import { resourceMembershipRoutes } from "./routes/resource-memberships.js";
|
||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
|
||||
@@ -244,6 +245,7 @@ export async function createApp(
|
||||
api.use(userProfileRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(sidebarPreferenceRoutes(db));
|
||||
api.use(subscriptionCredentialRoutes(db));
|
||||
api.use(resourceMembershipRoutes(db));
|
||||
api.use(inboxDismissalRoutes(db));
|
||||
api.use(instanceSettingsRoutes(db));
|
||||
|
||||
@@ -16,6 +16,7 @@ export { activityRoutes } from "./activity.js";
|
||||
export { dashboardRoutes } from "./dashboard.js";
|
||||
export { sidebarBadgeRoutes } from "./sidebar-badges.js";
|
||||
export { sidebarPreferenceRoutes } from "./sidebar-preferences.js";
|
||||
export { subscriptionCredentialRoutes } from "./subscription-credentials.js";
|
||||
export { resourceMembershipRoutes } from "./resource-memberships.js";
|
||||
export { inboxDismissalRoutes } from "./inbox-dismissals.js";
|
||||
export { llmRoutes } from "./llms.js";
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
subscriptionCredentialTestStatusSchema,
|
||||
upsertSubscriptionCredentialSchema,
|
||||
} from "@paperclipai/shared";
|
||||
import { z } from "zod";
|
||||
import { validate } from "../middleware/validate.js";
|
||||
import { logActivity, subscriptionCredentialService } from "../services/index.js";
|
||||
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
|
||||
// BYO subscription credentials are strictly per-user: every route requires a
|
||||
// board user context and scopes all reads/mutations to that user within the
|
||||
// company. Agents cannot manage another user's subscription seats.
|
||||
function requireBoardUserId(req: Request, res: Response): string | null {
|
||||
assertBoard(req);
|
||||
if (!req.actor.userId) {
|
||||
res.status(403).json({ error: "Board user context required" });
|
||||
return null;
|
||||
}
|
||||
return req.actor.userId;
|
||||
}
|
||||
|
||||
const recordTestResultSchema = z.object({
|
||||
testStatus: subscriptionCredentialTestStatusSchema,
|
||||
});
|
||||
|
||||
export function subscriptionCredentialRoutes(db: Db) {
|
||||
const router = Router();
|
||||
const svc = subscriptionCredentialService(db);
|
||||
|
||||
// List the current user's credential records (redacted).
|
||||
router.get("/companies/:companyId/subscription-credentials", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
res.json(await svc.list(companyId, userId));
|
||||
});
|
||||
|
||||
// Read a single credential record (redacted).
|
||||
router.get(
|
||||
"/companies/:companyId/subscription-credentials/:credentialId",
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
res.json(await svc.getById(companyId, userId, req.params.credentialId as string));
|
||||
},
|
||||
);
|
||||
|
||||
// Link or update a credential (upsert keyed by company + user + provider).
|
||||
router.put(
|
||||
"/companies/:companyId/subscription-credentials",
|
||||
validate(upsertSubscriptionCredentialSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const body = req.body as z.infer<typeof upsertSubscriptionCredentialSchema>;
|
||||
const result = await svc.upsert({
|
||||
companyId,
|
||||
userId,
|
||||
provider: body.provider,
|
||||
credentialKind: body.credentialKind,
|
||||
material: body.material,
|
||||
status: body.status,
|
||||
});
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
// Audit only redacted, non-sensitive fields; never the credential material.
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "subscription_credential.linked",
|
||||
entityType: "subscription_credential",
|
||||
entityId: result.id,
|
||||
details: {
|
||||
userId,
|
||||
provider: result.provider,
|
||||
credentialKind: result.credentialKind,
|
||||
status: result.status,
|
||||
},
|
||||
});
|
||||
res.status(200).json(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Record the outcome of a downstream readiness/validity test.
|
||||
router.post(
|
||||
"/companies/:companyId/subscription-credentials/:credentialId/test-result",
|
||||
validate(recordTestResultSchema),
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const credentialId = req.params.credentialId as string;
|
||||
const body = req.body as z.infer<typeof recordTestResultSchema>;
|
||||
const result = await svc.recordTestResult(companyId, userId, credentialId, body.testStatus);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "subscription_credential.tested",
|
||||
entityType: "subscription_credential",
|
||||
entityId: result.id,
|
||||
details: {
|
||||
userId,
|
||||
provider: result.provider,
|
||||
testStatus: result.testStatus,
|
||||
},
|
||||
});
|
||||
res.json(result);
|
||||
},
|
||||
);
|
||||
|
||||
// Delete a credential record.
|
||||
router.delete(
|
||||
"/companies/:companyId/subscription-credentials/:credentialId",
|
||||
async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const userId = requireBoardUserId(req, res);
|
||||
if (!userId) return;
|
||||
|
||||
const credentialId = req.params.credentialId as string;
|
||||
// Resolve first so we can attach redacted provider info to the audit log.
|
||||
const existing = await svc.getById(companyId, userId, credentialId);
|
||||
await svc.delete(companyId, userId, credentialId);
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
await logActivity(db, {
|
||||
companyId,
|
||||
actorType: actor.actorType,
|
||||
actorId: actor.actorId,
|
||||
agentId: actor.agentId,
|
||||
runId: actor.runId,
|
||||
action: "subscription_credential.deleted",
|
||||
entityType: "subscription_credential",
|
||||
entityId: credentialId,
|
||||
details: {
|
||||
userId,
|
||||
provider: existing.provider,
|
||||
credentialKind: existing.credentialKind,
|
||||
},
|
||||
});
|
||||
res.status(204).end();
|
||||
},
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -47,6 +47,11 @@ export { activityService, type ActivityFilters } from "./activity.js";
|
||||
export { approvalService } from "./approvals.js";
|
||||
export { budgetService } from "./budgets.js";
|
||||
export { secretService } from "./secrets.js";
|
||||
export {
|
||||
subscriptionCredentialService,
|
||||
type UpsertSubscriptionCredentialInput,
|
||||
type DecryptedSubscriptionCredential,
|
||||
} from "./subscription-credentials.js";
|
||||
export { routineService } from "./routines.js";
|
||||
export { costService } from "./costs.js";
|
||||
export { financeService } from "./finance.js";
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { userSubscriptionCredentials } from "@paperclipai/db";
|
||||
import {
|
||||
SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER,
|
||||
type SubscriptionCredentialKind,
|
||||
type SubscriptionCredentialProvider,
|
||||
type SubscriptionCredentialReadModel,
|
||||
type SubscriptionCredentialStatus,
|
||||
type SubscriptionCredentialTestStatus,
|
||||
} from "@paperclipai/shared";
|
||||
import { badRequest, notFound, unprocessable } from "../errors.js";
|
||||
import { getSecretProvider } from "../secrets/provider-registry.js";
|
||||
|
||||
// The BYO subscription credential layer reuses the existing managed encryption
|
||||
// provider so credential material is encrypted at rest with the same key
|
||||
// management as company secrets. Only the local_encrypted (managed) scheme is
|
||||
// used here; external-reference providers are not applicable to pasted seats.
|
||||
const ENCRYPTION_PROVIDER = "local_encrypted" as const;
|
||||
|
||||
type CredentialRow = typeof userSubscriptionCredentials.$inferSelect;
|
||||
|
||||
export interface UpsertSubscriptionCredentialInput {
|
||||
companyId: string;
|
||||
userId: string;
|
||||
provider: SubscriptionCredentialProvider;
|
||||
credentialKind: SubscriptionCredentialKind;
|
||||
material: string;
|
||||
status?: SubscriptionCredentialStatus;
|
||||
}
|
||||
|
||||
export interface DecryptedSubscriptionCredential {
|
||||
id: string;
|
||||
companyId: string;
|
||||
userId: string;
|
||||
provider: SubscriptionCredentialProvider;
|
||||
credentialKind: SubscriptionCredentialKind;
|
||||
// Decrypted credential material (token string or JSON document). This is the
|
||||
// narrow server-side surface for downstream runtime injection. Callers must
|
||||
// never persist or log this value.
|
||||
material: string;
|
||||
}
|
||||
|
||||
function sha256Hex(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function assertKindMatchesProvider(
|
||||
provider: SubscriptionCredentialProvider,
|
||||
kind: SubscriptionCredentialKind,
|
||||
) {
|
||||
const allowed = SUBSCRIPTION_CREDENTIAL_KINDS_BY_PROVIDER[provider];
|
||||
if (!allowed.includes(kind)) {
|
||||
throw badRequest(`Credential kind "${kind}" is not valid for provider "${provider}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// Build non-sensitive redacted metadata for read models. Only safe hints (no
|
||||
// secret material) are stored or returned.
|
||||
function buildRedactedMetadata(
|
||||
kind: SubscriptionCredentialKind,
|
||||
material: string,
|
||||
): Record<string, unknown> {
|
||||
const length = material.length;
|
||||
if (kind === "claude_oauth_token") {
|
||||
const suffix = material.length >= 4 ? material.slice(-4) : null;
|
||||
return { kind, materialLength: length, tokenSuffix: suffix };
|
||||
}
|
||||
// JSON credential documents: surface only the top-level key names so the UI
|
||||
// can hint at shape without exposing values.
|
||||
let jsonKeys: string[] | null = null;
|
||||
try {
|
||||
const parsed = JSON.parse(material) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
jsonKeys = Object.keys(parsed as Record<string, unknown>).sort();
|
||||
}
|
||||
} catch {
|
||||
jsonKeys = null;
|
||||
}
|
||||
return { kind, materialLength: length, jsonKeys };
|
||||
}
|
||||
|
||||
function toReadModel(row: CredentialRow): SubscriptionCredentialReadModel {
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
userId: row.userId,
|
||||
provider: row.provider as SubscriptionCredentialProvider,
|
||||
credentialKind: row.credentialKind as SubscriptionCredentialKind,
|
||||
status: row.status as SubscriptionCredentialStatus,
|
||||
testStatus: (row.lastTestStatus as SubscriptionCredentialTestStatus | null) ?? "untested",
|
||||
redactedMetadata: row.redactedMetadata ?? null,
|
||||
lastTestedAt: row.lastTestedAt ? row.lastTestedAt.toISOString() : null,
|
||||
lastResolvedAt: row.lastResolvedAt ? row.lastResolvedAt.toISOString() : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export function subscriptionCredentialService(db: Db) {
|
||||
async function findRow(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
provider: SubscriptionCredentialProvider,
|
||||
): Promise<CredentialRow | undefined> {
|
||||
return db.query.userSubscriptionCredentials.findFirst({
|
||||
where: and(
|
||||
eq(userSubscriptionCredentials.companyId, companyId),
|
||||
eq(userSubscriptionCredentials.userId, userId),
|
||||
eq(userSubscriptionCredentials.provider, provider),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function findRowById(companyId: string, userId: string, id: string) {
|
||||
const row = await db.query.userSubscriptionCredentials.findFirst({
|
||||
where: eq(userSubscriptionCredentials.id, id),
|
||||
});
|
||||
if (!row) return undefined;
|
||||
// Enforce company + user isolation: one user/company can never read or
|
||||
// mutate another's credential, even with a guessed id.
|
||||
if (row.companyId !== companyId || row.userId !== userId) return undefined;
|
||||
return row;
|
||||
}
|
||||
|
||||
return {
|
||||
async list(companyId: string, userId: string): Promise<SubscriptionCredentialReadModel[]> {
|
||||
const rows = await db.query.userSubscriptionCredentials.findMany({
|
||||
where: and(
|
||||
eq(userSubscriptionCredentials.companyId, companyId),
|
||||
eq(userSubscriptionCredentials.userId, userId),
|
||||
),
|
||||
});
|
||||
return rows.map(toReadModel);
|
||||
},
|
||||
|
||||
async getById(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
): Promise<SubscriptionCredentialReadModel> {
|
||||
const row = await findRowById(companyId, userId, id);
|
||||
if (!row) throw notFound("Subscription credential not found");
|
||||
return toReadModel(row);
|
||||
},
|
||||
|
||||
// Link or update a credential. Keyed by (company, user, provider) so each
|
||||
// employee holds at most one record per provider. Material is encrypted at
|
||||
// rest before persistence; plaintext is never stored.
|
||||
async upsert(
|
||||
input: UpsertSubscriptionCredentialInput,
|
||||
): Promise<SubscriptionCredentialReadModel> {
|
||||
assertKindMatchesProvider(input.provider, input.credentialKind);
|
||||
|
||||
const provider = getSecretProvider(ENCRYPTION_PROVIDER);
|
||||
const prepared = await provider.createSecret({ value: input.material });
|
||||
const now = new Date();
|
||||
const redactedMetadata = buildRedactedMetadata(input.credentialKind, input.material);
|
||||
|
||||
const [row] = await db
|
||||
.insert(userSubscriptionCredentials)
|
||||
.values({
|
||||
companyId: input.companyId,
|
||||
userId: input.userId,
|
||||
provider: input.provider,
|
||||
credentialKind: input.credentialKind,
|
||||
secretProvider: ENCRYPTION_PROVIDER,
|
||||
material: prepared.material as Record<string, unknown>,
|
||||
valueSha256: prepared.valueSha256,
|
||||
redactedMetadata,
|
||||
status: input.status ?? "active",
|
||||
// A freshly linked/updated credential is untested until verified.
|
||||
lastTestStatus: "untested",
|
||||
lastTestedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
userSubscriptionCredentials.companyId,
|
||||
userSubscriptionCredentials.userId,
|
||||
userSubscriptionCredentials.provider,
|
||||
],
|
||||
set: {
|
||||
credentialKind: input.credentialKind,
|
||||
secretProvider: ENCRYPTION_PROVIDER,
|
||||
material: prepared.material as Record<string, unknown>,
|
||||
valueSha256: prepared.valueSha256,
|
||||
redactedMetadata,
|
||||
status: input.status ?? "active",
|
||||
lastTestStatus: "untested",
|
||||
lastTestedAt: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.returning();
|
||||
|
||||
return toReadModel(row);
|
||||
},
|
||||
|
||||
async delete(companyId: string, userId: string, id: string): Promise<void> {
|
||||
const row = await findRowById(companyId, userId, id);
|
||||
if (!row) throw notFound("Subscription credential not found");
|
||||
await db.delete(userSubscriptionCredentials).where(eq(userSubscriptionCredentials.id, row.id));
|
||||
},
|
||||
|
||||
// Record the outcome of a downstream readiness/validity test. The actual
|
||||
// test (running the official CLI) is a downstream task; this just persists
|
||||
// redacted test metadata so the UI can surface readiness.
|
||||
async recordTestResult(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
id: string,
|
||||
testStatus: SubscriptionCredentialTestStatus,
|
||||
): Promise<SubscriptionCredentialReadModel> {
|
||||
const row = await findRowById(companyId, userId, id);
|
||||
if (!row) throw notFound("Subscription credential not found");
|
||||
const now = new Date();
|
||||
const [updated] = await db
|
||||
.update(userSubscriptionCredentials)
|
||||
.set({ lastTestStatus: testStatus, lastTestedAt: now, updatedAt: now })
|
||||
.where(eq(userSubscriptionCredentials.id, row.id))
|
||||
.returning();
|
||||
return toReadModel(updated);
|
||||
},
|
||||
|
||||
// Narrow server-side interface for downstream runtime injection. Returns
|
||||
// decrypted credential material. This must only be called by trusted
|
||||
// server-side runtime code, never exposed through an HTTP response.
|
||||
async resolveDecryptedMaterial(
|
||||
companyId: string,
|
||||
userId: string,
|
||||
provider: SubscriptionCredentialProvider,
|
||||
): Promise<DecryptedSubscriptionCredential> {
|
||||
const row = await findRow(companyId, userId, provider);
|
||||
if (!row) throw notFound("Subscription credential not found");
|
||||
if (row.status !== "active") {
|
||||
throw unprocessable("Subscription credential is not active", { code: "credential_inactive" });
|
||||
}
|
||||
const secretProvider = getSecretProvider(row.secretProvider as "local_encrypted");
|
||||
const material = await secretProvider.resolveVersion({
|
||||
material: row.material as Record<string, unknown>,
|
||||
externalRef: null,
|
||||
context: {
|
||||
companyId: row.companyId,
|
||||
secretId: row.id,
|
||||
secretKey: `subscription_credential:${row.provider}`,
|
||||
version: 1,
|
||||
},
|
||||
});
|
||||
|
||||
await db
|
||||
.update(userSubscriptionCredentials)
|
||||
.set({ lastResolvedAt: new Date() })
|
||||
.where(eq(userSubscriptionCredentials.id, row.id))
|
||||
.catch(() => undefined);
|
||||
|
||||
// Integrity check against the stored plaintext hash.
|
||||
if (sha256Hex(material) !== row.valueSha256) {
|
||||
throw unprocessable("Subscription credential failed integrity check", {
|
||||
code: "credential_integrity_failed",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
companyId: row.companyId,
|
||||
userId: row.userId,
|
||||
provider: row.provider as SubscriptionCredentialProvider,
|
||||
credentialKind: row.credentialKind as SubscriptionCredentialKind,
|
||||
material,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user