@vtex/faststore 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ Every published version of `@vtex/faststore` has a section here. From `0.0.2` on
4
4
  written when its release pull request opens, and reviewed alongside the API delta that justifies the
5
5
  version.
6
6
 
7
+ ## 0.0.2
8
+
9
+ No change to the public API surface across the four snapshotted entries (`.`, `./client`,
10
+ `./components`, `./graphql`). The code is identical to `0.0.1`.
11
+
12
+ This is the first release cut through the gated pipeline: `release-checks.yml` validates this pull
13
+ request, and `npm-publish.yml` publishes and tags `v0.0.2-faststore` on merge. It supersedes the
14
+ hand-published `0.0.1` bootstrap as the baseline every later release diffs against.
15
+
7
16
  ## 0.0.1
8
17
 
9
18
  Bootstrap release. It exists so that `@vtex/faststore` has a page on npmjs, which npm requires
@@ -60,7 +60,7 @@ Content model (`account-entry-point`) fields, edited in the CMS:
60
60
 
61
61
  **One element, two links, chosen by the session.** `loggedIn` renders the signed-in link; `anonymous` and `identified` both render the signed-out one, because an identified but unauthenticated shopper cannot reach their account yet. There is no prop that selects between them — the merchant authors one element and the session decides.
62
62
 
63
- **The destinations are not authored.** Sign in goes to `/login`, carrying a `returnUrl` with the current path so the deferred proxy can return the shopper where they started; the account link goes to the platform's account route. Both are module constants, so no schema edit and no CMS entry can misroute the auth flow. The `returnUrl` value is constrained to a same-origin relative path and dropped otherwise, which keeps the proxy from being turned into an open redirect — though the proxy has to validate it again on its own side. The signed-out link also opts out of Next.js prefetching: a header control is always in the viewport, and the destination is an auth proxy that may set a cookie or redirect, so the default would fire that request on every page view.
63
+ **The destinations are not authored.** Sign in goes to `/login`, carrying a `returnUrl` with the current path so the shopper returns where they started; the account link goes to the storefront's own `/account`. Both are fixed constants, so no schema edit and no CMS entry can misroute the auth flow. The sign-in destination comes from the shared `resolveSignInHref` (`utils/safe-href.ts`) rather than from this component, so any other surface that has to send a signed-out shopper to sign in reaches the same place by the same rule. The `returnUrl` value is constrained to a same-origin relative path, which keeps the sign-in route from being turned into an open redirect; a path the narrowing refuses drops the parameter, and so does a router reporting no path, leaving a bare `/login` rather than a broken link. That is the sender's half of the guarantee. Nothing stops a hand-crafted link, so the receiving half — narrowing the parameter through `resolveSiteRelativeHref` on arrival — belongs to the route that will serve `/login`. The signed-out link also opts out of Next.js prefetching: a header control is always in the viewport, and the destination is an auth route rather than a page, so it can set a cookie or redirect and the default would fire that request on every page view.
64
64
 
65
65
  **`returnUrl` carries the path only — not the query string, not the hash.** A shopper signing in from `/s?q=tenis` or a filtered listing returns to the bare path. This is a deliberate narrowing rather than a limitation of the router: recovering the query would mean reading it through `useSearchParams()` and wrapping the header in a Suspense boundary, and the hash never leaves the browser at all. Search and filter state is cheap to re-apply and the sign-in round trip is rare, so the entry point keeps the simpler shape. Revisit it if merchants report losing filters across sign-in.
66
66
 
@@ -4,14 +4,14 @@ import { defineContentType } from "../../core/dist/define-content-type.js";
4
4
  import "../../core/dist/index.js";
5
5
  import { useSession } from "../session/session.js";
6
6
  import { Icon } from "../../particles/icon/icon.js";
7
- import { resolveSafeHref } from "../../utils/safe-href.js";
7
+ import { resolveSignInHref } from "../../utils/safe-href.js";
8
8
  import { NavigationLink } from "../../particles/navigation-link/navigation-link.js";
9
9
  import { useEffect, useState } from "react";
10
10
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
11
11
  import { usePathname } from "next/navigation";
12
12
  //#region src/matter/account-entry-point/account-entry-point.tsx
13
- /** Deferred IO proxy. A fixed platform convention, never an authored value. */
14
- const SIGN_IN_ROUTE = "/login";
13
+ /** The account's root. A fixed convention, never an authored value: an authored destination could
14
+ * misroute the entry into the account. */
15
15
  const ACCOUNT_ROUTE = "/account";
