mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +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,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