Compare commits

...

7 Commits

Author SHA1 Message Date
GitHub Action
df48470961 [automated] Apply ESLint and Oxfmt fixes 2026-07-08 01:24:53 +00:00
luke-mino-altherr
5fcb7a3b05 [chore] Update Ingest API types from cloud@75c21eb 2026-07-08 01:21:09 +00:00
Benjamin Lu
95b121bed9 fix(website): unbreak website-e2e on main (card count, CTA overflow, stale goldens) (#13496)
## Summary

Fix the website-e2e job, red on main since 2026-07-07 15:19 UTC, by
updating the cloud model-card count test and regenerating stale visual
screenshot goldens.

## Context

website-e2e runs Playwright tests against the marketing site
(`apps/website`). It broke on main in two independent ways, so every PR
since — however unrelated — has shown a red website-e2e check:

1. **Model-card count.** #13431 added a sixth model card (GPT Image 2)
to the /cloud "AI models" section but didn't update the test that pins
the card count at 5. CI: `locator resolved to 6 elements`.
2. **Stale screenshot goldens.** #13431 also swapped the ProductCard CTA
to the shared `Button` (`whitespace-nowrap`, so e.g. "SEE ENTERPRISE
FEATURES" renders on one line instead of wrapping) and committed
matching `home-product-cards-*` goldens. Minutes later #13445 — a branch
cut from main *before* #13431 — ran the screenshot-regen workflow, which
checks out the raw PR branch, not the branch merged with main. Its
regenerated lg/xl goldens therefore depict the **old** pre-#13431 card
(wrapped label; pixel-identical layout to the pre-#13431 golden), and
overwrote #13431's correct ones at merge. #13445's own website-e2e was
red at merge time for exactly this reason. The sm/md goldens (last
captured by #13431, without #13445's `ppformula-text-center`
0.19em→0.1em nudge) went stale by ~900 px the moment #13445 landed.

## Changes

- **What**: `cloud.spec.ts` — model-card count assertion and test title
5 → 6 (verified against the 6 entries in `AIModelsSection.vue`; passes
locally).
- **What**: `ProductCard.vue` — `h-auto whitespace-normal` on the CTA
`Button`. The shared Button's `whitespace-nowrap` made long labels ("SEE
ENTERPRISE FEATURES") overflow past the card edge at lg/xl (live on prod
since #13431); labels now wrap inside the card as they did before
#13431.
- **What**: regenerated `home-product-cards-*` goldens via the `Update
Website Screenshots` workflow, run on this branch, so they capture the
fixed rendering rather than enshrining the overflow.

## Review Focus

- At lg, "SEE DESKTOP/CLOUD FEATURES" now also wrap to two lines: #13431
raised the CTA font from `text-xs` to `md:text-sm`, so those labels no
longer fit one line in the 200px content box either (pre-fix they
silently consumed the card padding). If design prefers one-liners,
shrinking the CTA font is a follow-up.
- Process gaps this incident exposed (follow-ups, not in this PR): the
regen workflow captures against the raw branch instead of the
main-merged result, and website-e2e was red on #13445 at merge without
blocking it.

---

*Six cards where five once stood,*
*a button's text sat where it should —*
*but pixels pinned in amber lied,*
*so Linux looked, and rectified.*

---------

Co-authored-by: github-actions <github-actions@github.com>
2026-07-07 17:59:21 -07:00
Wei Hai
baeb6df662 Gate signup submit on Turnstile being enabled, not enforced (#13463)
## Summary
- Shadow-mode Turnstile never blocked the signup Submit button on the
async Cloudflare challenge resolving, so most real submits raced ahead
of the widget and reached the backend with an empty token. This defeated
the point of shadow mode, which needs real tokens to measure the
false-positive rate before flipping to enforce.
- Submit is now blocked while the widget is enabled (shadow or enforce)
and has no token yet, in both modes.
- To keep a broken or slow Cloudflare load (network issue, ad-blocker,
CDN outage) from permanently blocking a legitimate signup,
`TurnstileWidget` now reports itself "unavailable" on a script-load
failure, a challenge error, or a 9s load timeout, and the form treats
that the same as shadow previously did: proceed without a token.

## Test plan
- [x] `vitest run` on `TurnstileWidget.test.ts` + `SignUpForm.test.ts`
(unit tests updated/added, all passing)
- [x] `pnpm typecheck` / eslint / oxlint / stylelint / oxfmt via
pre-commit hooks
- [ ] Manual click-through on staging to confirm no perceptible UX
regression during normal-latency challenge solves
- [ ] Confirm the 9s fallback timeout against real p95 Turnstile
challenge-solve latency

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-07-08 00:39:58 +00:00
CodeJuggernaut
e58b231664 fix: suppress the stray focus ring on the sidebar splitter panel (#13482)
Clicking empty space in a workspace panel focuses the whole PrimeVue
SplitterPanel (tabindex=-1 makes it click-focusable), and any following
non-chord keypress (e.g. Shift) trips the browser focus-visible
heuristic, painting the default blue ring around the entire panel.

The wrappers are not Tab-reachable (Tab lands on the controls inside,
never the panel box) and nothing focuses them programmatically, so the
ring conveys nothing. `focus-visible:outline-hidden` suppresses it while
keeping a forced-colors (High Contrast) indicator. Confirmed as noise
with design (Alex Tov).

Covers every click-focusable panel in
`LiteGraphCanvasSplitterOverlay.vue`: the sidebar panel (both
locations), the properties-side panel (both branches), and the bottom
panel. The center and graph-canvas panels are left untouched - they
inherit `pointer-events-none`, so a click can never focus them.

## Repro / QA

Ring trigger: click an empty, non-interactive spot inside the panel,
then press solo Shift. On main the browser paints a blue ring around the
whole panel; on this PR nothing appears. (Ctrl+Shift only triggers when
Shift lands first, hence the original "sometimes".)

| Panel | How to open | Fixed |
| --- | --- | --- |
| Sidebar (left, default) | Any rail icon, e.g. Assets | yes |
| Sidebar (right) | Settings > Sidebar Location > right | yes |
| Properties-side panel | Toggle properties panel / builder mode | yes |
| Bottom panel | Toggle Logs/Terminal | yes |
| Canvas / center | n/a | untouched - pointer-events-none, cannot be
click-focused |

- [ ] Each fixed panel: click empty spot, press Shift, no ring
- [ ] Same steps on main/prod show the ring (before-state)
- [ ] Tab still reaches controls inside each panel and their own focus
rings still show
- [ ] Media Assets shortcuts unchanged (Ctrl/Cmd+A, marquee modifiers) -
PR is CSS-only

- Surfaced during design review of #13323
2026-07-08 00:33:41 +00:00
Comfy Org PR Bot
545b48ee5b [chore] Update Ingest API types from cloud@421de6d (#12777)
## Automated Ingest API Type Update

This PR updates the Ingest API TypeScript types and Zod schemas from the
latest cloud OpenAPI specification.

- Cloud commit: 421de6d
- Generated using @hey-api/openapi-ts with Zod plugin

These types cover cloud-only endpoints (workspaces, billing, secrets,
assets, tasks, etc.).
Overlapping endpoints shared with the local ComfyUI Python backend are
excluded.

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: Benjamin Lu <benjaminlu1107@gmail.com>
Co-authored-by: GitHub Action <action@github.com>
2026-07-08 00:29:59 +00:00
Benjamin Lu
22ea53fb56 fix(ci): remove out-of-rootDir config files from package tsconfig includes (#13485)
## Summary

Type-aware oxlint rejects `packages/ingest-types/tsconfig.json` and
`packages/object-info-parser/tsconfig.json` as invalid (TS6059): their
`include` lists a root-level config file (`openapi-ts.config.ts` /
`vitest.config.ts`) that sits outside `rootDir: "src"`. This fails the
lint-and-format job with "Invalid tsconfig" on any PR that touches those
packages' src files — currently blocking every auto-generated
ingest-types sync (e.g. #12777).

## Changes

- **What**: Drop the out-of-`rootDir` config-file entries from the two
package tsconfig `include` arrays, matching the other workspace
packages. Repro: `pnpm exec oxlint --type-aware
packages/ingest-types/src/index.ts` fails before, passes after; the
config files themselves still lint clean.

## Review Focus

Neither package emits a build, so `rootDir`/`outDir` are
editor/lint-only; excluding the config files from the project has no
runtime effect (vitest/openapi-ts load their configs directly).
2026-07-07 17:03:12 -07:00
18 changed files with 1977 additions and 98 deletions

View File

@@ -40,7 +40,7 @@ test.describe('Cloud page @smoke', () => {
}
})
test('AIModelsSection heading and 5 model cards are visible', async ({
test('AIModelsSection heading and 6 model cards are visible', async ({
page
}) => {
const heading = page.getByRole('heading', { name: /leading AI models/i })
@@ -49,7 +49,7 @@ test.describe('Cloud page @smoke', () => {
const section = heading.locator('xpath=ancestor::section')
const grid = section.locator('.grid')
const modelCards = grid.locator('a[href="https://comfy.org/workflows"]')
await expect(modelCards).toHaveCount(5)
await expect(modelCards).toHaveCount(6)
})
test('AIModelsSection CTA links to workflows', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -30,7 +30,12 @@ const { title, description, cta, href, bg } = defineProps<{
<p class="text-sm text-white/70">
{{ description }}
</p>
<Button as="span" variant="default" size="sm" class="mt-4">
<Button
as="span"
variant="default"
size="sm"
class="mt-4 h-auto whitespace-normal"
>
{{ cta }}
</Button>
</div>

View File

@@ -12,6 +12,11 @@ export type {
AddAssetTagsErrors,
AddAssetTagsResponse,
AddAssetTagsResponses,
AdminDeleteHubWorkflowData,
AdminDeleteHubWorkflowError,
AdminDeleteHubWorkflowErrors,
AdminDeleteHubWorkflowResponse,
AdminDeleteHubWorkflowResponses,
Asset,
AssetCreated,
AssetCreatedWritable,
@@ -42,6 +47,11 @@ export type {
CancelJobErrors,
CancelJobResponse,
CancelJobResponses,
CancelJobsData,
CancelJobsError,
CancelJobsErrors,
CancelJobsResponse,
CancelJobsResponses,
CancelSubscriptionData,
CancelSubscriptionError,
CancelSubscriptionErrors,
@@ -84,6 +94,11 @@ export type {
CreateDeletionRequestErrors,
CreateDeletionRequestResponse,
CreateDeletionRequestResponses,
CreateDesktopLoginCodeData,
CreateDesktopLoginCodeError,
CreateDesktopLoginCodeErrors,
CreateDesktopLoginCodeResponse,
CreateDesktopLoginCodeResponses,
CreateHubAssetUploadUrlData,
CreateHubAssetUploadUrlError,
CreateHubAssetUploadUrlErrors,
@@ -186,12 +201,31 @@ export type {
DeleteWorkspaceResponses,
DeletionRequest,
DeletionStatus,
DesktopLoginCodeCreateRequest,
DesktopLoginCodeCreateResponse,
DesktopLoginCodeExchangeRequest,
DesktopLoginCodeExchangeResponse,
DesktopLoginCodeRedeemRequest,
DesktopLoginCodeRedeemResponse,
DownloadExportData,
DownloadExportError,
DownloadExportErrors,
DownloadExportResponse,
DownloadExportResponses,
EnsureWorkspaceBillingLegacySnapshot,
EnsureWorkspaceBillingProvisionedData,
EnsureWorkspaceBillingProvisionedError,
EnsureWorkspaceBillingProvisionedErrors,
EnsureWorkspaceBillingProvisionedRequest,
EnsureWorkspaceBillingProvisionedResponse,
EnsureWorkspaceBillingProvisionedResponse2,
EnsureWorkspaceBillingProvisionedResponses,
ErrorResponse,
ExchangeDesktopLoginCodeData,
ExchangeDesktopLoginCodeError,
ExchangeDesktopLoginCodeErrors,
ExchangeDesktopLoginCodeResponse,
ExchangeDesktopLoginCodeResponses,
ExchangeTokenData,
ExchangeTokenError,
ExchangeTokenErrors,
@@ -230,6 +264,11 @@ export type {
GetAssetByIdErrors,
GetAssetByIdResponse,
GetAssetByIdResponses,
GetAssetContentData,
GetAssetContentError,
GetAssetContentErrors,
GetAssetContentResponse,
GetAssetContentResponses,
GetAssetSeedStatusData,
GetAssetSeedStatusResponse,
GetAssetSeedStatusResponses,
@@ -303,6 +342,11 @@ export type {
GetHistoryData,
GetHistoryError,
GetHistoryErrors,
GetHistoryEventsData,
GetHistoryEventsError,
GetHistoryEventsErrors,
GetHistoryEventsResponse,
GetHistoryEventsResponses,
GetHistoryForPromptData,
GetHistoryForPromptError,
GetHistoryForPromptErrors,
@@ -345,8 +389,6 @@ export type {
GetJwksData,
GetJwksResponse,
GetJwksResponses,
GetLegacyAssetContentData,
GetLegacyAssetContentErrors,
GetLegacyHistoryByIdData,
GetLegacyHistoryByIdErrors,
GetLegacyHistoryData,
@@ -556,6 +598,7 @@ export type {
HistoryDetailEntry,
HistoryDetailResponse,
HistoryEntry,
HistoryEventRequest,
HistoryManageRequest,
HistoryResponse,
HubAssetUploadUrlRequest,
@@ -589,6 +632,8 @@ export type {
JobCancelResponse,
JobDetailResponse,
JobEntry,
JobsCancelRequest,
JobsCancelResponse,
JobsListResponse,
JobStatusResponse,
JwkKey,
@@ -627,7 +672,19 @@ export type {
ListJobsErrors,
ListJobsResponse,
ListJobsResponses,
ListLinkedFirebaseUidsData,
ListLinkedFirebaseUidsError,
ListLinkedFirebaseUidsErrors,
ListLinkedFirebaseUidsRequest,
ListLinkedFirebaseUidsResponse,
ListLinkedFirebaseUidsResponse2,
ListLinkedFirebaseUidsResponses,
ListMembersResponse,
ListSecretProvidersData,
ListSecretProvidersError,
ListSecretProvidersErrors,
ListSecretProvidersResponse,
ListSecretProvidersResponses,
ListSecretsData,
ListSecretsError,
ListSecretsErrors,
@@ -775,6 +832,17 @@ export type {
QueueInfo,
QueueManageRequest,
QueueManageResponse,
RedeemDesktopLoginCodeData,
RedeemDesktopLoginCodeError,
RedeemDesktopLoginCodeErrors,
RedeemDesktopLoginCodeResponse,
RedeemDesktopLoginCodeResponses,
ReleaseDeletionHoldData,
ReleaseDeletionHoldError,
ReleaseDeletionHoldErrors,
ReleaseDeletionHoldResponse,
ReleaseDeletionHoldResponses,
ReleaseHoldResponse,
RemoveAssetTagsData,
RemoveAssetTagsError,
RemoveAssetTagsErrors,
@@ -785,11 +853,23 @@ export type {
RemoveWorkspaceMemberErrors,
RemoveWorkspaceMemberResponse,
RemoveWorkspaceMemberResponses,
ReportHistoryEventData,
ReportHistoryEventError,
ReportHistoryEventErrors,
ReportHistoryEventResponse,
ReportHistoryEventResponses,
ReportPartnerUsageData,
ReportPartnerUsageError,
ReportPartnerUsageErrors,
ReportPartnerUsageResponse,
ReportPartnerUsageResponses,
ResolveSecretData,
ResolveSecretError,
ResolveSecretErrors,
ResolveSecretRequest,
ResolveSecretResponse,
ResolveSecretResponse2,
ResolveSecretResponses,
ResubscribeData,
ResubscribeError,
ResubscribeErrors,
@@ -808,6 +888,9 @@ export type {
RevokeWorkspaceInviteResponse,
RevokeWorkspaceInviteResponses,
SecretListResponse,
SecretProvider,
SecretProvidersResponse,
SecretResolveProvider,
SecretResponse,
SeedAssetsData,
SeedAssetsResponse,
@@ -819,6 +902,8 @@ export type {
SetReviewStatusResponse,
SetReviewStatusResponse2,
SetReviewStatusResponses,
ShortLinkRedirectData,
ShortLinkRedirectErrors,
SubmitFeedbackData,
SubmitFeedbackError,
SubmitFeedbackErrors,
@@ -848,6 +933,10 @@ export type {
TaskEntry,
TaskResponse,
TasksListResponse,
TeamCreditStop,
TeamCreditStopPrice,
TeamCreditStops,
TeamCreditStopSummary,
UpdateAssetData,
UpdateAssetError,
UpdateAssetErrors,
@@ -865,6 +954,7 @@ export type {
UpdateHubWorkflowRequest,
UpdateHubWorkflowResponse,
UpdateHubWorkflowResponses,
UpdateMemberRoleRequest,
UpdateMultipleSettingsData,
UpdateMultipleSettingsError,
UpdateMultipleSettingsErrors,
@@ -895,6 +985,11 @@ export type {
UpdateWorkspaceData,
UpdateWorkspaceError,
UpdateWorkspaceErrors,
UpdateWorkspaceMemberRoleData,
UpdateWorkspaceMemberRoleError,
UpdateWorkspaceMemberRoleErrors,
UpdateWorkspaceMemberRoleResponse,
UpdateWorkspaceMemberRoleResponses,
UpdateWorkspaceRequest,
UpdateWorkspaceResponse,
UpdateWorkspaceResponses,

File diff suppressed because it is too large Load Diff

View File

@@ -465,6 +465,20 @@ export const zCreateWorkflowRequest = z.object({
forked_from_workflow_version_id: z.string().optional()
})
/**
* Request body for forwarding a comfy-api audit/history event. Identify the target workspace by either user_id (cloud resolves the user's personal workspace via the converged identity, BE-1047) or an explicit workspace_id. At least one must be provided; workspace_id wins when both are set.
*/
export const zHistoryEventRequest = z.object({
user_id: z.string().optional(),
workspace_id: z.string().optional(),
event_type: z.string().min(1),
event_id: z.string().min(1),
params: z.record(z.unknown()).optional(),
auth_method: z.enum(['api_key', 'bearer_token']).optional(),
customer_ref: z.string().optional(),
timestamp: z.string().datetime().optional()
})
/**
* Response after recording partner usage data.
*/
@@ -540,11 +554,11 @@ export const zPaymentPortalRequest = z.object({
})
/**
* Response after successfully resubscribing to a billing plan.
* Response after accepting a resubscribe request.
*/
export const zResubscribeResponse = z.object({
billing_op_id: z.string(),
status: z.enum(['active']),
status: z.enum(['active', 'pending']),
message: z.string().optional()
})
@@ -585,6 +599,8 @@ export const zSubscribeResponse = z.object({
*/
export const zSubscribeRequest = z.object({
plan_slug: z.string(),
team_credit_stop_id: z.string().optional(),
billing_cycle: z.enum(['monthly', 'yearly']).optional(),
idempotency_key: z.string().optional(),
return_url: z.string().optional(),
cancel_url: z.string().optional()
@@ -626,7 +642,8 @@ export const zSubscriptionTier = z.enum([
'STANDARD',
'CREATOR',
'PRO',
'FOUNDERS_EDITION'
'FOUNDERS_EDITION',
'TEAM'
])
/**
@@ -714,6 +731,57 @@ export const zPreviewSubscribeRequest = z.object({
plan_slug: z.string()
})
/**
* Pre/post-discount price for a team credit stop, in cents.
*/
export const zTeamCreditStopPrice = z.object({
list_price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
price_cents: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* A selectable preset on the team pricing slider. Echoed on subscribe via
* team_credit_stop_id; the backend owns the resolved amounts. credits is a
* RAW monthly credit count (not cents). Save% is derived by the FE as
* (list_price_cents - price_cents) / list_price_cents.
*
*/
export const zTeamCreditStop = z.object({
id: z.string(),
credits: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
monthly: zTeamCreditStopPrice,
yearly: zTeamCreditStopPrice
})
/**
* Credit-stop ladder for the pricing slider (BE-1254). Returned by GET /api/billing/plans for every workspace regardless of the caller's token or workspace type (the personal/team distinction was removed); omitted only when the catalog defines no stops.
*/
export const zTeamCreditStops = z.object({
default_stop_index: z.number().int(),
stops: z.array(zTeamCreditStop)
})
/**
* Reason why a plan is unavailable
*/
@@ -773,7 +841,70 @@ export const zPlan = z.object({
*/
export const zBillingPlansResponse = z.object({
current_plan_slug: z.string().optional(),
plans: z.array(zPlan)
plans: z.array(zPlan),
team_credit_stops: zTeamCreditStops.optional()
})
/**
* The team credit stop a workspace is currently subscribed to: the
* per-workspace slider choice recorded at subscribe time
* (workspace_subscriptions.team_credit_stop_id). Amounts are owned by the
* catalog, not the subscription row. Returned on GET /api/billing/status
* for per-credit Team plans (BE-1254).
*
*/
export const zTeamCreditStopSummary = z.object({
id: z.string(),
credits_monthly: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
}),
stop_usd: z.coerce
.bigint()
.min(BigInt('-9223372036854775808'), {
message: 'Invalid value: Expected int64 to be >= -9223372036854775808'
})
.max(BigInt('9223372036854775807'), {
message: 'Invalid value: Expected int64 to be <= 9223372036854775807'
})
})
/**
* Identifier of an external provider whose user-supplied API key can be resolved through the internal secret-resolve flow.
*/
export const zSecretResolveProvider = z.enum(['gemini', 'runway'])
/**
* Decrypted secret value for the requested provider.
*/
export const zResolveSecretResponse = z.object({
provider: zSecretResolveProvider,
secret_value: z.string()
})
/**
* Request body for the internal secret-resolve endpoint. Carries only the provider; caller identity is derived from the forwarded credential.
*/
export const zResolveSecretRequest = z.object({
provider: zSecretResolveProvider
})
/**
* A provider the user may configure a secret for. The shape is deliberately minimal (identifier only) and reserved for future per-provider fields such as sub-keys.
*/
export const zSecretProvider = z.object({
id: z.string()
})
/**
* The providers available to the authenticated user in the current workspace.
*/
export const zSecretProvidersResponse = z.object({
data: z.array(zSecretProvider)
})
/**
@@ -813,7 +944,7 @@ export const zCreateSecretRequest = z.object({
})
/**
* A single billing event such as a charge, credit, or adjustment.
* A single history event. The cloud history-events store is the single source of truth for both billing events (charges, credits, adjustments) and user-facing usage events.
*/
export const zBillingEvent = z.object({
event_type: z.string(),
@@ -868,7 +999,8 @@ export const zBillingStatusResponse = z.object({
billing_status: zBillingStatus.optional(),
has_funds: z.boolean(),
cancel_at: z.string().datetime().optional(),
renewal_date: z.string().datetime().optional()
renewal_date: z.string().datetime().optional(),
team_credit_stop: zTeamCreditStopSummary.nullable()
})
/**
@@ -930,6 +1062,7 @@ export const zOAuthConsentChallenge = z.object({
csrf_token: z.string(),
client_display_name: z.string(),
resource_display_name: z.string(),
redirect_uri: z.string().url(),
scopes: z.array(z.string()),
workspaces: z.array(zOAuthConsentChallengeWorkspace)
})
@@ -1056,6 +1189,66 @@ export const zSyncApiKeyRequest = z.object({
customer_id: z.string().min(1)
})
/**
* The personal workspace's provisioned billing identity.
*/
export const zEnsureWorkspaceBillingProvisionedResponse = z.object({
workspace_id: z.string(),
stripe_customer_id: z.string(),
metronome_customer_id: z.string(),
metronome_contract_id: z.string()
})
/**
* The caller's already-resolved legacy (comfy-api) customer identity. When
* present and carrying provider IDs, provisioning ATTACHES this identity to
* the personal workspace (sharing the existing balance and subscription)
* instead of minting a net-new empty customer. Omit (or send with no
* provider IDs) for a free user with nothing to attach — provisioning then
* creates net-new. This closes the create-new-before-attach gap: a caller
* that already knows the legacy identity hands it over so the very first
* provisioning is an attach.
*
*/
export const zEnsureWorkspaceBillingLegacySnapshot = z.object({
stripe_customer_id: z.string().optional(),
metronome_customer_id: z.string().optional(),
metronome_contract_id: z.string().optional(),
has_funds: z.boolean().optional(),
subscription_tier: z.string().optional(),
legacy_stripe_subscription_id: z.string().optional(),
legacy_comfy_user_id: z.string().optional()
})
/**
* Request body for ensuring a user's personal workspace carries a fully
* provisioned billing identity. Sent by comfy-api's CreateCustomer (BE-1047)
* with the already canonical-resolved user identity.
*
*/
export const zEnsureWorkspaceBillingProvisionedRequest = z.object({
user_id: z.string().min(1),
email: z.string().email().min(1),
snapshot: zEnsureWorkspaceBillingLegacySnapshot.optional()
})
/**
* Firebase UIDs linked to the canonical comfy_user_id. Empty list when
* no mappings exist (not an error — callers can treat empty as "unknown
* canonical").
*
*/
export const zListLinkedFirebaseUidsResponse = z.object({
firebase_uids: z.array(z.string())
})
/**
* Request body for reverse-looking-up Firebase UIDs linked to a canonical comfy_user_id.
*/
export const zListLinkedFirebaseUidsRequest = z.object({
comfy_user_id: z.string().min(1)
})
/**
* Response confirming the validity and scope of a workspace API key.
*/
@@ -1172,7 +1365,8 @@ export const zMember = z.object({
name: z.string(),
email: z.string().email(),
role: z.enum(['owner', 'member']),
joined_at: z.string().datetime()
joined_at: z.string().datetime(),
is_original_owner: z.boolean()
})
/**
@@ -1183,6 +1377,13 @@ export const zListMembersResponse = z.object({
pagination: zPaginationInfo
})
/**
* Request body for changing a workspace member's role.
*/
export const zUpdateMemberRoleRequest = z.object({
role: z.enum(['owner', 'member'])
})
/**
* Request body for updating an existing workspace's settings.
*/
@@ -1227,6 +1428,60 @@ export const zWorkspace = z.object({
created_at: z.string().datetime()
})
/**
* Exchange poll result. Pending until the code is redeemed in the browser.
*/
export const zDesktopLoginCodeExchangeResponse = z.object({
status: z.enum(['pending', 'complete']),
custom_token: z.string().optional()
})
/**
* Request to exchange a redeemed login code for a custom token.
*/
export const zDesktopLoginCodeExchangeRequest = z.object({
code: z.string(),
code_verifier: z.string().min(43).max(128)
})
/**
* Result of redeeming a desktop login code.
*/
export const zDesktopLoginCodeRedeemResponse = z.object({
status: z.enum(['redeemed'])
})
/**
* Request to claim a desktop login code for the authenticated user.
*/
export const zDesktopLoginCodeRedeemRequest = z.object({
code: z.string()
})
/**
* A freshly minted desktop login code and its polling parameters.
*/
export const zDesktopLoginCodeCreateResponse = z.object({
code: z.string(),
expires_in: z.number().int(),
poll_interval: z.number().int()
})
/**
* Request to mint a desktop login code.
*/
export const zDesktopLoginCodeCreateRequest = z.object({
installation_id: z
.string()
.min(8)
.max(128)
.regex(/^[A-Za-z0-9._-]+$/)
.optional(),
platform: z.string().min(1).max(32),
app_version: z.string().min(1).max(64),
code_challenge: z.string().min(43).max(128)
})
/**
* Abbreviated workspace metadata used in list responses.
*/
@@ -1294,6 +1549,15 @@ export const zTasksListResponse = z.object({
pagination: zPaginationInfo
})
/**
* Result of authorizing a legal-hold release on a user's deletion.
*/
export const zReleaseHoldResponse = z.object({
firebase_id: z.string(),
released: z.boolean(),
message: z.string()
})
/**
* Current status of a user data deletion request.
*/
@@ -1363,6 +1627,20 @@ export const zJobDetailResponse = z.object({
execution_meta: z.record(z.unknown()).optional()
})
/**
* Response for POST /api/jobs/cancel.
*/
export const zJobsCancelResponse = z.object({
cancelled: z.array(z.string())
})
/**
* Request to cancel multiple jobs by ID.
*/
export const zJobsCancelRequest = z.object({
job_ids: z.array(z.string().uuid()).min(1).max(100)
})
/**
* Response for POST /api/jobs/{job_id}/cancel. Returned on both fresh cancels and idempotent no-ops.
*/
@@ -1529,6 +1807,7 @@ export const zAsset = z.object({
user_metadata: z.record(z.unknown()).optional(),
metadata: z.record(z.unknown()).readonly().optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -1624,6 +1903,7 @@ export const zSystemStatsResponse = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
comfyui_frontend_version: z.string().optional(),
workflow_templates_version: z.string().optional(),
cloud_version: z.string().optional(),
@@ -1962,6 +2242,7 @@ export const zAssetWritable = z.object({
tags: z.array(z.string()).optional(),
user_metadata: z.record(z.unknown()).optional(),
preview_url: z.string().url().optional(),
short_url: z.string().nullish(),
preview_id: z.string().uuid().nullish(),
job_id: z.string().uuid().nullish(),
created_at: z.string().datetime(),
@@ -2180,7 +2461,11 @@ export const zGetJobDetailData = z.object({
path: z.object({
job_id: z.string().uuid()
}),
query: z.never().optional()
query: z
.object({
short_link: z.enum(['ephemeral_tool_chain', 'default']).optional()
})
.optional()
})
/**
@@ -2201,6 +2486,17 @@ export const zCancelJobData = z.object({
*/
export const zCancelJobResponse = zJobCancelResponse
export const zCancelJobsData = z.object({
body: zJobsCancelRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success - cancel requests dispatched (or jobs were already terminal)
*/
export const zCancelJobsResponse = zJobsCancelResponse
export const zViewFileData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2558,6 +2854,17 @@ export const zInterruptJobData = z.object({
query: z.never().optional()
})
export const zResolveSecretData = z.object({
body: zResolveSecretRequest,
path: z.never().optional(),
query: z.never().optional()
})
export const zResolveSecretResponse2 = z.union([
zResolveSecretResponse,
z.void()
])
export const zListSecretsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -2580,6 +2887,17 @@ export const zCreateSecretData = z.object({
*/
export const zCreateSecretResponse = zSecretResponse
export const zListSecretProvidersData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Success
*/
export const zListSecretProvidersResponse = zSecretProvidersResponse
export const zDeleteSecretData = z.object({
body: z.never().optional(),
path: z.object({
@@ -2881,6 +3199,40 @@ export const zExchangeTokenData = z.object({
*/
export const zExchangeTokenResponse2 = zExchangeTokenResponse
export const zCreateDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeCreateRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Login code created
*/
export const zCreateDesktopLoginCodeResponse = zDesktopLoginCodeCreateResponse
export const zRedeemDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeRedeemRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Code redeemed (or already redeemed by the same user)
*/
export const zRedeemDesktopLoginCodeResponse = zDesktopLoginCodeRedeemResponse
export const zExchangeDesktopLoginCodeData = z.object({
body: zDesktopLoginCodeExchangeRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Pending (not yet redeemed) or complete with a custom token
*/
export const zExchangeDesktopLoginCodeResponse =
zDesktopLoginCodeExchangeResponse
export const zGetJwksData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3150,6 +3502,19 @@ export const zRemoveWorkspaceMemberData = z.object({
*/
export const zRemoveWorkspaceMemberResponse = z.void()
export const zUpdateWorkspaceMemberRoleData = z.object({
body: zUpdateMemberRoleRequest,
path: z.object({
userId: z.string()
}),
query: z.never().optional()
})
/**
* Member role updated
*/
export const zUpdateWorkspaceMemberRoleResponse = zMember
export const zListWorkspaceApiKeysData = z.object({
body: z.never().optional(),
path: z.never().optional(),
@@ -3236,6 +3601,19 @@ export const zSetReviewStatusData = z.object({
*/
export const zSetReviewStatusResponse2 = zSetReviewStatusResponse
export const zAdminDeleteHubWorkflowData = z.object({
body: z.never().optional(),
path: z.object({
share_id: z.string()
}),
query: z.never().optional()
})
/**
* Successfully deleted
*/
export const zAdminDeleteHubWorkflowResponse = z.void()
export const zUpdateHubWorkflowData = z.object({
body: zUpdateHubWorkflowRequest,
path: z.object({
@@ -3277,6 +3655,19 @@ export const zCreateDeletionRequestResponse = z.object({
user_found_in_cloud: z.boolean()
})
export const zReleaseDeletionHoldData = z.object({
body: z.object({
firebase_id: z.string()
}),
path: z.never().optional(),
query: z.never().optional()
})
/**
* Release authorized; the deletion workflow will proceed
*/
export const zReleaseDeletionHoldResponse = zReleaseHoldResponse
export const zReportPartnerUsageData = z.object({
body: zPartnerUsageRequest,
path: z.never().optional(),
@@ -3288,6 +3679,38 @@ export const zReportPartnerUsageData = z.object({
*/
export const zReportPartnerUsageResponse = zPartnerUsageResponse
export const zGetHistoryEventsData = z.object({
body: z.never().optional(),
path: z.never().optional(),
query: z
.object({
workspace_id: z.string().optional(),
user_id: z.string().optional(),
event_type: z.string().optional(),
start_date: z.string().datetime().optional(),
end_date: z.string().datetime().optional(),
page: z.number().int().optional(),
limit: z.number().int().optional()
})
.optional()
})
/**
* Paginated cloud history events for the workspace
*/
export const zGetHistoryEventsResponse = zBillingEventsResponse
export const zReportHistoryEventData = z.object({
body: zHistoryEventRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* History event recorded successfully
*/
export const zReportHistoryEventResponse = zPartnerUsageResponse
export const zUpdateSubscriptionCacheData = z.object({
body: z.object({
user_id: z.string(),
@@ -3305,6 +3728,29 @@ export const zUpdateSubscriptionCacheResponse = z.object({
status: z.string().optional()
})
export const zListLinkedFirebaseUidsData = z.object({
body: zListLinkedFirebaseUidsRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* Linked Firebase UIDs (possibly empty list)
*/
export const zListLinkedFirebaseUidsResponse2 = zListLinkedFirebaseUidsResponse
export const zEnsureWorkspaceBillingProvisionedData = z.object({
body: zEnsureWorkspaceBillingProvisionedRequest,
path: z.never().optional(),
query: z.never().optional()
})
/**
* The workspace's provisioned billing identity
*/
export const zEnsureWorkspaceBillingProvisionedResponse2 =
zEnsureWorkspaceBillingProvisionedResponse
export const zInsertDynamicConfigData = z.object({
body: z.record(z.unknown()),
path: z.never().optional(),
@@ -4010,6 +4456,14 @@ export const zGetModelPreviewData = z.object({
query: z.never().optional()
})
export const zShortLinkRedirectData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
})
export const zGetLegacyPromptByIdData = z.object({
body: z.never().optional(),
path: z.object({
@@ -4070,14 +4524,23 @@ export const zGetLegacyUserdataV2Data = z.object({
query: z.never().optional()
})
export const zGetLegacyAssetContentData = z.object({
export const zGetAssetContentData = z.object({
body: z.never().optional(),
path: z.object({
id: z.string()
}),
query: z.never().optional()
query: z
.object({
disposition: z.enum(['inline', 'attachment']).optional()
})
.optional()
})
/**
* Asset content stream (local runtime streams the bytes directly)
*/
export const zGetAssetContentResponse = z.string()
export const zGetLegacyViewMetadataData = z.object({
body: z.never().optional(),
path: z.object({

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "*.config.ts"]
"include": ["src/**/*"]
}

View File

@@ -4,5 +4,5 @@
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*", "vitest.config.ts"]
"include": ["src/**/*"]
}

View File

@@ -35,10 +35,10 @@
:class="
sidebarLocation === 'left'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'left' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE
@@ -82,7 +82,7 @@
</SplitterPanel>
<SplitterPanel
v-show="bottomPanelVisible && !focusMode"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg"
class="bottom-panel pointer-events-auto max-w-full overflow-x-auto rounded-lg border border-(--p-panel-border-color) bg-comfy-menu-bg focus-visible:outline-hidden"
>
<slot name="bottom-panel" />
</SplitterPanel>
@@ -95,10 +95,10 @@
:class="
sidebarLocation === 'right'
? cn(
'side-bar-panel pointer-events-auto bg-comfy-menu-bg',
'side-bar-panel pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden',
sidebarPanelVisible && 'min-w-78'
)
: 'pointer-events-auto bg-comfy-menu-bg'
: 'pointer-events-auto bg-comfy-menu-bg focus-visible:outline-hidden'
"
:min-size="
sidebarLocation === 'right' ? SIDEBAR_MIN_SIZE : BUILDER_MIN_SIZE

View File

@@ -7,7 +7,7 @@ import Password from 'primevue/password'
import PrimeVue from 'primevue/config'
import ProgressSpinner from 'primevue/progressspinner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { defineComponent, h, nextTick, ref } from 'vue'
import { computed, defineComponent, h, nextTick, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json' with { type: 'json' }
@@ -38,29 +38,45 @@ vi.mock('@/stores/authStore', () => ({
}))
const mockTurnstileEnabled = ref(false)
const mockTurnstileEnforced = ref(false)
const mockTurnstileToken = ref('')
const mockTurnstileUnavailable = ref(false)
const mockReset = vi.fn()
let emitTurnstileToken: ((token: string) => void) | undefined
let emitTurnstileUnavailable: ((unavailable: boolean) => void) | undefined
// The reset-on-toggle behavior lives in useTurnstileGate itself (see
// useTurnstile.test.ts); this fake just wires token/unavailable through to
// `waiting` the same way so SignUpForm's submit gating can be exercised.
vi.mock('@/composables/auth/useTurnstile', () => ({
useTurnstile: () => ({
enabled: mockTurnstileEnabled,
enforced: mockTurnstileEnforced
enabled: mockTurnstileEnabled
}),
useTurnstileGate: () => ({
token: mockTurnstileToken,
unavailable: mockTurnstileUnavailable,
waiting: computed(
() =>
mockTurnstileEnabled.value &&
!mockTurnstileToken.value &&
!mockTurnstileUnavailable.value
)
})
}))
// Stub the real widget (which loads the external Turnstile script) with one that
// exposes a spyable reset() and lets a test drive the v-model token the way a
// solved challenge would.
// exposes a spyable reset() and lets a test drive the v-model token/unavailable
// the way a solved challenge (or a broken/slow widget) would.
vi.mock('./TurnstileWidget.vue', async () => {
const { defineComponent: defineMock } = await import('vue')
return {
default: defineMock({
name: 'TurnstileWidget',
emits: ['update:token'],
emits: ['update:token', 'update:unavailable'],
setup(_, { expose, emit }) {
expose({ reset: mockReset })
emitTurnstileToken = (token: string) => emit('update:token', token)
emitTurnstileUnavailable = (unavailable: boolean) =>
emit('update:unavailable', unavailable)
return () => null
}
})
@@ -92,9 +108,11 @@ describe('SignUpForm', () => {
beforeEach(() => {
mockLoadingRef.value = false
mockTurnstileEnabled.value = false
mockTurnstileEnforced.value = false
mockTurnstileToken.value = ''
mockTurnstileUnavailable.value = false
mockReset.mockClear()
emitTurnstileToken = undefined
emitTurnstileUnavailable = undefined
})
afterEach(() => {
@@ -211,43 +229,22 @@ describe('SignUpForm', () => {
})
})
describe('Turnstile token hygiene', () => {
it('clears the stale token when Turnstile becomes disabled', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const { user } = renderComponent()
await fillValidSignup(user)
emitTurnstileToken!('stale-token')
await nextTick()
expect(
screen.getByRole('button', { name: signUpButton })
).not.toBeDisabled()
mockTurnstileEnabled.value = false
await nextTick()
// re-enable: the stale token must have been cleared so submit is blocked again
mockTurnstileEnabled.value = true
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
})
// Regression coverage for the shadow-mode race: previously submit was only
// gated in 'enforce' mode, so most real signups in 'shadow' mode raced
// ahead of the async Cloudflare challenge and reached the backend with an
// empty token. Gating now depends only on whether the widget is enabled
// (shadow or enforce both render it), so both modes behave identically here.
describe('Turnstile submit gating', () => {
it('disables the submit button in enforce mode until a token is present', async () => {
it('disables the submit button until a token is present', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
renderComponent()
await nextTick()
expect(screen.getByRole('button', { name: signUpButton })).toBeDisabled()
})
it('does not emit submit in enforce mode while the token is empty', async () => {
it('does not emit submit while the token is empty', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -257,9 +254,8 @@ describe('SignUpForm', () => {
expect(onSubmit).not.toHaveBeenCalled()
})
it('emits submit with the token in enforce mode once the challenge is solved', async () => {
it('emits submit with the token once the challenge is solved', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = true
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
@@ -271,13 +267,14 @@ describe('SignUpForm', () => {
expect(onSubmit).toHaveBeenCalledWith(expectedValues, 'token-xyz')
})
it('emits submit without a token in shadow mode (never blocks)', async () => {
it('emits submit without a token once the widget reports itself unavailable (broken/slow load fallback)', async () => {
mockTurnstileEnabled.value = true
mockTurnstileEnforced.value = false
const onSubmit = vi.fn()
const { user } = renderComponent({ onSubmit })
await fillValidSignup(user)
emitTurnstileUnavailable!(true)
await nextTick()
await user.click(screen.getByRole('button', { name: signUpButton }))
expect(onSubmit).toHaveBeenCalledWith(expectedValues, undefined)

View File

@@ -33,10 +33,11 @@
v-if="turnstileEnabled"
ref="turnstileWidget"
v-model:token="turnstileToken"
v-model:unavailable="turnstileUnavailable"
/>
<small
v-show="submitBlockedByTurnstile"
v-show="waitingForTurnstile"
id="comfy-org-sign-up-turnstile-hint"
role="status"
aria-live="polite"
@@ -51,11 +52,9 @@
v-else
type="submit"
class="mt-4 h-10 font-medium"
:disabled="!$form.valid || submitBlockedByTurnstile"
:disabled="!$form.valid || waitingForTurnstile"
:aria-describedby="
submitBlockedByTurnstile
? 'comfy-org-sign-up-turnstile-hint'
: undefined
waitingForTurnstile ? 'comfy-org-sign-up-turnstile-hint' : undefined
"
>
{{ t('auth.signup.signUpButton') }}
@@ -70,11 +69,11 @@ import { zodResolver } from '@primevue/forms/resolvers/zod'
import { useThrottleFn } from '@vueuse/core'
import InputText from 'primevue/inputtext'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, useTemplateRef, watch } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { useTurnstile } from '@/composables/auth/useTurnstile'
import { useTurnstile, useTurnstileGate } from '@/composables/auth/useTurnstile'
import { signUpSchema } from '@/schemas/signInSchema'
import type { SignUpData } from '@/schemas/signInSchema'
import { useAuthStore } from '@/stores/authStore'
@@ -86,25 +85,21 @@ const { t } = useI18n()
const authStore = useAuthStore()
const loading = computed(() => authStore.loading)
const { enabled: turnstileEnabled, enforced: turnstileEnforced } =
useTurnstile()
const turnstileToken = ref('')
const { enabled: turnstileEnabled } = useTurnstile()
const {
token: turnstileToken,
unavailable: turnstileUnavailable,
waiting: waitingForTurnstile
} = useTurnstileGate(turnstileEnabled)
const turnstileWidget =
useTemplateRef<InstanceType<typeof TurnstileWidget>>('turnstileWidget')
const submitBlockedByTurnstile = computed(
() => turnstileEnforced.value && !turnstileToken.value
)
watch(turnstileEnabled, (on) => {
if (!on) turnstileToken.value = ''
})
const emit = defineEmits<{
submit: [values: SignUpData, turnstileToken?: string]
}>()
const onSubmit = useThrottleFn((event: FormSubmitEvent) => {
if (event.valid && !submitBlockedByTurnstile.value) {
if (event.valid && !waitingForTurnstile.value) {
emit(
'submit',
event.values as SignUpData,

View File

@@ -261,4 +261,138 @@ describe('TurnstileWidget', () => {
expect(api.remove).toHaveBeenCalledWith('widget-id')
})
// A widget that never resolves (broken script, ad-blocker, CDN outage, or a
// hung challenge) must eventually tell the parent it cannot be relied on,
// so submission can fall back instead of blocking a legitimate signup
// forever.
describe('unavailable fallback', () => {
it('reports unavailable when the Turnstile script fails to load', async () => {
mockLoadTurnstile.mockRejectedValue(new Error('script failed'))
const { emitted } = renderWidget()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('reports unavailable on a challenge error', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
})
it('clears the unavailable fallback once a token is solved', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await flush()
options()!['error-callback']!()
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
options()!.callback!('token-abc')
await flush()
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
})
it('falls back once the widget fails to resolve within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
// Let the onMounted hook's `await loadTurnstile()` microtask settle
// and render() run, without yet advancing to the timeout itself.
await vi.advanceTimersByTimeAsync(0)
expect(options()).toBeDefined()
expect(emitted()['update:unavailable']).toBeUndefined()
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
it('does not fall back once a token arrives before the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
options()!.callback!('token-abc')
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']).toBeUndefined()
} finally {
vi.useRealTimers()
}
})
it('resets the widget to fetch a fresh challenge on token expiry', async () => {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
renderWidget()
await flush()
options()!.callback!('token-abc')
options()!['expired-callback']!()
await flush()
expect(api.reset).toHaveBeenCalledWith('widget-id')
})
it('falls back if a post-solve expiry is not followed by a fresh token within the load timeout', async () => {
vi.useFakeTimers()
try {
const { api, options } = fakeTurnstile()
mockLoadTurnstile.mockResolvedValue(api)
window.turnstile = api as unknown as NonNullable<Window['turnstile']>
const { emitted } = renderWidget()
await vi.advanceTimersByTimeAsync(0)
// Establish a solved, available widget: an initial error marks it
// unavailable, then solving a challenge clears that (the same
// transition the existing "clears the unavailable fallback" test
// verifies), so the expiry below is the only thing driving fallback.
options()!['error-callback']!()
options()!.callback!('token-abc')
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
// The token later expires (e.g. tab backgrounded past its ~300s
// lifetime) without the widget itself erroring.
options()!['expired-callback']!()
await vi.advanceTimersByTimeAsync(0)
// A fresh challenge was requested, but nothing solves it before the
// re-armed load timeout elapses, so submission must eventually be
// unblocked rather than staying stuck forever.
expect(emitted()['update:unavailable']?.at(-1)).toEqual([false])
await vi.advanceTimersByTimeAsync(9_000)
expect(emitted()['update:unavailable']?.at(-1)).toEqual([true])
} finally {
vi.useRealTimers()
}
})
})
})

View File

@@ -12,6 +12,7 @@
</template>
<script setup lang="ts">
import { useTimeoutFn } from '@vueuse/core'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -20,6 +21,14 @@ import { getTurnstileSiteKey } from '@/config/turnstile'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
const token = defineModel<string>('token', { default: '' })
/**
* Set true whenever the widget cannot be relied on to ever produce a token:
* the Cloudflare script failed to load, the rendered challenge errored out,
* or it simply hasn't resolved within `TURNSTILE_LOAD_TIMEOUT_MS`. The parent
* uses this to stop waiting on a token so a broken/slow widget (network
* issue, ad-blocker, CDN outage) can never permanently block signup.
*/
const unavailable = defineModel<boolean>('unavailable', { default: false })
const { t } = useI18n()
const colorPaletteStore = useColorPaletteStore()
@@ -28,6 +37,16 @@ const containerRef = ref<HTMLDivElement>()
const errorMessage = ref('')
let widgetId: string | undefined
/** How long to wait for the widget to resolve before falling back. */
const TURNSTILE_LOAD_TIMEOUT_MS = 9_000
const { start: armTimeout, stop: clearLoadTimeout } = useTimeoutFn(
() => {
unavailable.value = true
},
TURNSTILE_LOAD_TIMEOUT_MS,
{ immediate: false }
)
const clearToken = () => {
token.value = ''
}
@@ -46,12 +65,18 @@ const reset = () => {
errorMessage.value = ''
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A widget that renders can request a fresh challenge, so give it
// another chance before falling back again.
unavailable.value = false
armTimeout()
}
}
defineExpose({ reset })
onMounted(async () => {
armTimeout()
try {
const turnstile = await loadTurnstile()
if (!containerRef.value) return
@@ -64,23 +89,37 @@ onMounted(async () => {
sitekey: getTurnstileSiteKey(),
theme,
callback: (newToken: string) => {
clearLoadTimeout()
errorMessage.value = ''
unavailable.value = false
token.value = newToken
},
'expired-callback': () => {
clearToken()
errorMessage.value = t('auth.turnstile.expired')
if (widgetId && window.turnstile) {
window.turnstile.reset(widgetId)
// A solved token can expire on its own (e.g. the tab was
// backgrounded past the token's ~300s lifetime) without the widget
// ever erroring, so proactively request a fresh challenge and
// re-arm the load timeout in case it doesn't resolve in time.
armTimeout()
}
},
'error-callback': () => {
clearToken()
clearLoadTimeout()
console.warn('Turnstile challenge failed')
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
if (widgetId && window.turnstile) window.turnstile.reset(widgetId)
}
})
} catch (error) {
clearLoadTimeout()
console.warn('Turnstile failed to load', error)
errorMessage.value = t('auth.turnstile.failed')
unavailable.value = true
}
})

View File

@@ -1,9 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import {
isTurnstileEnabled,
normalizeTurnstileMode,
useTurnstile
useTurnstile,
useTurnstileGate
} from '@/composables/auth/useTurnstile'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { remoteConfig } from '@/platform/remoteConfig/remoteConfig'
@@ -137,3 +139,63 @@ describe('useTurnstile', () => {
})
})
})
describe('useTurnstileGate', () => {
it('waits while enabled with no token yet', () => {
const { waiting } = useTurnstileGate(ref(true))
expect(waiting.value).toBe(true)
})
it('never waits while disabled', () => {
const { waiting } = useTurnstileGate(ref(false))
expect(waiting.value).toBe(false)
})
it('stops waiting once a token arrives', () => {
const { token, waiting } = useTurnstileGate(ref(true))
token.value = 'token-abc'
expect(waiting.value).toBe(false)
})
it('stops waiting once the widget reports itself unavailable', () => {
const { unavailable, waiting } = useTurnstileGate(ref(true))
unavailable.value = true
expect(waiting.value).toBe(false)
})
it('clears stale token/unavailable state when enabled turns off', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
token.value = 'stale-token'
unavailable.value = true
enabled.value = false
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
// Regression coverage: the reset used to only run on the enabled->disabled
// transition, so state written while the widget was briefly disabled could
// survive into the next enabled widget instance.
it('clears stale token/unavailable state when enabled turns back on', async () => {
const enabled = ref(true)
const { token, unavailable } = useTurnstileGate(enabled)
enabled.value = false
await nextTick()
token.value = 'stale-token'
unavailable.value = true
enabled.value = true
await nextTick()
expect(token.value).toBe('')
expect(unavailable.value).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import { computed } from 'vue'
import { computed, ref, watch } from 'vue'
import type { Ref } from 'vue'
import { getTurnstileSiteKey } from '@/config/turnstile'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
@@ -42,3 +43,31 @@ export function useTurnstile() {
return { mode, siteKey, enabled, enforced }
}
/**
* Submit-gating state for the signup form's Turnstile widget: a token/
* unavailable pair, plus `waiting`, which is true while a real token is still
* needed. Waits in both shadow and enforce mode (`enabled`), not just
* `enforced`, so shadow mode's token can't race the async Cloudflare
* challenge; falls back open once the widget reports `unavailable` so a
* broken/slow load can never permanently block signup.
*
* `token`/`unavailable` reset on every `enabled` transition, in either
* direction, so state from a previous widget instance can never leak into a
* freshly (re-)rendered one.
*/
export function useTurnstileGate(enabled: Ref<boolean>) {
const token = ref('')
const unavailable = ref(false)
const waiting = computed(
() => enabled.value && !token.value && !unavailable.value
)
watch(enabled, () => {
token.value = ''
unavailable.value = false
})
return { token, unavailable, waiting }
}