Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 95 additions & 24 deletions app/(app)/projects/[id]/audience/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import {
createProjectApiKey,
revealProjectApiKey,
revokeProjectApiKey,
} from "@/app/actions/audience";

Expand All @@ -15,6 +16,7 @@ type KeyRow = {
last_used_at: string | null;
revoked_at: string | null;
created_at: string;
can_reveal: boolean;
};

type RepoRow = {
Expand All @@ -33,6 +35,29 @@ function fmt(iso: string | null): string {
}
}

function KeyValue({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="mt-2 flex items-start gap-2">
<code className="block min-w-0 flex-1 break-all rounded bg-[var(--color-bg)] p-2 text-xs">
{value}
</code>
<button
type="button"
className="btn btn-sm shrink-0"
onClick={() => {
void navigator.clipboard.writeText(value).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}}
>
{copied ? "Copied ✓" : "Copy"}
</button>
</div>
);
}

export function AudienceKeysClient({
projectId,
keys,
Expand All @@ -44,6 +69,7 @@ export function AudienceKeysClient({
const [pending, start] = useTransition();
const [name, setName] = useState("");
const [justMinted, setJustMinted] = useState<{ name: string; key: string } | null>(null);
const [revealed, setRevealed] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);

function submit(e: React.FormEvent) {
Expand All @@ -70,6 +96,26 @@ export function AudienceKeysClient({
});
}

function reveal(keyId: string) {
setError(null);
start(async () => {
const r = await revealProjectApiKey({ projectId, keyId });
if (!r.ok) {
setError(r.error);
return;
}
setRevealed((prev) => ({ ...prev, [keyId]: r.key }));
});
}

function hide(keyId: string) {
setRevealed((prev) => {
const next = { ...prev };
delete next[keyId];
return next;
});
}

return (
<div className="space-y-4">
<form onSubmit={submit} className="flex flex-wrap items-end gap-2">
Expand Down Expand Up @@ -97,13 +143,12 @@ export function AudienceKeysClient({
{error && <p className="text-sm text-[var(--color-fail)]">{error}</p>}

{justMinted && (
<div className="rounded border border-[var(--color-warn)]/40 bg-[var(--color-warn)]/10 p-3">
<div className="rounded border border-[var(--color-border)] bg-[var(--color-bg)]/40 p-3">
<p className="text-sm font-semibold">
Copy “{justMinted.name}” now — it won&apos;t be shown again.
“{justMinted.name}” is ready — you can reveal it again from this
list anytime.
</p>
<code className="mt-2 block break-all rounded bg-[var(--color-bg)] p-2 text-xs">
{justMinted.key}
</code>
<KeyValue value={justMinted.key} />
</div>
)}

Expand All @@ -114,28 +159,54 @@ export function AudienceKeysClient({
{keys.map((key) => (
<li
key={key.id}
className="flex flex-wrap items-center justify-between gap-2 rounded border border-[var(--color-border)] px-3 py-2"
className="rounded border border-[var(--color-border)] px-3 py-2"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{key.name}</span>
<code className="text-xs text-[var(--color-muted)]">{key.key_prefix}…</code>
{key.revoked_at ? (
<span className="badge badge-fail">revoked</span>
) : (
<span className="text-xs text-[var(--color-muted)]">
last used {fmt(key.last_used_at)}
</span>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{key.name}</span>
<code className="text-xs text-[var(--color-muted)]">{key.key_prefix}…</code>
{key.revoked_at ? (
<span className="badge badge-fail">revoked</span>
) : (
<span className="text-xs text-[var(--color-muted)]">
last used {fmt(key.last_used_at)}
</span>
)}
</div>
{!key.revoked_at && (
<div className="flex items-center gap-2">
{key.can_reveal &&
(revealed[key.id] ? (
<button
type="button"
className="btn btn-sm"
onClick={() => hide(key.id)}
>
Hide
</button>
) : (
<button
type="button"
className="btn btn-sm"
disabled={pending}
onClick={() => reveal(key.id)}
>
Reveal
</button>
))}
<button
type="button"
className="btn btn-sm"
disabled={pending}
onClick={() => revoke(key.id)}
>
Revoke
</button>
</div>
)}
</div>
{!key.revoked_at && (
<button
type="button"
className="btn btn-sm"
disabled={pending}
onClick={() => revoke(key.id)}
>
Revoke
</button>
{revealed[key.id] && !key.revoked_at && (
<KeyValue value={revealed[key.id]} />
)}
</li>
))}
Expand Down
14 changes: 10 additions & 4 deletions app/(app)/projects/[id]/audience/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -216,8 +222,8 @@ export default async function ProjectAudiencePage({
<h3 className="text-lg font-semibold">Server API keys</h3>
<p className="mt-1 text-sm text-[var(--color-muted)]">
Authenticate <code>POST {siteUrl}/api/events</code> with{" "}
<code>Authorization: Bearer cpk_…</code>. Keys are hashed at rest and
shown once at mint time.
<code>Authorization: Bearer cpk_…</code>. Keys are encrypted at rest
— reveal one again anytime from the list below.
</p>
<div className="mt-4">
<AudienceKeysClient projectId={projectId} keys={keys} />
Expand Down
37 changes: 36 additions & 1 deletion app/actions/audience.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -31,13 +33,46 @@ 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 };

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;
Expand Down
17 changes: 11 additions & 6 deletions lib/audience/projectKeys.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -31,6 +35,7 @@ export function mintProjectKey(): MintedProjectKey {
plaintext,
prefix: plaintext.slice(0, PREFIX_DISPLAY_LEN),
hash: hashProjectKey(plaintext),
ciphertext: encryptSecret(plaintext),
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 8 additions & 0 deletions tests/audience-hub.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
2 changes: 2 additions & 0 deletions tests/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <test@example.com>";
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");
Loading