-
{key.name}
-
{key.key_prefix}…
- {key.revoked_at ? (
-
revoked
- ) : (
-
- last used {fmt(key.last_used_at)}
-
+
+
+ {key.name}
+ {key.key_prefix}…
+ {key.revoked_at ? (
+ revoked
+ ) : (
+
+ last used {fmt(key.last_used_at)}
+
+ )}
+
+ {!key.revoked_at && (
+
+ {key.can_reveal &&
+ (revealed[key.id] ? (
+
+ ) : (
+
+ ))}
+
+
)}
- {!key.revoked_at && (
-
+ {revealed[key.id] && !key.revoked_at && (
+
)}
))}
diff --git a/app/(app)/projects/[id]/audience/page.tsx b/app/(app)/projects/[id]/audience/page.tsx
index e0910307..d2023119 100644
--- a/app/(app)/projects/[id]/audience/page.tsx
+++ b/app/(app)/projects/[id]/audience/page.tsx
@@ -18,6 +18,10 @@ type KeyRow = {
created_at: string;
};
+// Raw select row; the ciphertext itself never leaves the server — the
+// client component only gets a can_reveal flag.
+type KeySelectRow = KeyRow & { key_ciphertext: string | null };
+
type RepoRow = {
installation_id: number;
repo_owner: string;
@@ -64,7 +68,7 @@ export default async function ProjectAudiencePage({
await Promise.all([
supabase
.from("project_api_keys")
- .select("id, name, key_prefix, last_used_at, revoked_at, created_at")
+ .select("id, name, key_prefix, last_used_at, revoked_at, created_at, key_ciphertext")
.eq("project_id", projectId)
.order("created_at", { ascending: false }),
supabase
@@ -98,7 +102,9 @@ export default async function ProjectAudiencePage({
.eq("project_id", projectId),
]);
- const keys = (keysRes.data ?? []) as KeyRow[];
+ const keys = ((keysRes.data ?? []) as KeySelectRow[]).map(
+ ({ key_ciphertext, ...rest }) => ({ ...rest, can_reveal: !!key_ciphertext }),
+ );
const repos = (reposRes.data ?? []) as RepoRow[];
const runs = (runsRes.data ?? []) as RunRow[];
const lastBrowser = (lastBrowserRes.data?.[0]?.occurred_at as string | undefined) ?? null;
@@ -216,8 +222,8 @@ export default async function ProjectAudiencePage({
Server API keys
Authenticate POST {siteUrl}/api/events with{" "}
- Authorization: Bearer cpk_…. Keys are hashed at rest and
- shown once at mint time.
+ Authorization: Bearer cpk_…. Keys are encrypted at rest
+ — reveal one again anytime from the list below.
diff --git a/app/actions/audience.ts b/app/actions/audience.ts
index 565658c8..12171ad1 100644
--- a/app/actions/audience.ts
+++ b/app/actions/audience.ts
@@ -1,11 +1,13 @@
"use server";
// Audience Hub server actions: per-project ingest key management.
-// Keys authenticate POST /api/events; plaintext is shown once at mint time.
+// Keys authenticate POST /api/events; the plaintext is stored encrypted
+// (AES-256-GCM, lib/sp/vault.ts) so members can re-reveal it on demand.
import { serviceClient } from "@/lib/supabase/service";
import { requireProjectAccess } from "@/lib/lx/currentSite";
import { mintProjectKey } from "@/lib/audience/projectKeys";
+import { decryptSecret } from "@/lib/sp/vault";
export async function createProjectApiKey(input: {
projectId: string;
@@ -31,6 +33,7 @@ export async function createProjectApiKey(input: {
name,
key_prefix: minted.prefix,
key_hash: minted.hash,
+ key_ciphertext: minted.ciphertext,
created_by: access.userId,
});
if (error) return { ok: false, error: error.message };
@@ -38,6 +41,38 @@ export async function createProjectApiKey(input: {
return { ok: true, key: minted.plaintext, prefix: minted.prefix };
}
+export async function revealProjectApiKey(input: {
+ projectId: string;
+ keyId: string;
+}): Promise<{ ok: true; key: string } | { ok: false; error: string }> {
+ const access = await requireProjectAccess(input.projectId);
+ if (!access.ok) return { ok: false, error: "Not found." };
+ if (access.isViewer) return { ok: false, error: "Viewers can't reveal API keys." };
+
+ const svc = serviceClient();
+ const { data: row, error } = await svc
+ .from("project_api_keys")
+ .select("key_ciphertext, revoked_at")
+ .eq("id", input.keyId)
+ .eq("project_id", input.projectId)
+ .maybeSingle();
+ if (error) return { ok: false, error: error.message };
+ if (!row) return { ok: false, error: "Not found." };
+ if (row.revoked_at) return { ok: false, error: "Key has been revoked." };
+ if (!row.key_ciphertext) {
+ return {
+ ok: false,
+ error:
+ "This key predates recoverable storage and can't be shown again — generate a new one.",
+ };
+ }
+ try {
+ return { ok: true, key: decryptSecret(row.key_ciphertext as string) };
+ } catch {
+ return { ok: false, error: "Could not decrypt this key." };
+ }
+}
+
export async function revokeProjectApiKey(input: {
projectId: string;
keyId: string;
diff --git a/lib/audience/projectKeys.ts b/lib/audience/projectKeys.ts
index 2005d7ad..0878cdf5 100644
--- a/lib/audience/projectKeys.ts
+++ b/lib/audience/projectKeys.ts
@@ -1,22 +1,26 @@
// Per-project server ingest keys for POST /api/events.
//
// Token shape: `cpk_` prefix + 43 base64url chars from 32 bytes of
-// crypto-random. Mirrors lib/sp/apiToken.ts: we never persist the
-// plaintext — only the display prefix and sha256(plaintext + pepper).
-// SHA-256 is fine because the plaintext carries 256 bits of entropy;
-// the shared SP_TOKEN_PEPPER means a DB leak alone is useless.
+// crypto-random. Verification mirrors lib/sp/apiToken.ts: lookup is by
+// sha256(plaintext + pepper), which is fine because the plaintext
+// carries 256 bits of entropy; the shared SP_TOKEN_PEPPER means a DB
+// leak alone is useless. We additionally persist the plaintext
+// AES-256-GCM-encrypted under SOCIAL_VAULT_KEY (lib/sp/vault.ts) so
+// owners can re-reveal a key from the dashboard instead of show-once.
import crypto from "node:crypto";
import { env } from "@/lib/env";
+import { encryptSecret } from "@/lib/sp/vault";
import { serviceClient } from "@/lib/supabase/service";
const PREFIX = "cpk_";
const PREFIX_DISPLAY_LEN = 8;
export type MintedProjectKey = {
- plaintext: string; // shown to the user ONCE; never re-derivable.
+ plaintext: string;
prefix: string;
- hash: string;
+ hash: string; // verification lookup
+ ciphertext: string; // at-rest encrypted copy for later reveal
};
export function mintProjectKey(): MintedProjectKey {
@@ -31,6 +35,7 @@ export function mintProjectKey(): MintedProjectKey {
plaintext,
prefix: plaintext.slice(0, PREFIX_DISPLAY_LEN),
hash: hashProjectKey(plaintext),
+ ciphertext: encryptSecret(plaintext),
};
}
diff --git a/supabase/migrations/20260612160000_project_api_keys_ciphertext.sql b/supabase/migrations/20260612160000_project_api_keys_ciphertext.sql
new file mode 100644
index 00000000..8cdda322
--- /dev/null
+++ b/supabase/migrations/20260612160000_project_api_keys_ciphertext.sql
@@ -0,0 +1,7 @@
+-- Recoverable Audience ingest keys: store the key AES-256-GCM-encrypted
+-- (lib/sp/vault.ts, SOCIAL_VAULT_KEY) alongside the verification hash so
+-- owners can re-reveal a key instead of the old show-once flow.
+-- Keys minted before this column exists stay null = not revealable.
+
+alter table public.project_api_keys
+ add column if not exists key_ciphertext text;
diff --git a/tests/audience-hub.test.ts b/tests/audience-hub.test.ts
index ea16b09d..1996fdfa 100644
--- a/tests/audience-hub.test.ts
+++ b/tests/audience-hub.test.ts
@@ -168,4 +168,12 @@ describe("project ingest keys", () => {
expect(isProjectKeyShape("crp_not_a_project_key_aaaaaaaaaaaaaaaa")).toBe(false);
expect(isProjectKeyShape("cpk_short")).toBe(false);
});
+
+ it("stores a ciphertext that decrypts back to the plaintext", async () => {
+ const { mintProjectKey } = await import("@/lib/audience/projectKeys");
+ const { decryptSecret } = await import("@/lib/sp/vault");
+ const minted = mintProjectKey();
+ expect(minted.ciphertext).not.toContain(minted.plaintext);
+ expect(decryptSecret(minted.ciphertext)).toBe(minted.plaintext);
+ });
});
diff --git a/tests/setup.ts b/tests/setup.ts
index 13a7e3bd..f0d2e258 100644
--- a/tests/setup.ts
+++ b/tests/setup.ts
@@ -17,3 +17,5 @@ process.env.POSTHOG_INBOUND_WEBHOOK_SECRET ??= "stub_posthog_webhook_secret";
process.env.ANTHROPIC_API_KEY ??= "stub_anthropic";
process.env.RESEND_FROM ??= "Test
";
process.env.SP_TOKEN_PEPPER ??= "stub_pepper";
+// AES-256-GCM vault key (lib/sp/vault.ts) — must decode to 32 bytes.
+process.env.SOCIAL_VAULT_KEY ??= Buffer.alloc(32, 7).toString("base64");