@substrat-run/control-plane-api 0.48.1 → 0.49.0

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/dist/api.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AA6B5B,OAAO,KAAK,EAGV,eAAe,EAIhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAe3D,OAAO,KAAK,EAAE,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAC5E,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAQtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,qBAAqB,CAAC;IAC5C;;;;;;;;OAQG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;IAClC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AA8M7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CAygFhG"}
1
+ {"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AA+B5B,OAAO,KAAK,EAGV,eAAe,EAIhB,MAAM,yBAAyB,CAAC;AACjC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGtD,OAAO,KAAK,EAAE,iBAAiB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AAC3E,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAe3D,OAAO,KAAK,EAAe,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AACzF,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAQtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAC9D,OAAO,EAGL,KAAK,yBAAyB,EAC/B,MAAM,uBAAuB,CAAC;AAE/B,MAAM,WAAW,sBAAsB;IACrC,IAAI,EAAE,SAAS,CAAC;IAChB;;;;;;;OAOG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC3C;;;;;;OAMG;IACH,eAAe,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IAChG;;;;;;;;;OASG;IACH,sBAAsB,CAAC,EAAE,CACvB,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,eAAe,KACnB,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACzC;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,CAAC,aAAa,EAAE,MAAM,KAAK,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC,CAAC;IACpF;;;;;OAKG;IACH,cAAc,CAAC,EAAE,gBAAgB,CAAC;IAClC;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,sBAAsB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,qBAAqB,CAAC;IAC5C;;;;;;;;OAQG;IACH,sBAAsB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3C;;;;OAIG;IACH,YAAY,EAAE,iBAAiB,CAAC;IAChC;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,WAAW,CAAC;IAClC;;;;;OAKG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB;;;;;;;;OAQG;IACH,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC;;;;;;;OAOG;IACH,iBAAiB,CAAC,EAAE,yBAAyB,CAAC;IAC9C;;;;;;OAMG;IACH,mBAAmB,CAAC,EAAE,MAAM,EAAE,CAAC;CAChC;AAKD,KAAK,IAAI,GAAG;IAAE,KAAK,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,SAAS,CAAA;CAAE,CAAC;AA8M7D;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,sBAAsB,GAAG,IAAI,CAAC;IAAE,SAAS,EAAE,IAAI,CAAA;CAAE,CAAC,CA8oFhG"}
package/dist/api.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Hono } from 'hono';
2
- import { adminAction, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
2
+ import { adminAction, ASSET_PART_PREFIX, assetHash, channelName, createTenantInput, entitlementGrantInput, hostname as hostnameSchema, hostnameRegion, hostnameStatus, identityLink, listPageQuery, pageOf, principalId as principalIdSchema, promotionAcknowledgement, provisionableJurisdiction, publishVersionInput, queryScopeInput, readScopeTableInput, registerVerticalInput, scopeDump, scopeId as scopeIdSchema, scopeStatus, storageShape, surfaceName, tenantId as tenantIdSchema, tenantStatus, z, } from '@substrat-run/contracts';
3
3
  import { migrationProgress, ulid } from '@substrat-run/kernel';
4
4
  import { TENANT_HEADER } from './auth.js';
5
5
  import { ControlPlaneError } from './client.js';
@@ -1578,6 +1578,22 @@ export function createControlPlaneApi(options) {
1578
1578
  const registry = json ? (storedDeployManifest.parse(JSON.parse(json)).registry ?? null) : null;
1579
1579
  return c.json({ registry });
1580
1580
  });
1581
+ // The static files (#340) one version ships: path, size, content type, content address —
1582
+ // read straight out of the retained manifest, which is where they were persisted for the
1583
+ // promote path anyway. Owner-narrowed exactly like the registry route above. `assets` is
1584
+ // null for a version that retained no manifest (pushed pre-#286) or shipped no static
1585
+ // files; the two are distinguishable by the caller only as "nothing to show", which is
1586
+ // all the dashboard panel needs to render an empty state.
1587
+ app.get('/verticals/:slug/versions/:id/assets', async (c) => {
1588
+ const p = c.get('principal');
1589
+ const slug = await resolveVerticalId(c, c.req.param('slug'));
1590
+ if (p.kind === 'builder' && (await ownerOf(p.actor, slug)) !== p.tenantId) {
1591
+ return c.json({ error: 'not found' }, 404);
1592
+ }
1593
+ const json = await admin.versionManifest(c.get('actor'), slug, c.req.param('id'));
1594
+ const assets = json ? (storedDeployManifest.parse(JSON.parse(json)).assets ?? null) : null;
1595
+ return c.json({ assets });
1596
+ });
1581
1597
  app.post('/verticals/:slug/versions/:id/admit', async (c) => {
1582
1598
  const slug = c.req.param('slug');
1583
1599
  const id = c.req.param('id');
@@ -1725,6 +1741,13 @@ export function createControlPlaneApi(options) {
1725
1741
  modules,
1726
1742
  doClasses: manifest.doClasses,
1727
1743
  bindings: [...manifest.bindings, ...storeBindings],
1744
+ // #340: the version's static files travel with it onto the serving script — from
1745
+ // the RETAINED manifest, with no bytes. An asset upload session is driven by
1746
+ // content addresses, and the runtime's asset store is namespace-wide and deduped,
1747
+ // so re-declaring the same hashes re-attaches the same files. This is why the
1748
+ // manifest is retained rather than the bytes: the archive script gives back the
1749
+ // modules (#286), and the asset store gives back the assets.
1750
+ ...(manifest.assets ? { assets: manifest.assets } : {}),
1728
1751
  }, serving
1729
1752
  ? { priorDoClasses: serving.doClasses, priorMigrationTag: serving.migrationTag }
1730
1753
  : undefined);
@@ -1915,21 +1938,72 @@ export function createControlPlaneApi(options) {
1915
1938
  }, 409);
