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
2 changes: 1 addition & 1 deletion packages/auth/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pickforge/auth",
"version": "0.10.0",
"version": "0.11.0",
"description": "UI-free Supabase Auth and entitlements wrapper for Pickforge apps.",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/billing/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pickforge/billing",
"version": "0.10.0",
"version": "0.11.0",
"description": "UI-free Stripe billing and credit-ledger helpers for Pickforge apps.",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/brand/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pickforge/brand",
"version": "0.10.0",
"version": "0.11.0",
"description": "Pickforge CSS tokens, fonts, reset, and primitives.",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/edge-shared/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ Deno.serve(async (req) => {
Requesting deletion is terminal: once the fence exists, checkout stays disabled and the deletion endpoint is retry-only until every cleanup or refund finishes and auth deletion succeeds.
Registry state `open` means “registered and not yet reconciled or durably marked expired”; it does not assert that Stripe still reports the Session as open.

The lifecycle-aware helpers require a `0.9.0` package publish before deploying functions that import them.
The lifecycle-aware helpers require a `0.9.0` package publish before deploying functions that import them. `isCheckoutDeletionFenced`, `registerCheckoutSession`, and `markCheckoutSessionExpired` wrap the `checkout_lifecycle_is_deletion_fenced`, `checkout_lifecycle_register_session`, and `checkout_lifecycle_mark_expired` RPCs used by `createRegisteredCheckoutSession`'s callbacks, so a Checkout Session creation adapter never re-implements that RPC contract.

## Cross-repo imports

Expand Down
2 changes: 1 addition & 1 deletion packages/edge-shared/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pickforge/edge-shared",
"version": "0.10.0",
"version": "0.11.0",
"description": "Deno-compatible shared helpers for Pickforge Edge Functions.",
"license": "MIT",
"repository": {
Expand Down
45 changes: 44 additions & 1 deletion packages/edge-shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1162,7 +1162,46 @@ async function fenceAccountDeletion(admin: AccountAdminClientLike, userId: strin
}
}

async function markCheckoutSessionExpired(
/**
* Reads whether an account deletion fence is already in effect for a user.
* The sole production caller registers a new Checkout Session (see
* `createRegisteredCheckoutSession`'s `isDeletionFenced` callback); colocated
* here so the RPC name and its result contract have one owner.
*/
export async function isCheckoutDeletionFenced(
admin: Pick<AccountAdminClientLike, "rpc">,
userId: string,
): Promise<boolean> {
const { data, error } = await accountRpc<boolean>(admin, "checkout_lifecycle_is_deletion_fenced", {
target_user: userId,
});
if (error !== null || typeof data !== "boolean") {
throw databaseError("Failed to read account deletion fence", error ?? invalidRpcResultCause());
}
return data;
}

/**
* Registers a newly created Checkout Session against the durable lifecycle,
* returning whether an account-deletion fence raced the registration. See
* `isCheckoutDeletionFenced` for why this lives next to that check.
*/
export async function registerCheckoutSession(
admin: Pick<AccountAdminClientLike, "rpc">,
userId: string,
sessionId: string,
): Promise<boolean> {
const { data, error } = await accountRpc<boolean>(admin, "checkout_lifecycle_register_session", {
target_user: userId,
checkout_session_id: sessionId,
});
if (error !== null || typeof data !== "boolean") {
throw databaseError("Failed to register Checkout Session", error ?? invalidRpcResultCause());
}
return data;
}

export async function markCheckoutSessionExpired(
admin: Pick<AccountAdminClientLike, "rpc">,
sessionId: string,
): Promise<void> {
Expand All @@ -1174,6 +1213,10 @@ async function markCheckoutSessionExpired(
}
}

function invalidRpcResultCause(): SupabaseErrorLike {
return { message: "rpc returned an invalid result" };
}

async function runDeletionSettlementPass(
admin: AccountAdminClientLike,
stripe: StripeCustomerClientLike,
Expand Down
66 changes: 66 additions & 0 deletions packages/edge-shared/test/edge-shared.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,11 @@ import {
getBearerToken,
expireCheckoutSession,
getUserFromRequest,
isCheckoutDeletionFenced,
jsonResponse,
markCheckoutSessionExpired,
newIdempotencyKey,
registerCheckoutSession,
withCors,
operatorRouterSystemPrompt,
requireEntitlement,
Expand Down Expand Up @@ -370,6 +373,69 @@ describe("@pickforge/edge-shared", () => {
expect(markSessionExpired).toHaveBeenCalledWith("cs_raced");
});

it("reads the account deletion fence used to gate new Checkout Session registration", async () => {
const rpc = vi.fn(async (fn: string, args?: Record<string, unknown>) => {
expect(fn).toBe("checkout_lifecycle_is_deletion_fenced");
expect(args).toEqual({ target_user: USER_ID });
return { data: true, error: null };
});

await expect(isCheckoutDeletionFenced({ rpc }, USER_ID)).resolves.toBe(true);
});

it("raises a database_error when the deletion fence RPC fails or returns a non-boolean", async () => {
await expect(
isCheckoutDeletionFenced({ rpc: async () => ({ data: null, error: { message: "down" } }) }, USER_ID),
).rejects.toMatchObject({ code: "database_error" } satisfies Partial<EdgeSharedError>);

await expect(
isCheckoutDeletionFenced({ rpc: async () => ({ data: "nope", error: null }) }, USER_ID),
).rejects.toMatchObject({ code: "database_error" } satisfies Partial<EdgeSharedError>);
});

it("registers a Checkout Session against the durable lifecycle", async () => {
const rpc = vi.fn(async (fn: string, args?: Record<string, unknown>) => {
expect(fn).toBe("checkout_lifecycle_register_session");
expect(args).toEqual({ target_user: USER_ID, checkout_session_id: "cs_registered" });
return { data: false, error: null };
});

await expect(registerCheckoutSession({ rpc }, USER_ID, "cs_registered")).resolves.toBe(false);
});

it("raises a database_error when Checkout Session registration fails or returns a non-boolean", async () => {
await expect(
registerCheckoutSession(
{ rpc: async () => ({ data: null, error: { message: "down" } }) },
USER_ID,
"cs_x",
),
).rejects.toMatchObject({ code: "database_error" } satisfies Partial<EdgeSharedError>);

await expect(
registerCheckoutSession({ rpc: async () => ({ data: 1, error: null }) }, USER_ID, "cs_x"),
).rejects.toMatchObject({ code: "database_error" } satisfies Partial<EdgeSharedError>);
});

it("marks a Checkout Session expired against the durable lifecycle", async () => {
const rpc = vi.fn(async (fn: string, args?: Record<string, unknown>) => {
expect(fn).toBe("checkout_lifecycle_mark_expired");
expect(args).toEqual({ checkout_session_id: "cs_expired" });
return { data: null, error: null };
});

await expect(markCheckoutSessionExpired({ rpc }, "cs_expired")).resolves.toBeUndefined();
});

it("raises a database_error when marking a Checkout Session expired fails", async () => {
await expect(
markCheckoutSessionExpired(
{ rpc: async () => ({ data: null, error: { message: "down" } }) },
"cs_expired",
),
).rejects.toMatchObject({ code: "database_error" } satisfies Partial<EdgeSharedError>);
});

it("recognizes a completed Session without trying to expire it", async () => {
const stripe = checkoutStripe();
stripe.checkout.sessions.retrieve = vi.fn(async () => ({
Expand Down
2 changes: 1 addition & 1 deletion packages/flags/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pickforge/flags",
"version": "0.10.0",
"version": "0.11.0",
"description": "UI-free feature-flag registry for release gating in Pickforge apps.",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/sync/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pickforge/sync",
"version": "0.10.0",
"version": "0.11.0",
"description": "UI-free settings sync helpers for Pickforge apps.",
"license": "MIT",
"repository": {
Expand Down
2 changes: 1 addition & 1 deletion packages/tauri-release/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pickforge/tauri-release",
"version": "0.10.0",
"version": "0.11.0",
"description": "Signed Tauri release and updater-feed automation for Pickforge apps.",
"license": "MIT",
"repository": {
Expand Down
4 changes: 2 additions & 2 deletions supabase/functions/create-credit-checkout/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"imports": {
"stripe": "npm:stripe@19.1.0",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0",
"@pickforge/billing": "npm:@pickforge/billing@0.10.0",
"@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0"
"@pickforge/billing": "npm:@pickforge/billing@0.11.0",
"@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.11.0"
}
}
43 changes: 6 additions & 37 deletions supabase/functions/create-credit-checkout/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import {
createRequiredEnv,
EdgeSharedError,
getUserFromRequest,
isCheckoutDeletionFenced,
jsonResponse,
markCheckoutSessionExpired,
registerCheckoutSession,
} from "@pickforge/edge-shared";

const requiredEnv = createRequiredEnv(Deno.env);
Expand Down Expand Up @@ -40,7 +43,7 @@ Deno.serve(async (req) => {
const session = await createRegisteredCheckoutSession({
stripe,
userId,
isDeletionFenced,
isDeletionFenced: (id) => isCheckoutDeletionFenced(serviceSupabase, id),
createSession: async () => {
const created = await createCreditCheckoutSession({
stripe,
Expand All @@ -56,8 +59,8 @@ Deno.serve(async (req) => {
}
return { id: created.id, url: created.url };
},
registerSession: registerCheckoutSession,
markSessionExpired: markCheckoutSessionExpired,
registerSession: (id, sessionId) => registerCheckoutSession(serviceSupabase, id, sessionId),
markSessionExpired: (sessionId) => markCheckoutSessionExpired(serviceSupabase, sessionId),
});

return respond(200, { url: session.url });
Expand Down Expand Up @@ -92,40 +95,6 @@ async function readExistingCustomerId(userId: string): Promise<string | undefine
: undefined;
}

async function isDeletionFenced(userId: string): Promise<boolean> {
const { data, error } = await serviceSupabase
.rpc("checkout_lifecycle_is_deletion_fenced", { target_user: userId })
.overrideTypes<boolean, { merge: false }>();
if (error !== null || typeof data !== "boolean") {
throw new Error("Failed to read account deletion fence", { cause: error });
}

return data;
}

async function registerCheckoutSession(userId: string, sessionId: string): Promise<boolean> {
const { data, error } = await serviceSupabase
.rpc("checkout_lifecycle_register_session", {
target_user: userId,
checkout_session_id: sessionId,
})
.overrideTypes<boolean, { merge: false }>();
if (error !== null || typeof data !== "boolean") {
throw new Error("Failed to register Checkout Session", { cause: error });
}

return data;
}

async function markCheckoutSessionExpired(sessionId: string): Promise<void> {
const { error } = await serviceSupabase.rpc("checkout_lifecycle_mark_expired", {
checkout_session_id: sessionId,
});
if (error !== null) {
throw new Error("Failed to mark Checkout Session expired", { cause: error });
}
}

function respond(status: number, body: unknown): Response {
return jsonResponse(status, body, corsHeaders());
}
Expand Down
2 changes: 1 addition & 1 deletion supabase/functions/delete-account/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@
"imports": {
"stripe": "npm:stripe@19.1.0",
"@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0",
"@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0"
"@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.11.0"
}
}
2 changes: 1 addition & 1 deletion supabase/functions/export-account-data/deno.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"imports": {
"@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0",
"@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0"
"@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.11.0"
}
}
4 changes: 2 additions & 2 deletions supabase/functions/operator-router/deno.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"imports": {
"@supabase/supabase-js": "npm:@supabase/supabase-js@2.110.0",
"@pickforge/billing": "npm:@pickforge/billing@0.10.0",
"@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.10.0"
"@pickforge/billing": "npm:@pickforge/billing@0.11.0",
"@pickforge/edge-shared": "npm:@pickforge/edge-shared@0.11.0"
}
}
4 changes: 2 additions & 2 deletions supabase/functions/stripe-webhook/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Stripe from "npm:stripe@19.1.0";
import { createClient } from "npm:@supabase/supabase-js@2.110.0";
import { processStripeEvent, verifyStripeEvent } from "npm:@pickforge/billing@0.10.0";
import { createRequiredEnv, createStripeWebhookHandler } from "npm:@pickforge/edge-shared@0.10.0";
import { processStripeEvent, verifyStripeEvent } from "npm:@pickforge/billing@0.11.0";
import { createRequiredEnv, createStripeWebhookHandler } from "npm:@pickforge/edge-shared@0.11.0";

const requiredEnv = createRequiredEnv(Deno.env);
const stripe = new Stripe(requiredEnv("STRIPE_SECRET_KEY"));
Expand Down