@tokenoftrust/storefront-runner 2.0.13 → 2.0.14
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.
|
@@ -23,7 +23,9 @@ import type { CustomizationEnv, KvGet, TenantConfig } from "@tot/public-runtime"
|
|
|
23
23
|
import {
|
|
24
24
|
contentArtifactPath,
|
|
25
25
|
listPublishedArtifactPaths,
|
|
26
|
+
listVersionArtifactPaths,
|
|
26
27
|
readPublishedArtifact,
|
|
28
|
+
readVersionArtifact,
|
|
27
29
|
tenantDirRelative,
|
|
28
30
|
tenantDirSegment,
|
|
29
31
|
} from "@tot/public-runtime";
|
|
@@ -681,6 +683,99 @@ export class PublishedContentProvider implements ContentProvider {
|
|
|
681
683
|
}
|
|
682
684
|
}
|
|
683
685
|
|
|
686
|
+
// ---------------------------------------------------------------------------
|
|
687
|
+
// VersionPinnedContentProvider — pins content to ONE exact immutable
|
|
688
|
+
// `versionId`, bypassing channel/pointer resolution entirely (the candidate/
|
|
689
|
+
// revision preview's read path). Structurally the version-pinned twin of
|
|
690
|
+
// PublishedContentProvider (channel -> versionId vs. versionId direct); every
|
|
691
|
+
// method mirrors it 1:1 so a candidate's OWN pages/blog/products/etc. render
|
|
692
|
+
// through the exact same generic-content plumbing as a live/preview page,
|
|
693
|
+
// pinned to the exact commit under review instead of the shared channel.
|
|
694
|
+
// ---------------------------------------------------------------------------
|
|
695
|
+
|
|
696
|
+
export class VersionPinnedContentProvider implements ContentProvider {
|
|
697
|
+
constructor(
|
|
698
|
+
private readonly base: ContentProvider,
|
|
699
|
+
private readonly tenantId: string,
|
|
700
|
+
private readonly get: KvGet,
|
|
701
|
+
private readonly versionId: string,
|
|
702
|
+
) {}
|
|
703
|
+
|
|
704
|
+
private pinnedRaw(relative: string): Promise<string | null> {
|
|
705
|
+
return readVersionArtifact(
|
|
706
|
+
this.get,
|
|
707
|
+
this.tenantId,
|
|
708
|
+
this.versionId,
|
|
709
|
+
contentArtifactPath(this.tenantId, relative),
|
|
710
|
+
);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
private async pinned<T>(relative: string): Promise<T | null> {
|
|
714
|
+
const raw = await this.pinnedRaw(relative);
|
|
715
|
+
if (!raw) return null;
|
|
716
|
+
try {
|
|
717
|
+
return JSON.parse(raw) as T;
|
|
718
|
+
} catch {
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async getHome(): Promise<HomeContent> {
|
|
724
|
+
return (await this.pinned<HomeContent>("home.json")) ?? this.base.getHome();
|
|
725
|
+
}
|
|
726
|
+
async getHomeHtml(): Promise<string | null> {
|
|
727
|
+
const raw = await this.pinnedRaw("home.html");
|
|
728
|
+
return raw !== null ? stampTrackingIdsAtPublish(raw) : this.base.getHomeHtml();
|
|
729
|
+
}
|
|
730
|
+
async getPageHtml(slug: string): Promise<string | null> {
|
|
731
|
+
const rel = `pages-html/${normalizeRawPageSlug(slug)}.html`;
|
|
732
|
+
const raw = await this.pinnedRaw(rel);
|
|
733
|
+
return raw !== null ? stampTrackingIdsAtPublish(raw) : this.base.getPageHtml(slug);
|
|
734
|
+
}
|
|
735
|
+
listPageHtmlSlugs(): Promise<string[]> {
|
|
736
|
+
return this.listArtifactPaths("pages-html/").then((paths) =>
|
|
737
|
+
paths === null
|
|
738
|
+
? this.base.listPageHtmlSlugs()
|
|
739
|
+
: paths
|
|
740
|
+
.filter((path) => path.endsWith(".html"))
|
|
741
|
+
.map((path) => path.slice("pages-html/".length, -".html".length)),
|
|
742
|
+
);
|
|
743
|
+
}
|
|
744
|
+
async getChromeHtml(currentPath?: string): Promise<string | null> {
|
|
745
|
+
const publishedHtml = await this.pinnedRaw("chrome.html");
|
|
746
|
+
if (publishedHtml) return stampTrackingIdsAtPublish(publishedHtml);
|
|
747
|
+
const config = parseChromeConfig(await this.pinned<unknown>("chrome.json"));
|
|
748
|
+
if (config) {
|
|
749
|
+
const assignments = parseChromeAssignments(await this.pinned<unknown>("chrome-assignments.json"));
|
|
750
|
+
return renderRawChrome(config, currentPath, assignments);
|
|
751
|
+
}
|
|
752
|
+
return this.base.getChromeHtml(currentPath);
|
|
753
|
+
}
|
|
754
|
+
async getChrome(): Promise<ChromeConfig> {
|
|
755
|
+
const config = parseChromeConfig(await this.pinned<unknown>("chrome.json"));
|
|
756
|
+
return config ?? this.base.getChrome();
|
|
757
|
+
}
|
|
758
|
+
async getPage(slug: string): Promise<EditorialPage | null> {
|
|
759
|
+
return (await this.pinned<EditorialPage>(`pages/${slug}.json`)) ?? this.base.getPage(slug);
|
|
760
|
+
}
|
|
761
|
+
async listPages(): Promise<EditorialPage[]> {
|
|
762
|
+
const paths = await this.listArtifactPaths("pages/");
|
|
763
|
+
if (paths === null) return this.base.listPages();
|
|
764
|
+
const pages = await Promise.all(
|
|
765
|
+
paths.filter((path) => path.endsWith(".json")).map(async (path) => this.pinned<EditorialPage>(path)),
|
|
766
|
+
);
|
|
767
|
+
return pages.filter((page): page is EditorialPage => page !== null);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
readArtifact(relative: string): Promise<string | null> {
|
|
771
|
+
return this.pinnedRaw(relative);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
async listArtifactPaths(prefix = ""): Promise<string[] | null> {
|
|
775
|
+
return listVersionArtifactPaths(this.get, this.tenantId, this.versionId, prefix);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
684
779
|
// ---------------------------------------------------------------------------
|
|
685
780
|
// DevDraftContentProvider — the per-developer DRAFT overlay. Wraps the
|
|
686
781
|
// resolved provider chain and serves a
|
|
@@ -747,9 +842,22 @@ export class DevDraftContentProvider implements ContentProvider {
|
|
|
747
842
|
|
|
748
843
|
const PLACEHOLDER_TOKEN_RE = /PLACEHOLDER|^$/i;
|
|
749
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Pin content resolution to ONE exact immutable `versionId` instead of a
|
|
847
|
+
* `{live,test}` channel — the candidate/revision preview's read path (see
|
|
848
|
+
* `VersionPinnedContentProvider`). Takes priority over `customization` when
|
|
849
|
+
* both are given: a pinned candidate render must never fall through to the
|
|
850
|
+
* shared channel.
|
|
851
|
+
*/
|
|
852
|
+
export interface ContentVersionPin {
|
|
853
|
+
get: KvGet;
|
|
854
|
+
versionId: string;
|
|
855
|
+
}
|
|
856
|
+
|
|
750
857
|
export function createContentProvider(
|
|
751
858
|
tenant: TenantConfig,
|
|
752
859
|
customization?: ContentCustomization,
|
|
860
|
+
pin?: ContentVersionPin,
|
|
753
861
|
): ContentProvider {
|
|
754
862
|
// Content dirs are keyed by tenant_id (the adopter writes content/<tenant>/).
|
|
755
863
|
const local = new LocalContentProvider(tenant.tenant_id);
|
|
@@ -758,6 +866,9 @@ export function createContentProvider(
|
|
|
758
866
|
const base: ContentProvider = usable
|
|
759
867
|
? new StoryblokContentProvider(token, local)
|
|
760
868
|
: local;
|
|
869
|
+
if (pin) {
|
|
870
|
+
return new VersionPinnedContentProvider(base, tenant.tenant_id, pin.get, pin.versionId);
|
|
871
|
+
}
|
|
761
872
|
return customization
|
|
762
873
|
? new PublishedContentProvider(base, tenant.tenant_id, customization)
|
|
763
874
|
: base;
|
|
@@ -27,6 +27,8 @@ import { STATIC_SCRIPT_HASHES, STATIC_STYLE_HASHES } from "@/lib/cspStaticHashes
|
|
|
27
27
|
import { applySecurityHeaders } from "@/lib/securityHeaders";
|
|
28
28
|
import { maybeInjectPreviewReload } from "@/lib/previewReload";
|
|
29
29
|
import {
|
|
30
|
+
CANDIDATE_CONTENT_PIN_PARAM,
|
|
31
|
+
verifyContentPinToken,
|
|
30
32
|
resolveCustomizationEnv,
|
|
31
33
|
buildCsp,
|
|
32
34
|
STOREFRONT_CSP_ALLOWLIST,
|
|
@@ -288,6 +290,34 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
288
290
|
const isPreviewHost = host.toLowerCase().startsWith("preview.");
|
|
289
291
|
const queryToken = url.searchParams.get("__preview");
|
|
290
292
|
const cookieToken = context.cookies.get("tot_preview")?.value ?? null;
|
|
293
|
+
|
|
294
|
+
// Candidate-render CONTENT PIN (`__tot_candidate_pin`): a signed, tenant- and
|
|
295
|
+
// expiry-bound token minted ONLY by the control-plane candidate/revision
|
|
296
|
+
// renderers (`src/lib/preview/pinnedRouteRender.ts`, never client-forgeable)
|
|
297
|
+
// that pins THIS one render's content resolution to an exact immutable
|
|
298
|
+
// `versionId`, bypassing the shared `{live,test}` channel entirely. It rides
|
|
299
|
+
// an in-process `context.rewrite()` from those renderers so a real Astro
|
|
300
|
+
// route (blog, editorial pages, …) — not just the raw `home.html`/
|
|
301
|
+
// `pages-html/*` documents they serve directly — renders pinned to the
|
|
302
|
+
// candidate under review (fb-1788536381607). Verified here so it can only
|
|
303
|
+
// ever name the resolved tenant's own version; a mismatched/expired/absent
|
|
304
|
+
// token falls through to the normal channel-resolved read below, same
|
|
305
|
+
// fail-closed posture as the `__preview` token.
|
|
306
|
+
const candidatePinParam = url.searchParams.get(CANDIDATE_CONTENT_PIN_PARAM);
|
|
307
|
+
let candidatePin: { versionId: string; basePath: string } | null = null;
|
|
308
|
+
if (candidatePinParam) {
|
|
309
|
+
const pinSecret = (await readEnv("CUSTOMIZATION_PREVIEW_SECRET")) ?? null;
|
|
310
|
+
if (pinSecret) {
|
|
311
|
+
const verified = await verifyContentPinToken(
|
|
312
|
+
pinSecret,
|
|
313
|
+
candidatePinParam,
|
|
314
|
+
Math.floor(Date.now() / 1000),
|
|
315
|
+
);
|
|
316
|
+
if (verified.ok && verified.claims.tenantId === tenant.tenant_id) {
|
|
317
|
+
candidatePin = { versionId: verified.claims.versionId, basePath: verified.claims.basePath };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}
|
|
291
321
|
// Per-developer AI-edit draft: the signed
|
|
292
322
|
// tot_dev_draft cookie's mere PRESENCE opts the request out of the shared page
|
|
293
323
|
// cache below (a draft view must never be cached publicly); it's verified —
|
|
@@ -329,6 +359,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
329
359
|
!isPreviewHost &&
|
|
330
360
|
!queryToken &&
|
|
331
361
|
!cookieToken &&
|
|
362
|
+
!candidatePin &&
|
|
332
363
|
!draftCookie &&
|
|
333
364
|
managedIntegrationDeclarations.length === 0 &&
|
|
334
365
|
!url.pathname.startsWith("/api/");
|
|
@@ -451,6 +482,7 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
451
482
|
!isPreviewHost &&
|
|
452
483
|
!queryToken &&
|
|
453
484
|
!cookieToken &&
|
|
485
|
+
!candidatePin &&
|
|
454
486
|
!draftCookie &&
|
|
455
487
|
isCacheablePath(url.pathname, url.searchParams);
|
|
456
488
|
let pageKey: Request | null = null;
|
|
@@ -662,10 +694,15 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
662
694
|
: null;
|
|
663
695
|
locals.commerce = commerce ?? undefined;
|
|
664
696
|
locals.tot = tot;
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
697
|
+
// A verified candidate content pin takes priority over the channel-resolved
|
|
698
|
+
// read — a pinned candidate/revision render must never fall through to the
|
|
699
|
+
// shared `{live,test}` content (see `pinnedRouteRender.ts`).
|
|
700
|
+
locals.content = candidatePin
|
|
701
|
+
? createContentProvider(tenant, undefined, { get: kvGet, versionId: candidatePin.versionId })
|
|
702
|
+
: createContentProvider(tenant, {
|
|
703
|
+
get: kvGet,
|
|
704
|
+
env: customization.env,
|
|
705
|
+
});
|
|
669
706
|
// Developer-draft content overlay — serves the AI-edited artifacts to the draft
|
|
670
707
|
// owner only; every other viewer keeps the published/base chain above.
|
|
671
708
|
if (draft) {
|
|
@@ -686,8 +723,11 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
686
723
|
locals.activeWidgetPlacements = [];
|
|
687
724
|
// Prefix that path-routed pages live under ("" for host-routed). Pages stay
|
|
688
725
|
// tenant-agnostic; the Layout uses this to keep root-absolute links inside
|
|
689
|
-
// the tenant prefix on the client.
|
|
690
|
-
|
|
726
|
+
// the tenant prefix on the client. A candidate content pin overrides this to
|
|
727
|
+
// the PINNING render's own prefix (`/preview/rev/<sha>` or
|
|
728
|
+
// `/preview/render/<tenant>/<changeId>`) so a pinned page's OWN generated
|
|
729
|
+
// links stay inside that candidate instead of escaping to the live host.
|
|
730
|
+
locals.basePath = candidatePin ? candidatePin.basePath : basePath;
|
|
691
731
|
// Renderer-owned four-stage browser environment (ADR 0014 E1 / 0017), computed
|
|
692
732
|
// ONCE here from authoritative request state and serialized before app code by
|
|
693
733
|
// both document mounts (Layout.astro + rawHtmlResponse). The candidate render
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.14",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -130,6 +130,106 @@ export async function verifyPreviewToken(
|
|
|
130
130
|
return { ok: true, claims };
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
/**
|
|
134
|
+
* Query-string param a candidate/revision renderer's `context.rewrite()` uses to
|
|
135
|
+
* carry a signed content-pin token into the re-dispatched request — read by
|
|
136
|
+
* `src/middleware/index.ts` to bind that ONE request's content resolution to an
|
|
137
|
+
* exact `versionId` instead of the shared `{live,test}` channel. See
|
|
138
|
+
* {@link ContentPinClaims}.
|
|
139
|
+
*/
|
|
140
|
+
export const CANDIDATE_CONTENT_PIN_PARAM = "__tot_candidate_pin";
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Claims carried by a CONTENT-PIN token — distinct from {@link PreviewClaims}
|
|
144
|
+
* (which grants "test" channel access). A content pin grants read access to ONE
|
|
145
|
+
* exact immutable site version, bypassing channel resolution entirely. It backs
|
|
146
|
+
* the candidate/revision renderer's generic-route fallback: rendering any real
|
|
147
|
+
* Astro page (not just the raw `home.html`/`pages-html/*` documents) pinned to a
|
|
148
|
+
* candidate's `versionId` rather than the shared `live`/`test` channel.
|
|
149
|
+
*/
|
|
150
|
+
export interface ContentPinClaims {
|
|
151
|
+
tenantId: string;
|
|
152
|
+
versionId: string;
|
|
153
|
+
/** basePath the pinned render's own generated links must stay under. */
|
|
154
|
+
basePath: string;
|
|
155
|
+
/** Expiry, epoch seconds. */
|
|
156
|
+
exp: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Mint a content-pin token: `<payloadB64Url>.<sigB64Url>`. `ttlSeconds` is added
|
|
161
|
+
* to `nowSeconds` (caller supplies the clock so this stays pure/testable).
|
|
162
|
+
*/
|
|
163
|
+
export async function signContentPinToken(
|
|
164
|
+
secret: string,
|
|
165
|
+
claims: Omit<ContentPinClaims, "exp">,
|
|
166
|
+
ttlSeconds: number,
|
|
167
|
+
nowSeconds: number,
|
|
168
|
+
): Promise<string> {
|
|
169
|
+
const full: ContentPinClaims = { ...claims, exp: nowSeconds + ttlSeconds };
|
|
170
|
+
const payload = bytesToB64Url(textEncoder.encode(JSON.stringify(full)));
|
|
171
|
+
const key = await hmacKey(secret);
|
|
172
|
+
const sig = await crypto.subtle.sign(
|
|
173
|
+
"HMAC",
|
|
174
|
+
key,
|
|
175
|
+
textEncoder.encode(payload) as unknown as BufferSource,
|
|
176
|
+
);
|
|
177
|
+
return `${payload}.${bytesToB64Url(new Uint8Array(sig))}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type VerifyContentPinResult =
|
|
181
|
+
| { ok: true; claims: ContentPinClaims }
|
|
182
|
+
| { ok: false; reason: "malformed" | "bad-signature" | "expired" };
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Verify a content-pin token against `secret` and the caller's clock. Signature
|
|
186
|
+
* is checked with `crypto.subtle.verify` (constant-time) before expiry.
|
|
187
|
+
*/
|
|
188
|
+
export async function verifyContentPinToken(
|
|
189
|
+
secret: string,
|
|
190
|
+
token: string,
|
|
191
|
+
nowSeconds: number,
|
|
192
|
+
): Promise<VerifyContentPinResult> {
|
|
193
|
+
const dot = token.indexOf(".");
|
|
194
|
+
if (dot <= 0 || dot === token.length - 1) return { ok: false, reason: "malformed" };
|
|
195
|
+
const payload = token.slice(0, dot);
|
|
196
|
+
const sigPart = token.slice(dot + 1);
|
|
197
|
+
|
|
198
|
+
let sig: Uint8Array;
|
|
199
|
+
try {
|
|
200
|
+
sig = b64UrlToBytes(sigPart);
|
|
201
|
+
} catch {
|
|
202
|
+
return { ok: false, reason: "malformed" };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const key = await hmacKey(secret);
|
|
206
|
+
const valid = await crypto.subtle.verify(
|
|
207
|
+
"HMAC",
|
|
208
|
+
key,
|
|
209
|
+
sig as unknown as BufferSource,
|
|
210
|
+
textEncoder.encode(payload) as unknown as BufferSource,
|
|
211
|
+
);
|
|
212
|
+
if (!valid) return { ok: false, reason: "bad-signature" };
|
|
213
|
+
|
|
214
|
+
let claims: ContentPinClaims;
|
|
215
|
+
try {
|
|
216
|
+
claims = JSON.parse(new TextDecoder().decode(b64UrlToBytes(payload)));
|
|
217
|
+
} catch {
|
|
218
|
+
return { ok: false, reason: "malformed" };
|
|
219
|
+
}
|
|
220
|
+
if (
|
|
221
|
+
!claims ||
|
|
222
|
+
typeof claims.tenantId !== "string" ||
|
|
223
|
+
typeof claims.versionId !== "string" ||
|
|
224
|
+
typeof claims.basePath !== "string" ||
|
|
225
|
+
typeof claims.exp !== "number"
|
|
226
|
+
) {
|
|
227
|
+
return { ok: false, reason: "malformed" };
|
|
228
|
+
}
|
|
229
|
+
if (claims.exp <= nowSeconds) return { ok: false, reason: "expired" };
|
|
230
|
+
return { ok: true, claims };
|
|
231
|
+
}
|
|
232
|
+
|
|
133
233
|
export type CustomizationEnv = "live" | "test";
|
|
134
234
|
|
|
135
235
|
export interface EnvResolutionInput {
|
|
@@ -286,6 +286,46 @@ export async function readPublishedArtifact(
|
|
|
286
286
|
return get(customizationArtifactKey(tenantId, versionId));
|
|
287
287
|
}
|
|
288
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Read one artifact's text from a resolved immutable `versionId`, or null when
|
|
291
|
+
* that specific artifact is absent from the version. Pinned to the versionId,
|
|
292
|
+
* never a channel pointer — a candidate/revision preview must never resolve
|
|
293
|
+
* live/test content. `path` is the FULL artifact path (e.g. from
|
|
294
|
+
* `contentArtifactPath`), not tenant-content-relative.
|
|
295
|
+
*/
|
|
296
|
+
export async function readVersionArtifact(
|
|
297
|
+
get: KvGet,
|
|
298
|
+
tenantId: string,
|
|
299
|
+
versionId: string,
|
|
300
|
+
path: string,
|
|
301
|
+
): Promise<string | null> {
|
|
302
|
+
const manifest = await readSiteVersion(get, tenantId, versionId);
|
|
303
|
+
const contentHash = manifest?.artifacts?.[path];
|
|
304
|
+
if (!contentHash) return null;
|
|
305
|
+
return get(customizationArtifactKey(tenantId, contentHash));
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* List artifact paths in an immutable site version, pinned directly to
|
|
310
|
+
* `versionId` (never a channel pointer) — the enumeration companion to
|
|
311
|
+
* {@link readVersionArtifact}, used by a pinned `ContentProvider` to answer
|
|
312
|
+
* `listArtifactPaths` the same way {@link listPublishedArtifactPaths} does for
|
|
313
|
+
* the channel-resolved read. `prefix` is content-relative (e.g. `"blog/"`).
|
|
314
|
+
*/
|
|
315
|
+
export async function listVersionArtifactPaths(
|
|
316
|
+
get: KvGet,
|
|
317
|
+
tenantId: string,
|
|
318
|
+
versionId: string,
|
|
319
|
+
prefix = "",
|
|
320
|
+
): Promise<string[]> {
|
|
321
|
+
const manifest = await readSiteVersion(get, tenantId, versionId);
|
|
322
|
+
const root = contentArtifactPath(tenantId, "");
|
|
323
|
+
return Object.keys(manifest?.artifacts ?? {})
|
|
324
|
+
.flatMap((path) => (path.startsWith(root) ? [path.slice(root.length)] : []))
|
|
325
|
+
.filter((path) => path.startsWith(prefix))
|
|
326
|
+
.sort((a, b) => a.localeCompare(b));
|
|
327
|
+
}
|
|
328
|
+
|
|
289
329
|
/**
|
|
290
330
|
* List artifact paths in the immutable site version selected for `(tenant, env)`.
|
|
291
331
|
*
|