Skip to content
Merged
383 changes: 383 additions & 0 deletions ADMIN_SURFACE_PLAN.md

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -2639,3 +2639,30 @@ state nothing can currently produce. If a real schema change ever rebuilds
`processed_payments` anyway, add this check in the same rebuild: the DDL belongs
on the last column via the `alsoAbout` pattern in
`src/shared/db/migrations/schema/payments/columns.ts`.

---

## Square treats a malformed payment link as "provider not configured"

_Origin: the 2026-08 refactor survey (ADMIN_SURFACE_PLAN.md)._

Stripe and SumUp build their checkout through `makeCreateCheckoutSession`
(`src/shared/payment-helpers.ts:483`). Its `requiredCheckoutResult` throws when
a non-null provider response lacks its session id or URL. Square opted out:
`squarePaymentProvider.createCheckoutSession`
(`src/shared/square-provider.ts:241-246`) reads the created payment link with
`toCheckoutResult(link?.orderId, link?.url, "Square")`, which logs and returns
`null` for the same condition. A `null` checkout result means "provider not
configured" to callers. So a Square payment link that arrives without its
`orderId` or `url` is reported as an unconfigured provider, where the identical
Stripe/SumUp condition raises loudly. That breaks the offensive-programming
rule: an absent expected field from structured external data must fail at its
boundary, not become a quiet default. The fix is to route Square through
`makeCreateCheckoutSession` (create = `squareApi.createPaymentLink`, readResult
= `link => ({ id: link.orderId, url: link.url })`), keep null-link =
not-configured, and add a regression test in which a payment link arrives
without its URL and the checkout throws instead of a "not configured" answer.
This is a payment behaviour change, so it needs its own small PR with the test.
Starting points: `src/shared/square-provider.ts:241`,
`src/shared/payment-helpers.ts:455-501`, the Square checkout tests under
`test/`.
5 changes: 2 additions & 3 deletions scripts/mutation/equivalent-mutants/features.txt
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,8 @@ src/features/admin/attendees-merge.ts::toBookingChoice~033wh8z skip_source →
src/shared/db/contact-tokens.ts::loadTokenBlob~0hgbweg ?? → || # loadTokenBlob's `row?.attendee_tokens_blob ?? null`: attendee_tokens_blob is string|undefined; the only falsy-non-null is "" and tokenLinesFrom("") === tokenLinesFrom(null) === [], so ?? and || agree
src/shared/db/contact-tokens.ts::syncChannelToken~07hz79m ?? → || # `removedSource ?? sync.source`: removedSource is BookingSource|null, and both BookingSource values ("admin","public") are non-empty truthy strings, so it is never falsy-but-non-null; ?? and || agree

# Admin segment dispatch (admin/index.ts) — two provably-equivalent survivors
# from the routeAdmin/adminPathSegment run over the manifest + footer suites.
src/features/admin/index.ts::adminPathSegment~1hrcxzo ?? → || # `path.split("/")[2] ?? ""`: the RHS is "" and the only falsy string is "", so `x ?? ""` and `x || ""` agree on every string|undefined input
# Admin segment dispatch (admin/index.ts) — one provably-equivalent survivor
# from the routeAdmin run over the manifest + footer suites.
src/features/admin/index.ts::buildSegmentRouters.areas~1hp6gg8 ?? → || # areasBySegment values are arrays, so every present value is truthy and a miss is undefined

# Admin refund waves (refunds/waves.ts) — one provably-equivalent survivor from
Expand Down
3 changes: 3 additions & 0 deletions scripts/mutation/equivalent-mutants/shared-a-l.txt
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ src/shared/bunny-cdn.ts::listEdgeScriptSecretsImpl.secrets~0cl5i4z ?? → ||
src/shared/subrequest-budget.ts::getSubrequestUsage~13c9nif ?? → || # scoped counts is an object when present, and every object is truthy
src/shared/accounting/queries.ts::transferActivityBounds~0ioze8t || → && # transferActivityBounds: MIN(occurred_at) and MAX(occurred_at) over one table are NULL together (both iff the table is empty), so either-null and both-null coincide
src/shared/admin-features.ts::featureBySlug~1rzgri8 ?? → || # find(): AdminFeatureDefinition|undefined; a feature object is always truthy
src/shared/admin-surface/definitions.ts::adminPathSegment~1hrcxzo ?? → || # `path.split("/")[2] ?? ""`: the RHS is "" and the only falsy string is "", so `x ?? ""` and `x || ""` agree on every string|undefined input
src/shared/admin-surface/definitions.ts::groupDestinations~1i8n5di ?? → || # `group ?? {}`: group is AdminRouteGroup|undefined, and an object is always truthy, so only undefined reaches the fallback
src/shared/admin-surface/definitions.ts::foldAdminAreas.declaredSegments~08mzyyu ?? → || # `area.segments ?? []`: segments is a readonly array when present, and arrays are always truthy, so only undefined reaches the fallback
src/shared/db/admin-features.ts::requireSettingCondition.args~1i6rv6b __required_setting_condition__ → "" # the placeholder key is never stored: a failed condition always aborts on its NULL value
src/shared/db/admin-features.ts::requireSettingCondition.args~1i6rv6b __required_setting_condition__ → "__required_setting_condition__ mutated" # same as above; no successful statement exposes this key
src/shared/db/admin-features.ts::usageJsonEntry~0041kmq ?? → || # inUseSql: a non-empty SQL string|undefined, so only undefined reaches the fallback
Expand Down
2 changes: 1 addition & 1 deletion src/features/admin/area-loaders.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { ADMIN_API_MESSAGE_GROUPS } from "#locales/groups.ts";
import { GUIDE_MESSAGE_GROUPS, type MessageGroup } from "#locales/manifest.ts";
import type { AdminAreaId } from "#shared/admin-surface/definitions.ts";
import type { AdminAreaId } from "#shared/admin-surface/ids.ts";

