mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +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,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