1916
1939
  }
1917
1940
  }
1941
+ // Two kinds of part in one body (#340): worker MODULES (unprefixed) and static ASSETS
1942
+ // (`asset:<served path>`). Splitting on the prefix — rather than on content type or on
1943
+ // whether the manifest happens to name it — is what keeps an uploaded file from being
1944
+ // able to enter the wrong pipeline.
1918
1945
  const modules = [];
1946
+ const assetParts = new Map();
1919
1947
  for (const [name, value] of form.entries()) {
1920
1948
  if (name === 'manifest')
1921
1949
  continue;
1922
- if (value instanceof File) {
1923
- modules.push({
1924
- name,
1925
- content: new Uint8Array(await value.arrayBuffer()),
1926
- contentType: value.type || 'application/javascript+module',
1927
- });
1950
+ if (!(value instanceof File))
1951
+ continue;
1952
+ if (name.startsWith(ASSET_PART_PREFIX)) {
1953
+ assetParts.set(name.slice(ASSET_PART_PREFIX.length), value);
1954
+ continue;
1928
1955
  }
1956
+ modules.push({
1957
+ name,
1958
+ content: new Uint8Array(await value.arrayBuffer()),
1959
+ contentType: value.type || 'application/javascript+module',
1960
+ });
1929
1961
  }
1930
1962
  if (!modules.some((m) => m.name === manifest.entry)) {
1931
1963
  return c.json({ error: `entry module '${manifest.entry}' is not among the uploaded files` }, 400);
1932
1964
  }
1965
+ // The static-asset half of the §4 sandbox contract (self-serve-deploy.md §4.1). The
1966
+ // bytes are inert — no code, no authority — so they are ACCEPTED; the content-address
1967
+ // is not, so it is VERIFIED. The runtime's asset store dedups by hash across the whole
1968
+ // dispatch namespace, which means bytes stored under a hash they do not have would let
1969
+ // one push decide what another vertical's identical-hash asset serves. Re-deriving the
1970
+ // hash here (from the received bytes, with the same `assetHash` the CLI used) is what
1971
+ // makes that structurally impossible — and it is the only inspection of the bytes we do.
1972
+ const assets = [];
1973
+ for (const file of manifest.assets?.files ?? []) {
1974
+ const part = assetParts.get(file.path);
1975
+ if (!part) {
1976
+ return c.json({ error: `asset '${file.path}' is named in the manifest but was not uploaded` }, 400);
1977
+ }
1978
+ const content = new Uint8Array(await part.arrayBuffer());
1979
+ if (content.byteLength !== file.size) {
1980
+ return c.json({ error: `asset '${file.path}' declares ${file.size} bytes but ${content.byteLength} arrived` }, 400);
1981
+ }
1982
+ const actual = await assetHash(content, file.path);
1983
+ if (actual !== file.hash) {
1984
+ return c.json({
1985
+ error: `asset '${file.path}' does not match its declared content hash ` +
1986
+ `(declared ${file.hash}, computed ${actual}) — the hash is the runtime's shared dedup key, ` +
1987
+ `so a mismatch is refused rather than stored`,
1988
+ }, 400);
1989
+ }
1990
+ assets.push({
1991
+ path: file.path,
1992
+ hash: file.hash,
1993
+ size: file.size,
1994
+ contentType: file.contentType,
1995
+ content,
1996
+ });
1997
+ assetParts.delete(file.path);
1998
+ }
1999
+ if (assetParts.size > 0) {
2000
+ // An unlisted asset part would be uploaded by nothing and served by nothing; saying so
2001
+ // beats silently dropping it, because the builder's page would 404 with no explanation.
2002
+ const extra = [...assetParts.keys()].slice(0, 5).join(', ');
2003
+ return c.json({
2004
+ error: `${assetParts.size} uploaded asset part(s) are absent from the manifest (${extra}${assetParts.size > 5 ? ', …' : ''})`,
2005
+ }, 400);
2006
+ }
1933
2007
  // Mint the version id first: the deploymentRef (the dispatch script name) is keyed
1934
2008
  // on it, so it is CF-valid and unique per version.
1935
2009
  const id = ulid();
@@ -1942,6 +2016,10 @@ export function createControlPlaneApi(options) {
1942
2016
  modules,
1943
2017
  doClasses: manifest.doClasses,
1944
2018
  bindings: manifest.bindings,
2019
+ // #340: the verified bytes go up with the bundle. The manifest's routing config
2020
+ // rides along untouched — it decides what the RUNTIME does with paths, and carries
2021
+ // no reach, so there is nothing in it for the sandbox contract to refuse.
2022
+ ...(manifest.assets ? { assets: { ...manifest.assets, files: assets } } : {}),
1945
2023
  });
