From cb52c0f42565032ab3f1cfb9a48fe4c44aad381e Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Mon, 5 Oct 2026 10:11:13 +0100 Subject: [PATCH 01/41] chore(studio): update segment control in view permissions to ds one (#51125) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem We were using a custom segment control. Recently we introduced segmented toggle groups in our design system. The old one is inconsistent and doesn't match anything else. ## Solution Replace segmented control with [this one](https://supabase.com/design-system/docs/components/toggle-group#segmented). | Before | After | |--------|--------| | Screenshot 2026-10-01 at 11 36 47 | Screenshot 2026-10-01 at 11 37
00 | ## Test plan - [ ] Go to Account Settings → Access Tokens, create a new scoped token, and on the capability review step confirm the All/Read/Read-write segmented control renders correctly and filters the capability list as expected - [ ] Open an existing scoped token's "View" sheet and confirm the same segmented control filters correctly there too - [ ] Verify keyboard navigation (arrow keys) and that exactly one option is always selected (no deselect state) - [ ] Visual check against the design system's segmented `ToggleGroup` styling (no leftover custom border/divider artifacts from the old implementation) ## Summary by CodeRabbit * **Style** * Updated the capability-level selector to use a segmented control. Selection behavior remains unchanged. --- .../CapabilityLevelToggle.tsx | 44 ++++++++----------- .../Scoped/ViewTokenSheet.test.tsx | 4 +- 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx index e2306e9cd04..bb4fb35b43d 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/TokenCapabilities/CapabilityLevelToggle.tsx @@ -1,4 +1,4 @@ -import { Button, cn } from 'ui' +import { ToggleGroup, ToggleGroupItem } from 'ui' import type { CapabilityLevelFilter } from './TokenCapabilities.utils' @@ -13,31 +13,23 @@ const OPTIONS: { value: CapabilityLevelFilter; label: string }[] = [ { value: 'readwrite', label: 'Read-write' }, ] -/** - * Segmented control matching the "Show all / Error only" pattern from Auth's UserLogs: adjacent - * Buttons with their variant swapped on selection, glued together via rounding/border removal - * and a thin divider, rather than a dedicated segmented-control primitive. - */ export const CapabilityLevelToggle = ({ value, onChange }: CapabilityLevelToggleProps) => ( -
- {OPTIONS.map(({ value: optionValue, label }, index) => ( -
- {index > 0 &&
} - -
+ { + if (newValue) onChange(newValue as CapabilityLevelFilter) + }} + allowDeselect={false} + aria-label="Filter capabilities by permission level" + > + {OPTIONS.map(({ value: optionValue, label }) => ( + + {label} + ))} -
+ ) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx index 22fa3971d2f..bb70fbccc3b 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/ViewTokenSheet.test.tsx @@ -227,11 +227,11 @@ describe('ViewTokenSheet', () => { expect(screen.getByText('Backups')).toBeInTheDocument() // The All/Read/Read-write toggle sits next to the "Capabilities" title, not a text filter. - fireEvent.click(screen.getByRole('button', { name: 'Read-write' })) + fireEvent.click(screen.getByRole('radio', { name: 'Read-write' })) expect(screen.getByText('Database')).toBeInTheDocument() expect(screen.queryByText('Backups')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: 'Read' })) + fireEvent.click(screen.getByRole('radio', { name: 'Read' })) expect(screen.queryByText('Database')).toBeNull() expect(screen.getByText('Backups')).toBeInTheDocument() }) From 01bfab39d69852854d0ac34fc926fdeda69b763d Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Mon, 5 Oct 2026 07:46:32 -0400 Subject: [PATCH 02/41] studio: improve warning for replicas on spend cap (#51179) ## Summary The "> 8 GB warning" when spend cap enabled used to be conflated with the "has replicas with spend cap" warning, which causes a confusing error message. Opting to split them out into 2 separate warnings to give the user a better description of the problem. ## Summary by CodeRabbit * **Bug Fixes** * Read replicas alone no longer trigger the disk-size threshold warning. * **New Features** * When usage billing is disabled, a warning appears if read replicas are present and no project exceeds 8 GB, with guidance on next steps. --- .../Usage/UsageSection/DiskUsage.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx index a71d808b389..ea445225e8b 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx @@ -62,11 +62,14 @@ export const DiskUsage = ({ : [] }, [isSuccess, projects, projectRef]) + const hasReplicas = useMemo( + () => relevantProjects.some((it) => it.databases.some((db) => db.type === 'READ_REPLICA')), + [relevantProjects] + ) + const hasProjectsExceedingDiskSize = useMemo(() => { return relevantProjects.some((it) => - it.databases.some( - (db) => db.type === 'READ_REPLICA' || (db.disk_volume_size_gb && db.disk_volume_size_gb > 8) - ) + it.databases.some((db) => db.disk_volume_size_gb && db.disk_volume_size_gb > 8) ) }, [relevantProjects]) @@ -103,6 +106,21 @@ export const DiskUsage = ({ )} + {currentBillingCycleSelected && + subscription?.usage_billing_enabled === false && + !hasProjectsExceedingDiskSize && + hasReplicas && ( + + + + Read replicas add disk usage beyond your plan's included quota + + + You have read replicas in your projects, but do not allow any overages with the + Spend Cap on. Remove the read replicas or disable the spend cap. + + + )}
From 901d1915b6f915ee5e7f804975aa8e09bbd8ce99 Mon Sep 17 00:00:00 2001 From: Ben Fritsch Date: Mon, 5 Oct 2026 14:32:29 +0200 Subject: [PATCH 03/41] add Ben Fritsch to humans.txt (#51265) Adding myself to humans.txt --- apps/docs/public/humans.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/docs/public/humans.txt b/apps/docs/public/humans.txt index 8544ba1ef78..0ce95cc5169 100644 --- a/apps/docs/public/humans.txt +++ b/apps/docs/public/humans.txt @@ -38,6 +38,7 @@ Artyom Borissov Aziz Quadri Barco Fourie Barry Roodt +Ben Fritsch Beng Eu Benjamin Coenen Beth Long From 04a6baf86933bd000cc7a2d8919cf669ccccaf66 Mon Sep 17 00:00:00 2001 From: Kody Jackson Date: Mon, 5 Oct 2026 07:43:35 -0500 Subject: [PATCH 04/41] fix(kb) - add topic name (#51186) ## Problem Adds a new topic b/c `Comparisons` is reserved for other types of content. ## Preview links TBD ## Summary by CodeRabbit * **New Features** * Made the Comparison topic visible and added a hidden Rundowns topic covering comparisons across technologies and facets. --- apps/kb/src/lib/topics.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/kb/src/lib/topics.ts b/apps/kb/src/lib/topics.ts index 572a3800b3f..828fd6682e2 100644 --- a/apps/kb/src/lib/topics.ts +++ b/apps/kb/src/lib/topics.ts @@ -21,6 +21,12 @@ export const TOPICS = [ name: 'Comparison', description: 'How Supabase compares to other databases and platforms.', pinned: false, + visible: true, + }, + { + name: 'Rundowns', + description: 'How different technologies compare across a variety of facets.', + pinned: false, visible: false, }, { From 77e3b4382f6214da734068080d663dd0c6d28fd7 Mon Sep 17 00:00:00 2001 From: Tanun Turbo Chalermsinsuwan Date: Mon, 5 Oct 2026 19:54:55 +0700 Subject: [PATCH 05/41] feat(role): Allow eligible organizations to invite users as 'No-access' base role (#50922) ## Problem As the API has allow inviting users into `None / No-access` role for team, enterprise, and platform tier organization, we need to update the documentation and descriptions for this new role on the invitation form. ## Solution 1. Updated `apps/docs/content/guides/platform/access-control.mdx` to include the role 2. Added the role description on `apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx` 3. Add the roles into the proper sorting order at `apps/studio/data/organization-members/organization-roles-query.ts` 4. Add logic to invitation components to disable the role when inviting user into project(s), as the backend does not allow it. ## Testing and verification steps The UI: https://studio-staging-aa8is1m07-supabase.vercel.app/dashboard/org Documentation: https://docs-kht98bi78-supabase.vercel.app/docs/guides/platform/access-control ## Checklist Check all before review: - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which references [WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md) and the docs [CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide ## Summary by CodeRabbit ## Summary * **Updates** * The **None** role is labeled **No-access** and describes the lack of organization and project resource access. * **None** is included after **Read-only** in the role list. When inviting a member with project-only access, **None** is disabled with an explanation. * **Documentation** * Clarified plan coverage for **Read-Only** and **No access**, and added guidance on assigning **No access** at the organization level before granting project-specific roles. --- .../content/guides/platform/access-control.mdx | 5 ++++- .../TeamSettings/InviteMemberButton.tsx | 16 ++++++++++++---- .../TeamSettings/Roles.constants.tsx | 1 + .../UpdateRolesPanel/UpdateRolesPanel.tsx | 15 +++++++++++---- .../organization-roles-query.ts | 13 +++++++++++-- 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/apps/docs/content/guides/platform/access-control.mdx b/apps/docs/content/guides/platform/access-control.mdx index 73bea005159..36717e0e9e8 100644 --- a/apps/docs/content/guides/platform/access-control.mdx +++ b/apps/docs/content/guides/platform/access-control.mdx @@ -11,10 +11,11 @@ For each organization and project, a member can have one of the following roles: - **Administrator**: full access to everything in organization and project resources **except** updating organization settings, transferring projects outside of the organization, and adding new owners. - **Developer**: read-only access to organization resources and content access to project resources but cannot change any project settings. - **Read-Only**: read-only access to organization and project resources. +- **No-access**: no access to any organization or project resources by default. Use this for members who should only see the specific projects they're granted a [project-scoped role](#organization-scoped-roles-vs-project-scoped-roles) on. -Read-Only role is only available on the [Team and Enterprise plans](/pricing). +Read-Only and No-access roles are only available on the [Team and Enterprise plans](/pricing). @@ -67,6 +68,8 @@ Each member in the organization can be assigned a role that is scoped either to This allows for more granular control, ensuring that users only have visibility and access to the projects relevant to their role. +To default a member to zero project access and grant access on a per-project basis, assign them the organization-level **No access** role, then grant project-scoped roles for the specific projects they need. The **No-access** role is omitted from the permission tables below since it has none of the listed permissions by default. + ### Organization permissions across roles The table below shows the actions each role can take on the resources belonging to the organization. diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx index 516b9ed8a26..c9976e5bed0 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/InviteMemberButton.tsx @@ -358,12 +358,20 @@ export const InviteMemberButton = () => { const canAssignRole = rolesAddable.includes(role.id) const isOwnerRole = role.name === 'Owner' const disabledForStripe = isStripeProjectsOrg && isOwnerRole - const disabled = !canAssignRole || disabledForStripe + + const isNoAccessRole = role.name === 'No-access' + const isApplyingProjectRole = !applyToOrg + const disabledForProjectRole = isApplyingProjectRole && isNoAccessRole + + const disabled = + !canAssignRole || disabledForStripe || disabledForProjectRole const disabledReason = disabledForStripe ? 'Cannot be assigned in Stripe Projects organizations' - : !canAssignRole - ? 'Additional permissions required to assign role' - : undefined + : disabledForProjectRole + ? 'Cannot assign No-access role to project-scoped members' + : !canAssignRole + ? 'Additional permissions required to assign role' + : undefined return ( diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx index a3759ad9ca9..30b7ec2d957 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/Roles.constants.tsx @@ -17,4 +17,5 @@ export const ROLE_DESCRIPTIONS: Record = { 'Manage project content, including deleting data, users, files, and Edge Functions. Cannot change settings or delete projects.', 'Read-only': 'View resources without modifying or deleting them. SQL Editor access is limited to SELECT queries.', + 'No-access': 'No access to any organization or project resources', } diff --git a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx index c4470ea1bac..b57a713e1c7 100644 --- a/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx +++ b/apps/studio/components/interfaces/Organization/TeamSettings/UpdateRolesPanel/UpdateRolesPanel.tsx @@ -282,12 +282,19 @@ export const UpdateRolesPanel = ({ visible, member, onClose }: UpdateRolesPanelP const canAssignRole = rolesAddable.includes(role.id) const isOwnerRole = role.name === 'Owner' const disabledForStripe = isStripeProjectsOrg && isOwnerRole - const disabled = !canAssignRole || disabledForStripe + + const isNoAccessRole = role.name === 'No-access' + const disabledForProjectRole = + !isApplyingRoleToAllProjects && isNoAccessRole + const disabled = + !canAssignRole || disabledForStripe || disabledForProjectRole const disabledReason = disabledForStripe ? 'Cannot be assigned in Stripe Projects organizations' - : !canAssignRole - ? 'Additional permissions required to assign role' - : undefined + : disabledForProjectRole + ? 'Cannot assign No-access role to project-scoped members' + : !canAssignRole + ? 'Additional permissions required to assign role' + : undefined return ( ( .sort((a, b) => { return FIXED_ROLE_ORDER.indexOf(a.name) - FIXED_ROLE_ORDER.indexOf(b.name) }) - .filter((role) => FIXED_ROLE_ORDER.includes(role.name)), + .filter((role) => FIXED_ROLE_ORDER.includes(role.name)) + .map((role) => ({ ...role, name: getOverrideRoleName(role.name) })), } as TData }, ...options, }) + +const OVERRIDE_ROLE_NAMES: Record = { + None: 'No-access', +} + +export function getOverrideRoleName(rolename: string) { + return OVERRIDE_ROLE_NAMES[rolename] ?? rolename +} From 0d8b1417bcc37ae8b1c880f4306b6c9749cef16e Mon Sep 17 00:00:00 2001 From: "supabase-supabase-autofixer[bot]" <248690971+supabase-supabase-autofixer[bot]@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:16:39 +0000 Subject: [PATCH 06/41] [bot] Decrease ESLint ratchet baselines (#51225) Automated weekly decrease of ESLint ratchet baselines. Co-authored-by: github-actions[bot] Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- apps/studio/.github/eslint-rule-baselines.json | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/studio/.github/eslint-rule-baselines.json b/apps/studio/.github/eslint-rule-baselines.json index 745b53c2792..4fdc6d40801 100644 --- a/apps/studio/.github/eslint-rule-baselines.json +++ b/apps/studio/.github/eslint-rule-baselines.json @@ -6,15 +6,15 @@ "@typescript-eslint/no-explicit-any": 799, "no-restricted-exports": 176, "react/no-unstable-nested-components": 34, - "jsx-a11y/control-has-associated-label": 178, + "jsx-a11y/control-has-associated-label": 174, "jsx-a11y/label-has-associated-control": 33, "jsx-a11y/no-redundant-roles": 2, "jsx-a11y/anchor-is-valid": 6, "jsx-a11y/heading-has-content": 2, "valtio/state-snapshot-rule": 1, - "shadcn/no-arbitrary-values": 199, - "shadcn/no-unknown-classes": 151, - "shadcn/no-raw-colors": 64 + "shadcn/no-arbitrary-values": 212, + "shadcn/no-unknown-classes": 150, + "shadcn/no-raw-colors": 63 }, "ruleFiles": { "react-hooks/exhaustive-deps": { @@ -857,9 +857,7 @@ "components/interfaces/Database/Policies/PolicyEditorPanel/PolicyDetailsV2.tsx": 1, "components/interfaces/Database/Privileges/PrivilegesTable.tsx": 1, "components/interfaces/Database/Publications/PublicationSkeleton.tsx": 1, - "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/AnalyticsBucket/Fields.tsx": 2, "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/BigQuery/Fields.tsx": 1, - "components/interfaces/Database/Replication/DestinationPanel/DestinationForm/DuckLake/Fields.tsx": 2, "components/interfaces/Database/Roles/CreateRolePanel.tsx": 2, "components/interfaces/Database/Roles/RoleRow.tsx": 1, "components/interfaces/Functions/EdgeFunctionDetails/EdgeFunctionDetails.tsx": 1, @@ -1128,7 +1126,6 @@ "components/interfaces/Auth/Users/UsersGridComponents.tsx": 3, "components/interfaces/Billing/Payment/AddNewPaymentMethodModal.tsx": 1, "components/interfaces/BranchManagement/ConnectToGitHub.tsx": 1, - "components/interfaces/ConnectSheet/content/steps/direct-connection/IPv4StatusPanel.tsx": 1, "components/interfaces/Database/EnumeratedTypes/CreateEnumeratedTypeSidePanel.tsx": 1, "components/interfaces/Database/EnumeratedTypes/EditEnumeratedTypeSidePanel.tsx": 1, "components/interfaces/Database/Functions/FunctionsList/FunctionList.tsx": 1, @@ -1217,7 +1214,6 @@ "components/interfaces/BranchManagement/BranchPanels.tsx": 1, "components/interfaces/BranchManagement/CreateBranchModal.tsx": 6, "components/interfaces/BranchManagement/DeleteBranchModal.tsx": 1, - "components/interfaces/ConnectSheet/content/steps/direct-connection/IPv4StatusPanel.tsx": 1, "components/interfaces/Database/Functions/FunctionsList/FunctionsList.tsx": 2, "components/interfaces/Database/Hooks/DeleteHookModal.tsx": 2, "components/interfaces/Database/Migrations/Migrations.tsx": 1, From de41b029ef89bea2b8839e2f690e87c274fc29c7 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:18:24 -0400 Subject: [PATCH 07/41] always use canonical link for new status page (#51264) --- apps/studio/components/layouts/AppLayout/useStatusBanner.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/studio/components/layouts/AppLayout/useStatusBanner.ts b/apps/studio/components/layouts/AppLayout/useStatusBanner.ts index 85797ea0bf0..74fb1d90e37 100644 --- a/apps/studio/components/layouts/AppLayout/useStatusBanner.ts +++ b/apps/studio/components/layouts/AppLayout/useStatusBanner.ts @@ -61,5 +61,5 @@ export function useStatusBanner(): StatusBannerState { ) } - return { type: 'shown', selection, pageUrl: data?.pageUrl ?? DEFAULT_STATUS_PAGE_URL, onDismiss } + return { type: 'shown', selection, pageUrl: DEFAULT_STATUS_PAGE_URL, onDismiss } } From 0c2257fb3d6d8aa684bb2d0150efa7f4b8d73985 Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:20:21 +0100 Subject: [PATCH 08/41] feat(studio): scoped oauth data layer (#49476) ## I have read the [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) file. YES ## What kind of change does this PR introduce? ~~This is the first part (and ultimately the final part of the stacked PR). PR 1 introduces mock data for us while we build the requirements of the scoped oauth interstitial, all following PR's will be stacked on top of this one.~~ This is the first part, the data layer side. We began with mock data, but as backend support arrived we've used this PR to help us shape the UI as well as the frontend data layer. This now acts as the frontend data layer. ## Summary by CodeRabbit * **New Features** * Added OAuth app authorization request handling. * Added visibility into application details, requested scopes, and existing authorizations. * Added organization and project selection during authorization. * Added organization roles and project access details. * Added approval and denial options with secure redirect handling. * Added validation for required authorization details and project selections. * Added support for role validation feedback during approval. * Added representative authorization scenarios for approved, denied, and re-consent flows. --------- Co-authored-by: Claude Sonnet 5 Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com> Co-authored-by: Samir Ketema <6003000+samirketema@users.noreply.github.com> --- apps/studio/data/oauth-apps/keys.ts | 25 + apps/studio/data/oauth-apps/mocks.ts | 622 ++++++++++++++++++ .../oauth-apps/oauth-apps-approvals-query.ts | 40 ++ .../oauth-apps-authorize-approve-mutation.ts | 56 ++ .../oauth-apps-authorize-deny-mutation.ts | 47 ++ .../oauth-apps-authorize-mutations.test.ts | 82 +++ ...s-authorize-organization-projects-query.ts | 53 ++ ...auth-apps-authorize-organizations-query.ts | 49 ++ .../oauth-apps-authorize-request-query.ts | 42 ++ .../oauth-apps/oauth-apps-grants-query.ts | 37 ++ .../oauth-apps-member-grants-query.ts | 43 ++ .../oauth-apps-org-app-details-query.ts | 41 ++ .../oauth-apps-preflight-validation-query.ts | 47 ++ .../oauth-apps-revoke-grant-mutation.ts | 48 ++ .../oauth-apps/oauth-apps-revoke-mutation.ts | 57 ++ apps/studio/data/oauth-apps/types.test.ts | 142 ++++ apps/studio/data/oauth-apps/types.ts | 190 ++++++ knip.jsonc | 7 + 18 files changed, 1628 insertions(+) create mode 100644 apps/studio/data/oauth-apps/keys.ts create mode 100644 apps/studio/data/oauth-apps/mocks.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-authorize-approve-mutation.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-authorize-deny-mutation.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-authorize-mutations.test.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-authorize-organization-projects-query.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-authorize-organizations-query.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-authorize-request-query.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-grants-query.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-org-app-details-query.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts create mode 100644 apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts create mode 100644 apps/studio/data/oauth-apps/types.test.ts create mode 100644 apps/studio/data/oauth-apps/types.ts diff --git a/apps/studio/data/oauth-apps/keys.ts b/apps/studio/data/oauth-apps/keys.ts new file mode 100644 index 00000000000..7fa7cd3de54 --- /dev/null +++ b/apps/studio/data/oauth-apps/keys.ts @@ -0,0 +1,25 @@ +export const oauthAppsKeys = { + authorizeRequest: (id: string | undefined) => ['oauth-apps', 'authorize', id] as const, + authorizeOrganizations: (id: string | undefined) => + ['oauth-apps', 'authorize', id, 'organizations'] as const, + authorizeOrganizationProjects: (id: string | undefined, slug: string | undefined) => + ['oauth-apps', 'authorize', id, 'organizations', slug, 'projects'] as const, + orgAppDetails: (slug: string | undefined, appId: string | undefined) => + ['oauth-apps', 'organizations', slug, 'apps', appId, 'grant'] as const, + preflightValidation: (slug: string | undefined, appId: string | undefined) => + ['oauth-apps', 'organizations', slug, 'apps', appId, 'preflight-validation'] as const, + // cursor omitted (rather than passed as `undefined`) so the cursor-less key is a true prefix + // of every paginated key — invalidating without a cursor clears every cached page. + approvals: (slug: string | undefined, cursor?: string) => + cursor === undefined + ? (['oauth-apps', 'approvals', slug] as const) + : (['oauth-apps', 'approvals', slug, cursor] as const), + appMemberGrants: (slug: string | undefined, appId: string | undefined, cursor?: string) => + cursor === undefined + ? (['oauth-apps', 'authorized', slug, appId, 'member-grants'] as const) + : (['oauth-apps', 'authorized', slug, appId, 'member-grants', cursor] as const), + grants: (cursor?: string) => + cursor === undefined + ? (['oauth-apps', 'grants'] as const) + : (['oauth-apps', 'grants', cursor] as const), +} diff --git a/apps/studio/data/oauth-apps/mocks.ts b/apps/studio/data/oauth-apps/mocks.ts new file mode 100644 index 00000000000..d809a743c30 --- /dev/null +++ b/apps/studio/data/oauth-apps/mocks.ts @@ -0,0 +1,622 @@ +import dayjs from 'dayjs' + +import type { OAuthAppsAuthorizeIdentity } from './oauth-apps-authorize-organizations-query' +import type { + ListOAuthApprovalsResponse, + ListOrgAppGrantsResponse, + ListOwnGrantsResponse, + OAuthAppsAuthorizeApproveResult, + OAuthAppsAuthorizeOrganizationProject, + OAuthAppsAuthorizePreflightResult, + OAuthAppsAuthorizeRequest, + OAuthExistingGrant, + OAuthOrganizationRole, + OAuthOrgAppDetails, + OAuthScope, +} from './types' +import { isPreflightValidationFailure, isWriteScope } from './types' +import type { OrganizationRole } from '@/data/organization-members/organization-roles-query' + +const ENABLE_MOCKS = true +export const USE_MOCKS = ENABLE_MOCKS && process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod' + +export const OAUTH_APPS_MOCK_SCENARIOS = { + vercelDeveloper: 'mock-vercel-developer', + vercelReadOnly: 'mock-vercel-readonly', + vercelReconsent: 'mock-vercel-reconsent', + vercelOrgAdmin: 'mock-vercel-org-admin', + vercelManyProjects: 'mock-vercel-many-projects', + vercelRoleValidation: 'mock-vercel-role-validation', + vercelOptionalProjects: 'mock-vercel-optional-projects', + vercelAllProjects: 'mock-vercel-all-projects', + vercelReconsentAllProjects: 'mock-vercel-reconsent-all-projects', + kemalBot: 'mock-kemal-bot', + kemalBotOrgWide: 'mock-kemal-bot-org-wide', + dynamicMcpClient: 'mock-dynamic-mcp-client', +} as const + +export const OWNER_ROLE: OrganizationRole = { + id: 1, + base_role_id: 1, + name: 'Owner', + description: null, + projects: [], +} + +export const ADMINISTRATOR_ROLE: OrganizationRole = { + id: 2, + base_role_id: 2, + name: 'Administrator', + description: null, + projects: [], +} + +export const DEVELOPER_ROLE: OrganizationRole = { + id: 3, + base_role_id: 3, + name: 'Developer', + description: null, + projects: [], +} + +export const READ_ONLY_ROLE: OrganizationRole = { + id: 4, + base_role_id: 4, + name: 'Read-only', + description: null, + projects: [], +} + +const VERCEL_SCOPES: OAuthScope[] = [ + 'database:read', + 'database:write', + 'environment:read', + 'environment:write', + 'secrets:read', + 'secrets:write', + 'projects:read', + 'edge_functions:read', + 'storage:read', +] + +const VERCEL_REQUEST: OAuthAppsAuthorizeRequest = { + app_id: 'vercel', + app_name: 'Vercel', + name: 'Vercel', + website: 'https://vercel.com', + domain: 'vercel.com', + redirect_uri: 'https://vercel.com/api/integrations/supabase/callback', + registration_type: 'manual', + expires_at: dayjs().add(1, 'month').toISOString(), + grant_kind: 'member_bound', + project_scoping_mode: true, + scopes: VERCEL_SCOPES, +} + +const VERCEL_OPTIONAL_PROJECTS_REQUEST: OAuthAppsAuthorizeRequest = { + ...VERCEL_REQUEST, + app_id: 'vercel-optional', + project_scoping_mode: true, +} + +const VERCEL_ALL_PROJECTS_REQUEST: OAuthAppsAuthorizeRequest = { + ...VERCEL_REQUEST, + app_id: 'vercel-org-wide', + project_scoping_mode: false, +} + +const DYNAMIC_MCP_CLIENT_REQUEST: OAuthAppsAuthorizeRequest = { + ...VERCEL_REQUEST, + app_id: 'dynamic-mcp-client', + app_name: 'Northwind MCP', + name: 'Northwind MCP', + website: 'https://mcp.northwind.example', + domain: 'mcp.northwind.example', + redirect_uri: 'https://mcp.northwind.example/callback', + registration_type: 'dynamic', + // DCR (MCP) oauth apps are forced org-bound, unscoped. + grant_kind: 'organization_bound', + project_scoping_mode: false, +} + +const KEMAL_BOT_REQUEST: OAuthAppsAuthorizeRequest = { + app_id: 'kemal-bot', + app_name: 'kemal-bot', + name: 'kemal-bot', + website: 'https://kemal.lol', + domain: 'kemal.lol', + redirect_uri: 'https://kemal.lol/hollerback', + registration_type: 'manual', + expires_at: dayjs().add(1, 'month').toISOString(), + grant_kind: 'organization_bound', + project_scoping_mode: true, + scopes: ['projects:read'], +} + +const KEMAL_BOT_ORG_WIDE_REQUEST: OAuthAppsAuthorizeRequest = { + ...KEMAL_BOT_REQUEST, + app_id: 'kemal-bot-org-wide', + project_scoping_mode: false, +} + +const MOCK_AUTHORIZE_REQUESTS: Record = { + [OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReadOnly]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReconsent]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelOrgAdmin]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelManyProjects]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation]: VERCEL_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelOptionalProjects]: VERCEL_OPTIONAL_PROJECTS_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects]: VERCEL_ALL_PROJECTS_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReconsentAllProjects]: VERCEL_OPTIONAL_PROJECTS_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.kemalBot]: KEMAL_BOT_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide]: KEMAL_BOT_ORG_WIDE_REQUEST, + [OAUTH_APPS_MOCK_SCENARIOS.dynamicMcpClient]: DYNAMIC_MCP_CLIENT_REQUEST, +} + +const NORTHWIND_TRADERS_DEVELOPER: OAuthOrganizationRole = { + slug: 'northwind-traders', + name: 'Northwind Traders', + default_role: DEVELOPER_ROLE, +} + +const NORTHWIND_TRADERS_READ_ONLY: OAuthOrganizationRole = { + ...NORTHWIND_TRADERS_DEVELOPER, + default_role: READ_ONLY_ROLE, +} + +const CONTOSO_LABS: OAuthOrganizationRole = { + slug: 'contoso-labs', + name: 'Contoso Labs', + default_role: OWNER_ROLE, +} + +const TAILSPIN_TOYS_ADMIN: OAuthOrganizationRole = { + slug: 'tailspin-toys', + name: 'Tailspin Toys', + default_role: ADMINISTRATOR_ROLE, +} + +const FABRIKAM_OWNER: OAuthOrganizationRole = { + slug: 'fabrikam-industries', + name: 'Fabrikam Industries', + default_role: OWNER_ROLE, +} + +const WINGTIP_TOYS_DEVELOPER: OAuthOrganizationRole = { + slug: 'wingtip-toys', + name: 'Wingtip Toys', + default_role: DEVELOPER_ROLE, +} + +const LITWARE_DEVELOPER: OAuthOrganizationRole = { + slug: 'litware-inc', + name: 'Litware Inc.', + default_role: DEVELOPER_ROLE, +} + +const MOCK_IDENTITIES: Record = { + [OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReadOnly]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_READ_ONLY, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReconsent]: { + email: 'admin@example.com', + organizations: [TAILSPIN_TOYS_ADMIN, NORTHWIND_TRADERS_DEVELOPER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelOrgAdmin]: { + email: 'admin@example.com', + organizations: [TAILSPIN_TOYS_ADMIN, FABRIKAM_OWNER, NORTHWIND_TRADERS_DEVELOPER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelManyProjects]: { + email: 'admin@example.com', + organizations: [WINGTIP_TOYS_DEVELOPER, NORTHWIND_TRADERS_DEVELOPER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelOptionalProjects]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_READ_ONLY, CONTOSO_LABS, FABRIKAM_OWNER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.vercelReconsentAllProjects]: { + email: 'admin@example.com', + organizations: [TAILSPIN_TOYS_ADMIN, NORTHWIND_TRADERS_DEVELOPER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.kemalBot]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, + [OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, FABRIKAM_OWNER], + }, + [OAUTH_APPS_MOCK_SCENARIOS.dynamicMcpClient]: { + email: 'admin@example.com', + organizations: [NORTHWIND_TRADERS_DEVELOPER, CONTOSO_LABS], + }, +} + +const MOCK_ORGANIZATION_PROJECTS: Record = { + 'northwind-traders': [ + { ref: 'northwindstorefront1', name: 'northwind-storefront', role: ADMINISTRATOR_ROLE }, + { ref: 'northwindcms1', name: 'northwind-cms', role: DEVELOPER_ROLE }, + { ref: 'fabrikamapi1', name: 'fabrikam-api', role: READ_ONLY_ROLE }, + { ref: 'fabrikamjobs1', name: 'fabrikam-jobs', role: READ_ONLY_ROLE }, + ], + 'wingtip-toys': [ + { ref: 'wingtipweb1', name: 'wingtip-web', role: ADMINISTRATOR_ROLE }, + { ref: 'wingtipapi1', name: 'wingtip-api', role: DEVELOPER_ROLE }, + { ref: 'wingtipadmin1', name: 'wingtip-admin', role: DEVELOPER_ROLE }, + { ref: 'wingtipjobs1', name: 'wingtip-jobs', role: DEVELOPER_ROLE }, + { ref: 'wingtipsearch1', name: 'wingtip-search', role: READ_ONLY_ROLE }, + { ref: 'wingtipbilling1', name: 'wingtip-billing', role: ADMINISTRATOR_ROLE }, + { ref: 'wingtipmail1', name: 'wingtip-mail', role: DEVELOPER_ROLE }, + { ref: 'wingtipmedia1', name: 'wingtip-media', role: DEVELOPER_ROLE }, + { ref: 'wingtipmetrics1', name: 'wingtip-metrics', role: READ_ONLY_ROLE }, + { ref: 'wingtipstaging1', name: 'wingtip-staging', role: DEVELOPER_ROLE }, + { ref: 'wingtippreview1', name: 'wingtip-preview', role: DEVELOPER_ROLE }, + { ref: 'wingtipsandbox1', name: 'wingtip-sandbox', role: READ_ONLY_ROLE }, + ], + 'fabrikam-industries': [ + { ref: 'fabrikamledger1', name: 'fabrikam-ledger', role: ADMINISTRATOR_ROLE }, + ], + 'tailspin-toys': [ + { ref: 'tailspinshop1', name: 'tailspin-shop', role: ADMINISTRATOR_ROLE }, + { ref: 'tailspinwarehouse1', name: 'tailspin-warehouse', role: DEVELOPER_ROLE }, + ], + 'litware-inc': [{ ref: 'litwarecrm1', name: 'litware-crm', role: DEVELOPER_ROLE }], + 'contoso-labs': [], +} + +const TAILSPIN_VERCEL_EXISTING_GRANT: OAuthExistingGrant = { + approved_scopes: ['database:read', 'database:write', 'projects:read'], + project_refs: ['tailspinshop1', 'tailspindeleted1'], + approved_at: '2026-08-14T09:12:00.000Z', +} + +const TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT: OAuthExistingGrant = { + approved_scopes: ['database:read', 'projects:read'], + project_refs: [], + approved_at: '2026-08-29T16:40:00.000Z', +} + +const DEFAULT_ORG_APP_DETAILS: OAuthOrgAppDetails = { + existing_grant: null, +} + +const MOCK_ORG_APP_DETAILS: Record> = { + 'tailspin-toys': { + vercel: { existing_grant: TAILSPIN_VERCEL_EXISTING_GRANT }, + 'vercel-optional': { existing_grant: TAILSPIN_VERCEL_OPTIONAL_EXISTING_GRANT }, + }, +} + +// Real oauth_apps config, keyed by app_id — preflight-validation is org+app scoped, not tied to +// an in-flight authorization request. +const MOCK_APPS_BY_ID: Record = { + vercel: VERCEL_REQUEST, + 'vercel-optional': VERCEL_OPTIONAL_PROJECTS_REQUEST, + 'vercel-org-wide': VERCEL_ALL_PROJECTS_REQUEST, + 'dynamic-mcp-client': DYNAMIC_MCP_CLIENT_REQUEST, + 'kemal-bot': KEMAL_BOT_REQUEST, + 'kemal-bot-org-wide': KEMAL_BOT_ORG_WIDE_REQUEST, +} + +// The current member's org-level role, keyed by org slug — same reasoning as MOCK_APPS_BY_ID. +const MOCK_ORGANIZATIONS_BY_SLUG: Record = { + 'northwind-traders': NORTHWIND_TRADERS_READ_ONLY, + 'tailspin-toys': TAILSPIN_TOYS_ADMIN, + 'fabrikam-industries': FABRIKAM_OWNER, + 'contoso-labs': CONTOSO_LABS, + 'wingtip-toys': WINGTIP_TOYS_DEVELOPER, + 'litware-inc': LITWARE_DEVELOPER, +} + +const MOCK_APPROVALS: ListOAuthApprovalsResponse = { + data: [ + { + app: { + id: 'vercel', + name: 'Vercel', + icon: null, + created_by: '', + }, + grant_target: 'members', + org_grant: null, + }, + { + app: { + id: 'dynamic-mcp-client', + name: 'Northwind MCP', + icon: null, + created_by: '', + }, + grant_target: 'members', + org_grant: null, + }, + { + app: { + id: 'contoso-analytics', + name: 'Contoso Analytics', + icon: null, + created_by: '', + }, + grant_target: 'organization', + org_grant: { + grant_id: 'grant-contoso-analytics-org', + approved_scopes: ['analytics:read', 'projects:read'], + approved_at: '2025-11-03T14:20:00.000Z', + }, + }, + ], + pagination: { next_cursor: null }, +} + +const MOCK_APP_GRANTS: Record = { + vercel: { + data: [ + { + grant_id: 'grant-vercel-admin', + kind: 'member_bound', + user: { + gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000001', + email: 'admin@example.com', + }, + projects: [ + { ref: 'northwindstorefront1', name: 'northwind-storefront' }, + { ref: 'northwindcms1', name: 'northwind-cms' }, + ], + approved_scopes: ['database:read', 'database:write', 'projects:read'], + approved_at: '2026-08-18T09:12:00.000Z', + }, + { + grant_id: 'grant-vercel-developer', + kind: 'member_bound', + user: { + gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000002', + email: 'developer@example.com', + avatar_url: 'https://avatars.example/developer.png', + }, + projects: [{ ref: 'northwindcms1', name: 'northwind-cms' }], + approved_scopes: ['projects:read'], + approved_at: '2026-08-16T11:30:00.000Z', + }, + ], + pagination: { next_cursor: null }, + }, + 'dynamic-mcp-client': { + data: [ + { + grant_id: 'grant-northwind-mcp-ops', + kind: 'member_bound', + user: { + gotrue_id: 'b1d3e2f4-0000-4000-8000-000000000003', + email: 'ops@example.com', + }, + projects: [{ ref: 'northwindstorefront1', name: 'northwind-storefront' }], + approved_scopes: ['database:read', 'database:write'], + approved_at: '2026-09-01T08:45:00.000Z', + }, + ], + pagination: { next_cursor: null }, + }, + 'contoso-analytics': { + data: [ + { + grant_id: 'grant-contoso-analytics-org', + kind: 'organization_bound', + user: null, + projects: null, + approved_scopes: ['analytics:read', 'projects:read'], + approved_at: '2025-11-03T14:20:00.000Z', + }, + ], + pagination: { next_cursor: null }, + }, +} + +const MOCK_OWN_GRANTS: ListOwnGrantsResponse = { + data: [ + { + grant_id: 'grant-vercel-admin', + app: { id: 'vercel', name: 'Vercel', icon: null }, + organization: { slug: 'northwind-traders', name: 'Northwind Traders' }, + projects: [ + { ref: 'northwindstorefront1', name: 'northwind-storefront' }, + { ref: 'northwindcms1', name: 'northwind-cms' }, + ], + approved_scopes: ['database:read', 'database:write', 'projects:read'], + approved_at: '2026-08-18T09:12:00.000Z', + }, + { + grant_id: 'grant-northwind-mcp-ops', + app: { id: 'dynamic-mcp-client', name: 'Northwind MCP', icon: null }, + organization: { slug: 'northwind-traders', name: 'Northwind Traders' }, + projects: [{ ref: 'northwindstorefront1', name: 'northwind-storefront' }], + approved_scopes: ['database:read', 'database:write'], + approved_at: '2026-09-01T08:45:00.000Z', + }, + { + grant_id: 'kemal-bot-all-projects', + app: { id: 'kemal-bot', name: 'kemal-bot', icon: null }, + organization: { slug: 'northwind-traders', name: 'Northwind Traders' }, + // Targets all projects + projects: null, + approved_scopes: ['database:read', 'database:write'], + approved_at: '2026-09-20T14:30:00.000Z', + }, + ], + pagination: { next_cursor: null }, +} + +// Fixture lists are small and static — cursor is accepted for real-endpoint parity but not +// used to actually paginate the mock data. +export function getMockOAuthApprovals(_cursor?: string): ListOAuthApprovalsResponse { + return MOCK_APPROVALS +} + +export function getMockOAuthAppGrants(appId: string, _cursor?: string): ListOrgAppGrantsResponse { + return MOCK_APP_GRANTS[appId] ?? { data: [], pagination: { next_cursor: null } } +} + +export function getMockOAuthOwnGrants(_cursor?: string): ListOwnGrantsResponse { + return MOCK_OWN_GRANTS +} + +export function getMockOAuthAppsAuthorizeRequest(authId: string): OAuthAppsAuthorizeRequest { + const request = MOCK_AUTHORIZE_REQUESTS[authId] + if (!request) throw new Error(`No mock authorize request for id "${authId}"`) + return request +} + +export function getMockOAuthAppsAuthorizeIdentity(authId: string): OAuthAppsAuthorizeIdentity { + const identity = MOCK_IDENTITIES[authId] + if (!identity) throw new Error(`No mock identity for id "${authId}"`) + return identity +} + +export function getMockOAuthAppsAuthorizeOrganizationProjects( + slug: string +): OAuthAppsAuthorizeOrganizationProject[] { + return MOCK_ORGANIZATION_PROJECTS[slug] ?? [] +} + +export function getMockOAuthOrgAppDetails(slug: string, appId: string): OAuthOrgAppDetails { + return MOCK_ORG_APP_DETAILS[slug]?.[appId] ?? DEFAULT_ORG_APP_DETAILS +} + +function findMockOrganization(authId: string, slug: string): OAuthOrganizationRole | undefined { + return getMockOAuthAppsAuthorizeIdentity(authId).organizations.find( + (candidate) => candidate.slug === slug + ) +} + +const MOCK_OAUTH_STATE = 'mock_state_9f2c1b' + +function buildMockRedirectUrl(redirectUri: string, params: Record) { + const url = new URL(redirectUri) + Object.entries(params).forEach(([key, value]) => url.searchParams.set(key, value)) + return url.toString() +} + +export function getMockOAuthAppsAuthorizeRedirect( + authId: string, + { approved }: { approved: boolean } +) { + const request = getMockOAuthAppsAuthorizeRequest(authId) + const url = approved + ? buildMockRedirectUrl(request.redirect_uri, { + code: 'mock_authorization_code', + state: MOCK_OAUTH_STATE, + }) + : buildMockRedirectUrl(request.redirect_uri, { + error: 'access_denied', + error_description: 'The user denied the authorization request', + state: MOCK_OAUTH_STATE, + }) + + return { url } +} + +const ROLE_VALIDATED_SCENARIOS = new Set([ + OAUTH_APPS_MOCK_SCENARIOS.vercelRoleValidation, + OAUTH_APPS_MOCK_SCENARIOS.vercelAllProjects, + OAUTH_APPS_MOCK_SCENARIOS.kemalBotOrgWide, +]) + +function isReadOnlyRole(role: OrganizationRole) { + return role.id === READ_ONLY_ROLE.id +} + +function isOwnerOrAdmin(role: OrganizationRole) { + return role.id === OWNER_ROLE.id || role.id === ADMINISTRATOR_ROLE.id +} + +// Upfront org-level check shared by preflight-validation and the "all projects" branch of approve. +// Never checks project-scoped roles (that's approve's job once project_refs are known). +function evaluateGrantEligibility( + request: OAuthAppsAuthorizeRequest, + organization: OAuthOrganizationRole +): OAuthAppsAuthorizePreflightResult { + if (request.grant_kind === 'organization_bound') { + if (isOwnerOrAdmin(organization.default_role)) return { ok: true } + return { + error_code: 'role_validation_failed', + message: `Your ${organization.default_role.name} role cannot install this app for the organization.`, + validation: { scope_target: 'organization', role: organization.default_role }, + } + } + + const failedScopes = request.scopes.filter(isWriteScope) + if (failedScopes.length === 0 || !isReadOnlyRole(organization.default_role)) return { ok: true } + + return { + error_code: 'role_validation_failed', + message: `Your ${organization.default_role.name} role cannot satisfy this app's scopes for all projects.`, + validation: { + scope_target: 'all_projects', + role: organization.default_role, + failed_scopes: failedScopes, + }, + } +} + +export function getMockOAuthAppsPreflightValidation( + slug: string, + appId: string +): OAuthAppsAuthorizePreflightResult { + const request = MOCK_APPS_BY_ID[appId] + const organization = MOCK_ORGANIZATIONS_BY_SLUG[slug] + if (!request || !organization) return { ok: true } + + return evaluateGrantEligibility(request, organization) +} + +export function getMockOAuthAppsAuthorizeApproveResult( + authId: string, + { slug, projectRefs }: { slug: string; projectRefs: string[] | undefined } +): OAuthAppsAuthorizeApproveResult { + const approved = getMockOAuthAppsAuthorizeRedirect(authId, { approved: true }) + if (!ROLE_VALIDATED_SCENARIOS.has(authId)) return approved + + const request = getMockOAuthAppsAuthorizeRequest(authId) + const organization = findMockOrganization(authId, slug) + if (!organization) return approved + + if (projectRefs === undefined || request.grant_kind === 'organization_bound') { + const result = evaluateGrantEligibility(request, organization) + return isPreflightValidationFailure(result) ? result : approved + } + + const failedScopes = request.scopes.filter(isWriteScope) + if (failedScopes.length === 0) return approved + + const blocked = getMockOAuthAppsAuthorizeOrganizationProjects(slug).filter( + (project) => projectRefs.includes(project.ref) && isReadOnlyRole(project.role) + ) + if (blocked.length === 0) return approved + + return { + error_code: 'role_validation_failed', + message: `Your role is read-only on ${blocked.length} of the selected projects.`, + validation: { + scope_target: 'projects', + failures: blocked.map(({ name, ref, role }) => ({ + ref, + name, + role, + failed_scopes: failedScopes, + })), + }, + } +} diff --git a/apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts b/apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts new file mode 100644 index 00000000000..f440618196b --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-approvals-query.ts @@ -0,0 +1,40 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthApprovals, USE_MOCKS } from './mocks' +import type { ListOAuthApprovalsResponse } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthApprovalsVariables = { + slug?: string + cursor?: string +} + +export type { ListOAuthApprovalsResponse, OAuthApprovalItem } from './types' + +export async function getOAuthApprovals({ + slug, + cursor, +}: OAuthApprovalsVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!USE_MOCKS) throw new Error('OAuth app approvals are not yet implemented') + + return getMockOAuthApprovals(cursor) +} + +export type OAuthApprovalsData = Awaited> +export type OAuthApprovalsError = ResponseError + +export const useOAuthApprovalsQuery = ( + { slug, cursor }: OAuthApprovalsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.approvals(slug, cursor), + queryFn: () => getOAuthApprovals({ slug, cursor }), + enabled: enabled && USE_MOCKS && Boolean(slug), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-approve-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-approve-mutation.ts new file mode 100644 index 00000000000..d3da65fb547 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-approve-mutation.ts @@ -0,0 +1,56 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { getMockOAuthAppsAuthorizeApproveResult, USE_MOCKS } from './mocks' +import type { OAuthAppsAuthorizeApproveResult, OAuthAuthorizeApproveRequest } from './types' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthAppsAuthorizeApproveVariables = { + slug: string + auth_id: string + body: OAuthAuthorizeApproveRequest +} + +export type OAuthAppsAuthorizeApproveResponse = OAuthAppsAuthorizeApproveResult + +export async function approveOAuthAppsAuthorize({ + slug, + auth_id, + body, +}: OAuthAppsAuthorizeApproveVariables): Promise { + if (!auth_id) throw new Error('Authorization request id is required') + if (!slug) throw new Error('Organization slug is required') + if (!USE_MOCKS) throw new Error('OAuth app authorization approval is not yet implemented') + + return getMockOAuthAppsAuthorizeApproveResult(auth_id, { slug, projectRefs: body.project_refs }) +} + +type OAuthAppsAuthorizeApproveData = Awaited> + +export const useOAuthAppsAuthorizeApproveMutation = ({ + onError, + ...options +}: Omit< + UseCustomMutationOptions< + OAuthAppsAuthorizeApproveData, + ResponseError, + OAuthAppsAuthorizeApproveVariables + >, + 'mutationFn' +> = {}) => { + return useMutation< + OAuthAppsAuthorizeApproveData, + ResponseError, + OAuthAppsAuthorizeApproveVariables + >({ + mutationFn: (vars) => approveOAuthAppsAuthorize(vars), + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to authorize access to OAuth app: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-deny-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-deny-mutation.ts new file mode 100644 index 00000000000..e54431cd965 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-deny-mutation.ts @@ -0,0 +1,47 @@ +import { useMutation } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { getMockOAuthAppsAuthorizeRedirect, USE_MOCKS } from './mocks' +import type { OAuthAppsAuthorizeRedirect } from './types' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthAppsAuthorizeDenyVariables = { + slug: string + auth_id: string +} + +export type OAuthAppsAuthorizeDenyResponse = OAuthAppsAuthorizeRedirect + +export async function denyOAuthAppsAuthorize({ slug, auth_id }: OAuthAppsAuthorizeDenyVariables) { + if (!auth_id) throw new Error('Authorization request id is required') + if (!slug) throw new Error('Organization slug is required') + if (!USE_MOCKS) throw new Error('OAuth app authorization denial is not yet implemented') + + return getMockOAuthAppsAuthorizeRedirect(auth_id, { approved: false }) +} + +type OAuthAppsAuthorizeDenyData = Awaited> + +export const useOAuthAppsAuthorizeDenyMutation = ({ + onError, + ...options +}: Omit< + UseCustomMutationOptions< + OAuthAppsAuthorizeDenyData, + ResponseError, + OAuthAppsAuthorizeDenyVariables + >, + 'mutationFn' +> = {}) => { + return useMutation({ + mutationFn: (vars) => denyOAuthAppsAuthorize(vars), + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to deny access to OAuth app: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-mutations.test.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-mutations.test.ts new file mode 100644 index 00000000000..f53884948b3 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-mutations.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest' + +import { OAUTH_APPS_MOCK_SCENARIOS } from './mocks' +import { approveOAuthAppsAuthorize } from './oauth-apps-authorize-approve-mutation' +import { denyOAuthAppsAuthorize } from './oauth-apps-authorize-deny-mutation' +import { isRoleValidationFailure } from './types' + +const AUTH_ID = OAUTH_APPS_MOCK_SCENARIOS.vercelDeveloper +const SLUG = 'northwind-traders' +const BODY = { project_refs: ['northwindstorefront1'] } + +async function approveExpectingRedirect( + variables: Parameters[0] +) { + const result = await approveOAuthAppsAuthorize(variables) + if (isRoleValidationFailure(result)) throw new Error('expected a redirect, got a role failure') + return result +} + +describe('approveOAuthAppsAuthorize', () => { + test('returns a redirect url carrying both code and state', async () => { + const { url } = await approveExpectingRedirect({ slug: SLUG, auth_id: AUTH_ID, body: BODY }) + + const params = new URL(url).searchParams + expect(params.get('code')).toBeTruthy() + expect(params.get('state')).toBeTruthy() + expect(params.get('error')).toBeNull() + }) + + test('accepts a body without project_refs', async () => { + const { url } = await approveExpectingRedirect({ slug: SLUG, auth_id: AUTH_ID, body: {} }) + + expect(new URL(url).searchParams.get('code')).toBeTruthy() + }) + + test('requires an authorization request id', async () => { + await expect( + approveOAuthAppsAuthorize({ slug: SLUG, auth_id: '', body: BODY }) + ).rejects.toThrow('Authorization request id is required') + }) + + test('requires an organization slug', async () => { + await expect( + approveOAuthAppsAuthorize({ slug: '', auth_id: AUTH_ID, body: BODY }) + ).rejects.toThrow('Organization slug is required') + }) + + test('does not enforce a selection cap', async () => { + const manyRefs = Array.from({ length: 25 }, (_, index) => `ref-${index}`) + + const { url } = await approveExpectingRedirect({ + slug: SLUG, + auth_id: AUTH_ID, + body: { project_refs: manyRefs }, + }) + + expect(url).toBeTruthy() + }) +}) + +describe('denyOAuthAppsAuthorize', () => { + test('returns a redirect url carrying access_denied and state', async () => { + const { url } = await denyOAuthAppsAuthorize({ slug: SLUG, auth_id: AUTH_ID }) + + const params = new URL(url).searchParams + expect(params.get('error')).toBe('access_denied') + expect(params.get('state')).toBeTruthy() + expect(params.get('code')).toBeNull() + }) + + test('requires an authorization request id', async () => { + await expect(denyOAuthAppsAuthorize({ slug: SLUG, auth_id: '' })).rejects.toThrow( + 'Authorization request id is required' + ) + }) + + test('requires an organization slug', async () => { + await expect(denyOAuthAppsAuthorize({ slug: '', auth_id: AUTH_ID })).rejects.toThrow( + 'Organization slug is required' + ) + }) +}) diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-organization-projects-query.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-organization-projects-query.ts new file mode 100644 index 00000000000..8881f59a2b1 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-organization-projects-query.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppsAuthorizeOrganizationProjects, USE_MOCKS } from './mocks' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppsAuthorizeOrganizationProjectsVariables = { + id?: string + slug?: string +} + +export type { OAuthAppsAuthorizeOrganizationProject } from './types' + +export async function getOAuthAppsAuthorizeOrganizationProjects({ + id, + slug, +}: OAuthAppsAuthorizeOrganizationProjectsVariables) { + if (!id) throw new Error('Authorization request id is required') + if (!slug) throw new Error('Organization slug is required') + if (!USE_MOCKS) + throw new Error('OAuth app authorization organization projects are not yet implemented') + + return getMockOAuthAppsAuthorizeOrganizationProjects(slug) +} + +export type OAuthAppsAuthorizeOrganizationProjectsData = Awaited< + ReturnType +> +export type OAuthAppsAuthorizeOrganizationProjectsError = ResponseError + +export const useOAuthAppsAuthorizeOrganizationProjectsQuery = < + TData = OAuthAppsAuthorizeOrganizationProjectsData, +>( + { id, slug }: OAuthAppsAuthorizeOrganizationProjectsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + OAuthAppsAuthorizeOrganizationProjectsData, + OAuthAppsAuthorizeOrganizationProjectsError, + TData + > = {} +) => + useQuery< + OAuthAppsAuthorizeOrganizationProjectsData, + OAuthAppsAuthorizeOrganizationProjectsError, + TData + >({ + queryKey: oauthAppsKeys.authorizeOrganizationProjects(id, slug), + queryFn: () => getOAuthAppsAuthorizeOrganizationProjects({ id, slug }), + enabled: enabled && USE_MOCKS && Boolean(id) && Boolean(slug), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-organizations-query.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-organizations-query.ts new file mode 100644 index 00000000000..c41bf4992f3 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-organizations-query.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppsAuthorizeIdentity, USE_MOCKS } from './mocks' +import type { OAuthOrganizationRole } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppsAuthorizeOrganizationsVariables = { + id?: string +} + +export type OAuthAppsAuthorizeIdentity = { + email: string + organizations: OAuthOrganizationRole[] +} + +export async function getOAuthAppsAuthorizeIdentity({ + id, +}: OAuthAppsAuthorizeOrganizationsVariables) { + if (!id) throw new Error('Authorization request id is required') + if (!USE_MOCKS) throw new Error('OAuth app authorization identity is not yet implemented') + + return getMockOAuthAppsAuthorizeIdentity(id) +} + +export type OAuthAppsAuthorizeOrganizationsData = Awaited< + ReturnType +> +export type OAuthAppsAuthorizeOrganizationsError = ResponseError + +export const useOAuthAppsAuthorizeOrganizationsQuery = < + TData = OAuthAppsAuthorizeOrganizationsData, +>( + { id }: OAuthAppsAuthorizeOrganizationsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + OAuthAppsAuthorizeOrganizationsData, + OAuthAppsAuthorizeOrganizationsError, + TData + > = {} +) => + useQuery({ + queryKey: oauthAppsKeys.authorizeOrganizations(id), + queryFn: () => getOAuthAppsAuthorizeIdentity({ id }), + enabled: enabled && USE_MOCKS && Boolean(id), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-authorize-request-query.ts b/apps/studio/data/oauth-apps/oauth-apps-authorize-request-query.ts new file mode 100644 index 00000000000..a231073e086 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-authorize-request-query.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppsAuthorizeRequest, USE_MOCKS } from './mocks' +import type { OAuthAppsAuthorizeRequest } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppsAuthorizeRequestVariables = { + id?: string +} + +export type { OAuthAppsAuthorizeRequest } from './types' + +export async function getOAuthAppsAuthorizeRequest({ + id, +}: OAuthAppsAuthorizeRequestVariables): Promise { + if (!id) throw new Error('Authorization request id is required') + if (!USE_MOCKS) throw new Error('OAuth app authorization request is not yet implemented') + + return getMockOAuthAppsAuthorizeRequest(id) +} + +export type OAuthAppsAuthorizeRequestData = Awaited> +export type OAuthAppsAuthorizeRequestError = ResponseError + +export const useOAuthAppsAuthorizeRequestQuery = ( + { id }: OAuthAppsAuthorizeRequestVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + OAuthAppsAuthorizeRequestData, + OAuthAppsAuthorizeRequestError, + TData + > = {} +) => + useQuery({ + queryKey: oauthAppsKeys.authorizeRequest(id), + queryFn: () => getOAuthAppsAuthorizeRequest({ id }), + enabled: enabled && USE_MOCKS && Boolean(id), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-grants-query.ts b/apps/studio/data/oauth-apps/oauth-apps-grants-query.ts new file mode 100644 index 00000000000..f5f300fc160 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-grants-query.ts @@ -0,0 +1,37 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthOwnGrants, USE_MOCKS } from './mocks' +import type { ListOwnGrantsResponse } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthGrantsVariables = { + cursor?: string +} + +export type { ListOwnGrantsResponse, MemberOauthGrantItem, OAuthGrantProject } from './types' + +export async function getOAuthGrants({ + cursor, +}: OAuthGrantsVariables = {}): Promise { + if (!USE_MOCKS) throw new Error('OAuth grants are not yet implemented') + + return getMockOAuthOwnGrants(cursor) +} + +export type OAuthGrantsData = Awaited> +export type OAuthGrantsError = ResponseError + +export const useOAuthGrantsQuery = ( + { cursor }: OAuthGrantsVariables = {}, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.grants(cursor), + queryFn: () => getOAuthGrants({ cursor }), + enabled: enabled && USE_MOCKS, + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts b/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts new file mode 100644 index 00000000000..6cef0997500 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-member-grants-query.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppGrants, USE_MOCKS } from './mocks' +import type { ListOrgAppGrantsResponse } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppMemberGrantsVariables = { + slug?: string + appId?: string + cursor?: string +} + +export type { ListOrgAppGrantsResponse, OAuthGrantItem, OAuthGrantProject } from './types' + +export async function getOAuthAppMemberGrants({ + slug, + appId, + cursor, +}: OAuthAppMemberGrantsVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!appId) throw new Error('App id is required') + if (!USE_MOCKS) throw new Error('OAuth app member grants are not yet implemented') + + return getMockOAuthAppGrants(appId, cursor) +} + +export type OAuthAppMemberGrantsData = Awaited> +export type OAuthAppMemberGrantsError = ResponseError + +export const useOAuthAppMemberGrantsQuery = ( + { slug, appId, cursor }: OAuthAppMemberGrantsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.appMemberGrants(slug, appId, cursor), + queryFn: () => getOAuthAppMemberGrants({ slug, appId, cursor }), + enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-org-app-details-query.ts b/apps/studio/data/oauth-apps/oauth-apps-org-app-details-query.ts new file mode 100644 index 00000000000..169ae55359e --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-org-app-details-query.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthOrgAppDetails, USE_MOCKS } from './mocks' +import type { OAuthOrgAppDetails } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthOrgAppDetailsVariables = { + slug?: string + appId?: string +} + +export type { OAuthOrgAppDetails } from './types' + +export async function getOAuthOrgAppDetails({ + slug, + appId, +}: OAuthOrgAppDetailsVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!appId) throw new Error('App id is required') + if (!USE_MOCKS) throw new Error('OAuth org app details are not yet implemented') + + return getMockOAuthOrgAppDetails(slug, appId) +} + +export type OAuthOrgAppDetailsData = Awaited> +export type OAuthOrgAppDetailsError = ResponseError + +export const useOAuthOrgAppDetailsQuery = ( + { slug, appId }: OAuthOrgAppDetailsVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions = {} +) => + useQuery({ + queryKey: oauthAppsKeys.orgAppDetails(slug, appId), + queryFn: () => getOAuthOrgAppDetails({ slug, appId }), + enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts b/apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts new file mode 100644 index 00000000000..5bf8f6a2e68 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-preflight-validation-query.ts @@ -0,0 +1,47 @@ +import { useQuery } from '@tanstack/react-query' + +import { oauthAppsKeys } from './keys' +import { getMockOAuthAppsPreflightValidation, USE_MOCKS } from './mocks' +import type { OAuthAppsAuthorizePreflightResult } from './types' +import type { ResponseError, UseCustomQueryOptions } from '@/types' + +export type OAuthAppsPreflightValidationVariables = { + slug?: string + appId?: string +} + +export type { OAuthAppsAuthorizePreflightResult } from './types' + +export async function getOAuthAppsPreflightValidation({ + slug, + appId, +}: OAuthAppsPreflightValidationVariables): Promise { + if (!slug) throw new Error('Organization slug is required') + if (!appId) throw new Error('App id is required') + if (!USE_MOCKS) throw new Error('OAuth app preflight validation is not yet implemented') + + return getMockOAuthAppsPreflightValidation(slug, appId) +} + +export type OAuthAppsPreflightValidationData = Awaited< + ReturnType +> +export type OAuthAppsPreflightValidationError = ResponseError + +export const useOAuthAppsPreflightValidationQuery = ( + { slug, appId }: OAuthAppsPreflightValidationVariables, + { + enabled = true, + ...options + }: UseCustomQueryOptions< + OAuthAppsPreflightValidationData, + OAuthAppsPreflightValidationError, + TData + > = {} +) => + useQuery({ + queryKey: oauthAppsKeys.preflightValidation(slug, appId), + queryFn: () => getOAuthAppsPreflightValidation({ slug, appId }), + enabled: enabled && USE_MOCKS && Boolean(slug) && Boolean(appId), + ...options, + }) diff --git a/apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts new file mode 100644 index 00000000000..e7fe1faa1b4 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-revoke-grant-mutation.ts @@ -0,0 +1,48 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { oauthAppsKeys } from './keys' +import { USE_MOCKS } from './mocks' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthGrantRevokeVariables = { + slug: string + grantId: string +} + +export async function revokeOAuthGrant({ slug, grantId }: OAuthGrantRevokeVariables) { + if (!slug) throw new Error('Organization slug is required') + if (!grantId) throw new Error('Grant id is required') + if (!USE_MOCKS) throw new Error('OAuth grant revocation is not yet implemented') + + // 204 on success — no response body. +} + +type OAuthGrantRevokeData = Awaited> + +export const useOAuthGrantRevokeMutation = ({ + onError, + onSuccess, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => revokeOAuthGrant(vars), + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ queryKey: oauthAppsKeys.grants() }) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to revoke OAuth grant: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts b/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts new file mode 100644 index 00000000000..72f80a5b592 --- /dev/null +++ b/apps/studio/data/oauth-apps/oauth-apps-revoke-mutation.ts @@ -0,0 +1,57 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { oauthAppsKeys } from './keys' +import { USE_MOCKS } from './mocks' +import type { ResponseError, UseCustomMutationOptions } from '@/types' + +export type OAuthAppRevokeVariables = { + slug: string + appId: string +} + +export async function revokeOAuthApp({ slug, appId }: OAuthAppRevokeVariables) { + if (!slug) throw new Error('Organization slug is required') + if (!appId) throw new Error('App id is required') + if (!USE_MOCKS) throw new Error('OAuth app revocation is not yet implemented') + + return { id: appId } +} + +type OAuthAppRevokeData = Awaited> + +export const useOAuthAppRevokeMutation = ({ + onError, + onSuccess, + ...options +}: Omit< + UseCustomMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (vars) => revokeOAuthApp(vars), + async onSuccess(data, variables, context) { + // Revoking an app deletes every row for (app_id, organization_id), both kinds — the + // org's approvals overview, this app's grant list, and any member's own-grants view are + // all stale. + await Promise.all([ + queryClient.invalidateQueries({ queryKey: oauthAppsKeys.approvals(variables.slug) }), + queryClient.invalidateQueries({ + queryKey: oauthAppsKeys.appMemberGrants(variables.slug, variables.appId), + }), + queryClient.invalidateQueries({ queryKey: oauthAppsKeys.grants() }), + ]) + await onSuccess?.(data, variables, context) + }, + async onError(data, variables, context) { + if (onError === undefined) { + toast.error(`Failed to revoke OAuth app: ${data.message}`) + } else { + onError(data, variables, context) + } + }, + ...options, + }) +} diff --git a/apps/studio/data/oauth-apps/types.test.ts b/apps/studio/data/oauth-apps/types.test.ts new file mode 100644 index 00000000000..0af62eee06a --- /dev/null +++ b/apps/studio/data/oauth-apps/types.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from 'vitest' + +import { DEVELOPER_ROLE, READ_ONLY_ROLE } from './mocks' +import type { OAuthAppsAuthorizeOrganizationProject, OAuthExistingGrant } from './types' +import { getFailedProjects, getPreselectedProjectRefs, isRoleValidationFailure } from './types' + +const project = (ref: string): OAuthAppsAuthorizeOrganizationProject => ({ + ref, + name: ref, + role: DEVELOPER_ROLE, +}) + +const grant = (refs: string[]): OAuthExistingGrant => ({ + approved_scopes: ['projects:read'], + project_refs: refs, + approved_at: '2026-08-14T09:12:00.000Z', +}) + +describe('getPreselectedProjectRefs', () => { + const live = ['alpha', 'bravo', 'charlie', 'delta'].map(project) + + test('preselects the project_ref query param when it resolves against live projects', () => { + expect( + getPreselectedProjectRefs({ existingGrant: null, projectRef: 'alpha', liveProjects: live }) + ).toEqual(['alpha']) + }) + + test('drops a project_ref that does not resolve against live projects', () => { + expect( + getPreselectedProjectRefs({ existingGrant: null, projectRef: 'ghost', liveProjects: live }) + ).toEqual([]) + }) + + test('returns nothing when there is neither a grant nor a project_ref', () => { + expect( + getPreselectedProjectRefs({ existingGrant: null, projectRef: null, liveProjects: live }) + ).toEqual([]) + }) + + test('existing-grant refs win over the project_ref query param', () => { + expect( + getPreselectedProjectRefs({ + existingGrant: grant(['charlie', 'delta']), + projectRef: 'alpha', + liveProjects: live, + }) + ).toEqual(['charlie', 'delta']) + }) + + test('filters stale refs out of the existing grant', () => { + expect( + getPreselectedProjectRefs({ + existingGrant: grant(['alpha', 'ghost']), + projectRef: null, + liveProjects: live, + }) + ).toEqual(['alpha']) + }) + + test('falls back to the project_ref when the existing grant carries no refs', () => { + expect( + getPreselectedProjectRefs({ + existingGrant: grant([]), + projectRef: 'bravo', + liveProjects: live, + }) + ).toEqual(['bravo']) + }) + + test('does not cap the number of preselected refs', () => { + const manyProjects = Array.from({ length: 15 }, (_, index) => project(`ref-${index}`)) + const manyRefs = manyProjects.map((entry) => entry.ref) + + expect( + getPreselectedProjectRefs({ + existingGrant: grant(manyRefs), + projectRef: null, + liveProjects: manyProjects, + }) + ).toEqual(manyRefs) + }) + + test('returns nothing when there are no live projects', () => { + expect( + getPreselectedProjectRefs({ + existingGrant: grant(['alpha']), + projectRef: 'alpha', + liveProjects: [], + }) + ).toEqual([]) + }) +}) + +describe('isRoleValidationFailure', () => { + test('recognises a role validation failure', () => { + expect( + isRoleValidationFailure({ + error_code: 'role_validation_failed', + message: 'nope', + validation: { scope_target: 'organization', role: READ_ONLY_ROLE }, + }) + ).toBe(true) + }) + + test('treats a redirect as a success', () => { + expect(isRoleValidationFailure({ url: 'https://vercel.com/callback?code=abc' })).toBe(false) + }) +}) + +describe('getFailedProjects', () => { + test('returns the failures of a projects-level failure', () => { + const failure = { + ref: 'alpha', + name: 'alpha', + role: READ_ONLY_ROLE, + failed_scopes: ['database:write' as const], + } + + expect( + getFailedProjects({ + error_code: 'role_validation_failed', + message: 'nope', + validation: { scope_target: 'projects', failures: [failure] }, + }) + ).toEqual([failure]) + }) + + test('is empty for an organization-level failure', () => { + expect( + getFailedProjects({ + error_code: 'role_validation_failed', + message: 'nope', + validation: { scope_target: 'organization', role: READ_ONLY_ROLE }, + }) + ).toEqual([]) + }) + + test('is empty when there is no failure', () => { + expect(getFailedProjects(null)).toEqual([]) + expect(getFailedProjects(undefined)).toEqual([]) + }) +}) diff --git a/apps/studio/data/oauth-apps/types.ts b/apps/studio/data/oauth-apps/types.ts new file mode 100644 index 00000000000..4d811a3b37d --- /dev/null +++ b/apps/studio/data/oauth-apps/types.ts @@ -0,0 +1,190 @@ +import type { components } from 'api-types' + +import type { OrganizationRole } from '@/data/organization-members/organization-roles-query' + +type LiveAuthorizeRequest = components['schemas']['GetOAuthAuthorizationResponse_Output'] + +export type OAuthAppsAuthorizeLiveFields = Pick< + LiveAuthorizeRequest, + 'name' | 'website' | 'domain' | 'icon' | 'redirect_uri' | 'registration_type' | 'expires_at' +> + +export type OAuthScope = NonNullable[number] + +export function isWriteScope(scope: OAuthScope): boolean { + return scope.endsWith(':write') +} + +export type OAuthGrantKind = 'organization_bound' | 'member_bound' + +export type OAuthAppsAuthorizeRequest = OAuthAppsAuthorizeLiveFields & { + app_id: string + app_name: string + grant_kind: OAuthGrantKind + project_scoping_mode: boolean + scopes: OAuthScope[] +} + +export type OAuthOrganizationRole = { + slug: string + name: string + default_role: OrganizationRole +} + +export type OAuthAppsAuthorizeOrganizationProject = { + ref: string + name: string + role: OrganizationRole +} + +export type OAuthExistingGrant = { + approved_scopes: OAuthScope[] | null + project_refs: string[] + approved_at: string +} + +export type OAuthOrgAppDetails = { + existing_grant: OAuthExistingGrant | null +} + +export function getPreselectedProjectRefs({ + existingGrant, + projectRef, + liveProjects, +}: { + existingGrant: OAuthExistingGrant | null + projectRef: string | null + liveProjects: OAuthAppsAuthorizeOrganizationProject[] +}): string[] { + const liveRefs = new Set(liveProjects.map((project) => project.ref)) + const candidates = existingGrant?.project_refs.length + ? existingGrant.project_refs + : projectRef + ? [projectRef] + : [] + return candidates.filter((ref) => liveRefs.has(ref)) +} + +export type OAuthAuthorizeApproveRequest = { + // not allowed when project_scoping_mode is false. + // when project_scoping_mode is true, if omitted, this means all projects + project_refs?: string[] +} + +export type OAuthAppsAuthorizeRedirect = + components['schemas']['ApproveAuthorizationResponse_Output'] + +export type OAuthScopeValidationResult = + | { + scope_target: 'organization' + role: OrganizationRole + } + | { + scope_target: 'all_projects' + role: OrganizationRole + failed_scopes: OAuthScope[] + } + | { + scope_target: 'projects' + failures: Array<{ + ref: string + name: string + role: OrganizationRole + failed_scopes: OAuthScope[] + }> + } + +export type OAuthScopeValidationProjectFailure = Extract< + OAuthScopeValidationResult, + { scope_target: 'projects' } +>['failures'][number] + +export type OAuthAppsAuthorizeRoleValidationFailure = { + error_code: 'role_validation_failed' + message: string + validation: OAuthScopeValidationResult +} + +export type OAuthAppsAuthorizeApproveResult = + | OAuthAppsAuthorizeRedirect + | OAuthAppsAuthorizeRoleValidationFailure + +export function isRoleValidationFailure( + result: OAuthAppsAuthorizeApproveResult +): result is OAuthAppsAuthorizeRoleValidationFailure { + return 'error_code' in result && result.error_code === 'role_validation_failed' +} + +export function getFailedProjects( + failure: OAuthAppsAuthorizeRoleValidationFailure | null | undefined +): OAuthScopeValidationProjectFailure[] { + if (!failure) return [] + return failure.validation.scope_target === 'projects' ? failure.validation.failures : [] +} + +export type OAuthAppsAuthorizePreflightSuccess = { ok: true } + +export type OAuthAppsAuthorizePreflightResult = + | OAuthAppsAuthorizePreflightSuccess + | OAuthAppsAuthorizeRoleValidationFailure + +export function isPreflightValidationFailure( + result: OAuthAppsAuthorizePreflightResult +): result is OAuthAppsAuthorizeRoleValidationFailure { + return 'error_code' in result && result.error_code === 'role_validation_failed' +} + +export type OAuthApprovalTarget = 'organization' | 'members' | 'organization_and_members' + +export type OAuthApprovalItem = { + app: { + id: string + name: string + created_by: string + icon: string | null + } + grant_target: OAuthApprovalTarget + org_grant: { + grant_id: string + approved_scopes: string[] + approved_at: string + } | null +} + +export type ListOAuthApprovalsResponse = { + data: OAuthApprovalItem[] + pagination: { next_cursor: string | null } +} + +export type OAuthGrantProject = { + ref: string + name: string +} + +export type OAuthGrantItem = { + grant_id: string + kind: OAuthGrantKind + user: { gotrue_id: string; email: string; avatar_url?: string } | null + projects: OAuthGrantProject[] | null + approved_scopes: string[] + approved_at: string +} + +export type ListOrgAppGrantsResponse = { + data: OAuthGrantItem[] + pagination: { next_cursor: string | null } +} + +export type MemberOauthGrantItem = { + grant_id: string + app: { id: string; name: string; icon: string | null } + organization: { slug: string; name: string } + projects: OAuthGrantProject[] | null + approved_scopes: string[] + approved_at: string +} + +export type ListOwnGrantsResponse = { + data: MemberOauthGrantItem[] + pagination: { next_cursor: string | null } +} diff --git a/knip.jsonc b/knip.jsonc index 7a79952a7e0..5baa43c1ad7 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -65,6 +65,13 @@ // read are documentation of the valid values, not dead code. Scoped to // this file so `enumMembers` keeps working everywhere else. "data/database/constraints-query.ts": ["enumMembers"], + // Data contracts and mock hooks for the OAuth authorization + // interstitial, landed ahead of the UI that consumes them. `exports` + // and `types` cover the contract types that only gain consumers in the + // consent-screen PR. + // TODO(PROD-625): drop every oauth-apps knip exception in the + // consent-screen PR, once the interstitial route imports them. + "data/oauth-apps/**": ["files", "exports", "types"], "hooks/misc/useTrackExperimentExposure.ts": ["files"], // Staging-only pinned Postgres image + FDW request payload for creating // Warehouse test projects by hand. Nothing imports it yet — it's kept From f414a68e1bb0067f3f12953c8cd3b55ba75c79de Mon Sep 17 00:00:00 2001 From: Wen Bo Xie Date: Mon, 5 Oct 2026 06:26:52 -0700 Subject: [PATCH 09/41] docs(cli): address final review feedback on supabase stack guides (#51217) - Add stack = true under the existing [experimental] table instead of pasting a duplicate table, which leaves the setting off. - Note that db dump targets the linked project unless --local is passed, and that --db-url reaches a named local project through DB_URL. - Qualify offline destroy on the host data being deletable, and scope the shared Docker volume cleanup note to volume-backed projects. - Say Docker database storage is chosen when the local project is created. - Allow GitHub's release download host alongside github.com, or the S3 host alone, in allowlisted sandboxes. - www: most services stop when idle (not Functions), and state the [experimental] stack prerequisite in the native runtime entry. --- .../local-development/docker-and-native-runtimes.mdx | 6 ++++-- .../running-multiple-local-projects.mdx | 12 +++++++----- apps/www/data/features.tsx | 4 ++-- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/docs/content/guides/local-development/docker-and-native-runtimes.mdx b/apps/docs/content/guides/local-development/docker-and-native-runtimes.mdx index ec1da3c4bc3..113bc12b773 100644 --- a/apps/docs/content/guides/local-development/docker-and-native-runtimes.mdx +++ b/apps/docs/content/guides/local-development/docker-and-native-runtimes.mdx @@ -30,10 +30,12 @@ The following table compares how each runtime runs services, what it needs, and | Flag | `--runtime docker` or `--runtime podman` | `--runtime native` | | How services run | One container per service, from images the CLI pulls | One process per service, from archives the CLI downloads and verifies | | Requires | A running Docker or Podman engine | A supported platform, `tar` on the host, and, on first start, network access to the archive download hosts | -| Database data | A Docker volume when the Docker client and daemon are both version 26 or later, or a host directory otherwise | `~/.supabase/stacks//` in your home directory | +| Database data | Set when the local project is created. A Docker volume if the Docker client and daemon are both version 26 or later, otherwise a host directory | `~/.supabase/stacks//` in your home directory | | Downloaded artifacts | The engine's image cache | `~/.supabase/cache/stack` in your home directory, shared by all local projects | | Isolation between local projects | Separate network namespaces and file systems | Shared host process table and file locks | +A Docker local project keeps the database storage it was created with. A local project created on Docker 25 keeps its host directory after you upgrade to Docker 26. + Both runtimes keep each local project's saved settings under `~/.supabase/stacks//`. The native runtime keeps its database data there too. If you set `SUPABASE_HOME`, the CLI uses that directory instead of `~/.supabase`. In both runtimes, Postgres starts right away and other services start on their first request. By default, services that started this way stop again after 60 seconds idle, and Studio after 5 minutes. Functions doesn't stop on its own. A service also stays up while a running service depends on it, such as pgmeta while Studio runs. Pass `--eager` to start every enabled service before the command returns and turn off idle stops. @@ -69,7 +71,7 @@ The native runtime runs on these platforms: The native runtime doesn't run as `root`, because Postgres `initdb` can't. To run it as `root`, such as in a CI container, set `SUPABASE_NATIVE_POSTGRES_USER` to a non-root account. Create the account first if it doesn't exist. The CLI then runs Postgres as that user. In some supported coding agent sandboxes, the CLI finds a suitable non-root account without the variable. -On first start, the CLI downloads service archives from the [`supabase/slim-services` GitHub releases](https://github.com/supabase/slim-services/releases), with `supabase-cli-artifacts.s3.us-east-1.amazonaws.com` as a fallback. It verifies their checksums and extracts them with the system `tar`, so install `tar` in minimal sandbox images. In a sandbox with an allowlist, allow `github.com` or that S3 host. Allowing only `ghcr.io` isn't enough. +On first start, the CLI downloads service archives from the [`supabase/slim-services` GitHub releases](https://github.com/supabase/slim-services/releases), with `supabase-cli-artifacts.s3.us-east-1.amazonaws.com` as a fallback. It verifies their checksums and extracts them with the system `tar`, so install `tar` in minimal sandbox images. In a sandbox with an allowlist, allow the S3 host. To use GitHub instead, allow both `github.com` and `release-assets.githubusercontent.com`, because GitHub redirects release downloads to that host. Allowing only `ghcr.io` isn't enough. To download the archives ahead of time without starting any services, such as when you build a sandbox image, run: diff --git a/apps/docs/content/guides/local-development/running-multiple-local-projects.mdx b/apps/docs/content/guides/local-development/running-multiple-local-projects.mdx index b1766586047..e718ac1c1b8 100644 --- a/apps/docs/content/guides/local-development/running-multiple-local-projects.mdx +++ b/apps/docs/content/guides/local-development/running-multiple-local-projects.mdx @@ -57,13 +57,15 @@ You need: The `supabase stack` commands exist only when the `[experimental] stack` setting is on. Without it, `supabase stack` commands fail with `Unknown subcommand "stack"`. -1. Add this to `supabase/config.toml` in your app directory: +1. In `supabase/config.toml` in your app directory, add `stack = true` under the existing `[experimental]` table. If your `config.toml` doesn't have an `[experimental]` table, add one: ```toml [experimental] stack = true ``` + Don't add a second `[experimental]` table. A duplicate table makes `config.toml` invalid. The stack commands then stay off. Commands that read the config fail with `failed to load config`. + In a directory without a `config.toml`, set the environment variable instead: ```bash @@ -166,7 +168,7 @@ One app can run several local projects by name. This keeps a destructive test ru A local project started without `--stack` is the app directory's default local project. Named local projects and the default one are independent of each other. -The `db`, `migration`, and other database commands use the default local project for the current branch. They can't target a named local project. See [Limitations](#limitations). +The `db`, `migration`, and other database commands don't accept `--stack`. When they run against a local database, they use the default local project for the current branch. `supabase db pull`, `supabase db push`, and `supabase db dump` target the linked project unless you pass `--local`. To reach a named local project from a command that accepts `--db-url`, pass the `DB_URL` from `supabase status --stack --env`. See [Limitations](#limitations). ## Find a local project's endpoints and keys @@ -253,9 +255,9 @@ supabase stack destroy --stack test --yes `destroy` keeps two kinds of data: - Storage upload files, which live in your app at `supabase/.temp/stack-uploads//`. Delete that directory to remove them. -- Container engine resources, if Docker or Podman isn't running. If no process for the local project is still running, the command removes the local project's registration. It prints cleanup commands to run after the engine starts again. Otherwise it fails and asks you to start the engine and retry. +- Container engine resources, if Docker or Podman isn't running. The command can still remove the local project's registration if no process for it is still running and your user can delete its database files on the host. It then prints cleanup commands to run after the engine starts again. Otherwise it fails, keeps the local project, and asks you to start the engine and retry. Your user might not be able to delete database files that a container wrote to a host directory. Local projects created with Docker version 25 or earlier store data that way. -The printed cleanup command deletes only this local project's database data, from a Docker volume that other local projects share. Don't delete the volume itself. +If the local project stores its database data in a Docker volume, other local projects share that volume. The printed cleanup command deletes only this local project's data from it. Don't delete the volume itself. ## Differences from the default `supabase start` @@ -275,7 +277,7 @@ Keep these differences in mind when you turn the setting on for an app that used - Storage Analytics and Storage vector buckets - A custom `edge_runtime.deno_version` - OrioleDB, and `db.major_version` values other than 15 and 17 -- Database commands can't target a named local project. `supabase db reset`, `supabase db diff`, `supabase db pull`, `supabase db dump`, and the other local database commands use the app directory's default local project for the current branch, and don't accept `--stack`. +- Database commands don't accept `--stack`. `supabase db reset` and `supabase db diff` run against the current branch's default local project. `supabase db pull`, `supabase db push`, and `supabase db dump` target the linked project unless you pass `--local`. To reach a named local project, pass the `DB_URL` from `supabase status --stack --env` to a command that accepts `--db-url`. - Switching branches switches local projects. The git branch is part of the identity. After you check out another branch in the same directory, `supabase start` creates or resumes a different local project with its own data. The first branch's local project keeps running until you stop it. - Moving or renaming an app directory creates a new local project, because the identity includes the project root path. The previous local project's data stays until you destroy it. - `supabase stack logs` streams new log lines only. It has no history. diff --git a/apps/www/data/features.tsx b/apps/www/data/features.tsx index 597092badce..49bbfeeb214 100644 --- a/apps/www/data/features.tsx +++ b/apps/www/data/features.tsx @@ -2168,7 +2168,7 @@ With the default ports, a second \`supabase start\` on the same machine fails wi 2. Automatic ports: The CLI assigns ports from a shared range and keeps them across restarts. Remove the fixed ports from \`config.toml\` once, or create the project with the stack commands turned on. 3. Named environments: Start \`--stack dev\` and \`--stack test\` side by side in one app. 4. Docker or native runtime: Run in containers, or as processes on your machine without Docker on Linux and on macOS on Apple silicon. Use Docker when you run several local projects on one machine. -5. Services start on demand: Postgres starts right away. Other services start on their first request and stop when idle, which keeps idle local projects light. +5. Services start on demand: Postgres starts right away. Other services start on their first request. Most of them stop when idle, which keeps idle local projects light. ## Parallel local projects are valuable for: - Developers running coding agents in git worktrees @@ -2192,7 +2192,7 @@ The \`supabase stack\` commands are experimental. Their interface can change bet title: 'Native runtime for local development', subtitle: 'Run a local Supabase project as processes on your machine, without Docker.', description: ` -The Supabase CLI can run a local Supabase project as native processes instead of containers. Coding agent sandboxes and CI runners often have no Docker daemon. There, \`supabase stack start\` downloads verified service binaries and runs Postgres, Auth, Storage, and the other services directly on the host. +The Supabase CLI can run a local Supabase project as native processes instead of containers. Coding agent sandboxes and CI runners often have no Docker daemon. To run there, [turn on the \`[experimental] stack\` setting](https://supabase.com/docs/guides/local-development/running-multiple-local-projects#turn-on-the-stack-commands). Then \`supabase stack start\` downloads verified service binaries and runs Postgres, Auth, Storage, and the other services directly on the host. It is the same local project you get from \`supabase start\`, with the same services and most of the same \`config.toml\` settings. The CLI picks Docker when its daemon responds, then Podman, then native. To require one, pass \`--runtime docker\`, \`--runtime podman\`, or \`--runtime native\`. From 87681812a0b4aef538d18ea78a78e5ab6257952e Mon Sep 17 00:00:00 2001 From: Matt Rossman <22670878+mattrossman@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:37:51 -0400 Subject: [PATCH 10/41] fix(studio): assistant get_active_incidents url in prod (#51047) The Assistant's `get_active_incidents` tool has [failed in prod 99.8% of the time over the past 60 days](https://supabase.slack.com/archives/C051L8U2EJF/p1790712805774689), so users reporting outages get told it couldn't check incident status. The failures never showed up as errors, which is why nobody noticed. The Assistant now includes the `/dashboard` base path when it calls its own API routes, so the tool stops hitting a 404 in prod ([`/api/incident-status`](https://supabase.com/api/incident-status) is a 404, [`/dashboard/api/incident-status`](https://supabase.com/dashboard/api/incident-status) is a 200). The tool also throws on a failed fetch now instead of returning an `{ error }` result. That way failures show up as span errors in Braintrust and as a failed tool call in the UI. The model still gets the error message and tells the user it couldn't check. When the fetch fails (in dashboard): | Before (prod) | After (local, path temporarily broken) | | --- | --- | | CleanShot 2026-09-29 at 5 08 49
PM@2x | CleanShot 2026-09-29 at 5 08 06
PM@2x | When the fetch fails (in Braintrust): | Before (prod) | After (local, path temporarily broken) | | --- | --- | | CleanShot 2026-09-29 at 5 19 07
PM@2x | CleanShot 2026-09-29 at 5 19 10
PM@2x | When the fetch works (local): | Dashboard | Braintrust | | --- | --- | | CleanShot 2026-09-29 at 5 20 58
PM@2x | CleanShot 2026-09-30 at 9 07
03 AM@2x | Ran it locally with tracing on. Here's a [successful call](https://www.braintrust.dev/app/supabase.io/p/Assistant/logs?r=251bcd44-c55b-44bb-9ce2-b034b26f8832), and one with the path temporarily broken, which now [logs a span error](https://www.braintrust.dev/app/supabase.io/p/Assistant/logs?r=637f2dc8-f65b-4d65-9d86-0511b36e8685). Local dev has no base path, so the prod URL is covered by the new `getBasePathURL` tests. Closes AI-1272 ## Summary by CodeRabbit * **Bug Fixes** * Incident lookups now report fetch, HTTP, parsing, and validation errors rather than returning an empty incident result. * AI SQL generation now accounts for the configured site base path when building its service URL. * **Improvements** * Site URLs now handle trailing slashes and existing base paths consistently, avoiding duplicate path segments. --- .../lib/ai/tools/incident-tools.test.ts | 44 ++--- apps/studio/lib/ai/tools/incident-tools.ts | 163 ++++++++---------- apps/studio/lib/helpers.test.ts | 28 +++ apps/studio/lib/helpers.ts | 11 ++ apps/studio/pages/api/ai/sql/generate-v4.ts | 4 +- 5 files changed, 130 insertions(+), 120 deletions(-) diff --git a/apps/studio/lib/ai/tools/incident-tools.test.ts b/apps/studio/lib/ai/tools/incident-tools.test.ts index c2e0ccee842..f62d3c7a0af 100644 --- a/apps/studio/lib/ai/tools/incident-tools.test.ts +++ b/apps/studio/lib/ai/tools/incident-tools.test.ts @@ -170,22 +170,19 @@ describe('ai/tools/incident-tools', () => { expect((result as any).message).toContain('2 active incidents') }) - it('should handle fetch errors', async () => { + it('throws on fetch errors', async () => { const common = await import('common') vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) mockFetch.mockRejectedValue(new Error('Network error')) const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) - const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) - - expect(result).toEqual({ - incidents: [], - error: 'Unable to check incident status at this time.', - }) + await expect( + (tools.get_active_incidents.execute as any)({}, executeOptions) + ).rejects.toThrow('Network error') }) - it('should handle non-ok responses', async () => { + it('throws on non-ok responses with the status code', async () => { const common = await import('common') vi.spyOn(common, 'IS_PLATFORM', 'get').mockReturnValue(true) @@ -195,12 +192,9 @@ describe('ai/tools/incident-tools', () => { }) const tools = getIncidentTools({ baseUrl: 'https://supabase.com/dashboard' }) - const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) - - expect(result).toEqual({ - incidents: [], - error: 'Unable to check incident status at this time.', - }) + await expect( + (tools.get_active_incidents.execute as any)({}, executeOptions) + ).rejects.toThrow('Failed to fetch incident status: 500') }) it('should use timeout signal', async () => { @@ -424,7 +418,7 @@ describe('ai/tools/incident-tools', () => { }) }) - it('returns an error when the response fails to parse', async () => { + it('throws when the response fails to parse', async () => { mockFetch.mockResolvedValue({ ok: true, json: async () => ({ this_is: 'not a valid status page response' }), @@ -434,15 +428,12 @@ describe('ai/tools/incident-tools', () => { baseUrl: 'https://supabase.com/dashboard', useStatusPageWidget: true, }) - const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) - - expect(result).toEqual({ - incidents: [], - error: 'Unable to check incident status at this time.', - }) + await expect((tools.get_active_incidents.execute as any)({}, executeOptions)).rejects.toThrow( + 'Failed to parse status page response' + ) }) - it('returns an error on a non-ok response', async () => { + it('throws on a non-ok response with the status code', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500, @@ -452,12 +443,9 @@ describe('ai/tools/incident-tools', () => { baseUrl: 'https://supabase.com/dashboard', useStatusPageWidget: true, }) - const result = await (tools.get_active_incidents.execute as any)({}, executeOptions) - - expect(result).toEqual({ - incidents: [], - error: 'Unable to check incident status at this time.', - }) + await expect((tools.get_active_incidents.execute as any)({}, executeOptions)).rejects.toThrow( + 'Failed to fetch status page: 500' + ) }) }) }) diff --git a/apps/studio/lib/ai/tools/incident-tools.ts b/apps/studio/lib/ai/tools/incident-tools.ts index ffb8e0e0acb..d9e7a9cffbb 100644 --- a/apps/studio/lib/ai/tools/incident-tools.ts +++ b/apps/studio/lib/ai/tools/incident-tools.ts @@ -8,10 +8,6 @@ import { type AffectedComponent, } from '@/lib/status-page/status-page.schema' -const INCIDENT_STATUS_ERROR = { - incidents: [], - error: 'Unable to check incident status at this time.', -} const NO_ACTIVE_INCIDENTS_MESSAGE = 'No active incidents.' const DEFAULT_STATUS_PAGE_URL = 'https://status.supabase.com' @@ -74,108 +70,95 @@ export const getIncidentTools = ({ } if (useStatusPageWidget) { - try { - const response = await fetch(`${baseUrl}/api/status-page`, { - signal: timeoutSignal(abortSignal), - }) - - if (!response.ok) { - console.warn('Failed to fetch status page:', response.status) - return INCIDENT_STATUS_ERROR - } - - const json = await response.json() - const parseResult = StatusPageResponseSchema.safeParse(json) - - if (!parseResult.success) { - console.warn('Failed to parse status page response:', parseResult.error) - return INCIDENT_STATUS_ERROR - } - - const { data } = parseResult - - const incidents = data.ongoing_incidents - .filter((incident) => incident.visible) - .map((incident) => ({ - kind: 'incident' as const, - name: incident.name, - status: incident.status, - impact: incident.current_worst_impact, - affected_components: formatAffectedComponents(incident.affected_components), - last_update_message: incident.last_update_message ?? null, - url: incident.url, - })) - - const maintenances = data.in_progress_maintenances - .filter((maintenance) => maintenance.visible) - .map((maintenance) => ({ - kind: 'maintenance' as const, - name: maintenance.name, - status: maintenance.status, - affected_components: formatAffectedComponents(maintenance.affected_components), - last_update_message: maintenance.last_update_message ?? null, - url: maintenance.url, - started_at: maintenance.started_at, - })) - - const combined = [...incidents, ...maintenances] - - if (combined.length === 0) { - return { - incidents: [], - message: NO_ACTIVE_INCIDENTS_MESSAGE, - } - } - - const statusPageUrl = data.page_url || DEFAULT_STATUS_PAGE_URL - - return { - incidents: combined, - message: buildActiveIncidentsMessage( - incidents.length, - maintenances.length, - statusPageUrl - ), - } - } catch (error) { - console.warn('Failed to fetch status page:', error) - return INCIDENT_STATUS_ERROR - } - } - - try { - const response = await fetch(`${baseUrl}/api/incident-status`, { + const response = await fetch(`${baseUrl}/api/status-page`, { signal: timeoutSignal(abortSignal), }) if (!response.ok) { - console.warn('Failed to fetch incident status:', response.status) - return INCIDENT_STATUS_ERROR + throw new Error(`Failed to fetch status page: ${response.status}`) } - const incidents: IncidentInfo[] = await response.json() + const json = await response.json() + const parseResult = StatusPageResponseSchema.safeParse(json) - if (incidents.length === 0) { + if (!parseResult.success) { + throw new Error(`Failed to parse status page response: ${parseResult.error.message}`) + } + + const { data } = parseResult + + const incidents = data.ongoing_incidents + .filter((incident) => incident.visible) + .map((incident) => ({ + kind: 'incident' as const, + name: incident.name, + status: incident.status, + impact: incident.current_worst_impact, + affected_components: formatAffectedComponents(incident.affected_components), + last_update_message: incident.last_update_message ?? null, + url: incident.url, + })) + + const maintenances = data.in_progress_maintenances + .filter((maintenance) => maintenance.visible) + .map((maintenance) => ({ + kind: 'maintenance' as const, + name: maintenance.name, + status: maintenance.status, + affected_components: formatAffectedComponents(maintenance.affected_components), + last_update_message: maintenance.last_update_message ?? null, + url: maintenance.url, + started_at: maintenance.started_at, + })) + + const combined = [...incidents, ...maintenances] + + if (combined.length === 0) { return { incidents: [], message: NO_ACTIVE_INCIDENTS_MESSAGE, } } - const incidentSummaries = incidents.map((incident) => ({ - name: incident.name, - status: incident.status, - impact: incident.impact, - active_since: incident.active_since, - })) + const statusPageUrl = data.page_url || DEFAULT_STATUS_PAGE_URL return { - incidents: incidentSummaries, - message: buildActiveIncidentsMessage(incidents.length, 0, DEFAULT_STATUS_PAGE_URL), + incidents: combined, + message: buildActiveIncidentsMessage( + incidents.length, + maintenances.length, + statusPageUrl + ), } - } catch (error) { - console.warn('Failed to fetch incident status:', error) - return INCIDENT_STATUS_ERROR + } + + const response = await fetch(`${baseUrl}/api/incident-status`, { + signal: timeoutSignal(abortSignal), + }) + + if (!response.ok) { + throw new Error(`Failed to fetch incident status: ${response.status}`) + } + + const incidents: IncidentInfo[] = await response.json() + + if (incidents.length === 0) { + return { + incidents: [], + message: NO_ACTIVE_INCIDENTS_MESSAGE, + } + } + + const incidentSummaries = incidents.map((incident) => ({ + name: incident.name, + status: incident.status, + impact: incident.impact, + active_since: incident.active_since, + })) + + return { + incidents: incidentSummaries, + message: buildActiveIncidentsMessage(incidents.length, 0, DEFAULT_STATUS_PAGE_URL), } }, }), diff --git a/apps/studio/lib/helpers.test.ts b/apps/studio/lib/helpers.test.ts index cd867453c81..c800524d7c2 100644 --- a/apps/studio/lib/helpers.test.ts +++ b/apps/studio/lib/helpers.test.ts @@ -10,6 +10,7 @@ import { formatBytes, formatCurrency, formatRestoreWindow, + getBasePathURL, getDatabaseMajorVersion, getDistanceLatLonKM, getSemanticVersion, @@ -107,6 +108,33 @@ describe('getURL', () => { }) }) +describe('getBasePathURL', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + + it('appends the base path to the site URL', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://supabase.com') + expect(getBasePathURL('/dashboard')).toEqual('https://supabase.com/dashboard') + }) + + it('does not double the base path on the fallback URL', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', '') + vi.stubEnv('NEXT_PUBLIC_VERCEL_BRANCH_URL', '') + expect(getBasePathURL('/dashboard')).toEqual('https://supabase.com/dashboard') + }) + + it('strips a trailing slash before appending the base path', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'https://supabase.com/') + expect(getBasePathURL('/dashboard')).toEqual('https://supabase.com/dashboard') + }) + + it('returns the site URL when there is no base path', () => { + vi.stubEnv('NEXT_PUBLIC_SITE_URL', 'http://localhost:8082') + expect(getBasePathURL('')).toEqual('http://localhost:8082') + }) +}) + describe('makeRandomString', () => { it('should return a random string of the given length', () => { const result = makeRandomString(10) diff --git a/apps/studio/lib/helpers.ts b/apps/studio/lib/helpers.ts index 2206be48975..bf6c58c60b7 100644 --- a/apps/studio/lib/helpers.ts +++ b/apps/studio/lib/helpers.ts @@ -2,6 +2,7 @@ import { UIEvent } from 'react' import { v4 as _uuidV4 } from 'uuid' import type { TablesData } from '../data/tables/tables-query' +import { BASE_PATH } from './constants' export const uuidv4 = () => { return _uuidV4() @@ -73,6 +74,16 @@ export const getURL = () => { return url.includes('http') ? url : `https://${url}` } +/** + * Adds the base path to `getURL()` when it's missing. + * `https://supabase.com` → `https://supabase.com/dashboard` + * `https://supabase.com/dashboard` → unchanged + */ +export const getBasePathURL = (basePath = BASE_PATH) => { + const url = getURL().replace(/\/$/, '') + return url.endsWith(basePath) ? url : `${url}${basePath}` +} + /** * Generates a random string using alpha characters */ diff --git a/apps/studio/pages/api/ai/sql/generate-v4.ts b/apps/studio/pages/api/ai/sql/generate-v4.ts index f0e4fe3998e..67209870b15 100644 --- a/apps/studio/pages/api/ai/sql/generate-v4.ts +++ b/apps/studio/pages/api/ai/sql/generate-v4.ts @@ -30,7 +30,7 @@ import { getTools } from '@/lib/ai/tools' import { encodeNotebookToolError } from '@/lib/ai/tools/notebook-tools' import { apiWrapper } from '@/lib/api/apiWrapper' import { executeQuery } from '@/lib/api/self-hosted/query' -import { getURL } from '@/lib/helpers' +import { getBasePathURL } from '@/lib/helpers' import { isServerFlagEnabled, trustedUserEmail } from '@/lib/server/configcat' export const maxDuration = 300 @@ -192,7 +192,7 @@ async function handlePost(req: NextApiRequest, res: NextApiResponse, claims?: Jw authorization, aiOptInLevel, accessToken, - baseUrl: getURL(), + baseUrl: getBasePathURL(), supportMode, isExplorerEnabled: explorerEnabled, useStatusPageWidget, From 0f453c89fa0b07d926f85d0b3911a6b2dcd47fe5 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Mon, 5 Oct 2026 16:56:05 +0300 Subject: [PATCH 11/41] docs: add @supabase/middleware install step to server frameworks guide (#51266) The framework bridges in the `@supabase/server` reference import `@supabase/middleware` directly, but the guide never said to install it. `@supabase/server` depends on it, so npm and yarn users get it through hoisting, while pnpm users hit "Cannot find module '@supabase/middleware'" the moment they copy a bridge. This PR adds the direct-dependency note to the Versions admonition, an `npm install @supabase/middleware` line to the copy-the-bridge step, and the same instruction to the agent migration prompt. --- apps/docs/spec/reference/server/v1/partials/frameworks.mdx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/docs/spec/reference/server/v1/partials/frameworks.mdx b/apps/docs/spec/reference/server/v1/partials/frameworks.mdx index 9c1bd872b76..a6e4401b2c4 100644 --- a/apps/docs/spec/reference/server/v1/partials/frameworks.mdx +++ b/apps/docs/spec/reference/server/v1/partials/frameworks.mdx @@ -11,6 +11,8 @@ The framework adapters (`@supabase/server/adapters/*`) predate the middleware en The bridges need `@supabase/server` 1.6.0 or later and Node 22 or later. `withRequiredClaims` shipped in 1.5.1 and the entry form of `withSupabase` in 1.6.0. Releases before 1.5.1 have no `/middleware/*` exports. +The bridge file imports `@supabase/middleware`, so add it to your project as a direct dependency. `@supabase/server` depends on it, but a strict package manager such as pnpm does not let your code import a transitive dependency. + ### What a bridge gives you @@ -458,6 +460,7 @@ Do the steps in order. The auth inventory comes before any code change, because ```bash + npm install @supabase/middleware mkdir -p src/lib curl --fail -o src/lib/supabase-middleware.ts \ https://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.ts @@ -614,7 +617,8 @@ contributes jwtClaims: null and falls through, turning a 401 endpoint into a auth: 'user'. Never compose withClaims() and withRequiredClaims() together; they share the jwtClaims key and that is a compile-time conflict. -STEP 3: FETCH THE BRIDGE. Download +STEP 3: FETCH THE BRIDGE. Add @supabase/middleware as a direct dependency of +the project; the bridge imports it. Then download https://raw.githubusercontent.com/supabase/server/main/examples/frameworks/hono/supabase-middleware.ts and save it as src/lib/supabase-middleware.ts, verbatim. Do not "improve", condense, or drop comments from it. From b14a577f9d23608ebffeb89cab5e5e8c16326849 Mon Sep 17 00:00:00 2001 From: Kody Jackson Date: Mon, 5 Oct 2026 09:42:07 -0500 Subject: [PATCH 12/41] fix(docs) - remove temp redirect that should've been removed previously (#51200) ## Problem The `pg_partman` extension was temporarily redirected in #30149, but then never un-redirected. This guide became live again in https://github.com/supabase/supabase/pull/40037/changes#diff-4db130f9070f72e3e0a3f5794a6088230e28977fe743d79af767adb18327c8ab. (#40037) --- apps/www/lib/redirects.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index a7460cba810..a8a7159eafe 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -2498,11 +2498,6 @@ module.exports = [ source: '/docs/guides/database/extensions/pgrepack', destination: '/docs/guides/database/extensions/pg_repack', }, - { - permanent: false, - source: '/docs/guides/database/extensions/pg_partman', - destination: '/docs/guides/database/extensions', - }, { permanent: true, source: '/docs/guides/ai/structured-unstructured-embeddings', From d7f422eb7e339c5b6aacf927c78fc754a676a164 Mon Sep 17 00:00:00 2001 From: Kody Jackson Date: Mon, 5 Oct 2026 09:42:41 -0500 Subject: [PATCH 13/41] fix(www) - remove unnecessary docs redirect for serverless drivers page (#51197) ## Problem The [content](https://github.com/supabase/supabase/blob/master/apps/docs/content/guides/database/connecting-to-postgres/serverless-drivers.mdx) about serverless drivers can't be accessed b/c it's covered by a historical redirect (but still exists in the navigation menu). ## Solution Remove the redirect (tagging @czenko b/c I see you're closer to here contextually and you'd be able to confirm this is correct after your work in #49869). --- apps/www/lib/redirects.js | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index a8a7159eafe..ba04c799a64 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -3312,11 +3312,6 @@ module.exports = [ source: '/docs/guides/resources/examples', destination: '/docs/guides/getting-started', }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres/serverless-drivers', - destination: '/docs/guides/database/connecting-to-postgres', - }, { permanent: true, source: '/docs/guides/monitoring-troubleshooting/troubleshooting', From c4221ef689ef76644242f3e9c036660adce5b276 Mon Sep 17 00:00:00 2001 From: Kody Jackson Date: Mon, 5 Oct 2026 09:45:26 -0500 Subject: [PATCH 14/41] fix(www) - redirects are never evaluated for anchor fragments (#51198) ## Problem Fragments are never evaluated by the server, only paths. ## Solution Removes non-functioning redirects to reduce clutter + potential routes to evaluate. ## Preview links N/A, since these redirects are within the docs project but the redirect logic is attached to the www project (this setup makes redirects difficult to test and preview). ## Steps to test Try out any of the removed redirects in the live site today, they won't work. --- apps/www/lib/redirects.js | 169 -------------------------------------- 1 file changed, 169 deletions(-) diff --git a/apps/www/lib/redirects.js b/apps/www/lib/redirects.js index ba04c799a64..64e1267a2ec 100644 --- a/apps/www/lib/redirects.js +++ b/apps/www/lib/redirects.js @@ -1478,16 +1478,6 @@ module.exports = [ source: '/docs/guides/realtime/overview', destination: '/docs/guides/realtime', }, - { - permanent: true, - source: '/docs/guides/realtime#broadcast', - destination: '/docs/guides/realtime/broadcast', - }, - { - permanent: true, - source: '/docs/guides/realtime#presence', - destination: '/docs/guides/realtime/presence', - }, { permanent: true, source: '/docs/guides/realtime/postgres-cdc', @@ -1752,16 +1742,6 @@ module.exports = [ source: '/docs/guides/storage-cdn', destination: '/docs/guides/storage/cdn', }, - { - permanent: true, - source: '/docs/guides/functions/auth#understanding-authorization-headers', - destination: '/docs/guides/functions/auth-headers', - }, - { - permanent: true, - source: '/docs/guides/functions/auth#the-verify_jwt-platform-check', - destination: '/docs/guides/functions/auth-headers', - }, { permanent: true, source: '/docs/guides/functions/examples', @@ -1802,75 +1782,6 @@ module.exports = [ source: '/project/:path+', destination: 'https://supabase.com/dashboard/project/:path+', }, - // Reorganizing pooler docs:----------------------------- - - //external libraries - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#integrations', - destination: '/docs/guides/database/connecting-to-postgres#quickstart-connection-guides', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-drizzle', - destination: '/docs/guides/database/drizzle', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-prisma', - destination: '/docs/guides/database/prisma', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-postgresjs', - destination: '/docs/guides/database/postgres-js', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-pgadmin', - destination: '/docs/guides/database/pgadmin', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connecting-with-psql', - destination: '/docs/guides/database/psql', - }, - - // pooling - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#connection-pooler', - destination: '/docs/guides/database/connecting-to-postgres#connection-pooling-in-depth', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#troubleshooting-supavisor', - destination: '/docs/guides/database/supavisor', - }, - - //IPv4/IPv6 - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#finding-your-database-hostname', - destination: '/docs/guides/platform/ipv4-address#finding-your-databases-ip-address', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#managing-your-ip-address', - destination: '/docs/guides/platform/ipv4-address#supabase-and-ipv6-compatibility', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#ipv6-address', - destination: '/docs/guides/platform/ipv4-address#supabase-and-ipv6-compatibility', - }, - { - permanent: true, - source: '/docs/guides/database/connecting-to-postgres#ipv4-address', - destination: '/docs/guides/platform/ipv4-address', - }, - //-------------------------------------------------------- - // START docs 2.0, moving pages in to structure { permanent: true, @@ -3362,61 +3273,6 @@ module.exports = [ source: '/docs/guides/platform/org-based-billing', destination: '/docs/guides/platform/billing-on-supabase', }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#how-billing-is-organized', - destination: '/docs/guides/platform/billing-on-supabase#organization-based-billing', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#quotas-and-features', - destination: '/docs/guides/platform/billing-on-supabase#variable-usage-fees-and-quotas', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#usage-items', - destination: '/docs/guides/platform/billing-on-supabase#variable-usage-fees-and-quotas', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#billing-for-compute-compute-hours', - destination: '/docs/guides/platform/manage-your-usage/compute', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#compute-pricing', - destination: '/docs/guides/platform/manage-your-usage/compute#pricing', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#compute-credits', - destination: '/docs/guides/platform/manage-your-usage/compute#compute-credits', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#read-replicas', - destination: '/docs/guides/platform/manage-your-usage/read-replicas', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#project-add-ons', - destination: '/docs/guides/platform/manage-your-usage', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#unified-egress', - destination: '/docs/guides/platform/manage-your-usage/egress', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#included-egress-quota', - destination: '/docs/guides/platform/manage-your-usage/egress#pricing', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#egress-dashboards', - destination: '/docs/guides/platform/manage-your-usage/egress#usage-page', - }, { permanent: true, source: '/docs/guides/platform/manage-your-usage/log-ingest', @@ -3427,31 +3283,6 @@ module.exports = [ source: '/docs/guides/platform/manage-your-usage/log-query', destination: '/docs/guides/platform/manage-your-usage/logs-query', }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#disk-size', - destination: '/docs/guides/platform/manage-your-usage/disk-size', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#free-plan', - destination: '/docs/guides/platform/billing-on-supabase#free-plan', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#billing-examples', - destination: '/docs/guides/platform/billing-on-supabase', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#multiple-projects-in-a-free-plan-organization', - destination: '/docs/guides/platform/billing-on-supabase', - }, - { - permanent: false, - source: '/docs/guides/platform/org-based-billing#multiple-projects-in-a-pro-plan-organization', - destination: '/docs/guides/platform/billing-on-supabase', - }, { permanent: false, source: '/docs/guides/platform/spend-cap', From b02890813626695eb154df7e77fd8d112443b0ec Mon Sep 17 00:00:00 2001 From: "kemal.earth" <606977+kemaldotearth@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:42:17 +0100 Subject: [PATCH 15/41] feat(studio): add never option to scoped pat expiry (#51273) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem When building scoped pat's we had omitted the option to have them never expire. ## Solution This re-adds the option to select "never" and it comes with the caveat of an admonition to warn the user that they would need to manually delete or revoke this token. ## Review instructions Provide a clear numbered procedure that the PR reviewer can walk through. 1. Open /account/tokens 2. Click Generate new token. 3. Open Expires in. Confirm "Never" is the last option, after "Custom", and has no Recommended badge. 4. Select Never. A warning admonition appears directly below the expiry row: "This token never expires — Anyone with the token keeps access until you delete it." 5. Pick an org and project, grant one permission, click Review access. Summary shows Expires: Never. 6. Create the token. The POST body has no expires_at, and the new row's Expires column reads Never. Fixes FE-4527. Co-authored-by: Ali Waseem --- .../Form/NewScopedTokenForm.utils.test.ts | 18 +++++++- .../Scoped/Form/NewScopedTokenForm.utils.ts | 10 ++++- .../AccessTokens/Scoped/Form/TokenDetails.tsx | 11 +++++ .../Scoped/NewScopedTokenSheet.test.tsx | 43 +++++++++++++++++++ 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.test.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.test.ts index ec481a840bb..ce500e6e2eb 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.test.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.test.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs' import { describe, expect, test } from 'vitest' -import { TokenFormSchema } from './NewScopedTokenForm.utils' +import { getExpiryDate, TokenFormSchema } from './NewScopedTokenForm.utils' const BASE_VALUES = { tokenName: 'test', @@ -35,3 +35,19 @@ describe('TokenFormSchema custom expiry', () => { ) }) }) + +describe('TokenFormSchema never expiry', () => { + test('accepts the never preset without a date', () => { + const result = TokenFormSchema.safeParse({ ...BASE_VALUES, expiresAt: 'never' }) + expect(result.success).toBe(true) + }) +}) + +describe('getExpiryDate', () => { + test('returns no date for the never preset', () => { + expect(getExpiryDate('never')).toBeUndefined() + }) + test('returns no date for the custom preset', () => { + expect(getExpiryDate('custom')).toBeUndefined() + }) +}) diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts index 1ef60b1b2f5..6aa9589f18a 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/NewScopedTokenForm.utils.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import type { PermissionMode } from '../../AccessToken.permissions' import { getMaxCustomExpiryDate } from '../../AccessToken.utils' -export const EXPIRY_PRESETS = ['24h', '7d', '30d', '90d', 'custom'] as const +export const EXPIRY_PRESETS = ['24h', '7d', '30d', '90d', 'custom', 'never'] as const export type ExpiryPreset = (typeof EXPIRY_PRESETS)[number] export const DEFAULT_EXPIRY: ExpiryPreset = '7d' @@ -21,9 +21,13 @@ export const EXPIRY_OPTIONS: ExpiryOption[] = [ { value: '30d', label: '30 days' }, { value: '90d', label: '90 days' }, { value: 'custom', label: 'Custom' }, + { value: 'never', label: 'Never' }, ] -/** Resolves the final ISO expiry date for a preset, or `undefined` when the caller supplies a custom date. */ +/** + * Resolves the final ISO expiry date for a preset. Returns `undefined` for `custom`, where the + * caller supplies the date, and for `never`, where the token is created without an expiry. + */ export const getExpiryDate = (preset: ExpiryPreset): string | undefined => { switch (preset) { case '24h': @@ -34,6 +38,8 @@ export const getExpiryDate = (preset: ExpiryPreset): string | undefined => { return dayjs().add(30, 'days').toISOString() case '90d': return dayjs().add(90, 'days').toISOString() + case 'never': + return undefined default: return undefined } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx index 48d2897c301..ff25c9b8de8 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/Form/TokenDetails.tsx @@ -14,6 +14,7 @@ import { SelectValue, useWatch, } from 'ui' +import { Admonition } from 'ui-patterns/Admonition' import { DatePicker, DatePickerButton, @@ -35,6 +36,7 @@ interface TokenDetailsProps { } export const TokenDetails = ({ control, setValue }: TokenDetailsProps) => { + const expiresAt = useWatch({ control, name: 'expiresAt' }) const customExpiryDate = useWatch({ control, name: 'customExpiryDate' }) const maxExpiryDate = getMaxCustomExpiryDate().toDate() @@ -136,6 +138,15 @@ export const TokenDetails = ({ control, setValue }: TokenDetailsProps) => { )} /> + + {expiresAt === 'never' && ( + + )} ) } diff --git a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx index 3f40605cffe..cc15bd2c105 100644 --- a/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx +++ b/apps/studio/components/interfaces/Account/AccessTokens/Scoped/NewScopedTokenSheet.test.tsx @@ -255,6 +255,49 @@ describe('NewScopedTokenSheet', () => { ) }, 10_000) + test('creates a token without an expiry when the Never preset is selected', async () => { + let createBody: Record | undefined + addAPIMock({ + method: 'post', + path: '/platform/profile/scoped-access-tokens', + response: async ({ request }) => { + createBody = (await request.json()) as Record + return HttpResponse.json({ + created_at: '', + expires_at: null, + id: 'plop', + last_used_at: null, + name: 'test', + token: 'a_token_value', + token_alias: '', + permissions: [], + }) + }, + }) + renderSheet() + fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) + await screen.findByRole('dialog') + await user.type(await screen.findByLabelText('Name'), 'test') + fireEvent.click(await screen.findByRole('combobox', { name: 'Expires in' })) + fireEvent.click(await screen.findByRole('option', { name: 'Never' })) + fireEvent.click(await screen.findByRole('combobox', { name: 'Organization' })) + fireEvent.click(await screen.findByRole('option', { name: 'Acme Production' })) + fireEvent.click(await screen.findByRole('combobox', { name: 'Projects' })) + fireEvent.click(await screen.findByRole('option', { name: 'Project 1' })) + await expandPermissionCategory('Project') + fireEvent.click(await screen.findByLabelText('Project Settings', { exact: false })) + fireEvent.click(await screen.findByRole('option', { name: 'Read' })) + fireEvent.click(await screen.findByRole('button', { name: 'Review access' })) + await screen.findByText('Never') + fireEvent.click(await screen.findByRole('button', { name: 'Create token' })) + await waitFor(() => expect(createBody).toBeDefined()) + expect(createBody?.expires_at).toBeUndefined() + expect(mockTrack).toHaveBeenCalledWith( + 'access_token_created', + expect.objectContaining({ expiryPreset: 'never' }) + ) + }, 10_000) + test('tracks dismissal with the in-progress resourceAccess and touched state on Cancel', async () => { renderSheet() fireEvent.click(await screen.findByRole('button', { name: 'Generate new token' })) From 7d04c430824d3d7630ee396a7216e27428bb55fb Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:43:15 +0200 Subject: [PATCH 16/41] fix(logs): default unified logs to otel DEBUG-228 (#51089) ## Problem Unified Logs could start legacy BigQuery requests while ConfigCat loaded, then switch to OTEL when the flag resolved. ## Fix Default Unified Logs to OTEL unless otelUnifiedLogs is explicitly false. Use that selection for list, count, chart, facet, detail, download, and manual refresh requests. Keep BigQuery as an explicit opt-out until the legacy backend is removed. ## Validation - Studio typecheck passed locally. - Existing Unified Logs utility tests passed (21 tests). - The focused Unified Logs query test file was removed as requested; backend-selection and manual-refresh regressions are no longer covered by that suite. - CI checks are running on the current head. Tracks [DEBUG-228](https://linear.app/supabase/issue/DEBUG-228/prevent-bq-queries-before-the-feature-flag-loads). --- apps/studio/data/logs/get-unified-logs.ts | 5 ++--- .../data/logs/unified-log-inspection-query.ts | 4 ++-- apps/studio/data/logs/unified-logs-chart-query.ts | 9 ++++++--- apps/studio/data/logs/unified-logs-count-query.ts | 4 ++-- .../data/logs/unified-logs-facet-count-query.ts | 4 ++-- .../data/logs/unified-logs-infinite-query.ts | 14 +++++++++----- apps/studio/routes/__root.tsx | 2 +- 7 files changed, 24 insertions(+), 18 deletions(-) diff --git a/apps/studio/data/logs/get-unified-logs.ts b/apps/studio/data/logs/get-unified-logs.ts index fbcc0c4dc71..f9030b2b966 100644 --- a/apps/studio/data/logs/get-unified-logs.ts +++ b/apps/studio/data/logs/get-unified-logs.ts @@ -1,9 +1,8 @@ import { useMutation } from '@tanstack/react-query' -import { useFlag } from 'common' import { toast } from 'sonner' import { logsAllEndpointUrl, pickLogsQueryBuilder } from './logs-endpoint' -import { getUnifiedLogsISOStartEnd } from './unified-logs-infinite-query' +import { getUnifiedLogsISOStartEnd, useUnifiedLogsBackend } from './unified-logs-infinite-query' import { mapUnifiedLogRow, parseUnifiedLogsQueryRows } from './unified-logs.utils' import { getUnifiedLogsQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { getUnifiedLogsQuery as getUnifiedLogsQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq' @@ -58,7 +57,7 @@ export const useGetUnifiedLogsMutation = ({ UseCustomMutationOptions, 'mutationFn' > = {}) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useMutation({ mutationFn: (vars) => retrieveUnifiedLogs({ ...vars, useOtel: vars.useOtel ?? useOtel }), async onSuccess(data, variables, context) { diff --git a/apps/studio/data/logs/unified-log-inspection-query.ts b/apps/studio/data/logs/unified-log-inspection-query.ts index 216a173fab2..43894fbe7d1 100644 --- a/apps/studio/data/logs/unified-log-inspection-query.ts +++ b/apps/studio/data/logs/unified-log-inspection-query.ts @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query' -import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' @@ -12,6 +11,7 @@ import { analyticsLiteral as lit, safeSql, type SafeLogSqlFragment } from './saf import { getUnifiedLogsISOStartEnd, UNIFIED_LOGS_QUERY_OPTIONS, + useUnifiedLogsBackend, } from './unified-logs-infinite-query' import { getAuthServiceFlowQuery, @@ -301,7 +301,7 @@ export const useUnifiedLogInspectionQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = !!useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useQuery({ queryKey: [...logsKeys.serviceFlow(projectRef, search, logId), { otel: useOtel }], queryFn: ({ signal }) => diff --git a/apps/studio/data/logs/unified-logs-chart-query.ts b/apps/studio/data/logs/unified-logs-chart-query.ts index 6a9315153d1..2af1561f304 100644 --- a/apps/studio/data/logs/unified-logs-chart-query.ts +++ b/apps/studio/data/logs/unified-logs-chart-query.ts @@ -1,10 +1,13 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query' -import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' import { logsAllEndpointUrl, pickLogsQueryBuilder } from './logs-endpoint' -import { UNIFIED_LOGS_QUERY_OPTIONS, UnifiedLogsVariables } from './unified-logs-infinite-query' +import { + UNIFIED_LOGS_QUERY_OPTIONS, + UnifiedLogsVariables, + useUnifiedLogsBackend, +} from './unified-logs-infinite-query' import { parseLogsFilterUrlParams } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.filters' import { getLogsChartQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { getLogsChartQuery as getLogsChartQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq' @@ -163,7 +166,7 @@ export const useUnifiedLogsChartQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useQuery({ queryKey: [...logsKeys.unifiedLogsChart(projectRef, search), { otel: useOtel }], queryFn: ({ signal }) => getUnifiedLogsChart({ projectRef, search, useOtel }, signal), diff --git a/apps/studio/data/logs/unified-logs-count-query.ts b/apps/studio/data/logs/unified-logs-count-query.ts index 0a49dfbadd6..69fae9f7817 100644 --- a/apps/studio/data/logs/unified-logs-count-query.ts +++ b/apps/studio/data/logs/unified-logs-count-query.ts @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query' -import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' @@ -8,6 +7,7 @@ import { getUnifiedLogsISOStartEnd, UNIFIED_LOGS_QUERY_OPTIONS, UnifiedLogsVariables, + useUnifiedLogsBackend, } from './unified-logs-infinite-query' import { getLogsCountQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { getLogsCountQuery as getLogsCountQueryBq } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries.bq' @@ -90,7 +90,7 @@ export const useUnifiedLogsCountQuery = ( ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useQuery({ queryKey: [...logsKeys.unifiedLogsCount(projectRef, search), { otel: useOtel }], queryFn: ({ signal }) => getUnifiedLogsCount({ projectRef, search, useOtel }, signal), diff --git a/apps/studio/data/logs/unified-logs-facet-count-query.ts b/apps/studio/data/logs/unified-logs-facet-count-query.ts index ef26390917e..43dde999327 100644 --- a/apps/studio/data/logs/unified-logs-facet-count-query.ts +++ b/apps/studio/data/logs/unified-logs-facet-count-query.ts @@ -1,5 +1,4 @@ import { useQuery } from '@tanstack/react-query' -import { useFlag } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' @@ -9,6 +8,7 @@ import { getUnifiedLogsISOStartEnd, UNIFIED_LOGS_QUERY_OPTIONS, UnifiedLogsVariables, + useUnifiedLogsBackend, } from './unified-logs-infinite-query' import { getFacetCountQuery } from '@/components/interfaces/UnifiedLogs/UnifiedLogs.queries' import { @@ -65,7 +65,7 @@ export const useUnifiedLogsFacetCountQuery = ...options }: UseCustomQueryOptions = {} ) => { - const useOtel = !!useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useQuery({ queryKey: [ ...logsKeys.unifiedLogsFacetCount(projectRef, facet, facetSearch, search), diff --git a/apps/studio/data/logs/unified-logs-infinite-query.ts b/apps/studio/data/logs/unified-logs-infinite-query.ts index cf564c9c81f..86f3c68b8cd 100644 --- a/apps/studio/data/logs/unified-logs-infinite-query.ts +++ b/apps/studio/data/logs/unified-logs-infinite-query.ts @@ -1,5 +1,5 @@ import { InfiniteData, keepPreviousData, useInfiniteQuery } from '@tanstack/react-query' -import { useFlag } from 'common' +import { useFeatureFlags } from 'common' import { executeAnalyticsSql } from './execute-analytics-sql' import { logsKeys } from './keys' @@ -29,6 +29,11 @@ export type UnifiedLogsData = any export type UnifiedLogsError = ResponseError export type UnifiedLogsVariables = { projectRef?: string; search: QuerySearchParamsType } +export const useUnifiedLogsBackend = () => { + const { otelUnifiedLogs = true } = useFeatureFlags().configcat + return typeof otelUnifiedLogs === 'boolean' ? otelUnifiedLogs : true +} + export const getUnifiedLogsISOStartEnd = ( search: QuerySearchParamsType, endHoursFromNow: number = 1 @@ -152,12 +157,11 @@ export const useUnifiedLogsInfiniteQuery = ( PageParam | null > = {} ) => { - const useOtel = useFlag('otelUnifiedLogs') + const useOtel = useUnifiedLogsBackend() return useInfiniteQuery({ queryKey: [...logsKeys.unifiedLogsInfinite(projectRef, search), { otel: useOtel }], - queryFn: ({ signal, pageParam }) => { - return getUnifiedLogs({ projectRef, search, pageParam, useOtel }, signal) - }, + queryFn: ({ signal, pageParam }) => + getUnifiedLogs({ projectRef, search, pageParam, useOtel }, signal), enabled: enabled && typeof projectRef !== 'undefined', placeholderData: keepPreviousData, getPreviousPageParam: (firstPage) => { diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx index de414f0b2b0..a7abe08003a 100644 --- a/apps/studio/routes/__root.tsx +++ b/apps/studio/routes/__root.tsx @@ -341,7 +341,7 @@ export const Route = createRootRouteWithContext()({ maintenanceMode: IS_MAINTENANCE_MODE, hash: location.hash, }) - if (!match) return + if (!match) return undefined // `to`/`search`/`hash`, never `href`: the router treats `href` as an // opaque (external) target, and preloading a Link whose beforeLoad // throws `redirect({ href })` recurses forever — the preload retry From d7cac841c4f594b36134d69d2745a085f4e68f1f Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Mon, 5 Oct 2026 19:08:24 +0300 Subject: [PATCH 17/41] docs: state the adapter removal date in the server frameworks guide (#51276) The server frameworks guide still said the adapters "will be deprecated soon" and would be "removed in a future major". They have been deprecated since `@supabase/server` 1.9.0, with `@deprecated` tags shipping in 1.9.1, and the removal date is December 1, 2026. This PR updates the two sentences to state that date and drops the wording about the release shape, which is not decided. --- apps/docs/spec/reference/server/v1/partials/frameworks.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/spec/reference/server/v1/partials/frameworks.mdx b/apps/docs/spec/reference/server/v1/partials/frameworks.mdx index a6e4401b2c4..3e29ea0d870 100644 --- a/apps/docs/spec/reference/server/v1/partials/frameworks.mdx +++ b/apps/docs/spec/reference/server/v1/partials/frameworks.mdx @@ -5,7 +5,7 @@ title: Frameworks `@supabase/server` middleware runs inside Hono, H3, Elysia, NestJS, and TanStack Start through a bridge: one file you copy into your project. The bridge runs a `@supabase/middleware` entry array in the framework's own middleware slot. Your route handlers then read `supabase`, `jwtClaims`, and any other key the entries contribute from the place your framework keeps per-request state. -The framework adapters (`@supabase/server/adapters/*`) predate the middleware engine. We are moving off them, and they will be deprecated soon. The bridges on this page replace them. If you use an adapter today, read "Moving off the adapters" below before you change anything. One step in that migration can open an endpoint up without an error. +The framework adapters (`@supabase/server/adapters/*`) predate the middleware engine. They are deprecated and will be removed on December 1, 2026. The bridges on this page replace them. If you use an adapter today, read "Moving off the adapters" below before you change anything. One step in that migration can open an endpoint up without an error. @@ -270,7 +270,7 @@ The bridge files live in the server repository under [`examples/frameworks`](htt Each adapter solved the same problem in a framework-specific way: build a `SupabaseContext` and stash it where the handler can reach it. That meant a published entry point, a peer dependency range, and a release cycle per framework, all to wrap one call. The engine replaced the model. A middleware is now an entry, a `(handler) => handler` wrapper over the Web `Request` and `Response` pair, and `withSupabaseClient()`, `withSupabaseAdminClient()`, `withRequiredClaims()`, and the rest are ordinary entries you compose. What remains is the bridge, and it belongs in your project, not in a package that has to track your framework's major versions. -The adapters still work in v1 and will be deprecated soon. They will be removed in a future major. `withSupabase(config, handler)` from `@supabase/server` is not part of this change. +The adapters are deprecated. They still work today and will be removed on December 1, 2026. `withSupabase(config, handler)` from `@supabase/server` is not part of this change. Three changes come with the migration. The first can open an endpoint up. From 42dc4dbe90658192d9c272dac96bff7da549943d Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Mon, 5 Oct 2026 13:22:12 -0300 Subject: [PATCH 18/41] chore: Add observability to search_v2 route (#51149) ## Problem Our new Search V2 edge function can fail, we want to know when that happens. ## Solution Added the common Sentry wrapping plus try/catch strategy to the edge function so we can add alerts to our Sentry dashboards and report channels. ## Review instructions There's no way to reproduce this for the moment. ## Checklist Check all before review: - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which references [WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md) and the docs [CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide ## Summary by CodeRabbit * **Bug Fixes** * Search requests that encounter unexpected server-side errors now receive a handled 500 response instead of an unhandled failure. Errors returned by the search service also produce a 500 response, making failure behavior more consistent for clients. The search query and result-limit behavior remain unchanged. --- apps/docs/app/api/search_v2/route.ts | 48 ++++++++++++++++++---------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/docs/app/api/search_v2/route.ts b/apps/docs/app/api/search_v2/route.ts index 94030052ef7..9d08c899782 100644 --- a/apps/docs/app/api/search_v2/route.ts +++ b/apps/docs/app/api/search_v2/route.ts @@ -1,3 +1,4 @@ +import * as Sentry from '@sentry/nextjs' import { supabaseSearchV2 } from '~/scripts/search_v2/client' import { type NextRequest } from 'next/server' @@ -10,23 +11,36 @@ export async function OPTIONS() { } export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url) - const query = searchParams.get('q')?.trim() - const limit = Number(searchParams.get('limit')) || 10 + try { + const { searchParams } = new URL(request.url) + const query = searchParams.get('q')?.trim() + const limit = Number(searchParams.get('limit')) || 10 - if (!query) { - return Response.json({ error: 'Missing q parameter' }, { status: 400, headers: corsHeaders }) + if (!query) { + return Response.json({ error: 'Missing q parameter' }, { status: 400, headers: corsHeaders }) + } + + const { data, error } = await supabaseSearchV2().rpc('search_docs', { + query_text: query, + match_limit: limit, + }) + + if (error) { + console.error('Error running docs search v2:', error) + Sentry.captureException(new Error(error.message), { + tags: { route: 'search_v2' }, + extra: { query, limit, error }, + }) + return Response.json({ error: error.message }, { status: 500, headers: corsHeaders }) + } + + return Response.json(data, { headers: corsHeaders }) + } catch (error) { + console.error('Error handling docs search v2 request:', error) + Sentry.captureException(error, { tags: { route: 'search_v2' } }) + return Response.json( + { error: 'There was an error processing your request' }, + { status: 500, headers: corsHeaders } + ) } - - const { data, error } = await supabaseSearchV2().rpc('search_docs', { - query_text: query, - match_limit: limit, - }) - - if (error) { - console.error('Error running docs search v2:', error) - return Response.json({ error: error.message }, { status: 500, headers: corsHeaders }) - } - - return Response.json(data, { headers: corsHeaders }) } From 53ecbf9f2ab644339050ff5a127aa266b358c806 Mon Sep 17 00:00:00 2001 From: Jeremias Menichelli Date: Mon, 5 Oct 2026 13:22:22 -0300 Subject: [PATCH 19/41] chore: Add telemetry to search v2 user actions (#51146) ## Problem We need to collect data from search v2 experiment usage. ## Solution Add telemetry to search v2 modal being opened, closing, sending a query and clicking a search v2 result. ## Review instructions 1. Open the preview link and add the search v2 flag query: `?docs-search-v2=search-v2-active` 2. Trigger all actions mentioned above 3. Telemetry data should be sent on the network tab ## Checklist Check all before review: - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which references [WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md) and the docs [CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide ## Summary by CodeRabbit * **Improvements** * Documentation search activity is now recorded when the dialog opens from the keyboard shortcut or search input, and when it closes. Search submissions include the query and whether results were found; selected results include their destination and the highlighted query. This adds visibility into key search interactions without changing how search results or highlighting work. --- .../docs/features/SearchV2/SearchV2Dialog.tsx | 12 +++- .../features/SearchV2/SearchV2Trigger.tsx | 30 +++++++-- packages/common/telemetry-constants.ts | 66 +++++++++++++++++++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/apps/docs/features/SearchV2/SearchV2Dialog.tsx b/apps/docs/features/SearchV2/SearchV2Dialog.tsx index ee12c97675a..d475a107065 100644 --- a/apps/docs/features/SearchV2/SearchV2Dialog.tsx +++ b/apps/docs/features/SearchV2/SearchV2Dialog.tsx @@ -19,6 +19,7 @@ import { } from 'ui' import { formatHeadingPath, highlightMatches } from './SearchV2.utils' +import { useSendTelemetryEvent } from '@/lib/telemetry' interface SearchV2DialogProps { open: boolean @@ -27,6 +28,7 @@ interface SearchV2DialogProps { export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { const router = useRouter() + const sendTelemetryEvent = useSendTelemetryEvent() const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2() const [highlightQuery, setHighlightQuery] = useState('') @@ -40,10 +42,14 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { useEffect(() => { if (searchState.status === 'results' || searchState.status === 'noResults') { setHighlightQuery(searchState.query) + sendTelemetryEvent({ + action: 'docs_search_v2_search_submitted', + properties: { query: searchState.query }, + }) } else if (searchState.status === 'initial') { setHighlightQuery('') } - }, [searchState]) + }, [searchState, sendTelemetryEvent]) const results: DocsSearchV2Result[] = 'results' in searchState @@ -61,6 +67,10 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { } function handleSelect(path: string) { + sendTelemetryEvent({ + action: 'docs_search_v2_result_clicked', + properties: { resultPath: path, query: highlightQuery }, + }) router.push(path) onOpenChange(false) } diff --git a/apps/docs/features/SearchV2/SearchV2Trigger.tsx b/apps/docs/features/SearchV2/SearchV2Trigger.tsx index ae91f54d1f8..21d9c73cda2 100644 --- a/apps/docs/features/SearchV2/SearchV2Trigger.tsx +++ b/apps/docs/features/SearchV2/SearchV2Trigger.tsx @@ -1,10 +1,11 @@ 'use client' import { Search } from 'lucide-react' -import { useEffect, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useState, type ReactNode } from 'react' import { cn, KeyboardShortcut } from 'ui' import { SearchV2Dialog } from './SearchV2Dialog' +import { useSendTelemetryEvent } from '@/lib/telemetry' interface SearchV2TriggerProps { className?: string @@ -13,6 +14,17 @@ interface SearchV2TriggerProps { export function SearchV2Trigger({ className, placeholder = 'Search...' }: SearchV2TriggerProps) { const [open, setOpen] = useState(false) + const sendTelemetryEvent = useSendTelemetryEvent() + + const handleOpenChange = useCallback( + (next: boolean) => { + setOpen(next) + if (!next) { + sendTelemetryEvent({ action: 'docs_search_v2_closed' }) + } + }, + [sendTelemetryEvent] + ) useEffect(() => { function openOnKeyDown(event: globalThis.KeyboardEvent) { @@ -25,6 +37,10 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search event.preventDefault() setOpen(true) + sendTelemetryEvent({ + action: 'docs_search_v2_opened', + properties: { triggerType: 'keyboard_shortcut' }, + }) } } @@ -33,7 +49,7 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search return () => { window.removeEventListener('keydown', openOnKeyDown, { capture: true }) } - }, []) + }, [sendTelemetryEvent]) return ( <> @@ -42,7 +58,13 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search tabIndex={0} aria-haspopup="dialog" aria-expanded={open} - onClick={() => setOpen(true)} + onClick={() => { + setOpen(true) + sendTelemetryEvent({ + action: 'docs_search_v2_opened', + properties: { triggerType: 'search_input' }, + }) + }} className={cn( 'group cursor-pointer', 'grow md:min-w-44 xl:min-w-56 h-[30px] rounded-md', @@ -69,7 +91,7 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search className="hidden md:inline-flex border border-default bg-surface-300 text-foreground-lighter shadow-xs shadow-background-surface-100" /> - + ) } diff --git a/packages/common/telemetry-constants.ts b/packages/common/telemetry-constants.ts index c1ce60515b3..7dd44aeec0e 100644 --- a/packages/common/telemetry-constants.ts +++ b/packages/common/telemetry-constants.ts @@ -1045,6 +1045,68 @@ export interface DocsContentListingClickedEvent { } } +/** + * User opened the Search V2 dialog. + * + * @group Events + * @source docs + */ +export interface DocsSearchV2OpenedEvent { + action: 'docs_search_v2_opened' + properties: { + /** + * The trigger that opened the Search V2 dialog. + */ + triggerType: 'keyboard_shortcut' | 'search_input' + } +} + +/** + * User's search term was sent to the Search V2 endpoint. + * + * @group Events + * @source docs + */ +export interface DocsSearchV2SearchSubmittedEvent { + action: 'docs_search_v2_search_submitted' + properties: { + /** + * The search term sent to the Search V2 endpoint. + */ + query: string + } +} + +/** + * User activated a Search V2 result, either by clicking it or selecting it via keyboard. + * + * @group Events + * @source docs + */ +export interface DocsSearchV2ResultClickedEvent { + action: 'docs_search_v2_result_clicked' + properties: { + /** + * The path of the result that was activated. + */ + resultPath: string + /** + * The search term whose results were showing when the result was activated. + */ + query: string + } +} + +/** + * User closed the Search V2 dialog. + * + * @group Events + * @source docs + */ +export interface DocsSearchV2ClosedEvent { + action: 'docs_search_v2_closed' +} + /** * User clicked a recommended page card shown on a docs "not found" page. * @@ -4090,6 +4152,10 @@ export type TelemetryEvent = | AskAiClickedEvent | DocsAiPromptCopiedEvent | DocsContentListingClickedEvent + | DocsSearchV2OpenedEvent + | DocsSearchV2SearchSubmittedEvent + | DocsSearchV2ResultClickedEvent + | DocsSearchV2ClosedEvent | Docs404RecommendationClickedEvent | DocsProjectConfigVariablesCopyButtonClickedEvent | HomepageFrameworkQuickstartClickedEvent From a5688423d03f0b23891bdfe3fbe8a4128fae964a Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Mon, 5 Oct 2026 11:22:13 -0700 Subject: [PATCH 20/41] docs(cli): restructure and tighten the CLI getting started guide (#50736) Closes DOCS-1320 Was the bottom of a two-PR stack. The commit from #50680 moved here, so that PR is closed and this one carries both changes. ## Problem The CLI getting started guide had accumulated structural and prose problems, none of which change what the page claims: - **Nine flat H2 headings**, with Beta channel and Updating the Supabase CLI sitting between installing and running. A first-time reader crossed about 150 lines of beta and upgrade tabs before reaching `supabase init`. - **The introduction opened with a two-step procedure under no heading**, listing `init` and `start` before the CLI is installed. Its first sentence named the tool and where it runs rather than what the reader gets. - **Running a local Supabase project ran concept, fact, procedure, and process together** as one stretch of prose, so the two commands the reader has to run sat in paragraphs between the Docker background and the first-run note. - **Task headings mixed gerunds with imperatives:** Installing, Running, Stopping, and Updating next to Access and Manage. - **No navigation.** A long guide that mixes information types opened straight into commands, with no outline of its major groups. The sidebar contents is not a substitute: it isn't part of the document, and the generated markdown an agent reads has no sidebar at all. - **Four more sequences were prose.** Installing via npm, installing a Linux package, the pre-upgrade backup, and opting out of telemetry each had to be followed in order with nothing marking the order. - **Smaller things:** the Studio screenshot's alt text named the topic its heading already states, two links used "note above" and "here" as their text, and an admonition restated where `sb_publishable_...` comes from. ## Solution Twelve commits, one change type each, plus a master merge and its fixup. - **Style.** The install-method callout drops from four blocks to two paragraphs and uses the documented `title` prop. Active voice on the Postgres, analytics, and telemetry instructions. Descriptive link text. Alt text that describes the Studio screenshot rather than naming it. Cut the admonition restating `sb_publishable_...`. - **Structure.** Beta channel and Updating the Supabase CLI move out of the getting started path. - **Grouping.** Local setup goes under Set up a local project, updating and beta builds under Change your CLI version. The intro's `init` and `start` list gets a Quickstart heading. - **Value statement.** The opening sentence now says what the reader gets. - **Procedure format.** Running a local Supabase project leads with the container runtime prerequisite, then four numbered actions, then the first-run download as an outcome. Starting the container runtime is its own step, since the old prose only assumed it with "with a container runtime running". - **Imperative headings.** Install, Run, Access, Stop, Update, Use the beta channel. - **Four more procedures.** npm install, Linux packages, the pre-upgrade backup, and telemetry opt-out. The three pre-upgrade commands were one unexplained block inside an admonition, so each step now says what its command does. Re-enabling telemetry moves to a sentence, since it's the reverse action rather than a step. - **Intro navigation** listing the major groups, each line saying what the reader gets from it. - **Connect to a hosted project** (from #50680). A new section between Stop local services and Change your CLI version, saying the stack runs only on the reader's machine and nothing reaches a hosted project until they sign in and link one, then pointing at the page that owns the procedure. No commands. The `init` step gains a sentence saying it creates local files only, and the value statement and intro navigation cover the added goal. - **Style guide and word list fixes** from an audit of the page against `CONTRIBUTING.md` and `WORD_LIST.md`. `directory` over `folder` in command-line contexts, `might` over `may`, present tense over `will`, a noun after `this`, no `above` as a pointer, concrete verbs over `manage`, no time-relative `latest`, no parentheses for supplementary information, and an impact-first `caution` on the pre-upgrade callout. The container runtime list becomes a table of tool and platforms, and the group heading becomes Change your CLI version. ## Preview links | Site | Live | Preview | Search for | | ---- | ---- | ------- | ---------- | | Docs | [/docs/guides/local-development/cli/getting-started](https://supabase.com/docs/guides/local-development/cli/getting-started) | [/docs/guides/local-development/cli/getting-started](https://docs-git-docs-cli-getting-started-edits-supabase.vercel.app/docs/guides/local-development/cli/getting-started) | Change your CLI version, Connect to a hosted project | ## Manual testing 1. Open the docs preview link above. 2. Read the introduction. It opens with a value statement, then links the four major groups. Under Quickstart, the install-method callout explains how the install method changes the command you run. 3. Read the On this page list. It nests: Quickstart, Set up a local project with four sections under it, Change your CLI version with two sections under it, Telemetry, Learn more. 4. Check Run a local Supabase project renders four numbered steps, with code blocks inside steps 3 and 4. Check the npm tab of Install the Supabase CLI renders three, and How to opt out renders two. 5. Load the page at `#installing-the-supabase-cli`, `#beta-channel`, and `#updating-the-supabase-cli`. All three land on their sections despite the renamed headings. 6. Load the legacy path `/docs/guides/cli/getting-started#updating-the-supabase-cli`. It redirects to the current path and keeps the fragment. 7. Check the introduction's group list includes Connect to a hosted project, and that the section appears in the On this page list between Stop local services and Change your CLI version. 8. Check that section is two sentences and a pointer, with no commands. 9. In Run a local Supabase project, select the "Connect to a hosted project" link in step 3. The page scrolls to that section. 10. Follow both outbound links from the new section and confirm they resolve, including the `#configure-github-actions` fragment. ## Summary by CodeRabbit * **Documentation** * Reorganized the local development guide with a clearer quickstart, setup steps, service access instructions, and CLI version guidance. * Expanded installation examples to include bun and clarified package-runner commands. * Clarified upgrade, backup, container cleanup, and telemetry instructions. * Added a reference to the Microsoft Writing Style Guide. --- .../local-development/cli/getting-started.mdx | 448 ++++++++++-------- apps/docs/style-guide/WORD_LIST.md | 4 + 2 files changed, 246 insertions(+), 206 deletions(-) diff --git a/apps/docs/content/guides/local-development/cli/getting-started.mdx b/apps/docs/content/guides/local-development/cli/getting-started.mdx index 81f7b228c27..70b24e7c33d 100644 --- a/apps/docs/content/guides/local-development/cli/getting-started.mdx +++ b/apps/docs/content/guides/local-development/cli/getting-started.mdx @@ -4,25 +4,34 @@ description: 'The Supabase CLI provides tools to develop your project locally, d subtitle: 'Develop locally, deploy to the Supabase Platform, and set up CI/CD workflows' --- -The Supabase CLI enables you to run the entire Supabase stack locally, on your machine or in a CI environment. With two commands, you can set up and start a new local project: +The Supabase CLI runs the entire Supabase stack on your own machine or in a CI environment, so you can build and test locally, then connect your project to a hosted one when you're ready to deploy. -1. `supabase init` to create a new local project -2. `supabase start` to launch the Supabase services +- [Quickstart](#quickstart) is the two-command version, if you already have the CLI and a container runtime. +- [Set up a local project](#set-up-a-local-project) installs the CLI and brings the stack up on your machine. Start here if you haven't run it before. +- [Connect to a hosted project](#connect-to-a-hosted-project) links your local directory to a project on the Supabase Platform. +- [Change your CLI version](#change-your-cli-version) updates the CLI and switches between stable and pre-release builds. +- [Telemetry](#telemetry) covers what the CLI collects and how to opt out. - +## Quickstart -There are two ways to install the CLI, and they change the command you type: +With two commands, you can set up and start a new local project: -- **Project dependency** with `npm`, `pnpm`, or `yarn` installs the CLI into a single project (there is no global `supabase` command with this method). Run it through your package runner instead, for example `npx supabase `. -- **Global install** with Homebrew, Scoop, or Linux packages. Run commands as `supabase `. +1. Run `supabase init` to create a new local project. +2. Run `supabase start` to launch the Supabase services. -Either way, the CLI is **project-scoped**: most commands (including `start`) expect to run inside a directory that has been initialized with `supabase init`, which creates the `supabase/` folder and `config.toml`. Run `init` first, then the other commands from the same directory. + -The rest of this page writes examples as `supabase `; translate them to `npx supabase ` if you installed the CLI as a project dependency. +How you run a CLI command depends on how you install it. Homebrew, Scoop, and the Linux packages give you a global `supabase` command. Installing with npm, pnpm, yarn, or bun adds the CLI to one project instead, so you run it through your package runner as `npx supabase `. This page writes every example as `supabase `. + +The CLI is project-scoped either way. Most commands, including `start`, expect a directory that `supabase init` has already set up with a `supabase` directory and a `config.toml`. Run `init` first, then the rest from the same directory. -## Installing the Supabase CLI +## Set up a local project + +Install the CLI, bring the Supabase stack up on your machine, and stop it when you're done. + +### Install the Supabase CLI [#installing-the-supabase-cli] `; translate them to > -Install the CLI as a project dev dependency. This adds it to a single project rather than installing a global command: +1. Install the CLI as a project dev dependency. This command adds the CLI to a single project rather than installing a global command: -```sh -npm install supabase --save-dev -# or: pnpm add -D supabase / yarn add -D supabase / bun add -D supabase -``` + ```sh + npm install supabase --save-dev + # or: pnpm add -D supabase / yarn add -D supabase / bun add -D supabase + ``` -Pin the version in `package.json` so your whole team uses the same CLI version. Then run every command through your package runner: +2. Pin the version in `package.json` so your whole team uses the same CLI version. -```sh -npx supabase --help -# or: pnpm supabase / yarn supabase / bunx supabase -``` +3. Run the CLI through your package runner: + + ```sh + npx supabase --help + # or: pnpm supabase / yarn supabase / bunx supabase + ``` @@ -86,19 +97,193 @@ brew install supabase/tap/supabase #### Linux packages Linux packages are provided in [Releases](https://github.com/supabase/cli/releases). -To install, download the `.apk`/`.deb`/`.rpm` file depending on your package manager -and run one of the following: -- `sudo apk add --allow-untrusted <...>.apk` -- `sudo dpkg -i <...>.deb` -- `sudo rpm -i <...>.rpm` +1. Download the `.apk`, `.deb`, or `.rpm` file for your package manager. +2. Install the package: + - `sudo apk add --allow-untrusted <...>.apk` + - `sudo dpkg -i <...>.deb` + - `sudo rpm -i <...>.rpm` -## Beta channel +### Run a local Supabase project [#running-a-local-supabase-project] -Pre-release CLI builds ship from the development branch (`X.Y.Z-beta.N` versions). Use the npm `beta` dist-tag, or install `supabase-beta` via Homebrew / Scoop (separate packages from stable). +The most common thing you'll do with the CLI is run the full Supabase stack (Postgres, Auth, Storage, and the rest) on your own machine. That stack runs in Docker containers, so you need a container runtime installed first. On Windows and Linux, follow the official guide to install and configure [Docker Desktop](https://docs.docker.com/desktop). + +On macOS, we recommend [OrbStack](https://orbstack.dev/) instead of Docker Desktop. It's a drop-in replacement that handles extended file attributes (xattrs) on mounted volumes and container networking more reliably than Docker Desktop. It also starts faster and uses less CPU, memory, and disk, which makes a noticeable difference when running the full Supabase stack. + +Alternatively, you can use a different container tool that offers Docker-compatible APIs: + +| Tool | Platforms | +| --------------------------------------------- | --------------------- | +| [Rancher Desktop](https://rancherdesktop.io/) | macOS, Windows, Linux | +| [Podman](https://podman.io/) | macOS, Windows, Linux | +| [colima](https://github.com/abiosoft/colima) | macOS | + +To bring the stack up: + +1. Start your container runtime. + +2. Open a terminal in the directory where you want to create your project. + +3. Initialize the project: + + ```bash + supabase init + ``` + + The command creates a `supabase` directory, which is safe to commit to version control. `init` creates local files only: it doesn't sign you in or connect the directory to a project on the Supabase Platform. To connect one, see [Connect to a hosted project](#connect-to-a-hosted-project). + +4. Start the Supabase services from the same directory: + + ```bash + supabase start + ``` + + + +If you installed the CLI as a project dependency with npm, pnpm, yarn, or bun, run `npx supabase init` and `npx supabase start` instead. See [Install the Supabase CLI](#installing-the-supabase-cli). + + + +The first run takes time while the CLI downloads the Docker images. It pulls the entire Supabase stack, plus a few extra images useful for local development, such as a local SMTP server and a database diff tool. + +### Access your project's services + +After all the Supabase services are running, the CLI prints your local credentials. The output looks like this, with the URLs and keys you use in your local project: + +``` +Started supabase local development setup. + +╭──────────────────────────────────────╮ +│ 🔧 Development Tools │ +├─────────┬────────────────────────────┤ +│ Studio │ http://127.0.0.1:54323 │ +│ Mailpit │ http://127.0.0.1:54324 │ +│ MCP │ http://127.0.0.1:54321/mcp │ +╰─────────┴────────────────────────────╯ + +╭──────────────────────────────────────────────────────╮ +│ 🌐 APIs │ +├────────────────┬─────────────────────────────────────┤ +│ Project URL │ http://127.0.0.1:54321 │ +│ REST │ http://127.0.0.1:54321/rest/v1 │ +│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │ +│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │ +╰────────────────┴─────────────────────────────────────╯ + +╭───────────────────────────────────────────────────────────────╮ +│ ⛁ Database │ +├─────┬─────────────────────────────────────────────────────────┤ +│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │ +╰─────┴─────────────────────────────────────────────────────────╯ + +╭──────────────────────────────────────────────────────────────╮ +│ 🔑 Authentication Keys │ +├─────────────┬────────────────────────────────────────────────┤ +│ Publishable │ sb_publishable_... │ +│ Secret │ sb_secret_... │ +╰─────────────┴────────────────────────────────────────────────╯ +``` + + + + +```sh +# Default URL: +http://localhost:54323 +``` + +The local development environment includes Supabase Studio, a graphical interface for querying and editing your database. + +![Local Supabase Studio showing the Default Project home page, with a sidebar of section icons, a Client libraries row for JavaScript, Python, and Flutter, and a grid of example project cards.](/docs/img/guides/cli/local-studio.png) + + + + +```sh +# Default URL: +postgresql://postgres:postgres@localhost:54322/postgres +``` + +Access the local Postgres instance with [`psql`](https://www.postgresql.org/docs/current/app-psql.html) or any other Postgres client, such as [pgAdmin](https://www.pgadmin.org/). For example: + +```bash +psql 'postgresql://postgres:postgres@localhost:54322/postgres' +``` + + + +To access the database from an edge function in your local Supabase setup, replace `localhost` with `host.docker.internal`. + + + + + + +```sh +# Default URL: +http://localhost:54321 +``` + +If you are accessing these services without the client libraries, you might need to pass the client keys as an `Authorization` header. Learn more about [JWT headers](/docs/learn/auth-deep-dive/auth-deep-dive-jwts). + +```sh +curl 'http://localhost:54321/rest/v1/' \ + -H "apikey: sb_publishable_..." + +http://localhost:54321/rest/v1/ # REST (PostgREST) +http://localhost:54321/realtime/v1/ # Realtime +http://localhost:54321/storage/v1/ # Storage +http://localhost:54321/auth/v1/ # Auth (GoTrue) +``` + + + + +Local logs rely on the Supabase Analytics Server, which reads the Docker logging driver. On Linux and macOS, mount the `/var/run/docker.sock` domain socket. On Windows, expose the `tcp://localhost:2375` daemon socket. Configure the socket manually after [installing the CLI](#installing-the-supabase-cli). + + + +For advanced logs analysis using the Logs Explorer, use the BigQuery backend instead of the default Postgres backend. See [Using the BigQuery backend](/docs/reference/self-hosting-analytics/introduction#using-the-bigquery-backend). + + + +All logs are stored in the local database under the `_analytics` schema. + + + + +### Stop local services [#stopping-local-services] + +When you finish working, stop the stack. Stopping doesn't reset your local database: + +```bash +supabase stop +``` + +With the default ports, `supabase start` runs one local project per machine, because every project's `config.toml` uses the same ports. To run several local projects or git worktrees at the same time, see [Running multiple local projects](/docs/guides/local-development/running-multiple-local-projects). + +## Connect to a hosted project + +The stack you started in [Run a local Supabase project](#running-a-local-supabase-project) runs only on your machine. Nothing reaches a hosted project until you sign in to the CLI and link your local directory to one. + +For the steps, see [Pushing to a remote project](/docs/guides/local-development/cli-workflows#pushing-to-a-remote-project). To run deployments from CI, see [Configure GitHub Actions](/docs/guides/deployment/managing-environments#configure-github-actions). + +## Change your CLI version + +These sections cover the CLI itself rather than your local project. + +### Use the beta channel [#beta-channel] + +Pre-release CLI builds ship from the development branch and are versioned `X.Y.Z-beta.N`. Use the npm `beta` dist-tag, or install `supabase-beta` with Homebrew or Scoop. `supabase-beta` is a separate package from `supabase`. -## Updating the Supabase CLI +### Update the Supabase CLI [#updating-the-supabase-cli] -When a new [version](https://github.com/supabase/cli/releases) is released, you can update the CLI using the same channels. +Update the CLI with the same package manager you installed it with. See the [CLI releases page](https://github.com/supabase/cli/releases) for available versions. .apk` - `sudo dpkg -i <...>.deb` @@ -231,181 +416,31 @@ brew upgrade supabase-beta -If you have any Supabase containers running locally, stop them and delete their data volumes before proceeding with the upgrade. This ensures that Supabase managed services can apply new migrations on a clean state of the local database. +If you have any Supabase containers running locally, stop them and delete their data volumes before upgrading. Deleting the volumes lets Supabase managed services apply new migrations on a clean local database. - + -Remember to save any local schema and data changes before stopping because the `--no-backup` flag will delete them. - -```sh -supabase db diff -f my_schema -supabase db dump --local --data-only > supabase/seed.sql -supabase stop --no-backup -``` +`supabase stop --no-backup` deletes your local schema and data changes. Save them first. -## Running a local Supabase project +1. Save local schema changes as a migration: -The most common thing you'll do with the CLI is run the full Supabase stack (Postgres, Auth, Storage, and the rest) on your own machine. That stack runs in Docker containers, so you need a container runtime installed first. On Windows and Linux, follow the official guide to install and configure [Docker Desktop](https://docs.docker.com/desktop). + ```sh + supabase db diff -f my_schema + ``` -On macOS, we recommend [OrbStack](https://orbstack.dev/) instead of Docker Desktop. It's a drop-in replacement that handles extended file attributes (xattrs) on mounted volumes and container networking more reliably than Docker Desktop. It also starts faster and uses less CPU, memory, and disk, which makes a noticeable difference when running the full Supabase stack. +2. Dump local data to your seed file: -Alternately, you can use a different container tool that offers Docker compatible APIs. + ```sh + supabase db dump --local --data-only > supabase/seed.sql + ``` -- [Rancher Desktop](https://rancherdesktop.io/) (macOS, Windows, Linux) -- [Podman](https://podman.io/) (macOS, Windows, Linux) -- [colima](https://github.com/abiosoft/colima) (macOS) +3. Stop the containers and delete their data volumes: -With a container runtime running, go to the folder where you want to create your project and initialize it: - -```bash -supabase init -``` - -This creates a new `supabase` folder. It's safe to commit this folder to version control. - -Now, from the same folder, start the Supabase stack: - -```bash -supabase start -``` - - - -If you installed the CLI as a project dependency (npm, pnpm, yarn, or bun), run these as `npx supabase init` and `npx supabase start` instead. See the [note above](#installing-the-supabase-cli). - - - -This takes time on your first run because the CLI needs to download the Docker images to your local machine. The CLI includes the entire Supabase stack, and a few additional images useful for local development (like a local SMTP server and a database diff tool). - -## Access your project's services - -Once all the Supabase services are running, you'll see output containing your local Supabase credentials. It should look like the below, with urls and keys that you use in your local project: - -``` -Started supabase local development setup. - -╭──────────────────────────────────────╮ -│ 🔧 Development Tools │ -├─────────┬────────────────────────────┤ -│ Studio │ http://127.0.0.1:54323 │ -│ Mailpit │ http://127.0.0.1:54324 │ -│ MCP │ http://127.0.0.1:54321/mcp │ -╰─────────┴────────────────────────────╯ - -╭──────────────────────────────────────────────────────╮ -│ 🌐 APIs │ -├────────────────┬─────────────────────────────────────┤ -│ Project URL │ http://127.0.0.1:54321 │ -│ REST │ http://127.0.0.1:54321/rest/v1 │ -│ GraphQL │ http://127.0.0.1:54321/graphql/v1 │ -│ Edge Functions │ http://127.0.0.1:54321/functions/v1 │ -╰────────────────┴─────────────────────────────────────╯ - -╭───────────────────────────────────────────────────────────────╮ -│ ⛁ Database │ -├─────┬─────────────────────────────────────────────────────────┤ -│ URL │ postgresql://postgres:postgres@127.0.0.1:54322/postgres │ -╰─────┴─────────────────────────────────────────────────────────╯ - -╭──────────────────────────────────────────────────────────────╮ -│ 🔑 Authentication Keys │ -├─────────────┬────────────────────────────────────────────────┤ -│ Publishable │ sb_publishable_... │ -│ Secret │ sb_secret_... │ -╰─────────────┴────────────────────────────────────────────────╯ -``` - - - - -```sh -# Default URL: -http://localhost:54323 -``` - -The local development environment includes Supabase Studio, a graphical interface for working with your database. - -![Local Studio](/docs/img/guides/cli/local-studio.png) - - - - -```sh -# Default URL: -postgresql://postgres:postgres@localhost:54322/postgres -``` - -The local Postgres instance can be accessed through [`psql`](https://www.postgresql.org/docs/current/app-psql.html) or any other Postgres client, such as [pgAdmin](https://www.pgadmin.org/). For example: - -```bash -psql 'postgresql://postgres:postgres@localhost:54322/postgres' -``` - - - -To access the database from an edge function in your local Supabase setup, replace `localhost` with `host.docker.internal`. - - - - - - -```sh -# Default URL: -http://localhost:54321 -``` - -If you are accessing these services without the client libraries, you may need to pass the client keys as an `Authorization` header. Learn more about [JWT headers](/docs/learn/auth-deep-dive/auth-deep-dive-jwts). - -```sh -curl 'http://localhost:54321/rest/v1/' \ - -H "apikey: sb_publishable_..." - -http://localhost:54321/rest/v1/ # REST (PostgREST) -http://localhost:54321/realtime/v1/ # Realtime -http://localhost:54321/storage/v1/ # Storage -http://localhost:54321/auth/v1/ # Auth (GoTrue) -``` - - - -`sb_publishable_...` is the publishable key output when you run the command `supabase start`. - - - - - - -Local logs rely on the Supabase Analytics Server which accesses the docker logging driver by either volume mounting `/var/run/docker.sock` domain socket on Linux and macOS, or exposing `tcp://localhost:2375` daemon socket on Windows. These settings must be configured manually after [installing](/docs/guides/local-development/cli/getting-started#installing-the-supabase-cli) the Supabase CLI. - - - -For advanced logs analysis using the Logs Explorer, it is advised to use the BigQuery backend instead of the default Postgres backend. Read about the steps [here](/docs/reference/self-hosting-analytics/introduction#using-the-bigquery-backend). - - - -All logs are stored in the local database under the `_analytics` schema. - - - - -## Stopping local services - -When you are finished working on your Supabase project, you can stop the stack (without resetting your local database): - -```bash -supabase stop -``` - -With the default ports, `supabase start` runs one local project per machine, because every project's `config.toml` uses the same ports. To run several local projects or git worktrees at the same time, see [Running multiple local projects](/docs/guides/local-development/running-multiple-local-projects). + ```sh + supabase stop --no-backup + ``` ## Telemetry @@ -413,18 +448,19 @@ The Supabase CLI collects telemetry data about general usage. Participating in t ### How to opt out -You can disable telemetry by running: +1. Disable telemetry: -```bash -supabase telemetry disable -``` + ```bash + supabase telemetry disable + ``` -You can check the current status and re-enable with: +2. Confirm the current setting: -```bash -supabase telemetry status -supabase telemetry enable -``` + ```bash + supabase telemetry status + ``` + +To enable telemetry again, run `supabase telemetry enable`. You can also opt out using the `SUPABASE_TELEMETRY_DISABLED=1` environment variable. The broader `DO_NOT_TRACK=1` convention is also respected. diff --git a/apps/docs/style-guide/WORD_LIST.md b/apps/docs/style-guide/WORD_LIST.md index 61470ca93bc..050a71180fb 100644 --- a/apps/docs/style-guide/WORD_LIST.md +++ b/apps/docs/style-guide/WORD_LIST.md @@ -982,6 +982,10 @@ Don't use these phrases: Don't use _tl;dr_, _ymmv_, _rtfm_, _imo_, or _fwiw_. Write out the meaning or remove the aside. +## Further reference + +- [Microsoft Writing Style Guide](https://learn.microsoft.com/style-guide/welcome/) + ## Attribution Portions of this word list are modifications based on work created and shared by From 09a245d72de97bf72443cd5f878a849673103516 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Mon, 5 Oct 2026 12:05:21 -0700 Subject: [PATCH 21/41] [FE-4520] fix(studio): hide Realtime setup for published tables (#51152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Realtime Inspector now checks the Realtime publication before showing setup guidance. Projects with published tables get the join-channel view even before this Inspector session receives any messages; unconfigured projects keep the setup guide. Setup guidance also stays hidden while publications are loading or unavailable. Joining a channel and displaying received messages retain their existing behavior. Addresses [FE-4520](https://linear.app/supabase/issue/FE-4520/realtime-inspector-ui-implies-i-am-not-using-realtime-despite-already). ## To test - With no tables in `supabase_realtime`, open Realtime → Inspector and confirm the setup guide appears. - Enable Realtime on a table and confirm a client receives a database change. Open the Inspector without joining a channel: it should show “Join a channel to start listening to messages” and no setup guide. - Join a channel, trigger a table change, and check the event and payload appear. Stop listening and confirm messages remain visible. - Open Policies, then return to the Inspector and confirm the setup guide stays hidden for the configured project. - Join a broadcast-only channel with no incoming messages and confirm the messages view appears immediately. ## Validation Reproduced the original prompt locally with a working Realtime table, then verified the fix in the browser, including an independent client subscription, a live INSERT in the Inspector, navigation, stopping, and the unconfigured state. Temporary test data and services were cleaned up. All nine new regression tests pass; four fail against the original code. Typecheck, formatting, lint ratchet, Knip, and the case-sensitivity check pass. The full Studio suite passed 7,799 tests with one unrelated Explorer test failure; that test passed on a focused rerun alongside the Inspector tests. ## Summary by CodeRabbit * **Bug Fixes** * The Realtime inspector keeps the messages view visible while publication status is loading or unavailable, and when a channel is joined or messages are present. * Setup guidance appears only after publications load successfully and confirm that Realtime is unavailable, with no channel or messages to show. This includes cases where there are no publications, the Realtime publication has no tables, or only a differently named publication exists. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> Co-authored-by: Joshen Lim Co-authored-by: Claude Sonnet 5 --- .../Realtime/Inspector/MessagesTable.tsx | 2 +- .../Inspector/RealtimeInspector.test.tsx | 182 ++++++++++++++++++ .../interfaces/Realtime/Inspector/index.tsx | 10 +- e2e/studio/utils/realtime-helpers.ts | 4 +- 4 files changed, 191 insertions(+), 7 deletions(-) create mode 100644 apps/studio/components/interfaces/Realtime/Inspector/RealtimeInspector.test.tsx diff --git a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx index 555a8dc749f..1c30f3cec67 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/MessagesTable.tsx @@ -184,7 +184,7 @@ const MessagesTable = ({
({ logData: [] as LogData[] })) + +vi.mock('./useRealtimeMessages', () => ({ + useRealtimeMessages: () => ({ logData: realtime.logData, sendMessage: vi.fn() }), +})) +vi.mock('./useRealtimeInspectorShortcuts', () => ({ useRealtimeInspectorShortcuts: vi.fn() })) +vi.mock('@/lib/telemetry/track', () => ({ useTrack: () => vi.fn() })) +vi.mock('./Header', () => ({ + Header: ({ + config, + onChangeConfig, + }: { + config: RealtimeConfig + onChangeConfig: (config: RealtimeConfig) => void + }) => ( + + ), +})) +vi.mock('./MessagesTable', () => ({ default: () =>
Inspector messages
})) +vi.mock('./EmptyRealtime', () => ({ + EmptyRealtime: () => , +})) +vi.mock('./SendMessageModal', () => ({ SendMessageModal: () => null })) + +const PROJECT: ProjectDetail = { + cloud_provider: 'AWS', + connectionString: 'postgresql://postgres@localhost:5432/postgres', + db_host: 'db.default.supabase.co', + high_availability: false, + id: 1, + inserted_at: '2026-01-01T00:00:00.000Z', + integration_source: null, + is_branch_enabled: false, + is_hibernating: false, + is_physical_backups_enabled: false, + name: 'Test project', + organization_id: 1, + ref: 'default', + region: 'us-east-1', + restUrl: 'https://default.supabase.co/rest/v1', + status: 'ACTIVE_HEALTHY', + subscription_id: 'subscription-1', + updated_at: '2026-01-01T00:00:00.000Z', +} + +const publication = ( + tables: PGPublication['tables'], + name = 'supabase_realtime' +): PGPublication => ({ + id: 1, + name, + owner: 'postgres', + publish_insert: true, + publish_update: true, + publish_delete: true, + publish_truncate: true, + tables, +}) + +function mockPublications(publications: PGPublication[]) { + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => HttpResponse.json(publications), + }) +} + +function renderInspector() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + customRender(, { queryClient }) + return queryClient +} + +async function waitForPublications(queryClient: QueryClient, status = 'success') { + await waitFor(() => + expect(queryClient.getQueryState(databasePublicationsKeys.list('default'))?.status).toBe(status) + ) +} + +beforeEach(() => { + realtime.logData = [] + addAPIMock({ method: 'get', path: '/platform/projects/:ref', response: PROJECT }) +}) + +describe('Realtime Inspector setup guidance', () => { + test.each([ + ['a published table', [publication([{ id: 1, name: 'books', schema: 'public' }])]], + ['all tables published', [publication(null)]], + ])('does not show setup guidance with %s and no messages or channel', async (_, publications) => { + mockPublications(publications) + await waitForPublications(renderInspector()) + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) + + test.each([ + ['no publications', []], + ['an empty Realtime publication', [publication([])]], + ['a different publication', [publication(null, 'other_publication')]], + ])('retains setup guidance with %s', async (_, publications) => { + mockPublications(publications) + renderInspector() + + expect( + await screen.findByRole('button', { name: 'Set up realtime for me' }) + ).toBeInTheDocument() + expect(screen.queryByText('Inspector messages')).not.toBeInTheDocument() + }) + + test('does not show setup guidance while publications are loading', async () => { + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: async () => { + await delay('infinite') + return HttpResponse.json([]) + }, + }) + const queryClient = renderInspector() + await waitFor(() => + expect(queryClient.getQueryState(databasePublicationsKeys.list('default'))?.fetchStatus).toBe( + 'fetching' + ) + ) + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) + + test('does not imply Realtime is unconfigured when publications fail to load', async () => { + addAPIMock({ + method: 'post', + path: '/platform/pg-meta/:ref/query', + response: () => HttpResponse.json({ message: 'Unavailable' }, { status: 500 }), + }) + await waitForPublications(renderInspector(), 'error') + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) + + test('shows messages immediately after joining a broadcast-only channel', async () => { + mockPublications([publication([])]) + renderInspector() + await screen.findByRole('button', { name: 'Set up realtime for me' }) + + await userEvent.click(screen.getByRole('button', { name: 'Join a channel' })) + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) + + test('retains received messages without a channel or published tables', async () => { + realtime.logData = [{ id: '1', timestamp: 1, message: 'BROADCAST' }] + mockPublications([]) + await waitForPublications(renderInspector()) + + expect(screen.getByText('Inspector messages')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Set up realtime for me' })).not.toBeInTheDocument() + }) +}) diff --git a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx index 08af2260f63..ab2b33a43a0 100644 --- a/apps/studio/components/interfaces/Realtime/Inspector/index.tsx +++ b/apps/studio/components/interfaces/Realtime/Inspector/index.tsx @@ -19,7 +19,7 @@ export const RealtimeInspector = () => { const { data: project } = useSelectedProjectQuery() // Check if realtime publications are available - const { data: publications } = useDatabasePublicationsQuery({ + const { data: publications, isSuccess: isPublicationsSuccess } = useDatabasePublicationsQuery({ projectRef: project?.ref, connectionString: project?.connectionString, }) @@ -55,10 +55,10 @@ export const RealtimeInspector = () => { const hasChannel = realtimeConfig.channelName.length > 0 const isListening = realtimeConfig.enabled - // Once a channel is set, MessagesTable renders its own empty states (including - // the "Broadcast a message" entry point), so sending doesn't depend on a - // message having arrived first. EmptyRealtime is only the pre-channel onboarding. - const showMessagesTable = hasChannel || (logData ?? []).length > 0 + // Only show setup guidance once publications confirm that Postgres Changes + // is not configured. An empty Inspector session does not imply Realtime is unused. + const showMessagesTable = + !isPublicationsSuccess || isRealtimeAvailable || hasChannel || (logData ?? []).length > 0 const handleJoinChannel = useCallback(() => { if (!hasChannel) { diff --git a/e2e/studio/utils/realtime-helpers.ts b/e2e/studio/utils/realtime-helpers.ts index d58dba5397f..20e381f00d4 100644 --- a/e2e/studio/utils/realtime-helpers.ts +++ b/e2e/studio/utils/realtime-helpers.ts @@ -3,7 +3,9 @@ import { toUrl } from './to-url.js' export async function navigateToRealtimeInspector(page: Page, ref: string) { await page.goto(toUrl(`/project/${ref}/realtime/inspector`)) - await expect(page.locator('text=Join a channel')).toBeVisible({ timeout: 30000 }) + await expect(page.getByRole('button', { name: 'Join a channel' })).toBeVisible({ + timeout: 30000, + }) } export async function joinChannel(page: Page, channelName: string) { From 47fd296fc1ef2ad586509190ef8bac11264ec240 Mon Sep 17 00:00:00 2001 From: Wendie Cheung <49030266+wendeh@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:23:52 -0700 Subject: [PATCH 22/41] Add Flick Games case study (#51285) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem New customer case study for the Case Studies content track: Flick Games, an indie UK games studio running Art of Solitaire and Goalman on Supabase. ## Solution Adds `apps/www/_customers/flick-games.mdx` plus the logo (on-light/on-dark) and quote-avatar assets. Content is ready for design/eng review. Tracked in [MARKET-2252](https://linear.app/supabase/issue/MARKET-2252/case-study-flick-games-sept-23). ## Review instructions 1. Open the preview link for `/customers/flick-games`. 2. Check the logo renders correctly in both light and dark mode, and that both quote avatars load. 3. Read through for tone and flow. ## Checklist - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [ ] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which applies the docs [style guide](https://github.com/supabase/supabase/tree/master/apps/docs/style-guide) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Wendie Cheung Co-authored-by: Claude Sonnet 5 --- apps/www/_customers/flick-games.mdx | 124 ++++++++++++++++++ .../blog/avatars/alan-thomson-flick.jpg | Bin 0 -> 67305 bytes .../images/blog/avatars/ian-masters-flick.jpg | Bin 0 -> 61920 bytes .../customers/logos/on-dark/flick-games.png | Bin 0 -> 12667 bytes .../customers/logos/on-light/flick-games.png | Bin 0 -> 12797 bytes 5 files changed, 124 insertions(+) create mode 100644 apps/www/_customers/flick-games.mdx create mode 100644 apps/www/public/images/blog/avatars/alan-thomson-flick.jpg create mode 100644 apps/www/public/images/blog/avatars/ian-masters-flick.jpg create mode 100644 apps/www/public/images/customers/logos/on-dark/flick-games.png create mode 100644 apps/www/public/images/customers/logos/on-light/flick-games.png diff --git a/apps/www/_customers/flick-games.mdx b/apps/www/_customers/flick-games.mdx new file mode 100644 index 00000000000..f0db53c9461 --- /dev/null +++ b/apps/www/_customers/flick-games.mdx @@ -0,0 +1,124 @@ +--- +name: Flick Games +title: How a two-person studio keeps two live games in sync on Supabase +description: Flick Games is an indie UK studio running two live products, Art of Solitaire and Goalman, on Supabase. A single backend covers authentication, cross-device cloud saves, and remote content delivery across iOS, Android, and Steam for a team of two. +author: wendie_cheung +author_title: Product Marketing +logo: /images/customers/logos/on-light/flick-games.png +logo_inverse: /images/customers/logos/on-dark/flick-games.png +tags: + - supabase +date: '2026-10-06' +company_url: https://flick.games +stats: [] +misc: + [ + { + label: 'Use case', + text: 'Cross-platform mobile and Steam games with unified authentication and cross-device cloud saves', + }, + { label: 'Solutions', text: 'Database, Auth' }, + ] +about: 'Flick is an indie games studio based in the UK running two live games, Art of Solitaire and Goalman, built in Solar2D.' +# "healthcare" | "fintech" | "ecommerce" | "education" | "gaming" | "media" | "real-estate" | "saas" | "social" | "analytics" | "ai" | "developer-tools" +industry: ['gaming'] +# "startup" | "enterprise" | "indie_dev" +company_size: 'indie_dev' +# "Asia" | "Europe" | "North America" | "South America" | "Africa" | "Oceania" +region: 'Europe' +# "database" | "auth" | "storage" | "realtime" | "functions" | "vector" +supabase_products: ['database', 'auth'] +--- + + + When you run a game as a service, you worry about what's going to break while you're not at work, + over a weekend or on holiday. Just knowing how battle-tested Supabase is takes a level of stress + off. + + +[Flick](https://flick.games) is an indie games studio based in the UK. Ian Masters, the studio's founder, has been making video games for close to three decades. Alongside engineer Alan Thomson, he now runs two live products on Supabase: Art of Solitaire, a card game on iOS, Android, and Steam Early Access, and Goalman, a very silly penalty shootout browser game that launched in time for the World Cup. + +Art of Solitaire has built an audience that differs from the typical solitaire crowd. Where most classic solitaire games skew 65 and older, Flick's version pulls in players aged 18 to 25, largely on the strength of its art. The studio has commissioned more than 70 artists from around the world to design its card decks, adding a new one every month, including collaborations for Pride, Black History Month, and International Women's Month, and more. + +## The challenge + +Before landing on its current setup, Flick pieced together a different backend service for each platform, and none of them held up. Google Play Game Services and Game Center covered authentication on mobile, but treated Android and iOS as separate identities entirely, with no way to recognize the same player moving between them. + + + Android users could only authenticate on Android. If they also had an iOS device, tough. We don't + know that they're the same person, and that's a pretty big problem. + + +Flick used ChilliConnect for backups and save data until Unity acquired it and shut the service down, forcing a move. The team evaluated Firebase, but it didn't fit how they worked, so they settled on each platform's native option: iCloud on iOS and Google Play on Android. That held up while Flick was mobile-only, but it was never cross-platform, and once Steam arrived as a third platform it stopped making sense. + +## Why they chose Supabase + +Adding a third channel meant Flick didn't want to bolt on a third separate backend service. Supabase covered authentication, database, and storage together, and the team could see it working almost immediately. + + + It was just really easy to use. It had authentication, it had all the database stuff, it had + storage. It ticked loads of boxes very quickly. + + +## How Flick runs on Supabase + +Art of Solitaire's authentication now runs on a passwordless email flow through Supabase Auth, with no passwords stored anywhere. The studio recently migrated from a magic link players clicked in their email to a one-time code players type in directly, a change made to better support platforms like Steam Deck, which doesn't have its own email client. + + + We were actually able to have both services live at the same time. So as people update the game + from one version to another, it never breaks, because both services are working simultaneously. + + +Rather than adopt a separate SDK for each platform, Flick built one lightweight Lua wrapper around Supabase's REST endpoints and reused it across iOS, Android, and Steam, so adding a platform never means maintaining another SDK in parallel. + + + Supabase having REST endpoints is massive. There are some tools where they're SDK only, and that's + problematic when you're cross-platform, because you're suddenly managing two, three, four SDKs, + and they're not always kept in sync. Whereas REST endpoints with our Lua code base means we just + write a very lightweight wrapper and we're done. It's very easy for us to expand to other + platforms. + + +Steam added its own challenge: unlike iOS and Android, it has no native way to check whether a player has already made an in-game purchase. Flick now records every Steam transaction directly in Supabase, and even players who haven't logged in are tracked as anonymous Supabase users so the game still has somewhere to store their activity. + +## Keeping three platforms and one experience in sync + +Players who move between a phone and Steam expect their progress and purchases to already be there, so Flick built merge logic that reconciles saves across devices on login. Progress adds up rather than overwrites: currency collected on two different devices adds together instead of picking one number, and progress made offline catches up correctly once a device reconnects. + + + That requires some sort of record of those things on all the devices that can then be married up + and turned into one single source of truth. We were able to do that through the Supabase database + tables. + + +Remote delivery solved a different problem for Flick: shipping new content without shipping a new build. Art of Solitaire's card decks used to ship embedded inside the game binary, which meant a store submission and approval cycle every time a new deck went out, and the team often had to turn a deck around on a tight deadline when an artist delivered late in the month. The studio now serves deck data remotely through Supabase, so a new deck can go out without a single update to the game itself. + + + We can now roll decks out remotely. We don't have to update the game at all. It was always a big + source of stress that's now gone. + + +## The results + +Flick is two people running two live games, with backend work in place that would normally take a much bigger team to build. + + + If we were to write all of this authentication, cloud saves, and remote config ourselves, the + stuff we use Supabase for, I think it's realistic to say we wouldn't have all of those features + built out for users. We've got limited resources so Supabase has really helped us be way ahead + from when we started. + + +For a team this size, cost matters every bit as much as capability. + + + You're building a game, you hope it will be successful and grow, but the truth is most games + won't. Supabase's pricing is really accessible, and it feels like you're intentionally trying to + support smaller developers with that kind of structure. + + +## What's next + +Flick's next moves build on the same backend. The team is opening up an artist portal on its own website, powered by Supabase. The artists it commissions will be able to log in to test their assets today and, soon, upload them directly. Players will get a similar login to view their card collection outside the game itself. + +Separately, Art of Solitaire is collaborating with another Steam game, Forbidden Solitaire, still with a two-person team behind it. diff --git a/apps/www/public/images/blog/avatars/alan-thomson-flick.jpg b/apps/www/public/images/blog/avatars/alan-thomson-flick.jpg new file mode 100644 index 0000000000000000000000000000000000000000..793c9c5e0e85093f16da2be72399d2863a2a6f07 GIT binary patch literal 67305 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
9L0uaYB0rhbA3s2^rsJ^i(;)r z$A!S#a~7ty;!{$a2EQ;*PPa(foqi`%9>ZzC`gVKHt9+jG&>clPk&TMGnerWoxd3Nz_%jY{hK|ws*cRvcnEHmH zw2eV@q_#=b$FKro=rEPV>Ob7mYV2i0kdogK$_4&nQ17sg40k=?6@b;=8lB>S3_-2o zf&r8?k_ON&UD$$e@gN6ShFPV!iDS$K_JYvK1Z@MDQtMHUMWF$9F~fh0kOgc+PJCpq zwrOP|OC9ALrvycegc_Y3_(2D0gCc1)3#a4Bw9$>WzG4urk+$%|5%`l6T302;s{l6U zV;MX~t8IM1)#{N6Uso{!zxXBV1L6bqR@byEJ&Z!IKmPzQ!;S&DP|vt;DyZ%LWoq#* z?gVZ5ln4^Zs5fVb1T3C5ggA@T-L{k6$!G**+5@HE5QDpdc#sirU8(^OiC4T3 zWdTQ%`@$@p;Ij~6o?x1VK*GLYm-8X}wiPIggiHP51AU;?2`+>JJM9+tz}{NQcA+2; zYx5Mtm{v>|G$1XEQ$2Caw%nMsI+*-Qg9--Xtny;riLu_{JCqYMxi)p8uYZbA=vzFa?Bk&o177D+$6y()LPeu=3^8(-Tu>EqW$wqi8# zCOj-}CY^^T#M9PeS{d*-WIuM*Qyc4Lq{z#BfOazsn%yR1y1+Vr8%3UnD-Bi%j2&lF zkEH!JyT%Rg2Nrycbe)HpMf9CEOg&07RZm8S)^Syf6>plIjAz)fUB4=Fo(Fhq$CwH| z(8uvI^bI~CpyZzLF_>U&3SlFKxq7_7En7>lYWaZGBJaE+z>DC-sotxYWQ`}xqRA(` zhU@3%Qi~rjTE%a~g3JfV5O29MHU9qq5o~B~AyL|efiq;u`4HKGyP5K|iq8C--VO{$ zKTxVp*TlCqHT^LFM$22C@|C5OAOa62Ooq&W!!e^7*hdQDCm9qBV-c~17}5=%WCSq$ z!LUSHWLqP!l^Rs(uZTs3?=$+9-r;IPzjz14Rzd}umR^SEc#N4fABhAlMpeZZ-cTo) zQ`R*><|h*inpEu|gl!i<#$CZOfGQ>~sVG>RiJ7wtQz*obd>BI^?^7UsIayCP8g~*d zG%MV4yyvSC-aQ^=ThYE@U6H=iA~B9!hlxgo{Qm$j%Qb8V?GO$3goIxv5am8k@d!U? zokO%Pd+#b3Z}TP80?anJ8p!0_2ABP%YWa(>Wr7R?usJdl2VwsJ^C-1IE0%YQR}5ju zAZacMBIR8fP<^+RD8A4`Z_EO{N`izPzs#9v zAS3vLq=nR5VPKO7D}POUjzj`iIuHT#LUwu&^neO=a%UkFm0C1R>tY!d5I$WyQ6YH} zH4f@2!sF$b{{RuX0*Vn>lm^w$f@8r4ysAr+RA3UIZywZ4LoVm@BT;H^w6fI%3YK_= znLeYJ)9JD`{{U?AAxp7l?q_@J{YIXYp#I}HK%J`Q{{W9C?bq>lQq_%Z#D^1=Cqbdi zrEManUUt!uh8I9~VMI)tN`Xe|Zo`H1){#qFnU5wciIJ3pw|PZaBjz(2((w|i#m6@{ zCNe(p`$}4+475nt&ejd_kHq0+VqAC~r*EZgSXbg5e=F^x_=GnxYuXZ>=3bLV(Ci)J zt{7_&qdS>m0Bx$IC*~B=W!bMWVYJr#$!5sxU>>bK}wwIzAn!nFt0WW?lpxko0^Ia_Yydjf$6!PCOZfrp>a2fWLV91x$pN{e2J+hP*a$|xLAz7Vd zQ$}fQdra5w5Xa^K-S8m*K;BVlH4`Yis3gm~f=mdaxP%Qs7a=kO3<;M4Rl+MS zO(Tg|&5Pz+C~-te#-RmvfI#mG47=tKRsf4qOA>gN4Vekk0JT)I!^BnZydrj}3U&;6 z;wLN670fqUHa3X8MVr48p;;(ycQRpX|@S+As^$1bsH3)1+U^K#y8D;}c#2THHTXkkS ztN5HAo!m7Kb?^r>y@8h|6w%{(8i$kInHhjszM(a!-l*IQKeUa=GWKMx_LL!k4Jx3y zr7?ue-9rUJSkQ#E=l-{=sLr_8VaYQ&Y*+=iXdPc%GUX=_v-3ImPF8PqnY?&AyIV8; zL7j9ZD*O%1%jyXirpQN$N2hRV#JL%D)2MuF>7k*`#^pY@P7d0^-1(VrQmcVr{{V>W zRspkjyuwlR>5mpwVl4d4D!#s4glJV90&3=MuXsU~mbv|*mWCZL6{mkQM@j(106R`w z3Omk+Lf_K=01~@?e_D{k8%2W&Opj53*n}Vk_=Y^ftQwYD1=*=PKw;jcmZ4E$wvd=p zHJXcOVt(j1;t3xxERp$&eb0DjXr?xKfG&B6Ym9O`Tn=PX*b*kC$w(jt6~)11r6hk6 zrB+9{INyJXmnLI=3_$7`GGAy%b#iUYwsSf7^V)(9i1o-%cp5!Q9KhA9dZ@i$GK zNUmjuxPs7;_JI9VARWxDaXm*+Fk>uv>>(G_bvU|I)seUHIUQHkvt_Y|`fL7T^E2nj zUH36(AH=B6+48Zr$i)1Z%vtj(_Pc&AQ{W;NJar+?>wXTo!5ZgF4^?z$6&8%sjmr6% zbasYo9MlY%dkx8$7dsu0e=}E2h_h;bV8*Nq?ejD$?JFA4i~@wH5~94sy5DP$J9dI- z{6N8z8B=0XJssw(i@(Inku;s4)0Kyqz}s?WRWZS&Q-(@3Vcm(I!S5K3MZy{lNvz5Q z5zL{?0}f9x?0)b@!dRkUSO?6MZcT)%K4k=8gDY0@69^(@YVW+DZeM8ytL-aR@~T11 zAdHG&-tdO+MpYI)>Av ze85dCjpj_uMxCq^QXnal+qiG}l@>IRq>b*jfs}+}0kVY1HE<5#%IfkBCA+}Rft9s( zmKm1oeNxjPDZ&0FDDA`%zoo*$s)Y+J+)dpvY-?lex-%b0PbBI-OzGeUN!7tJIzgS_ zh+s(IT#dw2Mi#1oOa^FCyNaCNyQszju1hLT_2N6J&FSN2y@cT9&fQKz-`l{JM<3U` zjD1pc#%i~iu{WiZ9>#CVw~1U1;QmBbx`Phv12-p`24zM8wzC@83k5jM zh8I&WRhW!p5ylt}4{;+6p^<^JG<1Yf1BuMU+LxX_X8wWfRIZ$TZk8I1sYJ;29bynM zSF|8uOxPJ<#axAtm{F5QX*KT`au$GPu2J)Gcf1zWd~E=W9{WW*_>{r7vjCbBDh^=* z0`3qn84GU^0gM7vWY5zsPjL31iOtQ^vR%=g!T^}_Ai#Kroi^gk+3g-B0lX84a+7=gi`Ge;BO}TKr5Ad$W1Mt&_$8r1?v|^FdepwDyNZh zi)tZ8&=^U-Xup^wvF4BBQED1Nls}0O4EA`VFD7s+Z{7}5N9JYJ58hAlDjAzLq3w$# z99P;2WMvu}?kSscmshV7h8MWEeJX6gX9DcbUtRS8 zPo;-*+>X+**K#LYo7ZBO?FRnjJG6Q!G`8Oe5SyUmz7*6Hx3JY*_?u&H!;?TXHaOyf6BF_ZQkr#X28%w{Ije=^r= zHPpgbF+_SiOK(pb0^?BbYUT`i4lIE4HNHhS%dr44;^theCnlB1P9yCzU(?(SfOnV< zMY;qS?vcRHurjyL*Ko)rUDoAD^+8MK05a|rcPN=e!MlYRG7|(E5Ln-M5ag|d26N_7 zAP#qfsstp;CcuKC%*xKKZszycO-5}|CiXNShhW4F!0!aHZru5n zD2f~YC1Xq(E3`Gv2RlSmg>FTatMyA<%Kbw(+9q(D@fwLzHK!>jb03bs{{XW(8F#-u z;A>oG(A?dUgW?zi4TtR8Wu#A2De9Xg16_jj>lCkXR064Mw3gmD&QFTuwn^Xd;I5HdX=# zY=Dl@I<9Iy;6%rj3Ni5ju3g0|?+yb2j8=#Tw`f3-2+?xGc`-rZuI2-IgchI;qXF6h z9w2~4+^}fKO~AQVEEy|!6@K76NOqJ-5BZ1BFn|We(w9kcDn|B_Q*Vf+%mz|_nt;a| z4fl~-Ym3V2z|pw0DhIrfd_pbT-SOr?z!R{uFD2+a5i_yi6TAfyF|j-Xe`sAsA(UO| zBZ47i1mine_c2YGiu9RH9Dg!ZIr{ z>Bf_L)mJg9g|M46M*=70AQX2O63wWt zH6q%9g?4&OhxFkX3|_KUUR^D%w@8 zITthaX>Pd&^mvXr0-c4!1oBpdFz2hda1fuw+_>KcAi0~}lX{F*bq6LqU|8fB1zEBN zc4n?D_JOA}UMyq)5~$aCk0)4wfCEq!9%!gW1ORVom9Xk!S1JK~Mk5-w_X_Etrjx*^ zRHXQo=s25D(Payr_>IOrrDdWX!sKFtml(E((&ReJ0sO@EH?Sh5bnl}ezqh!Qk4=E; z#;0w?#vV+|(qtZp(q^mLN1}WU%eV()EEyUV-ojAkRmvs>vs41a_m+2pYN`N}2fVtW z1e$>u#g?cp{M<-uY7lEun=@8AH8BH1KL(=OiP{re6o5pxVR0{%Eh_HatF#S%^2}@x z^9!dlW3O=4@iBFD*%J~12Q5a#Gn6Y?Hr=bV!PGk@QWnW!b5WaD);hE42Ef{9)LhWm zc9H(TydLII8e7}H=2tqjO-;>@m_@H61Qer_ERq*vD3AzM;t*)IH5kPJixEurp+sgM zYMB~_h4_$wrlFuDYJrsQBxy#&R|CvjFw{Th6F#k?i&jBms!^*0TNSPo{{XuG023-O8EvhFLdd~fk;gKc>fit?8|3X7 zhc=cfcoPO5p{OliXw6^}PRs=nBI~4b2Hc(Ded)hx&KZj?`6W1g#8qnOIwI8=ZF9yjN-Sal>YJ)sb}Qab`;aw3osX@giwoRGWX7(b?|wwg#<<*k$}}IOEQvJ@WBcG_M+=-P@qz(#Zp?FPx_9xNV{E9%s}$>d5(EJ@qMb|o6Qu-%V{8VIwb z`G-IVqk64Q*TIddubCPrs7+XM4?-;EwxD>rtaoSsAf?Z`?MS*K;OE zI=5nfFfUgFc~j%B^xT9MUc}DFGtcxdSri%E1c7(^NbSw^jyu4(?TFqet#2b~t9c27 zR}1q1Nic2^5Kj=HCZe)6{6;?$CT5^_fV{akpm&iB&1Al#9gOK)GP&B)8rWm~$(oUY z*?1F}xh&uO%`2DK0`Ys8U284w7}L(@_KR$#*y!0*(EPwTeLPm1ziNV+3gH$##96K& zM|xvh?aa-Usr4P9s{zT0aYIvbPV-FA>~VvB_S}=&LPFB=e8Dyp1GFVgM&XqmUSR?6 zuJztRXwaz>oA1O1Edh75YSmdg+&~---QnsiZWcBn+74l2;4AH2`%27xyPA=r2_e6E zSq`tvCpQ2aBF569)y_J-?rBEUJWP2MgUn_oZUljjNNHvD03bQv@dossAZW(Kuu@1K zL?#XnqSiXnKqDBAS`>k>Y9fXo3#Y4l=E&NTMa7(s|gzy2+C9Y6k8A&!xcr z*q)unG^2V<655=R#4Dh322(SJ(s%C_k1q6N%fKQ5zGWO9A|EKSM=&ylZN|oBa#gvg zMy&ZYC@OcEGn=SD*ysGgayT#qn=zQ3v7UB{eX5MKVoe|B5?1vskW(5T9L)%!MFBAM z*+@DYwCE);k+>kR*NTt>2nsN%WC+|OGT-e54)TZeoFDBxz#CKAsUa_#Q1kOn$5yvu(KNIf{IKh7<#WZv00+x5hxS#B4}_DE^Y0@3^^c8y`$^U$~wRaTbeM z!!ubXqu)zYWE2P5J|HCtJwV6>3LWIirMTS7TW~d zH0OrX)TumKGkWd_;4rvYel@YT<}ipj&@M6D!>JkzaXimfx;mVL`IYCa#j@hr!LG(=>3*QNU;2rtdGR$2 zCpIR?pf)uGlR5eNaaxnNG9$0jI`vwLSOoFnE9w~Xblp2@Thu5Y{LKFVPj80cHVq*a0gwc!g&3 zwC&?uZrO zNVr6Jg#Zv(LkfA2i~Zndakmo=e&EiAZ)o}PF!d>=N0#0avZ{lro9!E1rfpxeW>0RM zRj?L*Oi`jw?KHw&8!^KzfQzu1a-Ca7U~h7dFpa9}i+5(CTOlgMox4Tkab)(nLA8D^ zB8W0p=Xke$G`D048iNz_13Q0IP~=L=7Pc4MMrzWADR=zJyES}D8lLqLqLg2V7=RG+ znq7p;O2);>+ioss(l(dVC|p5#Xv}L}L0h^OA#oKLn*;ZQn;oX2cN1AtwTk>q-AL5x zzuSArxXMLJt%T8+834qAOJ-EC3~zZ@ZJdF~fK7@US1?l{#^-&EtecRx{^e`l%&$-| zF-3@IRrgdA z^$NXkM9uW$d5!VR*^?N3M}rDk%0nCC67?E}lgOFQ!*jdYHXAY&6TyKUhtW; zrssK)>=HqSYeKe=B*;T7QaB@50t@B@?X;+%S#~@}s{*2CIX8x^bleSLxIM(_bh!cB zyyJ(y`%S$YE9~6)6E(SAg&pK#0N%bLapT+C&0YzQRjq=lK6}X0)tIi!`@$)xdyU*3 zKy21OB3X1*9G}FZ^ZL=KIXKUn2_&K=UdqQDDO*s`egW<6ma~0BDWT_L6VnS@!Axdu{`8jU)@$ z!N^a!dl^XD(C^0L0LiM!IcZ0j(z-d3a|ycKqFS%&3M}Jls{?aE%E5xYi;J9aN)@KU(;*Q+>7lrWy*g{ zQ59KOgMGxuk=r4^?lVRuWgO50VIT|^*Q;UkH5^XeDGAQ|K}1&o7o*9H89>1Jw>gRS zk-(7|@nURvw78>t$Z-}oI+cO?5^`;oK^5NSN9%Jgm0G1x2GXXveWl%qHwf`7(6MlU zf;ap^kTExwZMNFHLA@YUwQJrPt8f(Fz?r|JU`bcpRK|)JfWqdEmBPCY0EW41Ldy63 zpw*7TYP2OtKGXjIFY!B=FHa5ZUl3%9>;d`Rfud&1{-$IEFaodHz@W- z+!;}7)IpZR-bj}C%F_ewGJ$~HO|)?;)Hs>bc+d%%bdCKx1zIDB>`tDolSIyPh9BJx zUDWxRZyg7FY8#)4Sy2k<2UgX8GdlGo54U+u)Rx++7sS;dDOdZvL}dUei*{l`xDL-% z%%$a@D%rc*0OE{m0%G*h=4s0W4G1|HQ1dL1MF=|eI344gs8e)odAW?HW&r^zJPCiJbIB+7g~MN{$J~n% zEz~95+4z)tI+idn*bSh-Wa5xX}<^Yv9>K)ICZ0JzxHMemXPp}hQOqp^smBh8AoyAIs91=8J06F=Dz>F_-bWA!_ zKo%wKnt|H4fiPxN_OlBj;vUXs0>YT*WjOTJnuq{}7Hr*FglR+=(U^7+T|=cG7bAv6 z-S#7jic)hp0gjc}!iN&Bd`pc*5Nw&JH@z$SYEGT8;th<@@hXXG{oxmZuN{qtXwXIK zh+q_L+|+6tLvRzgr3O@5Jn# z!JO}@VlEVqGRAtF9AROosr<&SmK8*sjc%Frf_4Xpr^NV_y5v#tW}a#{#*9G=q>k~q zm!?jTxX7i)3wxQFS=frmt@(~lojr4?Z(PGHy4jR%OLxT3xUGhAM#G5Ay)H%onpN%x zcz>r;q$_Ye!^D8FIM4ku!B+2n=1#lmIlobO&$NIM8&{*^$~e76lZ8vDz5ed`vh4 zz6KXeybjY(Pmn%8k-?}By+evc76pTk8BbrEdu410yK2RlnpmJ zsnUzs2-U)%o*{BMSo$_(;y({Yy~X}!_F5ZlEA3t(FrPyj{{U!$$!BQOzZY8=99i3S1=!WgV0rmjRhctw*_qv>tGF+~7z;u&GjgIL&Z1g}fjHEes$ zc`@o0#Ll?Gu>p_bL}km25#UaLQo2d2Gq;lhsJ6e{&OTzfbLmmH#IUiQZ)wozl&(=T z`XygUAH3Pq8q5LlB_peh>?73BnNXPZGAPO4crF1I0iLn}+Ay1xBNKXV%Eys#GdHP` zKsyN5vzQCmh$TqOEZ*F}UO}mXvl{RqCLo7ZZ^r$EW>sf!;MSOwd&<^jLte-LD8DfX zG-Nm5XkV&Oqy=JrCT!h9iqMQd{{UeKV=zSpxF!lPe1HM5i|N(+oVN5riKvB80uAf5 zDOt5K(03rsSXEI#QQEGibzM1E)Egh3^FhcJhr|Tt)9Wf$_feVu0H#46B65Z~mB6!n zOX@YTw>4ZS5Q(0dP${v@Vs62F4dCM+Xs#urtutQtP!ZLgL>}G5Ry%Swpa&9WC776a z4Jxf2_a+c{z->eyi<+LjmDTg2bU0%YruH%`NmEKa^SeOFrf(G_up2q*!}#+Wm83_&&UW7vE4f-)Rr zG8#VkvZ6$YhN zbs89sL;uxjU-%|36u*U~$BL@jK0vm>t*>}*!YB*(5j3;t$M&bN^OC&7oW zt#A&EVNY;wq;0znO-e!J8jb)P)v%o0XF6 zq0%h}v-25^X@<~L8loX^Y8MRz1GGIOUZWJIyv7ZLA%yTMszsp%h;G}$#xGDZp-1kx zS7%Y?LI9O_3c3|?5&M2*E3Vh!4W>BMZ+GNVi$kqG8xMpss@O>Y^7h3eSHbqT2#PQ(~E^sqXG_AoSH$ZttMznJrxMFn8T zz?oMPx>4W3kLlG$pcabA8HsI+G2g^#G$4Zm{Z>2DkMk%_V#UEmA-RMyxi_R;RopYB z9Bo1`H@&TSJ3;}2m85dMXy5$6DWGoJk8>JSV*p>~A@bXreEkC7nwhsn9afBB8u@DA3Mb_J2ANhubw^DDgxCKE)f_IDA zvJJTAFD6DTxcCd+FaU_eer{2pHcIML1|VN(NZz5y!~0I(Gd4{pTa(*iahUaE!XB#p z$c7dw+Qr>enJ*R#Cwk&nOjlySmK&Rh&6Q3V4Q-D`xjVxkOY*o(R)&7T@4kMqZ2PQh)0l1t@Sl#}^?(k<^^zwD| zL5>hUBR^cuwWUYRKyH($ZJjDl(PkfT+GUokPCYTfE;qSdA?kW-P^$Rbv<^>q#Q>L{cs*XKxPR8_U_^GTI({w&=3Eci6 zA);ku8d~LdLB&Etv=PqS7)u~6;ZY(5#w;aJYTJ`6v6M(n7eSDql zcA7ICR0to5Sg~j{C>aAWt&R4${9p~FMZQF0rEhYJ(jbxIG;b)V0PtZ!b~uy;Npxf_ z0$nsMDqx-E7NH9$$_Zf!`G6Srh8o8(zDm$<;uUQw%%ae~O9X>asC0sS%=)*4A80hf zB?aCFv&5Q+?+c?FLjV}7H!k5kjtNkO4$)}aRl>T1S9|dYIQ`%VN(3f~?0A+05=R17 zv&VmkO*mJ0K#e;anS~#_cwx5M18&5C^C%mZ2GA>rB;Lv+DhOJ-kR{YA+1-x-!c1CM zjn5_$sX0F!Tw$^xD6Crm6z?=LkT#1bPlz1WQn?8LmGKmyU4?5VbR9QhM%+w!l;Rrq z0GUw~g(pxTej(v+>KY)}i~#BPw#UrPjD*h2zi(k6*5}$~Vs&KO{7U^U;g zkpUlYKkbwTV4{hkTZ1yYF{K8CW>WbHTBROf6(@ee5S)m-9wT$*q{ok)gjZDS=~{Df zKB#mBwmd><%FNjq*d@CN8Aqne?6vumq$&%wUi(~kq}c;Ia!mmWECsP*)T%?B_}Xh2 z-Pc>y@*v>C`aLlFcLYZup`nPeB8scxYYU|nd`yRt-fRBRekN;B1WbH0s#k^$@;g^ab&)uq(F{MumhM~CsF`RLv|;L+s2fm@jV<} zk2mQt;PNvo1!L`AcHBi|>UAkyR1Xuq{{T{bI3fK`B?A8X9Q@BT__Yu~-sXGf9D$T- z(TG_j?J5!`c&IoS>-5`$zVbRXEvr>rwko3A zh`Q1?lv_}$v2rMw0*#fv>!kz4c06PNPcdnXk~-<4`-F9p!(iz@R%oSW63QO?<{=-`Z-x?JptHR7B*-d4uw!pC(ba5`lu> zp9WqdLt$Pfbj_!QBptvou@_=>wFnbA81pS?tD(}>>Y_Jn0( z&ya(#2Wx<)&3J{C%QcGmFtG&~?~`!LS54C^;Rep*4BWpJ)zaNx)j+@O5LXX)Iwt_O%z z$B>bAG-1iVH4}|=r~c#v+z%4B&2Cc1lVh}IKfO;iw$TXdrit6kYN#8o%TVnPXHb9l z30D6AAjGv02($T&)lGl#BA!KoT!_qI5)TG16M`&|A(98$Y{;!5$mS3fBJr^#Mw`HT5-ac@VKGZo|Z`rG|rzKrDOsl>mp^ec`Qzwu)0q0QVc@ zOUsnl8`VIpg|Zr5hnOUUs^${pI!Cv2ybno>YHNfuS*q_XoF8d|ch%rQTl#9%l6ni7?KEZD(*GcKm>AlkXdFMRl)~K?y;DZ5kzm|S;R%qvGWSGQtBTG+;R+Z z*-%Ia@iV3dR@9kFpB<$uu)Kn->-B%pg4j`?FsT}bEZ;v2Cjg?#EuJEuTHXST4<`_|w z7I)*2Hj^1ePfZrbGN4HZ52m8X{^-9^)LqyMQH69PH2`;^2b^3~1>!ureaDN4&P!wh z{{V@jAq%Axf%u4IV9dbKCt)f9=@bf}d4-CBC_UP^ftU>lp|ut@UMA0`U{-TuR-LX& zLollV0UB)fofO0$ZUb;X0$GxYBqR;YU33KJYWcy-0vcRhVu_r#mGhFjR~#JkW3$@ z^v%`%K}{VfZsw%+mgIN7nSfyFQ8*x3n%WReBA}jUtC^AG>g_4S$JTV~)O6V~FuNOm zX3UXfPO*oD>H%BE!kG;ioQ1s-7oO9x{{T_+zLs^)gWCHa%;cO}mdg*s^8TFVRz8_M zEZW@*eg)0nuEGBR_y_R^+^g*y)XK}BR>~@JzSWt}xF6b3KIv>Im^Vgk+*}4*{24@S zByYd-2yTbZXcjmdd`96qa)T6{ej(vBz{r)iIabN2c$Mw~)Ld3PI$XJHvmbPmE&y($ zA_3|zx8f3IM%){Vbp%=e0GJ7pT$Kw%c!1|i+(f$2-X2`ul8fG9Zl%e(+G<9*Xc;KY zojzdXD{wax0y?20Ci4~|CG3sng>qJ~?orEaig9H90tOEikC}2SgU>h-@)U&vSWZCjj4MTxb3$ia! zlThvd0EySryvv)VS7s-?4tjemp(}mFR#jr(ab`T!+TAlL+js}3V_YftFadE7PmrtJ zlm7q^a1=QU=_C!5O#`JlKh(>suoc7(p`CxFfho8r;#5F|&bJ%?0K~k(h8EC)kHplN zG~*h{C%}}rz20G5{Tfw$MIasP=4cpi3HJU3zNCJk{WRe@Bg}5U{@Y9KwOE8?D9RO= zao#mkUQA}q0|`TbF)!M80LF|(8^{I}*TOY|2II_fC~!93Q5vyxN6fC7TB*4PMO)$> zz%pXMfZmaRiKHM9w4TBO#xQ8qW||Oylb8Yz%r4StsRhU?T}#s}ejo`o2}HB_lYs)N zUuiB0JHQ-Ypy00L3WIF_ z0A+#ZL4`@Oqf{`>bjp7s1XKz(=e&%d^UuU0ManXvEsPJjiTuF{M7nJWmsl&omcL+M z9iapiGVZnyX`d(bMwJAqnET z6X^!XawsOb-)T_5RdSZfM&n`g6_gy9w5AHnT5Y0-o;kachSOiY2>*?O4q;#3wKF4p7^AkLT59Y!a%8j0h)MiK%HeAu97 zzJ~(hR^tBv6E$N_BqfWtej_xJYqW~od`r|U!$Bizl){9S^CPjMFPImI(1Hf6gTRKn zAo!CA7Fh3dDsdvOv{pm3YZ;$2g;NU6oR)BlxjO+pAJV!((sar{yza;5b3TvK9GQPm z>#+N7cfhIJ^XK~E`gQz5U@(MWndo`TXtNk#nNf@CJt*q>N5(~(vH6}qMuNyj;O}$Y z9w(Uk{{TubXBm`IKR=m|)18EJS#|q$SK>7e3`nV~ffqmip&w-*iDR+zA>SIvKG7&Og~V7f!F3@cw24}A)dJAi8nLkXio=}ZA8N4qj6YIG zQj3UL6<}3J;bA#RQ@vOG!axWt0Q||58)k6LKo8~`g~l}BJ)k2#A6Z2ZE6l8&3i=?f z&)bAEUdw28>1=BPB<7w{%K(myD2ZfeoZP1p!GZM-LF(LK(QJzi`X)o{KguhI;<0spdnP#k6}~6BPjyUE;`nC$ChL%* zf{!3pdV_f4Sz3*{b)(3CfMXkY#S&99CdCW|F9 zxoBznVAD@`nW;5dydK$1ZdHq#2~MQ)cISjBNdCn_7fkBZj?nAERy(Pw!$+zv&domT zKd=dD%%R?R#(x}{f$bCO!>-D<6+m3$&#)0lF^dZWe)+W!gH=g@^dHw%#eKAM_920J z8;VFa*-sQ(-a?AMjHD5NRwaRC{Lt6z=vRSu5~`fJ#}%3gG?^ur^njKeQms4QI9KX>0kSj9A3*1XtMb zB*4wR_nHDn;kzaUsEU~3F`OH>{{4|Vzb@72uSnYJR3sI?i+%K{5o@Cz8_qj@HM}kI z10M+^Q~z@cxXlTJ{W>m)g{gcS(ND>oT|eZ?-n2wj$m>OGoaLT*gWo!pVR{{HK=%;Dg;K?QeR2hX1CScl2JI4q zd;G1>Su^dG1+?S)-R2Guw%>KUBn=n8vN@^5Mbho;aHj-*F>eRsf z%;JHoW;Ole8)WyWW}gL@S!L;?sWkRNMYm!S>Bbi`_uRX|!1KO~nHGiWQKz(M8b==7 zYX=0*S#fo;vIH?SaYWNNY@2^=t{8-$@H#c}6o9o56_d$*Yd=bH3Hp-5zDhqoOe0q86AYCB|c$39>EwMpeET0ZrSsc6^d# zzEyojBd~M`KH#V?AmaDUTrg~TetgP|ST5TOEMukZZtSMa0pV^Do-<7ZCCDg=G40&` z^53*knXQ8h!Tcc^;|g+QK8QV$z@ZOmJyeumSZoDulJ$I~@%(s4`rS1|Jo5c8hsj*U zzAYZ=atS2{d+rO?>J>q2X70Y&mx%sMVT7?0%E67MNhXtp$I6{sN_eQfZ)>eU2+im2 zG@K97t`w7FTzz@&qIJZvmd39+{A_ajfk2Y$EOc516>-#vw77UP_1o_e$`|)CbI9c7 z#(`2xCYR&sk2P@dkVXo9Rkzr*GBPeR5B(YW84Kp+$JNynTPqfiyPKOgok$(Q9%NM| zQyvt*Y681`8&x$uGn!`s!oc_5vGpZfaqfO;+9s1;cO{jHN^*&nfS*w@ z$p%H1Y%H{X9JQH|vBsF_*qWQ|$VIP^D4&By&bt-s%nAX5Oj9k?iDEEScQuQVQ=XicMy)fWgaU zPgC2j(2*{(h-DfXa4_t$nr!ksjpbve`(`;$2Fs1S9s0l$aEck z9J+Q&#BME^{XPIBm;1XixUW#f;!W0Dc@42A+6+Xy|I`lVs#?nGoMvfRIjl|s_%xO? z>yFc^i0K4bhV;f0*-F|6nLL6H9H)+l$Ejt92D+;g>#xNcSF4lZ8~lP##OZW^gPOZK z_|Z8KflGY+B+y?-;K3?<;Sl@HM*gV(QVr!Rxwz^?2|G0FvkWhvMSpoZx&m!idCBUs zr=0s@Z8K}vpMq^|oq8X{ojDQzA0XUiLx%loj}F~dnPNDW7k707$8s{YW=BKwwcBc0 za5`~bQlB5%H`8wudnK{t8}3ny38C9!aEVF@y9sCopBCkad0f4?p|ZO!Bo_g^oZ9dc zd_$m2krn)J6ko(FKAgk(%vG0gA!xV+GQO6S zY_2x4yW1iO<>CB31VX1%3~nayD(c%rW{3p7DPq9GnOS2~HAY<-%x|!SQm)eblHaK; z;5M1~m}%-T6HQ%}u8(dOy-@0I3x{^ToK|}KFY@IlGvD23Hs%x=v6%#SmT+9d#(!TT zGGr1Sb>++3E~Ns2jg^{ExQ{D!UF&s!u}pC8@+WMtYV{+pol|islUbFMAu=@jXPxE= z=%};9&h;NMF1vTts*w|`dvJn-vUsw3lW>k0b((3_e&HNTeMO0te2tk&VKScKM=!P< z??kegCcyrlJW_9CcBJZdEV?R*e{~~a(u$f5awpyPoOc^`3hBFuo<4+4;Ib?ple4sz zpZfV6^*t2S#kKtmNX(Z*mH06)U8J^!0#t* z)-PRO>R}FBHi)4!eb97hT^<)zdMZc&lJ!+!TSfY1OsmdsFss1d;`zvO z{!Y13?i#K7#>Z;o#1X&)w_L0IuYym=rO|ba!jPfj&2Hy5P~mL;=x0U{ja>9}f;OF- zddCL&WD{k_q3PaoZ}i{zrj;K-e16D|gRN|wQ`+V7yo7q!<>*d1eZ+vgecRqU)r2kCdNQhysY< zGc@rP?P#G_PJ-?(;zo| zuaQUyRm+D+WShn(7j#;FvG3rxloZS(tG5R4-6Ab#n-5*NODv32S#Fg;%7hIV+W=>Q zdeK(oeP{~r*wv_idTqa4qjO;HO!0|O5MXyov%svYY{G0%(|3%6fD+E)?9Pk)`==+P zkC7bPkU3nvHJGGUP_cuvX7h>sj$7)opTw?`@yY5D266T^t2Nu6Wzu}=%s5Vctcc$g zk2*K=#0i*jo^~0J+0dkz+BL%#W0isN$XuP$U zX}Hy^Wlv3N^EP)t;B|Uo!H>3k6EOZs4QkSQ{B~#p^#2tqk&{0+9T^OVIDH?Q%W9u zM%op}nNQy~dZgCx^0uScm}r6c$|{70Nlug04DmSxVf)1t14}yj zW|)I6yg5Fy>ddAfSQ8j%r$S(-EsDkz&myMrt zfxrOIP(NiV)gK|_WR1#Ptry->!T#N0Py?|5os+(sEIqHIV`$$p38}P*5Zq{oL3wn9 zo=-%-1#3S~-Bdl1`Y5tMl zXXM&+f5eW6h;@6F66Jg?7&OM5aB{8sS;Yg%NSW8X0&F z7n$J)$iNEHpZUzqd71z!HyZP)10HwcPfzexI03Z%y?-E8qemj*T!b?mjTl&j0>bdr zzKi^(XmUGArP#2gRQRUP;wm|Oh$FQ*aR8Qabb4W7a{_c`u-e}9m^^IFYIc1mV&xlH zUe!$H*^KJM#uN=F_94YO6){_yFV?m-N-5L@ZBaH3-N%4FKsW^12;aspb&Po(LGrC+ zeT!kHlKPN~BMVm()#U9Slc?3mw3TPfZ2ML=>&N{lLSKoN!IvEs)#T5>r%SwQQkE0Q zBp&63x|SrZ@rkFkQM)YD5g?D-lhgZrTPpREGBQ!P;5xjp)F9@=%U_15x*qnl^vSlU zz8qA8qs7dNryY5jR@+az4t)9D&V3sSK_6aQ-c!;KAsW- z;9#GJrlFPAs`G=_v89`-!))vCQmFrKKgZfqt-0)-5`)RljMdu z`@VJ5g3PyAkl)q1+a#R|Mk0t$1dGg58&dhFlmFG?>5FkA@HsjkMQlElTC!CLOaJV;wMqsl9+u}+ADPyp8mpTrRhM7ur*fy4Os>3^?+abF(*nOcD09ij zSbEwWyj1-UfaKK%YMW>SYnx(b;m*cTR7wOcvBoC>@~?T_AWEd;F|Xm1%aQgtY!Cpj z6>K{p`Y5q7xmZ?^h%Z|v&u2gOCEQu%?CIk`dHF+TmknT^-wB6XZVWD ztH#v}h#nI8nW#0*ZU`UplmBewF|mT-_ciC{OsRA-c!~%#semw3boo&F>4EK=3RGn) zmC!>D`sQD@ry`5Y+L{tQJ7a9iG&RaBjoXOTvH22wPu{UPu88K{&8%EnE!}Hq2(M?W z<&ikuMol*Vmiwns19G$`=HQOXY1+F?bV5xT-VHr)Q?5eAZK~s=;ifthmt_2mMuu0z zd(UkBX01fu4`H6ItTxm3*&Qm@y{9zf7>y_`>@$$$`69~{=*l#yJoP>DgT9_QA$XCt z>LYEkS!~;cI{mA5WxKu8+!yPZzubELW~FUt-43b_Yp9nq;HI!ei+mgNjRfl8+icNO z1x--)`|_3lno1e(R2*(eXe!vDC)%?F%Jef)1*v_Jx0yvD}WO^riMLg$w2HRT9wAg-8;}1 z&yrM$>K5%GOGwrb7Q*sm)l^;Ft3qp~1BruZKH2(;H(iaB!Pcjna?57Eqf9$QIERqT z+1CJe;QG0+RQEv}&WpVo$TK}Vr9o$Rf>{*Fp5Pgc*FLQcXUm(AU!OWNF4;fy*9XL( zv^=M(5QW=)lk}PV;RD7swJywIg>=DGaRaI+|FC4VW~@^zuKna{i;q{x#7m#8KOmpo zwHz$>dkVp`*hk^y5%V5lQ8(J#gz@F^av~C&RI4{MJvGJ(?XFL(wXdyCq#WmxnLN3t zzYht15gYHQJ%g@b)K5<8fJD{v5 z@vV2pq(y2gFdbkh9~|D3gNF#Tg=F`Sto};&;$1vgdO-b|TtB+G{hsWADDrg7dY3 zbu(ZhaK14-s96;R2N#Qw%8kZbJh;m19t%-dv@X2k*Da7M%XK)6`P4l7#nR~JjrB9B zu=3)E_S@{Y_9@isrcXF`4W;Pd*kG$IFO49D<%Qh`Ud{o#uyXz;?>A|o-6^R$kDPGB zyDgO6bEtQXIoxAft6GLMWFJkvC2?MjOq!3;AkX)l*D`<89Dq}rjhjdGv68((n&WdK zr+B5VYvmC6p|^w;B4m`zMklqeM1UhRSczXZkN1l)IP^QqDi-tE4pG$v>3u|d$aEg?uTDzq{~8nh9PAyW z%e;;mcRsQtAYI)FM}0pyA~Z<nT#DNrGCvQ%rL1YZ-@bQhiNY9eru1A z^)q!RM3G^S%>!eN%I_SY?S-CvF=Cq~wlMwmoBN#psgRbmFt1S7JX4QxZozYZMiNKf zYyU%Q*r=tV8~)W<_%RDDtigM;JK>WSfWoOJze*@xZX&+K;6jr?%yz|ib9k{O)LaT* z)7eO65hr_}Fjb1Vk#@s4i6ce+=c05lS0 z5h1_tx;^ET2WhlORP++G%u|ijL>-kyRXZ||I+Tnq@cDz>uvtqKZ2A>^SXqmB*Yd?o zA``$ka|{k?Boc!5mi2#|YVX18H~Fp!E)>;P*DVyq%hx6&2IZTP9-3=|uNBhdWC>yzvvH%$z}s5Y`B$Ot zwfOWUk*eKT{HCh%F(kDnvb1r+#~K@So(p4$p77>cs8Zo!7hCcIXbdivTnLkLo?`9) zd)-G4y=@Www0>-G2xE}X)j5?7(8>sPL+GJ1F!8DPP*7*4tZazrcQNawD4l)YOzy5m zGvQTfl-V)?M4|z_eJJGF-3$k2aSO`EP_UmrdHPvB$h{Ro1Qur zb8~r+BZ_c^8V&hO-?$T`<`D)R6#B&a)qeml^c>!DEBsTa5-MW4Zt;u_rT{Icaw1(se zVAh!c^h^3howCRs?gnK&W))-hZM!E$;CWF%Z|^WFMWoj_dMx{s;JQOuhO91O4gaH6kRmYs{P06|@+R z8&NxLoaM)t6q7B?Li-o)7qSmb3ddHp|tcC73{0V(&Ni|K4-b?|B6T)g1OAH1}z zHGsQ?qL=JdUKJsZ%Gj6ef7+r4c)Y3HRLQo3h3H0n-o=;N9nHip>-zVcD|3*zO5bT~ z*N?vA1NJ%mCL5{C73XqH0qwjbH6AD&^+z})-~A#3f40HG^0oOjB?`e^0xkKfU}8~= zNoC*xR0nP;s#Ca)KO;g+Fo{2h`)>M4v5`R5Z!1P#+xM5x8~IlT_@`HG?lcQ%C>-w1 zFHI8xM-TeWkg4T8jw01x4Elk03!*Xe zrD9>=smq(7exnPz-+;th(}D(y!~hZ0MLv{t1i5jv$*G%!TT3HSAk$-fYa=Q>^Eq<( z>~o8q)1^THk>bbMsmbwYoEG6hEolJ$&4ra>O5%FL1MPjXDD@A;O>K@Q+2etqS`{9z zvVRI)RmSZr+p>mS+|zywb`@7b zjIW~ydJ58}6?L+mPjLHwxewQ1gg=IS6L+pZfZn7nn9`RSh$x@_iWKu)tw|4dK%AZ& z!R3xk%LDx->BWUI%g2f2%@99WAt9eCHbNirE=0a~lOf@HiUz28?dF-K3wF)b;H2@| z?1NLeSO*wZ)@^Gwhrx?DQQ*{EaXu0wi!c(PRr&;ptEqm*8K)Gs(vVUedVGlVT}$D9 zCL=7}2;*e0&-IR&&*AyQ`myaZBADGH3<$N6csN%_g4!xkaW`{9N#7uG^i#ck>9&oZQ#Ph|x@XmY1H4U?@KI*l# z+_&;rXvizDyE&Lq$NuzOVlw5tb&ON<4jEp$*OUk&fH6JtW)0)ksC8XzXe9p4-(OkK4jG_aM24Ho1eznD3+Fpa zN&y+~Q8_b6#C)j2uYkzLSPu-vgSetQ%eD&|Y>9yF_vlaZKNUjbf864a56p|-l;U$Z zJ*G(|2!FMoJn%|82-k#KV)i60oIv*_{R|#+fapOXn6wG!rNJ^RX$IU0tRfaw1?sZ? zQG@pa5=G*%4U*{YQf`Mj_z#HExPsF#`OP<9q3Y{$9!0;JhpJnI8lJN$EDXqauYBoK zVT{4v)%b$6r*9wj6(|=KOJpfB893Ks(KcD`8ruc+Q%e^=QtlwOp&^43|-=PGO~9CXr90hfzD#_w=%A%meTiaX*}g&ynAai?OT%m>KD6 z=wR+6lkiVb03$XWRIfBv2e3^1L?h_34tG~#%2O+g|B`PFl6d3$o|s`B_xkl8Q2*jyQQd>Sia&%@HEUc zdb_!uJt=|n_NKU?JTvE4K7V}j$36K?r4c98N|HAnP5}uYNRVt40(K~B(#+f>He&9p zOptHAUQ=m+_27k($0YM7T!+-uNZU?a)DT>pJee7mgYN9yP=A`4Ds27W%5>?c|8VBj zB?9>e12F}wX$WSJac$xDhch-G5a7b|_ugx_ zF~luL*a|nZMqQvdGwmR5xcrbWmSZW4i5tBtGuM~bU$DLXVHL>7OOgQ#%OWt^(=Dr= zVhwSe2~dqJFk2=uD;6>i(%H5@#G~0=Du}@69m74OVuih$0nxzvMa^~LVc_b~)r3|e zHBY`wBBn(`WSDSjSIiuF&nR@1RL)Qk%l*HP*7nP(V;|9{{7&`cOdpK7RYz zdW@mm#ZH4_Ox2m+ujq-s)vTS1krfKP{{T&Ba?!o0|5+$2@Eg2SGUyxK)eJ|bc^%}N zEsK8B$={7Bi*1f`nO;lELBRJsl|4`8{jDqm@1|VROl`ulQDOO=pT^In>-~Pf{Hh#q z@Ej5YZd;?qcc`BcElrNId3AQtaqum?a&djA+H-o}OMq(aPd~tk%+&Lm&!G_veM9J3 z!F+j!C5g;Cq)ttrB&(;(Z`lI^ha1`1>_q99X^lf;TPMCAIn>XxTvGl;S`9`QUgXKL zQ`+7zuFR)WDGiYz8!ciKOKUd|9+PP ze3B>Yqn_dOL{V??2H5Eec#h0yI+`+5KXBW;DlU1#{D$o_a$zI02L(IydS2(|(1QgC zQ(qy@Lsp?Unup;<&spMqEF$~8v$1iouy0PUrX#4DZG~!L_f3hMf#hl>^faNavg#)a z;rB`xuVSU>F~MzvemKXEuzG_xf+oeF+=WAf&x z{gZ&EL(0b=*7ANz;vWQ$_o6Zf!RL{S{3e$JUl!P>{Df-TYFWo$m8mG_Q?t&tz+u|F z)I#{Pig?3|HXHukxWL*3CT3A}+oZ*9V#xDI<$OgU%11$|FUg4r1!mWT4j4nY)G-BS z1c|5$l)*r8%OURAOWV}SGw*YpvCMZdsGo@#CW^z7}r?OuN7vH$G zmKpY7HO3{yO-vA{43B`7BP-B&U4D2&n*ob zLJqSIhi+!*rYQ`YdP2t#~3Em>2(fi6)bXxyl~F)6;)+c5>{oNp+{T*K*b@eD?Uuwd6#f`flZt9kFuomiw;*1h;QK#&#tkqxuy{#K! zvHcmO*GPLidCq9ijEcZ)6}zua4WC>2yq@ODxE3nYL2}LTZ$ZOnN_>+&g|^`~roy;2 zVj%q4FSVcdat}EG!k=eH@;_7+*aEs%X50CvVe0Pu@%%Q`SWWUn&6;VHeM#g@_tm?{ z>Pr_Y13n}T_iN8?N=U_7#k>D~;3uue`ZCBGz|GIP+-Ji80sN|pt`nJFAb=Eyb5wTi zaFAG27^{O5bpek+%?!C-%;t2A` ztzV&R2uVE2smOP$pN$_bP8yt!%-8|F$Ypu}kxKaYSAo6iM63zCX>TofXQ}$(CU-i? z0$AI$@F3iv)KfqXO|vp87u>I61$R~z(1oiwa4Qe>(c#ZY@MitZlaVbNfe7me;?c%_ zB_L4g1tRrR3CuXtKHN5}23|0ciUSX#Ezysg?ln1;SJ-m;@8}H_ZoD41L#-o4JsPcds^C%nE8&j$D2msh=IA?z z5lN?nd|>xENqBiiV7qL9qHaDym{*1g!JUJV75h6U4UQHvsf&25F2V%0MJ zYYg9y=&f@u1NFr_db!jf%wrN!l|j!MSKOJbY0r0uX^Cl!v{DB5l~kV*UDwAvC2u;a z0nO)F~R`rO9y8A zD@!v$7$(-6A`3U&d=(*OU}g`;r|BzqZ+3%t}R1QQ1_V-sJP z=+T9u6Z5g0D`%IY#8(Xw*d?FaRPxzylF2jg%3Ii^s@s<#gBSGi&k`}F_?M|@DC8DvzZvISkxpm;? zL;ZNej=IH8Zy^Tb-?Bt1SAl}H>;CJ??x5=zt@2tb2s)O@mB$XB_J4FR&OQeOIfa%P za1_wGhE>W@oX>rWsW8IC+79L%+m9=X#0M8+?6l`!Mf-1*#g}E566rY_B`#ldi_H5B zSbFBPSpVdftg71cn#vI{x}E0l(N1J9(}YWGE%PZ>3alVKZc6A?Nljh$+#Q*Ww=EcD zpeCh;gx!tncu%zE@+3tD;Jnm9y76y0@CwhF)KjSxjR=;CH%!tQg)N701 zsy*Va_3#dRfG!Z%<*-7ID(HWJJKDrf!tR4un+97u<=G7Hf*?&~OXciBkMm$!O{1X$ zu8Tjby89Gzu2{PY4Gt`f#$_&`=@5$B^*U5wroOK}2=H3Q3}$es#gp@RWh+MwvEZO8xqea|cSpy8hX z$oiq~V_bt$7cjQw!(LSFB*ca3kAOLQ-M+b+ZN#b&4vc8D(tkTDVo$9bRB%nc*X3e4 z&AR3&2WP_=Qb|OXF^g0u9#8*#a6UVnNux-ih@m$A^;O**(VE^|w;s1x`_Os5zihhF zuJ$xb|8HUQ*sz{_u#h-vVzmQI+?gfvzWAiiOw}}w`k0o2+SjxyC!LxE!lgs=&Gm`m zx}D5AZ2l_FC4hoU^JO$2N1oE%6#so!)yrsizpUr&-$#*cnuwqNYcrc)IMaIeOixK$ z###^Dc7nx2^kRSPZ1@-`$ek@qB}pISB_66tZv^|gsaf0Y8$wgLfvJV8Mj<4Ty+r|sky&+o%&4}tch;Bg>d${mW&JYvjaKm= zW5FZ-mD7v6Wc;CdgV5}ODJASwpta8z7h~{I#5>DVooE6_lO|X$ctK{n2MZpRxZ2JP zRf*P7u@rab6ETAGVYx}|XD}{wAM_+r`G3PSky(w90~M&ileiy`ScFMlbw!OT4DO}H z@4@%2XeUA$9^ZE2$9-E~hwX09SU9*Y6?7a?cGCbu$<52e9dDYWZWD~X zH{Bg)%mp@;_iXRkY%k9ANfO4`WzTP-FxUkzcsuA%SJO)KR3Z!U z!g4Qr17iG^Nz$t9cYzGLINZom31d~ago%wNo?pC;x1RLBDvzcD z=LF)STylaptD)5-E&ou8s;*fnC^TsZ0Lb}3xrepN#d=+W=)s~>Pq=n!btmqG%IhKgMn3brKqOXn&64f8D=6KKb9vK#2 z(fX@jT#Q$Cygir3F?}sBbGdW-n1U*uULA+oSmP$=hy2?dz zi~?%Q&2|Nl=a%gSKQpMHmFp2;e5_>e86? z8$CklrXC(9*^pO)63CqU<2BBRak~jY?PwWs3(;B{Mo>K>uW_DLHsd>*+?~uqzD`S% zb0}^(C+|v3QbWS8LyK@PV*AZ{LRf<3NTd%F2R1Y6=7nY2_3}#9;&&frmHzf}LIEWpkeuyw>I6@i8kbTS#eg3@I8oU}AR{QEGa8RKvjF6P}{@ zD)XdRdy=O2JNozY6(#K_(74x)D%6Uj(PKaUNL#LoN*XcVrLqxYj4c@?)jz{FLjN81 zZ$Q`Z@&G+^{a=!|ns`SGiHM|0e!xC_>?30QAkKoFEhLBR6%Ja+ja6(r@v~H#>V1ZO zxy+;ox*xC%I7x|oSRpZrndi1D`|0>(@=!PJe~RFkIXw|;SNQnV>+FMMe!YV{KZ^qUn|)c1vNN^Q(yA0LFkS1!WR7~ z_&&mu_E*CXZ>e9pWvjhiCoU8@CPe7>7XpZe7xi3e9B~T6ClAFS2MZg4uTSLI8CMvo z$P2bsOv_x_I}>YfgAeOxI?5I*8uWyKscS|u^dw=g_EYSv`OBDp^hck1k)OsAISn(pN!Y(ui-L(VU=g`x_2y3!3_V?(!U3f(O|kCW z9CU4HduWiGkS6@osi3hqlRTrFs z773~}XfAQoA=`lUm@(nTt(tEhYp^-)y_xN=1d^tcm!jXuYvqu(us4#F2%(T36`E-> z{BxYY;`9NdCz7G@p`KaA8~ZMrNVqZqYHj5k5BK`JYpNpem3tmw$UD$U-ou{z_@^iD zl#C?Ek103&a}p(M93p@0@242uIFpQ*F=;!SaRD&|8dXMzF~=$d^7H=W1moa4#5|D{ z(!(VVBqL2m5`Tw;W$hMT<1lsDoY0V$$<*agTWP+P^5WLrQ|9?qPhCNEEqR@?^6L)- zS3`~^`t=@tr!)<X>rUu4=eiv6QtJj;#qf2VrS{~BFh)lhv6%y#`e-ROn zk5nd40z3x2*iuG429vK(kniK+QRlx&An@%(OqbW*0dC@D7=CD0=l&M<7?Lxe+WV}n z@Kqlr;LoJlefsy+a4!bY!BwLiDULq)574M}n=OI}5}TwPG-}e8JRU!dJ{HWcJ`L4H zGV(#Y)s$=tuF~jUrNZP4g)jd_$)Y1QJ_>wrYOzaHasC;XvwS(LV4F=OMfB?SyUq93 z$c0k=ZhuLy4PT`TjPnksnfDmbl7^f0T+XynWRk`FgMyE;vn*GcH=~0Tq7Q4B+6UFL8<3KlZ$vn*iqXaDc`vpS@`jC zW~I?+;0*`hoPIeZ`@?N{>$bscv@-l?z!KwdsoJGI|mYh z$00{%74H^XMtOE8G3jMaC;?-@$PLMCv z`i+F@j*{@+DN!kE=lVEL5lD`lGR8J!9aSc=FafSc(xPy7%_`--ARUoW+IuuE{#T#S z>0s9Rh>llDI1kR9>8#}6b!$-v)(5Etx&cG%PrN$o;5FFIAM{tt%Q%)kNat5PFBkWI zY}}DYok#q0@m~3U`|PQw5rFq6`1m5SWiBP!;Ib;5++(PlxGK6mJiRe*j^>q_$DT54 zcPi+&9z;M#uy$;4EMnnRbfupG*Qi#gupn(k5MD&;-0;(v9Ad})1?@j$!EUOfT@r_A zglshbm%T{JwtHpZTefhF^i~|7e|-(%nqpGshV#c2_2rTnYpzkh!f3d{(KL!zg17KKHoU%K6Yz?`x!Ae`RH4a? zKL&ogN(`ETgY&g$>~IBU0>Jsq8hFrPFbBwP795`cm6U*9RhupDY^;fiL4Kxkm2~jX z_2=!yQLzfSHETTg0QMP>;Vz>j-Q)mr+St8O5>FE*_Orn*$Fk_-svoH?YoYuW8oL~L z8~SrD0hIK>@C3aPNaQvi>x{WbFc;j?MBw9cW|%}3V9`uaH7y{$SYVD#M}+*V4x37n zu>?1JhJ?Fnw+ShPDS_Q6FQOPNpq;P!w-Bg>7q5avA1IMn00+C*;b&!^erbWJ!^-9o zBFZb{)!ZeXaV!PcvTXj-2#FN@nJ7do23^ZppGQ~*I;W(rI>+q^Nx~F)b8YrC&94B@ zY5fi28R%1% zT@I!5rfd);1Buj*&xH;kfYO|!WJLD4p`O%BZ+j;g+k0BQ*KjJOHGWjg%&O}}$ARA& zg~RIYNX9U`va6mLu5o)Km0^8!7nDwh|FE&k~ z6A#TTX>7T_+2&HHn`$B~XE`^P1q%e;kWqJ*^EXa1qO|xqU@o~!8E9qsxN~FSw$v9! zE}xu2At8@i0B2IA^sTy7Yu+D51=B^a9#Mu>8z^;&_0+5)Wy*PL-Y)ECRs>_HI31br zzXkI;|CmVJ1$&Yzw}a;ZMJmHaM;V99txzq8}FUELq6(>=VaW0+5rj@tV! zEA&bJ1H>u$9~e?t{jarIxqtp6YL~f%L%-xsm*iT_IJsYkZT6iG(Fu1+sgnbN@*_AN z(g<51(8S2}3&37jETgMP1`D5N1qzhN zZM_pB&wx9h7O2H7Gw_$~mOB}l4{qSC;(fc<#Hbad;B<{Cf-dhpoddoq6+Au~F(ixC zOY|>4d%Bu%`)noQKY)YO!Ec;0Zl5`@5w6po@dmX_g8tmQ%27e=gngy`ZrzhGRpazD>qUL^C)>DZN@3r z=QTWT$*QcX))x?R2IPe^-s=mKWaags=y|aB(ZoUj(qykGqgJm*NMKsuyck3C6M|DO z9t%G)4c=Lr!#ufZSFBqyUMe{mf{zZM%ck2TRB7o+?=$x|bFzaceUc9i!vng{SkxT) zD63c~=lSTh3f8ZPyJ?wo$?BbtnD{<@&^sw;AVq$^t!hgBo8L2+^%3_dAxzp-w(3o< zbgaIPlacgWgfT*<^Ylp@6Qfh(r$8`-e()%= zg(bEhGm+KxZo) zMGCwL4>1i<5~a$4B$7kC2`XO_ngtm?B)C}C&A>csLNcWRE>up^#`1(9HiSSb6;PEX zs1WTIOeQw~nt&{OMgg=17Gpxtfd~Quvk33Ni^KfnS)GI?mh(Sz7(0_mrZ9 zD!{6c^DYTH!1-w4ek2k}JWpKki={OU1IV7DSPFT?- zlTnHx3P~oBB_2=aNg)DYw30%nX;Mi7hcV0ANg)>BddN?<49O%4l_Zb|O73Km1ee}PAWrfbB!N3gB#G+*~kL+nAnNz#LKXj5!GLoc{MNs>t_cBQW zN-{|Tj}f@|l1M3nNhFn(iDpS4K`8SikOc7r$s`NM8aR?j1=7(kR+Jkr^1`kkgPEa?UyDoTG#RhMbckSxJ&nvg9BjK|l}#AfRF-h=>S^ zf}ntiiUEN$=)3p3zjOBep1YrW&%dvsr>m=KRq9%6y4G5AG=KC8pf=Jo)B`{e1Pn-j zz|m{)rfw|J2LKEWg#a=D0LTFVgaIH4>OzyizhDhY0H6XP0Fs0+B%RbG_?Ww#bkc&* z-~7jISU~u1xQ+y~{smtm!IC6?4C&fKI;BW39qBquI_V&PwuSv``uzh(d9`WxxsV5_KOW26a4Nk~a5Nk}P4N#G=M!^-2^Prsqu2XbQ9crYkuDNaQWDVoKenAA@xcCq%SkX<;UE5EcGO=mgao4t z|H!{g;sI;^_61V&cMPF_!N)O#HvG+VEbCZ5wEzGp82!2aJ1_nnj_X)A={(v3*ev`) zB10nlLPBxUViJIYmZ2%^*qvkQHpbB}GBQ+2Ts$~J%)>jxlOX055+ok$5h^YzCLyk( z9vkZ66+no@c@lhy!K&OJhi-A>h~BE)_OhlDrlHydKcYc=IKevJ%*HD|z)R7aTU`yU z5~~y&6dFW`^uWai1qMeb#j0}umaRmBkI~}XxZf<10jk^%rWQEukZ=M{ModObLX?zi z(Nf%MXq-y8w~vyQj^1A)NO!8-f0-H+6C)NQEfx~)D=w+1s3sZlCb$l z+KNd?Nc>juyNwCa*DumKBr4pCpvwI(c)buWk3bVbuy3TFDz~&WP8=usFI3&w(2$6z z@ZZ{0xg{kee$)SpU<+a_A<#jGWKM7dDR?APIT?k&$=V@-A>r1f z>>*ht`8QgZgHHyPjE8kS8IWq(PBn`v2*Iy&o}> zpyd(h5&X9qq^GB38sbg#i91&K-zz!h)ea|kM23XhgoKbR`Y%JuZ_|&TqLRLawT*|b zDz}X6p8)+v8U}gz66}fI$MOGX=>Des^U?exY`;kkR)mOxg~}lXQ`e?U>Kf$@~5*~6T<%7{kaV!s*zG#Mf|wm zli=TG|GE1Iql)-%d;g8ZKYaf(dXSp`!I4HK(jX`P&r$9#jmP83|Hb3C@&6a){}%l} zO8%n=|4+F7CtUwg1pXuD|H-cZ3DNk{33Bu5|Xka zI9-z2IP1ueU;<7shTuthktQhN{!vX6g9+XQ-*5sU!jljf67##NR>l#6JcxlxxMPPS zjvH!_UcCPgKD-#t#*Y|*BV`XRG(5zINK!+(4J3Mz3NeB=E-KiY5RUV}{Vq(1(F9WY z926819D&n}j0`7w{;nNFt^GX037Ub#0K#$6kFyO94GE7VH8%Mp1t)lHXauS5_c|_b zNK!~;|FMwYRrO!0BE&u8L?b-JNdm+J3BDeIUZi(-yx4Ipj`Q~T3IKoSV}y^i)Nd3> zp#=c&rK6+Id;owf0)T`3qoe)WqoV`TtPgaNG_e!>cl(eU(&WZh5$01x4rq|_PaDtybV-?L02l&BfC*pCfN5Bbi0bBt$ zz#Z@)Wu_Nt2FHh#qkg2^3?Suh5D*N60AZwjjUZ)hG!O&C0&&1e;1rMmoCcDBWFQ4d z1=4{tz*!&z$ON*19N-*~3*-X@qpaeJ%TmVW*^J*19B~S%i1ZsdfeGLaa1WRS z?gI~iN5Es?2`~lB0JFeT;2AIvJO>tlMc^f{1iS)X11rE=U=>&c)`1P+J+KLE0Uv=K z;1lo}_yX(!-+=GHPhcN701km8()=L=gn@7n36g9GKPU*|L19o76aytdNl+S;0p&miP!UuHRX{aR1JneyL0wP}Gysi2 zW6%^d2Q5G=&<3;x?LkM-33LJ7z!RVc=mio$U(gTq2Lr)iFa!((Bfuyy8jJJ0B!OLJ5*aP;0 z{onw24ZIHC1c$*RRpdjQB41@|o3&BDdAWRSz2s?xm!Uf@h@IeG2LJ$#% z7(@~x4UvT?K$IXV5H*Mr4sr@|8j=i2g`9z8K(Zm{Ao-9&ND1Tuq#RNSxd^F+G(eglt&ldzWk?s~ z3ZxG*02zYZfDA)MA>)v{kV(ix$YaPf)yoJ1jyoYQ;K0S`00P zRzRzvwWNvCX6Pko2eb=%1=v zZbNsWU!mWj`_Mxe1O|tZ!N_4$Fj^Qrj0wgDZ@!c<`zFm0GV z%m`)*vxM2e>|xF@H<%}k0P}+d!a`sXuxMBuECH4TOM{(-Wy5k|g|HG>8LSdk1FMHM z!!E%t!@6O8umRY0*e%#?*aU16_6RlwdkUL}Ey7;G-oW0$-ov(GyRfgYAFu;B0EfZJ z;N);BI31h;&I0FvbHn-Ic(^EB5-tN*fUCeY;M#C~xG~%uZUwi4JHg%Ho^T&H5gr5& zgGa&R;0f?#cp5wdo&(Q^7sE^8mGByP1H1*^4)26tfnS9W!EeED!|%ZF!ym(E;LqSM z;LGqg@OSVJ@Q?5>@bB?kRd1#)Ceqs3BiWoLhvE*2vLM2LKdNjP(x@T^bke} zGlVt59^s5|M|dIp5P^tLL?j{(k$^}>q$9Erxrjo<1w;knBBBA&f@nu{Bl-}7h?|H} z#026#;xS?tF^^b8yhf}d-XpdVyNGXyeI$T{AyG&Sk`~E;WJPi!d69xhQKTeN4ylAx zM`|MtkfumWq#e=;>5lY5`XPgmVaRCYNn|224Vj5Nhb%;1Kvp1Ykd4Sz5b8>!=ab1nL253iS-N zh+09tLv5jUQQuJqXb74NjX~3)nb90*UNjypj+Q|yq1Dm4Xd|=*+79iE_CWif1JU8= z7<2+U6`g_3MHi#X&==8-=u7A>bU*q!dIWt3{SZBao<}dC-=g26Kcc^)_sKzWBsm2+ zEjbf82RSdf5V<(HEV(kdCb>SjDY-SdBe^@dH@QD~D0wvbDe@HZ4Dwv^V)AnG8uBLc zcJdzb0rH#VW8{IJ^culcR zu}$%nVjlxy$S{-`dJHRu3nPdT!^mQkF@Z?vy^1fs_%H@s!Dw8I-w{C6twv^^}(=yD0}KZ&Hp^KA@bTT%deSxlZ|!a*y(W z3PwdvMN7p@g`*On5~Gr(QlZkOGNQ7ia-edj@}UZ%iljP8l|q$Cl}}Ylb&;xxs)MSR zYKUr-YLaS-YMyGDYK>}(>MPYRYA7|DnwFZG8b>WaElw>*txBy!Z9;8L?L_TKO{5N` zj-^hdK0}>LT|!+&-9X(&eTDiO^(gf{>L=9m)XUWGsJE&2s1Io1G!!&g8de$}8X+1f z8bumS8Uq>&8haXd8ef`VnrNB?nsl0TG{rQPGz~OuG*@VbXhvx!X{KqO)4ZnHpxL4M zP7BbI(NfVe(sI%Y(2CK@(W=qv(VEfP(z?=Q+9UUtj51lZbG@UY?4xI^|4V?>}H(elIB;6^xG`bwR zV!A52M!F8VKDrxp<8%+{=IEB_*66nB_UMkVNGuhW5sSkLVkNK&SWT=U))MQ4^~CyP zBd{m2sn{HBF}4!hi0#1kV{c+7u#d6Nu&=P|*iYCW^dLQ&o|c}Ko`+t9UWQ(kUYFjC z-k#o_-j6<%K8`+_K8wDHzJk7izMa00{wDnd{bTxh`d9QD^t<#w8K4Xl3|Izs27U%{ z26+Yz215ob1}6qDhCqfWhSLmZ81fiO8EP3WFV$5VLWUORtWW3CHm2sHy9^*9Q3&yvMTa4eB z045X@EfXsfFOwLPJd*~KA(Iu83lo7Um??%SnJJ5@h^dmPiK&xmfN7NJKGQ7I64N`T z9j2emP-Y5d24+rXJhK$DGP5qT8M6bk2Xg>(B=c$Jv&;p|<;)Gt9n4pmhnXjtXP93y zuQBg1|73x(U|1Mfa4bSB(k!YhdMp+!PApz5K`b#W$t+nc#Vpk<%`Dw4LoDMgk67ke zR#-M!zOo##qFCuz*;xfxC0LbMby&?<9aueC16iY36InA^i&(2zn_0VAhgip1AG1DZ zeZ#uN`i%`_BWI&$<7C6LNwcZ4>9bj~IkOSiLfPWk(%5p@O4;hz+S&TqM%eDNJ!M;F z+hF^`cF0b~PRq{5F2F9yuFS5>Zo%%v?#&*;9><=_p37d!UdP_SewBTM{Q>(N`)l?O z>|Z$m4m1Zn2PcORhYW`rharbGhZ~0Im08Ei^3)0vT^5ewYYZNRop1TU_6{Vcb;QEZlsgMLZR5eQqmm zH*O+#BzGcrHurh%TJ8?+0q)z}kGU7P*SJ4%|KcIzq2uA?5#o{M(d04Vap3Xd3E_$7 zIm1)HQ^nK5)5~*<=RVII&kD~r&ktTWFEuY4uOP29uR5e94?$FoFiN&+$h{5d`tL&@N?lc;m;zV2&D+Ch@gn9 zh?a=Ch>HkOBw8d@BwwUjq)lW%WL#ugWLacOMa^3dRjDF zv`n-~^or<+=p)e=q8p-nVlXipF%B^iF-0+bF&i-tu@JFSVp(Dr#2UqV#D>KlioFn9 z7uyquiqnX5h>M6TiR+8oihGKOik}wG7B3TT7Vi}w6@McBQv8GX4+(?>R)SkXTtZdC zSi(`lS0Yj(MIv9KTB2QIP~winQ;9bcpCpbXF_J8jf|7EQI+9kBCnSRIlIfGVEi*0iO6H@?fh>hAi!5H2w0%R?M%GI*agoN93Q#FUxPsA1F{Luqp^CC@L5z*em!bL@A^x6e-jxbSsP~JW*Iy*j6}D zq)=p46joGHG*om@^i_;eJfm2m*r3>}cw2E=@wMV7B|wQviBm~TNma>I$wetZ>7-JY zQn^yA(ty%krDsa-l=hSn%Jj;7$}-A2%GSzW%Hhf>$_2``%3aFC%1@M+l|L#Us!*zM zsEDels+g*{s06Bbjhbl^ySrxCUsA{0OVA48q69(8cG^Q8crGk8YeZfH7YgQHLhzs)Oe|} zrE#c9smY-!uBonRu6aT;R5Mw#K(kJ>NAtGkjOJU-FIsRddM$n}IW2uHdo4e$SglO0 za;-~RLt6K>7PYpt4zww?Ikm;LHMA|YJ+#BMQ?-k=8?^hhC$yhwztjG%gVJHq5!O-G zG0}0=3D!x}$=9jX>CqY0nbCQx^Hmq2%cv`;tEg+F>#Q57o1mMkdr`MbcSLtu_l@os zJ-8mDo}iwho{^rjUZ7rrUY=f!Ubo(;-i+Q`y*+)TK9fFPUs>No-&H?YKS{qpzfQkb ze_Ve~e_j8F0os7gK-56pz{0@8Ai^Nc;JiVz!JxsU!J@&I!I2@gA-AElp{}8wp`T&A zVYXqF;bp^N!)e1ehF^^kModORM#@H}M(##oMkz+cMomUnjqVw}FxoOYGNv}>HkL8g zGj=d08lN;iXMEAP+xWKeQ{#8WKTOajY$jqR8YWgI-X_r|87Ac>?It%(o|vqdd@)6s zGMfsUs+yXcdYDF-rkh?cZ8g1a`pEQ^>8=^fjL{5lrfgfx9G6A zWif5>*5aEb%971e+)~rh#?sd^&N9dHqGh+`nB_Ce4a;9vR94(pGFJLlPF6uyNmhkc zjaCCz_pO$!K3T)88LaWvDx{@$PwOb_4C@N(%hn^-v)1pdf7)Pda5mC5dNz(Wfi{UY zg*J^g12*?2_sy?RK~9X6)ANe%fQ~arQFy`u5KD!S*TkCHAfM*X^I!zp?-3fOg<;kaW;>aBv88 zNOCB0Xm+^f@W^4s;j1Ick=;?kQOD82F~Bj=vBEYy4Jb&yWV$QcKz%|=EmkG;ilu}=oaLb z;&$HclG`n}8Mk+Czuc+a`P>!V&D=fRW8AadYutO?@43Hp-#vji!Fodcgw6?v6G10Z zPMkl{cH-8F*%KQl4m@Z*1U!^IEIbGv@gBJz^&SHr4?SLc?0J%V;yh(N4L#jFBRn%a zt2}!=?|8oO-0^~Yv3QAl>3BJM1$(7>m3nn}je0%v+Vlp!8N7wPHN0)T{k;>tOS~_6 z-}0XI-tayo&=K$iHG(z4kB~qpBD4^05T*(1gaaR19|0d#A1fbUp9G&mpJtyMKGQzy zJ_o+EzJk80zSh2ezNdYQd|Q2Q`p)`p_#XLT{e=9~{cQdG{gV97`?dLv_|5rk5+OuJ zq9{?D=s*l6rV-1Coy2kC3*wGH!k^7w%HP1>)jz^N)BmD>pZ}!)EC0O!iU95ag#fbv z?|}G#{D7u_>jBdN>j8&>*g&B`jX=A=z`&Hi(!k4sV}T2SJ3)vbwjk*s!yxydsG#hi z+Mt1;M?r6ceg@M73k0hMTL=3GCk3Am?g+je{5*Ik1QEg(A{}BFav~%;BqyXkWH97O z$XdujC|#&fs79!LXi#WcXnAON=$+7|&@W-+VccN~Vdh~zVW+~1!Y+jkhdm414u^%a zhD(JTgu91Fho1{?2)`CS6}}#R6u}T75}_U86cHAY5pgl1KjLA;+lZf$G?9Xl>XCMl zL6K>Z6_GuW_aa|Kev6`v;*U~|vWW_aN{K3q>WaD(wG_1%jfv)sR*trc_K!}EE{*Pt zz7xF^{WXRnhBrnz#wx}?COM`wrYq)7%u>u=EGCvORwdRZHXt@7wmh~w_Fn9(*za*v zaRPB_advURap`eYalLU5;@-sV$J50N#cRbo#fQab#@EIV#6O8&KM9;TO-xL@kl2-YH}O^Ck0hET ze3E98Q&MSelT#%2~|wq=fGE@ghp zqRtY^(#~?tiq6W*YRwwWTFm;IO_eQ}t(EPZ9hIG%-I6_;{UZBo4rPvDj#iFyPE<}_ zPD{>c&SK8jb5!T>=d{kboQpn}f3Ef1?Q<{BeaofE70%Vkb<2&-EzE7toydKa`!kO& zPc%HPV%w;lV=4Jk6>18!# zLuGSipUNr91`3kFwpo)x&hKgGi3l(20X(~l3^((z9 zPgj;z_EkQq{7{9g;;vGya;%E1%CBmxny6Z-I;du@mZ`R=4yZm`T~~dxdZGI3MVgBu z7Y#0YT}-@Kak2m6^u_HOR1IH^W{pctY)x@ZSIzyJceT)3&RV5fyV~&D+}caE6Sc2v z59*lfWa}*Jg6cBs8tX>tUe^7nr>~c+H?1eur`Olk->83H|FwapLA1fJfzXiLP~C8? zVXk4fk+M;!QNPivF|o0taiDRwai@u*3E!mK2n)(n`oO+n{QicTTR=IwuQED?bvpScGGtM_OtB`?IZ0=?fV@}9kLx( z9U&b#9jzS`9d9lJm)S2XUADg*dAaa%=j8{NH#!lWJe?YyZk;DPOFR2IXF7MfFkM1j z23>@%l&*_iH@X(OzID@gOLbdx2X$w4w{(wpzv%&bIC_+O9DAaBihH_y9`|fsL0=KL zqI<>jO460;D??YFU)k%$_Dc1d_XhQ5_qOy-^uFzb^l|p7_Br>(^_}nQ?VIk~>8I=$ z={M~6?N9Ho?;q)3?mxK7dR6hN{netWSqz=>$+!}Z}us_H= zs4!?Z7&%xt*fscQaO)cRHT*UGYd+V~uGL)|zP5brV2E`{X~0ds@W%X&y_@tmrEgl@47-_k^YYDyH$U7$ z-@@P0zvXi){Z_-R(Oa*Ffnm;JwPBaxlf&i1gTwQ~-$v+1WJat#3_ zMMjNB{YNuLTSo7UuHA;;=Dn?b+w*qv?V8)ex0i1pjl-4(p6 zf7kc!nY&GQ$M3G*gWu!5r*qHyUh2L2d$;ecOhP8PCp9NMCX**?Cx<6r-3RXD?rYq4 zzn^&j;{99qm+v1v;C!I|!0o~52h|U5K3IBi_>kkF+C#U8ryo{7y!mkH;o&3BN9vE< zA0E^ARI?JZ7PDcqg|j`g zGqYcwVxP)BwR;-#^up7Dr_Z1MoMV|&o^zQ?n5&+c*gZi>zUWHv}cXaCZ4^U zC!5F58_fsI=gfD^Kbrseocg)sbF1eO&x@b;J)e92eSvvFX~B8n)I#;bt%X-Fz!%&v zbY2i%oO#jm;@*q*i{y)YlS`Y+m}T*0i{C)1!uG*O+(+$? zJ|8nbwtsy5ad(G)M`6c#=k!kP&iKyyC-P5XpDaH`ek%Pm^l51q+~wUh*bUgt-R<6e zy8Hbz>u2@Po}be{w|;)`dFKn=7r8G^UlP95ei{3+{+0Zz_*bj1(O=8H-uU`z54I<; zXS^4(SGd=|_k8c*8`n3TZ+_o$zIA<@`S$HQ>vy&9p5M=Wzx4gl_fJ3Qe<=QN{gM2m z@yER%n?I?4%KUWrdFp4)&#|BD`xN^U`!@Tr`<45{`)_}d{Sx_Q@hj?A*{>VFULU{@ z@CT*`;Rojrt{p5LLJkEEjSoW(iw_457mt7=z9Yk-rN$191R0B zWTeGk019#g5E>9l10Ib5?4&&mFzE6Apnn{sEe!}H$w(-QMAo4OKqv$bfx(~%C>iPJ zFi;Qzr2$}Y1TB}OrX`Y&+XEYxTtFsOOV6Wa)ji@lwS!{dl@3pN{Ykr!iLZ`PW|Sm_ zl_Zm-<6o2jAZZm8ia?TxQZ%FmY0`2%^mtwLcO*$f8Yt{o0i2Fo$`Z@t5r&{o?jDgY znA*{*eSNe5P(Vo%X-I~s0~;s!GawnPmdi(gZE+;bGS5F08#xXDg2{S0R@A^k2AvLR zWoiUl?;mMpu;fRdglcReE|%l0$r0tD^;nG>K^imELPn$68LsX+Jh;~kn`wZ0W|(|7 zLp6iS)?lsVmRauXtK75Y;nt>H4zxvP5d4U=F8;+*QwdFXt&EmIbJ{}%{?a^EnJsCv zX`f2E8(6M#BC*q;!5X_B88?v$saoGCFsIcOmDX?=#$z9<@S>GsYD7NIJeld8NvihS$gOyyw;33u zbpu$+#{$FUl4kX+4*>~*cqM(3gINGzmC>BoT8{;u;HH~(1#1v_b2*lf91Ez|0gI%} zdO3{-1h1=v*Y^jAbOu?GRzIFVJ<~$WvtSndI!Vp?h#ae#pSIRF(l9Nc8FbYQlI-xL z92eCL8>bd%gy|*gZ|Iw{%UEDC>)^_!OiNXiXD0Tolj^a$N=$;|Tspinh;E@gp)jnYpv;krMpG z5m~CoYEs@8G3AxFOWWJs4q4r0@L#KwMf0rk?%K+hC9=mNC>C6<+*7=?J#|N=sq?ak zZQ%V&o9MKQHT-pN3#K%07EHgOiqL`nY7#E`q<$x84}GU9>h*cN0X&-ABM&VWoRf^A+{(>0wh^q0Y&~EOGSb zs#jOKA2HZDEO57h_)1rBA&C{+TYf6UfUE{VY_K-RH^2cvd7AX#vH{0IpbY3jhDnGS z)E{SRU^fC-K`N0(xDc6P6vvnK`f--XVXX{ox(d3=A_>1?N?o;b1L6n3Ocl5*(+pSY z9UqUevQ?jFq8Q5beCB=C_v{P3tLI@Ome28Qt@_?chPBr=%T0IA<@!DNZf88)DOu_N zqf(xtC*;BD)z4&sk>7Z$QuN+#3a4JT`9)Z9;}@oOL*=*4nKqQfyd?)0=nL{;Px6hMetUOM1R29P~JIH1tqM@>t}*?-SKENGjzGj}Y@3 zU(>&^f2B|~)cO9tN=I1)zfZq;RamBHY8hq@=Ctunw8anH-B{Rw4`e9qlKJ>%n ze8YfA8ps;4Sf0_w5!JL9lvek&v4P9bThroQ#NZL2RqP>fUs+K%G#jyh$7SJ-eTy6M z&Xq{T8%P!A0HUW(#eYnI2g_5ccXzr z4{78kzHarz*44h4y$-g7_kcz=j z6XqamX;d!1uG907u}UgwiInk!EH?r3rY-Uy-(SKesk;*XOR73jand~-;hvwoi<^U5 zu%zKYD+%u8eFWeOv`rm*vQ;82@SUDj>)4N0G%MD|CMTb@ntUFIRL_E|Y*RCC1;*w} zNh&`Yn>*CtqLLaE5TBfr*7fmvNz4cMu+PoL5!B-@V+B}e>N24HU;L=KGq9wup`SK|U`zv? zsh)MV1wdjwb1bt;cmeEd19WKf37qg0cf2Vq*{zjsIt$=aepY9FgZJErCt8|9uYyn8 z(i8+90hJx$!O)n<{qf?SdvU9^O6QBsiW_+@&DGHsEbiN*PYa%k7#fz?cfr`JQ&mVq z-49poCO@kaUQfd6?)63TGCL)ew3LL^kQaSYu5`TlfXNN?Uyt9CCytBdcb)3x8pH(T z78dgSGLjv3+*fp3%cJZ3)HX+-;HNcH>o52{9i|~*LWH}LepmbDQcua`If~dzLMA&S zR~`wJgs;gRl4mHV+{hHAkPkYmP^@CL*f#g>9R_0AmDCrWAPX5Z?iW)GiU@l*7z|h# zKIcbGE9b?39`9!CSO|>wVKeLC(s&QB=doh)xclTL#qm#DO2ml*rEh*+8uJS4DsG)x zlSJlUm@X-<{rIS0jv{N~sZ6C=p;y?GComnB{=yxbpdWA{n@c)yyR&HuLrm8$hfm=2 zvS(?6SMpXvL)E(J?Voit&h*Qhx=8fQJQNfqH%uvk;<8pRu-s#B-^%70m*w6Z4Gfkb z->(p!n4C5*>}9Wgd?=e2$2oJubZ0N<<0e^<<_BGfOD}KA{flFuN-!^H@m%<3&~>f5 z-#KD~;7!#$nDNP2k9#QN&of4M1)du;d%utiRoS_-Rnz}`FwVhs-E1T1DTXUt$sdK= z6Jl?>PZ%QZuKEuHgE6&tG`{3BNdH@P*ZF?9a;y1YWd znASoZpMjG=H{Rh#+FlFGR)`e8kH`u>hVr`8q-ErpRVvOE<8$np+c0zP*vr&ExW^7f zhOR4p`Y5@-#plF-+_bt0hkl=3eFwgi*z>;)M%E67dm zDHw0+Y^_D@&u3iH32Cu;#DJjCgPS%FT7%@a+-q;dp5{fOtnz;KePOwZ?1Xev=NtjA zx4urW#$~t;^iN+1n*OO>ZFOh22j zH3v7MYta!s9Klhr=JD*Fs1p4wDyeo`?P*)8J=>+m+Y+*BRYg>jY;`O6raM3BH+72G z71>)GbcxK!Vv9~w8FX}wh{sAkc15*ShXw9sP>Vf~;ZDY!+t5{0E9Qv@0(E(AC<%@O za{R^CNsU;5rw_+ng{>pKfOuxm=)nOwZqf)+YzoL=)^f+DvNTxo0jdsB8PDPw@cOsL zd-Ze4q|LQkdV*6~Z}w?f*;_K|S<7*jCg8XN`-a|^^`<%?ua8B=GAkciE@_loCNu7t z6-jzq=aS``mwCuUdQTh%%EqC+*KrCp9+&8R9!_nyPYec5c1?h8{J234Q$QT+>~A`Sv^&}->x|rrQ6U@%B5u1sPACNtM72xbh^jVZd$>9 zTx9Buo9F!M{YmFU+6y+*6>u^9TzMRq(mU_CYz>}YJcE~2$2yD8Lc`Pf22prw%C|)y z2iq#fRqnR6^}Z+&mAmTXA&EAyYBC>8erTz1-`}W2S#vSBXk_13gn0Gdpj_drXP#nl zla?w%ed}R?f~`^@i|I)Po2v7?95*&viFbLqj5OaS%QnLMEUVO9>+hW&v{2aCOk;f?lhcQfDN>~p=fjh>bZ_G++DWTQd*tA6?9 zlE{{=KIz!~i~oCp?}hFhd;T>U#@$EW1iMFaH(6pc(3}!#8-^QpW`xO@L2X&(v=t%e zn+`##kqQabLCe)|fucj+lTVrBiYiC93&QW>t^LtP1>G=a7{{4woxp^{r~1+RzF&$e zcJCwRR&QSI=Y4OKbm*-K?S|%Gdhqm-qPi4+QyxW^4Zp!H?7Uig(ODlhRq%l}I6%B# zn3iuH65H1yEP0o*d9&WBq>^eP1YI#6?C5&%o@ur=SpQ;c=Z_4dYYN4UMuzGY_tjUP zs$GeHtP*>&EO0sKjyjQWFyw^j5?aZI<=s6KiM`jf2cqRJh)JBMbyBBkxisS1fA9vsoM-JjZ->l0&EfPyJ*ye8 z3_;ye?-il`ve9!5MLwlIdVy~J;@YlFd=caD6~6w{eY$UwUc8ZOF^(apgk4)1m_w~; zEHWbkdJ78|Gdz3udVY)qYIGYL+U829wbMJNE1*jx5<}NwerJ!cMATG|B=6X9EfGLCIcsH$l z{R=$G+N5d|nNNJ&BIln&pMPI$3{=aaoWr*1ZQf_wJfC7l-qAd>k}-U_TR2B^KQe!h zJyXJAOwLKZ|fjk+1-U`(#3$kHmo8&BVfFOOAT>1)!D$~IT}$avrU66;`O>$6feq9cz> zb1=bjIk}_hsh}*g8U@DZ?4nty%@Hs-2ZKI~hec)Wj(#FnaT{;DPo5m+zmC=#?SrW1 z9R~1b$X+Y=pIYNBe_d^1y)veqo+3X9P0^+9|kDbPU(6G2$ZfTx( zLvSsrHYsttnB|n^jEUgqnOia%&BUaH^^KnvSQ)Yg*vy+vyhgJ&b5jP5S(YWF6q8J< zm9~ro!2*qBbM9hYAa3GIW_kEoEYCuUNH#Hxr;lkV#|8z#6YJngg7oJ;m}O}&*JIa3 zAbI|H*i4G968$+;Gw;c%+*yq#2!u44R$+mpy*k8~))6T|5Zsdal8Xn;Q8h#ucXjbN zj{AmHxrtXajfy?;wA`ssXUALA!y*hX+C4=1D$)fH^=Gw`HDT1f$?W=V3quzqRytlW zAp|n6YdK%(5Bo}c0)rRWw;x+(j2NE_ddxJz-E6aZ<^dOhF9hNU> ze>k4Y_Ca)?VO+x-#ul1iGO%2>xtQ?nTg&*@+cD^w@ZJ}T=dxSun&#BB^EMWQZ_^l6 zjzTUg`O*p&TLaX#^0{OI~YHV%cny|x^&c*J+`O)?TLW3q-(L=k( zEqmYf&R@{`Jd{9f((&3Vs8=yU{1WebTA|U=9&5kpVwrqeZm&kuu1XXxsk?h!NH^%j zne((Xd-P|ER2uKAx?wm}0@5_Mc1x2~T4@|Px9dD18XWt!ITBA9hEb_*!o#jsMm2Y| zbZx(}%3!&(9=#K1xX^4cvm^}PM5&LKu@%`}+db^h!d|oq)OVV7A?zg8*ITkBai7~< zuVt{ck0VmnT2%+!7Ibs_?prSU>AI|uX0XM+cI36G=LS5fSKq^($VZ}Z4Lx3qAxk~5MOXwbGb>oO<~Ar7ns_brrcz31d!oc1!~jEQ@`iE z{v0Fsc%?Le7(R6`YF18W)b>_+!LOlhi%maGut2yxbIjv=v1MU5DjMf<~Zku%U zF>Aj@kqPZtYM6ZaVh+oi_@vC!20Xazv&pikp6m&lxk^k^$o25O)%cZ#oKvFq4)q_I z*gef!y2U!a``mYhMel06ht%|du*Q3(vB1+$D_$RZ%FV|=kAKccS?WuDn-RKFsD7?s zipZK)^~0p9Zp`;#S(&6&XE*X52iIV_)XX0$!K)EDC%-GB-F-gfi7N)B2`d`TSHw!Zb_)-ks}xel z{?}M>>Ht3$7DJy|a)pB@=}qpXd*lOz9qsR-c%xtoc^2Owui};voIPoujgvx~>ji$g z6{^h-AD=5!)s8Z(^bS0H!K2BR;>2T|`Jok}vlDQteJ~}v*Zo8YTX)tXK>e(2ZQgBb z#Be^vHv_-d!cSi=!8Wrqujbur9ba^5Zq*EbR;OwTJuet#P{0%XwXbhP@_O3mCBpJn z<>mFc7Z)hrTk~?*<&!B#nQ(@=%rxNjo}Y?Mtmw`e;P`$Lu>f%y9IcM<%?a+m@NvrZ zaZ6O&1-^GKtYac5rha6km}p;XbGgIx)|J-hrb@XMz15mO4KC}9ND~eUY}Z(`;;?mHHq-pRC8QEAn56~LVTm-w zDbKV3=ukQXbS#Yqz+Z{%Q^c3YQVX(UP|eA=uqgpk5K|~EoyxP8AoWSLS=!QOkrn|X zoBS(3qL_*-A^pGfyrLbwMozuVa97I?i1c`Twd3}NkTn5sK3f2}DMT8#ZX1U+nOD2! zzo%%#WwM*AJeykH%ZsfeZL#bfw)#NfcKS)solQ2Dxh`5GtmYxL*3GET?52Ym%a`dJ zM6^aJc=a`u(NW)&y>21a7M_YY)SeEm>wHiXBk*pn|GB%!jojrX7L5RlubM(?`=8A2 z4<>wgEZWOs>@~B7{n`82?Do&6AM`rMjQQT9S zro0+hyiSeNSYB#oP>6SYQS{NRy_W?=qpa)GQ>J!lw6a3=jG&;9o@=&Yus(lm$5z!q zXnG|3U`N6bj*pvXf9?aBuX7T6k?jzncm4T-Ge!#hQGVB2sR;I^(>O|0bJ%Hjx(FKT z9n;?5Vb`LBGNh%SZpd#R?e8*L1SQ)BJEKmRDD+sB3M70u>6Ox~;Zz(YX!f#gZhFMv z`g9#y`5V1z8i(!c0{h+maYNK>06Z?VRboZcsec;onERT@(cRGZBICzQM`+zyYzbda z?&s@YT2IOezIPIQkSMreXc*#THq{(v(XimYx}VuAWI{JwHAFMQ@pNWj&D@R8zqcp< ztigP`{s*UhcXGNI@2S&Ix>f+sV~wq(qcapD zv#5wsBBqY*DW9CP-HzzJF`yM@OqDzye;(UpsES>vPZ;L8SVn(xMGZRds&pf0z(((b zLDr1=t8?27Q{i{CH|vjpOhdFu6iXaybp4C+EqVc!h*MH^U7uJnW5vOA@u#_XjPvk4 zufur0vuhcWvzFo@%66*_uS?$>J@4-u=ufha@T*Jb^Ey}T&H2mzX3$QU{XNt19@EBQ zx0)4^0)}wG&@(o2F0pOW1g?(|Mim z=nlja&e{{A{7=18c`2-2Q!q#!Sq89Q%Z=+e?Tyh5t`4s98FCqCr}GUmaOnV*a;mjJ*aJ>ZLfeNX*50sDD-OX6qH~Ht znmoH-Y0aKQFO@6aOPCTGVZV=2jn!jWVyGxVUJj~&XifagSFpe4N!2HEzX)3q9l>CD zi^!B}N{_J8&|0W=cIT4*h0ZTDNbb`S#f-OncMeIq)+b<1Fyk{S`qfYXUFZoFxf7SZ zk|Z(J6*NBjoJlQZ-IolGMmMm2p!9*yyoV}x;u z=gWoBY*^J9aPvmc&wY;;4#|-jA1@GhQp%GQPe$QBAv;2=Bky;-8Hf*-_yAK%D^nVF zLN|HnFhgRkOd84-P2AMe+2-0#p_rY%yExyRXmY#K#0>QO}tk(y&_AWuV1kNwh(sGu39 zkxumPv^MQbLt|x4tSslxTB_sPUf6Gbcu}uokoxy>7)V_V|qYsPNo0@YC351 zNK`;kP6;%Cqd=@6PfBfMJ)4=wmF#*LnDKw&-2TBGcjjPcq-jGc^3I-IWkC;#o znA3?!A&ekA}Ryp$BAutPvLewn3md0mW&lLmqN;1aQ;+{8_fHN-lB+~Ha{NO zqSrY1+NM~#FIZtVt=lBV!BV19k<`slcrFT+I<=)6Kwrocw4UesNu`>+QO0~t&3J}m z%nC)-{o{sopdkqc2E*AP4)v1N;+0~}y{Z(K7rTn;rFB0jfCoS;j#g>u;;9qNqimHt zm>fc6z)bsftGfZUEZP7P6FmT`XT!`hL+F#K&9xPslk+utVa({ZKuU={PZfim@_PLg zYBpuCi9eQWI3vs6ye)?CHu+X^8qkS9t!GJ_Q8pGxFNDa1_o)Sg0g@xsI?ZToU5z0i znB1O~F2Y(5z*56www+Z4**Q8kY+HHppc>ub)~iZmbtG-L;MbLnvhBi9sR2e(tvru< zlVO%+aH2so1te^0+#zL5nJFH&wyk+yW}Rs%f}iED3tTlO`VQ?R>NCjg^sVDI>1DVH z5j=mLdfVF}7X?8Q4)A)_uWzE3{>&F_JhslH>O#39u$o80D@cVP@!V8_^pvSef}|^< z=~^5^_e&}gK{K~Xl#)cC?}KoZx3bd;K|A!`v)d&=pd=|Hh~~7mE)+BsAnM*LXK3I{ ziCTR-K?ki(F*9>#Zif(nqy+;zo<(ddFjgG&5C9wLRbksbtMYn4{X zTWcFZ<9OaA$*pbXT)>@As@*O5gK>1;ruOVc-)<|l@f?kX;wW(!Ht2Iyje0~894K`a z54`+8;VRav4M}Y7SW(oEpg-EFOGA<`;_VkBuf;E`gO1$F2H7RdEI78xwCw3p2XFzI z`Wnv;d^1-Tn615~>na{ws4DcXIZF2mO1gysw#K@16YyE~t$d)RA+*loB|V_&HBI=j z;mzf~8FDO~Y0OsM6w}urooS7dqX2~PW*|WZIjFod#;u_D;gQm1DE<|0Z`>Bj452Ae zLflyRgn`3uBoLc~^a|N~a-&{(XEtLQFT@-t7sIgE+iuy^s3}TO^P8ViQV!|beI!+R zg!r|OGm1NeW-PabD&Z>7-NnOtQdA5=l$eR=pkuhJiv)iaRia%M%vv&AE7NawryEQj z0!;13^VX-#9h{|7O&$GbaEtpJd$!h8+wG;qk|Sa!y$Fr_R*N89w}|9y678nTNOsj8 zp2P?v?kju}d(C=yy~#7e&25uIc{B)(>BpK;71D?dia<>`j%fiOS}0>;DMar?jylng z^e6$bB7{JvMJVsJFa;tg#Lzoe$?oSQ4|2!G$Ll6MK*v0P>82MGeUterh%t# z%>zLaF&${cZAXeR_n?>~dNgDI0CeCw&?^A<+LTkGlu#=W6l3(Fk?l?bXb?2b5F}H8 znlV8n6md@-si_;&z}}+95w$rZdH_?vq&|lvQ^25+UWpV4az`K9xm*3A*z+v-H1-gi z=FscTwB^mBm8nIYTM&V{C0b*wB}ag3s({;g1Kz%K{{Xbt6T(}}xEmOIfvjyzAB|Hx zDaL2gm5_J@Y!7ouxuc0Unf- z5-LH12SwR!xe0Ug0o&fGWPBRag!N|KUbbnXpagW#h$^T8!W!A`_) z^Q@aUzjz`@oyBUDvbNB)aXTLV)tQGo55d_g5(w{BQ{<}Ayj8kx8CgUQf}{6#hE|e2 zD%jTc+Vu%S0!SKp2dVE~iES>vLX)W~+hJWA+;UQJWOm-$&PqZ_1u!(lZ*6epI;6(R zNQj;5#hY)3eQh|Tg*IT2HZ{4kxom_eLLElLo%ZM{#xzQKAA{P)**42YqEwZnD5f`> zkXX5(03Z`2cBu04J93b+;FTXr0NqA<3fy71ow`<(Janl-VO)ys8ce4?I;FQFj>#n?B$_y&iE~$e0k|DQ>O7$a;P?#WXKGW@7m6&ps#toVAJXMQj zvu3u6(3cbzlrK<)C0wZ?*3e+?H|AvGxa^Zm-ct8snmb?c{f%5-<(F+d#Ja_@${RBP zfO>qv2CZ%Sq@?z$5Ae^8KJ)kX%eF!+ZxD<72T&u>;MAto)6%U#RsKgm(!CCwoJjHd z#kVw(=0@}m^ztH_MOrDLq)jhsBvK!9KG!ah&??4K06G%gn zE72lpqP-F}+iC`Y=lrGZss{#tw)OJ6@#_8!ahmOaWMIg(yLC!U-YmBrF5M7Eq$NNE zfxhFK`lhv|mejS75RoLP?G^Gv@u`?Gym!Y;QLIfqTNb0sv9?JX8D@Vp58_dhXMQHR z16O(iDG~CxCl_x3zRzkdz|X(QRflJft>UjV4C% zHNLjTQl>yIjZk4+8TYPev(D|8bs^0P8^Bkj{JTv_ZL`j{f|B4W9nTbdl8A^Vg%e#5k=T0`wP38ac=ZJx zr>7BCFywP|XX*8IuHcDJ3cL ziVoUGr29wrsI|%#mX5uQat&FNXsM2*j9g0_cHZehlxuMt126~r_N{&^ot1}M1vzrq zx#fCy2>SP^?qtMQWa5KGMMsl0Q|8=x=5+%R&gQe{mP>?eVA97WIT!W;;}Y#sbl)N5 zr0yG1R6st&ikjixF=|p7bb_Z9=~C91p0Y>ut`Td*du%Q9@h>$agcS(;RKG3w+hWq& z@~$;#*dRi*{UlKhMrig+Kc`@q)7kU~3C!uaN*xa&z^L^AK>1YT3&b0^L=u%RszCOx zCCUCayL=IEps57P78L-;aCkL&wA25EE;ez8P@c}E-1u_V%*Id#_R>XI!-J=4WBvF~454dLSY^-e| zO~CY?;y+5EGqzE05W^q3v1KU@ytc-q>I5%%{{Xc^Vr!>-LAUa68gMjL98?7-_$PvW zq*XzP;f^_Fh&1n^4!T}M6B>aMnH@re{p%|{O^i~dXP@C^%j7A`TdNUhE!%MorL*WQ z70A;(jas1nE22~+)d%=>w}0bqp7X&=Yg&AWBhpneoq&zZf!B~K{yUo3(sPO@oY#M^ zBh2$nigLZ^Kqe~X6b90YJt=jiKnSJH5_h2#=vD)@9<-vB6c!3Jy3*o`6@#rRBvAR& zptb`@(TW6bGzbEb8`9>L05pxMghBq*>UpLp`|m)}9TD@T6oohr)e=GiVw^VKkP*_H zC>;Vi(F63ON`ZaVyluI!jea|!Hhw4LDP70MK3`g%dLbH$i6I;Q zQN;DG8MsKbr{+wr)`F!F2qjyAz^NU851VFD3NQpFqo_%7VM^fgd(_{&?r^RtXw3IU_T8Ro;dv>i2!d|%F$si|%C=IJ(8xAT!GukREn(l5fE0y7v zmqy8!ld9$9M#Hq8v?$vxZc&LI&7L4Jmx#Ay+?Q1|oxF#Dj{EZT*SQtraW z{iF(y=S*~1Uq5e#EoBUB?fu8CXW2aftWOUyj7Jg2SW+BbB*I)CYcGEg>+;*RYh%-; z9n^W;`q%DlvS7wJni&Phu%_`FV31VhlbWC&wIaZ=TWoa-OAaZA)A{Y*8dj6;E2Z&| z!kQSt?eYAtc;%(RX)k;mz^yBhI!OXY)!%a_bK}lO4w5vu(3Og(*l;PkrkNZd@gWGj;%ZvQV&jNgWUN zJ!@2?r4Q}5Q8iC$W}};f;I^_BVSM6@6eykGh??&21=hFlwskf%rZkxv{{XKZ#<&Z^ zTW4->m~AqIm5EsG*1Pw@l?!WDG`13z1k7!~!%Xa zO@gHoO0<}l?Q0px7sy(WK!Kdf(8PmQ*B__f0rEt*Je#cR4) zF(7c0?KN3vJU#3ODO_3L%RXpsE?#S8BopcYj*a< zoxizCfN?hIFBZ&h3iz4#sc(n51v$*Vytjwl+_=IuskTf!fz?`ONcl}e@yvsmu{*pc z56ceQp?51OS{!)|9bAn{TnLHlUA6wt&%^TbHX(1CuW{DwZmga*=EmKvPPqt@wR8wP zb^U8w4i%ZC-)E22dY(-@p4cL($arpK!tqmTga@5#mM#Hp(eDHe{fMrX@Shk?JBb$E zaf?f)mF2wCvVs&j3Qt2_9pXpfqYln(o1_+*#jVDsFH(S$xA3dD_BG62EckPGkrRby zJ2z0Ype3wHrdE9j1_PY%59mi~t)ELhJoheAH1xiqkJMnX%i|}<_5T3r)mdKkY$Yu? zrkfzZE&f#q>Dr~rc@MM{_5SZa5~aL2~gmetqgNh zjib+MaX3w<+994Tt=k(LJI3`CB$nJr>XJs^PJWeN-&)+S4!O46sh3O1DF7wjNRB|? zf2~_SBwCxSrFH=Tqj@5K*GjBf#2R+lwY0)nS{w3RB>_MHIV0q2hDGuSOW=(^faURt zUp|DV;apd!AIz0|q;PkQLvH+4=B`aro*A`oglFr}4x@4mAtV5@I;4Hh+>V_p>N|<= zS~^}aBw=eE=4@fyno%@O3b;`Q^rDXxqJSF`DM!6HZAw0r2yf{|DE@|ob3g(qMIXH) zC;_n_Dp9o_DMZi%VA78C==)Q7nV>)-Xh!0mX{Lb#nrRf{x3vzm0UQ+S0DE&z8;Ock zbfAiG;LsBk?hmygiZ-M>QTb94#}o`8cB4Xw6bZ4yh!g;hDcsORaNe8%ngfb*N4)?L zHuk3{Y7tKYfrMA0X^Gyw5)A@8aWt}U%db6XX`d}xmbB^sM={pEFnINMWqsm)Gk1r$ z>()!SA(tEwT?6=l=$^y0SK2H=gk0g*EjW^-EwYl3CS-9PYvca_;+HQR8^z3BfXXe9 z%fGv61dss&H~#?8>;S6S`bFI@)XKjOwk`)$r6v%NdE9RTt#1acL)Y&TWHtu=^;qF- z{^4sRgCk+VtCzs*U)|oScGNlAvU9(KJtm1>WLvgvkR?Z9S}bAJTt49nAPxsg-n_pl zy$R+vH50uKH0h!~I`CInr^5St zSME{pZ(Um66(2dUOcG~t$TL>&;ivG!`*Zd~u3(R0_V!n9zj=K6%w3@A^4kbJL@G$+ zA8Op;_`TiB#1~s}N0RDe^&8cLfL>y)1P1Xpe6Tldr`4qP+OA^NZFMDPPN&j6%a%FS z{)bjz_LerOX4df_ShffO1SFlfn&q#>23^UxY9GI67;_KJ(>9FTx8^r;f<}b7zQhec zorv3>o|uir(%U|3&M8U$B`m6b=BwP^aG`ALm&k3@fwx0lvTBw^TScMtho+jy@_(^P z-Qx+zFa8?ozkn1O$sK024lww|RMVqE0UA7o`q*nz_S5iQ7!mUM59opTx9wkVeX_y5RBKs%+#x9dPz=*WAPH3#_Tu30@(kT2!K+^>jVBIP&BX4_V!F>2~nr{qYGIoi52!TiaD z_-luZH!a~#E6g6Z9(WHoh$P3#sXPk^TMvS!RPf`t`*T;0PF1@NGYL`;FhmLY5mt2B zvPJR1mFgM|Eh}5Usd3@=*^fT5%XsD>F#I=En@dSaaUGPdO>A=>M#FJjIm|hmbgtP= z6gZcCL1{n2XWF!Q0V>j4c}boL5}B(D3uVm*%yA2Bh!P=0`_`@RT%KDa_MgLqydsOr zIU!yiKD;d}D#MhywJZG1kL3rb6>i`uKMWF+t$lh;WMg@ZPAI4( zB8*fxrBX%1vCe&(iO{>z9Adr-uQ$OK5 zS4CeimA0YER5Xx!il8%1sbPkEmWXK~cHpK#5Krr{`&4qSh~ak@WcYVvi>y)hpJP%& z660!c4UhOw$knKL5ye1!C~4n>xLVW{60S-03FbY&%BRPA(ZH)3FttwdupiQdcBcvl zZ)&hr1u8Tmf+C2eONvwgBkfD&MvQ&v3jpSXQ=@7ee5e@MqX{xCKAA#1KkSVbdPXnby>0$$FJJJG(6oSMynjrmY z3LsQPVkU?aNPMV)C)$AqDD|a0Q@M%&Dd;Aj{i&dD+MGGuPy>=a^zb5@2K4YYpa-OH zPXd}Yr=)eDXQBZ+0#Z)WITiBf@yV84wDEHN_YkCuw1UY|6A1u79V2S`j)4bi;ve>f zWxY4JHZU1&Ax)`*pglx$C;AVqaLle2S<|}6k;AwZsCmF$N`no;WBJwp0I935!S)MS zGtQVA-BH_V-m0eU)Rz3ffR@1`2UAx!hqmV5EwD_bY7#_N26t%o8VLOCmfSiNIG>ei zZ>&qmDhGadt)hKGPtLu3k2NP~98X%J-5!l11^XZO>Tzir&mF5a+$~OqOKLm+00`!! zH<(abhL8$pc&xijEB+-8$!VnQH1qFEawL_QHWpq&(m;;kU21cLV-4BcgtU;5c^>s1 za?m#LDdVK_Yb6f>3Q9tB^fd)8Y_yfCWAWTp?MPWmhfgv@z^a2CxqlS%Ql}O{Go|hRr{99Ldu>y^CZ`GB5FKQ5f~tyN=XDBJJ#CGki&41x#qK-Xalz$ z%`vv&du|DcrsZcpZk>{xhi>ArG%gN;M%CLM6_UlJvu-3HfwE5o``07*a>{)3$XYBhlfqMx@O?Y0CE4y}ONEvK(n+uS(~P-QhLXUVYn(FsE&S)dQgU zb*`!j4xpt`Jv-Kq7QG!L z0HzNjrYpNc%>^rQ&$vF-dM|?qEM4+t;@1JYa6#mWj-K_8hqSPw@?0tk00hjBoigZ z4>hgAE^OMhLRm_h4tj%rS@io%ZYyX+)`ljwq+(}`l-!yb{U}r5(+@Ef*s8@7Nb~JU zAb}LbgRIXTC?Y;Y(%wDk3NlKox9L59%-j)apsz@(tsL&(WaiX=#8dnb)bth0VJAmq6I36A#LwUC~;4U02DL8k6kR^!*ln zT<;WZF3vh>3Mo#ep(|NJ5;x;=YU{w#1%oI`U?l@;gI?f~eQ4s4l$5NfBc}fV<-gvi z*m`{_N@IBXSDVQ#-5%pjr?mmpK5{W@$v+ zIfr>ll|qtt1R8Q}D%CQ@gkVtJClkeK37Pbwgtwlpn8v)IA zT%4LF-W%bIxM?U$-boAd6#_{ErF2$Vyj_~vZNN_H**~HA*Ex87c-_1DLdsTs`t+;! z1-}7{XBGg`LS;r|_dfO8A7x!aZ5g6}um$Gs5RhWN~QpF9et*rC*_}96{8+geO=_ zK|@lfuRme8-m$T#9JRFgfOc#1k}&CS7$NK%SY6}gY?SQmIZDrLu#*p%-eYCCis)6sZ`lodJnwGPV-asz5rnN{E67y-FEUU_tZ|={;)!%O;ycu#(`7K?7iS zhrc^+cHb5y{Yz+&8oZjO(rS6hrBUcz6?FygS0& zAuHIe1weaMd>QgfTP%P0qB5=^zT%uWpa(?LhQev7=W2KoDGJAmdSF+W z;8Vub0P(r6M2hoB--xeJHlPYy@r94OfcPhRmR3;Z<;!dmHuUbIKmKdx)=SJ6cy2ER z#k*~A2_Yy#fV%m0nx+$yrp8U{<3PaaWjo%(jxJ8%grl8%%5_ zv$9hByD*0i!(dNS-m|_PEf=DVfh8-{Rk5rLM(Pl#>BuIs z^_4fFQzL^~3|(@KGJn^4fo2i6$)dzA!+~4Rzj}?`T!|nV_pOd3^~tXI_H6(iw%n+#10Sd^2vHFH?!HkoadrO85wFie45YnF4x z;_Q;fExp50{{Rr$dHI3Y3 zL!re)Y%0dH#am(UuQ4f|$R@e7BIEbU%I6QluN!L>Ey%iclH+Z3kT%qRO098E0(@y} z9ab~`{{Y?M3DxEVD&iZ7{JJhH3ZVLFYs{BnZ|Qb{m-EYy|%?{9c(EoOO2@{ zgT#vG+)om?z;O_VWVa~q#g$8a5OxQm9|O9yDwH$emD z1#fafQkSD)5WPpbyrVrFHOqG_a&??#mX zBl^=w+MUfcTp9pueP|vgjLaQX1zG;K$%5;HA+WHf8wvo$>;(bJAKt$OOpX(D6L^eU%PQccLD(Z_2zv?{cq@fhxlDM`dpvhPy7KJsupX~m~Xl%;DE&<*N>ENY`uvUHE$Tar%WVOYxrDp26ZGtGHnII=J7 zn_77yeZF+(R#M^UCMamHkFmkTj=eVatm_+uiRfmu?>nhe*0L>L0X+MPjUE`U@5xrSOyYSgER0yAt0=vT$q*l$W$BT~1 zK4reJ_;+=>H2`)T)DfG@o9ByGi&Mj3t!4sexvc}Q++*09ESXSBmP&Of!2bXeiR=WL zm0)&^-r6|c9|*UyQkLS}CR6|*sQaw&2Vgo?^^e<)vl3c|o?I#?xfd;3I_nE@OMWktyVvrTXz(3mK37ho#OnJ4VJW`0+c>yS@{mtpFPrUjT~}}BCPIz3!1Zb(W2pP zrKu@eQlJTl{<~GBVw^SNg5~JAvr~jA1!XBK=T7_%I`ytd@b@&miDac?3U|#;7DHDq zwxOw3>-51#BT9-0KjJlDVg4eMlRVsI?pE8;@Ge3P$BYfp0k)bZ-$i72#F&83z} zImP)pt(*H6idt{?Kp+n*Mu3shCu+@IlW(AH^GUWD>D}1Ohg#i$5H-t z)hQ$e0#A7qRE;Pypp)!&73XuUC?J`E(}F9hQ5d&wg)2n+f_ODrcz_$ZYVz?V33-N8 zl&q1kf zitoUC+uKZSmRIX&EC7F--ntIbz+tw_VI)VM>cU4!QHsK`$k@tZ4AOQKrsbx37k-TcE1y{{W%oM2Iv`6bCh`l^#E#N8XhqOMy&? zqLh15p-zBZJ?I{Hr%%qC!KXk8-1MfCPCsg5xuSp*x4jBSiW3w7yHYy}N7{iuY5-Hc z1HA%wq)4U!Gzp+~pn0GTFdO^RfTRT&isxjkF%)^`konORg<=AXN6LsDDFHx&MK})o z(4zqLqydQAr4R_E8&iO%Kp-jMGe8b2(~5L7H6z-d1a+V|r$DAic-|@Ln6EHI*Qh&C z1x5I)$STjC58wF>mv1noi}uoyB%}fy1OC!a(!P3FTB^@2C8S9$yy!4Hnf`=V*2KiY z0ZBU$M1no*=hxyF!}~u8yh_Av3uT2k^(J zY*Nurr^^2TEBu_s(yyP#kZjpomklU6=pU2=$vrAhov)n4np3GBQ?E1%kVffD4Pbln zKDA@zY%dJ6!f_WZTSL#h=+@|iC<9;*%$kGeOLZmJu7VO!QRR>a1|WUw6YP48Ot;#u zjfPCbek#QYVpJOeGM&U$?c2F?Niu(>SGfk^+XXEL(hlJ>20i`iUh<31>mHO1_WqRF zR} zmIzM`jfc5C>$VI#zD&*4>Rf&iHva(cZQ9ulsr3s%I+CSJk2xFaUm-s)>ssp@mu@ap zrKw7FgKyTsbJajhvJ`dlZ1Iv=D**0=d*AvtSxY|bR=MT2fB#g@_*qLmHOB#0z{CU$^o zyyY23lS2+1yOWCGI95FE^VW^BpxdZzWUOqYokZ$C{r2}2rNypWV)6UX;@DGn1lt6s zZzOtm`GTpb)(@3%%oh?XAgK=}DJpP<69;8`bdlbyyeGnZubCLPan-HDBorY&qDlUh zOLC03M!M2u$qvZxg#IO`FJsJIS!r>r_Pm>mSGR3pNz$M}*uuB>095_#>u-*;bZpyc zn>;xd$#s%R326#Q3LD6gq#v2_B)H*B-$(`#Cb)NsVguSq$mUb08)0JV_q3|4Zgc^3qp{jTU?>w3bd05+wR9sqIlTq5*Cb(WU<=v&Y2~#+8iIb!l zWbX=BQ8G6js>jN?^7{q9VXKuDq15OtICTw4+en_Ai3hQ+e7DZ%wzoNxDLM*Z zCBPwJN2ol7dl~6h)&YzhPTR~wlH*DUI@Pkq@iW%FPK#CVMxSSl*J)lUanUbQlIjXq zxgtRC9V=gRh&OKfn{h=6(hL)~ZI91ytvoxWy{iN_AI5%TrE-`cxET0(y`98o=;Q$>;B{F0LC&1HdT@ZUAx#1V>JK zR$TJ#%?PjiM!LhZ1l#`rwq6Uj8pErdX&OQg0QZjdTv;pIKaps#=D5AtoAa|uZQk|)h4_Z_yaZChF14e$-)egsc00hM}_vV~; z6x9RPfEzTaQ0qVdlR&|uu9OeyKmhYViZc`mpavdhk>iRcnn@G^s@xiIpeXxP03(jn zfgeg#MJfQ#-iX}qKni#pQvmTu4&spKdN2>tfsRPukwzfU8&k*LfC_2ALE3;3{&Z+0 z7~YicPU%hNC=(nfn)OY20oIoBR zKG1%ZZlv|EM#$Cyf;cnUp*X3<#rZqo&pkd^<%8vNeA2?O1mz5_mUL;jxFz+VAIm%s zwLx=3x@}TcsX!%UbJP5);PK+o{J)K1 zCII@H@_C`MPAAte&FV0F-*sk_3Ul^D#FYhw0i^S{N|xJV0p_4^cBr-P5%EujCT-tZ zlGBA!H0co@+(m5df-EgmT`*9+DkGA63glPD>Cq(tLKLV2*mGF-&Jfz5er>ktST^@g z+$1eV0NE%8cK58zKq;G5DPEsJ2itB%a+~1Ohb1kXY<%^3gJ(+A37!Fu@~G1pZsz!a z+76M_4xcKY++j0(RHYZ%ZBS2h{{Xt4T4m;N_MCCHQRJ!p4XEx^kOuz%DVmbb@uZPT zdUcjfvZS%d=);*+&F-%+Nd;Z2-~H;sZJ0LxLfs(jr8GewEvnN>Iq&Ni;kNw+WafKy8d}5f_gq)-sY6JuwhL>U@gz8*X48_8u#~TG zK2WP0mVcSBm0VylqK1DWQHH0sBZi*&6$Pf1=qs3`9eycWn=}W4@1Rx{{Z;Qn=z)m z;d~i)Q4J+&Ne5}*MAt%PYl{nu#1QJVqBMz;M{2e38!R!zAh;B_PYNbCnzxD=riz-6 zRh}*qI5O8Hc%9+J@`fjuAH?jCbtU{f`J^YgN9OxYNplWw!83LMt9(?Z#^oEOR|zdG zC)_Fxccs1;XQd%+CDcJotMdu1Han7Vd`Y60u-kW$xgo~Fr|(eBq>R3oMtAe2HkL?o zyrbqjxI0R^Y5Oc#6gIM;ZJpjC6p{hsek*dD(Ee<8?$DGi*Ng3x5|t%dMyVVWD9ohr zYpgs={5<7cLdol^EGdf%(<)2$$k0LSu&!`UKwXvn(iBRRz)&O(kW}d!WAYJP z^mExh3Vx%BO&1N$WHzWRG~fjWRHqdQ+umb-Y9o*4YbPBS9(h__4mPK))IWB#rb#3I zkaza2c4W*fVa(VU65IC5T2rCKf~14gdlSdKL$J(R+UEM*oe8+Le)P1oYm#|N79-vg zM?2QWZA$uV#KWbfzKJ9EDt$;O(#USy+=RCwQec7y*hPC}>r8tLYROndoyF@(dB)sI zN=D)Wj2MBvM_I)d-Sw5JX-l{WTP={1qT0{!syPKSzsj#{n_z5kam&+GqeDYHV#p??BKN1I;x@b41XfY1qXb=_tsgcBZKBK+SZ2?vSEs z0r#i?K%yxR)}74&B>ReR6BGo_^zc3Y)C~Zsi}=aeUD%7 zS(A%BCM>BVFA_s*wOi;3Pvr$E+edHd&(5~i*uTVEVd7L9^5BJ?r8@#DC361R(? zCUlwG{clV|u3PF_RvlYXq@eC&gIqQiJ;cf4ZkKR{YJw90k=)X^2}6#dX@>!b_o&_z znC)J+x(qyCxI{xmBp4k0$o4yltg9S!T&XXVXnmBzlM)vK5`B*2?KJkZH8`?qhO)`N zV$B9!T$f(hgaBd(zr9Q@p0mMSc-!0%%SH>XDjQF?xjlzz>rgBr;x6p%*@U?5*$z0e zcLQ5 z%0q~C#gw$#fFGI;;Xzw-xZeP@BUra7vj%P=vO z@T>tLk2?#weMr1P<1+U@Y$G@|P&4>5jX z&`V(tyQyp`BXsIdA{8Kye|n5_OB=M#8S!t2+`lmV=2%g;xJWBNg=yT+GxpxL{{Xg& zt))p?8VVsGtAvxm`qdnBT5fSccGM+grB9(G><9x|exvlMWu|6L`z~9k1u3N^3IJ|M zC;JihtN69;@Y#=(M_i;xan(C>XrdbqB}D2_R1P---~8*Ivn~p}ubM^r$u@wf>Is?j zpUgUr{{ULL?%xQq^OCZqg@n&D&`nc*CSomKx~p|MwpO@CrEn5XcYV&IfVLO#+A;g2(T4YG%3bSplTDn8lRuJ1dnsrYVMwJxu zPe6ADsl1Z=tY;N27M2`YQoO!Pjib;(_kr{^X<^K_V#3l^w%%|9tv|qLWY2H&tjsc| zXlhqHvj;P!%j_vdNJ1}=>WfV!(x7yS^pUqJG5-LXhU#6i?b>uCEu{I*KG=YdNs@lL zcO9y4b$Mph#mk;^ZTV~STVX2U#YUuo^HJNdN$F4w!d_eWQf*Zat?!3?Hm}}k-+17U zfAMWm&C9er4~l&jpM^MO)@{rzUHRJEQ_a5n{-6R7keDCLN{XbPdg(I5XekI9bSFrT z)e7*FBe#+A*TTAj?edi`yiAFJKu`EY)Yn*0+kbwBz2~OsUYiQv^*&kh?^o6>UZL#z zR|o)7G`9wUJNs6-gJLIr=u(;zC;*cth%-$csLcQ)y(9kssw3?}fEQlWs2Wme0QB~z zlSrBb{pg?<6bYfI{U}e`od6E>(=_8vCXkpqQ%=-LBATt}7!MyhVcgLsnonwy0|ahq z(KOMpr-L=fkbt6TM0BS_bJBni5%QzKphs$QM)VAFc%>WC>9r6=Iuc`m+MPctU{~6l zDWo>XeCVXbAxH@nP!6F+klKL=ZBHE20H?;(P!6B1IVAr8;+Q6!D4-q;Q_!fdF;5f$ zr_Fh8*Dzq$3|+LB+e<_V*+~RKh$0NZpP{dhx#KTAm$ObQYowqs^PwR^OeMtvl^*{9 z!U zNNVkEp+Kt5#{x-|{{RRwK19<$YoS4)gpHdij(89zwDuT-*nQ2)^yzJA^3^+t237ij z_o0bhwT6dbVW2@qiS={LeCrLy&siDmMs@wjPng%76w6@>ac!s6g+L>d+{F7{G4coWt$ntI z;|w$P0Dnr;-=%gK&Caq3^Ar-TQWQE6eZdse6u4q}9C<~pm$9{7 zuIp9XOd$zL)=4|1V{rig00`&apP4ZhZ&}4L6cwuwNOZL71P7fa7NfkA8lZLtt4uQ= zUKGQ{x$~BSf>ppLs2?u<>Scys1?B4p7i!b1dDovIYAH%%N>UPgpWglcRl-=dyDY~x z^|YMe*^%6I;W$Gr2iI(*d8kT5iAuDeO2ULBOrJ1s)3nO7o@VoLtU0DF$WuDig(aX; zR6_*;2ch*w$Em8%CSt9$_1(*>c&Se*NkSY^2u7b!1aG7P&~imtS&GKe^Df(5dCOKM zg}Pk3Oqoe=1g0alg$=e4Cz`2|D9eKGH@R{gvxxrE%-C}6?hugQ!dqRgN4;l*Zsff%Y|Dcz2L(u+-XL;ukM6(w%DKMY{oH z`drZ5k^)MG!Y6IaRF??8a?w{eE;OcGym+I?T4PFoFrLIrPqk;|eoL6+YiVHRyQTir zbg7W}a1=h4oRp-e(Xi)IhNVE*ZBqO;{VTh-xd~DmxoCb};al#0WFNSw{t0fDS`XS= zsUhZ)viTtSWTeR-&9A_UgOA5j*@f87#QyPV5vZhtB6f)EVx)cXNbl?O--r5chIANG!)FO$yuIfDzi5u4t&! zCvMaMixl7|dIFp_gIvd9O%dO%0nHiSiXDgbrwSctjfFa)Sb9*W4d_&mf(1B8_xVsA zsoWZ1ICbq#9^b7tclM{ud(=q_=|EDQ=}iDQaZVJ`P6zEk4%&!2P!bIgXaUKmgCJ7} zH000+y9f7(`HK0SkQve_38sShOX6kJe<`d8BiKjtUrUpxF=w{6Vfm{b4@ z7q5VS?>kmUsd0`!x#>L*{ds=JA!a15+1CSV^VIAZXz4S_nd5MN)f>ivb<10mtQ9RO zUfW5J>LC5<($6_nFD(}hz2zw>S`rWO>fjjU&fi+EpTZnXvdf?IQ>Q`{ui{B01OEW^ z_N;tzTppJ!B^J>gMyD=Rf;1t=$Veyhrgr&ly=C8HAGB`;%Si}EuPwBl%nm-hPrYN_ zUM(2X-cpcKloZNS2}ubckO#eCn|*ieG^Gao)uCXd@Cgz}y<6I)%PFb2@QDJg-G0vB z%LPt5Aps|oBoMCAxu2b3S=)ygFS>O!kfk9+@FpaEcKg=VjA@r!TWLV}ptXeR;XaU% zBjx&&O9^p-@u&t{w@6Z%Jf;CkIr$&Gai)_j+HG=U+TjjfU9{GP4>*LSByOU1;-q56 z=L@-N-sl$vt9nv8Ga#Kqxd4KH>h`UthpD%^%&lBq=~?qMu$KvdwIM+x>^84_Qu4~( z_Y{OJ(Rh`z+M{O57L`E%0H`baRY^&=w7rfCZtFGqD(sADk*`q>Ct5(AU@aw4>__NL zS-5s2o7`_4!=G#^!+I{z_nD|JLWl_vHYrf=RaOY_%Neo27)#okQZy@cfv#Yasn0vrhddvw>wrgEpPt-9OD27EtdLJmp>_zLRAN(nK9qz zQOgeytni$(XLzL*TihmB379&;A3#+x{{U4Hqn`Xh%n>MACEYTLLe;Bq3S*%JrFGcD5yXG7?`Qk))% zLux?$$_IfY{{Z%iQBBII={B*(+u)n{7m;x6?}_18_X%4|w0J+|p(9V2nb^qPO7yBH z(gE%(vF({fvuSMtG$%+T$s(e@7UhOA##pe!gH4sTgzOWrAZ|#Fi6^gWmna012E(AQ zf9aSO2-lW;zt;6HQ8fH6fyYg~sie}FrLh~<`e&42nV?M4f<9C}eA58XpZ!qPMs}bB z%?d)4&;jjAMFC2H5!icBnq4VCpasC7eW@L|;(_Ce0BJL~6b#W(ng?#Q3}pkjfkeFA4+%5f_u}uPy>hQ-jB5b#?(NXi6I&z{Y?Y%qZ9ymrw+8iPM)*? z{wRYq+9H4*P8`!g5^2ex4|{X_#Qf{$SBUN==P~0B1F0@uysft7XL|Y|w}rbdym)Q3 zEd2pL+P-gQ<@{~q%Lh-XOM&iZdd2lO@QOV*p)byp{Rkc+!!6q>E+8m?V8v8<>GWFN zINFR7Az{kXoXQ++C zf}wqFxbRYe4V~2oefoUs!tUX;-n#nI3L9ZcZ7DDTh(RB5_O13Wg*3ZWxPcEO1f4t* z4^L>Qjv;g9rIfhX^b&$j(v$vGvy0@W-iZKRwg;tu2S|TXV_;mX5!Yeds@W>R4*t5*lgZ@<`+o03-hZdK3vvXda0jH0Qbz}| z_N^Wzh`V=(w8E4NYPD@%S_Em-O346q^?rh~(|FbOwYa*(8@9i;VIc&t;+1mTNHPHc ziR%@g?9`hzW1Hi&klo?e7|u6v)vDC1d2dUJfhwQO4{d}-)xFG*3tR@x*HBY%-O|`7 zD+NWeH!u#{>EGV8SS~5>s=Z;I+0vHVTc7}vkpU$1>Ib^1n1QlMLk;4V>RU=KAh1gG z7yzJjDkJC(Qj}YgzLB9Br54w0m}T6+i!Y0CJ@BhY4uUoc2`SVMzW)Ge6>0c)ot1Tj z-@5g}!mRJxa@kyjp+-nQ;TxZkHAz?^!xd?pOIx-ZlpCA2)(9S2Oso}{1C8UJ05vzj zaa${9UGrXPpNTX&*7ji-j~v zW$t9U0*XmLm=o3}D<0R#IqX;Op^`~Lu1?C-;NPq?&k zScS!~N_Hiu5(-*E(h5mC!BO5h5+)|O`@)|E*L&n#UL{GsWFadJh|-eafh6;>JvOfW z%2;bSb_l?>PnJ}X=O~nvDPjDwt2b6HGL~XLI^d`hBQI-8Q5uC;~mH!0!Z5 z6HZSwz;sfjJ5u0OKq6?zQ9?JT4n68XEiq3UiJ&RNW5iNpLK{J+$O3>HQX3jW3{5DC zRHlFxI8$lSKm=2TG#YRptpGSv#?;YIPUq!86IGk9#ub!t%fdo@n-`V|J#-rR&5S8a zaHdSo_1!;+Om(~$!t6z|=xzJ;CfT(0RFGBr57xL-7eY(mg>Tj?&ib6bQHkt*2OUOJ z`)O{v+BfV-d94V`nw&&zj5UAl*wsl%cpl(fJIlrc2Hx-DFY1$xI=0j=A8>d=u zu!I#Xl_V-Q*a6z9d~YsZt-@VkiEmKUBW{K(re0`X6?#+(OhUE__o#+)$;S~vbP|^d z3o1zZ5gc(>Fy(M&Zl_G9t|vaY$-?ax&{9;jiBgl)@gE^tWtAkI3c^(908(ggni zhP>`>mv)4#sB3MosYu+B%=hW{tUDW>7GbFgTHFLFLUe#QA2T0n^Ge*aybBG33*1K| zul1-n+dIZwB4K*SxU#grWna0vD|ECuOQ6AzsN3c%0ri{L8)cNq1h)YsI8{A+f_Uw? z?OSVHT{n0Va2tT5XKbflw=Jerl`JHaKKe)XuUBx)M+|+Z!)EO*K9ZN39-x$_3JB&z zOns`2)uTatl3~G?#vO)VEyi`1U11Ju8T7`c)CA!9OBLH7&-novqc1e5_%MoBNjZv>ic5#3hsjq@<)o zm1{{8GCI?8wg-&kRxPH&*r{t$m~!Rx0H#Kr;zXnb%ASI~YEg!KnAk$SVIxdT@hLi^orJ}7kAr>|{>Z?ZcJ-j+?>7x2)S@|n!Bg^%$;A*k1y#!np6n~t!g73i)ursS_IIa{nHTQH=;~ad7=#U zpaMCiG$v?I(9i>^O$rmzfE_XKNSTUhG18W;sep-!X+5bEO(%byAOSm2CXTL@*fT(Z z&w3p>qINXWWK$Rw73QPAy*QflQKSG-ieWTP^wMB=paC1rATzb&*RvL zXf|6qV67u!J5_&)!~cZz`DrN6gfHpAN6zX%8Vy7L=eA*c*Y<`0z_v{4+^!QJpCvBi?$|8O}{nrwGCvvIA|9F1|9x)%I5V zOK1f=R5cetg%qTFqzUA~HFV^BgL)qQ`7X5cX=maTAsUKEAfMs^CMWGv%)jBQIHlW6 zj4UM#yQNBT^D03)l_~?0Ol{JxI}Dv<+by)KW~H@)hLXQdxY)-}VN%DVmR@93&96{f zVnnh|M0wk`UM{;NA3P({(og_^Hj<+fdq+*G>cjB+Yece^hOaIVktdG-0I98Z4{haP z%%?(I4WmGV5xJeap7ptDtuHA@(Xi9grEFux98sis{MlueR?l>_lqCS{A_3+FH7o|! zPT+R0cFv-YM5n()LC_R-QBrzvZan`zeq zRG~2-9&2jH@`0nS=Bj_hCxF*pG+|6Rg66Btr%u?#3&1dHK#<<1{`*HpmX$hBVmSxc)=)cCx;c&piQA3nPSmHh0uKGCQJJOQ zkO&y0Pg(>}pe!>&c%jyj-hd3#Nu$<;&q@NusP95~rygmf&;TcDX+G3R+L}!OG?}k5 ze_C-g!!!(OJt?I9O%i+44%7hg$7)BV0k>Lk`BFeQQ2M8@`pO2XX z!A6jChGSz3xpA*KyCkJf+$|Ftf)WV{out61hvL8SJA?ijatqgraf{s7c#_^3g%yoO zL?7QRJciTn$$&YpkiIGSiH>JX(sNE~#21NX8~1$m+mZXGQ4kWL)WHxs0TeN%XMcF3 z{?Z$)Lk75a!u$!Am}EDx<^buGqI zsP!sOr2ha)=GWYkR$3XG#ap~r8EW;+LgNv-*4v79?Zr5?t74*5Ovg29cr$v3Fl*~j z?y}Xwl%H^~P(Q75_1k5)g!&Rjp)RvHyx86yj*Ca$8m^KW(=#MVN&85w{caaL zFK>fS((^2Oh`Dt5I!@8e3Lx(zdddm_dseN5!=iQ-xk>`iHlLMw7JU&OWg$r)#GbHg z#ngsbT9x@m@C40gBH2k05(%2hO@a!Cn2@A{#VaJ4r?#~hETz=QN{7hTp~FY;>m$8L z8?kQbepS2`Cv_z}rkK1rNKs0XqytSzosmo1Wj2lg9@VdM*)NnRjb%H7*woS7H6B?2 zNb4i@tv$_$lIEBZ&lTA*Hbt+knn;zV5~2wqI-yzs8=FD z@4=>~%_<5I2|UepPL48-ni~uT$H{#GgcaN=D_gKNZ99IoImy{W2uW{N9LOJ(rX(mH z{c1n(o>71!+ADW~Vv4l5KvJ}c=9HbB(ykGGWelrjcGzpkDA0g}0l@c;^=DQz@rj2@VxzEzlAa zkP=SFOll;OPqyUN<%ZC;Bq^;@dI^Z{TAVpSD$tcmxrvCa?jGV6w30ywa5=3Q^kqoe zGME}k-@jp54a&llWW1;s5fYi>)Hk0ro9amUuSWJe;nD+$W(VA!H@ z2CtsM@y60tZtNW_BI%oVkw`Ypn40N`FTnj)=!T>ylCIb2X zS?+4I^wNsByF14^d`VHz{V7yaDfp$9vzt?6h_>6n=~FMLB!zYdgHqCxo<&t@t{Gu8 z`WrN+k)TJ`kO-LROKKlF6%e4Egwsm5rSha`p->5+t*Ff~pRFJfG{u;rO)*F9K_RL% z!gr!iG}$!33gVbf)JWg8F-DLcqjCOp@0T<@QO?=?-Tm7W@ z!xg>1v&i^8wqLSg1cz>tE-uLuNk71zf*}0rFX8w5MZ8JEZ&(Qhk%+dFAN?kkG44;v+}cU@q!T{%^OGL`0Bd$4tw_QAJhHV}{HngaTf`rd zLTVkD{{Y$v;|?i?d}H{Hj#sp}LcE4>>m&!)1|=nBL*rXldN?jdK2$UZ-7&$X0F!d=kD!Y7Omd8m|+dP zWhz(j%4ARUto(NiZH8|fUDG!=mhL9tD%C);q^>okF}J0AZMM~J4vh zFNQB?YjEx2t}YozmTWDo^fuE1O2(lQe~XUfnoS*%ufMxiA!NdW2@NAopg4;0z%R{? zFvYIrHv+lK!TjLccJ`|CL1Za5*9hRDM0!+x zj}u)hhO!(8Qg`|EuNkeu?BuTu`Uj`#zY~Qw+U(D@V3i3@+`Qr+L<9p8u#M1 zZM0V6W70j1bWtMaY7vAba7uM3a0sndFMtgT5E3~a)jqYcCUpQ4G7n#sV`6w>76?Ym zjfZ;dqem}yl9{}>RjJZMo_*^XwCh)=ZKOb{mOX`!HUJ@M1Z~=*sJHT#VnGHZ^rvTS z&6uH*Q@^PrlUa6nIDPbJN{qn>Jbde6XJ*k<5J)^v2kBCb7cQ_KX;5^7w);~_vMyVB zHyJX-*{Q!TQBg8?;;yW$;+tG8K4J*u>_u1ke+RZ{&Ri=eWo#%{c25Wr&0iMFk%4fT8ua)O{G3q3F%S{C4~e8DKV$sWLA<~ z84_mIrD|*t0O&OFPD^^GJtX%NG~!g9kO2|F6k7Bw!gf4wGf05;27#phZiOfMnnc2Z z0$@)x@T6(dl!AGIUTacfcHjAGBy7pGQk0Z{ndzo~TFq@L1rkDJ@9kRKRYS;$iRdOx zZc+-86g^Nrw9(2;+S;GWRH9Nx6~n)X7p<>+LC$U6JkwUoytQe?IITKDTPg?TC+A(w zY3WFgM>|)~FCWwUTNuyol%P2FG^=JDSRo~7WI&&yht{uYRp*EA{t6jgV9EP0?pB^B z-hqTR>aKn@%Z9wB-DCyo(FBm8!~ws`pq?LilXaK#?g%X}@as2~iW4}a+AYX>!D&8f zlnByPfiu)}t+q32<+fA!OZ{o{o=3vGPMP%~D^zn5l9RVjdWK=xMV=w!1)Z)jg{v~y z;kWJBxo2(rr=Mvo-6}WRR0*B80=;~f#*ZqW%FD|z;*8%}qY!C^3AQgG{{Y!f`L^2!8J$P->6j11o;4|qUeJhZY ziQMJfz=qO>n`tZ5<0Q!&@}I04x@*bArge@^{7PV0`jXBqYW4={I(+9hXr1IwBZ{_dE||nETe`G;yQ^nJq2^Q+l@DSo z=Pq)@aSSflGl<(4QsNM$AaE8>s2_B}J)nwrhu%D6eiaAZ!R}VOB`ANvu2zMUz*0C# z*ohTaCYg5v#5E78A4ZKM@~(8izZv*t#td&UJhy)=g!${T0a`&Dt4Z9a+)unzivavz z_;r|^N>OuvY?W{3yJ|`Y%%qt5)i;W3!DVEF@?8W9X%(u#vwj7c*h{!pEopp}bZybA z>JIg`P>*Wl*W90DPkLdZW+|tNR4M|Gy?Ln9h^CRXL}L#0!eX4Sy)c>v0%Dk>KJ?;t zrXRHeW=C3ZKWY)ZI1TxrXmH-2(wh|EIG~XT@w>%*KfxahaqQ!Wd22QdMXj>c9}#e` zOHGfOh@WY%l%61G{Aa~%a_bkz@mFs#OSCL4*9{{TwcF3!mbE~ULnOp@Z@B_sZxYr#0{wn|7*^IIw^ z1+{vC9^JjE$pl?8FdbtITWPmaZt*281+5`2B@@u3@=n`y>svN3*Bl7AzIWoW5{)Qj zv`7$Vo(xXFYyjG}PUW^yb}<~jhE;G+?^;mwfJEv*Jav@)wybtA_sfW1n6{QTLYfk1 z+xbOtQTv_SRY-l^v+O8~r8d){309SQy)m*xdw)ugTwW>b&`^~&o+DIg{{SkKveH{F z7f^>EaA^*MsX+HL$8EdTqZF}irxcX745dp7)uaviKOxe((aOv_Yk_Y2it^IoDS+CO zt5^3iPhH2Kp)$QeN7Kmv09`8`EFrXqSaoI9h*Foaf;W!Ul8p*cETFXOFjAG6)G94< zO4ml06|_=9)Y?D_+<+kUtn8tSF@$3F{(bxSZWiDl6t)^GTJ(c0f&Mvz(z9K$cI~YJ z1UO|#QuYV9pYK?SuonsVd&1#qYmeN)I-_I1yh*8SZh$?YDph~$+4r-^zuz%RMY+N#Y+&biW*6ydO3H+zBhXhv_A0>q=;P%Q{ zh0LvqruDRWw(Ch|;gTm(Pd`z}_Z8Y666O@+czv=K;+H53W#j(<%-2Kn8v;5S;#+%^ zmt0YA%u2~B1wA+3tiJ|)NP8;dcDQxZ#TH z?Gh|Cp#8>}BmvCwYclf-1qVzjb_3q9@Y{gN1;SD!2?MQ35>T)dCvmtPYQz#{Nj8~P z<~$sw!o4aRM&dT8YnCZ^h>)c4pn0p)6~fK-piY$a+iI<`Y`1Y~a=i&jW@l}Ps7f@; zn6f6RkyJ42D^98QBt=TH?bC?-!%0sBcds-s+pH;<&@~Q<*pGVhvtj!ufB-E&#lJDu zg~2&HMU#RM=9MUFPkNeQ&ggIwh>=y+UcFJ#r-FS_YIBCX2^vTV5wz9ZY|P4wCkDF( zv8gf-zTZlb;r9bp#YutQpg2X?@_;0#{;l_^juPNnMuiET#;#AbT#Mq2*>*|opAR7> z6qy}%KWZvUop~fA!bl{{NBP%-7U^)6D5&ujoXS#{P^7?wkeytlQ#sj^yK;aCGNBTo z*08Q;%me^ZPaUgcFrQf(j{++K@nkurAP@xVR@>1>G|yWG$yg_W+mlioB9!S!Ompd; zI?1gb9H3I4HAE#t@{WG>Ah$?bK~ANK*)jk$(aR$;7jm3F*5c*TmhIX>aH@L(OxMkC z6*D67j6TuQ;5K-b-XVVUDi5thG9owL4EtAc{v`8Y$=(R&oAjt6;dY9*XLu#W`Vv0o zzIkK!A0PWa17XKncGV3xppZ+8g9r*7K{F?z;(?be%W)xuO8 zKb#~kNLM0a4QzZ`Y5WT@VR&WT>q|g!C*m7El>`uHZJ_PaLZIq?h8#c1F4P86erG` zk*muR5iz)!nXR56((W1s-zjee@2Hf=GZFMPtHspvv%Vm=RN7o!+qWrZ(i>@IYf?#U z{{SY_+vPQ5;5kOa6t`&NWwCc8mfE=0BaKJ&+mXF?=^UxHb4Q7cqYA%Mw{5e0wGb0? zZ>TAxszKE|NFWeuIhXPLM+L=8?xn!m5DuW35;xnptDiaGi?YAHw_q|7(wtM1y4wSw zozG4xoXt35+j+FK{lk|*utHXS1V+L+kug=WMW?wIBGXiKeo^sf0nHX@!tv`Sj<7U5 z_?bF(RD&>jR97}-Oh+o*kJ#vKcx}vr-o1J{g#{ns+DB2 zqYLoc8Ok@-_jXRXjW!Tg+xCx*JV*sQC<*%SR=0)Ui2T!&@iB$whS!PN0s(A~TSITk zv&?oo8mhcL&F|XWxn|-TLz^p4nxLo~`bjVd>oq8{zPHA)C)VT0Yg}k^VDdV_Bl9PN z+*F=8%~X*UCmv$X;lQ!m49|r#iD8!rxwLplQcmgiQ0Qwo*B)hT(~bCfj4kU+=7SF> zbf?spNIP)=$RnP$(79v9MzL!~UA<{3ZD4A3pZ+u1Y3cJ7m6uN_b;>j`=2PMJPSw_F zhK0S$H_RpLcToFE;b<}`#wwpjbIC%QPSoY?@~<;A0L2~Yrkt()sfs&L24JTOXd<4G zG>IX?y_!K%fa+_>A`Mo5iEk0r;fI4*{vl}KMmK!Qu^D|u%h2g2aTNVm!dbP6G?vknE2};QM)m@R)-Noa~sl6_(1qMHeazCK0 zUO$Tq>|u8gx$!Jop*GB&{dzDeGS#A!|W+ z1uj$+2H&Mx#O={mWD6K=yLFPAcNED1K?l;6dr!`yIFRLu6#n+l)*?ZUgmoQiy18*T0mY%RAzKo6Ad1@Hn3EO`d5>g&yG?i#mjt3!)@Dp#Pdyl>5C<8B+q+Qd?a zOLqs$3Xus|jiaCu@saG|*FH>pt{CJrp&Rq|Ov{XCTeu|D;-!wGt4eK+&-q?nObWI$s2e$%`-ADDqoT zUa4)xI{bFsYMNyCPPn@jhCdy zKGe+Rgj(Wn+Tyosvcr>&jc-~pbcYR{%D3}$C-SuCG0>=upi^<|HulJ^-z9A;%h2&2 zokOa9{@xfE!*M@r6Ohn zodFRVPq^EGSo+N-@63m0)`wTK@~792(CEBh3l1~pstihX4z&pkn=6M-k~OQ<{VL8) znD`*hwJ0P6g)eeXNvIY*YMXn7w6pF3+iLTfVO*Z04ol$J;CPZ$r6EhwxQJHZ`&7RT zUZv_h4nV5D-r%;R1p2g?9qMI(V+(Q8+K=xeukjD_r3n(rA0)-rkl<9ofDK16Og~bJ zKtj(lzM`deO$(MMqP9QfUasMY)3iw3PU5aOZYjqm##Z05m@X736zSW2^#1^zV_jwP zRpvIN6n6#%Pj2w0oz@oGL5<3GKYG)>V7qrx*#T+ktCLXn=&-Wcqrq)1Enm>{BW|hO z{c3lG+abcO2s)7f^!wJD#@ZGoIsyJLBvhW&ZnWG^q>~`XHPR6|Wu2S69?iugT%e^U zCsyJrXNI)d5{EUFM#TDeW7@QMaFA3Il@Yww@wBEu*%CNB3h0r=nIUOF>aDjiSPm>WN|1HV;F`^G zI$Ba>7548~0Z2*$69EHY2Ju5yKz1I8T2hig1d>fkELM@Eh$Dpj>N{$K=1OHij=T!P z&iV5W@bfBS7}fgF(>9GMTb_2O*CZtuCrF}y7+KYfcQa+YlH08dOJ_f8;(FEsLrwlf zpPILiYUGSU)%%>&af@3z>YZ`Xbn~tc-8O(gAV7mKBdD5}-D7X(d@~Zwc!~mXoWpor zOOB+GZDO5}4?qb|*HqPSe{*s1cFeSwEpD%`5|ZgUTN;ceM+p#Tx8A)crsCTVarVp) zQplXvCDebvk{9DPD&5Wxh1=^wR2Je{Jh)0mkumCp0m$~NkMPLNo?W-hH>}*DmoRKO zizKMrxo%x?DRScEu;EUHtSxMXG_FTY#ZVaPZ83cO(i?p(HDaaRI;FT$RpkvzP#{43 zymbe?S@w-$SYL+sZ`$T3+r+aV7g*j~eS(5qbeCIs2vCKi%Sr%o8-gInBDGrF=*WxP z%QYAGZZV0@A#&Pn8ijclNFmK+&XQsP9csqNc>4rhGAzqUXVj9kt;IoB!AczmaqU%O zcWcA!QE=kEA;qIxWljYwq<;#B^Kr5LDq(@CvsPJo+&M<;LkDDn+n_A+JtS@k z5Da#!2O7qaWp3J;*7dvS;JGEv$^)>1An(OOF8susml&&H2Hm_2Yq4~%25^9is>hA%>{N$3-Lcc0@5<6F7W0*zmGA>23MZLi~ z3uu$(B#x(`{{SlHzZ7zX-J$rETdz_-<|;u4vHrC~QH`a#C9D|}mf0Om_<8Z!oX>-^ zf?{`#qTxvlg(yr;!22D}{{Sk)&e=}SD`u;fF;qMqxmuRXvQm(~K}>Pej`hbr9_FXA zj%c&JN{T~k8opKZLoR2GKgPVPV!QU3IEnxatvY<^8xT@?1aL>aOHY^GRQncue%mUR zZCi5?Z(A#IzzLMNP!yxcGr68;=TdCHikqvnTd`)_msZjW5~&UjqI&hMc6iA+t*O0s z-GsEuWawJS);5WblRs*&GG;2uuh7OJc;ip7qJ#jl6oby-bgP%Qf~{J!sBnwR+$#C& zM(xu5vYQ@djlfCM*r$mb{{VX2;aQbHW)*=0?r5gZZLq1Zj+w|GMrj1^wn zGLYNJ4wbF22Fgi0p3&O3msql~n>$roIv%z_3Wd1^v)G>fINxd-Olh=PnR7Z^;clUL zeduupN|2IOALc64%YG+WV-5>zh)PSlfxhz_ z@3!5lVTe6*`yn`$n~58G?VUSn08EkJy;I47NtYp!qom|1GLjUNxFE15=4+AA*&zg-g;t&?{v`Z4@OUj5k1?>D^OBPL%oI@M4nRNASwe(2pM?Wg#3^y0B#<2vqnK65-R-7`sl{e;)qJN4vA37`U zLCcy)&^%xLr?_?YCagav_qXpmp4e<)6d{;v+QvB=aI|*L7}& zZ5t?R*A7yRm7+)i%_=ZlTH*_bXxTeSJ{gZBWE``VY~Nk}D~aLvsQjj+q8p!*oW`(*a1}1xxR=~1gtUlS zTkc7X$@b#4*4GYcOD24@nF&da>gB%tG2&K@w6<}ZYqYI8lBXA>9`W>wkFUUN!j}|O zTWzH7x0H4R=}E!9gyd*!?^fNy+B>D>gq`;1X{(DOTq5rApyQ}uv>>=s91Y2j(x{Aa zk(M!*?AGYG(;Ae7=?Ca)-N>qD@Y-oho_4Ue6qht7%R50oKkHpF+MOKd^qaivm07dC zxOAYFE(e%^f^{}eF&zBr1%HCrUlQG|yTu6fB~Pdj54rtn`^4}@@r*RvH0S(FLsMif zT-%sEs-VX$9KqIC@arxT33Ypbt>jj#j|DBocZpyZYPpu&Nw;o7$r?fc{i} zumTf11y0kr#6w3robO;nJs6pITmJAtV^#N7(VbR~Z87r*Bhk<4Jc~ z3uS7MmgF9H6>nh~TJa67cF&|V8xCr0z##Ch(Z4ls60){V3@k?sMb;MOt>ItVCS`w%DEYTLx{ON;iNRkBoBXiCzzB!k<5 z^gU{|$FR&p_I<^(w(DCCIj#zIDFjHLt#`=_@N$Ic^Il|4-Wh7nD~LIX_=0gYxW(1- z;g%T*g3`)_Ee~x2f$RyWwr0RX4gS=zWc&+@Q!m@CFsloIVN9qV$Ai=!@&-l^h?^Y2 zhvEvfYZJP(Ar4vHBosc-+eZHY#5#4U-Z_k`HL-|ht6Owmp;pK^^^Kk%@b*PA0tTXG zcPQ2gR0)H>Ged25UQ*nXMK0q3_LAyQs6LgH_nu?wD=!1Y@eCUiX8Og|+FsjwkhBPt z^`GYVuUm^WSls#SEWc#nmYq<5zxZDrUHh|kVpOqyeXg$a3K3rnz z^l9@SCf1KzD7u$|*1l-iIjM7{B0=l5Y;xWv)%TNh)5-}ZB#PVT3&U8I8f_r5^;B5TL0MxC!Y{ zY@c$4sX9jg06Mg=rpxU9Ss-kl^?w&A%FXeS7=G2Xw`ELra};ru!$nIOG#v? zDhM0I?rR~t;e?f0oeY^LQ3ZNM~=B`2uuS}YZS*;2riEn z?nRR-Q>5t}EExSOjXx3|AUWdpdA9KW!t2Vx?a8^tu99IWZ9Bm}{Cv$>{{V>J8q<+- z_06r5-exIqlG`n(atYeG6E%ERN6PHs2w}^d%bD93IHTbH^dRcc!Uyvc$?42jO+F{7 z%CWAOW6zOj@I2D`pAr86lW&INcX{Ui{l?pN%K@_ihLfm`OtrgB>DmI836tzP)pz1n zD|4A&vP%xM!>%}%+)VjtLxn^GJ)=m2v>l_RfrE>jnQ3u{W4DdE@@&KYU8+cJI>KSK zsBthrC$#*z{?{ir8DTQK0zyIY1>C(UJOP?ZNvmzUh<(AV>TiiaR**K})UhjeMr}ue9&sn|TdO8t zxo+Ce6;W|zhJad0gR+n1m?C?{OI^in4hGYhD@#eY8lHXijU_@oMMvAWQB~e6f#Mj} z0E?TTyX(fCmY!LK`DO}%6R?Ph`c~Mm((tTG-O#qJ(9pO_E#N4Ukd^FXr!m^P`5|;q za*7u{AVD@s(uCI}PiS3C%+OT}=Tm0h=Tw$!$CA+)qf5fo%!289>N%l8eIo>T~fSBK%_ z!~=$7&09QMOMs`!bWYvbxRGYAx0yb)nWIqVK?nQ+KL=Pf!OXV&&9km zvua6a$NHaCGb6_^Wwj)vqTwkdxKXp45J;E?cdk)=YRVbGcaDPE(iWwu_QI05;E}w? z8&!e$YVnCVr$z~jUOjS$n^(iQ`H89E8fyQw=b*(HS z+>@ybI$K$e0UJ)@Dw4%*uG5P?)y=xwd4QC8*IOxR>DP!AYUQAvnw)ubMUq@&Ia=j=^jN)ah8|Ib7MvnT*oX)J03&L*@OvAKPbH|fq$O?wu!5-^ zaop`(YvP7{Z8@<4ygOTIx>ACikQAQ7+uz!{yTYu>tarmlU$QC}O=(Vr83S+fnv9+B ziNw@l7`5IUheyD=!mZp|4z$o4#yH}WyQt=~Kl=I!rJC40~h(B_S5u#gS-7aZBS9N@Wn!0WA zD=dABq}$@|0K)}ow@M+kjXt40PhI)mxs^GR$yqTtMXj5SVqpIDF(FYTtRCG@`Bb~Y zJmG=i76BN+ad+r*O}j`1C1juCJw!-~c;!!#;1 zvtipnSXc>Kof@G)gFU`qI)`SAva3Aa_8={hX=H)sL(of5Wl1r~;ycxel&@d7Q*PKc z(ppGLLhf8q4jnP+N4Ox*wRMbsc5#%xG*2-*B-*7n2>szitTgZL)}lHH8;OnmsxzG} z7{n1_e#uR<4+4kLq&eif%n+XYkZM-eGI1O+i(AH2pIM9jnPWBNmPe zXAsy;*d=N~KC{e_XRlG(wNkZ37VDyj%s6|ls5xrOt*uJ(-Dx5d6UYa@_o&U&PPV1L z=t2tN0n;HY-fgTw1PzIwlvTT#>~S0p<;GjMeGHHj`_@9zw8Rms+vI8si)EKybtq6n zg+c&W?*>Oi^%Yuhqoib3WkTB$4{80;h04>-%nd(N z#d%}db-WhbUO2Ex;f0A&_W6NYydKSW*9|Nsw2-(BFKC`5{dTIf;>%W~iw_#YEc`^n z1GZ%hV$7u^CBeIct4H6l`c+u)cFN(#Qqd_;iC*77rCZr~hO8EggxnCOf#47 zYf0j+S9sgKnK00YER?h+^(Y^ZtMa)6qK?G$KRW2v=u#ZK6$9%H zrA5F*L7KEOb|S)Gvs0IWfZVnlc1TbiH1v@o54};DJ%Nd0NkD($n@6cjUjjY0tD`UA zRtFO;vxytDEgHj&0(F>~`GdF8x?uedS$N=@-6fkQEbf{KN*1_MlA!@cW(UX*l}ED+ z4`Kz?hTObuV^WkF0JgAwTMmR)TCMEuN5nkc@wP;Vp#ecVKvzO{d{F$WmS*lWMb(nwO5w%R*;tv0%d23-)S+wwO2TWt?pWQ2OV?-irr%92A%ooE z7sZ4xQ)(sKb)iz1Q7{xEkYn_#7Y?#}!`$hY-o$QQy~<3!ehjqsrmYsTH3bR$r}%-| z4)wCnSz87+x7em!DSrh?_?zrL+=pMm)~;M?HdV5UfFOQT>?*R2aLW>w?om%-Rt>V+ zE-Y3O2$${E`I}JYd#9NC^oow%I9zx>0?@LpUSz>0d6D#~!{N7^TWTZXR)Dg$oKA-j zW+&XoU#vw#@f!zh-tyf_+e*Dj>_>6!Trx+4wi(;x>R-cW;(qTdw%%CI4k_#v-64zR z!3>f>l`Ej0PWFhDdINUV_DhsQPf7`!!-1|`RQj;WdQhBaq@aH+@%-M2Pg5ITzl%WfI4Y*KCjgzs`cuC}{}Qly=_Ps*`Y3E-C5qk3A5dWF6Wvb==cTap5ah>=kIcMJ&%R+4?`G+xUC){(wW^VFgp8osm>>d`A@8Y6Ut82 z62=ka5|t-o#aEPkmmx0(nVYqT3KU1pC#6^!Js|mVWb~S@t=UJ#g$Uoct2ZaLq&A_` z_^J@)oig-V7%DWN5)wA%c&R%Mq#z0Dw`z>xq$CJZKs?6vv$(xO4C+?E3btl5-umG! z5~CWEF$Ss+7Q9fj$=F59s&oaR2TEshPeEC}Ao!1ImhkuP9k>WW6SnolS*thW9y#ZB zuzWn~LX=9D52dET^*FA;Rcy0q?1wUDmX}W%CzmUzF3gMr8X3nm;yKeFSzVSGN5V3> zhwfB=c9GD51fQ1m3bo9yVj029_fqA~D|@alTTxV}QAk=+K+=UsNF#*Ldd=}S6SB3q zv%;|bO<-6vBm^=^_?Jlatd504so+m~#L8~nSmAf~*tMzCe8ST0&zYsx*hu`TF#tl6 zkrBB(b6&^O^nZlKPwIW1C)D-dj?U#CBDi0%hnz9ff#6t%9?4`b>1l1((>`UwQK@Tk zNuN&05fKJB+K0%PL%ty9hA=yD%OenZMcd`Lq$a}6KmP!*rGPe2ND!!kSKo<_etQkf z`JGmQ#arc^J)~S+x>i!AEz+1JvmcoHKpjkN&`ta;alZ#LHX)m@EHLat633ElaV#GY zb4|^z9Jb1>+d`ANR_^OjJ(4Qi-MU6}E4EB?&DZ<2n;poQMm2MfWH$|^!&owj(`?qC z_;AyJdSJj&2Yt!jqIsVXa>QJ}a{8GGK4hS3DkKm@^T7k@QEZ=%UFH;BSXtYADid;> zmlvr^Q;RZEk2A7Dj1QUVQ{2&ls}IW;tUmK;dGHaq3Y3L(Jfx61_uj2zNk@XT-LpZ1 zn_-l%-L?-tx7JH*P@O9QG6?FDHwU4td`5QYV8~h9QRA*(k89olmVGrBF#x zpUXWuRS)8pAjOs>mTql-d%;$+M6;@ z-8(1_2hU@_wOc+41N#>C;GGR3*+ofdatcn$1Rm2>t|}SLL4>fIl(yrVgC#nL9D+Fd z)yd%w8~*NOOjqKW)_k=jT&WSQGqL{Fu@3CZnmGeH=GRr59A@1MdBt2QAU>(vR6w1D zc7Ne@LF}`NGJ=wovWEgEQc|J9eUCN4nd0a_oo->3+of+2>mZP=TNwAQ=KL_VW~{4- z2A_FXinoH54LZaE2#%nNtrv4>>m|fk6PawE%=cCowrNwBP8Rj3gsmVAmF!0&xT&SK zB<@(hX@=fE2;*UGDO-SI75z8*RaKg9*-n2K@3z?mT2!4rspD^YvGMB!UD?@RN`o#T zI&~<36{H`&)pezOmF(W!J#BY*%21~e;)Jra9f>=^{eIPD_*tD#lx;55QKfnguh9Ie zgtNT(7uMy=t_JlXNeccBlioO~mJxihizGI$IIq&$)BI$2`d2Ktl;cNOWO0?BJTb2f zESv?)Yk-GG=41kYU+qyWLnUHX%2GoM(-M_MUTeI!e$6J{+s#=q=S)WX2ps(Dcf3cI!lOJaYX3R?Hgku%+9rkn>Ik6no(dr&X%iK$edQjku0M>F-?qn5;2R3p2(cid)-< zFw~K3^PmkcAzUdF?vOzpsb#~p??+r&TU!fzE8uuWcFGIEaV4x?p|u2DY6PH=N_H_R zN$1%8w0;;db7hM!48>Y4d@7dB(o}%B2_OX|?bCxi9@P!YS)%tV=Am-+>TZIPA91@v zODWbRAf7lic40VY?U#{^8-=9+lsR_*HngjQAK?H3W_BD`SW{@@J`5Z|ZyaPhYU}7_ zy=^H$!7Z=&08dVvZ(HY*#wN-ic`b{rlPi#Qe`0=6DE|Px_%c{Qb$PFCt8R3T6}R=M&Ixz$*Ur51 zkbs?nV%*(-h z7bT5?C3m>Bx-8ZmbeITU`ySl@;LTYXdAodP4!N;%#S60ysSc$mpEOSDQ5(m%PHK_J zw?tw{AxlBhq!j}oj;G$b6E9&mR_-x%hcu^IN_5;Z`<|qOwCNoWW7I`-)AGpW;S_Eo zlWwwxb#mRbO_PXHt*$j}w#epq)CR_^teb~9dtljNMJ|Pfp-m)|6>xalzcnY#ULPYj zkfC}E?m)a9Ma+ndJVLZ)2((`_D_zE zGM?z$CF}jB#md;cL1hInP!vbFCV#Cz2iJLsy=dS?qlrO7DMIz6tDW~GlP0D3-bBA% z8E!!@mHzCN=>;lL^rREd`W2_J$yWG%@|5kMdiTpNXfWiV5ULSg6Kiz*lv48*mkc7(2$?arCOwgY54a(Fw&ZZ7rtP0Kd4zQ1kNTsx3+q z`qB)#z!T{kjR$}2g#Ez9FfSqIwWW|sE*l5;W@k+Q0B-Z%xA;5wpN2Vsj8(fvaU@}` z721cCN^P7nQs4mY0W;c0^<&|=M#nm3t9-`ZD`4(+#O|G2m_zDHZE>V(5itRqjm&f; zZxTtU<6IWECrvY(Qna-CL#iPX&OR%g?_XKsjnNr7Immz3XXxioJVmt9R6H?b}F=U@6AH*l1S=<<_M& z9I>e!VJYI3#QT5YAB(wD#k?sOId>Uq>A--eZAyol-0>iPTAbf76qqm+2j!WX_#2Wu zR>wRk#*>QWj6wW1=?WfVnQ(~%c>{qvPdr!BufqQT`$01Aii%?h&Sk7C9c`U0;%fm7 zyTqk`%6QytVzIK}aC{s1H47s7aL$d#ZPd%9L~2Pq^%V%mtx{64zvYk*_Nyx!LROIL zOG~XSGLs4xA z7FS+m1fZ*)K&gF>WMbK%q^T-MADVWq82DwJskB;Z#apy$ROaB!kx(BHyim2ktR8vC z(%3>t-CT;UJV)`r56kursmqk1KoXD@Ts`8ik2m?=`O9{$R#++_Ev98&zzUS|c!;rS z*VmEKC%!Xy<#(MgTxQwTH(-T82_{y5y-qNEr1ytj8lu64n_T0VZQ8zl)QEYjWjGQN zI)ucNQ2zi0JQH4Cdc!>UfTY^nv!>$51u6=0)eI&%(VUMTIek75^% zrw%TmM2l-;lCszx0P20~W7708jQ#wplN;83N;zP(*m#N{ciA-Iu2@<&<0w-N3#*%= zpEp{`I+YPBNHg>!>k9E3G+AMJG}%1+ZZ_koY$n|Y%+o4V1dqxJh@W8{1xkER##qV( z;pHnr@P2s{3tpsTwOKyw1OOQ=3;f4^B$PPsck>V*tcm z%Q=A!u+V6(RHq1(sLT+k>9CJ_)#m(d!;0TpN>t6tiUQj~^q@+_lfPQmU*o~NY0}7X zr;1=HTau}h9_M3NSJwz^H1)|#`rIihCO>$tLdoBG2Bk01NtJFQ^LAGbi!`Pha3}%_ zi-4IY+j_nH9md=*B!!^}OE9!7kd=JB}U!e{6!_EjU|9Qc|#EXg&C@cJVU;U7-c%Wyva2XLSK5k?uuamoa5{ z{Ya*-u;!(;Axlz9WU0uWV}Zpd*==@r7;|MycVgOffRJ{RJc{+jZBu=TBqXV)6{Ia1 zb{j&TH7S4#>wB?e$}D1 zeN0>>#F7+~Ap6vx7_nXA)bfZ96YAOr{wg16)M^Oa(#?`=HALbVDTu>?Wsn*nz zh!NC#*K&AanAyj?D$F)=kovIIxYA4#N+6N;{$lLx2utEcd9j4ABf zZDHtVN`jKJ!iXU6TwyQIYwmW)zaldG7SE0N((Lm)o=a>YOHfW{Y2q`jGk1lug=$J{o6w`mqo=`2nrrVzN3&}XsnReCC z(h{rHt7Mf%r0oFz0Iro{vGB`fi>r2Ac-<>ot-9ePDtGxOn5?{iBFwrs*iInJwryLx zbn_27e{$RD)Y3p3ksFHh#Mg(vh$qfrFQFizH*Cz0@eq0g(y*DtKWF*RT`&?{a024} zMG1}v(zEdErkKN>U7Kz?m*oX+X-}AuxdlDuAWv~u5;)n6)tgAd7jV%JB zHz4m7wecG)S@?sMnz3apq2^GZA!twu2TT;1gA=fr-n4%LU$c7Q!mCo0>{YNc$OGlP zR$Y^yy4B}2TU;R_DJ4ZCVoC48{(`5qJ_MylIAGXTFNnVOPE(EBs>S6m%1dzLhIf5lk;HJe6FjhRw3PwLnrqOs+=+fTB-*>aOvh4Z6!Yft<;PHoH|^p=nV` znL>d|f%;8Dyd}N0!82`x_`V`=>^=R~u#YiCKI!+Yj&Wq#%^ottdM?JWB-!QzVAHx; zO43l`2E9$4!I88?Z#8FN4c#SW_Y$?SaFQ0XA_Vgzu^**Ga_%L9-R1Z1Fmmi+jx9lN zLYA~a0#gS_f_m2T0L&LJ*VY25gPiV8fGW7;)yFxJzKj z)#y)I=Y6K5c*YLyBZs`pXmG6q&O@suNdR(@)(>xLndTG>!-cnSi-bxL9vx6lm@~Zf z6`{f{@eC1n5p;`&l!nw&8zcj~fI*I=8nC%X7Et-41ajrYI246Cw8Rs*RP7Lce2&#w z=Ik2d3Vb5tNLT@CN_K#KtED_i%gi-uyhhs0qU5M8PB2c5$4$BBx!X5hF7fAA@Wqn} zLX@RyB#pktIIPV~XuX15qthW`%wd?jZY^3}1_1^jAMM_{$0^`ed4k!;1*?{-Ft=2d zs!|o)j*>)e&2df)`52=CD)Nq@EB;@4?tckiYck=hPn4$Ib(96Dtte4(JC!HeBB7*} zv!QCS1D?h>Eu3)=H*g40R<-(h z3fqncH7C5o&MMm3E~S^ki6RD)djfjRN30Ww<<(u-IN}QdC1uTUrj#NejpLoYtG9w4 zO=f0Nm3IBPvsly$ys$SKVm2FYK9vi0&3_Q-Rl-o=P|_UTPHbxd zm@F+1lKX6hETi{bu$&O{if4*g@Cs_g`}atu^}_m4x~`IcgJQDtXm25n`_App-1vgojQaP4?k$AU&GwxtVi~y3YOP$+lY1h6(Uf*E(Hak^fEtM znA}<^@>$lHQS*GnnI?aRMQg5^@&b@`i-3}l;&^O`-a3KWt7MIxVK)|fVR(~%EclCe zaF-Q;y7kr7`f=~gb!6B=%3FC83u{nJ^#WtHRaoW{&M$KMk?^+Hz)BNs{{SNP6m>Y! z={)KFoK&Y7u(8Ha>z2R%zZePK_TJJT2W2NuzxmBaRhh;al&^CA)uU@exPmS~Q(&#I zl$5DWxylrFo`-20R(4p@_64s7P+*1M)0!JSsLOpaenx04mRw!I}!PD>ioyi{zII$ax|NQ1q+h zf6lQOynfBl>kcWUfyzyJw&{Y6icj#KD3m9J$^QTiCiij*J9s+}@WuG`#qtKM2Rk_S zGk_?5>B6aXk7Se6+PgCf@%ts`>rX!j#+q?@brxP46&!EPd~ZP3sZo!WZ!mnpdxR|? z0{YTNx#lCGu1^$a+1@$li|IbFUSr6(WIt|F@)zm_rG+H#Q4G_WF}AIdZH8Gq zNZ_a$AjalB%@OqYx5Fm=R&iX!zj40>GsbqiTlt3>!?NZam8mO2-}9aXeqo_IPqfzI zoIAtc1+Z}DS+z!R%!)^<_Vd-`xRnB)kdesuRAMmm#h)#h6aCoyAKrensg)Qk@WQhp&#EKVzRsX$t?5<-%_ zKo0T0QC>KnOkP}R3%+d*W^6$#x5aH!sC*M8I#XiV9+Jpu=G+P#tpxNHJF~*klHat( zuW+QN7DboD<%}xjV&R)+q@~v1AIEs=1Cq1(AOL1+6^@Kc9K@9Kw`*Xn3288O1fc>D z-rJ}pFelhkigS11B-B-TDi0H}1l(U@46^g9d5r;2IAL!nY^4$0{{VWXarLo@;t5&; z*6V2iE?@}f^{YcX&87Ax@n}9>RO(43UgAD=O^r&pBrM5P5I7svT;0*zA68yI2;!F* z72^&BrS{N5O4go1B1C+qx(6{_CdXT@s0J=tC4{7f7(bK0+NeJZaF>5$H*s*3`62%R zEw>z05P1p%u%3HW?}#&ReQRLz=?^s1>1;R$p&$sKefR6dcf-Os%d(x$&LZa-OU*K- ziBL<92!tv(5;i?CYvqE*5bKLlRtoea1Jn>W6Hz>R!OtmIo_>~;-jI~1bdDo$*03^_{l$hf*0m~A z=}0`pji-vEdu3&g)g8Z+51)si1u>klhxUe+BmPi_r^5t(ws;}5f)mcIj2=gR)mq~0 z5&O@~0fJ|(Sy;`>PN*l$dAo9m3Iah_y;k^@n}}Q5q$HhEYdbRFyM_FXQ!f^tO2*M$ zarjuq+`n-)PPC_d)>OeD>=Hp6{N}k0K!aDu;a@i+3ePr7+|Q6z2Y8)~4nA8~EhMWY z9kr0%4ak9bpiq=Brh{Ky7D1C!su7Nxhnc&T(7x%^h?2sc|=1$;5~5 zZPReeZY8&UNhu%^^Mf^TcnVXAcyV~hDpP80DNCsv1x)gIf-9T<01{ZrpH@`8P+pgC z)WeD&niNuZ=cRPl;o9Ui;C;i^lBdIRq%7(hh}u5JwQijmsb3@)Hz_!`inud&?k$H{ zA+;&^QdVLNbU!+j_?c*JZ|t4ALbRJ?xQ3aH=6yrA993rb!{$#BY@E?^Y};2zOWIU4 nj`7=iw7hX?E@j&f-uye|u!fhbNN^?&l=kMVT}qB!rqBP`5046d literal 0 HcmV?d00001 diff --git a/apps/www/public/images/customers/logos/on-dark/flick-games.png b/apps/www/public/images/customers/logos/on-dark/flick-games.png new file mode 100644 index 0000000000000000000000000000000000000000..52c86a2654afd68a664c12a429d6ceea658bfa17 GIT binary patch literal 12667 zcmX9^c_5VE*S}*d$sWd#P>Rxo$-Yk!Ekdc75REKF*6iD$Y%{jB*eQiJ+kA76eT?K%H008XeK2E5m zFf(Hn0QA`lMrW=CX8#=~JpQR!alDD~aCi54P(i7AsS{S<@sYUTJI0#+E)@SuM=h=& zKigeVGAjmj%a2_^iPiB#qd1i98t($|^~Z*4kEID{AJ_5h90pB>nXN##B0%WwZ*HKq zW+Vs}pE#p|i+Y$EF;b7rn{L>G1cB52;}Lu*Rq+l?l}0R!wOS+A^vwFOGXNMtf>Jd+ zs#jTRcIN2l2w1=+Yr+6X8^H1!1^iT9d~ni0XJ2GMxotK8_zS9pNM6-=w_lcgho>FQ zb^BF00iJLd0k``4FY(xy?QX#+sU(UdnhgNOF)D6UfksI@7Q7g+o1a7)Ph5lc5DksH zDOS@-<6!#y#)WVPyJP-Q?r4b5$2uy=G+rsEm^lJ~B-3!8Y?OAEDkr1*gD7L`)Ya1` z+g*?7KGU17Elp{;UT$oPU(+3h;f+)YfAVK#>=mnTgalyiJED#?wz4qn}^)*wNcpU@Z6G6s@ORW9rEjVFD+>>9eu<8+?vy#dI zFb6glgs&PrNgSmbKC8P+gjL@l2g>qeXx3%6i}7%K3K!UIqv5m_5r5ifB1BkIxk!p{ zEOVdDt+aF)=F)CgIP3{&WSb4AENus`J{z+oy}f)!?NYv0$V6qFIQZ`+mRh75+tIu> zk>w4>?_}D~AljYo@33H_r;}cc?rS|TfurqorHXhpR8G$#PUf5K&?Jg9jqAC*Qk>B? z$KT5+vBX9-U7nqt`{X=3bWLD+$+uCB2;=WYF@#!0EOJ`m*)I22q+0pg)95;6`CGqy z)ufxuGF!JS&i9=8i0i2f|L`*qFQiQoPjrlX?I=06RlYb)Phd9Hu3bHC=89N-T^#j; z>WybgTEfBt+p|Be=zW6Q!Npx`;vKM7+Yg6@F^qy>}{tXb*lXD75Y9Yb#La*$Ee$?(z5IhB3d!LQxzVE3X*OH(QNbHef{xqqHW*>e)*L_ zp2w}9*zCN6``TXf{5TlLy?I@d%Zr1_P%D3Qk9GgT#wQb%{!*(jTX9PZB?`(uegKy_q^tqKc)myfADrLYm@ zM%?AAEWFK5as28nqAt%q5VxU-^bw>xt=Rk#ZK@%QXXW4IE~1~51ENfoML8ovqeP|g zV*%BV#y~l0TD1*5h;#Pl3J0|0wzHaI?+q#IfH8pt;`}TTdw)Uen z50~tJRFCw{-9HY)Tfb%edAl$xJhRWZ;wQ2yHBUbuyQKve%$MTP3pD!+|Cynr_eQol zih`c#teqV;h|R8r+y+q2O%_}|5LW&rP=aFH5m-Q?Y6WaJ?((Tc>}G94r}(_AFIwu6 zn{{3YRlqUwU0W8~4Lhd%2$M!ggFy|(i9Or7rf)7A~q zhtA%q2f6>t?Zw(_q!6s(Uc|K^seYe>{qY{PaVlz$T6$5OYgx?+HWc{Qi+x<0W|u#I zBWhLr45pI967S7Lw2KWLavVK#zd?o#;7M=LHJNA=^}Knh@1;7C&dhL5DU4Er``UPj zV)3IxJ%;q zvKzwlxpxX|c`7WXe;;U)p3#6l5xQ-9|Neo9AIE7PVN1J8q;Yyx4pe7vZN_w?INNQ=Hm}$w#L$h4xstpXs}SGO zTx}Qg0&7JnBhs2DrvzA4^#;{WT>v9rkk#_herAQlvMvaGIp2kg<{4<2R|3;r8!!ic zw{oqK$@)`m7wa}z$w_YqHdx~MHlzU=xc=+&FnsuXS-_~^B!n1v9BeSzNvgauVy8l! z)5Bd0dXl8za|YkWx6Ei!OMoFKJT(;>#(;NHGL zpTO>F9YS2hA7x?sD;0EP;gAb;wUU-;;4+7Tutrt6L+(e}&ZsOD@moLo zn&fMqa{P($QCHFco+Dp@M-VTe8rMM`cr-!=g^97UZ5GHf5W(vD1_Wc5_xCv`S!OW1 z{yCmlnMbL+!Wcr)k%Q#JFI9}+kOO0GQG-Sf)%$kHsktEFph+!szY~h!u5h|!ml167 zw0xL;;KPN`S6KH^HeGpRwkF*r<)`Am8f*BkIk~U?*Rbxn`m4UoNS}lRK!%OS{nO$h zaSLDmPRBZyJCV1O`~TgM9DN;C_Q$vdxupH>$`)(2JyPwI&{(508pEFeb5Qq=J5Myh zkLx~Yf8OS?kRdtXX{IPvpJeBPI2Z?E8TRjT51h9Q7lKfneO3CJ@7BI8)IM-2|I7t| zkz)>9QUN|G$aLr3{o%ER-0fVaYoviDIKZ>+&SbX)a|HX|I5{X@`>Gfnk=e?L0zmNT z@_w(Ml0w4myenF>741l=yn;dxe z2}S+;`_04h9bM1>5FMw!qY*zAv#Z&>JM3Sr4x?6>eC<{uC1MD|7#w&VtK@15OnOMl60O8BjnC<{i9`;kKszsiOeRTR>ewl5Lyxc8h^az(UACn&wV zk_{}LN^)WDEQw@L{=C^nb>TjJMRIz?bA$eJz!mp7X*r_f%AM1pF6GId78&gOfO6h4 zC&2pjnM3!&tEXWSEv@?Q~M+qV~x?IQ}(UfAAhet zY20VJsz)KJ02!TS;v-Fry4&aeYW=k--i5Tauy>R1B0scQY}xXr5lH`~I{=VqeG+(8 z{gnMxHLvi!Wz4kGN`;1E-1{ph4lr=U1jxQW8xTFTtFIM*$|HFsOqKRMYxn-Q&aWuO z#Sd~~p!?#KcH2uy${$Ia>A%;b=X)a32m|*9#T}oQId&1dPmADLe(lKdFDrX@5@8;oRMh79Q~flv zzGTXAL@2FlwjvHK2dcG8+x6;CCC--or$c~nYx~7i$dUKkX!K15Urw2$j@cl70$`DK zPNxU^{OlHTciVSP&RM|mFf*go9|=#1=QvhZSTg>MiFuaYAabyOMpU8asPm<|+Cy-h zL!?r5uQI2m*%mtp^b{XRk26ZUW1ttGJ|w(C)-Er#Caqradi{gbiB+_usn;uF&N zHq?$*Gg`QtVEg%okD*V}3rG?UJbYlb2bf$pRYNq_hgMaW)jZ%?jR2U zWtS(<8GxxjDN5RPZR$D%&zf?mF{cZmcm3BP&ZeMeoTWOmA$3OIb6V*hWn*IPW|DAc zp@0EgYMr z)sxTss$;tDScR1uUh_okrW_AW2&O|PBTnufjlJrM4O^K>QC-iU`rMb7pb=uc?dDH{ zrpCVW%UICj8w|QNJkxijr~6Rq=kFE!NdHxgDW@udw`sFrpulltdM(%B`x~sO;`Pkt zQdcA#6g((?{pqSfv%kOC*rL?a_o5gHa*O6( zGb6}w*6sB#ysLK`bwHJ;y@ovFQ-crtN(K zVJTt{5e=$Rl#mn}F0Q5R<%Z8fB$k|abBJD-t-{@OV~NA?F2WFn2wD_>Bn$@I?ST95 z&qtnjqhbP8i7XWk3!F!-$P3lj%S|#R=Y7J1@@Y(Gh0()hg%o3aXi!W^85+0 ztm+j|Hy(;>-c;M<0ICcaP$sNd#&XDm2^p8PsGQamLaQEdDYK6B&9DS@N6b@Va-dPS z1K0LV^#Cv`t`+s*(;TWw=H-EL@tr~dH|`X1>ErJIqN)z6pGls%X!pNGbOktC`vyu~ z2JN41ojAvn{)2WT&tnLt{}v(2kU5Tn-Q<31tS0$l$(_2?Ocer*Q5MKPdR*&{;$;A& zQl*c7rs3K<&kBj$!8B@;IK%IM3$yp^m&BV)aD9PPGX$w-;L?0?XMJK1d$%%}ZizPf z;?2te>BH9~6G(6g(pT&LS@4bWK~gs05CBcc%mz@0(=*e`Y_*7H=K%EwAKi-_S(V1u zXjw}Y@^<0yvnP(yZVL%4#-<~SurhQ*Tih|(4%m}?EU(}Bi=g|tw#*H~3oRgDNekbn zN_c_b<_4o1#!Qlg>3z=b=aQI9PJsB`ah(V6$$99rq`sJQ3o+mO*-e$4;$#7txlIpfxlCvYzJmxpW=;H6N;Gv4Mf z8G~9P_qjb_y;M=XMYL-LMu&@pqoB!;L5kFcCda7yi>u`9S@G73$bEoXc$=!aH)HCe zK~X%sEzSUnN~a#13K)Cs}lEY9rEgc?DQP{{Lfd^%9E6m zqry-_@n?y=-=9Bb?<1i#@eLgt8VFIkR3_^UV=l?|Ip1A|YgYwxS?EtWw zCkI{ne<8+^=wdQ3rRwbvK@|!CqxB1+CtMysE${sENsXX(5E>m6H^T+YzJ1v1_0O?) z-(tz(Qj;EN9 zoT+j`ij~7sVi-bjAykTgi^kD*t6idoO{~yGYh^ zTAM|aY3H!gD4GSwCroI(Ow?$z#CS3ccA@ygwgvJTc)i*~-;${oezCuw4Yz(=uo>rd z?WEz5EwtOfImLJ8R;g*}Q~Lb7jZ`7xn|@Z;GECsYnt?x}B}fDbnley7g8N*1?4|wu zmp$}qKhDgLl{PP&?5up&KlV;{cd(s?)1=Mc1~1Fj-rfybYQ$VlWmMNS3oU$a`%cpu zqDL_E#dQd`buRFM^!u|k4BmY3cKeOa&%c!NCf$$(Zayer@Qsq8I`KD$BkY#r9)W(A z!+hq}#3c?EQ}7L}3%8b)&js|_*D=PkvCR9PzGjYbAqAA^VM@?|lWn#$dYIb-+;O?W z5yU9`k#^qE)6Ew$!IQ-QmP zI{>s>vY|@>)&9$HahAU|tUF1>I-vLs}Vi!-S(&T|F%8=~~@$h1Q~ zrnS~M=Et+ri7v%N8E1{Kzo(PvisNKeLcHtUY~FVb0RZ#`_r17H71uvxm`P#Ai*nyZ z2%lRW?b+UjgXHz<`#V2q^9l_aZOTcu;#s=s&_zbtcc26Yrc%sTyz8qWQti4w?}2zb zsIT}1SA?%9?Dv9vZV4B}kSMc(vQACyTB7ip=L%2$f0GQch-#_DOf9ybSYDWNny!)2 zcVR7D?3&P%oL1ryO^6bZUM1j`Pd8~My>9<3Ukab8BMmr6wpHpaocQ@y3FOOHUJp;c zr?qUS^mMAc-1N@VsavAB^U66{t;EBE$vjWkj)UZO0l|=@_mTDoFruH}|#d)!u_RB_+$zxs~V_#bL zTp51mdk3MC=QD0TZf#17|6aDY(a8_#+CkB-O224(oa@<-o9H-g?Kkcee?nuuZ zOgGH_&{{4c_GbigZS7y!_A#g*%^4%~JhccIPPl0NtFqKw|Frbp`kJtbaK5-qRi$v1 zhImbl=Q+Orc0T8>A4+Bwb5)R#xPh!tb|ZifL#^CKxrw|yusiGH$<>W={)avNu@Q`v zw~vahv*U~VlpkEJmwLCe^S0brGs77zoBj$AZ^qdWAyPqfJ5hJ&9c0?Rlf9C{VtDhR zsZ?6fYgP!COrb(eNA%r>!4H{foBnL}ULSY!eQ8>@Qg4N=9H`mB-<6-)OdIoM(Q7x4 zB~3ah>Ng@NesJCjQUw^d~NLff41bjXec%E z`3{G}Nv&}-k7)4)D#4ru+p!kb2sL=~y6<+)9myigy9gCRM{qWo{hBYOxz_F0pN)pq z$2uLV=3TP`c$eeTm5PDJXN=S}3W4kiE zqit&hLrPE3zpoCw68)N5^wD_<#MwG>MetD8xUG)!S7k=5^*`tWwv(%O0Np0b>5vh8 zb`@u&FbpL>3ZPYphaAjWtcPglOe4;a)-=+zX=>71w@(nk z^EsDo_#`kEKL-;XnMcIjVstWFjefy;CktQ;xgHnQLEAOzVV`FG60t!fNo&5VI47Pu zd8;B@Rgw7GR-r_kzZd2FDph)6dUPtd#^ZrQw#wCSW2G~jQTJFb=1FM+;~`ZgpnLh~qkNCJx zT`+BUthA$zD(S1jC=5)YZ+wG50hYEKY*c^*#Vg7Mf1BAKpZ1LW@b4%zk;GS&+{;t; z3H?6JY>Vh~!AjWq)iAq)^#^?Y>E`Y^McA=!-YZl2Ava9OA>vFm?_2M0!rQ94pyP*E z6qy2{&GKNwLZy%CA^Sr`cIp%J`X)iB7m~@t;#SMPeh!PL9Lt&|Ck=>?n6{1+B<~~k zR#RpwkC{*DpO~w!8%v>6-ZYI<_+s{c&_LSwe4|VT70p(6;BWvsuW&@2@dZt}p*U*= z&uo3ri^IXo-o^6`iX|X2y-HMA(YkOgy9^t>ly;|Wh57zDh}WA_FscxFd}%aF;m2%b zsW4+>bwQ&l87tg+gMu~RVi}BgWDij!kG_4^_S!bL?WUH;T}1EY!4A{o2Xw8Jj3XnQ zvZO!qkljlUt!3R5)cn#?sQdk`S{4MUh$^4jJG(V$7Sqp8{c{bHJQt(I)swEl>S$fm z(9(;o#Laz0aXJ*V6a39xF2rxeq?@c)$eO{*Nu1n|AmX;jNpXS-NV3c}TVe&mJ&wjPzXk-NQ!v)-+jXxp{yTM)ELXyvW z%pq!&*_j%^t)$WDv1_L)R60D;e~77P6nfbrG3a}8h?EIAlhfgW2X{v79ICLlxAHy5 z`SwxRBcoi$OD|_dnU&!`luH(Kr`u?<0~FoJT$(=D+Lz!fNp#0yW%cHfOI7w{BM?SOl+SLztZ70K;zIBwJHDtYa^ZhOs@BrcTS@^dahU*F=^c_h^NsiuXTdNn` zBp@`b?&gx%d#^tE%XcEWE2H(=gNVW@t7y#4_4+d_q!<;ogdl3=(X4Ac2dYy7bLDT< zSlyY^q6QwCK+4Zr9f7PU%_+(`C`Z}yH79e>Rv7<7D(ugR(vC#$p=6tJWRgn6(wxGr z#>ZZ~9M|u!BZDjRGygcId0oq74}tZ+lwUoBPsncB-}c(gSiNFo`R>(!1(4K8#gVEr zvr*VNnVnVb2aSlPkQZWyz0g4_J7FCUoV_Rd5s$PqV#_mJ^xxY@4q~@rSr#14bj}1M2C-UU8NLhW*|gQh`yPun=!>q%Xn+eS?K{N!IJaRlE^=4+AB*)WGad zDjKT-nud?8$I`|8dr)!(mF?^Ad-D*mvd$k{Q!WmN*+YeuSZZZMq~O!PdSAPyRelY* zEz>pDn4zh4r`CKSvpFN}IKVi~{AcSzKcj}r3=@8h`bEgN8MF? z2#-{n)W@7kLVU9iYAvu885d%EyJ zKFa%iMYm^Ca`q}thkS(IHmVQu4w&o|GN?|6v|`iUQs+IUD;ei zZL6`}-6Frbb%Z|HUV5L?S0&GnY(InC-+oz{tGLr-P-ltiI))v*Tq(N{{RKtau6- z52iUfFT~x9QY^vWrw(u1s+{(jleR;SUv@+sXc3#NC0@|)7Wd1@pH*Un-rfs&e;!YWGLu$UB&8GkVC^Y+6DQx2njF<3U|9apU3Hy4c@sf>li4bm zhUZakNZ(kWW|79em=wS$f;O`sutS!oRUdsIwRzbQdQdUm?9|+$fcD$;m*3ftnwM5% zErx$Z9Lw~&^SeF8t(wBhd1kLhuvh7Z;Y+!@_|)3{OGL%}+%x?{8o{AAo%{R5GFz3O zVYn@#1NptOjlB=~3p6;tk4C2IBOq*L_>o<~f~_y~V#GQ9*=H&*viN=LT0-WUKQYuC z!w&Yp;bIJf1m~)1myv7?5M>DhH~MQ4dHtP1u-1=26MEtM=C3))hHm# zT}6H*0pdadZFoDaW~Bc5WY}WtGH==QYN4OKpA1QBdeJ)1sE>-u*)0gw{KS-+kq?U- zFwNv(t=qlUq;#xEYBKjL0PYMsWi!=UGTu*B37rf^EBS<$Pwz5bs}O$QG!VA+VQ&wk zh@q`RLuuk!el(TbbvyY>N)iiiK^-%1b@d^#eHaV{NW zJ}tj_rt3OtbV;4Ku#ko1@Q(YisNsvtBl&F1yUimL?yE8>*3tKC@?I>3z}u&1(zBNf z7)_PmWy%YFh%sTTI9a_v5l8c}RQCg9Pc}KsFYC}v-^p{%R|sSc(v^2-c-x=>l~}l} zpLHR3`Tco0gmiVBUyd<_`&#jwaWnSDL%EJZ5_tza~$W{Wk=R;>m$? zlUS;6ANy)K#(j-AE0&e6kz7;WHR)(c3e<^40vZ0wL1c;BEr~j2Se@)2(d#ahnRc3t&+<8XZmLDK45YDU3W_(I+*V6=8OnlzzN| zogLg$xIPMNf`7O5t=ZYVCD8xS;F^=-}? zJ-2xrMSSR1ZXedkS2QU@g8_;939J4H;iuM&8Efm~8kkm;JwXLW1@qFAyWM`}Se{aRZ}nZy^QvCA`w(!Pxu2 zL%-noe9$hD3S?3w1de5Lue6!yZs>^IgtR+MF0U5z;`hA^)wqGU@gDC3A9r`=qRvN( zw*9Ry8_zN&3&mYZOMf5Amh^#K4ePfct*LMx0|mYbws0ePvopGkW8IGe1C}ZwJ`TSw zqFFzvWympxBs*xJMQ08>rq>fQgiE2&5>Q{{!vO77ReMYQ4+`9GMY+^0xVRWvW`D>7 zMP{l2PY!aESz`j4Oz))%>j%But32{XnIGsN2RbQfu@@WLMr%>)ZQmbskYq0Il`BHb z#&gQPZ?1(cMte_2MM94Wn;iY2HsNzczB@K$}IpdL5n zvpn06;?#`g6!@qbKPaq27Elo8`K`;aJT*Vn*k?q1;!QiH5?<`g4C1LJMk@+mfVY2@ zc_yHFv7|u3TS(tU3Kpu^8+<=wIU35=)Y`RU`(OqYMoADWC$y%0qAzRs9K@ajGX47^JAet)UJi^F1bS3``@1i(bqzY{XD6=AN{T~{c z{R90y9x`FO8ho<&U~N$$Q1B+>x0V`LQ+D;ia&@fB1P5ehqiB=MUa#X?PY{n*H8)ZT zxkIZ{p)Mj;3EE=)?|$VuqX*9m8cC*6oK8uQLBF8eZvhr8mo#$ZChAs$&){;f8Z?B) zWt_7zW5hS{o^QmTTZY93qiTt`lB&?PU$RpM@BIE&(CYh zE9AV zXZh`Kv)(;l7{2Y9QI?o_htl4=J1;l|(*{f@?OG`s7_&=@G0Zo!HeF+A5!m>->}*VE zCK)i}d+*@v*t%N|sIKJQ(%9DCRAP5MdNpcP>oQk+=hok_g-LA1fm#RRLY+f6y4Obq zA=`JtkP!`~F0MI=p)npf&B0@yv)hl+-1raxMq_pB#~5Ya$LQZ@EBpcFNOp640>*D&i5!xv|lADouA-($MkK^jI(v)rxHv3Kp~vGSPS7 zbmV8ywOA=LYF|$K&bc8hBU_J>j$bXA%7lBFdyJ-)Gg*rf!)2}--$8n1=URp2E39sA z6a;6-P5`vXaBam8%Ajw3HdOBpRLRE zWE}a1(((Da;lOXJEw4&=GuCs5ZAqmRR;|$HdSvy!$U6-gvDpZ1z4p3n$KxQ0Iq5%s z0>F^J=adqIR?+v?Q67m5*+nDEFkO$Z2shH~S*1=ZFQ`CqpMfCWPBp$wSmO@{6+~CV z-PpmJt&L^;G=5dLQbRgeJI`%S`1@!A1@@BIby?O@pC9SR)!aShhIj_R}4>EpY4$s3{zkxpXxtz3dZ`I6KjIY zuHhFeBc;2tN1W!~<}18V9S$B)?G4n(e@&_uc8A;Z@*WwZ?M|JuU!Z4?NX+W0MsuqY zN}rw@HTh(=P?F=uv!4DDc-bz;es^Q73LIkmr^O4?iis-rFbH0qIr!$bocX6@bnKt$ zsm@Yz;&^BzGN)d<;&1q^Yso5Opl{VuC1XK3a?i1d7+noJtey~L>N3Xp@1HxITx>q% zbbUGOv(PHcO&iDetB1?-u^rB>>?^Gu>=E*v5SjKM3LuaPs zV4g(y+s(~bMUjV#8ta}0-t3gNmwI`es`9t9_vcZUUq7&b2Cz!g%;)kwB-N`tVkQ~M zET8tz&F~d$`D=Sk1w4l`#{2i1D|}rHqK3w)p0WEr9UGWTJcHyiQ@*gJetP6Yn~+>Kv&+J ztwk$lOl3i$nLi++tMWzEoHSpEx3Wgt*IP7wQn*$+ z)r+2=UEql8cqs&eQ&=zdb&a__xQDzX2+HiMf-wzT_+Y=5TJu9}Q0ihqLqW!czX3rM z4A3_S(PNzQda?!sT1(8}u_FFRIY`~C`K__MUBsJ?{~o=9F{h?_e4loC0?U$zX7rWms@|73Zj_#<{DJ>8ydFhuO|;NdaU80LQ7cv3{P)$+6+q!2of1#1MV? zS{6u-KN|F*t%hf{CMGI=ewq7vbEgx*@WDnbf63js!ztUsi&Qi zyj^=zU#-kf<4AXGI@H7cCDyi+np_Y6sdaBJ8Mn`bRISc;qPXTKJ$vujtOz^Q(oX1CZ4Gb%aj5cPkF`KNLK literal 0 HcmV?d00001 diff --git a/apps/www/public/images/customers/logos/on-light/flick-games.png b/apps/www/public/images/customers/logos/on-light/flick-games.png new file mode 100644 index 0000000000000000000000000000000000000000..ae22998e739b4964546fbd5e6127dfaa7e03e3c1 GIT binary patch literal 12797 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 Date: Mon, 5 Oct 2026 23:33:19 +0300 Subject: [PATCH 23/41] feat(docs): update search v2 ui (#51175) --- .../Navigation/NavigationMenu/TopNavBar.tsx | 1 - .../features/SearchV2/SearchV2.utils.test.tsx | 24 +- .../docs/features/SearchV2/SearchV2.utils.tsx | 30 ++- .../docs/features/SearchV2/SearchV2Dialog.tsx | 223 ++++++++++++------ .../docs/features/SearchV2/SearchV2Result.tsx | 61 +++++ .../features/SearchV2/SearchV2Trigger.tsx | 24 +- apps/docs/features/ui/LoadingBeam.tsx | 33 +++ apps/docs/styles/code-block.css | 1 + apps/docs/styles/globals.css | 2 + apps/docs/styles/loading-beam.css | 80 +++++++ apps/docs/styles/scroll-fade.css | 32 +++ packages/common/hooks/index.ts | 1 + packages/common/hooks/useDocsSearchV2.ts | 9 +- packages/common/hooks/useMountEffect.ts | 12 + 14 files changed, 425 insertions(+), 108 deletions(-) create mode 100644 apps/docs/features/SearchV2/SearchV2Result.tsx create mode 100644 apps/docs/features/ui/LoadingBeam.tsx create mode 100644 apps/docs/styles/loading-beam.css create mode 100644 apps/docs/styles/scroll-fade.css create mode 100644 packages/common/hooks/useMountEffect.ts diff --git a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx index 1c06cc8b765..451bb8b77b0 100644 --- a/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx +++ b/apps/docs/components/Navigation/NavigationMenu/TopNavBar.tsx @@ -51,7 +51,6 @@ const TopNavBar: FC = () => { {searchVariant === 'search-v2-active' ? ( Search diff --git a/apps/docs/features/SearchV2/SearchV2.utils.test.tsx b/apps/docs/features/SearchV2/SearchV2.utils.test.tsx index d79b031082c..632dafa905b 100644 --- a/apps/docs/features/SearchV2/SearchV2.utils.test.tsx +++ b/apps/docs/features/SearchV2/SearchV2.utils.test.tsx @@ -20,13 +20,13 @@ describe('formatHeadingPath', () => { function renderHighlight(text: string, query: string) { const result = highlightMatches(text, query) - if (typeof result === 'string') return { html: result, strongTexts: [] as string[] } + if (typeof result === 'string') return { html: result, markTexts: [] as string[] } const html = renderToStaticMarkup(<>{result}) const $ = load(html) return { html: $.root().text(), - strongTexts: $('strong') + markTexts: $('mark') .map((_, el) => $(el).text()) .get(), } @@ -37,19 +37,19 @@ describe('highlightMatches', () => { const result = highlightMatches('Bring your own MCP', 'mcp server') expect(typeof result).not.toBe('string') - const { strongTexts, html } = renderHighlight('Bring your own MCP', 'mcp server') - expect(strongTexts).toEqual(['MCP']) + const { markTexts, html } = renderHighlight('Bring your own MCP', 'mcp server') + expect(markTexts).toEqual(['MCP']) expect(html).toBe('Bring your own MCP') }) it('highlights multiple non-overlapping token matches independently', () => { - const { strongTexts } = renderHighlight('MCP servers for your server', 'mcp server') - expect(strongTexts).toEqual(['MCP', 'server', 'server']) + const { markTexts } = renderHighlight('MCP servers for your server', 'mcp server') + expect(markTexts).toEqual(['MCP', 'server', 'server']) }) it('merges overlapping/adjacent matches into a single run', () => { - const { strongTexts } = renderHighlight('server', 'server serv') - expect(strongTexts).toEqual(['server']) + const { markTexts } = renderHighlight('server', 'server serv') + expect(markTexts).toEqual(['server']) }) it('returns the original string unchanged when there is no match', () => { @@ -58,8 +58,8 @@ describe('highlightMatches', () => { }) it('is case-insensitive but preserves the original casing of the matched text', () => { - const { strongTexts } = renderHighlight('Bring your own MCP', 'MCP') - expect(strongTexts).toEqual(['MCP']) + const { markTexts } = renderHighlight('Bring your own MCP', 'MCP') + expect(markTexts).toEqual(['MCP']) }) it('returns the text unchanged for an empty or whitespace-only query', () => { @@ -68,8 +68,8 @@ describe('highlightMatches', () => { }) it('excludes common prepositions/articles/conjunctions from highlighting', () => { - const { strongTexts } = renderHighlight('The best MCP server for you', 'the mcp server') - expect(strongTexts).toEqual(['MCP', 'server']) + const { markTexts } = renderHighlight('The best MCP server for you', 'the mcp server') + expect(markTexts).toEqual(['MCP', 'server']) }) it('returns the text unchanged when the query is made up entirely of ignored words', () => { diff --git a/apps/docs/features/SearchV2/SearchV2.utils.tsx b/apps/docs/features/SearchV2/SearchV2.utils.tsx index d7db0f83e3c..b309ec1f7df 100644 --- a/apps/docs/features/SearchV2/SearchV2.utils.tsx +++ b/apps/docs/features/SearchV2/SearchV2.utils.tsx @@ -1,5 +1,13 @@ +import type { useDocsSearchV2 } from 'common' import type { ReactNode } from 'react' +type DocsSearchV2State = ReturnType['searchState'] + +interface GetIsSearchingParams { + searchState: DocsSearchV2State + query: string +} + /** Common English prepositions/articles/conjunctions, excluded from highlighting so a query like "the mcp server" doesn't bold "the". */ const IGNORED_WORDS = new Set([ 'a', @@ -65,7 +73,7 @@ function getMatchRanges(text: string, query: string): Array<[number, number]> { /** * Highlight every case-insensitive, per-word partial match of `query` inside `text`. * Returns the plain string when there's no match, otherwise a fragment with matches - * wrapped in , preserving the original casing of `text`. + * wrapped in , preserving the original casing of `text`. */ function highlightMatches(text: string, query: string): ReactNode { const ranges = getMatchRanges(text, query) @@ -75,7 +83,11 @@ function highlightMatches(text: string, query: string): ReactNode { let cursor = 0 ranges.forEach(([start, end], i) => { if (start > cursor) nodes.push(text.slice(cursor, start)) - nodes.push({text.slice(start, end)}) + nodes.push( + + {text.slice(start, end)} + + ) cursor = end }) if (cursor < text.length) nodes.push(text.slice(cursor)) @@ -83,4 +95,16 @@ function highlightMatches(text: string, query: string): ReactNode { return <>{nodes} } -export { formatHeadingPath, highlightMatches } +// drives the loading beam: on while a request is in flight or about to be sent +function getIsSearching({ searchState, query }: GetIsSearchingParams): boolean { + if (searchState.status === 'loading') return true + if (searchState.status === 'error') return false + + const trimmedQuery = query.trim() + const settledQuery = 'query' in searchState ? searchState.query : null + + // so a search is on its way and the beam starts on the keystroke, not when the request fires + return trimmedQuery !== '' && trimmedQuery !== settledQuery +} + +export { formatHeadingPath, getIsSearching, highlightMatches } diff --git a/apps/docs/features/SearchV2/SearchV2Dialog.tsx b/apps/docs/features/SearchV2/SearchV2Dialog.tsx index d475a107065..e0ffd7f79c7 100644 --- a/apps/docs/features/SearchV2/SearchV2Dialog.tsx +++ b/apps/docs/features/SearchV2/SearchV2Dialog.tsx @@ -1,10 +1,10 @@ 'use client' +import { LoadingBeam } from '~/features/ui/LoadingBeam' import { useDocsSearchV2, type DocsSearchV2Result } from 'common' -import { Loader2 } from 'lucide-react' import { useRouter } from 'next/navigation' import { VisuallyHidden } from 'radix-ui' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState, type KeyboardEvent } from 'react' import { Command, CommandEmpty, @@ -16,9 +16,11 @@ import { DialogContent, DialogDescription, DialogTitle, + KeyboardShortcut, } from 'ui' -import { formatHeadingPath, highlightMatches } from './SearchV2.utils' +import { getIsSearching } from './SearchV2.utils' +import { SearchV2Result } from './SearchV2Result' import { useSendTelemetryEvent } from '@/lib/telemetry' interface SearchV2DialogProps { @@ -26,28 +28,62 @@ interface SearchV2DialogProps { onOpenChange: (open: boolean) => void } +interface SearchV2PanelProps { + onResultSelect: (path: string) => void + onResultOpen: () => void +} + export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { const router = useRouter() + + function handleSelect(path: string) { + router.push(path) + onOpenChange(false) + } + + function handleResultOpen() { + onOpenChange(false) + } + + return ( + + + + + + ) +} + +function SearchV2Panel({ onResultSelect, onResultOpen }: SearchV2PanelProps) { const sendTelemetryEvent = useSendTelemetryEvent() const { searchState, handleDocsSearchDebounced, resetSearch } = useDocsSearchV2() - const [highlightQuery, setHighlightQuery] = useState('') + const [query, setQuery] = useState('') + const [isDeleting, setIsDeleting] = useState(false) + const inputRef = useRef(null) - // Clear stale results once the dialog closes - useEffect(() => { - if (!open) resetSearch() - }, [open, resetSearch]) + // highlight with the query the visible results belong to, not the one being typed + const highlightQuery = + 'query' in searchState + ? searchState.query + : 'staleQuery' in searchState + ? searchState.staleQuery + : '' - // Only update the highlighted query once a new result set actually lands, so highlights - // don't shift on every keystroke while the debounced search is still in flight. useEffect(() => { if (searchState.status === 'results' || searchState.status === 'noResults') { - setHighlightQuery(searchState.query) sendTelemetryEvent({ action: 'docs_search_v2_search_submitted', properties: { query: searchState.query }, }) - } else if (searchState.status === 'initial') { - setHighlightQuery('') } }, [searchState, sendTelemetryEvent]) @@ -58,7 +94,25 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { ? searchState.staleResults : [] + const isListVisible = + results.length > 0 || searchState.status === 'noResults' || searchState.status === 'error' + const isSearching = getIsSearching({ searchState, query }) + + function handleClear() { + setQuery('') + resetSearch() + inputRef.current?.focus() + } + + // cmdk's root turns enter into "open highlighted result", so keep it on the button. + // other keys must bubble, or tab never reaches the dialog's focus trap + function handleClearKeyDown(event: KeyboardEvent) { + if (event.key === 'Enter') event.stopPropagation() + } + function handleValueChange(value: string) { + setIsDeleting(value.length < query.length) + setQuery(value) if (value) { handleDocsSearchDebounced(value) } else { @@ -71,8 +125,7 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { action: 'docs_search_v2_result_clicked', properties: { resultPath: path, query: highlightQuery }, }) - router.push(path) - onOpenChange(false) + onResultSelect(path) } // Announced via the aria-live region below — a sighted user sees the spinner/list update, @@ -89,69 +142,89 @@ export function SearchV2Dialog({ open, onOpenChange }: SearchV2DialogProps) { } return ( - - {/* hideClose: this is a search box, not a form — closing is Escape/click-outside only, no "X" */} - - - - Search docs - Search the Supabase documentation - - - {/* + + + Search docs + Search the Supabase documentation + +
+ + {query ? ( + + ) : null} + +
+ {/* Screen-reader-only status announcement. Focus stays in the input as results come in (that's what lets people keep typing), so nothing else here gets read aloud on its own — this is what tells a screen reader user a search ran and how many results it found. */} -
- {getStatusMessage()} -
- - {searchState.status === 'initial' && ( - Start typing to search the docs. - )} - {searchState.status === 'loading' && results.length === 0 && ( -
-
- )} - {searchState.status === 'noResults' && No results found.} - {searchState.status === 'error' && ( - Something went wrong. Please try again. - )} - {results.length > 0 && ( - - {results.map((page) => ( - handleSelect(page.path)} - > -
- - {highlightMatches(formatHeadingPath(page.headingPath), highlightQuery)} - - {page.excerpt && ( - - {highlightMatches(page.excerpt, highlightQuery)} - - )} -
-
- ))} -
- )} -
-
-
-
+
+ {getStatusMessage()} +
+ + {searchState.status === 'noResults' && No results found.} + {searchState.status === 'error' && ( + Something went wrong. Please try again. + )} + {results.length > 0 && ( + + {results.map((result) => ( + + ))} + + )} + + {results.length > 0 ? : null} + + ) +} + +function SearchV2Footer() { + return ( +
+ + Navigate + + + + + + Open + + +
) } diff --git a/apps/docs/features/SearchV2/SearchV2Result.tsx b/apps/docs/features/SearchV2/SearchV2Result.tsx new file mode 100644 index 00000000000..d19147e9d8c --- /dev/null +++ b/apps/docs/features/SearchV2/SearchV2Result.tsx @@ -0,0 +1,61 @@ +'use client' + +import type { DocsSearchV2Result } from 'common' +import Link from 'next/link' +import { useRef, type MouseEvent } from 'react' +import { CommandItem } from 'ui' + +import { formatHeadingPath, highlightMatches } from './SearchV2.utils' + +interface SearchV2ResultProps { + result: DocsSearchV2Result + highlightQuery: string + onResultSelect: (path: string) => void + onResultOpen: () => void +} + +export function SearchV2Result({ + result, + highlightQuery, + onResultSelect, + onResultOpen, +}: SearchV2ResultProps) { + // clicks navigate via the link and also fire onSelect, so onSelect only navigates for Enter + const isPointerSelectRef = useRef(false) + + function handleLinkClick(event: MouseEvent) { + isPointerSelectRef.current = true + const isNewTab = event.metaKey || event.ctrlKey || event.shiftKey || event.altKey + if (!isNewTab) onResultOpen() + } + + function handleSelect() { + if (isPointerSelectRef.current) { + isPointerSelectRef.current = false + return + } + onResultSelect(result.path) + } + + return ( + + +
+

+ {highlightMatches(formatHeadingPath(result.headingPath), highlightQuery)} +

+ {result.excerpt ? ( +

+ {highlightMatches(result.excerpt, highlightQuery)} +

+ ) : null} +
+ +
+ ) +} diff --git a/apps/docs/features/SearchV2/SearchV2Trigger.tsx b/apps/docs/features/SearchV2/SearchV2Trigger.tsx index 21d9c73cda2..fadb6e383cb 100644 --- a/apps/docs/features/SearchV2/SearchV2Trigger.tsx +++ b/apps/docs/features/SearchV2/SearchV2Trigger.tsx @@ -66,30 +66,22 @@ export function SearchV2Trigger({ className, placeholder = 'Search...' }: Search }) }} className={cn( - 'group cursor-pointer', + 'cursor-pointer', 'grow md:min-w-44 xl:min-w-56 h-[30px] rounded-md', - 'pl-1.5 md:pl-2 pr-1', + 'pl-2 pr-1', 'flex items-center justify-between', - 'bg-transparent text-foreground-lighter border border-strong', - 'hover:bg-popover hover:border-control-hover', + 'border border-default bg-surface-75 text-foreground-lighter shadow-(--shadow-codeblock)', + 'hover:border-strong hover:text-foreground-light', 'focus-ring', 'transition-colors', className )} > -
- -

{placeholder}

+
+ +

{placeholder}

- + diff --git a/apps/docs/features/ui/LoadingBeam.tsx b/apps/docs/features/ui/LoadingBeam.tsx new file mode 100644 index 00000000000..e9dd73faf67 --- /dev/null +++ b/apps/docs/features/ui/LoadingBeam.tsx @@ -0,0 +1,33 @@ +'use client' + +import { useState } from 'react' +import { cn } from 'ui' + +export interface LoadingBeamProps { + isActive: boolean + direction?: 'forward' | 'backward' + className?: string +} + +export const LoadingBeam = ({ isActive, direction = 'forward', className }: LoadingBeamProps) => { + const [run, setRun] = useState(0) + const [runDirection, setRunDirection] = useState(direction) + const [wasActive, setWasActive] = useState(isActive) + if (isActive !== wasActive) { + setWasActive(isActive) + if (isActive) { + setRun((current) => current + 1) + setRunDirection(direction) + } + } + + return ( +
+ ) +} diff --git a/apps/docs/styles/code-block.css b/apps/docs/styles/code-block.css index 762e9e829c3..66bc752d432 100644 --- a/apps/docs/styles/code-block.css +++ b/apps/docs/styles/code-block.css @@ -39,6 +39,7 @@ } [data-theme='light'], .light { + --shadow-codeblock: 0 0 #0000; --code-token-keyword: #5f2fc4; --code-foreground: oklch(from var(--foreground-light) l c h / 1); --code-token-constant: #15593b; diff --git a/apps/docs/styles/globals.css b/apps/docs/styles/globals.css index 2a39bcb51c0..82d4d622f81 100644 --- a/apps/docs/styles/globals.css +++ b/apps/docs/styles/globals.css @@ -1,6 +1,8 @@ @import 'config/tailwind.config.css'; @import './code-block.css'; @import './reference.css'; +@import './loading-beam.css'; +@import './scroll-fade.css'; @source '../app/**/*.{ts,tsx,mdx}'; @source '../components/**/*.tsx'; diff --git a/apps/docs/styles/loading-beam.css b/apps/docs/styles/loading-beam.css new file mode 100644 index 00000000000..c82758df11f --- /dev/null +++ b/apps/docs/styles/loading-beam.css @@ -0,0 +1,80 @@ +@property --loading-beam-x { + syntax: ''; + initial-value: 0.5; + inherits: true; +} + +@keyframes loading-beam-sweep { + 0%, + 100% { + --loading-beam-x: 0; + } + 50% { + --loading-beam-x: 1; + } +} + +@layer components { + .loading-beam { + --loading-beam-color: var(--foreground-default); + --loading-beam-half: 100px; + --loading-beam-center: calc( + var(--loading-beam-half) + var(--loading-beam-x) * (100% - var(--loading-beam-half) * 2) + ); + position: absolute; + inset: auto 0 0; + height: 16px; + overflow: hidden; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease-out; + animation: loading-beam-sweep 2s ease-in-out -0.25s infinite paused; + } + + :is([data-theme='light'], .light) .loading-beam { + --loading-beam-color: var(--primary); + } + + .loading-beam[data-direction='backward'] { + animation-delay: -1.25s; + } + + .loading-beam[data-active] { + opacity: 1; + transition-duration: 250ms; + animation-play-state: running; + } + + .loading-beam::before, + .loading-beam::after { + content: ''; + position: absolute; + } + + .loading-beam::before { + inset: 0 0 -8px; + filter: blur(4px); + background: radial-gradient( + 120px 12px at var(--loading-beam-center) calc(100% - 8px), + color-mix(in oklch, var(--loading-beam-color) 3.2%, transparent), + transparent + ); + } + + .loading-beam::after { + inset: auto 0 0; + height: 1px; + background: radial-gradient( + var(--loading-beam-half) 4px at var(--loading-beam-center) 100%, + color-mix(in oklch, var(--loading-beam-color) 44.5%, transparent), + color-mix(in oklch, var(--loading-beam-color) 16%, transparent) 45%, + transparent + ); + } + + @media (prefers-reduced-motion: reduce) { + .loading-beam { + animation: none; + } + } +} diff --git a/apps/docs/styles/scroll-fade.css b/apps/docs/styles/scroll-fade.css new file mode 100644 index 00000000000..e7525d8dd83 --- /dev/null +++ b/apps/docs/styles/scroll-fade.css @@ -0,0 +1,32 @@ +@property --scroll-fade-size { + syntax: ''; + initial-value: 0px; + inherits: false; +} + +@keyframes scroll-fade-bottom { + 0%, + 85% { + --scroll-fade-size: 24px; + } + 100% { + --scroll-fade-size: 0px; + } +} + +@layer components { + @supports (animation-timeline: scroll()) { + .scroll-fade-bottom { + mask-image: linear-gradient( + to bottom, + #000 calc(100% - var(--scroll-fade-size)), + rgb(0 0 0 / 0.8) calc(100% - var(--scroll-fade-size) * 0.75), + rgb(0 0 0 / 0.5) calc(100% - var(--scroll-fade-size) * 0.5), + rgb(0 0 0 / 0.2) calc(100% - var(--scroll-fade-size) * 0.25), + transparent 100% + ); + animation: scroll-fade-bottom linear both; + animation-timeline: scroll(self); + } + } +} diff --git a/packages/common/hooks/index.ts b/packages/common/hooks/index.ts index 23bbecba6a7..93b1984adde 100644 --- a/packages/common/hooks/index.ts +++ b/packages/common/hooks/index.ts @@ -11,6 +11,7 @@ export * from './useDocsSearchV2' export * from './useDragToClose' export * from './useEffectEvent' export * from './useIsomorphicLayoutEffect' +export * from './useMountEffect' export * from './useOnChange' export * from './useParams' export * from './useSearchParamsShallow' diff --git a/packages/common/hooks/useDocsSearchV2.ts b/packages/common/hooks/useDocsSearchV2.ts index 57ecd61aeeb..26af620d52d 100644 --- a/packages/common/hooks/useDocsSearchV2.ts +++ b/packages/common/hooks/useDocsSearchV2.ts @@ -3,6 +3,8 @@ import { compact, debounce } from 'lodash' import { useCallback, useMemo, useReducer, useRef } from 'react' +import { useMountEffect } from './useMountEffect' + // This app's own base path, set only for apps deployed under a path prefix (docs' is '/docs'). const BASE_PATH = process.env.NEXT_PUBLIC_BASE_PATH ?? '' // Public URL of the docs deployment, which hosts the search API routes. @@ -21,7 +23,7 @@ interface DocsSearchV2Result { type SearchState = | { status: 'initial'; key: number } - | { status: 'loading'; key: number; staleResults: DocsSearchV2Result[] } + | { status: 'loading'; key: number; staleResults: DocsSearchV2Result[]; staleQuery: string } | { status: 'results'; key: number; results: DocsSearchV2Result[]; query: string } | { status: 'noResults'; key: number; query: string } | { status: 'error'; key: number; message: string } @@ -74,6 +76,8 @@ function reducer(state: SearchState, action: Action): SearchState { key: action.key, staleResults: 'results' in state ? state.results : 'staleResults' in state ? state.staleResults : [], + // keep highlighted query while loading + staleQuery: 'query' in state ? state.query : 'staleQuery' in state ? state.staleQuery : '', } case 'reset': return { status: 'initial', key: action.key } @@ -122,6 +126,9 @@ const useDocsSearchV2 = () => { debouncedSearch.cancel() }, [debouncedSearch]) + // the dialog unmounts on close, so cancel any pending search instead of fetching after it's gone + useMountEffect(() => debounceCancel) + const resetSearch = useCallback(() => { debounceCancel() key.current += 1 diff --git a/packages/common/hooks/useMountEffect.ts b/packages/common/hooks/useMountEffect.ts new file mode 100644 index 00000000000..8bf47840b61 --- /dev/null +++ b/packages/common/hooks/useMountEffect.ts @@ -0,0 +1,12 @@ +'use client' + +import { useEffect, type EffectCallback } from 'react' + +/** + * runs an effect once on mount, with its cleanup on unmount, the escape hatch for one-time + * sync with an external system and derive state or use event handlers everywhere else + */ +export function useMountEffect(effect: EffectCallback): void { + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(effect, []) +} From 259dbe11f1dc13162437b1c341db660917dbf926 Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Mon, 5 Oct 2026 15:14:27 -0700 Subject: [PATCH 24/41] docs/functions auth structure (#50885) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Restructure the topic based off of the PRed Style Guide. ## Solution - **Group the seven sections into three.** A concept opener, `Choose an auth mode`, holds the mode table. `Secure your function` holds the six patterns. `Environment variables` stays last as the fact group. - **Add an intro outline** linking the three groups, and a group introduction for the patterns. - **Move the Authorization headers link below the mode table**, so the sentence that introduces the table sits next to it. - **Correct the content listing entry** to match the page's own title. **Anchors:** every heading text is unchanged. Five headings move from `##` to `###`, which preserves the slug. The in-page link to `#external-webhooks` and the two existing redirects in `apps/www/lib/redirects.js` all still resolve. Verified by grepping the repo for `functions/auth#` before and after. ## Preview links | Site | Live | Preview | Search for | | ---- | ---- | ------- | ---------- | | Docs | [/docs/guides/functions/auth](https://supabase.com/docs/guides/functions/auth) | [/docs/guides/functions/auth](https://docs-git-docs-functions-auth-structure-supabase.vercel.app/docs/guides/functions/auth) | Choose an auth mode | | Docs | [/docs/guides/functions](https://supabase.com/docs/guides/functions) | [/docs/guides/functions](https://docs-git-docs-functions-auth-structure-supabase.vercel.app/docs/guides/functions) | Securing Edge Functions | ## Review instructions 1. Open the live and preview links side-by-side. 2. See three top-level entries in the preview's table of contents, with six nested under `Secure your function`. 3. Load `/docs/guides/functions/auth#external-webhooks` on the preview. See the page jump to that section. 4. Open the second preview link. See the listing card read "Securing Edge Functions" rather than "With supabase-js". ## Checklist Check all before review: - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which references [WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md) and the docs [CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide ## Summary by CodeRabbit * **Documentation** * Reorganized the authentication guide with a table of contents and clearer sections on choosing an auth mode and securing a function. * Clarified that the guide covers all supported auth modes, combining modes, and custom error responses. * Renamed the guide listing to “Securing Edge Functions” and updated its description to mention declaring accepted credentials. --- apps/docs/content/guides/functions/auth.mdx | 26 +++++++++++++------ .../data/content-listings/functions.data.ts | 4 +-- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/apps/docs/content/guides/functions/auth.mdx b/apps/docs/content/guides/functions/auth.mdx index 511dfe9ce9a..4ab0d76a578 100644 --- a/apps/docs/content/guides/functions/auth.mdx +++ b/apps/docs/content/guides/functions/auth.mdx @@ -7,9 +7,13 @@ subtitle: 'Authentication patterns for Edge Functions' Secure an Edge Function by declaring which credentials it accepts. The `withSupabase` wrapper from the [`@supabase/server` package](https://github.com/supabase/server) on GitHub checks each caller against the `auth` mode you set. Your handler receives a preconfigured Supabase client on `ctx`. -For how authorization headers and the `verify_jwt` platform check work, see [Authorization headers](/docs/guides/functions/auth-headers). +- [Choose an auth mode](#choose-an-auth-mode) compares the four modes. Start here if you aren't sure which one your function needs. +- [Secure your function](#secure-your-function) has an example for each mode, plus how to combine modes and shape your own error response. +- [Environment variables](#environment-variables) lists what `@supabase/server` reads from the environment. -The wrapper accepts four `auth` modes: +## Choose an auth mode + +Each mode names the credential a caller must present. `withSupabase` accepts four: | Mode | Accepts | | --------------- | ------------------------------------------ | @@ -18,7 +22,13 @@ The wrapper accepts four `auth` modes: | `'publishable'` | A publishable key on `apikey` | | `'none'` | Any caller, no check (for signed webhooks) | -## Authenticated user calls +For how authorization headers and the `verify_jwt` platform check work, see [Authorization headers](/docs/guides/functions/auth-headers). + +## Secure your function + +Each of the following sections covers one mode, then how to combine modes and shape your own error response. Every example wraps the handler in `withSupabase` and reads its Supabase client from `ctx`. + +### Authenticated user calls When a signed-in user calls a function, the request carries the user's session JWT on the `Authorization` header. Your app usually makes that call through `supabase.functions.invoke`. The default is `verify_jwt = true`. The platform validates the JWT before your handler runs. Use `auth: 'user'` to get a `ctx.supabase` scoped to the caller's Row Level Security (RLS) policies. @@ -40,7 +50,7 @@ export default { } ``` -## Service-to-service calls +### Service-to-service calls Cron jobs, workers, `pg_net`, and other Edge Functions make calls with a secret key on the `apikey` header rather than a user JWT. Disable `verify_jwt` and use `auth: 'secret'`. The wrapper validates the key against any secret key in your [project's API keys](/dashboard/project/_/settings/api-keys), and gives your handler `ctx.supabaseAdmin` for privileged work. @@ -63,7 +73,7 @@ To accept only one specific key, use `auth: 'secret:'`. For example, `auth -## Public functions +### Public functions For a genuinely public function, such as a health check, use `auth: 'none'` with `verify_jwt = false` so anonymous callers can reach the handler. @@ -85,7 +95,7 @@ export default { `auth: 'none'` accepts every caller. For a function that authenticates callers itself, see the [External webhooks](#external-webhooks) section of this page. -## External webhooks +### External webhooks External providers such as Stripe or GitHub don't send Supabase credentials. They sign the request body with their own shared secret. Use `auth: 'none'` to skip the wrapper's credential check, then verify the provider's signature inside the handler. Keep `verify_jwt = false`. @@ -131,7 +141,7 @@ export default { -## Combining modes +### Combining modes Functions that answer both users and internal callers take an array on `auth`. The wrapper tries each mode in order and uses the first one that matches. `ctx.authMode` tells you which mode matched. @@ -151,7 +161,7 @@ export default { } ``` -## Custom error responses +### Custom error responses To shape the 401 response yourself, use `createSupabaseContext` instead of `withSupabase`. It returns a `{ data, error }` tuple instead of rejecting the request for you. diff --git a/apps/docs/data/content-listings/functions.data.ts b/apps/docs/data/content-listings/functions.data.ts index bb69d870704..7400312387c 100644 --- a/apps/docs/data/content-listings/functions.data.ts +++ b/apps/docs/data/content-listings/functions.data.ts @@ -21,9 +21,9 @@ export const functionsExamplesSupabase: ContentListingGroup = { type: 'grid', items: [ { - title: 'With supabase-js', + title: 'Securing Edge Functions', href: '/guides/functions/auth', - description: 'Use the Supabase client inside your Edge Function.', + description: 'Declare which credentials your Edge Function accepts.', }, { title: 'Connect to Postgres', From 63c165311ec003b1d50354cae98f144288ef3c8f Mon Sep 17 00:00:00 2001 From: Miranda Limonczenko Date: Mon, 5 Oct 2026 15:14:27 -0700 Subject: [PATCH 25/41] docs(functions): act on the Edge Function auth eval findings (#50886) Closes DOCS-1318 ## Problem An agent was asked to build an Edge Function returning the order history for whoever is signed in and calling it. Three runs, all correct: each one used the page's `auth: 'user'` pattern and read through the caller-scoped client. The eval scores 7 of 7, including the guide-read check. These are the gaps that showed up around it. | Finding | What the page does now | Why it matters | | --- | --- | --- | | Two clients, no guidance | The first example destructures `supabase` and `supabaseAdmin` together and labels the second "bypasses RLS (service role)" | A reader skimming for the client to use sees two, and one of them is wrong for that section | | No consequence named | "Bypasses RLS" is the strongest phrasing anywhere | A handler querying a shared table through the privileged client without a filter returns every user's rows. The page never said so | | `verify_jwt` as a value to set | Appears six times, five of them as something to change | Switching it off to clear a 401 in development is a reported failure. The page never said the default is the safe one | ## Solution - **Say which client to reach for**, in the section where both are handed over. - **Name the outcome** in a `danger` admonition: a handler that queries a shared table through `ctx.supabaseAdmin` without filtering by the caller's ID returns every user's rows. - **Frame `verify_jwt = true` as the default to leave alone** on user-facing functions, and say what turning it off costs. - **Say every project starts with a secret key named `default`.** **Not asserted here:** the eval is not re-run. It was already at 7 of 7, so there is no headroom to measure an improvement. A candidate new check is proposed on DOCS-1318. ## Preview links | Site | Live | Preview | Search for | | ---- | ---- | ------- | ---------- | | Docs | [/docs/guides/functions/auth](https://supabase.com/docs/guides/functions/auth) | [/docs/guides/functions/auth](https://docs-git-docs-functions-auth-eval-findings-supabase.vercel.app/docs/guides/functions/auth) | returns every user's rows | ## Review instructions 1. Open the live and preview links side-by-side. 2. Read Authenticated user calls on the preview. See a paragraph on choosing between `ctx.supabase` and `ctx.supabaseAdmin`, then a `danger` admonition naming the every-user's-rows outcome. 3. See the same section say to leave `verify_jwt = true` on for user-facing functions. 4. Read the note under Service-to-service calls. See it mention the `default` secret key. ## Checklist Check all before review: - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [x] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which references [WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md) and the docs [CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide ## Summary by CodeRabbit * **Documentation** * Clarified that `secret` and `publishable` authentication modes accept the `default` key, and documented how default, wildcard, and additional keys are handled. * Explained that JWT verification is enabled by default and that disabling it leaves `withSupabase` as the only caller-verification step. * Added guidance that admin queries bypass row-level security and should be scoped to the caller when accessing shared tables. * Clarified that service-to-service authentication with `secret` validates only the `default` key. --- apps/docs/content/guides/functions/auth.mdx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/docs/content/guides/functions/auth.mdx b/apps/docs/content/guides/functions/auth.mdx index 4ab0d76a578..f502c125ad4 100644 --- a/apps/docs/content/guides/functions/auth.mdx +++ b/apps/docs/content/guides/functions/auth.mdx @@ -18,8 +18,8 @@ Each mode names the credential a caller must present. `withSupabase` accepts fou | Mode | Accepts | | --------------- | ------------------------------------------ | | `'user'` | A valid user JWT on `Authorization` | -| `'secret'` | A secret key on `apikey` | -| `'publishable'` | A publishable key on `apikey` | +| `'secret'` | The `default` secret key on `apikey` | +| `'publishable'` | The `default` publishable key on `apikey` | | `'none'` | Any caller, no check (for signed webhooks) | For how authorization headers and the `verify_jwt` platform check work, see [Authorization headers](/docs/guides/functions/auth-headers). @@ -30,7 +30,9 @@ Each of the following sections covers one mode, then how to combine modes and sh ### Authenticated user calls -When a signed-in user calls a function, the request carries the user's session JWT on the `Authorization` header. Your app usually makes that call through `supabase.functions.invoke`. The default is `verify_jwt = true`. The platform validates the JWT before your handler runs. Use `auth: 'user'` to get a `ctx.supabase` scoped to the caller's Row Level Security (RLS) policies. +When a signed-in user calls a function, the request carries the user's session JWT on the `Authorization` header. Your app usually makes that call through `supabase.functions.invoke`. Use `auth: 'user'` to get a `ctx.supabase` scoped to the caller's Row Level Security (RLS) policies. + +The default is `verify_jwt = true`, so the platform validates the JWT before your handler runs. Leave it on for user-facing functions. Turning it off to clear a 401 during development removes the platform's check, leaving `withSupabase` as the only thing that verifies the caller. ```ts import { withSupabase } from 'npm:@supabase/server@1' @@ -50,9 +52,17 @@ export default { } ``` +Reach for `ctx.supabase` when the handler serves the signed-in caller. It applies their RLS policies to every query. Use `ctx.supabaseAdmin` only for work that has to cross those policies. + + + +A handler that queries a shared table through `ctx.supabaseAdmin` without filtering by the caller's ID returns every user's rows. `ctx.supabaseAdmin` bypasses Row Level Security, so filter by the caller's ID from `ctx.userClaims`, or use `ctx.supabase` instead. + + + ### Service-to-service calls -Cron jobs, workers, `pg_net`, and other Edge Functions make calls with a secret key on the `apikey` header rather than a user JWT. Disable `verify_jwt` and use `auth: 'secret'`. The wrapper validates the key against any secret key in your [project's API keys](/dashboard/project/_/settings/api-keys), and gives your handler `ctx.supabaseAdmin` for privileged work. +Cron jobs, workers, `pg_net`, and other Edge Functions make calls with a secret key on the `apikey` header rather than a user JWT. Disable `verify_jwt` and use `auth: 'secret'`. The wrapper validates the key against the secret key named `default` in your [project's API keys](/dashboard/project/_/settings/api-keys), and gives your handler `ctx.supabaseAdmin` for privileged work. ```ts import { withSupabase } from 'npm:@supabase/server@1' @@ -67,7 +77,7 @@ export default { -To accept only one specific key, use `auth: 'secret:'`. For example, `auth: 'secret:automations'` accepts only the secret key named `automations`. To name a key, open [**Settings > API keys**](/dashboard/project/_/settings/api-keys) in the Supabase Dashboard. The same syntax works for publishable keys: `auth: 'publishable:'`. +`auth: 'secret'` accepts only the key named `default`. To accept a different key, use `auth: 'secret:'`. For example, `auth: 'secret:automations'` accepts only the secret key named `automations`. To accept any secret key on the project, use `auth: 'secret:*'`. Every project starts with a secret key named `default`, and you can add more. To name a new key, open [**Settings > API keys**](/dashboard/project/_/settings/api-keys) in the Supabase Dashboard. The same syntax works for publishable keys: `auth: 'publishable:'` and `auth: 'publishable:*'`. ![The Secret keys section of the Supabase Dashboard. A table with Name and API key columns lists two keys, "default" and "automations". Each row shows a masked sb_secret_ value with reveal and copy buttons.](/docs/img/guides/functions/secret-keys-automations.png) From a9078612f23bc448531b55438e3a5deabcdeb0c1 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 5 Oct 2026 15:17:04 -0700 Subject: [PATCH 26/41] fix(www): stop cross-zone link prefetch (#51066) About 95% of the 404s served on supabase.com are App Router RSC prefetches (`?_rsc=`) that www ``s fire at paths another zone serves: `/docs`, `/dashboard`, `/library`, and the footer's `humans.txt`, `lawyers.txt` and `security.txt`. Next.js can't prefetch or client-navigate across multi-zone boundaries, so each prefetch 404s even though the link itself works. I turned every www link into another zone into a plain `` and added a lint rule so new ones stay that way. **Changed:** - **Cross-zone links are plain anchors**: links that always leave www (literal `/docs`, `/dashboard` and `.txt` hrefs, absolute `https://supabase.com/dashboard` URLs, the `getDashboardCtaHref` CTAs) render ``. Renderers whose href comes from data (nav, footer, plan and add-on CTAs, product cards) branch on `isCrossZoneHref`, which reads the zone list from `lib/rewrites.js`. In-zone links stay `` and keep prefetching. - **New literal links can't regress**: `www/no-cross-zone-link` errors on a `next/link` `` whose literal or template href points at another zone. It evaluates `lib/rewrites.js` as production, so `/docs` counts in every environment. - **Click tracking survives the full navigation**: `sign_in_button_clicked`, `start_project_button_clicked` and `www_pricing_plan_cta_clicked` now send with `keepalive`, like `sign_in_submitted` already did, so an immediate page load can't cancel them. The mobile nav Sign in and Start your project buttons used `legacyBehavior`, which never called their `onClick`: PostHog has no `Mobile Nav` location for either event in the last 30 days. Those clicks report from this PR on. - **Typecheck no longer crashes**: the functions page's default export inferred a type through `RealtimeLogs`'s unexported `Props`, which makes the native TypeScript compiler panic during `tsc --noEmit`. I exported `Props`. **Note:** the lint rule only sees literal hrefs. A new renderer whose href comes from data needs its own `isCrossZoneHref` branch, and review is the only check on that. ## To test `/docs` is only rewritten on production and absolute `https://supabase.com/...` links are cross-origin on a preview, so the preview proves the relative non-docs cases (`/dashboard*`, `/library`, the footer .txt files). `/docs/...` prefetches still appear on the preview because it has no docs rewrite. Tested on Vercel preview: - [x] Open `/` with the network tab filtered to `_rsc` and scroll to the footer: no requests for `/dashboard*`, `/library`, `/design-system`, `/kb`, `/evals`, `/humans.txt`, `/lawyers.txt` or `/.well-known/security.txt`, while in-zone ones such as `/pricing`, `/features` and `/blog` still appear - [x] Same check on `/pricing`, `/auth`, `/database`, `/storage`, `/realtime`, `/edge-functions`, `/blog` and a blog post: no `_rsc` requests to `/dashboard*`, `/library` or the footer .txt files - [x] Open the Developers dropdown on desktop and the mobile menu at 390px: no new `_rsc` requests to `/dashboard*` or `/library` - [x] Click header Docs, footer Humans.txt, the hero Start your project button and the pricing Free plan button: each lands where it did before (`/docs`, `/humans.txt` text, `https://supabase.com/dashboard/sign-up`, `https://supabase.com/dashboard/new?plan=free`). Signed out, the Free plan button lands on the dashboard sign-in with `plan=free&returnTo=%2Fnew` - [x] Click the hero Start your project button: the `/platform/telemetry/event` POST with `start_project_button_clicked` completes with a 2xx after the page starts navigating. 201 with the navigation held; on the real navigation the event still reached staging PostHog - [x] At 390px, open the mobile menu and click Sign in: a `/platform/telemetry/event` POST with `sign_in_button_clicked` and `buttonLocation: "Mobile Nav"` fires. Start your project in the same menu also sends `start_project_button_clicked` with `buttonLocation: "Mobile Nav"` - [ ] Signed in, load `/`: no `/dashboard/projects?_rsc=` request (not run: the preview origin has no signed-in session) - [x] Open the desktop Product dropdown and the Product section of the 390px mobile menu: Compute shows its Private Alpha badge and the other products show none (checks the master merge into `MenuItem`) After deploy, `/` and `/pricing` on supabase.com show no `_rsc` requests to `/docs*`, `/dashboard*` or `/library`. After a full day, the share of supabase.com 404s carrying `_rsc=` should drop from about 95% to under 10%, and `sign_in_button_clicked` and `start_project_button_clicked` should start showing a `Mobile Nav` location in PostHog. ## Linear - fixes GROWTH-1294 --- .../accenture-reinvention-2026/contest.tsx | 6 +- .../contest-thank-you.tsx | 2 +- .../ai-engineer-europe-2026/contest.tsx | 2 +- apps/www/_go/events/dash-2026/contest.tsx | 2 +- .../mcp-dev-nyc-2026/contest-thank-you.tsx | 2 +- .../_go/events/mcp-dev-nyc-2026/contest.tsx | 2 +- .../pgconf-dev-2026/contest-thank-you.tsx | 2 +- .../_go/events/pgconf-dev-2026/contest.tsx | 2 +- .../contest-thank-you.tsx | 2 +- .../events/postgres-summit-2026/contest.tsx | 2 +- .../contest-thank-you.tsx | 2 +- .../events/postgresconf-sjc-2026/contest.tsx | 2 +- .../_go/events/startup-grind-2026/contest.tsx | 2 +- .../events/stripe-sessions-2026/contest.tsx | 4 +- .../_go/events/stripe-sessions-2026/party.tsx | 6 +- apps/www/_go/events/sxsw-2026/contest.tsx | 2 +- .../contest-thank-you.tsx | 2 +- .../vercel-ship-sydney-2026/contest.tsx | 2 +- apps/www/_go/thank-you/example-thank-you.tsx | 5 +- .../agency-webinar-mar2026-thankyou.tsx | 5 +- .../_go/webinar/bolt-webinar-thank-you.tsx | 5 +- .../figma-webinar-may2026-thankyou.tsx | 5 +- .../_components/BuiltWithSupabaseSection.tsx | 4 +- .../www/app/(home)/_components/CTASection.tsx | 4 +- .../_components/FrameworksSectionClient.tsx | 57 +++++---- apps/www/app/(home)/_components/Hero.tsx | 4 +- .../auth/_components/ApiSectionClient.tsx | 5 +- .../app/(products)/auth/_components/Hero.tsx | 3 +- .../auth/_components/HighlightsSection.tsx | 8 +- .../auth/_components/RLSSectionClient.tsx | 5 +- .../database/_components/ApiSectionClient.tsx | 5 +- .../(products)/database/_components/Hero.tsx | 3 +- .../_components/HighlightsSection.tsx | 8 +- .../edge-functions/_components/CTASection.tsx | 3 +- .../_components/ExamplesSection.tsx | 4 +- .../_components/GlobalPresenceSection.tsx | 6 +- .../edge-functions/_components/Hero.tsx | 3 +- .../_components/IntegratesSectionClient.tsx | 5 +- .../_components/LocalDXSection.tsx | 4 +- .../realtime/_components/ApiSectionClient.tsx | 5 +- .../(products)/realtime/_components/Hero.tsx | 3 +- .../storage/_components/ApiSectionClient.tsx | 5 +- .../_components/BucketTypesSection.tsx | 8 +- .../(products)/storage/_components/Hero.tsx | 3 +- .../_components/PermissionsSectionClient.tsx | 5 +- apps/www/app/partners/PartnersContent.tsx | 36 ++++-- .../StateOfStartups2026Content.tsx | 4 +- .../components/SectionCallout.tsx | 32 +++-- .../components/BuiltWithSupabase/index.tsx | 2 +- .../components/BuiltWithSupabaseSection.tsx | 4 +- apps/www/components/CTABanner/index.tsx | 4 +- apps/www/components/CTASection.tsx | 4 +- apps/www/components/Footer/index.tsx | 3 +- apps/www/components/Go/TweetsSection.tsx | 8 +- .../www/components/Nav/DevelopersDropdown.tsx | 36 ++++-- apps/www/components/Nav/MenuItem.tsx | 115 +++++++++--------- apps/www/components/Nav/MobileMenu.tsx | 85 ++++++------- apps/www/components/Nav/index.tsx | 11 +- .../Pricing/ComputePricingTable.tsx | 26 ++-- apps/www/components/Pricing/PricingAddons.tsx | 17 ++- .../Pricing/PricingComparisonTable.tsx | 75 ++++++------ .../Pricing/PricingComputeSection.tsx | 13 +- .../components/Pricing/PricingDiskSection.tsx | 5 +- apps/www/components/Pricing/PricingPlans.tsx | 13 +- .../components/Pricing/PricingTableRow.tsx | 25 ++-- .../Products/Functions/RealtimeLogs.tsx | 2 +- apps/www/components/Products/ProductCard.tsx | 27 ++-- .../components/Sections/GithubExamples.tsx | 2 +- .../www/components/Sections/ProductHeader.tsx | 4 +- .../components/Sections/ProductHeader2.tsx | 7 +- .../www/components/StartYourProjectButton.tsx | 4 +- .../Wrapped/Pages/ProductAnnouncements.tsx | 37 ++++-- apps/www/data/products/functions/page.tsx | 16 +-- apps/www/eslint-rules/no-cross-zone-link.cjs | 83 +++++++++++++ .../eslint-rules/no-cross-zone-link.test.ts | 39 ++++++ apps/www/eslint.config.cjs | 7 ++ apps/www/lib/cross-zone.test.ts | 53 ++++++++ apps/www/lib/cross-zone.ts | 30 +++++ apps/www/pages/assistant.tsx | 4 +- apps/www/pages/brand-assets.tsx | 5 +- apps/www/pages/state-of-startups-2025.tsx | 4 +- packages/common/telemetry.tsx | 16 ++- 82 files changed, 720 insertions(+), 364 deletions(-) create mode 100644 apps/www/eslint-rules/no-cross-zone-link.cjs create mode 100644 apps/www/eslint-rules/no-cross-zone-link.test.ts create mode 100644 apps/www/lib/cross-zone.test.ts create mode 100644 apps/www/lib/cross-zone.ts diff --git a/apps/www/_go/events/accenture-reinvention-2026/contest.tsx b/apps/www/_go/events/accenture-reinvention-2026/contest.tsx index a0c3b136a0a..9c2123e2381 100644 --- a/apps/www/_go/events/accenture-reinvention-2026/contest.tsx +++ b/apps/www/_go/events/accenture-reinvention-2026/contest.tsx @@ -71,13 +71,13 @@ const page: GoPageInput = {

No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/ai-engineer-europe-2026/contest-thank-you.tsx b/apps/www/_go/events/ai-engineer-europe-2026/contest-thank-you.tsx index 80b4e3df8be..6a2603d919e 100644 --- a/apps/www/_go/events/ai-engineer-europe-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/ai-engineer-europe-2026/contest-thank-you.tsx @@ -22,7 +22,7 @@ const page: GoPageInput = { children: (

No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/dash-2026/contest.tsx b/apps/www/_go/events/dash-2026/contest.tsx index aded5437805..d95756b3f2f 100644 --- a/apps/www/_go/events/dash-2026/contest.tsx +++ b/apps/www/_go/events/dash-2026/contest.tsx @@ -85,7 +85,7 @@ const page: GoPageInput = {

  • Complete these steps by the deadline in your post-event note
  • No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/mcp-dev-nyc-2026/contest-thank-you.tsx b/apps/www/_go/events/mcp-dev-nyc-2026/contest-thank-you.tsx index 3ae184c25b9..9c72fcc5a7e 100644 --- a/apps/www/_go/events/mcp-dev-nyc-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/mcp-dev-nyc-2026/contest-thank-you.tsx @@ -22,7 +22,7 @@ const page: GoPageInput = { children: (

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/pgconf-dev-2026/contest-thank-you.tsx b/apps/www/_go/events/pgconf-dev-2026/contest-thank-you.tsx index f5354497172..0feefeefe44 100644 --- a/apps/www/_go/events/pgconf-dev-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/pgconf-dev-2026/contest-thank-you.tsx @@ -22,7 +22,7 @@ const page: GoPageInput = { children: (

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx b/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx index c1d81ff7309..50fa7eeef07 100644 --- a/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/postgres-summit-2026/contest-thank-you.tsx @@ -22,7 +22,7 @@ const page: GoPageInput = { children: (

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/postgresconf-sjc-2026/contest-thank-you.tsx b/apps/www/_go/events/postgresconf-sjc-2026/contest-thank-you.tsx index 632e1b99976..a5f7b172bd2 100644 --- a/apps/www/_go/events/postgresconf-sjc-2026/contest-thank-you.tsx +++ b/apps/www/_go/events/postgresconf-sjc-2026/contest-thank-you.tsx @@ -23,7 +23,7 @@ const page: GoPageInput = { children: (

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/startup-grind-2026/contest.tsx b/apps/www/_go/events/startup-grind-2026/contest.tsx index 01c3afabc31..db8761b2eeb 100644 --- a/apps/www/_go/events/startup-grind-2026/contest.tsx +++ b/apps/www/_go/events/startup-grind-2026/contest.tsx @@ -45,7 +45,7 @@ const page: GoPageInput = {

  • Complete these steps by Monday, May 11, 2026 at 12:00 PM PST
  • No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/stripe-sessions-2026/contest.tsx b/apps/www/_go/events/stripe-sessions-2026/contest.tsx index 71cc333b550..66d91148c81 100644 --- a/apps/www/_go/events/stripe-sessions-2026/contest.tsx +++ b/apps/www/_go/events/stripe-sessions-2026/contest.tsx @@ -62,12 +62,12 @@ stripe projects env --sync`} Connect your Stripe account to Supabase and sync your payments, customers, and subscriptions into a live Postgres schema.

    - Install the Stripe Sync Engine → - +

    diff --git a/apps/www/_go/events/stripe-sessions-2026/party.tsx b/apps/www/_go/events/stripe-sessions-2026/party.tsx index 83238bc8992..0cbc564741a 100644 --- a/apps/www/_go/events/stripe-sessions-2026/party.tsx +++ b/apps/www/_go/events/stripe-sessions-2026/party.tsx @@ -34,7 +34,7 @@ const page: GoPageInput = { title: "Tonight's hosts", children: ( <> - @@ -43,7 +43,7 @@ const page: GoPageInput = {

    Open-source Postgres development platform

    - + Complete these steps by Monday, May 11, 2026 at 12:00 PM PST

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/events/sxsw-2026/contest.tsx b/apps/www/_go/events/sxsw-2026/contest.tsx index 4d18e0f0f29..b4fbb998be6 100644 --- a/apps/www/_go/events/sxsw-2026/contest.tsx +++ b/apps/www/_go/events/sxsw-2026/contest.tsx @@ -46,7 +46,7 @@ const page: GoPageInput = {

    No purchase necessary. Void where prohibited.{' '} diff --git a/apps/www/_go/thank-you/example-thank-you.tsx b/apps/www/_go/thank-you/example-thank-you.tsx index 80641077db9..87b12d5dfec 100644 --- a/apps/www/_go/thank-you/example-thank-you.tsx +++ b/apps/www/_go/thank-you/example-thank-you.tsx @@ -1,5 +1,4 @@ import type { GoPageInput } from 'marketing' -import Link from 'next/link' import { Button } from 'ui' const page: GoPageInput = { @@ -22,10 +21,10 @@ const page: GoPageInput = { children: (

    ), diff --git a/apps/www/_go/webinar/agency-webinar-mar2026-thankyou.tsx b/apps/www/_go/webinar/agency-webinar-mar2026-thankyou.tsx index d5c8b91727c..6ce6a357d5c 100644 --- a/apps/www/_go/webinar/agency-webinar-mar2026-thankyou.tsx +++ b/apps/www/_go/webinar/agency-webinar-mar2026-thankyou.tsx @@ -1,5 +1,4 @@ import type { GoPageInput } from 'marketing' -import Link from 'next/link' import { Button } from 'ui' const page: GoPageInput = { @@ -22,10 +21,10 @@ const page: GoPageInput = { children: (
    ), diff --git a/apps/www/_go/webinar/bolt-webinar-thank-you.tsx b/apps/www/_go/webinar/bolt-webinar-thank-you.tsx index 88a4369adbc..065fe977179 100644 --- a/apps/www/_go/webinar/bolt-webinar-thank-you.tsx +++ b/apps/www/_go/webinar/bolt-webinar-thank-you.tsx @@ -1,5 +1,4 @@ import type { GoPageInput } from 'marketing' -import Link from 'next/link' import { Button } from 'ui' const page: GoPageInput = { @@ -21,10 +20,10 @@ const page: GoPageInput = { children: (
    ), diff --git a/apps/www/_go/webinar/figma-webinar-may2026-thankyou.tsx b/apps/www/_go/webinar/figma-webinar-may2026-thankyou.tsx index 310700cf987..d2110e29c7d 100644 --- a/apps/www/_go/webinar/figma-webinar-may2026-thankyou.tsx +++ b/apps/www/_go/webinar/figma-webinar-may2026-thankyou.tsx @@ -1,5 +1,4 @@ import type { GoPageInput } from 'marketing' -import Link from 'next/link' import { Button } from 'ui' const page: GoPageInput = { @@ -22,10 +21,10 @@ const page: GoPageInput = { children: (
    ), diff --git a/apps/www/app/(home)/_components/BuiltWithSupabaseSection.tsx b/apps/www/app/(home)/_components/BuiltWithSupabaseSection.tsx index 17cb65d331d..80a86d4ee06 100644 --- a/apps/www/app/(home)/_components/BuiltWithSupabaseSection.tsx +++ b/apps/www/app/(home)/_components/BuiltWithSupabaseSection.tsx @@ -108,12 +108,12 @@ export function BuiltWithSupabaseSection() { Kickstart your next project
    with production ready templates
    - View all examples - +
    diff --git a/apps/www/app/(home)/_components/CTASection.tsx b/apps/www/app/(home)/_components/CTASection.tsx index 1423c100f41..f6850df860c 100644 --- a/apps/www/app/(home)/_components/CTASection.tsx +++ b/apps/www/app/(home)/_components/CTASection.tsx @@ -20,7 +20,7 @@ export function CTASection() {
    diff --git a/apps/www/app/(home)/_components/Hero.tsx b/apps/www/app/(home)/_components/Hero.tsx index e10b92618c5..972fb74c727 100644 --- a/apps/www/app/(home)/_components/Hero.tsx +++ b/apps/www/app/(home)/_components/Hero.tsx @@ -27,7 +27,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/auth/_components/Hero.tsx b/apps/www/app/(products)/auth/_components/Hero.tsx index ccd37f2cb40..f450bb73fca 100644 --- a/apps/www/app/(products)/auth/_components/Hero.tsx +++ b/apps/www/app/(products)/auth/_components/Hero.tsx @@ -1,5 +1,4 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' -import Link from 'next/link' import { Button } from 'ui' import { StartYourProjectButton } from '@/components/StartYourProjectButton' @@ -21,7 +20,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/auth/_components/HighlightsSection.tsx b/apps/www/app/(products)/auth/_components/HighlightsSection.tsx index 74e46741de3..bf7041acbd7 100644 --- a/apps/www/app/(products)/auth/_components/HighlightsSection.tsx +++ b/apps/www/app/(products)/auth/_components/HighlightsSection.tsx @@ -3,6 +3,8 @@ import { Check } from 'lucide-react' import Link from 'next/link' import { Button } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + const HIGHLIGHTS = [ { title: 'Multi-Factor Authentication', @@ -65,7 +67,11 @@ export function HighlightsSection() {
    diff --git a/apps/www/app/(products)/auth/_components/RLSSectionClient.tsx b/apps/www/app/(products)/auth/_components/RLSSectionClient.tsx index 453dafe44e0..af4d867dce3 100644 --- a/apps/www/app/(products)/auth/_components/RLSSectionClient.tsx +++ b/apps/www/app/(products)/auth/_components/RLSSectionClient.tsx @@ -2,7 +2,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -178,7 +177,7 @@ export function RLSSectionClient({ examples }: { examples: RLSExample[] }) { /> - @@ -200,7 +199,7 @@ export function RLSSectionClient({ examples }: { examples: RLSExample[] }) { strokeLinejoin="round" /> - + diff --git a/apps/www/app/(products)/database/_components/ApiSectionClient.tsx b/apps/www/app/(products)/database/_components/ApiSectionClient.tsx index 342014446ba..10ffae261a4 100644 --- a/apps/www/app/(products)/database/_components/ApiSectionClient.tsx +++ b/apps/www/app/(products)/database/_components/ApiSectionClient.tsx @@ -3,7 +3,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' import { ArrowDownUp, ChevronDown, Filter, Plus, Search } from 'lucide-react' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -188,7 +187,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { /> - @@ -209,7 +208,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { strokeLinejoin="round" /> - + diff --git a/apps/www/app/(products)/database/_components/Hero.tsx b/apps/www/app/(products)/database/_components/Hero.tsx index 8d6a35bc1e9..e9e8a277e43 100644 --- a/apps/www/app/(products)/database/_components/Hero.tsx +++ b/apps/www/app/(products)/database/_components/Hero.tsx @@ -1,5 +1,4 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' -import Link from 'next/link' import { Button } from 'ui' import { StartYourProjectButton } from '@/components/StartYourProjectButton' @@ -21,7 +20,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/database/_components/HighlightsSection.tsx b/apps/www/app/(products)/database/_components/HighlightsSection.tsx index 71f2b94c573..cbe7ef15c8e 100644 --- a/apps/www/app/(products)/database/_components/HighlightsSection.tsx +++ b/apps/www/app/(products)/database/_components/HighlightsSection.tsx @@ -4,6 +4,8 @@ import Image from 'next/image' import Link from 'next/link' import { Badge, Button } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + type Highlight = { title: string badge?: string @@ -75,7 +77,11 @@ export function HighlightsSection() {
    diff --git a/apps/www/app/(products)/edge-functions/_components/CTASection.tsx b/apps/www/app/(products)/edge-functions/_components/CTASection.tsx index 658eb674196..a13b23887ec 100644 --- a/apps/www/app/(products)/edge-functions/_components/CTASection.tsx +++ b/apps/www/app/(products)/edge-functions/_components/CTASection.tsx @@ -2,7 +2,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' -import Link from 'next/link' import { useEffect, useRef, useState } from 'react' import { Button } from 'ui' @@ -88,7 +87,7 @@ export function CTASection() {
    diff --git a/apps/www/app/(products)/edge-functions/_components/ExamplesSection.tsx b/apps/www/app/(products)/edge-functions/_components/ExamplesSection.tsx index c88ad81fbb9..59c58503e42 100644 --- a/apps/www/app/(products)/edge-functions/_components/ExamplesSection.tsx +++ b/apps/www/app/(products)/edge-functions/_components/ExamplesSection.tsx @@ -25,12 +25,12 @@ export function ExamplesSection() { What you can build
    with Edge Functions - View all examples - + {/* Cards */} diff --git a/apps/www/app/(products)/edge-functions/_components/GlobalPresenceSection.tsx b/apps/www/app/(products)/edge-functions/_components/GlobalPresenceSection.tsx index 437599640f9..223bd75bf6d 100644 --- a/apps/www/app/(products)/edge-functions/_components/GlobalPresenceSection.tsx +++ b/apps/www/app/(products)/edge-functions/_components/GlobalPresenceSection.tsx @@ -1,5 +1,3 @@ -import Link from 'next/link' - import SectionContainerWithCn from '../../../../components/Layouts/SectionContainerWithCn' const features = [ @@ -8,12 +6,12 @@ const features = [ paragraph: ( <> Edge functions run globally or can be{' '} - pinned to your database's proximity - + ), }, diff --git a/apps/www/app/(products)/edge-functions/_components/Hero.tsx b/apps/www/app/(products)/edge-functions/_components/Hero.tsx index 57665fcfc2e..d1aeadd65ef 100644 --- a/apps/www/app/(products)/edge-functions/_components/Hero.tsx +++ b/apps/www/app/(products)/edge-functions/_components/Hero.tsx @@ -1,5 +1,4 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' -import Link from 'next/link' import { Button } from 'ui' import { Highlights } from './Highlights' @@ -22,7 +21,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx b/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx index 0587801d4a3..56dddfac327 100644 --- a/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx +++ b/apps/www/app/(products)/edge-functions/_components/IntegratesSectionClient.tsx @@ -3,7 +3,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' import { Database, HardDrive, Shield, Webhook, Zap } from 'lucide-react' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -197,7 +196,7 @@ export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) {

    {active.paragraph}

    - @@ -219,7 +218,7 @@ export function IntegratesSectionClient({ useCases }: { useCases: UseCase[] }) { strokeLinejoin="round" /> - + diff --git a/apps/www/app/(products)/edge-functions/_components/LocalDXSection.tsx b/apps/www/app/(products)/edge-functions/_components/LocalDXSection.tsx index 86588996ef0..5937c75708a 100644 --- a/apps/www/app/(products)/edge-functions/_components/LocalDXSection.tsx +++ b/apps/www/app/(products)/edge-functions/_components/LocalDXSection.tsx @@ -83,14 +83,14 @@ function CICard() {

    Continuous Integration

    Use the{' '} - Supabase CLI with GitHub actions - {' '} + {' '} to preview and deploy your functions along with the rest of your application

    diff --git a/apps/www/app/(products)/realtime/_components/ApiSectionClient.tsx b/apps/www/app/(products)/realtime/_components/ApiSectionClient.tsx index cfcd68ec584..86a16180b91 100644 --- a/apps/www/app/(products)/realtime/_components/ApiSectionClient.tsx +++ b/apps/www/app/(products)/realtime/_components/ApiSectionClient.tsx @@ -3,7 +3,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' import { Database, Radio, Users } from 'lucide-react' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -167,7 +166,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { /> - @@ -188,7 +187,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { strokeLinejoin="round" /> - + diff --git a/apps/www/app/(products)/realtime/_components/Hero.tsx b/apps/www/app/(products)/realtime/_components/Hero.tsx index 7dd04c79cd3..47b7f69eff5 100644 --- a/apps/www/app/(products)/realtime/_components/Hero.tsx +++ b/apps/www/app/(products)/realtime/_components/Hero.tsx @@ -1,5 +1,4 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' -import Link from 'next/link' import { Button } from 'ui' import { StartYourProjectButton } from '@/components/StartYourProjectButton' @@ -21,7 +20,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/storage/_components/ApiSectionClient.tsx b/apps/www/app/(products)/storage/_components/ApiSectionClient.tsx index a0abc97d11b..3809e5106b8 100644 --- a/apps/www/app/(products)/storage/_components/ApiSectionClient.tsx +++ b/apps/www/app/(products)/storage/_components/ApiSectionClient.tsx @@ -3,7 +3,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' import { ArrowRightLeft, ChevronDown, Download, List, Upload } from 'lucide-react' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -189,7 +188,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { /> - @@ -210,7 +209,7 @@ export function ApiSectionClient({ examples }: { examples: ApiExample[] }) { strokeLinejoin="round" /> - + diff --git a/apps/www/app/(products)/storage/_components/BucketTypesSection.tsx b/apps/www/app/(products)/storage/_components/BucketTypesSection.tsx index f81c8b39ab3..35dacee0d39 100644 --- a/apps/www/app/(products)/storage/_components/BucketTypesSection.tsx +++ b/apps/www/app/(products)/storage/_components/BucketTypesSection.tsx @@ -3,6 +3,8 @@ import { Check } from 'lucide-react' import Link from 'next/link' import { Button } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + const BUCKET_TYPES = [ { title: 'Files buckets', @@ -65,7 +67,11 @@ export function BucketTypesSection() {
    diff --git a/apps/www/app/(products)/storage/_components/Hero.tsx b/apps/www/app/(products)/storage/_components/Hero.tsx index 4e6d340841e..7f6cf68618f 100644 --- a/apps/www/app/(products)/storage/_components/Hero.tsx +++ b/apps/www/app/(products)/storage/_components/Hero.tsx @@ -1,5 +1,4 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' -import Link from 'next/link' import { Button } from 'ui' import { StartYourProjectButton } from '@/components/StartYourProjectButton' @@ -21,7 +20,7 @@ export function Hero() {
    diff --git a/apps/www/app/(products)/storage/_components/PermissionsSectionClient.tsx b/apps/www/app/(products)/storage/_components/PermissionsSectionClient.tsx index d463a0b887b..57af15fd110 100644 --- a/apps/www/app/(products)/storage/_components/PermissionsSectionClient.tsx +++ b/apps/www/app/(products)/storage/_components/PermissionsSectionClient.tsx @@ -2,7 +2,6 @@ import SectionContainerWithCn from '~/components/Layouts/SectionContainerWithCn' import { AnimatePresence, motion, useInView } from 'framer-motion' -import Link from 'next/link' import { useCallback, useEffect, useRef, useState } from 'react' import { cn } from 'ui' @@ -167,7 +166,7 @@ export function PermissionsSectionClient({ examples }: { examples: PermissionExa /> - @@ -188,7 +187,7 @@ export function PermissionsSectionClient({ examples }: { examples: PermissionExa strokeLinejoin="round" /> - + diff --git a/apps/www/app/partners/PartnersContent.tsx b/apps/www/app/partners/PartnersContent.tsx index 8744b8fe019..49429aae35f 100644 --- a/apps/www/app/partners/PartnersContent.tsx +++ b/apps/www/app/partners/PartnersContent.tsx @@ -14,6 +14,7 @@ import Panel from '@/components/Panel' import BecomeAPartner from '@/components/Partners/BecomeAPartner' import ProductHeaderCentered from '@/components/Sections/ProductHeaderCentered' import pageData, { type PARTNER_TIER } from '@/data/partners' +import { isCrossZoneHref } from '@/lib/cross-zone' type FeaturedPartner = { slug: string; title: string; logo: string } @@ -264,14 +265,8 @@ export default function PartnersContent({ featuredPartners }: Props) {
    - {pageData.integrationOptions.options.map((option) => ( - + {pageData.integrationOptions.options.map((option) => { + const panelContent = ( - - ))} + ) + return isCrossZoneHref(option.href) ? ( + + {panelContent} + + ) : ( + + {panelContent} + + ) + })}
    diff --git a/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx b/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx index d2ce5b7adbd..892f97c71b3 100644 --- a/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx +++ b/apps/www/app/state-of-startups/StateOfStartups2026Content.tsx @@ -301,7 +301,7 @@ const CTABanner = forwardRef((props, ref) => {
    ) diff --git a/apps/www/components/BuiltWithSupabase/index.tsx b/apps/www/components/BuiltWithSupabase/index.tsx index 768e7d484c3..ac5c3e93eb1 100644 --- a/apps/www/components/BuiltWithSupabase/index.tsx +++ b/apps/www/components/BuiltWithSupabase/index.tsx @@ -17,7 +17,7 @@ const BuiltWithSupabase = () => {

    diff --git a/apps/www/components/CTABanner/index.tsx b/apps/www/components/CTABanner/index.tsx index 5434aa39f1b..c9e205b8f81 100644 --- a/apps/www/components/CTABanner/index.tsx +++ b/apps/www/components/CTABanner/index.tsx @@ -31,7 +31,7 @@ const CTABanner = ({ darkerBg, className }: Props) => {
    ))}
    diff --git a/apps/www/components/Nav/DevelopersDropdown.tsx b/apps/www/components/Nav/DevelopersDropdown.tsx index 2e252c85cbe..3a012d3c386 100644 --- a/apps/www/components/Nav/DevelopersDropdown.tsx +++ b/apps/www/components/Nav/DevelopersDropdown.tsx @@ -3,6 +3,8 @@ import { data as DevelopersData } from 'data/Developers' import { ChevronRight } from 'lucide-react' import Link from 'next/link' +import { isCrossZoneHref } from '@/lib/cross-zone' + type LinkProps = { text: string description?: string @@ -23,12 +25,9 @@ export const DevelopersDropdown = () => { {column.label} ))} diff --git a/apps/www/components/Nav/MenuItem.tsx b/apps/www/components/Nav/MenuItem.tsx index 0532f7f60d8..a1dacd8acd0 100644 --- a/apps/www/components/Nav/MenuItem.tsx +++ b/apps/www/components/Nav/MenuItem.tsx @@ -3,6 +3,8 @@ import Link from 'next/link' import React from 'react' import { Badge, cn } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + const MenuItem = React.forwardRef< React.ElementRef<'a'>, React.ComponentPropsWithoutRef<'a'> & { @@ -31,64 +33,65 @@ const MenuItem = React.forwardRef< }, ref ) => { - return ( - + {icon && ( +
    + +
    )} - {...props} - > - {children ?? ( - <> - {icon && ( -
    - -
    - )} -
    -
    -

    - {title} -

    - {badge && {badge.label}} - {hasChevron && ( - - )} -
    - {description && ( -

    - {description} -

    +
    +
    +

    - - )} + > + {title} +

    + {badge && {badge.label}} + {hasChevron && ( + + )} +
    + {description && ( +

    + {description} +

    + )} +
    + + ) + const linkClassName = cn( + 'group/menu-item flex items-center text-foreground-light text-sm hover:text-foreground select-none gap-3 rounded-md p-2 leading-none no-underline focus-ring focus-visible:text-foreground', + description && 'items-center', + className + ) + return isCrossZoneHref(href) ? ( + + {content} + + ) : ( + + {content} ) } diff --git a/apps/www/components/Nav/MobileMenu.tsx b/apps/www/components/Nav/MobileMenu.tsx index 2c3cc44eae4..1898ac58d1b 100644 --- a/apps/www/components/Nav/MobileMenu.tsx +++ b/apps/www/components/Nav/MobileMenu.tsx @@ -15,6 +15,7 @@ import SupabaseWordmark from './SupabaseWordmark' import staticContent from '@/.generated/staticContent/_index.json' import ProductModulesData from '@/data/ProductModules' import { DEFAULT_EASE } from '@/lib/animations' +import { isCrossZoneHref } from '@/lib/cross-zone' import { useSendTelemetryEvent } from '@/lib/telemetry' interface Props { @@ -195,6 +196,14 @@ export const MobileMenu = ({ open, setOpen, menu }: Props) => { {menuItem.title} + ) : isCrossZoneHref(menuItem.url ?? '/') ? ( + setOpen(false)} + > + {menuItem.title} + ) : ( { {!isUserLoading && ( <> {isLoggedIn ? ( - - - + ) : ( <> - - sendTelemetryEvent({ - action: 'sign_in_button_clicked', - properties: { buttonLocation: 'Mobile Nav' }, - }) - } - > - - - - sendTelemetryEvent({ - action: 'start_project_button_clicked', - properties: { buttonLocation: 'Mobile Nav' }, - }) - } - > - - + + )} diff --git a/apps/www/components/Nav/index.tsx b/apps/www/components/Nav/index.tsx index 5c72689f343..8b08d18c78d 100644 --- a/apps/www/components/Nav/index.tsx +++ b/apps/www/components/Nav/index.tsx @@ -5,7 +5,6 @@ import ScrollProgress from 'components/ScrollProgress' import { getMenu } from 'data/nav' import { DevToolbarTrigger } from 'dev-tools' import { useSendTelemetryEvent } from 'lib/telemetry' -import Link from 'next/link' import { usePathname } from 'next/navigation' import React, { useState } from 'react' import { useWindowSize } from 'react-use' @@ -217,14 +216,14 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => { {isLoggedIn ? ( <> ) : ( <> )} diff --git a/apps/www/components/Pricing/ComputePricingTable.tsx b/apps/www/components/Pricing/ComputePricingTable.tsx index d99d608734d..342ab640be2 100644 --- a/apps/www/components/Pricing/ComputePricingTable.tsx +++ b/apps/www/components/Pricing/ComputePricingTable.tsx @@ -3,6 +3,8 @@ import Link from 'next/link' import { Fragment, useMemo } from 'react' import { cn } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + const ComputePricingTable = () => { const columnNames = useMemo( () => @@ -49,13 +51,23 @@ const ComputePricingTable = () => { translate={column.key === 'pricing' ? 'no' : undefined} > {column.url ? ( - - {column.value} - + isCrossZoneHref(column.url) ? ( + + {column.value} + + ) : ( + + {column.value} + + ) ) : ( column.value )} diff --git a/apps/www/components/Pricing/PricingAddons.tsx b/apps/www/components/Pricing/PricingAddons.tsx index e95472af019..01e5b2f28bb 100644 --- a/apps/www/components/Pricing/PricingAddons.tsx +++ b/apps/www/components/Pricing/PricingAddons.tsx @@ -8,6 +8,7 @@ import { FC } from 'react' import { Button, cn } from 'ui' import CostControlAnimation from './CostControlAnimation' +import { isCrossZoneHref } from '@/lib/cross-zone' const addons = [ { @@ -97,9 +98,15 @@ const AddonCard = ({ addon }: any) => {
    @@ -152,6 +159,10 @@ const AddonCard = ({ addon }: any) => {
    + ) : isCrossZoneHref(addon.ctaLink) ? ( + + + ) : ( diff --git a/apps/www/components/Pricing/PricingComparisonTable.tsx b/apps/www/components/Pricing/PricingComparisonTable.tsx index b8b42e4d436..ae0005aefef 100644 --- a/apps/www/components/Pricing/PricingComparisonTable.tsx +++ b/apps/www/components/Pricing/PricingComparisonTable.tsx @@ -21,6 +21,7 @@ import { } from 'ui' import UpgradePlan from './UpgradePlan' +import { isCrossZoneHref } from '@/lib/cross-zone' const MobileHeader = ({ description, @@ -46,6 +47,17 @@ const MobileHeader = ({ const selectedPlan = plans.find((p) => p.name === plan)! const isUpgradablePlan = selectedPlan.name === 'Pro' || selectedPlan.name === 'Team' + const trackPlanCtaClick = () => + sendTelemetryEvent({ + action: 'www_pricing_plan_cta_clicked', + properties: { + plan, + showUpgradeText: false, + section: 'comparison_table', + tableMode: 'mobile', + }, + ...(orgSlug && { groups: { organization: orgSlug } }), + }) return (
    @@ -84,23 +96,15 @@ const MobileHeader = ({ /> ) : ( )}
    @@ -407,6 +411,17 @@ const PricingComparisonTable = ({ {plans.map((plan) => { const isUpgradablePlan = plan.name === 'Pro' || plan.name === 'Team' + const trackPlanCtaClick = () => + sendTelemetryEvent({ + action: 'www_pricing_plan_cta_clicked', + properties: { + plan: plan.name, + showUpgradeText: false, + section: 'comparison_table', + tableMode: 'desktop', + }, + ...(orgSlug && { groups: { organization: orgSlug } }), + }) return ( - - sendTelemetryEvent({ - action: 'www_pricing_plan_cta_clicked', - properties: { - plan: plan.name, - showUpgradeText: false, - section: 'comparison_table', - tableMode: 'desktop', - }, - ...(orgSlug && { groups: { organization: orgSlug } }), - }) - } - > - {plan.cta} - + {isCrossZoneHref(plan.href) ? ( + + {plan.cta} + + ) : ( + + {plan.cta} + + )} )} diff --git a/apps/www/components/Pricing/PricingComputeSection.tsx b/apps/www/components/Pricing/PricingComputeSection.tsx index 3dd252eff7e..93532b63bb4 100644 --- a/apps/www/components/Pricing/PricingComputeSection.tsx +++ b/apps/www/components/Pricing/PricingComputeSection.tsx @@ -1,7 +1,6 @@ 'use client' import { ChevronDownIcon } from '@heroicons/react/outline' -import Link from 'next/link' import React, { useEffect, useRef, useState } from 'react' import { useWindowSize } from 'react-use' import { plans as allPlans } from 'shared-data/plans' @@ -129,9 +128,9 @@ const PricingComputeSection = () => {
    {

    Read more on{' '} - usage-based billing for compute - {' '} + {' '} or{' '} - Compute Add-ons - + .

    diff --git a/apps/www/components/Pricing/PricingDiskSection.tsx b/apps/www/components/Pricing/PricingDiskSection.tsx index 5c7873f03a1..341681f6dc6 100644 --- a/apps/www/components/Pricing/PricingDiskSection.tsx +++ b/apps/www/components/Pricing/PricingDiskSection.tsx @@ -1,5 +1,4 @@ import { ArrowUpRight } from 'lucide-react' -import Link from 'next/link' import { Button } from 'ui' import Panel from '../Panel' @@ -103,9 +102,9 @@ const PricingDiskSection = () => (
    diff --git a/apps/www/components/Pricing/PricingPlans.tsx b/apps/www/components/Pricing/PricingPlans.tsx index a7380bb0742..4b9f0f923f2 100644 --- a/apps/www/components/Pricing/PricingPlans.tsx +++ b/apps/www/components/Pricing/PricingPlans.tsx @@ -8,6 +8,7 @@ import { plans } from 'shared-data/plans' import { Button, cn } from 'ui' import UpgradePlan from './UpgradePlan' +import { isCrossZoneHref } from '@/lib/cross-zone' interface PricingPlansProps { organizations?: Organization[] @@ -86,9 +87,15 @@ const PricingPlans = ({ organizations, hasExistingOrganizations }: PricingPlansP variant={plan.name === 'Enterprise' ? 'default' : 'primary'} asChild > - - {plan.cta} - + {isCrossZoneHref(plan.href) ? ( + + {plan.cta} + + ) : ( + + {plan.cta} + + )} )} diff --git a/apps/www/components/Pricing/PricingTableRow.tsx b/apps/www/components/Pricing/PricingTableRow.tsx index 87f7890ead1..b34abd998dc 100644 --- a/apps/www/components/Pricing/PricingTableRow.tsx +++ b/apps/www/components/Pricing/PricingTableRow.tsx @@ -1,5 +1,4 @@ import ProductIcon from 'components/ProductIcon' -import Link from 'next/link' import { Fragment, ReactNode } from 'react' import { FeatureKey } from 'shared-data/pricing' import { InfoTooltip } from 'ui-patterns/info-tooltip' @@ -104,9 +103,9 @@ export const pricingTooltips: PricingTooltips = { AWS PrivateLink enables private connectivity between your AWS VPC and Supabase, keeping traffic within the AWS network. Read more in our{' '} - + docs - + . ), @@ -117,9 +116,9 @@ export const pricingTooltips: PricingTooltips = { Supabase provides granular access controls to manage permissions across your organizations and projects. Read more in our{' '} - + docs - + . ), @@ -136,9 +135,9 @@ export const pricingTooltips: PricingTooltips = { automatically captured for all authentication events and help you monitor user authentication activities, detect suspicious behavior, and maintain compliance with security requirements. Read more in our{' '} - + docs - + . ), @@ -150,9 +149,9 @@ export const pricingTooltips: PricingTooltips = { Any Platform API/Dashboard actions performed by organization members are logged automatically for auditing and security purposes. Includes actions such as creating a new project, inviting members or changing project settings. Read more in our{' '} - + docs - + . ), @@ -164,9 +163,9 @@ export const pricingTooltips: PricingTooltips = { You are charged for the total volume of log data that Supabase ingests across all your project's services (Postgres, API gateway, Auth, Storage, Realtime, Edge Functions, and others) during the billing cycle. Read more in our{' '} - + docs - + . ), @@ -179,9 +178,9 @@ export const pricingTooltips: PricingTooltips = { allowance that scales with how much log data you ingest. The allowance covers the volume of log data scanned when you read logs through the Studio UI, the Management API, the CLI, or any other interface. Read more in our{' '} - + docs - + . ), diff --git a/apps/www/components/Products/Functions/RealtimeLogs.tsx b/apps/www/components/Products/Functions/RealtimeLogs.tsx index 551dabd483f..66500433799 100644 --- a/apps/www/components/Products/Functions/RealtimeLogs.tsx +++ b/apps/www/components/Products/Functions/RealtimeLogs.tsx @@ -4,7 +4,7 @@ import { FC, useEffect, useState } from 'react' import { useInterval } from 'react-use' import { Badge, cn } from 'ui' -interface Props { +export interface Props { isActive?: boolean isInView?: boolean className?: string diff --git a/apps/www/components/Products/ProductCard.tsx b/apps/www/components/Products/ProductCard.tsx index a7481f73b13..7ee8e45966f 100644 --- a/apps/www/components/Products/ProductCard.tsx +++ b/apps/www/components/Products/ProductCard.tsx @@ -6,6 +6,8 @@ import Link from 'next/link' import React, { useEffect, useState } from 'react' import { cn } from 'ui' +import { isCrossZoneHref } from '@/lib/cross-zone' + const ProductCard = ({ className, title, @@ -36,15 +38,12 @@ const ProductCard = ({ setHasShimmer(detectBrowser() !== 'Safari') }, []) - return ( - + const cardClassName = cn( + 'group relative w-full sm:h-[400px] flex flex-col gap-5 lg:flex-row focus-ring rounded-xl', + className + ) + const cardContent = ( + <> {image && } + + ) + + return isCrossZoneHref(url) ? ( + + {cardContent} + + ) : ( + + {cardContent} ) } diff --git a/apps/www/components/Sections/GithubExamples.tsx b/apps/www/components/Sections/GithubExamples.tsx index 58eaed6dabb..2dc11554f17 100644 --- a/apps/www/components/Sections/GithubExamples.tsx +++ b/apps/www/components/Sections/GithubExamples.tsx @@ -27,7 +27,7 @@ function GithubExamples() {

    {props.documentation_url && ( ) })} diff --git a/apps/www/components/StartYourProjectButton.tsx b/apps/www/components/StartYourProjectButton.tsx index 4dc263ffd06..ad24b52f53f 100644 --- a/apps/www/components/StartYourProjectButton.tsx +++ b/apps/www/components/StartYourProjectButton.tsx @@ -26,9 +26,9 @@ export function StartYourProjectButton({ return ( ) } diff --git a/apps/www/components/Wrapped/Pages/ProductAnnouncements.tsx b/apps/www/components/Wrapped/Pages/ProductAnnouncements.tsx index 81ba3b4e0f8..e5f4907dde1 100644 --- a/apps/www/components/Wrapped/Pages/ProductAnnouncements.tsx +++ b/apps/www/components/Wrapped/Pages/ProductAnnouncements.tsx @@ -2,6 +2,7 @@ import Link from 'next/link' import { cn } from 'ui' import { AnimatedGridBackground } from '../AnimatedGridBackground' +import { isCrossZoneHref } from '@/lib/cross-zone' type Announcement = { title: string @@ -356,17 +357,31 @@ function MonthSection({ month }: { month: Month }) {
      {month.announcements.map((announcement) => (
    • - - - → - - {announcement.title} - + {isCrossZoneHref(announcement.url) ? ( + + + → + + {announcement.title} + + ) : ( + + + → + + {announcement.title} + + )}
    • ))}
    diff --git a/apps/www/data/products/functions/page.tsx b/apps/www/data/products/functions/page.tsx index b01d2e4548b..73de99861c1 100644 --- a/apps/www/data/products/functions/page.tsx +++ b/apps/www/data/products/functions/page.tsx @@ -236,12 +236,12 @@ export default (isMobile?: boolean) => ({ paragraph: ( <> Use the{' '} - Supabase CLI with GitHub actions - {' '} + {' '} to preview and deploy your functions along with the rest of your application ), @@ -264,12 +264,12 @@ export default (isMobile?: boolean) => ({ paragraph: ( <> Edge functions run globally or can be{' '} - pinned to your database's proximity - + ), }, @@ -336,12 +336,12 @@ export default (isMobile?: boolean) => ({ paragraph: ( <> Edge Functions are designed to work seamlessly with{' '} - Supabase Auth - + ), panel: , @@ -351,12 +351,12 @@ export default (isMobile?: boolean) => ({ paragraph: ( <> Edge Functions are designed to work seamlessly with{' '} - Supabase Storage - + ), panel: , diff --git a/apps/www/eslint-rules/no-cross-zone-link.cjs b/apps/www/eslint-rules/no-cross-zone-link.cjs new file mode 100644 index 00000000000..36d8e877c0a --- /dev/null +++ b/apps/www/eslint-rules/no-cross-zone-link.cjs @@ -0,0 +1,83 @@ +const { readFileSync } = require('node:fs') +const { join } = require('node:path') +const vm = require('node:vm') + +const SUPABASE_ORIGIN = 'https://supabase.com' + +// Evaluated as production: the /docs rewrite only exists there, and /docs links must be flagged in every environment. +function loadProductionRewrites() { + const rewritesModule = { exports: [] } + const source = readFileSync(join(__dirname, '../lib/rewrites.js'), 'utf8') + vm.runInNewContext(source, { + module: rewritesModule, + process: { env: { NEXT_PUBLIC_VERCEL_ENV: 'production' } }, + }) + return rewritesModule.exports +} + +const crossZonePrefixes = loadProductionRewrites() + .filter(({ destination }) => !destination.startsWith('/')) + .map(({ source }) => source.replace(/\/(:path\*)?$/, '')) + +function parseHref(href) { + try { + return new URL(href, SUPABASE_ORIGIN) + } catch { + return null + } +} + +function isCrossZonePath(href) { + const url = parseHref(href) + if (url?.origin !== SUPABASE_ORIGIN) return false + + return crossZonePrefixes.some( + (prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`) + ) +} + +function staticHrefPrefix(value) { + if (!value) return null + if (value.type === 'Literal') return typeof value.value === 'string' ? value.value : null + if (value.type !== 'JSXExpressionContainer') return null + + const { expression } = value + if (expression.type === 'Literal' && typeof expression.value === 'string') return expression.value + if (expression.type === 'TemplateLiteral') return expression.quasis[0].value.cooked + return null +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + messages: { + crossZone: + "'{{href}}' is served by another zone. Use a plain : Next.js can't prefetch or client-navigate across zones, so 404s its prefetch.", + }, + schema: [], + }, + create(context) { + const linkNames = new Set() + + return { + ImportDeclaration(node) { + if (node.source.value !== 'next/link') return + for (const specifier of node.specifiers) { + if (specifier.type === 'ImportDefaultSpecifier') linkNames.add(specifier.local.name) + } + }, + JSXOpeningElement(node) { + if (node.name.type !== 'JSXIdentifier' || !linkNames.has(node.name.name)) return + + const hrefAttribute = node.attributes.find( + (attribute) => attribute.type === 'JSXAttribute' && attribute.name.name === 'href' + ) + const href = staticHrefPrefix(hrefAttribute?.value) + if (!href || !isCrossZonePath(href)) return + + context.report({ node: hrefAttribute, messageId: 'crossZone', data: { href } }) + }, + } + }, +} diff --git a/apps/www/eslint-rules/no-cross-zone-link.test.ts b/apps/www/eslint-rules/no-cross-zone-link.test.ts new file mode 100644 index 00000000000..7b6e70c82cb --- /dev/null +++ b/apps/www/eslint-rules/no-cross-zone-link.test.ts @@ -0,0 +1,39 @@ +import { RuleTester } from 'eslint' +import { describe, it } from 'vitest' + +import noCrossZoneLink from './no-cross-zone-link.cjs' + +RuleTester.describe = describe +RuleTester.it = it + +const ruleTester = new RuleTester({ + languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } }, +}) + +ruleTester.run('no-cross-zone-link', noCrossZoneLink, { + valid: [ + 'import Link from \'next/link\'; ', + 'import Link from \'next/link\'; ', + "import Link from 'next/link'; ", + '', + 'import Link from \'somewhere-else\'; ', + ], + invalid: [ + { + code: 'import Link from \'next/link\'; ', + errors: [{ messageId: 'crossZone' }], + }, + { + code: 'import Link from \'next/link\'; ', + errors: [{ messageId: 'crossZone' }], + }, + { + code: "import Link from 'next/link'; ", + errors: [{ messageId: 'crossZone' }], + }, + { + code: 'import NextLink from \'next/link\'; ', + errors: [{ messageId: 'crossZone' }], + }, + ], +}) diff --git a/apps/www/eslint.config.cjs b/apps/www/eslint.config.cjs index 4f6ba19eb29..37ec0adc370 100644 --- a/apps/www/eslint.config.cjs +++ b/apps/www/eslint.config.cjs @@ -1,6 +1,8 @@ const { defineConfig } = require('eslint/config') const supabaseConfig = require('eslint-config-supabase/next') +const noCrossZoneLink = require('./eslint-rules/no-cross-zone-link.cjs') + module.exports = defineConfig([ supabaseConfig, { @@ -19,4 +21,9 @@ module.exports = defineConfig([ 'react/no-children-prop': 'warn', }, }, + { + files: ['**/*.{jsx,tsx}'], + plugins: { www: { rules: { 'no-cross-zone-link': noCrossZoneLink } } }, + rules: { 'www/no-cross-zone-link': 'error' }, + }, ]) diff --git a/apps/www/lib/cross-zone.test.ts b/apps/www/lib/cross-zone.test.ts new file mode 100644 index 00000000000..9dcd13dfa31 --- /dev/null +++ b/apps/www/lib/cross-zone.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +async function loadIsCrossZoneHref(vercelEnv: string) { + vi.stubEnv('NEXT_PUBLIC_VERCEL_ENV', vercelEnv) + vi.resetModules() + const { isCrossZoneHref } = await import('./cross-zone') + return isCrossZoneHref +} + +afterEach(() => { + vi.unstubAllEnvs() +}) + +describe('isCrossZoneHref in production', () => { + let isCrossZoneHref: (href: string) => boolean + + beforeEach(async () => { + isCrossZoneHref = await loadIsCrossZoneHref('production') + }) + + it.each([ + '/docs/guides/auth#setup', + '/dashboard', + '/dashboard/project/_?sidebar=ai-assistant', + 'https://supabase.com/dashboard/sign-up', + '/humans.txt', + ])('%s is served by another zone', (href) => { + expect(isCrossZoneHref(href)).toBe(true) + }) + + it.each([ + '/pricing', + '/dashboards', + '/.well-known/ai-catalog.json', + 'https://github.com/supabase/supabase', + 'https://supabase.com.example.com/dashboard', + ])('%s stays in the www zone or is external', (href) => { + expect(isCrossZoneHref(href)).toBe(false) + }) + + it('treats an unparseable href as not cross-zone instead of throwing', () => { + expect(isCrossZoneHref('https://')).toBe(false) + }) +}) + +describe('isCrossZoneHref outside production', () => { + it('only treats /docs as another zone where the docs rewrite exists', async () => { + const isCrossZoneHref = await loadIsCrossZoneHref('preview') + + expect(isCrossZoneHref('/docs')).toBe(false) + expect(isCrossZoneHref('/dashboard')).toBe(true) + }) +}) diff --git a/apps/www/lib/cross-zone.ts b/apps/www/lib/cross-zone.ts new file mode 100644 index 00000000000..f419d2357c1 --- /dev/null +++ b/apps/www/lib/cross-zone.ts @@ -0,0 +1,30 @@ +import rewrites from './rewrites' + +const SUPABASE_ORIGIN = 'https://supabase.com' +const PATH_WILDCARD = '/:path*' + +const crossZoneSources: string[] = rewrites + .filter(({ destination }) => !destination.startsWith('/')) + .map(({ source }) => source) + +function matchesSource(pathname: string, source: string) { + if (!source.endsWith(PATH_WILDCARD)) return pathname === source + + const prefix = source.slice(0, -PATH_WILDCARD.length) + return pathname === prefix || pathname.startsWith(`${prefix}/`) +} + +function parseHref(href: string) { + try { + return new URL(href, SUPABASE_ORIGIN) + } catch { + return null + } +} + +export function isCrossZoneHref(href: string): boolean { + const url = parseHref(href) + if (url?.origin !== SUPABASE_ORIGIN) return false + + return crossZoneSources.some((source) => matchesSource(url.pathname, source)) +} diff --git a/apps/www/pages/assistant.tsx b/apps/www/pages/assistant.tsx index 84e2d876921..024e834e94b 100644 --- a/apps/www/pages/assistant.tsx +++ b/apps/www/pages/assistant.tsx @@ -683,9 +683,9 @@ function Assistant() {
    {!isUserLoading && ( )}
    @@ -70,13 +132,21 @@ export const ThemeColorSettings = () => {
    - {THEME_OVERRIDE_KNOBS.map((knob) => { - const rawValue = draft[knob.key] ?? getThemeOverrideValue(knob, mode, overrides) + {knobs.map((knob) => { + const isSpotHue = knob.key === 'primaryHue' + const rawValue = draft[knob.key] ?? getThemeOverrideValue(knob, mode, visibleOverrides) const sliderValue = themeOverrideToSliderValue(knob, mode, rawValue) + const roundedHue = Math.round(rawValue) + const trackStyle = sliderTrackStyle(knob.key, mode) return (
    -
    +
    { {knob.description}
    - - {sliderValue} - + {isSpotHue ? ( + + + {PRIMARY_SWATCHES.map((swatch) => ( + + ))} + + {roundedHue}° + + ) : ( + + {sliderValue} + + )}
    { - const raw = sliderValueToThemeOverride(knob, mode, next) - writeDraft({ ...draftRef.current, [knob.key]: raw }) - previewThemeOverride(knob, mode, raw) + queueInteraction(knob, sliderValueToThemeOverride(knob, mode, next)) }} onValueCommit={([next]) => commitDraft(knob.key, sliderValueToThemeOverride(knob, mode, next)) diff --git a/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.utils.ts b/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.utils.ts new file mode 100644 index 00000000000..0cf96f49f75 --- /dev/null +++ b/apps/studio/components/interfaces/Account/Preferences/ThemeColorSettings.utils.ts @@ -0,0 +1,50 @@ +import type { CSSProperties } from 'react' + +import type { ThemeOverrideKey, ThemeOverrideMode } from '@/lib/theme-overrides' + +/** Full-hue OKLCH spectrum for the spot color track. */ +const HUE_SPECTRUM_TRACK = + 'linear-gradient(to right, oklch(0.7 0.14 0), oklch(0.7 0.14 60), oklch(0.7 0.14 120), oklch(0.7 0.14 180), oklch(0.7 0.14 240), oklch(0.7 0.14 300), oklch(0.7 0.14 360))' + +type SliderTrackStyle = CSSProperties & { + '--slider-track-fill': string +} + +export function sliderTrackStyle( + key: ThemeOverrideKey, + mode: ThemeOverrideMode +): SliderTrackStyle | undefined { + switch (key) { + case 'primaryHue': + return { '--slider-track-fill': HUE_SPECTRUM_TRACK } + case 'chroma': + // Grey → tinted at the live surface hue (follows spot via CSS offset). + return { + '--slider-track-fill': + mode === 'dark' + ? 'linear-gradient(to right, oklch(0.4 0 var(--surface-hue)), oklch(0.4 0.07 var(--surface-hue)))' + : 'linear-gradient(to right, oklch(0.9 0 var(--surface-hue)), oklch(0.9 0.05 var(--surface-hue)))', + } + case 'contrast': + return { + '--slider-track-fill': + mode === 'dark' + ? 'linear-gradient(to right, oklch(0.35 0 0), oklch(0.9 0 0))' + : 'linear-gradient(to right, oklch(0.82 0 0), oklch(0.2 0 0))', + } + case 'surface': + // Chroma 0 at the ends avoids out-of-gamut flashes at the caps. + return { + '--slider-track-fill': + 'linear-gradient(to right, oklch(0.14 0 0), oklch(0.5 0.015 var(--surface-hue)), oklch(0.97 0 0))', + } + case 'elevationStep': + // Flat → stronger lift between layers (smooth ramp, not hard bands). + return { + '--slider-track-fill': + mode === 'dark' + ? 'linear-gradient(to right, oklch(0.28 0 var(--surface-hue)), oklch(0.55 0 var(--surface-hue)))' + : 'linear-gradient(to right, oklch(0.92 0 var(--surface-hue)), oklch(0.99 0 var(--surface-hue)))', + } + } +} diff --git a/apps/studio/components/interfaces/App/AppearanceSettingsProvider.tsx b/apps/studio/components/interfaces/App/AppearanceSettingsProvider.tsx index 29bf52eaad2..99e9ac4bca1 100644 --- a/apps/studio/components/interfaces/App/AppearanceSettingsProvider.tsx +++ b/apps/studio/components/interfaces/App/AppearanceSettingsProvider.tsx @@ -1,17 +1,23 @@ -import { useIsomorphicLayoutEffect } from 'common' +import { useFlag, useIsomorphicLayoutEffect } from 'common' import { useTheme } from 'next-themes' import { useThemeOverrides } from '@/hooks/misc/useThemeOverrides' -import { applyThemeOverrides } from '@/lib/theme-overrides' +import { + APPEARANCE_SPOT_COLOR_FLAG, + applyThemeOverrides, + resolveThemeOverridesForFlags, +} from '@/lib/theme-overrides' export const AppearanceSettingsProvider = () => { const { resolvedTheme } = useTheme() const { mode, overrides } = useThemeOverrides() + const isSpotColorEnabled = useFlag(APPEARANCE_SPOT_COLOR_FLAG) + const effectiveOverrides = resolveThemeOverridesForFlags(overrides, { isSpotColorEnabled }) useIsomorphicLayoutEffect(() => { if (resolvedTheme === undefined) return - applyThemeOverrides(document.documentElement, mode, overrides) - }, [mode, overrides, resolvedTheme]) + applyThemeOverrides(document.documentElement, mode, effectiveOverrides) + }, [mode, effectiveOverrides, resolvedTheme]) return null } diff --git a/apps/studio/lib/__tests__/theme-overrides.test.ts b/apps/studio/lib/__tests__/theme-overrides.test.ts index 601ac2967f5..63823bf4af9 100644 --- a/apps/studio/lib/__tests__/theme-overrides.test.ts +++ b/apps/studio/lib/__tests__/theme-overrides.test.ts @@ -9,6 +9,7 @@ import { mergeThemeOverride, parseThemeOverridesByMode, resolveThemeOverrideMode, + resolveThemeOverridesForFlags, sliderValueToThemeOverride, THEME_OVERRIDE_DEFAULTS, THEME_OVERRIDE_KNOBS, @@ -141,11 +142,32 @@ describe('theme override application', () => { expect(root.style.getPropertyValue('--contrast')).toBe('') }) + it('applies a spot hue via --primary-hue and leaves brand and surface hues alone', () => { + const root = document.createElement('html') + applyThemeOverrides(root, 'dark', { primaryHue: 48 }) + + expect(root.style.getPropertyValue('--primary-hue')).toBe('48') + expect(root.style.getPropertyValue('--surface-hue')).toBe('') + expect(root.style.getPropertyValue('--brand-default')).toBe('') + + applyThemeOverrides(root, 'dark', {}) + expect(root.style.getPropertyValue('--primary-hue')).toBe('') + }) + it('reports whether a mode has overrides', () => { expect(hasThemeOverrides({})).toBe(false) expect(hasThemeOverrides({ chroma: 0.02 })).toBe(true) }) + it('strips spot hue when the employee-only flag is off', () => { + expect( + resolveThemeOverridesForFlags({ primaryHue: 48, chroma: 0.02 }, { isSpotColorEnabled: false }) + ).toEqual({ chroma: 0.02 }) + expect( + resolveThemeOverridesForFlags({ primaryHue: 48, chroma: 0.02 }, { isSpotColorEnabled: true }) + ).toEqual({ primaryHue: 48, chroma: 0.02 }) + }) + it('resolves classic dark as dark for legacy stored themes', () => { expect(resolveThemeOverrideMode('dark')).toBe('dark') expect(resolveThemeOverrideMode('light')).toBe('light') diff --git a/apps/studio/lib/theme-overrides.ts b/apps/studio/lib/theme-overrides.ts index d363f6fb072..dee967a9f84 100644 --- a/apps/studio/lib/theme-overrides.ts +++ b/apps/studio/lib/theme-overrides.ts @@ -1,8 +1,11 @@ import * as z from 'zod' -export type ThemeOverrideKey = 'chroma' | 'contrast' | 'surface' | 'elevationStep' +export type ThemeOverrideKey = 'primaryHue' | 'chroma' | 'contrast' | 'surface' | 'elevationStep' export type ThemeOverrideMode = 'dark' | 'light' +/** ConfigCat flag: Spot color control (employee-only via Supabase Team Email segment). */ +export const APPEARANCE_SPOT_COLOR_FLAG = 'appearanceSpotColor' + type ThemeOverrideRange = { min: number; max: number } export interface ThemeOverrideKnob { @@ -14,11 +17,18 @@ export interface ThemeOverrideKnob { } export const THEME_OVERRIDE_KNOBS: readonly ThemeOverrideKnob[] = [ + { + key: 'primaryHue', + cssVar: '--primary-hue', + label: 'Spot color', + description: 'Changes the hue of primary controls and the canvas.', + ranges: { dark: { min: 0, max: 360 }, light: { min: 0, max: 360 } }, + }, { key: 'chroma', cssVar: '--chroma', - label: 'Color intensity', - description: 'Controls how vivid interface colors appear.', + label: 'Surface tint', + description: 'Controls how much color tints backgrounds, text, and borders.', ranges: { dark: { min: 0, max: 0.04 }, light: { min: 0, max: 0.03 } }, }, { @@ -48,8 +58,8 @@ export const THEME_OVERRIDE_DEFAULTS: Record< ThemeOverrideMode, Record > = { - dark: { chroma: 0.005, contrast: 0.5, surface: 0.19, elevationStep: 0.025 }, - light: { chroma: 0, contrast: 0.53, surface: 0.995, elevationStep: 0.024 }, + dark: { primaryHue: 157.5, chroma: 0.005, contrast: 0.5, surface: 0.19, elevationStep: 0.025 }, + light: { primaryHue: 157.5, chroma: 0, contrast: 0.53, surface: 0.995, elevationStep: 0.024 }, } export type ThemeOverrides = Partial> @@ -57,6 +67,7 @@ export type ThemeOverridesByMode = Partial overrides[knob.key] !== undefined) } +/** Drops the Spot color override when the employee-only flag is off. */ +export function resolveThemeOverridesForFlags( + overrides: ThemeOverrides, + { isSpotColorEnabled }: { isSpotColorEnabled: boolean } +): ThemeOverrides { + if (isSpotColorEnabled || overrides.primaryHue === undefined) return overrides + const { primaryHue: _removed, ...rest } = overrides + return rest +} + function sanitizeThemeOverrides( mode: ThemeOverrideMode, overrides: ThemeOverrides diff --git a/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx index d0bc1d73299..ab3c04ee6a7 100644 --- a/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx +++ b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' import type { ComponentProps } from 'react' import type * as UI from 'ui' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -8,11 +8,20 @@ import { customRender } from '@/tests/lib/custom-render' type SliderProps = ComponentProps -const { resetOverrides, setOverride } = vi.hoisted(() => ({ +const { resetOverrides, setOverride, mockUseFlag } = vi.hoisted(() => ({ resetOverrides: vi.fn(), setOverride: vi.fn(), + mockUseFlag: vi.fn(() => true), })) +vi.mock('common', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useFlag: mockUseFlag, + } +}) + vi.mock('@/hooks/misc/useThemeOverrides', () => ({ useThemeOverrides: () => ({ mode: 'dark', @@ -23,7 +32,7 @@ vi.mock('@/hooks/misc/useThemeOverrides', () => ({ })) vi.mock('ui', async (importOriginal) => { - const actual = await importOriginal() + const actual = await importOriginal() return { ...actual, @@ -45,14 +54,17 @@ describe('ThemeColorSettings', () => { beforeEach(() => { resetOverrides.mockReset() setOverride.mockReset() + mockUseFlag.mockReset() + mockUseFlag.mockReturnValue(true) document.documentElement.style.removeProperty('--chroma') + document.documentElement.style.removeProperty('--primary-hue') document.documentElement.dataset.theme = 'dark' }) it('persists a rapid pointer change for the active mode', () => { customRender() - const slider = screen.getByRole('slider', { name: 'Color intensity' }) + const slider = screen.getByRole('slider', { name: 'Surface tint' }) fireEvent.click(slider) fireEvent.lostPointerCapture(slider) @@ -62,13 +74,50 @@ describe('ThemeColorSettings', () => { it('persists an ordinary committed change', () => { customRender() - const slider = screen.getByRole('slider', { name: 'Color intensity' }) + const slider = screen.getByRole('slider', { name: 'Surface tint' }) fireEvent.click(slider) fireEvent.keyUp(slider) expect(setOverride).toHaveBeenCalledWith('chroma', 0.04) }) + it('previews and saves the spot color hue when the flag is on', async () => { + customRender() + + expect(screen.getByText('158°')).toBeInTheDocument() + + const slider = screen.getByRole('slider', { name: 'Spot color' }) + fireEvent.click(slider) + + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--primary-hue')).toBe('360') + }) + expect(await screen.findByText('360°')).toBeInTheDocument() + + fireEvent.keyUp(slider) + expect(setOverride).toHaveBeenCalledWith('primaryHue', 360) + }) + + it('hides the spot color control when the employee-only flag is off', () => { + mockUseFlag.mockReturnValue(false) + customRender() + + expect(screen.queryByRole('slider', { name: 'Spot color' })).not.toBeInTheDocument() + expect(screen.getByRole('slider', { name: 'Surface tint' })).toBeInTheDocument() + }) + + it('cancels a queued hue preview when the flag turns off', async () => { + const { rerender } = customRender() + fireEvent.click(screen.getByRole('slider', { name: 'Spot color' })) + + mockUseFlag.mockReturnValue(false) + rerender() + + await new Promise((resolve) => requestAnimationFrame(() => resolve())) + expect(document.documentElement.style.getPropertyValue('--primary-hue')).toBe('') + expect(screen.queryByRole('slider', { name: 'Spot color' })).not.toBeInTheDocument() + }) + it('resets the active mode', () => { customRender() @@ -77,11 +126,13 @@ describe('ThemeColorSettings', () => { expect(resetOverrides).toHaveBeenCalledOnce() }) - it('restores persisted values when an active preview unmounts', () => { + it('restores persisted values when an active preview unmounts', async () => { const { unmount } = customRender() - fireEvent.click(screen.getByRole('slider', { name: 'Color intensity' })) - expect(document.documentElement.style.getPropertyValue('--chroma')).toBe('0.04') + fireEvent.click(screen.getByRole('slider', { name: 'Surface tint' })) + await waitFor(() => { + expect(document.documentElement.style.getPropertyValue('--chroma')).toBe('0.04') + }) unmount() diff --git a/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.utils.test.ts b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.utils.test.ts new file mode 100644 index 00000000000..84a6b766cd8 --- /dev/null +++ b/apps/studio/tests/components/interfaces/Account/Preferences/ThemeColorSettings.utils.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { sliderTrackStyle } from '@/components/interfaces/Account/Preferences/ThemeColorSettings.utils' +import type { ThemeOverrideKey, ThemeOverrideMode } from '@/lib/theme-overrides' + +describe('sliderTrackStyle', () => { + const cases: Array<{ + key: ThemeOverrideKey + mode: ThemeOverrideMode + start: string + end: string + }> = [ + { key: 'primaryHue', mode: 'dark', start: '0.7 0.14 0', end: '0.7 0.14 360' }, + { key: 'primaryHue', mode: 'light', start: '0.7 0.14 0', end: '0.7 0.14 360' }, + { + key: 'chroma', + mode: 'dark', + start: '0.4 0 var(--surface-hue)', + end: '0.4 0.07 var(--surface-hue)', + }, + { + key: 'chroma', + mode: 'light', + start: '0.9 0 var(--surface-hue)', + end: '0.9 0.05 var(--surface-hue)', + }, + { key: 'contrast', mode: 'dark', start: '0.35 0 0', end: '0.9 0 0' }, + { key: 'contrast', mode: 'light', start: '0.82 0 0', end: '0.2 0 0' }, + { key: 'surface', mode: 'dark', start: '0.14 0 0', end: '0.97 0 0' }, + { key: 'surface', mode: 'light', start: '0.14 0 0', end: '0.97 0 0' }, + { + key: 'elevationStep', + mode: 'dark', + start: '0.28 0 var(--surface-hue)', + end: '0.55 0 var(--surface-hue)', + }, + { + key: 'elevationStep', + mode: 'light', + start: '0.92 0 var(--surface-hue)', + end: '0.99 0 var(--surface-hue)', + }, + ] + + it.each(cases)('illustrates the $key range in $mode mode', ({ key, mode, start, end }) => { + const gradient = sliderTrackStyle(key, mode)?.['--slider-track-fill'] + expect(gradient).toMatch(/^linear-gradient\(to right, /) + expect(gradient).toContain(`oklch(${start})`) + expect(gradient).toContain(`oklch(${end})`) + }) +}) diff --git a/packages/ui/build/css/source/semantic.css b/packages/ui/build/css/source/semantic.css index 4a51e400faa..fb9de6df356 100644 --- a/packages/ui/build/css/source/semantic.css +++ b/packages/ui/build/css/source/semantic.css @@ -9,17 +9,17 @@ :root { /* - * Hue inputs. A single --hue used to drive both the neutral surface/text ramp - * and the brand, so the two could never drift apart. They are now split so a - * theme can run, say, a cool-gray surface against the green brand: - * --surface-hue tints backgrounds, text and borders (the neutral ramp) - * --primary-hue is the spot hue that functional primary colors use - * Both default to --hue (Supabase green ≈157.5° in OKLCH), so a theme that only - * sets --hue keeps surfaces and brand locked together as before; override - * either one to let them diverge. */ + * Hue inputs. --primary-hue is the spot colour; --surface-hue tints the + * neutral ramp (backgrounds, text, borders). Surface is derived from primary + * plus a theme offset so canvases stay coupled when spot changes: + * dark offset 0 → same hue as spot (subtle brand tint) + * light offset 180 → complementary canvas tint (brand green → rose) + * Themes can still set --surface-hue directly to break the link. + */ --hue: 157.5; - --surface-hue: var(--hue); --primary-hue: var(--hue); + --surface-hue-offset: 0; + --surface-hue: calc(var(--primary-hue) + var(--surface-hue-offset)); /* Root defaults mirror the dark theme (the app's default appearance). */ --chroma: 0.016; --surface: 0.17; diff --git a/packages/ui/build/css/themes/light.css b/packages/ui/build/css/themes/light.css index 492040b6607..b87596d70bf 100644 --- a/packages/ui/build/css/themes/light.css +++ b/packages/ui/build/css/themes/light.css @@ -2,7 +2,8 @@ .light { --helpers-os-appearance: Light; --hue: 157.5; - --surface-hue: 34; + /* Complementary canvas tint relative to --primary-hue (157.5° → ≈337.5°). */ + --surface-hue-offset: 180; --chroma: 0; /* * Soft-gray canvas (not pure white) so elevated surfaces have headroom to From 36364e7df31917bb52d6817f42edd6d07890edf1 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:28:36 +1100 Subject: [PATCH 31/41] fix(www): stop wrapping oklch semantic tokens in hsl() (#51161) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Semantic colour tokens (`--border-*`, `--foreground-*`, `--background-*`) now resolve to full `oklch(...)` values. Call sites that still wrapped them in `hsl(var(--…))` are invalid CSS and drop the colour (Partners ambient grid was the clearest case). Brand / destructive / warning channel tokens still correctly use `hsl(var(--…))` and were left alone. Presence avatar stack polish is in https://github.com/supabase/supabase/pull/51164. ## Solution - Use bare `var(--…)`, `currentColor`, or Tailwind utilities for semantic tokens. Opacity cases use `oklch(from var(--…) l c h / …)`. - Partners grid: fix, and then use `text-foreground/20` in light, `text-foreground/30` in dark for optical correction. | Before | After | | --- | --- | | CleanShot 2026-10-02 at 16 29
08@2x | CleanShot 2026-10-02 at 16 28
44@2x | | 20701 | 59176 | ## Review instructions 1. [Live /partners](https://supabase.com/partners) · [Preview /partners](https://zone-www-dot-com-git-dnywh-fixhsl-oklch-semanti-9bc03a-supabase.vercel.app/partners). Ambient dashed grid under the hero (light and dark). 2. [Live /database](https://supabase.com/database) · [Preview /database](https://zone-www-dot-com-git-dnywh-fixhsl-oklch-semanti-9bc03a-supabase.vercel.app/database). Postgres elephant idle outline should be muted grey, not black. Hover still brand green. 3. [Live /state-of-startups](https://supabase.com/state-of-startups) · [Preview /state-of-startups](https://zone-www-dot-com-git-dnywh-fixhsl-oklch-semanti-9bc03a-supabase.vercel.app/state-of-startups). Chart **More AI-generated code, less likely to be monetizing yet**: horizontal gridlines at 0/25/50/75/100% should render. 4. Storage / Edge Functions changes are correctness-only (shadows, idle borders, a top fade). Near-invisible to the naked eye; skip unless debugging. --- ...5-12-17-building-chatgpt-apps-with-supabase.mdx | 2 +- .../database/_components/FeaturesSection.tsx | 2 +- .../realtime/_components/FeaturesSection.tsx | 2 +- .../storage/_components/FeaturesSection.tsx | 14 +++++++------- .../components/SurveyCrossTabChart.tsx | 4 ++-- apps/www/components/Partners/BackgroundPattern.tsx | 4 ++-- .../components/Products/Functions/ParityImage.tsx | 4 ++-- .../components/Products/Functions/QueryLogs.tsx | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/apps/www/_blog/2025-12-17-building-chatgpt-apps-with-supabase.mdx b/apps/www/_blog/2025-12-17-building-chatgpt-apps-with-supabase.mdx index b49c6cc5ab5..a405a1ba2e7 100644 --- a/apps/www/_blog/2025-12-17-building-chatgpt-apps-with-supabase.mdx +++ b/apps/www/_blog/2025-12-17-building-chatgpt-apps-with-supabase.mdx @@ -194,7 +194,7 @@ const SupabaseStatusWidget: React.FC = () => {
    -

    +

    Supabase Status

    diff --git a/apps/www/app/(products)/database/_components/FeaturesSection.tsx b/apps/www/app/(products)/database/_components/FeaturesSection.tsx index 43b464f8c93..d85dc3ee53a 100644 --- a/apps/www/app/(products)/database/_components/FeaturesSection.tsx +++ b/apps/www/app/(products)/database/_components/FeaturesSection.tsx @@ -162,7 +162,7 @@ function PostgresSkeleton() { viewBox="0 0 390 430" className="w-80 h-80 relative z-[5]" style={{ - color: hovered ? 'hsl(var(--brand-default))' : 'hsl(var(--foreground-muted))', + color: hovered ? 'hsl(var(--brand-default))' : 'var(--foreground-muted)', opacity: hovered ? 1 : 0.8, transition: 'color 0.5s, opacity 0.5s', }} diff --git a/apps/www/app/(products)/realtime/_components/FeaturesSection.tsx b/apps/www/app/(products)/realtime/_components/FeaturesSection.tsx index 7dc72de5698..1f31b8f6938 100644 --- a/apps/www/app/(products)/realtime/_components/FeaturesSection.tsx +++ b/apps/www/app/(products)/realtime/_components/FeaturesSection.tsx @@ -610,7 +610,7 @@ function BroadcastSkeleton() { -

    +
    -
    +
    @@ -110,7 +110,7 @@ function InteroperableSkeleton() {
    -
    +
    -
    -
    +
    +
    -
    -
    +
    +
    {
    diff --git a/apps/www/components/Products/Functions/ParityImage.tsx b/apps/www/components/Products/Functions/ParityImage.tsx index 913dc8ed8aa..6b9417247cc 100644 --- a/apps/www/components/Products/Functions/ParityImage.tsx +++ b/apps/www/components/Products/Functions/ParityImage.tsx @@ -107,7 +107,7 @@ const ParityImage = ({ isHovered = false }: { isHovered?: boolean }) => ( boxShadow: '0 0 12px 0 #3ecf8e40', } : { - border: '1px solid hsl(var(--border-default))', + border: '1px solid var(--border-default)', boxShadow: 'none', } } @@ -127,7 +127,7 @@ const ParityImage = ({ isHovered = false }: { isHovered?: boolean }) => ( boxShadow: '0 0 12px 0 #bda4ff40', } : { - border: '1px solid hsl(var(--border-default))', + border: '1px solid var(--border-default)', boxShadow: 'none', } } diff --git a/apps/www/components/Products/Functions/QueryLogs.tsx b/apps/www/components/Products/Functions/QueryLogs.tsx index 040b5a82268..f00c6bcf7c8 100644 --- a/apps/www/components/Products/Functions/QueryLogs.tsx +++ b/apps/www/components/Products/Functions/QueryLogs.tsx @@ -120,7 +120,7 @@ const QueryLogs = ({ isActive, isInView }: { isActive?: boolean; isInView?: bool )} style={{ background: - 'linear-gradient(to bottom, hsl(var(--background-surface-75)) 0%, transparent 100%)', + 'linear-gradient(to bottom, var(--background-surface-75) 0%, transparent 100%)', }} />
    Date: Tue, 6 Oct 2026 10:46:59 +1100 Subject: [PATCH 32/41] fix(www): plate State of Startups Sign in over the aurora (#51163) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem On `/state-of-startups`, the nav starts transparent over the aurora. Default buttons use a translucent fill in dark mode, so Sign in (and Dashboard) look see-through. ## Solution Wrap those default nav buttons in `FloatingPlate`. Primary “Start your project” is already opaque and unchanged. | Before | After | | --- | --- | | 23474_296a1f8f952ae7f73ae9205e5db6bb9cda4cf0a904bc2d440d6395b5f12346ab | State of Startups 2026
Supabase | ## Review instructions In dark mode: 1. [Live /state-of-startups](https://supabase.com/state-of-startups) · [Preview /state-of-startups](https://zone-www-dot-com-git-dnywh-fixsos-sign-in-float-91d2b1-supabase.vercel.app/state-of-startups) 2. At the top of the page (transparent nav, before scrolling), check Sign in: opaque plate, aurora does not show through the fill. 3. Scroll until the nav gains a solid background: Sign in should still look normal. Ideally you could sign in and confirm the now ‘Dashboard’ button gets the same plate treatment. But that’s not possible until merge. --- apps/www/components/Nav/index.tsx | 59 ++++++++++++++++++++++--------- 1 file changed, 43 insertions(+), 16 deletions(-) diff --git a/apps/www/components/Nav/index.tsx b/apps/www/components/Nav/index.tsx index 8b08d18c78d..ba37f71424c 100644 --- a/apps/www/components/Nav/index.tsx +++ b/apps/www/components/Nav/index.tsx @@ -12,6 +12,7 @@ import { Button, buttonVariants, cn, + FloatingPlate, NavigationMenu, NavigationMenuContent, NavigationMenuItem, @@ -215,26 +216,52 @@ const Nav = ({ hideNavbar, stickyNavbar = true }: Props) => { {isLoggedIn ? ( <> - + {isStateOfStartupsPage ? ( + + + + ) : ( + + )} ) : ( <> - + {isStateOfStartupsPage ? ( + + + + ) : ( + + )} +
    +
    + +
    + + ) +} From a629c9c1ac6bf1238c21602d65d1d64f250d7cb2 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:09:25 +1100 Subject: [PATCH 36/41] Add hidden Supplemental Terms legal page (#51100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Nicole Kramer** · [Slack thread](https://supabase.slack.com/archives/C0161K73J1J/p1790784371448999)_ ## Problem No page existed yet for the upcoming "Supplemental Terms" document. Nicole Kramer (Commercial & Product Counsel) needs a stable, linkable URL to reference from future Terms of Service / Enterprise SaaS Subscription Agreement updates, before the legal content itself is ready. The page needs to be reachable by direct URL but not surfaced in the visible Legal Hub nav yet, matching the existing "hidden" precedent used for `/enterprise-terms`. ## Solution Added a new page at `/legal/customer-resources/supplemental-terms`: - `apps/www/pages/legal/customer-resources/supplemental-terms.tsx` — a page component mirroring the existing Data Processing Addendum page (`apps/www/pages/legal/customer-resources/data-processing-addendum.tsx`): `DefaultLayout` + `PageHeader` (h1 "Supplemental Terms") + `LegalDocVersions` rendering a single MDX version. - `apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx` — placeholder body content (`_Content coming soon._`), no frontmatter/h1, following this repo's existing legal MDX convention (the h1 is rendered by `PageHeader`, not the MDX itself). Nicole will fill in the actual legal content later. **On "hidden":** I investigated the actual codebase state before implementing, since the two precedents named didn't turn out to work the same way: - `/enterprise-terms` is genuinely hidden — it is not linked anywhere in `apps/www/pages/legal/index.tsx`'s Legal Hub listing, and its `NextSeo` meta sets `noindex: true, nofollow: true`. - The Data Processing Addendum and Subprocessor List pages, by contrast, **are** linked in the visible Legal Hub listing (`apps/www/pages/legal/index.tsx`, "Customer Legal Resources" section) — they are not hidden today. Given that, this PR mirrors the DPA/Subprocessor List *structural* pattern (route under `/legal/customer-resources/`, page-component + versioned-MDX content) since that's what "under Customer Legal Resources" means structurally in this codebase, but mirrors `enterprise-terms`' *hidden* mechanism: the new page's `NextSeo` meta sets `noindex: true, nofollow: true`, and — importantly — **no entry was added** to the `sections` array in `apps/www/pages/legal/index.tsx`, so it does not appear in the visible Legal Hub or any nav. The page is reachable only via its direct URL. Terms of Service and the Enterprise SaaS Subscription Agreement pages were not touched. ## Review instructions 1. Confirm `/legal/customer-resources/supplemental-terms` renders with h1 "Supplemental Terms" and placeholder body text. 2. Confirm the page does **not** appear anywhere on `/legal` (the Legal Hub listing). 3. Confirm `apps/www/pages/terms.tsx` / `apps/www/pages/enterprise-terms.tsx` (and their MDX content) are unchanged. ## Checklist - [x] I have read CONTRIBUTING.md - [ ] N/A — no docs topic edited (legal page content is a placeholder, not docs content) --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K2vqszbsJiMJPABAnLMgaC --- _Generated by [Claude Code](https://claude.ai/code/session_01K2vqszbsJiMJPABAnLMgaC)_ Co-authored-by: Claude --- .../supplemental-terms/v1.mdx | 1 + .../customer-resources/supplemental-terms.tsx | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx create mode 100644 apps/www/pages/legal/customer-resources/supplemental-terms.tsx diff --git a/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx b/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx new file mode 100644 index 00000000000..22e1c755629 --- /dev/null +++ b/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx @@ -0,0 +1 @@ +_Content coming soon._ diff --git a/apps/www/pages/legal/customer-resources/supplemental-terms.tsx b/apps/www/pages/legal/customer-resources/supplemental-terms.tsx new file mode 100644 index 00000000000..0463c0a648b --- /dev/null +++ b/apps/www/pages/legal/customer-resources/supplemental-terms.tsx @@ -0,0 +1,47 @@ +import { MDXProvider } from '@mdx-js/react' +import DefaultLayout from '~/components/Layouts/Default' +import SectionContainer from '~/components/Layouts/SectionContainer' +import LegalDocVersions, { type LegalDocVersion } from '~/components/Legal/LegalDocVersions' +import PageBreadcrumb from '~/components/Sections/PageBreadcrumb' +import PageHeader from '~/components/Sections/PageHeader' +import V1 from '~/data/legal/customer-resources/supplemental-terms/v1.mdx' +import mdxComponents from '~/lib/mdx/mdxComponents' +import { NextSeo } from 'next-seo' + +// This page is intentionally not linked from the Legal Hub (`/legal`) or any +// visible navigation — it is reachable by direct URL only, following the +// same pattern as `/enterprise-terms`. See PR description for details. +const meta = { + title: 'Supplemental Terms | Supabase', + description: 'Supabase Supplemental Terms', + noindex: true, + nofollow: true, +} + +const versions: LegalDocVersion[] = [ + { id: 'v1', label: 'Version 1', effectiveDate: 'TBD', Component: V1 }, +] + +export default function SupplementalTermsPage() { + return ( + + + + } + h1="Supplemental Terms" + /> + + + + + + + ) +} From 20d6f2197f07608f6558686491ba58e3413ab2f0 Mon Sep 17 00:00:00 2001 From: Pamela Chia Date: Mon, 5 Oct 2026 19:24:14 -0700 Subject: [PATCH 37/41] chore(studio): remove privacy policy notice (#51299) I removed the Studio Privacy Policy update notice that #50397 added on 2026-09-16, when Privacy Policy v4 took effect. It has been up for almost three weeks, and the ToS v4 banner (#51109) goes out next. I did the same in #44380, removing the March 2026 privacy notice after 15 days. This is the exact inverse of #50397: the banner component and its test, the banner ID, the dismissal local storage key, and the org-landing path helper that only this notice used. ## To test Tested on Vercel preview: - [ ] In a fresh browser profile (no `privacy-policy-update-2026-09-16-dismissed` key), open `/organizations`: expect no Privacy Policy notice - [ ] Open `/org/`: expect no Privacy Policy notice and the project list renders normally - [ ] Open a project's Logs page: expect the logs deprecation banner behavior unchanged (only shows before its expiry) ## Linear - fixes GROWTH-1322 --- .../interfaces/App/AppBannerWrapper.tsx | 27 +----- .../interfaces/App/AppBannerWrapper.utils.ts | 8 -- .../ui/BannerStack/BannerStackProvider.tsx | 1 - .../BannerPrivacyPolicyUpdate.test.tsx | 55 ----------- .../Banners/BannerPrivacyPolicyUpdate.tsx | 95 ------------------- .../App/AppBannerWrapper.utils.test.ts | 18 +--- packages/common/constants/local-storage.ts | 1 - 7 files changed, 2 insertions(+), 203 deletions(-) delete mode 100644 apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.test.tsx delete mode 100644 apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.tsx diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx index 07705189c50..7ba03996c58 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.tsx +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.tsx @@ -4,13 +4,12 @@ import { usePathname } from 'next/navigation' import { PropsWithChildren, useEffect, useRef, useState } from 'react' import { OrganizationResourceBanner } from '../Organization/HeaderBanner' -import { isLogsOrObservabilityPath, isOrganizationLandingPath } from './AppBannerWrapper.utils' +import { isLogsOrObservabilityPath } from './AppBannerWrapper.utils' import { ClockSkewBanner } from '@/components/layouts/AppLayout/ClockSkewBanner' import { NoticeBanner } from '@/components/layouts/AppLayout/NoticeBanner' import { StatusBanner } from '@/components/layouts/AppLayout/StatusBanner' import { StatusPageBanner } from '@/components/layouts/AppLayout/StatusPageBanner' import { BannerLogsAllDeprecation } from '@/components/ui/BannerStack/Banners/BannerLogsAllDeprecation' -import { BannerPrivacyPolicyUpdate } from '@/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate' import { BANNER_ID, useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider' import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' import { useTrack } from '@/lib/telemetry/track' @@ -31,30 +30,6 @@ export const AppBannerWrapper = ({ children }: PropsWithChildren<{}>) => { const pathname = usePathname() const track = useTrack() - const [privacyPolicyUpdateAcknowledged, , { isSuccess: isPrivacyPolicyDismissalLoaded }] = - useLocalStorageQuery(LOCAL_STORAGE_KEYS.PRIVACY_POLICY_UPDATE, false) - - useEffect(() => { - if (!isPrivacyPolicyDismissalLoaded || pathname == null) return - - if (isOrganizationLandingPath(pathname) && !privacyPolicyUpdateAcknowledged) { - addBanner({ - id: BANNER_ID.PRIVACY_POLICY_UPDATE, - isDismissed: false, - content: , - priority: 0, - }) - } else { - dismissBanner(BANNER_ID.PRIVACY_POLICY_UPDATE) - } - }, [ - pathname, - privacyPolicyUpdateAcknowledged, - isPrivacyPolicyDismissalLoaded, - addBanner, - dismissBanner, - ]) - const [isLogsAllDeprecationDismissed, , { isSuccess: isLogsAllDeprecationLoaded }] = useLocalStorageQuery(LOCAL_STORAGE_KEYS.LOGS_ALL_DEPRECATION_2026_09_23, false) diff --git a/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts b/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts index 48b20261bbb..2035a8d9bad 100644 --- a/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts +++ b/apps/studio/components/interfaces/App/AppBannerWrapper.utils.ts @@ -1,16 +1,8 @@ // Anchored to the section root so per-resource log pages (e.g. a single edge // function's logs) don't pull the banner outside Logs/Observability. const LOGS_SECTION_PATH = /^\/project\/[^/]+\/(logs|observability)(\/|$)/ -const ORGANIZATION_HOME_PATH = /^\/org\/[^/]+\/?$/ export function isLogsOrObservabilityPath(pathname: string | null | undefined): boolean { if (!pathname) return false return LOGS_SECTION_PATH.test(pathname) } - -export function isOrganizationLandingPath(pathname: string | null | undefined): boolean { - if (!pathname) return false - return ( - pathname === '/org' || pathname === '/organizations' || ORGANIZATION_HOME_PATH.test(pathname) - ) -} diff --git a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx index 50148decc82..2ecdcd95600 100644 --- a/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx +++ b/apps/studio/components/ui/BannerStack/BannerStackProvider.tsx @@ -5,7 +5,6 @@ export const BANNER_ID = { TABLE_EDITOR_QUEUE_OPERATIONS: 'table-editor-queue-operations-banner', RLS_EVENT_TRIGGER: 'rls-event-trigger-banner', FREE_MICRO_UPGRADE: 'free-micro-upgrade-banner', - PRIVACY_POLICY_UPDATE: 'privacy-policy-update-banner', LOGS_ALL_DEPRECATION: 'logs-all-deprecation-banner', EXPLORER: 'explorer-banner', } as const diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.test.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.test.tsx deleted file mode 100644 index b0ace47f5d0..00000000000 --- a/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' -import { LOCAL_STORAGE_KEYS } from 'common/constants/local-storage' -import { PropsWithChildren } from 'react' -import { beforeEach, describe, expect, it } from 'vitest' - -import { BannerStackProvider } from '../BannerStackProvider' -import { BannerPrivacyPolicyUpdate } from './BannerPrivacyPolicyUpdate' - -const renderBanner = () => { - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }) - - const Wrapper = ({ children }: PropsWithChildren) => ( - - {children} - - ) - - return render(, { wrapper: Wrapper }) -} - -describe('BannerPrivacyPolicyUpdate', () => { - beforeEach(() => window.localStorage.clear()) - - it('shows the compact notice and the full update details', async () => { - renderBanner() - - expect(screen.getByText("We've updated our Privacy Policy")).toBeVisible() - - fireEvent.click(screen.getByRole('button', { name: 'Learn more' })) - - expect(await screen.findByRole('dialog')).toBeVisible() - expect(screen.getByRole('link', { name: 'Privacy Policy' })).toHaveAttribute( - 'href', - 'https://supabase.com/privacy' - ) - expect(screen.getByRole('link', { name: 'contact us' })).toHaveAttribute( - 'href', - 'mailto:privacy@supabase.com' - ) - }) - - it('persists acknowledgement from the dialog', async () => { - renderBanner() - - fireEvent.click(screen.getByRole('button', { name: 'Learn more' })) - fireEvent.click(await screen.findByRole('button', { name: 'Understood' })) - - await waitFor(() => - expect(window.localStorage.getItem(LOCAL_STORAGE_KEYS.PRIVACY_POLICY_UPDATE)).toBe('true') - ) - }) -}) diff --git a/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.tsx b/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.tsx deleted file mode 100644 index 6dbcd364efb..00000000000 --- a/apps/studio/components/ui/BannerStack/Banners/BannerPrivacyPolicyUpdate.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { LOCAL_STORAGE_KEYS } from 'common' -import { - Badge, - Button, - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogSection, - DialogSectionSeparator, - DialogTitle, - DialogTrigger, -} from 'ui' - -import { InlineLink } from '../../InlineLink' -import { BannerCard } from '../BannerCard' -import { BANNER_ID, useBannerStack } from '../BannerStackProvider' -import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage' - -export const BannerPrivacyPolicyUpdate = () => { - const { dismissBanner } = useBannerStack() - const [, setPrivacyPolicyUpdateAcknowledged] = useLocalStorageQuery( - LOCAL_STORAGE_KEYS.PRIVACY_POLICY_UPDATE, - false - ) - - const acknowledgeUpdate = () => { - setPrivacyPolicyUpdateAcknowledged(true) - dismissBanner(BANNER_ID.PRIVACY_POLICY_UPDATE) - } - - return ( - -
    - - Notice - - -
    -

    We've updated our Privacy Policy

    -

    - The data controller is now Supabase Pte. Ltd. Your rights and protections are unchanged. -

    -
    - -
    -
    - ) -} - -const PrivacyPolicyUpdateDialog = ({ onAcknowledge }: { onAcknowledge: () => void }) => { - return ( - - - - - - - Privacy Policy update - - We've updated our Privacy Policy to align with our Data Processing Addendum. - - - - - - -

    - The data controller is now listed as Supabase Pte. Ltd. (previously Supabase, Inc.). -

    -

    - This is an organizational change for clarity. Your rights, our obligations, and your - data protections remain unchanged. -

    -

    - Read the updated{' '} - Privacy Policy or{' '} - contact us with any - questions. -

    -
    - - - - - - -
    -
    - ) -} diff --git a/apps/studio/tests/components/interfaces/App/AppBannerWrapper.utils.test.ts b/apps/studio/tests/components/interfaces/App/AppBannerWrapper.utils.test.ts index bdb577a3881..12716cda236 100644 --- a/apps/studio/tests/components/interfaces/App/AppBannerWrapper.utils.test.ts +++ b/apps/studio/tests/components/interfaces/App/AppBannerWrapper.utils.test.ts @@ -1,9 +1,6 @@ import { describe, expect, test } from 'vitest' -import { - isLogsOrObservabilityPath, - isOrganizationLandingPath, -} from '@/components/interfaces/App/AppBannerWrapper.utils' +import { isLogsOrObservabilityPath } from '@/components/interfaces/App/AppBannerWrapper.utils' describe('isLogsOrObservabilityPath', () => { test.each([ @@ -29,16 +26,3 @@ describe('isLogsOrObservabilityPath', () => { expect(isLogsOrObservabilityPath(pathname)).toBe(false) }) }) - -describe('isOrganizationLandingPath', () => { - test.each(['/org', '/organizations', '/org/my-org', '/org/my-org/'])('matches %s', (pathname) => { - expect(isOrganizationLandingPath(pathname)).toBe(true) - }) - - test.each([undefined, null, '', '/project/abc', '/org/my-org/general', '/organizations/new'])( - 'does not match %s', - (pathname) => { - expect(isOrganizationLandingPath(pathname)).toBe(false) - } - ) -}) diff --git a/packages/common/constants/local-storage.ts b/packages/common/constants/local-storage.ts index 262980f4cd7..f987589efcc 100644 --- a/packages/common/constants/local-storage.ts +++ b/packages/common/constants/local-storage.ts @@ -80,7 +80,6 @@ export const LOCAL_STORAGE_KEYS = { GITHUB_AUTHORIZATION_STATE: 'supabase-github-authorization-state', // Notice banner keys API_KEYS_FEEDBACK_DISMISSED: (ref: string) => `supabase-api-keys-feedback-dismissed-${ref}`, - PRIVACY_POLICY_UPDATE: 'privacy-policy-update-2026-09-16-dismissed', SUPAVISOR_MAINTENANCE: (ref: string) => `supavisor-maintenance-2026-06-09-${ref}`, REPORT_DATERANGE: 'supabase-report-daterange', PROJECT_PAUSING_STARTED_AT: (ref: string) => `supabase-project-pausing-started-at-${ref}`, From 642a02db4939be6300ee722eff260f322a50eab2 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 6 Oct 2026 11:12:35 +0800 Subject: [PATCH 38/41] Prevent enabling spend cap if org has projects with RRs (#51253) ## Context Prevents organizations from enabling spend cap if the org has projects with read replicas - We currently gate creation of read replicas to ensure that orgs have spend caps disabled, but were missing the guard for the other way around image ## Other changes involved - Refactored to use new `Sheet` and `Table` components in `SpendCapSidePanel` --- .../BillingSettings/BillingSettings.tsx | 2 +- .../CostControl/CostControl.tsx | 13 +- .../CostControl/SpendCapSidePanel.tsx | 422 ++++++++++-------- 3 files changed, 250 insertions(+), 187 deletions(-) diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx index d9f7d90b3b1..bc2048e54d6 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingSettings.tsx @@ -5,7 +5,7 @@ import { InvoicesSection } from '../InvoicesSettings/InvoicesSection' import BillingBreakdown from './BillingBreakdown/BillingBreakdown' import { BillingCustomerData } from './BillingCustomerData/BillingCustomerData' import BillingEmail from './BillingEmail' -import CostControl from './CostControl/CostControl' +import { CostControl } from './CostControl/CostControl' import CreditBalance from './CreditBalance' import Subscription from './Subscription/Subscription' import { diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx index a46f66a38be..ff59657a02b 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/CostControl.tsx @@ -9,7 +9,7 @@ import { Admonition } from 'ui-patterns/Admonition' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { ProjectUpdateDisabledTooltip } from '../ProjectUpdateDisabledTooltip' -import SpendCapSidePanel from './SpendCapSidePanel' +import { SpendCapSidePanel } from './SpendCapSidePanel' import { ScaffoldSection, ScaffoldSectionContent, @@ -27,20 +27,19 @@ import { BASE_PATH, DOCS_URL } from '@/lib/constants' import { MANAGED_BY } from '@/lib/constants/infrastructure' import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings' -export interface CostControlProps {} - -const CostControl = ({}: CostControlProps) => { +export const CostControl = () => { const { slug } = useParams() const { resolvedTheme } = useTheme() + const snap = useOrgSettingsPageStateSnapshot() const { data: selectedOrganization } = useSelectedOrganizationQuery() + const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') + const { isSuccess: isPermissionsLoaded, can: canReadSubscriptions } = useAsyncCheckPermissions( PermissionAction.BILLING_READ, 'stripe.subscriptions' ) - const snap = useOrgSettingsPageStateSnapshot() - const projectUpdateDisabled = useFlag('disableProjectCreationAndUpdate') const { data: subscription, error, @@ -203,5 +202,3 @@ const CostControl = ({}: CostControlProps) => { ) } - -export default CostControl diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx index e507106268e..c29db6d377f 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/CostControl/SpendCapSidePanel.tsx @@ -4,19 +4,49 @@ import { ChevronRight, ExternalLink } from 'lucide-react' import { useTheme } from 'next-themes' import Image from 'next/image' import Link from 'next/link' -import { useEffect, useState } from 'react' +import { Fragment, useEffect, useMemo, useState } from 'react' import { pricing } from 'shared-data/pricing' import { toast } from 'sonner' -import { Button, cn, Collapsible, CollapsibleContent, CollapsibleTrigger, SidePanel } from 'ui' +import { + Button, + Card, + CardContent, + cn, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + Sheet, + SheetContent, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from 'ui' import { Admonition } from 'ui-patterns/Admonition' -import Table from '@/components/to-be-cleaned/Table' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useOrgProjectsInfiniteQuery } from '@/data/projects/org-projects-infinite-query' import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query' import { useOrgSubscriptionUpdateMutation } from '@/data/subscriptions/org-subscription-update-mutation' import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { BASE_PATH, DOCS_URL, PRICING_TIER_PRODUCT_IDS } from '@/lib/constants' +import { PROJECT_STATUS } from '@/lib/constants/infrastructure' import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings' +const BILLING_METRIC_CATEGORIES: (keyof typeof pricing)[] = [ + 'database', + 'auth', + 'storage', + 'realtime', + 'edge_functions', +] + const SPEND_CAP_OPTIONS: { name: string value: 'on' | 'off' @@ -37,7 +67,7 @@ const SPEND_CAP_OPTIONS: { }, ] -const SpendCapSidePanel = () => { +export const SpendCapSidePanel = () => { const { slug } = useParams() const { resolvedTheme } = useTheme() @@ -53,6 +83,19 @@ const SpendCapSidePanel = () => { const visible = snap.panelKey === 'costControl' const onClose = () => snap.setPanelKey(undefined) + const { data } = useOrgProjectsInfiniteQuery({ slug }) + const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) || [], [data?.pages]) + + const hasReplicas = useMemo( + () => + projects.some( + (it) => + it.status !== PROJECT_STATUS.INACTIVE && + it.databases.some((db) => db.type === 'READ_REPLICA') + ), + [projects] + ) + const { data: subscription, isPending: isLoading } = useOrgSubscriptionQuery({ orgSlug: slug }) const { mutate: updateOrgSubscription, isPending: isUpdating } = useOrgSubscriptionUpdateMutation( { @@ -70,12 +113,21 @@ const SpendCapSidePanel = () => { const isSpendCapOn = !subscription?.usage_billing_enabled const isTurningOnCap = !isSpendCapOn && selectedOption === 'on' const hasChanges = selectedOption !== (isSpendCapOn ? 'on' : 'off') + const isBlockedByReplicas = isTurningOnCap && hasReplicas - useEffect(() => { - if (visible && subscription !== undefined) { - setSelectedOption(isSpendCapOn ? 'on' : 'off') - } - }, [visible, isLoading, subscription, isSpendCapOn]) + const disabled = + isFreePlan || + isLoading || + !hasChanges || + isUpdating || + !canUpdateSpendCap || + isBlockedByReplicas + + const confirmTooltipText = !canUpdateSpendCap + ? 'You do not have permission to update spend cap' + : isBlockedByReplicas + ? 'Remove your read replicas before enabling the spend cap' + : undefined const onConfirm = async () => { if (!slug) return console.error('Org slug is required') @@ -87,25 +139,17 @@ const SpendCapSidePanel = () => { updateOrgSubscription({ slug, tier }) } - const billingMetricCategories: (keyof typeof pricing)[] = [ - 'database', - 'auth', - 'storage', - 'realtime', - 'edge_functions', - ] + useEffect(() => { + if (visible && subscription !== undefined) { + setSelectedOption(isSpendCapOn ? 'on' : 'off') + } + }, [visible, isLoading, subscription, isSpendCapOn]) return ( - -

    Spend cap

    + onClose()}> + + + Spend cap -
    - } - tooltip={!canUpdateSpendCap ? 'You do not have permission to update spend cap' : undefined} - > - -
    -

    - Use the spend cap to manage project usage and costs, and control whether the project can - exceed the included quota allowance of any billed line item in a billing cycle -

    + - - -
    - -

    - How are each resource charged after exceeding the included quota? -

    -
    -
    - - - -

    Item

    -
    - -

    Rate

    -
    - + +
    +

    + Use the spend cap to manage project usage and costs, and control whether the project + can exceed the included quota allowance of any billed line item in a billing cycle +

    + + + +
    + +

    + How is each resource charged after exceeding the included quota? +

    +
    +
    + + + +
    + + + Item + Rate + + + + {BILLING_METRIC_CATEGORIES.map((categoryId) => { + const category = pricing[categoryId] + const usageItems = category.features.filter((it) => it.usage_based) + + return ( + + + + {category.title} + + + + {usageItems.map((item) => ( + + {item.title} + + {Array.isArray(item.plans['pro']) + ? item.plans['pro']?.join(', ') + : item.plans['pro']} + + + ))} + + ) + })} + +
    + + +
    +
    + + {isFreePlan && ( + snap.setPanelKey('subscriptionPlan')}> + View available plans + } - body={billingMetricCategories.map((categoryId) => { - const category = pricing[categoryId] - const usageItems = category.features.filter((it: any) => it.usage_based) + /> + )} + +
    +
    + {SPEND_CAP_OPTIONS.map((option) => { + const isSelected = selectedOption === option.value return ( - <> - - -

    {category.title}

    -
    - {null} -
    - {usageItems.map((item: any) => { - return ( - - -

    {item.title}

    -
    - -

    - {Array.isArray(item.plans['pro']) - ? item.plans['pro']?.join(', ') - : item.plans['pro']} -

    -
    -
    - ) - })} - + ) })} - /> - - - - {isFreePlan && ( - snap.setPanelKey('subscriptionPlan')}> - View available plans - - } - /> - )} - -
    -
    - {SPEND_CAP_OPTIONS.map((option) => { - const isSelected = selectedOption === option.value - - return ( -
    !isFreePlan && setSelectedOption(option.value)} - > - Spend Cap - -

    - {option.name} -

    -
    - ) - })} +
    + + {isBlockedByReplicas ? ( + + ) : selectedOption === 'on' ? ( + + ) : ( + + )} + + {hasChanges && !disabled && ( + <> +

    + {selectedOption === 'on' + ? 'Upon clicking confirm, spend cap will be enabled for your organization and you will no longer be charged any extra for usage.' + : 'Upon clicking confirm, spend cap will be disabled for your organization and you will be charged for any usage beyond the included quota.'} +

    +

    + Toggling spend cap triggers an invoice and there might be prorated charges for any + usage beyond the Pro Plans quota during this billing cycle. +

    + + )}
    + - {selectedOption === 'on' ? ( - - ) : ( - - )} - - {hasChanges && ( - <> -

    - {selectedOption === 'on' - ? 'Upon clicking confirm, spend cap will be enabled for your organization and you will no longer be charged any extra for usage.' - : 'Upon clicking confirm, spend cap will be disabled for your organization and you will be charged for any usage beyond the included quota.'} -

    -

    - Toggling spend cap triggers an invoice and there might be prorated charges for any - usage beyond the Pro Plans quota during this billing cycle. -

    - - )} -
    - - + + + + Confirm + + + + ) } - -export default SpendCapSidePanel From be1ba651ed6e1aa7769622e86812f92cb3c6df87 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 6 Oct 2026 11:12:53 +0800 Subject: [PATCH 39/41] Add branching nav items to cmd k (#51255) ## Context Adds a couple of branching nav items to Command K - Create new branch - Branch management - Merge requests - Github Connection (For branching) - Branching feedback Switch branch is still available, and only visible after a branch has been created (status quo) image --- .../BranchManagement/Branch.Commands.tsx | 63 +++++++++++++++++-- .../BranchManagement.constants.ts | 1 + .../BranchDropdownCommandContent.tsx | 3 +- .../ProjectSettings.Commands.tsx | 20 +++--- .../pages/project/[ref]/branches/index.tsx | 7 +-- .../project/[ref]/branches/merge-requests.tsx | 33 ++++++---- 6 files changed, 96 insertions(+), 31 deletions(-) create mode 100644 apps/studio/components/interfaces/BranchManagement/BranchManagement.constants.ts diff --git a/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx b/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx index 7e061b6de5b..b426fb729e6 100644 --- a/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx +++ b/apps/studio/components/interfaces/BranchManagement/Branch.Commands.tsx @@ -1,26 +1,37 @@ -import { Forward, GitBranch } from 'lucide-react' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { Forward, GitBranch, Plus } from 'lucide-react' import { PageType, useRegisterCommands, useRegisterPage, useSetPage } from 'ui-patterns/CommandMenu' import { COMMAND_MENU_SECTIONS } from '../App/CommandMenu/CommandMenu.utils' import { orderCommandSectionsByPriority } from '../App/CommandMenu/ordering' +import { BRANCHING_GITHUB_DISCUSSION_LINK } from './BranchManagement.constants' import { useBranchesQuery } from '@/data/branches/branches-query' +import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject' +import { useAppStateSnapshot } from '@/state/app-state' const SWITCH_BRANCH_PAGE_NAME = 'Switch branch' const EMPTY_ARRAY = [] as Array export function useBranchCommands() { const setPage = useSetPage() + const { setShowCreateBranchModal } = useAppStateSnapshot() const { data: selectedProject } = useSelectedProjectQuery() + const ref = selectedProject?.ref || '_' const isBranchingEnabled = selectedProject?.is_branch_enabled === true + const { can: canCreateBranches } = useAsyncCheckPermissions( + PermissionAction.CREATE, + 'preview_branches', + { resource: { is_default: false } } + ) + let { data: branches } = useBranchesQuery( - { - projectRef: selectedProject?.parent_project_ref || selectedProject?.ref, - }, + { projectRef: selectedProject?.parent_project_ref || selectedProject?.ref }, { enabled: isBranchingEnabled } ) + branches ??= EMPTY_ARRAY useRegisterPage( @@ -60,4 +71,48 @@ export function useBranchCommands() { sectionMeta: { priority: 3 }, } ) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.ACTIONS, + [ + { + id: 'create-branch', + name: 'Create branch', + value: 'Create branch, New branch', + action: () => setShowCreateBranchModal(true), + icon: () => , + }, + ], + { + enabled: canCreateBranches, + orderSection: orderCommandSectionsByPriority, + sectionMeta: { priority: 3 }, + } + ) + + useRegisterCommands( + COMMAND_MENU_SECTIONS.NAVIGATE, + [ + { + id: 'nav-branch-management', + name: 'Branch management', + route: `/project/${ref}/branches`, + }, + { + id: 'nav-branch-merge-requests', + name: 'Merge requests', + value: 'Branch', + route: `/project/${ref}/branches/merge-requests`, + }, + ], + { enabled: !!selectedProject } + ) + + useRegisterCommands(COMMAND_MENU_SECTIONS.NAVIGATE, [ + { + id: 'nav-branch-feedback', + name: 'Branching feedback', + route: BRANCHING_GITHUB_DISCUSSION_LINK, + }, + ]) } diff --git a/apps/studio/components/interfaces/BranchManagement/BranchManagement.constants.ts b/apps/studio/components/interfaces/BranchManagement/BranchManagement.constants.ts new file mode 100644 index 00000000000..d52b0fd0dc1 --- /dev/null +++ b/apps/studio/components/interfaces/BranchManagement/BranchManagement.constants.ts @@ -0,0 +1 @@ +export const BRANCHING_GITHUB_DISCUSSION_LINK = 'https://github.com/orgs/supabase/discussions/18937' diff --git a/apps/studio/components/layouts/AppLayout/BranchDropdownCommandContent.tsx b/apps/studio/components/layouts/AppLayout/BranchDropdownCommandContent.tsx index ac0d6d4397c..e77fef3876f 100644 --- a/apps/studio/components/layouts/AppLayout/BranchDropdownCommandContent.tsx +++ b/apps/studio/components/layouts/AppLayout/BranchDropdownCommandContent.tsx @@ -14,12 +14,11 @@ import { } from 'ui' import { BranchLink } from './BranchLink' +import { BRANCHING_GITHUB_DISCUSSION_LINK } from '@/components/interfaces/BranchManagement/BranchManagement.constants' import { CommandItemLink } from '@/components/ui/CommandItemLink' import type { Branch } from '@/data/branches/branches-query' import { useTrack } from '@/lib/telemetry/track' -const BRANCHING_GITHUB_DISCUSSION_LINK = 'https://github.com/orgs/supabase/discussions/18937' - export interface BranchDropdownCommandContentProps { embedded: boolean className?: string diff --git a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx index 9755bce0b57..aefdcb0b6c8 100644 --- a/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx +++ b/apps/studio/components/layouts/ProjectSettingsLayout/ProjectSettings.Commands.tsx @@ -45,7 +45,7 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { }, { id: 'nav-project-settings-general', - name: 'General Settings', + name: 'General settings', route: `/project/${ref}/settings/general`, defaultHidden: true, }, @@ -61,13 +61,13 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { : []), { id: 'nav-project-settings-database', - name: 'Database Settings', + name: 'Database settings', route: `/project/${ref}/database/settings`, defaultHidden: true, }, { id: 'nav-project-settings-auth', - name: 'Auth Settings', + name: 'Auth settings', route: authenticationSignInProviders ? `/project/${ref}/auth/providers` : `/project/${ref}/database/policies`, @@ -77,7 +77,7 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { ? [ { id: 'nav-project-settings-webhooks', - name: 'Project Webhooks', + name: 'Project webhooks', route: `/project/${ref}/settings/webhooks`, defaultHidden: true, } as IRouteCommand, @@ -95,13 +95,19 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { : []), { id: 'nav-project-settings-api', - name: 'API Settings', + name: 'API settings', route: `/project/${ref}/integrations/data_api/settings`, defaultHidden: true, }, + { + id: 'nav-project-settings-integration', + name: 'Project Integrations', + value: 'Branch integration, Vercel integration, GitHub integration, AWS PrivateLink', + route: `/project/${ref}/settings/integrations`, + }, { id: 'nav-project-settings-storage', - name: 'Storage Settings', + name: 'Storage settings', route: `/project/${ref}/storage/settings`, defaultHidden: true, }, @@ -109,7 +115,7 @@ export function useProjectSettingsGotoCommands(options?: CommandOptions) { ? [ { id: 'nav-project-settings-custom-domains', - name: 'Custom Domains', + name: 'Custom domains', route: `/project/${ref}/settings/general#custom-domains`, defaultHidden: true, } as IRouteCommand, diff --git a/apps/studio/pages/project/[ref]/branches/index.tsx b/apps/studio/pages/project/[ref]/branches/index.tsx index f16f58b939c..e40721ce812 100644 --- a/apps/studio/pages/project/[ref]/branches/index.tsx +++ b/apps/studio/pages/project/[ref]/branches/index.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/router' import { useState, type PropsWithChildren } from 'react' import { Button } from 'ui' +import { BRANCHING_GITHUB_DISCUSSION_LINK } from '@/components/interfaces/BranchManagement/BranchManagement.constants' import { DeleteBranchModal } from '@/components/interfaces/BranchManagement/DeleteBranchModal' import { Overview } from '@/components/interfaces/BranchManagement/Overview' import BranchLayout from '@/components/layouts/BranchLayout/BranchLayout' @@ -189,11 +190,7 @@ export const BranchesPageWrapper = ({ children }: PropsWithChildren) => { variant="text" icon={} > - + Branching feedback diff --git a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx index fa1d83f6f99..e98869a918e 100644 --- a/apps/studio/pages/project/[ref]/branches/merge-requests.tsx +++ b/apps/studio/pages/project/[ref]/branches/merge-requests.tsx @@ -12,9 +12,12 @@ import { DropdownMenuItem, DropdownMenuTrigger, Tooltip, + TooltipContent, + TooltipTrigger, } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { BRANCHING_GITHUB_DISCUSSION_LINK } from '@/components/interfaces/BranchManagement/BranchManagement.constants' import { BranchManagementSection, BranchRow, @@ -250,14 +253,22 @@ const MergeRequestsPage: NextPageWithLayout = () => { // whether the branch is linked to a GitHub PR. branch.review_requested_at && ( - - From b6d42390100757d643b355c3be871e3ce9a2e148 Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:20:55 +0000 Subject: [PATCH 40/41] Update Supplemental Terms placeholder text (#51301) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Requested by **Nicole Kramer** · [Slack thread](https://supabase.slack.com/archives/C0161K73J1J/p1790784371448999)_ ## Problem **Before:** The Supplemental Terms page (`/legal/customer-resources/supplemental-terms`) says "Content coming soon." **After:** The page shows "Check here for any feature specific terms that we may need to release, or clauses that allow for compliance with geography specific laws that we may introduce." ## Solution Replaces the placeholder body in `apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx` with the supplied text, as a plain paragraph. Nothing else changes. ## Review instructions 1. Open the Vercel preview link for `/legal/customer-resources/supplemental-terms`. 2. Confirm the page shows the new text instead of "Content coming soon." ## Checklist - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01K2vqszbsJiMJPABAnLMgaC --- _Generated by [Claude Code](https://claude.ai/code/session_01K2vqszbsJiMJPABAnLMgaC)_ Co-authored-by: Claude --- .../www/data/legal/customer-resources/supplemental-terms/v1.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx b/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx index 22e1c755629..94fbcd084cb 100644 --- a/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx +++ b/apps/www/data/legal/customer-resources/supplemental-terms/v1.mdx @@ -1 +1 @@ -_Content coming soon._ +Check here for any feature specific terms that we may need to release, or clauses that allow for compliance with geography specific laws that we may introduce. From 2538f7eb292dc6a5df4cc553e8bc59b96e5a88ed Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Tue, 6 Oct 2026 11:35:01 +0800 Subject: [PATCH 41/41] Fix unescaped SQL identifiers in row export (#51256) ## Context Resolves https://github.com/supabase/supabase/issues/49977 Addresses an issue in `formatTableRowsToSQL` to use `ident` for schema, table, and column name which will handle escaping of SQL identifiers. Can verify fix by creating a table like `test"table`, then adding some rows, and selecting either Copy as SQL or Export as SQL --- .../TableGridEditor/TableEntity.utils.test.ts | 32 +++++++++++++++---- .../TableGridEditor/TableEntity.utils.ts | 6 ++-- e2e/studio/features/table-editor.spec.ts | 4 +-- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts index e79948922f9..7e9777caf5d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts +++ b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.test.ts @@ -25,7 +25,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => { ] const result = formatTableRowsToSQL(table, rows) - const expected = `INSERT INTO "public"."people" ("id", "name") VALUES (1, 'Person 1'), (2, 'Person 2'), (3, 'Person 3');` + const expected = `INSERT INTO public.people (id, name) VALUES (1, 'Person 1'), (2, 'Person 2'), (3, 'Person 3');` expect(result).toBe(expected) }) @@ -49,7 +49,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => { ] const result = formatTableRowsToSQL(table, rows) - const expected = `INSERT INTO "public"."people" ("id", "name") VALUES (1, 'Person 1'), (2, null), (3, 'Person 3');` + const expected = `INSERT INTO public.people (id, name) VALUES (1, 'Person 1'), (2, null), (3, 'Person 3');` expect(result).toBe(expected) }) @@ -85,7 +85,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => { }, ] const result = formatTableRowsToSQL(table, rows) - const expected = `INSERT INTO "public"."demo" ("id", "name", "tags", "metadata") VALUES (2, 'Person 1', ARRAY['tag-a','tag-c'], '{"version": 1}'), (3, 'ONeil', ARRAY['tag-a'], '{"version": 1, "name": "O''Neil"}');` + const expected = `INSERT INTO public.demo (id, name, tags, metadata) VALUES (2, 'Person 1', ARRAY['tag-a','tag-c'], '{"version": 1}'), (3, 'ONeil', ARRAY['tag-a'], '{"version": 1, "name": "O''Neil"}');` expect(result).toBe(expected) }) @@ -116,7 +116,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => { ] const result = formatTableRowsToSQL(table, rows) - const expected = `INSERT INTO "storage"."buckets" ("id", "public", "avif_autodetection", "file_size_limit", "allowed_mime_types") VALUES ('emails', true, false, 10485760, ARRAY['image/*','image/o''neil']);` + const expected = `INSERT INTO storage.buckets (id, public, avif_autodetection, file_size_limit, allowed_mime_types) VALUES ('emails', true, false, 10485760, ARRAY['image/*','image/o''neil']);` expect(result).toBe(expected) }) @@ -133,7 +133,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => { const rows = [{ email: "o'neil@example.com" }] const result = formatTableRowsToSQL(table, rows) - const expected = `INSERT INTO "public"."users" ("email") VALUES ('o''neil@example.com');` + const expected = `INSERT INTO public.users (email) VALUES ('o''neil@example.com');` expect(result).toBe(expected) }) @@ -154,6 +154,26 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => { expect(result).toBe('') }) + it('should escape double quotes in schema, table and column identifiers', () => { + const table: SupaTable = { + id: 1, + type: ENTITY_TYPE.TABLE, + columns: [ + { name: 'id', dataType: 'bigint', format: 'int8', position: 0 }, + { name: 'we"ird', dataType: 'text', format: 'text', position: 1 }, + ], + name: 'pe"ople', + schema: 'pub"lic', + comment: undefined, + estimateRowCount: 1, + } + const rows = [{ id: 1, 'we"ird': 'value' }] + + const result = formatTableRowsToSQL(table, rows) + const expected = `INSERT INTO "pub""lic"."pe""ople" (id, "we""ird") VALUES (1, 'value');` + expect(result).toBe(expected) + }) + it('should remove the idx property', () => { const table: SupaTable = { id: 1, @@ -173,7 +193,7 @@ describe('TableEntity.utils: formatTableRowsToSQL', () => { ] const result = formatTableRowsToSQL(table, rows) - const expected = `INSERT INTO "public"."people" ("id", "name") VALUES (1, 'Person 1'), (2, 'Person 2');` + const expected = `INSERT INTO public.people (id, name) VALUES (1, 'Person 1'), (2, 'Person 2');` expect(result).toBe(expected) }) }) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts index dc09cc2b9d3..1b9bbb32e8e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts +++ b/apps/studio/components/interfaces/TableGridEditor/TableEntity.utils.ts @@ -1,3 +1,5 @@ +import { ident } from '@supabase/pg-meta' + import { SupaTable } from '@/components/grid/types' import { Lint } from '@/data/lint/lint-query' @@ -37,7 +39,7 @@ export const getTablePoliciesUrl = ( export const formatTableRowsToSQL = (table: SupaTable, rows: any[]) => { if (rows.length === 0) return '' - const columns = table.columns.map((col) => `"${col.name}"`).join(', ') + const columns = table.columns.map((col) => ident(col.name)).join(', ') const valuesSets = rows .map((row) => { @@ -78,7 +80,7 @@ export const formatTableRowsToSQL = (table: SupaTable, rows: any[]) => { }) .join(', ') - return `INSERT INTO "${table.schema}"."${table.name}" (${columns}) VALUES ${valuesSets};` + return `INSERT INTO ${ident(table.schema)}.${ident(table.name)} (${columns}) VALUES ${valuesSets};` } /** diff --git a/e2e/studio/features/table-editor.spec.ts b/e2e/studio/features/table-editor.spec.ts index c46a6cc57dd..5336a1bbf78 100644 --- a/e2e/studio/features/table-editor.spec.ts +++ b/e2e/studio/features/table-editor.spec.ts @@ -432,7 +432,7 @@ testRunner('table editor', () => { const downloadSqlPath = await downloadSql.path() const sqlContent = fs.readFileSync(downloadSqlPath, 'utf-8') expect(sqlContent).toContain( - `INSERT INTO "public"."${tableNameUpdated}" ("id", "created_at", "${columnNameUpdated}") VALUES` + `INSERT INTO public.${tableNameUpdated} (id, created_at, ${columnNameUpdated}) VALUES` ) expect(sqlContent).toContain('789') expect(sqlContent).toContain('456') @@ -734,7 +734,7 @@ testRunner('table editor', () => { const downloadSqlPath = await downloadSql.path() const sqlContent = fs.readFileSync(downloadSqlPath, 'utf-8') expect(sqlContent).toBe( - `INSERT INTO "public"."${tableNameDataActions}" ("id", "created_at", "pw_column") VALUES (4, '2025-01-01 12:00:00+00', 'value 4 to export'), (5, '2025-01-01 12:00:00+00', 'value 5 to export'), (6, '2025-01-01 12:00:00+00', 'value 6 to export');` + `INSERT INTO public.${tableNameDataActions} (id, created_at, pw_column) VALUES (4, '2025-01-01 12:00:00+00', 'value 4 to export'), (5, '2025-01-01 12:00:00+00', 'value 5 to export'), (6, '2025-01-01 12:00:00+00', 'value 6 to export');` ) await page.waitForTimeout(1000) // wait for event processing to complete fs.unlinkSync(downloadSqlPath)