mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +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
@@ -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>;
|
||||
Reference in new issue
Block a user