1946
2024
  }
1947
2025
  catch (e) {
@@ -2041,16 +2119,20 @@ export function createControlPlaneApi(options) {
2041
2119
  if (!options.observability) {
2042
2120
  return c.json({ error: 'observability is not configured on this control plane' }, 501);
2043
2121
  }
2122
+ // `service` repeats: one deployed unit per param, so a caller can ask for a
2123
+ // vertical's whole set (the dashboard's "all versions") in one query and get one
2124
+ // merged stream back. Capped because each extra service is another backend query.
2125
+ const services = (c.req.queries('service') ?? []).filter((s) => s.length > 0);
2044
2126
  const input = z
2045
2127
  .object({
2046
- service: z.string().min(1).max(200).optional(),
2128
+ services: z.array(z.string().min(1).max(200)).max(20).optional(),
2047
2129
  level: z.enum(['log', 'info', 'warn', 'error', 'debug']).optional(),
2048
2130
  search: z.string().min(1).max(200).optional(),
2049
2131
  hours: z.coerce.number().int().min(1).max(72).default(1),
2050
2132
  limit: z.coerce.number().int().min(1).max(500).default(100),
2051
2133
  })
2052
2134
  .parse({
2053
- service: c.req.query('service') || undefined,
2135
+ services: services.length ? services : undefined,
2054
2136
  level: c.req.query('level') || undefined,
2055
2137
  search: c.req.query('search') || undefined,
2056
2138
  hours: c.req.query('hours'),
@@ -2265,6 +2347,16 @@ export function createControlPlaneApi(options) {
2265
2347
  * label is unique within a tenant, so dropping the prefix stays collision-free. Both
2266
2348
  * create (provision + reuse-match) and delete (reap-match) run through here, so they agree. */
2267
2349
  const previewSlug = (slug, tag) => `${slug.split('/').at(-1)}--${tag}`;
2350
+ /** Reap one preview: wipe the DO in its own deployment, then drop the directory row and
2351
+ * its hostnames. Storage-before-row, the same ordering the DELETE route uses — a crash
2352
+ * between the two converges on retry. Shared by that route and by the create path, which
2353
+ * reaps a HALF-BUILT leftover before re-forking (see `orchestratedPreview`). */
2354
+ const reapPreview = async (c, preview) => {
2355
+ const vertical = await verticalForScope(c, preview);
2356
+ if (vertical)
2357
+ await vertical.deleteScope({ scopeId: preview.id });
2358
+ await options.host.deleteSnapshot(c.get('actor'), preview.tenantId, preview.id);
2359
+ };
2268
2360
  /** Given a base hostname `<label>.<domain>`, mint (or find) the preview's `--<tag>`
2269
2361
  * hostname `<label>--<tag>.<domain>` bound to `previewId`. Non-canonical, so it never
2270
2362
  * demotes the prod surface. Shared by the fork and clean-room paths. */
@@ -2302,13 +2394,22 @@ export function createControlPlaneApi(options) {
2302
2394
  }
2303
2395
  return src.hostname;
2304
2396
  }
2305
- const base = platformBaseDomains[0];
2306
- if (!base) {
2397
+ if (platformBaseDomains.length === 0) {
2307
2398
  throw new ControlPlaneError(409, `no platform base domain configured — a clean-room preview has no source URL to derive from`);
2308
2399
  }
2400
+ // Mint under `<label>.<jurisdiction>.<baseDomain>`, exactly as provisioning does
2401
+ // (provision.ts `bindDefaultHostname` → `egeryds.global.substrat.run`). A clean-room
2402
+ // preview scope is provisioned `global` by construction (see the caller), and the
2403
+ // wildcard DNS/cert lives on `*.global.substrat.run` — NOT the certless apex
2404
+ // `*.substrat.run`. `platformBaseDomains` lists every platform suffix for custom-hostname
2405
+ // detection (`substrat.run`, `global.substrat.run`, …); the registrable base is the
2406
+ // shortest, the one all jurisdiction domains are subdomains of. Taking `[0]` grabbed the
2407
+ // bare apex and stranded clean-room previews on a hostname that never resolves.
2408
+ const baseDomain = [...platformBaseDomains].sort((a, b) => a.length - b.length)[0];
2409
+ const jurisdiction = 'global';
2309
2410
  const tenant = await admin.getTenant(actor, tenantId);
2310
2411
  const handle = tenant?.slug ?? tenantId;
2311
- return `${slug.split('/').at(-1)}-${handle}.${base}`;
2412
+ return `${slug.split('/').at(-1)}-${handle}.${jurisdiction}.${baseDomain}`;
2312
2413
  };
2313
2414
  const orchestratedPreview = async (c, tenantId, slug,
2314
2415
  // A FORK copies this scope's data; `null` provisions an empty clean-room scope (#509 (b)).
@@ -2361,7 +2462,20 @@ export function createControlPlaneApi(options) {
2361
2462
  // onto the SAME preview — successive pushes roll their migrations forward on one copy
2362
2463
  // (§4's rehearsal case) — unless `refresh` asks for a fresh one.
2363
2464
  const existing = (await admin.listScopes(actor, { tenantId, vertical: slug })).find((s) => s.kind === 'preview' && s.slug === previewSlug(slug, opts.tag));
2364
- if (existing && !opts.refresh) {
2465
+ // A preview only HAS data once its two-phase create finished: the directory row lands
2466
+ // first as `provisioning` (K-31), the fork's export→restore runs, and `activateScope`
2467
+ // is the last step. So a row still at `provisioning` is a create that DIED mid-fork —
2468
+ // its DO is empty. Reuse must never adopt one: reuse only rebinds the version and the
2469
+ // hostname, it never copies data, so adopting a half-built row hands back a
2470
+ // permanently EMPTY preview and reports `reused: true` — success for a URL that shows
2471
+ // a reviewer no data at all. That is exactly what a CI retry does (the generated
2472
+ // workflow retries `preview create` on a transient), so the failure mode is the
2473
+ // COMMON one, not a corner: attempt 1 forks and dies, attempt 2 adopts its corpse and
2474
+ // goes green. Instead, reap the leftover and fall through to a fresh fork below —
2475
+ // which is what the retry was asking for. Same for an explicit `refresh`, whose fresh
2476
+ // scope would otherwise collide with the old row's still-bound `--<tag>` hostname.
2477
+ const stale = existing !== undefined && (opts.refresh || existing.status !== 'active');
2478
+ if (existing && !stale) {
2365
2479
  // Heal a preview provisioned before #527: clear any inherited serving_ref so routing
2366
2480
  // follows the bound version (its per-version script), not the prod serving script.
2367
2481
  if (existing.servingRef)
@@ -2373,6 +2487,10 @@ export function createControlPlaneApi(options) {
2373
2487
  await assertServesBoundVersion(existing.id);
2374
2488
  return { scopeId: existing.id, hostname, url: `https://${hostname}`, versionId: opts.versionId, reused: true };
2375
2489
  }
2490
+ // Free the tag: the slug is unique per tenant and the `--<tag>` hostname is still bound
2491
+ // to the old row, so the fresh fork below cannot be provisioned until this one is gone.
2492
+ if (existing && stale)
2493
+ await reapPreview(c, existing);
2376
2494
  const previewId = scopeIdSchema.parse(ulid());
2377
2495
  if (source) {
2378
2496
  // A fresh fork. Export from where the prod data lives TODAY. The canonical
@@ -2531,10 +2649,7 @@ export function createControlPlaneApi(options) {
2531
2649
  // Storage-before-row, the same ordering as the fork hard-delete: wipe the DO in the
2532
2650
  // PR version's deployment, then deleteSnapshot (fork-only re-check, hostnames + row,
2533
2651
  // audit). A crash between the two converges on retry.
2534
- const vertical = await verticalForScope(c, preview);
2535
- if (vertical)
2536
- await vertical.deleteScope({ scopeId: preview.id });
2537
- await options.host.deleteSnapshot(actor, tenantId, preview.id);
2652
+ await reapPreview(c, preview);
2538
2653
  return c.json({ deleted: preview.id });
2539
2654
  }
2540
2655
  catch (e) {