16
16
  const AccountEntryPointContent = defineContentType({
17
17
  id: "account-entry-point",
@@ -74,18 +74,6 @@ const AccountEntryPointDefinition = defineComponent({
74
74
  },
75
75
  guideline: "Session-aware header control rendering one link: the signed-out label toward /login for anonymous and identified shoppers, the signed-in label toward the account route for logged-in ones, and a politely announced placeholder while the session resolves. Both destinations are fixed conventions, not props, so no authored value can misroute the auth flow. Must be inside a SessionRoot, normally the one hosted by the shared layout — a page-hosted provider resolves the session server-side without the shopper cookie and sticks on the signed-out label. Author the label for a state or that state renders nothing; the labels are optional in the type only because a CMS entry may supply them, and an entry overrides all four fields at runtime. Style it through data-fs-account-entry-point and switch on data-fs-account-entry-point-state."
76
76
  });
77
- /**
78
- * Builds the sign-in destination, carrying the current path so the deferred proxy can return the
79
- * shopper where they started. Defense in depth only: the proxy has to validate the parameter on its
80
- * own side, since nothing stops a hand-crafted link. A path `resolveSafeHref` refuses — protocol-
81
- * relative, scheme-smuggled, absolute — drops the parameter instead of travelling encoded.
82
- */
83
- function signInHref(pathname) {
84
- if (!pathname) return SIGN_IN_ROUTE;
85
- const destination = resolveSafeHref(pathname);
86
- if (!destination.safe || !destination.href.startsWith("/")) return SIGN_IN_ROUTE;
87
- return `${SIGN_IN_ROUTE}?returnUrl=${encodeURIComponent(destination.href)}`;
88
- }
89
77
  function AccountEntryPoint({ signedOutLabel, signedInLabel, icon, loadingLabel, $content, className, ref, ...props }) {
90
78
  const { state, loading } = useSession();
91
79
  const pathname = usePathname();
@@ -108,7 +96,7 @@ function AccountEntryPoint({ signedOutLabel, signedInLabel, icon, loadingLabel,
108
96
  return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsxs(NavigationLink, {
109
97
  ...props,
110
98
  ref,
111
- href: signedIn ? ACCOUNT_ROUTE : signInHref(pathname),
99
+ href: signedIn ? ACCOUNT_ROUTE : resolveSignInHref(pathname),
112
100
  prefetch: signedIn ? void 0 : false,
113
101
  className,
114
102
  "data-fs-account-entry-point": "",
@@ -1 +1 @@
1
- {"version":3,"file":"account-entry-point.js","names":[],"sources":["../../../src/matter/account-entry-point/account-entry-point.tsx"],"sourcesContent":["'use client';\n\nimport { type ContentValue, defineComponent, defineContentType } from '@vtex/fs-core';\nimport { usePathname } from 'next/navigation';\nimport { type ComponentPropsWithRef, useEffect, useState } from 'react';\nimport { Icon } from '../../particles/icon/icon.js';\nimport { NavigationLink } from '../../particles/navigation-link/navigation-link.js';\nimport { resolveSafeHref } from '../../utils/safe-href.js';\nimport { useSession } from '../session/session.js';\n\n/** Deferred IO proxy. A fixed platform convention, never an authored value. */\nconst SIGN_IN_ROUTE = '/login';\n\n// Provisional: unlike `/login`, no route for the FastStore-native My Account page is settled yet.\n// Pin this with the My Account epic before the signed-in link is anything but dead.\nconst ACCOUNT_ROUTE = '/account';\n\n// Nothing is required: an entry may author any subset, and a slice missing a required property is\n// shape-invalid, which drops the whole element rather than the one field.\nexport const AccountEntryPointContent = defineContentType({\n id: 'account-entry-point',\n title: 'Account Entry Point',\n description: 'Labels and icon for the header sign-in / account control.',\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n signedOutLabel: { type: 'string', minLength: 1, title: 'Signed-out label' },\n signedInLabel: { type: 'string', minLength: 1, title: 'Signed-in label' },\n icon: { type: 'string', minLength: 1, title: 'Icon' },\n loadingLabel: { type: 'string', minLength: 1, title: 'Loading announcement' },\n },\n },\n});\n\n/** Props for {@link AccountEntryPoint}. Extends native `<a>` attributes (the rendered root). */\nexport type AccountEntryPointProps = Omit<ComponentPropsWithRef<'a'>, 'href' | 'children'> & {\n /** Label for anonymous and identified shoppers. Optional: the render gate, not the type,\n * requires a resolved value. */\n signedOutLabel?: string;\n /** Label for logged-in shoppers. Same gate. */\n signedInLabel?: string;\n /** Icon reference for the glyph. Never gated: a control with no glyph is a valid configuration. */\n icon?: string;\n /** Text the live region announces while the session resolves. */\n loadingLabel?: string;\n /** Merchant-authored fields, sliced from the page/layout content entry; win over the props field\n * by field when present. Absent in a non-CMS store — the props are the fallback. */\n $content?: ContentValue<typeof AccountEntryPointContent>;\n};\n\n/** Props for {@link AccountEntryPointLoading}. Extends native `<span>` attributes. */\nexport type AccountEntryPointLoadingProps = Omit<ComponentPropsWithRef<'span'>, 'children'>;\n\nexport const AccountEntryPointDefinition = defineComponent<AccountEntryPointProps>({\n name: 'AccountEntryPoint',\n importPath: '@vtex/faststore/components',\n component: AccountEntryPoint,\n content: AccountEntryPointContent,\n editor: { label: 'Account Entry Point', category: 'identity' },\n requiresContext: 'session',\n schema: {\n signedOutLabel: { type: 'string', label: 'Signed-out Label' },\n signedInLabel: { type: 'string', label: 'Signed-in Label' },\n icon: { type: 'icon', label: 'Icon' },\n loadingLabel: { type: 'string', label: 'Loading Announcement' },\n },\n guideline:\n 'Session-aware header control rendering one link: the signed-out label toward /login for anonymous and identified shoppers, the signed-in label toward the account route for logged-in ones, and a politely announced placeholder while the session resolves. Both destinations are fixed conventions, not props, so no authored value can misroute the auth flow. Must be inside a SessionRoot, normally the one hosted by the shared layout — a page-hosted provider resolves the session server-side without the shopper cookie and sticks on the signed-out label. Author the label for a state or that state renders nothing; the labels are optional in the type only because a CMS entry may supply them, and an entry overrides all four fields at runtime. Style it through data-fs-account-entry-point and switch on data-fs-account-entry-point-state.',\n});\n\n/**\n * Builds the sign-in destination, carrying the current path so the deferred proxy can return the\n * shopper where they started. Defense in depth only: the proxy has to validate the parameter on its\n * own side, since nothing stops a hand-crafted link. A path `resolveSafeHref` refuses — protocol-\n * relative, scheme-smuggled, absolute — drops the parameter instead of travelling encoded.\n */\nfunction signInHref(pathname: string | null): string {\n if (!pathname) {\n return SIGN_IN_ROUTE;\n }\n const destination = resolveSafeHref(pathname);\n // `resolveSafeHref` admits absolute http(s) values, which are legitimate for an authored link but\n // are exactly what would turn the proxy into an open redirect. Narrow to the site-relative subset\n // it has already cleared of protocol-relative and scheme-smuggled spellings.\n if (!destination.safe || !destination.href.startsWith('/')) {\n return SIGN_IN_ROUTE;\n }\n return `${SIGN_IN_ROUTE}?returnUrl=${encodeURIComponent(destination.href)}`;\n}\n\nexport function AccountEntryPoint({\n signedOutLabel,\n signedInLabel,\n icon,\n loadingLabel,\n $content,\n className,\n ref,\n ...props\n}: AccountEntryPointProps) {\n const { state, loading } = useSession();\n const pathname = usePathname();\n\n // Blank entry fields fall through, so clearing one in the Admin reads as unauthored.\n const entrySignedOutLabel = $content?.signedOutLabel?.trim() ? $content.signedOutLabel : signedOutLabel;\n const entrySignedInLabel = $content?.signedInLabel?.trim() ? $content.signedInLabel : signedInLabel;\n const entryIcon = $content?.icon?.trim() ? $content.icon : icon;\n const entryLoadingLabel = $content?.loadingLabel?.trim() ? $content.loadingLabel : loadingLabel;\n\n // Assistive technology only reports a change inside a region that was already in the tree, so a\n // region mounted together with its text announces nothing. The region therefore holds the same\n // position in both branches — React keeps it mounted across the swap — and starts empty, with the\n // label written on the pass after mount and cleared again once the session resolves.\n //\n // It trails the branch content rather than leading it so the authored swatch, which lands on\n // whichever element leads, can still reach the region through a following-sibling selector.\n const [announcement, setAnnouncement] = useState('');\n const resolvingLabel = loading ? entryLoadingLabel : undefined;\n useEffect(() => {\n setAnnouncement(resolvingLabel ?? '');\n }, [resolvingLabel]);\n\n // Gate on `loading` before `state`: an unresolved session reports `anonymous`, so reading the\n // tri-state first would flash \"Sign In\" at a shopper who is already signed in.\n if (loading) {\n return (\n <>\n <AccountEntryPointLoading {...props} className={className} />\n <AccountEntryPointAnnouncement>{announcement}</AccountEntryPointAnnouncement>\n </>\n );\n }\n\n // `identified` stays on the signed-out link: an identified but unauthenticated shopper cannot\n // reach their account yet.\n const signedIn = state === 'loggedIn';\n const label = signedIn ? entrySignedInLabel : entrySignedOutLabel;\n\n // A focusable link a screen reader announces as its bare URL is worse than no control at all.\n if (!label?.trim()) {\n return null;\n }\n\n return (\n <>\n <NavigationLink\n {...props}\n ref={ref}\n href={signedIn ? ACCOUNT_ROUTE : signInHref(pathname)}\n // The signed-out destination is a deferred auth proxy that may set a cookie or redirect, and\n // a header control is always in the viewport — the default prefetch would fire that GET on\n // every page view. The account route is an ordinary page, so it keeps the default.\n prefetch={signedIn ? undefined : false}\n className={className}\n data-fs-account-entry-point=\"\"\n data-fs-account-entry-point-state={signedIn ? 'signed-in' : 'signed-out'}\n >\n {entryIcon ? <Icon name={entryIcon} /> : null}\n <span data-fs-account-entry-point-label=\"\">{label}</span>\n </NavigationLink>\n <AccountEntryPointAnnouncement>{announcement}</AccountEntryPointAnnouncement>\n </>\n );\n}\n\n/**\n * The live region that carries the control across the wait. Separate from the visual placeholder\n * because it has to outlive it: the placeholder is only in the tree while the session resolves,\n * while the region has to be there before the text lands for the text to be announced at all.\n */\nfunction AccountEntryPointAnnouncement({ children }: { children: string }) {\n return (\n <output aria-live=\"polite\" data-fs-account-entry-point-announcement=\"\">\n {children}\n </output>\n );\n}\n\n/**\n * The visual stand-in occupying the control's slot while the session resolves. Purely visual: the\n * wait is spoken by the sibling live region, so this is hidden from assistive technology rather\n * than announced twice. Named for the state it represents, as `ShippingSimulatorLoading` is, not\n * for the bar it draws.\n */\nexport function AccountEntryPointLoading({ className, ...props }: AccountEntryPointLoadingProps) {\n return <span {...props} aria-hidden=\"true\" className={className} data-fs-account-entry-point-loading=\"\" />;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"account-entry-point.js","names":[],"sources":["../../../src/matter/account-entry-point/account-entry-point.tsx"],"sourcesContent":["'use client';\n\nimport { type ContentValue, defineComponent, defineContentType } from '@vtex/fs-core';\nimport { usePathname } from 'next/navigation';\nimport { type ComponentPropsWithRef, useEffect, useState } from 'react';\nimport { Icon } from '../../particles/icon/icon.js';\nimport { NavigationLink } from '../../particles/navigation-link/navigation-link.js';\nimport { resolveSignInHref } from '../../utils/safe-href.js';\nimport { useSession } from '../session/session.js';\n\n/** The account's root. A fixed convention, never an authored value: an authored destination could\n * misroute the entry into the account. */\nconst ACCOUNT_ROUTE = '/account';\n\n// Nothing is required: an entry may author any subset, and a slice missing a required property is\n// shape-invalid, which drops the whole element rather than the one field.\nexport const AccountEntryPointContent = defineContentType({\n id: 'account-entry-point',\n title: 'Account Entry Point',\n description: 'Labels and icon for the header sign-in / account control.',\n schema: {\n type: 'object',\n additionalProperties: false,\n properties: {\n signedOutLabel: { type: 'string', minLength: 1, title: 'Signed-out label' },\n signedInLabel: { type: 'string', minLength: 1, title: 'Signed-in label' },\n icon: { type: 'string', minLength: 1, title: 'Icon' },\n loadingLabel: { type: 'string', minLength: 1, title: 'Loading announcement' },\n },\n },\n});\n\n/** Props for {@link AccountEntryPoint}. Extends native `<a>` attributes (the rendered root). */\nexport type AccountEntryPointProps = Omit<ComponentPropsWithRef<'a'>, 'href' | 'children'> & {\n /** Label for anonymous and identified shoppers. Optional: the render gate, not the type,\n * requires a resolved value. */\n signedOutLabel?: string;\n /** Label for logged-in shoppers. Same gate. */\n signedInLabel?: string;\n /** Icon reference for the glyph. Never gated: a control with no glyph is a valid configuration. */\n icon?: string;\n /** Text the live region announces while the session resolves. */\n loadingLabel?: string;\n /** Merchant-authored fields, sliced from the page/layout content entry; win over the props field\n * by field when present. Absent in a non-CMS store — the props are the fallback. */\n $content?: ContentValue<typeof AccountEntryPointContent>;\n};\n\n/** Props for {@link AccountEntryPointLoading}. Extends native `<span>` attributes. */\nexport type AccountEntryPointLoadingProps = Omit<ComponentPropsWithRef<'span'>, 'children'>;\n\nexport const AccountEntryPointDefinition = defineComponent<AccountEntryPointProps>({\n name: 'AccountEntryPoint',\n importPath: '@vtex/faststore/components',\n component: AccountEntryPoint,\n content: AccountEntryPointContent,\n editor: { label: 'Account Entry Point', category: 'identity' },\n requiresContext: 'session',\n schema: {\n signedOutLabel: { type: 'string', label: 'Signed-out Label' },\n signedInLabel: { type: 'string', label: 'Signed-in Label' },\n icon: { type: 'icon', label: 'Icon' },\n loadingLabel: { type: 'string', label: 'Loading Announcement' },\n },\n guideline:\n 'Session-aware header control rendering one link: the signed-out label toward /login for anonymous and identified shoppers, the signed-in label toward the account route for logged-in ones, and a politely announced placeholder while the session resolves. Both destinations are fixed conventions, not props, so no authored value can misroute the auth flow. Must be inside a SessionRoot, normally the one hosted by the shared layout — a page-hosted provider resolves the session server-side without the shopper cookie and sticks on the signed-out label. Author the label for a state or that state renders nothing; the labels are optional in the type only because a CMS entry may supply them, and an entry overrides all four fields at runtime. Style it through data-fs-account-entry-point and switch on data-fs-account-entry-point-state.',\n});\n\nexport function AccountEntryPoint({\n signedOutLabel,\n signedInLabel,\n icon,\n loadingLabel,\n $content,\n className,\n ref,\n ...props\n}: AccountEntryPointProps) {\n const { state, loading } = useSession();\n const pathname = usePathname();\n\n // Blank entry fields fall through, so clearing one in the Admin reads as unauthored.\n const entrySignedOutLabel = $content?.signedOutLabel?.trim() ? $content.signedOutLabel : signedOutLabel;\n const entrySignedInLabel = $content?.signedInLabel?.trim() ? $content.signedInLabel : signedInLabel;\n const entryIcon = $content?.icon?.trim() ? $content.icon : icon;\n const entryLoadingLabel = $content?.loadingLabel?.trim() ? $content.loadingLabel : loadingLabel;\n\n // Assistive technology only reports a change inside a region that was already in the tree, so a\n // region mounted together with its text announces nothing. The region therefore holds the same\n // position in both branches — React keeps it mounted across the swap — and starts empty, with the\n // label written on the pass after mount and cleared again once the session resolves.\n //\n // It trails the branch content rather than leading it so the authored swatch, which lands on\n // whichever element leads, can still reach the region through a following-sibling selector.\n const [announcement, setAnnouncement] = useState('');\n const resolvingLabel = loading ? entryLoadingLabel : undefined;\n useEffect(() => {\n setAnnouncement(resolvingLabel ?? '');\n }, [resolvingLabel]);\n\n // Gate on `loading` before `state`: an unresolved session reports `anonymous`, so reading the\n // tri-state first would flash \"Sign In\" at a shopper who is already signed in.\n if (loading) {\n return (\n <>\n <AccountEntryPointLoading {...props} className={className} />\n <AccountEntryPointAnnouncement>{announcement}</AccountEntryPointAnnouncement>\n </>\n );\n }\n\n // `identified` stays on the signed-out link: an identified but unauthenticated shopper cannot\n // reach their account yet.\n const signedIn = state === 'loggedIn';\n const label = signedIn ? entrySignedInLabel : entrySignedOutLabel;\n\n // A focusable link a screen reader announces as its bare URL is worse than no control at all.\n if (!label?.trim()) {\n return null;\n }\n\n return (\n <>\n <NavigationLink\n {...props}\n ref={ref}\n href={signedIn ? ACCOUNT_ROUTE : resolveSignInHref(pathname)}\n // The signed-out destination is a deferred auth proxy that may set a cookie or redirect, and\n // a header control is always in the viewport — the default prefetch would fire that GET on\n // every page view. The account route is an ordinary page, so it keeps the default.\n prefetch={signedIn ? undefined : false}\n className={className}\n data-fs-account-entry-point=\"\"\n data-fs-account-entry-point-state={signedIn ? 'signed-in' : 'signed-out'}\n >\n {entryIcon ? <Icon name={entryIcon} /> : null}\n <span data-fs-account-entry-point-label=\"\">{label}</span>\n </NavigationLink>\n <AccountEntryPointAnnouncement>{announcement}</AccountEntryPointAnnouncement>\n </>\n );\n}\n\n/**\n * The live region that carries the control across the wait. Separate from the visual placeholder\n * because it has to outlive it: the placeholder is only in the tree while the session resolves,\n * while the region has to be there before the text lands for the text to be announced at all.\n */\nfunction AccountEntryPointAnnouncement({ children }: { children: string }) {\n return (\n <output aria-live=\"polite\" data-fs-account-entry-point-announcement=\"\">\n {children}\n </output>\n );\n}\n\n/**\n * The visual stand-in occupying the control's slot while the session resolves. Purely visual: the\n * wait is spoken by the sibling live region, so this is hidden from assistive technology rather\n * than announced twice. Named for the state it represents, as `ShippingSimulatorLoading` is, not\n * for the bar it draws.\n */\nexport function AccountEntryPointLoading({ className, ...props }: AccountEntryPointLoadingProps) {\n return <span {...props} aria-hidden=\"true\" className={className} data-fs-account-entry-point-loading=\"\" />;\n}\n"],"mappings":";;;;;;;;;;;;;;AAYA,MAAM,gBAAgB;AAItB,MAAa,2BAA2B,kBAAkB;CACxD,IAAI;CACJ,OAAO;CACP,aAAa;CACb,QAAQ;EACN,MAAM;EACN,sBAAsB;EACtB,YAAY;GACV,gBAAgB;IAAE,MAAM;IAAU,WAAW;IAAG,OAAO;GAAmB;GAC1E,eAAe;IAAE,MAAM;IAAU,WAAW;IAAG,OAAO;GAAkB;GACxE,MAAM;IAAE,MAAM;IAAU,WAAW;IAAG,OAAO;GAAO;GACpD,cAAc;IAAE,MAAM;IAAU,WAAW;IAAG,OAAO;GAAuB;EAC9E;CACF;AACF,CAAC;AAqBD,MAAa,8BAA8B,gBAAwC;CACjF,MAAM;CACN,YAAY;CACZ,WAAW;CACX,SAAS;CACT,QAAQ;EAAE,OAAO;EAAuB,UAAU;CAAW;CAC7D,iBAAiB;CACjB,QAAQ;EACN,gBAAgB;GAAE,MAAM;GAAU,OAAO;EAAmB;EAC5D,eAAe;GAAE,MAAM;GAAU,OAAO;EAAkB;EAC1D,MAAM;GAAE,MAAM;GAAQ,OAAO;EAAO;EACpC,cAAc;GAAE,MAAM;GAAU,OAAO;EAAuB;CAChE;CACA,WACE;AACJ,CAAC;AAED,SAAgB,kBAAkB,EAChC,gBACA,eACA,MACA,cACA,UACA,WACA,KACA,GAAG,SACsB;CACzB,MAAM,EAAE,OAAO,YAAY,WAAW;CACtC,MAAM,WAAW,YAAY;CAG7B,MAAM,sBAAsB,UAAU,gBAAgB,KAAK,IAAI,SAAS,iBAAiB;CACzF,MAAM,qBAAqB,UAAU,eAAe,KAAK,IAAI,SAAS,gBAAgB;CACtF,MAAM,YAAY,UAAU,MAAM,KAAK,IAAI,SAAS,OAAO;CAC3D,MAAM,oBAAoB,UAAU,cAAc,KAAK,IAAI,SAAS,eAAe;CASnF,MAAM,CAAC,cAAc,mBAAmB,SAAS,EAAE;CACnD,MAAM,iBAAiB,UAAU,oBAAoB,KAAA;CACrD,gBAAgB;EACd,gBAAgB,kBAAkB,EAAE;CACtC,GAAG,CAAC,cAAc,CAAC;CAInB,IAAI,SACF,OACE,qBAAA,UAAA,EAAA,UAAA,CACE,oBAAC,0BAAD;EAA0B,GAAI;EAAkB;CAAY,CAAA,GAC5D,oBAAC,+BAAD,EAAA,UAAgC,aAA4C,CAAA,CAC5E,EAAA,CAAA;CAMN,MAAM,WAAW,UAAU;CAC3B,MAAM,QAAQ,WAAW,qBAAqB;CAG9C,IAAI,CAAC,OAAO,KAAK,GACf,OAAO;CAGT,OACE,qBAAA,UAAA,EAAA,UAAA,CACE,qBAAC,gBAAD;EACE,GAAI;EACC;EACL,MAAM,WAAW,gBAAgB,kBAAkB,QAAQ;EAI3D,UAAU,WAAW,KAAA,IAAY;EACtB;EACX,+BAA4B;EAC5B,qCAAmC,WAAW,cAAc;EAV9D,UAAA,CAYG,YAAY,oBAAC,MAAD,EAAM,MAAM,UAAY,CAAA,IAAI,MACzC,oBAAC,QAAD;GAAM,qCAAkC;GAAI,UAAA;EAAY,CAAA,CAC1C;CAChB,CAAA,GAAA,oBAAC,+BAAD,EAAA,UAAgC,aAA4C,CAAA,CAC5E,EAAA,CAAA;AAEN;;;;;;AAOA,SAAS,8BAA8B,EAAE,YAAkC;CACzE,OACE,oBAAC,UAAD;EAAQ,aAAU;EAAS,4CAAyC;EACjE;CACK,CAAA;AAEZ;;;;;;;AAQA,SAAgB,yBAAyB,EAAE,WAAW,GAAG,SAAwC;CAC/F,OAAO,oBAAC,QAAD;EAAM,GAAI;EAAO,eAAY;EAAkB;EAAW,uCAAoC;CAAI,CAAA;AAC3G"}
@@ -49,6 +49,42 @@ function resolveSafeHref(raw) {
49
49
  reason: "forbidden-scheme"
50
50
  };
51
51
  }
52
+ /** The storefront's own sign-in route, which forwards to the platform's login. A fixed convention,
53
+ * never an authored value. */
54
+ const SIGN_IN_ROUTE = "/login";
55
+ /** The parameter carrying the shopper's way back, read by the sign-in route and the platform's login. */
56
+ const RETURN_URL_PARAM = "returnUrl";
57
+ /**
58
+ * `resolveSafeHref` narrowed to the site-relative subset: the destinations a shopper may be sent back to
59
+ * across the auth hop. The allowlist admits absolute http(s) values, which are legitimate for an authored
60
+ * link but are exactly what would turn the hop into an open redirect. Every other rejection travels out
61
+ * unchanged, so a caller logging the reason still sees which spelling arrived.
62
+ *
63
+ * Both ends of the flow are meant to narrow through here rather than only the sender, because the sender
64
+ * cannot vouch for a URL the shopper typed. Today only the sender calls it: the receiving route is not
65
+ * served yet.
66
+ */
67
+ function resolveSiteRelativeHref(raw) {
68
+ const destination = resolveSafeHref(raw);
69
+ if (!destination.safe) return destination;
70
+ return destination.href.startsWith("/") ? destination : {
71
+ safe: false,
72
+ reason: "absolute"
73
+ };
74
+ }
75
+ /**
76
+ * Builds the sign-in destination, carrying the current path so the shopper can be returned where they
77
+ * started. The sender's half only: nothing stops a hand-crafted link, so the sign-in route is to narrow
78
+ * the parameter again on arrival. A path the narrowing refuses drops the parameter instead of
79
+ * travelling encoded, and so does a router reporting no path — `usePathname()`'s type is why this takes
80
+ * a nullable one.
81
+ */
82
+ function resolveSignInHref(pathname) {
83
+ if (!pathname) return SIGN_IN_ROUTE;
84
+ const destination = resolveSiteRelativeHref(pathname);
85
+ if (!destination.safe) return SIGN_IN_ROUTE;
86
+ return `${SIGN_IN_ROUTE}?${RETURN_URL_PARAM}=${encodeURIComponent(destination.href)}`;
87
+ }
52
88
  /** Any C0 control or space anywhere in the value — the set a URL parser removes or trims. */
53
89
  function hasStrippableCharacter(value) {
54
90
  for (const character of value) {
@@ -58,6 +94,6 @@ function hasStrippableCharacter(value) {
58
94
  return false;
59
95
  }
60
96
  //#endregion
61
- export { resolveSafeHref };
97
+ export { RETURN_URL_PARAM, resolveSafeHref, resolveSignInHref, resolveSiteRelativeHref };
62
98
 
63
99
  //# sourceMappingURL=safe-href.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"safe-href.js","names":[],"sources":["../../src/utils/safe-href.ts"],"sourcesContent":["export type SafeHrefRejection = 'blank' | 'malformed' | 'protocol-relative' | 'forbidden-scheme';\n\nexport type SafeHrefResult = { safe: true; href: string } | { safe: false; reason: SafeHrefRejection };\n\nconst ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);\n\n/**\n * The only gate on a CMS-authored destination: `http:`, `https:`, `mailto:`, `tel:`, and paths\n * starting with a single `/`. Neither the runtime slice nor the sukima preflight enforces a model's\n * `pattern`, so an authored `javascript:` value reaches the component intact and this is what stands\n * between it and an `href`.\n *\n * Rejects rather than sanitizes: a destination that comes back safe is character-for-character the\n * trimmed authored value, so no value is ever silently rewritten into a different working one.\n */\nexport function resolveSafeHref(raw: string): SafeHrefResult {\n const href = raw.trim();\n if (href.length === 0) {\n return { safe: false, reason: 'blank' };\n }\n // Browsers strip ASCII tabs and newlines from a URL before parsing it, so `java<TAB>script:alert(1)`\n // executes. Refusing the whole value is the only defense that does not depend on stripping the same\n // characters in the same order the parser does.\n if (hasStrippableCharacter(href)) {\n return { safe: false, reason: 'malformed' };\n }\n // A URL parser reads `\\` as `/` under a special scheme, so `/\\evil.example` resolves against the\n // store's origin as an authority rather than a path. Any pair of leading slashes is an off-site\n // jump however it is spelled, and only `//` looks like one.\n if (/^[/\\\\]{2}/.test(href)) {\n return { safe: false, reason: 'protocol-relative' };\n }\n if (href.startsWith('/')) {\n return { safe: true, href };\n }\n\n let protocol: string;\n try {\n protocol = new URL(href).protocol;\n } catch {\n return { safe: false, reason: 'malformed' };\n }\n // `new URL` lowercases the protocol, so `JavaScript:` and `javascript:` are the same rejection.\n return ALLOWED_PROTOCOLS.has(protocol) ? { safe: true, href } : { safe: false, reason: 'forbidden-scheme' };\n}\n\n/** Any C0 control or space anywhere in the value — the set a URL parser removes or trims. */\nfunction hasStrippableCharacter(value: string): boolean {\n for (const character of value) {\n const code = character.codePointAt(0) ?? 0;\n if (code <= 0x20 || code === 0x7f) {\n return true;\n }\n }\n return false;\n}\n"],"mappings":";AAIA,MAAM,oCAAoB,IAAI,IAAI;CAAC;CAAS;CAAU;CAAW;AAAM,CAAC;;;;;;;;;;AAWxE,SAAgB,gBAAgB,KAA6B;CAC3D,MAAM,OAAO,IAAI,KAAK;CACtB,IAAI,KAAK,WAAW,GAClB,OAAO;EAAE,MAAM;EAAO,QAAQ;CAAQ;CAKxC,IAAI,uBAAuB,IAAI,GAC7B,OAAO;EAAE,MAAM;EAAO,QAAQ;CAAY;CAK5C,IAAI,YAAY,KAAK,IAAI,GACvB,OAAO;EAAE,MAAM;EAAO,QAAQ;CAAoB;CAEpD,IAAI,KAAK,WAAW,GAAG,GACrB,OAAO;EAAE,MAAM;EAAM;CAAK;CAG5B,IAAI;CACJ,IAAI;EACF,WAAW,IAAI,IAAI,IAAI,CAAC,CAAC;CAC3B,QAAQ;EACN,OAAO;GAAE,MAAM;GAAO,QAAQ;EAAY;CAC5C;CAEA,OAAO,kBAAkB,IAAI,QAAQ,IAAI;EAAE,MAAM;EAAM;CAAK,IAAI;EAAE,MAAM;EAAO,QAAQ;CAAmB;AAC5G;;AAGA,SAAS,uBAAuB,OAAwB;CACtD,KAAK,MAAM,aAAa,OAAO;EAC7B,MAAM,OAAO,UAAU,YAAY,CAAC,KAAK;EACzC,IAAI,QAAQ,MAAQ,SAAS,KAC3B,OAAO;CAEX;CACA,OAAO;AACT"}
1
+ {"version":3,"file":"safe-href.js","names":[],"sources":["../../src/utils/safe-href.ts"],"sourcesContent":["export type SafeHrefRejection = 'blank' | 'malformed' | 'protocol-relative' | 'forbidden-scheme' | 'absolute';\n\nexport type SafeHrefResult = { safe: true; href: string } | { safe: false; reason: SafeHrefRejection };\n\nconst ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'mailto:', 'tel:']);\n\n/**\n * The only gate on a CMS-authored destination: `http:`, `https:`, `mailto:`, `tel:`, and paths\n * starting with a single `/`. Neither the runtime slice nor the sukima preflight enforces a model's\n * `pattern`, so an authored `javascript:` value reaches the component intact and this is what stands\n * between it and an `href`.\n *\n * Rejects rather than sanitizes: a destination that comes back safe is character-for-character the\n * trimmed authored value, so no value is ever silently rewritten into a different working one.\n */\nexport function resolveSafeHref(raw: string): SafeHrefResult {\n const href = raw.trim();\n if (href.length === 0) {\n return { safe: false, reason: 'blank' };\n }\n // Browsers strip ASCII tabs and newlines from a URL before parsing it, so `java<TAB>script:alert(1)`\n // executes. Refusing the whole value is the only defense that does not depend on stripping the same\n // characters in the same order the parser does.\n if (hasStrippableCharacter(href)) {\n return { safe: false, reason: 'malformed' };\n }\n // A URL parser reads `\\` as `/` under a special scheme, so `/\\evil.example` resolves against the\n // store's origin as an authority rather than a path. Any pair of leading slashes is an off-site\n // jump however it is spelled, and only `//` looks like one.\n if (/^[/\\\\]{2}/.test(href)) {\n return { safe: false, reason: 'protocol-relative' };\n }\n if (href.startsWith('/')) {\n return { safe: true, href };\n }\n\n let protocol: string;\n try {\n protocol = new URL(href).protocol;\n } catch {\n return { safe: false, reason: 'malformed' };\n }\n // `new URL` lowercases the protocol, so `JavaScript:` and `javascript:` are the same rejection.\n return ALLOWED_PROTOCOLS.has(protocol) ? { safe: true, href } : { safe: false, reason: 'forbidden-scheme' };\n}\n\n/** The storefront's own sign-in route, which forwards to the platform's login. A fixed convention,\n * never an authored value. */\nconst SIGN_IN_ROUTE = '/login';\n\n/** The parameter carrying the shopper's way back, read by the sign-in route and the platform's login. */\nexport const RETURN_URL_PARAM = 'returnUrl';\n\n/**\n * `resolveSafeHref` narrowed to the site-relative subset: the destinations a shopper may be sent back to\n * across the auth hop. The allowlist admits absolute http(s) values, which are legitimate for an authored\n * link but are exactly what would turn the hop into an open redirect. Every other rejection travels out\n * unchanged, so a caller logging the reason still sees which spelling arrived.\n *\n * Both ends of the flow are meant to narrow through here rather than only the sender, because the sender\n * cannot vouch for a URL the shopper typed. Today only the sender calls it: the receiving route is not\n * served yet.\n */\nexport function resolveSiteRelativeHref(raw: string): SafeHrefResult {\n const destination = resolveSafeHref(raw);\n if (!destination.safe) {\n return destination;\n }\n return destination.href.startsWith('/') ? destination : { safe: false, reason: 'absolute' };\n}\n\n/**\n * Builds the sign-in destination, carrying the current path so the shopper can be returned where they\n * started. The sender's half only: nothing stops a hand-crafted link, so the sign-in route is to narrow\n * the parameter again on arrival. A path the narrowing refuses drops the parameter instead of\n * travelling encoded, and so does a router reporting no path — `usePathname()`'s type is why this takes\n * a nullable one.\n */\nexport function resolveSignInHref(pathname: string | null): string {\n if (!pathname) {\n return SIGN_IN_ROUTE;\n }\n const destination = resolveSiteRelativeHref(pathname);\n if (!destination.safe) {\n return SIGN_IN_ROUTE;\n }\n return `${SIGN_IN_ROUTE}?${RETURN_URL_PARAM}=${encodeURIComponent(destination.href)}`;\n}\n\n/** Any C0 control or space anywhere in the value — the set a URL parser removes or trims. */\nfunction hasStrippableCharacter(value: string): boolean {\n for (const character of value) {\n const code = character.codePointAt(0) ?? 0;\n if (code <= 0x20 || code === 0x7f) {\n return true;\n }\n }\n return false;\n}\n"],"mappings":";AAIA,MAAM,oCAAoB,IAAI,IAAI;CAAC;CAAS;CAAU;CAAW;AAAM,CAAC;;;;;;;;;;AAWxE,SAAgB,gBAAgB,KAA6B;CAC3D,MAAM,OAAO,IAAI,KAAK;CACtB,IAAI,KAAK,WAAW,GAClB,OAAO;EAAE,MAAM;EAAO,QAAQ;CAAQ;CAKxC,IAAI,uBAAuB,IAAI,GAC7B,OAAO;EAAE,MAAM;EAAO,QAAQ;CAAY;CAK5C,IAAI,YAAY,KAAK,IAAI,GACvB,OAAO;EAAE,MAAM;EAAO,QAAQ;CAAoB;CAEpD,IAAI,KAAK,WAAW,GAAG,GACrB,OAAO;EAAE,MAAM;EAAM;CAAK;CAG5B,IAAI;CACJ,IAAI;EACF,WAAW,IAAI,IAAI,IAAI,CAAC,CAAC;CAC3B,QAAQ;EACN,OAAO;GAAE,MAAM;GAAO,QAAQ;EAAY;CAC5C;CAEA,OAAO,kBAAkB,IAAI,QAAQ,IAAI;EAAE,MAAM;EAAM;CAAK,IAAI;EAAE,MAAM;EAAO,QAAQ;CAAmB;AAC5G;;;AAIA,MAAM,gBAAgB;;AAGtB,MAAa,mBAAmB;;;;;;;;;;;AAYhC,SAAgB,wBAAwB,KAA6B;CACnE,MAAM,cAAc,gBAAgB,GAAG;CACvC,IAAI,CAAC,YAAY,MACf,OAAO;CAET,OAAO,YAAY,KAAK,WAAW,GAAG,IAAI,cAAc;EAAE,MAAM;EAAO,QAAQ;CAAW;AAC5F;;;;;;;;AASA,SAAgB,kBAAkB,UAAiC;CACjE,IAAI,CAAC,UACH,OAAO;CAET,MAAM,cAAc,wBAAwB,QAAQ;CACpD,IAAI,CAAC,YAAY,MACf,OAAO;CAET,OAAO,GAAG,cAAc,GAAG,iBAAiB,GAAG,mBAAmB,YAAY,IAAI;AACpF;;AAGA,SAAS,uBAAuB,OAAwB;CACtD,KAAK,MAAM,aAAa,OAAO;EAC7B,MAAM,OAAO,UAAU,YAAY,CAAC,KAAK;EACzC,IAAI,QAAQ,MAAQ,SAAS,KAC3B,OAAO;CAEX;CACA,OAAO;AACT"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "FastStore AI Native component library built on Base UI with AI-editable styles",
5
5
  "license": "MIT",
6
6
  "homepage": "https://developers.vtex.com/docs/guides/faststore",