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

@@ -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
}
]
}
+1
View File
@@ -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,
),
}),
);
+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>;
@@ -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();
});
});
+2
View File
@@ -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));
+1
View File
@@ -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;
}
+5
View File
@@ -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,
};
},
};
}