type HandlerMap = Record<string, (...args: never[]) => unknown>;

Expand Down
7 changes: 2 additions & 5 deletions src/features/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,12 @@ import {
import { isJsonApiPath } from "#routes/middleware.ts";
import { createRouter } from "#routes/router.ts";
import type { PathMethodRoute } from "#routes/types.ts";
import type { AdminAreaId } from "#shared/admin-surface/definitions.ts";
import { adminPathSegment } from "#shared/admin-surface/definitions.ts";
import type { AdminAreaId } from "#shared/admin-surface/ids.ts";
import { ADMIN_SURFACE } from "#shared/admin-surface.ts";
import { enableFooterDebug } from "#shared/db/query-log.ts";
import { isStaffRole } from "#shared/types.ts";

/** The `/admin/<segment>` part of a path, or "" for `/admin`. */
export const adminPathSegment = (path: string): string =>
path.split("/")[2] ?? "";

type AdminSegment = {
load: () => Promise<PathMethodRoute>;
messageGroups: readonly MessageGroup[];
Expand Down
71 changes: 31 additions & 40 deletions src/shared/admin-pages.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import type { AdminSurfaceContext } from "#shared/admin-surface/definitions.ts";
import type {
AdminSectionId,
AdminSurfaceContext,
} from "#shared/admin-surface/definitions.ts";
import {
ADMIN_SURFACE,
type AdminDestinationId,
adminDestination,
} from "#shared/admin-surface.ts";
AdminNavEntry,
AdminSectionDef,
} from "#shared/admin-surface/sections.ts";
import { ADMIN_SURFACE, adminDestination } from "#shared/admin-surface.ts";
import type { AdminLevel } from "#shared/types.ts";

export interface NavLink {
Expand All @@ -20,36 +17,31 @@ export interface NavSection {
readonly topHref: string;
}

const landingPattern = (
section: Pick<(typeof ADMIN_SURFACE.sections)[number], "landing">,
): string => adminDestination(section.landing as AdminDestinationId).pattern;

const navRoutesFor = (section: AdminSectionId) =>
ADMIN_SURFACE.destinations.filter(
(route) => route.section === section && route.nav !== undefined,
);
const landingPattern = (section: AdminSectionDef): string =>
adminDestination(section.landing).pattern;

const sectionVisible = (
section: (typeof ADMIN_SURFACE.sections)[number],
section: AdminSectionDef,
ctx: AdminSurfaceContext,
): boolean =>
adminDestination(section.landing).audience.includes(ctx.adminLevel) &&
(section.visible === undefined || section.visible(ctx));

const navEntryVisible = (
entry: AdminNavEntry,
ctx: AdminSurfaceContext,
): boolean => {
const landing = adminDestination(section.landing as AdminDestinationId);
const route = adminDestination(entry.id);
return (
landing.audience.includes(ctx.adminLevel) &&
(!("visible" in section) || section.visible(ctx))
route.audience.includes(ctx.adminLevel) &&
!(ctx.isReadOnly && route.intent === "write-form") &&
(entry.visible === undefined || entry.visible(ctx))
);
};

const routeVisible = (
route: (typeof ADMIN_SURFACE.destinations)[number],
const visibleAdminSections = (
ctx: AdminSurfaceContext,
): boolean =>
route.nav !== undefined &&
route.audience.includes(ctx.adminLevel) &&
!(ctx.isReadOnly && route.intent === "write-form") &&
(!("visible" in route.nav) || route.nav.visible(ctx));

const visibleAdminSections = (ctx: AdminSurfaceContext) =>
): readonly AdminSectionDef[] =>
ADMIN_SURFACE.sections.filter((section) => sectionVisible(section, ctx));

export const visibleTopLevel = (ctx: AdminSurfaceContext): NavLink[] =>
Expand All @@ -60,15 +52,14 @@ export const visibleTopLevel = (ctx: AdminSurfaceContext): NavLink[] =>

export const visibleSections = (ctx: AdminSurfaceContext): NavSection[] =>
visibleAdminSections(ctx)
.map((section) => ({ routes: navRoutesFor(section.id), section }))
.filter(({ routes }) => routes.length > 1)
.map(({ routes, section }) => ({
items: routes
.filter((route) => routeVisible(route, ctx))
.map((route) => ({
href: route.pattern,
// navRoutesFor keeps only destinations with navigation metadata.
labelKey: route.nav!.labelKey,
// A section with one link needs no sub-navigation of its own.
.filter((section) => section.nav.length > 1)
.map((section) => ({
items: section.nav
.filter((entry) => navEntryVisible(entry, ctx))
.map((entry) => ({
href: adminDestination(entry.id).pattern,
labelKey: entry.labelKey,
})),
labelKey: section.labelKey,
topHref: landingPattern(section),
Expand All @@ -82,14 +73,14 @@ export const entityReturnPath = (
const section = ADMIN_SURFACE.sections.find(
(candidate) => landingPattern(candidate) === sectionPath,
);
if (!section || !("detailPath" in section)) return sectionPath;
if (!section?.detailPath) return sectionPath;
const detail = section.detailPath.replace(":id", String(id));
return section.staffOnlyDetail && adminLevel === "editor"
? `${detail}/edit`
: detail;
};

export const readOnlyGetRoutePatterns = (): readonly string[] =>
ADMIN_SURFACE.destinations
Object.values(ADMIN_SURFACE.destinations)
.filter((route) => route.intent === "write-form")
.map((route) => route.pattern);
49 changes: 22 additions & 27 deletions src/shared/admin-surface.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,36 @@
/**
* The admin surface every consumer reads: each route by id, and the segments
* each area serves. Both derive from the one declaration in
* `admin-surface/areas.ts`.
*/

import { ADMIN_AREAS } from "#shared/admin-surface/areas.ts";
import {
ADMIN_SECTIONS,
ADMIN_SURFACE_AREAS,
type AdminDestinationDef,
foldAdminAreas,
} from "#shared/admin-surface/definitions.ts";
import { ADMIN_NAV_ROUTES } from "#shared/admin-surface/nav-routes.ts";
import { ADMIN_WRITE_ROUTES_A_M } from "#shared/admin-surface/write-routes-a-m.ts";
import { ADMIN_WRITE_ROUTES_N_Z } from "#shared/admin-surface/write-routes-n-z.ts";
import type { RouteParamNames } from "#shared/route-pattern.ts";
import type {
AdminDestinationId,
AdminPathParams,
} from "#shared/admin-surface/ids.ts";
import { ADMIN_SECTIONS } from "#shared/admin-surface/sections.ts";
import type { AdminLevel } from "#shared/types.ts";

const ADMIN_DESTINATIONS = [
...ADMIN_NAV_ROUTES,
...ADMIN_WRITE_ROUTES_A_M,
...ADMIN_WRITE_ROUTES_N_Z,
] as const;

export type AdminDestinationId = (typeof ADMIN_DESTINATIONS)[number]["id"];
export type { AdminDestinationId, AdminPathParams };

type PathFor<Id extends AdminDestinationId> = Extract<
(typeof ADMIN_DESTINATIONS)[number],
{ readonly id: Id }
>;
export type AdminPathParams<Id extends AdminDestinationId> = Record<
RouteParamNames<PathFor<Id>["pattern"]>,
string | number
>;
const folded = foldAdminAreas(ADMIN_AREAS);

// AdminDestinationId is derived from this list, so the lookup cannot miss.
export const adminDestination = (id: AdminDestinationId) =>
ADMIN_DESTINATIONS.find((candidate) => candidate.id === id)!;
/** The id type comes from the same table, so the lookup cannot miss. */
export const adminDestination = (id: AdminDestinationId): AdminDestinationDef =>
folded.destinations[id]!;

export const adminPath = <Id extends AdminDestinationId>(
id: Id,
params: AdminPathParams<Id>,
): string =>
adminDestination(id).pattern.replace(
/:(\w+)/g,
(_, name: RouteParamNames<PathFor<Id>["pattern"]>) => String(params[name]),
(_, name: keyof AdminPathParams<Id>) => String(params[name]),
);

export const adminDestinationAllowed = (
Expand All @@ -51,7 +46,7 @@ export const adminDestinationAllowed = (
};

export const ADMIN_SURFACE = {
areas: ADMIN_SURFACE_AREAS,
destinations: ADMIN_DESTINATIONS,
areas: folded.areas,
destinations: folded.destinations,
sections: ADMIN_SECTIONS,
} as const;
Loading