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,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,
};
},
};
}