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:
Claude (Paperclip)andPaperclip authored and Devin Foley committed 2026-06-24 14:49:52 -07:00
1 parent bac15ebd09
commit d31c90f293
16 files changed
+1148

No files matched your search

+1
View File
@@ -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;
+31
View File
@@ -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];
+17
View File
@@ -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,
+11
View File
@@ -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>;