Skip to content
Open
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
1 change: 0 additions & 1 deletion .claude/scheduled_tasks.lock

This file was deleted.

19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ Stack is the *control plane*. [Phantom](https://phm.dev) is the *vault*. ashlr-p
```bash
stack init # interactive template picker
stack add supabase # OAuth → new project → secrets → .mcp.json
stack add stripe # paste sk_live_… → validates → stores STRIPE_SECRET_KEY

# Stripe webhook endpoint — fully agent-driveable, no dashboard copy-paste
stack add stripe --webhook-endpoint https://example.com/webhooks/stripe
# → creates the endpoint via Stripe API
# → stores STRIPE_WEBHOOK_SECRET (whsec_…) + STRIPE_WEBHOOK_ENDPOINT_ID in Phantom
# → default events: customer.subscription.{created,updated,deleted,trial_will_end}
# invoice.payment_failed

# Custom event list
stack add stripe \
--webhook-endpoint https://example.com/webhooks/stripe \
--events "payment_intent.succeeded,charge.failed"

# Reuse an existing sk_… from the vault (skip the interactive paste)
stack add stripe \
--webhook-endpoint https://example.com/webhooks/stripe \
--secret-key-from-vault

stack providers # full catalog (29 services across 11 categories)
stack doctor --fix # verify every service; re-run setup for anything broken
stack exec -- bun dev # run with Phantom's secret proxy active
Expand Down
29 changes: 28 additions & 1 deletion packages/cli/src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,22 @@ export const addCommand = defineCommand({
default: "ask",
description: "SDK install behaviour after provisioning: ask (default), always, never.",
},
webhookEndpoint: {
type: "string",
description:
"Stripe only: HTTPS URL to register as a webhook endpoint. Triggers webhook provisioning and stores STRIPE_WEBHOOK_SECRET + STRIPE_WEBHOOK_ENDPOINT_ID in Phantom.",
},
events: {
type: "string",
description:
"Stripe only: comma-separated list of Stripe events to subscribe to. Defaults to the subscription-lifecycle set when --webhook-endpoint is given.",
},
secretKeyFromVault: {
type: "boolean",
default: false,
description:
"Stripe only: skip the interactive sk_… paste and reuse STRIPE_SECRET_KEY already in Phantom.",
},
},
async run({ args }) {
if (!hasConfig()) {
Expand Down Expand Up @@ -145,7 +161,7 @@ export const addCommand = defineCommand({
const result = await addService({
providerName: service,
existingResourceId: args.use ? String(args.use) : undefined,
hints: args.region ? { region: String(args.region) } : undefined,
hints: buildHints(args),
interactive: process.stdout.isTTY === true,
log: (event) => {
spinner.stop();
Expand Down Expand Up @@ -221,6 +237,17 @@ async function handleSdkInstall(pkgs: string[], mode: string, dryRun: boolean):
}
}

function buildHints(
args: Record<string, unknown>,
): Record<string, unknown> | undefined {
const hints: Record<string, unknown> = {};
if (args.region) hints.region = String(args.region);
if (args.webhookEndpoint) hints.webhookEndpoint = String(args.webhookEndpoint);
if (args.events) hints.events = String(args.events);
if (args.secretKeyFromVault) hints.secretKeyFromVault = true;
return Object.keys(hints).length > 0 ? hints : undefined;
}

async function groupProvidersForPicker(names: string[]) {
const out: Array<{ label: string; value: string; hint?: string }> = [];
for (const name of names) {
Expand Down
263 changes: 263 additions & 0 deletions packages/core/src/__tests__/stripe-webhook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import type { ProviderContext } from "../providers/_base.ts";
import stripe, {
DEFAULT_WEBHOOK_EVENTS,
_createWebhookEndpoint,
_verifyKey,
} from "../providers/stripe.ts";
import { type Harness, readVault, setupFakePhantom } from "./_harness.ts";

/**
* Unit tests for the Stripe webhook provisioning flow.
*
* All HTTP is mocked — these tests never reach api.stripe.com.
*
* Scenarios:
* (a) Successful webhook creation — keys land in vault, secrets returned correctly.
* (b) Webhook creation with custom events list.
* (c) Default events list is used when --events is omitted.
* (d) Error path when sk_ is not in vault and --secret-key-from-vault is set.
* (e) Stripe API error propagates as StackError with STRIPE_WEBHOOK_CREATE_FAILED code.
* (f) Plain sk_… flow (no webhook) still works after the refactor.
*/

const FAKE_SK = "sk_test_fake_secret_key";
const FAKE_WEBHOOK_ID = "we_1234567890abcdef";
const FAKE_WHSEC = "whsec_abcdefghijklmnopqrstuvwxyz0123456789";
const WEBHOOK_URL = "https://example.com/webhooks/stripe";

function makeCtx(overrides: Partial<ProviderContext> = {}): ProviderContext {
return { cwd: process.cwd(), interactive: false, log: () => {}, ...overrides };
}

function mockFetchOk(body: unknown, status = 200): typeof fetch {
return (async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch;
}

function stripeAccountOk(): Response {
return new Response(JSON.stringify({ id: "acct_fake", type: "standard" }), { status: 200 });
}

function webhookCreateOk(endpointId: string, whsec: string, url: string): Response {
return new Response(
JSON.stringify({ id: endpointId, secret: whsec, url, object: "webhook_endpoint" }),
{ status: 201 },
);
}

describe("stripe provider — webhook provisioning", () => {
let h: Harness;
let realFetch: typeof fetch;

beforeEach(() => {
h = setupFakePhantom();
realFetch = globalThis.fetch;
});

afterEach(() => {
globalThis.fetch = realFetch;
h.cleanup();
});

// ── (a) Successful webhook creation ──────────────────────────────────────

test("(a) provision creates webhook, materialize stores all three secrets", async () => {
const captured: { url: string; method: string; body: string }[] = [];

globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
captured.push({ url, method, body: String(init?.body ?? "") });

if (url.includes("/v1/account")) return stripeAccountOk();
if (url.includes("/v1/webhook_endpoints") && method === "POST") {
return webhookCreateOk(FAKE_WEBHOOK_ID, FAKE_WHSEC, WEBHOOK_URL);
}
throw new Error(`Unexpected ${method} ${url}`);
}) as unknown as typeof fetch;

const ctx = makeCtx();
const auth = { token: FAKE_SK, identity: { id: "acct_fake", type: "standard" } };

const resource = await stripe.provision(ctx, auth, {
hints: { webhookEndpoint: WEBHOOK_URL },
});

expect(resource.id).toBe(FAKE_WEBHOOK_ID);
expect(resource.displayName).toBe(`Stripe webhook (${FAKE_WEBHOOK_ID})`);
expect((resource.meta as Record<string, string>).webhookSecret).toBe(FAKE_WHSEC);
expect((resource.meta as Record<string, string>).webhookEndpointId).toBe(FAKE_WEBHOOK_ID);

const materialized = await stripe.materialize(ctx, resource, auth);

// All three env names must be in the returned secrets map.
expect(materialized.secrets.STRIPE_SECRET_KEY).toBe(FAKE_SK);
expect(materialized.secrets.STRIPE_WEBHOOK_SECRET).toBe(FAKE_WHSEC);
expect(materialized.secrets.STRIPE_WEBHOOK_ENDPOINT_ID).toBe(FAKE_WEBHOOK_ID);

// Vault must contain all three.
const vault = await readVault(h.dir);
expect(vault.STRIPE_SECRET_KEY).toBe(FAKE_SK);
expect(vault.STRIPE_WEBHOOK_SECRET).toBe(FAKE_WHSEC);
expect(vault.STRIPE_WEBHOOK_ENDPOINT_ID).toBe(FAKE_WEBHOOK_ID);

// The POST to /v1/webhook_endpoints must use form-encoded body.
const webhookCall = captured.find(
(c) => c.url.includes("/v1/webhook_endpoints") && c.method === "POST",
);
expect(webhookCall).toBeTruthy();
expect(webhookCall!.body).toContain(encodeURIComponent(WEBHOOK_URL));
});

// ── (b) Custom events list is forwarded to Stripe ────────────────────────

test("(b) custom --events are sent in the POST body", async () => {
const customEvents = ["payment_intent.succeeded", "charge.failed"];
let capturedBody = "";

globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
if (url.includes("/v1/webhook_endpoints") && method === "POST") {
capturedBody = String(init?.body ?? "");
return webhookCreateOk(FAKE_WEBHOOK_ID, FAKE_WHSEC, WEBHOOK_URL);
}
throw new Error(`Unexpected ${method} ${url}`);
}) as unknown as typeof fetch;

const ctx = makeCtx();
const auth = { token: FAKE_SK, identity: {} };

await stripe.provision(ctx, auth, {
hints: { webhookEndpoint: WEBHOOK_URL, events: customEvents.join(",") },
});

for (const ev of customEvents) {
expect(capturedBody).toContain(encodeURIComponent(ev));
}
// Default events must NOT appear when a custom list is provided.
for (const defaultEv of DEFAULT_WEBHOOK_EVENTS) {
expect(capturedBody).not.toContain(encodeURIComponent(defaultEv));
}
});

// ── (c) Default events list ───────────────────────────────────────────────

test("(c) default subscription-lifecycle events are used when --events is omitted", async () => {
let capturedBody = "";

globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
if (url.includes("/v1/webhook_endpoints") && method === "POST") {
capturedBody = String(init?.body ?? "");
return webhookCreateOk(FAKE_WEBHOOK_ID, FAKE_WHSEC, WEBHOOK_URL);
}
throw new Error(`Unexpected ${method} ${url}`);
}) as unknown as typeof fetch;

const ctx = makeCtx();
const auth = { token: FAKE_SK, identity: {} };

await stripe.provision(ctx, auth, { hints: { webhookEndpoint: WEBHOOK_URL } });

expect(DEFAULT_WEBHOOK_EVENTS.length).toBeGreaterThan(0);
for (const ev of DEFAULT_WEBHOOK_EVENTS) {
expect(capturedBody).toContain(encodeURIComponent(ev));
}
});

// ── (d) --secret-key-from-vault fails when vault is empty ────────────────

test("(d) login throws STRIPE_AUTH_REQUIRED when vault is empty and --secret-key-from-vault", async () => {
// Vault is empty (setupFakePhantom initialises with {}).
const ctx = makeCtx({ hints: { secretKeyFromVault: true } });

await expect(stripe.login(ctx)).rejects.toMatchObject({
code: "STRIPE_AUTH_REQUIRED",
});
});

// ── (e) Stripe API error propagates correctly ─────────────────────────────

test("(e) Stripe 400 during webhook creation throws STRIPE_WEBHOOK_CREATE_FAILED", async () => {
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const method = (init?.method ?? "GET").toUpperCase();
if (url.includes("/v1/webhook_endpoints") && method === "POST") {
return new Response(
JSON.stringify({ error: { message: "Invalid URL" } }),
{ status: 400 },
);
}
throw new Error(`Unexpected ${method} ${url}`);
}) as unknown as typeof fetch;

const ctx = makeCtx();
const auth = { token: FAKE_SK, identity: {} };

await expect(
stripe.provision(ctx, auth, { hints: { webhookEndpoint: "not-a-url" } }),
).rejects.toMatchObject({ code: "STRIPE_WEBHOOK_CREATE_FAILED" });
});

// ── (f) Plain sk_… flow unchanged ────────────────────────────────────────

test("(f) provision without webhookEndpoint hint behaves like makeApiKeyProvider", async () => {
// No HTTP call is made in the plain flow.
globalThis.fetch = (async () => {
throw new Error("fetch must not be called in the plain sk_ flow");
}) as unknown as typeof fetch;

const ctx = makeCtx();
const auth = { token: FAKE_SK, identity: { id: "acct_fake", type: "standard" } };

const resource = await stripe.provision(ctx, auth, {});
expect(resource.id).toBe("acct_fake");

const materialized = await stripe.materialize(ctx, resource, auth);
expect(materialized.secrets.STRIPE_SECRET_KEY).toBe(FAKE_SK);
// Webhook secrets must NOT appear when no webhook was created.
expect(materialized.secrets.STRIPE_WEBHOOK_SECRET).toBeUndefined();
expect(materialized.secrets.STRIPE_WEBHOOK_ENDPOINT_ID).toBeUndefined();

const vault = await readVault(h.dir);
expect(vault.STRIPE_SECRET_KEY).toBe(FAKE_SK);
expect(vault.STRIPE_WEBHOOK_SECRET).toBeUndefined();
});
});

describe("stripe provider — _createWebhookEndpoint (unit)", () => {
let realFetch: typeof fetch;

beforeEach(() => {
realFetch = globalThis.fetch;
});

afterEach(() => {
globalThis.fetch = realFetch;
});

test("returns parsed endpoint response on 201", async () => {
globalThis.fetch = mockFetchOk(
{ id: FAKE_WEBHOOK_ID, secret: FAKE_WHSEC, url: WEBHOOK_URL },
201,
);
const result = await _createWebhookEndpoint(FAKE_SK, WEBHOOK_URL, DEFAULT_WEBHOOK_EVENTS);
expect(result.id).toBe(FAKE_WEBHOOK_ID);
expect(result.secret).toBe(FAKE_WHSEC);
expect(result.url).toBe(WEBHOOK_URL);
});

test("throws on non-2xx with Stripe error detail", async () => {
globalThis.fetch = (async () =>
new Response(
JSON.stringify({ error: { message: "URL must be HTTPS" } }),
{ status: 400 },
)) as unknown as typeof fetch;

await expect(
_createWebhookEndpoint(FAKE_SK, "http://insecure.example.com/hook", ["payment_intent.succeeded"]),
).rejects.toMatchObject({ code: "STRIPE_WEBHOOK_CREATE_FAILED" });
});
});
1 change: 1 addition & 0 deletions packages/core/src/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export async function addService(opts: AddServiceOpts): Promise<AddServiceResult
cwd,
interactive: opts.interactive ?? process.stdout.isTTY === true,
log: opts.log ?? (() => {}),
hints: opts.hints,
};

const auth = await provider.login(ctx);
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/providers/_base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@ export interface ProviderContext {
interactive: boolean;
/** Structured logger — callers wire this to the CLI's @clack spinner. */
log: (event: LogEvent) => void;
/**
* Provider-specific hints forwarded from the CLI (e.g. webhookEndpoint,
* secretKeyFromVault). Available to both `login` and `provision` so providers
* can alter their auth flow based on flags.
*/
hints?: Record<string, unknown>;
}

export interface LogEvent {
Expand Down
Loading
Loading