@superblocksteam/sdk 2.0.131-next.1 → 2.0.132-next.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.
@@ -18,7 +18,6 @@ import {
18
18
  ConflictError,
19
19
  DEFAULT_NON_GIT_BRANCH,
20
20
  isGitHubRemoteUrl,
21
- NotFoundError,
22
21
  SUPERBLOCKS_LIVE_GIT_BRANCH,
23
22
  } from "@superblocksteam/shared";
24
23
  import { maskUnixSignals } from "@superblocksteam/util";
@@ -234,6 +233,17 @@ async function readPackageLock(cwd: string): Promise<string | null> {
234
233
  }
235
234
  }
236
235
 
236
+ async function readInstalledPackageLock(cwd: string): Promise<string | null> {
237
+ try {
238
+ return await nodeFs.readFile(
239
+ path.join(cwd, "node_modules", ".package-lock.json"),
240
+ "utf8",
241
+ );
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
246
+
237
247
  /**
238
248
  * Canonicalize a lockfile for post-install change detection. Every boot,
239
249
  * `stripResolvedFromLockfile` (APPS-4300) removes the `resolved` URLs and
@@ -262,23 +272,120 @@ export function lockfileComparisonKey(raw: string | null): string | null {
262
272
  }
263
273
  }
264
274
 
265
- async function shouldValidatePrivateRegistryInstall(
275
+ function registryUrlMatches(resolved: string, registryUrl: string): boolean {
276
+ try {
277
+ const resolvedUrl = new URL(resolved);
278
+ const registry = new URL(registryUrl);
279
+ return (
280
+ resolvedUrl.protocol === registry.protocol &&
281
+ resolvedUrl.host === registry.host &&
282
+ resolvedUrl.pathname.startsWith(registry.pathname.replace(/\/?$/, "/"))
283
+ );
284
+ } catch {
285
+ return false;
286
+ }
287
+ }
288
+
289
+ function packageNameFromLockfilePath(lockfilePath: string): string | null {
290
+ const parts = lockfilePath.split("/");
291
+ const nodeModulesIndex = parts.lastIndexOf("node_modules");
292
+ if (nodeModulesIndex < 0) return null;
293
+ const first = parts[nodeModulesIndex + 1];
294
+ if (!first) return null;
295
+ if (first.startsWith("@")) {
296
+ const second = parts[nodeModulesIndex + 2];
297
+ return second ? `${first}/${second}` : null;
298
+ }
299
+ return first;
300
+ }
301
+
302
+ function registryUrlForPackage(
303
+ packageName: string,
304
+ result: NpmRegistryFetchResult,
305
+ ): string | null {
306
+ const scope = packageName.startsWith("@")
307
+ ? packageName.slice(0, packageName.indexOf("/"))
308
+ : null;
309
+ if (!scope) {
310
+ return result.config.default?.url ?? "https://registry.npmjs.org/";
311
+ }
312
+ // npm falls back to the default registry for scoped packages without an
313
+ // explicit scope registry, so mirror that resolution order here.
314
+ return (
315
+ (scope ? result.config.scopes?.[scope]?.url : undefined) ??
316
+ result.config.default?.url ??
317
+ null
318
+ );
319
+ }
320
+
321
+ function lockfileResolvesThroughRegistry(
322
+ raw: string | null,
323
+ result: NpmRegistryFetchResult,
324
+ logger: Logger,
325
+ ): boolean {
326
+ if (!raw) return false;
327
+ try {
328
+ const parsed = JSON.parse(raw) as { packages?: Record<string, unknown> };
329
+ if (!parsed.packages || typeof parsed.packages !== "object") return false;
330
+
331
+ let checked = 0;
332
+ for (const [lockfilePath, entry] of Object.entries(parsed.packages)) {
333
+ if (!entry || typeof entry !== "object") continue;
334
+ const resolved = (entry as { resolved?: unknown }).resolved;
335
+ if (typeof resolved !== "string" || !resolved.startsWith("http")) {
336
+ continue;
337
+ }
338
+ const packageName = packageNameFromLockfilePath(lockfilePath);
339
+ if (!packageName) continue;
340
+ const registryUrl = registryUrlForPackage(packageName, result);
341
+ if (!registryUrl || !registryUrlMatches(resolved, registryUrl)) {
342
+ return false;
343
+ }
344
+ checked += 1;
345
+ }
346
+ // No HTTP resolved URLs means there is no registry proof; fall back to npm.
347
+ return checked > 0;
348
+ } catch (error) {
349
+ logger.warn(
350
+ "Failed to parse package-lock.json for private registry skip check; falling back to install",
351
+ getErrorMeta(error),
352
+ );
353
+ return false;
354
+ }
355
+ }
356
+
357
+ function lockfileProvesSafeToSkipRegistryInstall(
358
+ packageLock: string | null,
359
+ installedPackageLock: string | null,
360
+ result: NpmRegistryFetchResult,
361
+ logger: Logger,
362
+ ): boolean {
363
+ if (!packageLock || !installedPackageLock) return false;
364
+ // Structural equality, including integrity hashes, proves node_modules was
365
+ // installed from the same package graph. The root lockfile's resolved URLs
366
+ // prove future installs will use the fetched registry config, including a
367
+ // stale last-known-good config while the registry service is unavailable.
368
+ return (
369
+ lockfileComparisonKey(packageLock) ===
370
+ lockfileComparisonKey(installedPackageLock) &&
371
+ lockfileResolvesThroughRegistry(packageLock, result, logger)
372
+ );
373
+ }
374
+
375
+ async function privateRegistryValidationResult(
266
376
  npmRegistryClient: NpmRegistryClient | undefined,
267
377
  logger: Logger,
268
- ): Promise<boolean> {
378
+ ): Promise<NpmRegistryFetchResult | null> {
269
379
  if (!npmRegistryClient) {
270
- return false;
380
+ return null;
271
381
  }
272
382
 
273
383
  try {
274
384
  const result: NpmRegistryFetchResult = await npmRegistryClient.getConfig();
275
385
  // `configured` / `stale` → the org is known to route installs through a
276
386
  // registry (`stale` = last-known-good config during a config-service
277
- // outage), so the startup install must run even on identical
278
- // package.json snapshots: with `resolved` stripped from the lockfiles
279
- // (APPS-4300), npm re-resolves the entire baked tree through that
280
- // registry, surfacing missing packages at app-creation time instead of
281
- // at deploy-time bundle build (APPS-4527).
387
+ // outage), so any startup install that is already required should also
388
+ // validate through that registry.
282
389
  //
283
390
  // `not-configured` and `unreachable` deliberately do NOT force the
284
391
  // install. Not-configured orgs gain nothing from re-resolving against
@@ -289,13 +396,15 @@ async function shouldValidatePrivateRegistryInstall(
289
396
  // the wrong host and writing its URLs back into the lockfile. Fail
290
397
  // closed instead (same principle as the AppShell short-circuit,
291
398
  // APPS-4370): skip validation and keep today's snapshot decision.
292
- return result.source === "configured" || result.source === "stale";
399
+ return result.source === "configured" || result.source === "stale"
400
+ ? result
401
+ : null;
293
402
  } catch (err) {
294
403
  logger.warn(
295
404
  "Could not resolve npm registry config for startup install validation; preserving package snapshot decision",
296
405
  getErrorMeta(err),
297
406
  );
298
- return false;
407
+ return null;
299
408
  }
300
409
  }
301
410
 
@@ -979,12 +1088,10 @@ export async function dev(options: {
979
1088
  async (span) => {
980
1089
  try {
981
1090
  return await Promise.all([
982
- getDraftOrLiveEditHash(
983
- sdk,
984
- applicationConfig.id,
985
- activeDbfsBranchName,
986
- logger,
987
- ),
1091
+ sdk.dbfsGetApplicationDirectoryHash({
1092
+ applicationId: applicationConfig.id,
1093
+ branch: activeDbfsBranchName,
1094
+ }),
988
1095
  sdk.fetchCurrentUser(),
989
1096
  ]);
990
1097
  } finally {
@@ -1039,7 +1146,6 @@ export async function dev(options: {
1039
1146
  logger,
1040
1147
  tokenManager,
1041
1148
  features: AiServiceFeatureFlags.create(flagBootstrap),
1042
- flagBootstrap,
1043
1149
  pluginExecutionVersions:
1044
1150
  currentUser.organizations[0].pluginExecutionVersions,
1045
1151
  });
@@ -1055,10 +1161,6 @@ export async function dev(options: {
1055
1161
  },
1056
1162
  );
1057
1163
 
1058
- if (uploadFirst) {
1059
- aiService?.notifyMigrationAppArtifactsReady();
1060
- }
1061
-
1062
1164
  logger.info(
1063
1165
  `[dev-startup] Starting git sync (${downloadFirst ? "download" : "upload"}-first) on branch '${activeDbfsBranchName}'`,
1064
1166
  );
@@ -1250,20 +1352,32 @@ export async function dev(options: {
1250
1352
  logger.info("[dev-startup] Skipping download, already in sync");
1251
1353
  }
1252
1354
 
1253
- if (downloadFirst) {
1254
- aiService?.notifyMigrationAppArtifactsReady();
1255
- }
1355
+ const npmRegistryClient = aiService?.getNpmRegistryClient();
1356
+ const registryValidationResult =
1357
+ await privateRegistryValidationResult(npmRegistryClient, logger);
1358
+ const packageLockBeforeResolvedStrip = await readPackageLock(cwd);
1359
+ const installedPackageLockBeforeResolvedStrip =
1360
+ await readInstalledPackageLock(cwd);
1361
+ const lockfileAlreadyResolvesThroughRegistry =
1362
+ !!registryValidationResult &&
1363
+ lockfileProvesSafeToSkipRegistryInstall(
1364
+ packageLockBeforeResolvedStrip,
1365
+ installedPackageLockBeforeResolvedStrip,
1366
+ registryValidationResult,
1367
+ logger,
1368
+ );
1256
1369
 
1257
- // Unconditional lockfile sanitation (APPS-4300): strip
1258
- // cross-registry `resolved` URLs from any lockfile on disk
1259
- // regardless of whether install runs next. The lockfile here is
1260
- // whatever survived the DBFS path (downloadFirst overwrite,
1261
- // prior boot, brownfield import); npm honors `resolved` verbatim
1262
- // and would bypass the active registry. `integrity` is
1263
- // preserved, so genuine cross-registry tarball drift surfaces as
1264
- // EINTEGRITY. No-op when there's no lockfile or no `resolved`
1265
- // entries.
1266
- await stripResolvedFromLockfile(cwd);
1370
+ // Lockfile sanitation (APPS-4300): strip cross-registry `resolved`
1371
+ // URLs unless the lockfile already proves it resolves through the
1372
+ // active private registry. npm honors `resolved` verbatim;
1373
+ // stripping forces re-resolution through the current `.npmrc`.
1374
+ if (!lockfileAlreadyResolvesThroughRegistry) {
1375
+ await stripResolvedFromLockfile(cwd);
1376
+ } else {
1377
+ logger.info(
1378
+ "Skipping lockfile resolved-URL strip: lockfile already proves private registry resolution",
1379
+ );
1380
+ }
1267
1381
 
1268
1382
  // Materialise `~/.superblocks/npmrc` from the server-fetched
1269
1383
  // per-org npm registry config BEFORE the global Superblocks CLI
@@ -1279,7 +1393,7 @@ export async function dev(options: {
1279
1393
  } else {
1280
1394
  try {
1281
1395
  await syncHomeNpmrc({
1282
- npmRegistryClient: aiService.getNpmRegistryClient(),
1396
+ npmRegistryClient,
1283
1397
  logger,
1284
1398
  orgId: currentUser.user.currentOrganizationId,
1285
1399
  });
@@ -1374,32 +1488,43 @@ export async function dev(options: {
1374
1488
  if (!packageJsonBefore && packageJsonRequiresInstall) {
1375
1489
  logger.info("package.json was created, installing packages…");
1376
1490
  }
1377
- const npmRegistryClient = aiService?.getNpmRegistryClient();
1378
1491
  const forcePackageInstallRequested = !!options.forcePackageInstall;
1379
1492
  let forcePackageInstall = forcePackageInstallRequested;
1380
- const privateRegistryRequiresInstallValidation = packageJsonAfter
1381
- ? await shouldValidatePrivateRegistryInstall(
1382
- npmRegistryClient,
1383
- logger,
1384
- )
1385
- : false;
1493
+ const privateRegistryRequiresInstallValidation =
1494
+ !!registryValidationResult && !!packageJsonAfter;
1495
+ let packageLockBeforeInstall: string | null = null;
1496
+ if (privateRegistryRequiresInstallValidation) {
1497
+ packageLockBeforeInstall = await readPackageLock(cwd);
1498
+ }
1386
1499
  if (
1387
1500
  forcePackageInstallRequested &&
1388
1501
  hasPackageJsonSnapshotBeforeRestore
1389
1502
  ) {
1390
- forcePackageInstall =
1391
- packageJsonRequiresInstall ||
1392
- privateRegistryRequiresInstallValidation;
1503
+ forcePackageInstall = packageJsonRequiresInstall;
1393
1504
  }
1394
1505
 
1506
+ // Run the verification install when EITHER the package.json
1507
+ // requires it, upgrades just modified package.json/lockfile
1508
+ // (re-syncing node_modules), or the caller forced it. Private
1509
+ // registry validation piggybacks on those installs rather than
1510
+ // forcing a no-op refresh to wait for npm.
1511
+ const packageInstallRequired =
1512
+ packageJsonRequiresInstall ||
1513
+ upgradePromises.length > 0 ||
1514
+ forcePackageInstall ||
1515
+ (privateRegistryRequiresInstallValidation &&
1516
+ !lockfileAlreadyResolvesThroughRegistry);
1517
+
1395
1518
  logger.info("Package install decision", {
1396
1519
  packageJsonBeforePresent: !!packageJsonBefore,
1397
1520
  packageJsonAfterPresent: !!packageJsonAfter,
1398
1521
  hasPackageChanged,
1399
1522
  packageJsonRequiresInstall,
1523
+ packageInstallRequired,
1400
1524
  forcePackageInstall,
1401
1525
  forcePackageInstallRequested,
1402
1526
  privateRegistryRequiresInstallValidation,
1527
+ lockfileAlreadyResolvesThroughRegistry,
1403
1528
  upgradePromiseCount: upgradePromises.length,
1404
1529
  packageJsonSnapshotBefore: packageJsonSnapshotDiagnostic(
1405
1530
  packageJsonSnapshotBefore,
@@ -1442,18 +1567,7 @@ export async function dev(options: {
1442
1567
  });
1443
1568
  }
1444
1569
 
1445
- // Run the verification install when EITHER the package.json
1446
- // requires it, upgrades just modified package.json/lockfile
1447
- // (re-syncing node_modules), the caller forced it, or a custom
1448
- // registry must validate that the required packages resolve there.
1449
- let packageLockBeforeInstall: string | null = null;
1450
- if (
1451
- packageJsonRequiresInstall ||
1452
- upgradePromises.length > 0 ||
1453
- forcePackageInstall ||
1454
- privateRegistryRequiresInstallValidation
1455
- ) {
1456
- packageLockBeforeInstall = await readPackageLock(cwd);
1570
+ if (packageInstallRequired) {
1457
1571
  // Launch the install while the upload/restart decisions below
1458
1572
  // are still being evaluated. The synchronous joins at upload,
1459
1573
  // CLI restart, and pre-Vite-startup all observe and surface
@@ -1493,7 +1607,8 @@ export async function dev(options: {
1493
1607
  if (
1494
1608
  shouldUploadPackageState ||
1495
1609
  uploadFirst ||
1496
- privateRegistryRequiresInstallValidation
1610
+ (privateRegistryRequiresInstallValidation &&
1611
+ packageInstallRequired)
1497
1612
  ) {
1498
1613
  // Upload serializes the post-install lockfile + node_modules
1499
1614
  // tree to DBFS, so it must observe quiesced upgrade+install.
@@ -2126,34 +2241,6 @@ async function ensureLiveBranchCheckedOutAfterFetch(
2126
2241
  }
2127
2242
  }
2128
2243
 
2129
- async function getDraftOrLiveEditHash(
2130
- sdk: SuperblocksSdk,
2131
- applicationId: string,
2132
- branchName: string,
2133
- logger: Logger,
2134
- ): Promise<string> {
2135
- try {
2136
- const draftHash = await sdk.dbfsGetApplicationDirectoryHash({
2137
- applicationId,
2138
- branch: branchName,
2139
- });
2140
- return draftHash;
2141
- } catch (e: unknown) {
2142
- if (e instanceof NotFoundError) {
2143
- const liveEditHash = await sdk.dbfsGetApplicationDirectoryHash({
2144
- applicationId,
2145
- branch: branchName,
2146
- });
2147
- logger.warn(
2148
- "Draft state not found, using live edit hash: " + liveEditHash,
2149
- );
2150
- return liveEditHash;
2151
- } else {
2152
- throw e;
2153
- }
2154
- }
2155
- }
2156
-
2157
2244
  async function ensureRuntimeDbfsBranchConsistency({
2158
2245
  sdk,
2159
2246
  applicationConfig,
@@ -82,12 +82,115 @@ describe("getApplicationDirectoryHash", () => {
82
82
  expect(rpcClient.close).toHaveBeenCalledOnce();
83
83
  });
84
84
 
85
- it("falls back from draft to live for non-commit lookups", async () => {
86
- directoryContentsGet
87
- .mockResolvedValueOnce("draft-request")
88
- .mockResolvedValueOnce("live-request");
85
+ // The cold-start restore must prefer the live-edit working copy, which can have
86
+ // advanced past every commit via Clark's interim autosaves. This mirrors the
87
+ // vite-plugin-file-sync copy of getApplicationDirectoryHash (see download.ts).
88
+ // APPS-4873.
89
+ it("prefers the live-edit working copy over the draft/live commit views", async () => {
90
+ directoryContentsGet.mockResolvedValue("request");
91
+ unwrapResponseDto.mockResolvedValueOnce({ hash: "live-edit-hash" });
92
+
93
+ await expect(
94
+ getApplicationDirectoryHash(
95
+ "token",
96
+ "https://app.superblocks.com",
97
+ "app-123",
98
+ "main",
99
+ ),
100
+ ).resolves.toBe("live-edit-hash");
101
+
102
+ expect(directoryContentsGet).toHaveBeenCalledTimes(1);
103
+ expect(directoryContentsGet).toHaveBeenCalledWith({
104
+ applicationId: "app-123",
105
+ branchName: "main",
106
+ viewMode: ExportViewMode.EXPORT_LIVE_EDIT,
107
+ });
108
+ expect(warn).not.toHaveBeenCalled();
109
+ expect(rpcClient.close).toHaveBeenCalledOnce();
110
+ });
111
+
112
+ it("falls back to draft/live when the app has no live-edit working copy", async () => {
113
+ directoryContentsGet.mockResolvedValue("request");
114
+ unwrapResponseDto
115
+ .mockRejectedValueOnce(
116
+ new NotFoundError(
117
+ "Application live-edit directory contents hash not found.",
118
+ ),
119
+ )
120
+ .mockResolvedValueOnce({ hash: "draft-hash" });
121
+
122
+ await expect(
123
+ getApplicationDirectoryHash(
124
+ "token",
125
+ "https://app.superblocks.com",
126
+ "app-123",
127
+ "main",
128
+ ),
129
+ ).resolves.toBe("draft-hash");
130
+
131
+ expect(directoryContentsGet).toHaveBeenNthCalledWith(1, {
132
+ applicationId: "app-123",
133
+ branchName: "main",
134
+ viewMode: ExportViewMode.EXPORT_LIVE_EDIT,
135
+ });
136
+ expect(directoryContentsGet).toHaveBeenNthCalledWith(2, {
137
+ applicationId: "app-123",
138
+ branchName: "main",
139
+ viewMode: ExportViewMode.EXPORT_DRAFT,
140
+ });
141
+ expect(warn).toHaveBeenCalledWith(
142
+ expect.stringContaining("falling back to draft/live views"),
143
+ );
144
+ expect(rpcClient.close).toHaveBeenCalledOnce();
145
+ });
146
+
147
+ it("falls back to draft/live against a server that predates the live-edit view mode", async () => {
148
+ directoryContentsGet.mockResolvedValue("request");
89
149
  unwrapResponseDto
90
- .mockRejectedValueOnce(new NotFoundError("draft hash not found"))
150
+ .mockRejectedValueOnce(
151
+ new Error("Export view mode export-live-edit is not implemented."),
152
+ )
153
+ .mockResolvedValueOnce({ hash: "draft-hash" });
154
+
155
+ await expect(
156
+ getApplicationDirectoryHash(
157
+ "token",
158
+ "https://app.superblocks.com",
159
+ "app-123",
160
+ "main",
161
+ ),
162
+ ).resolves.toBe("draft-hash");
163
+
164
+ expect(directoryContentsGet).toHaveBeenNthCalledWith(2, {
165
+ applicationId: "app-123",
166
+ branchName: "main",
167
+ viewMode: ExportViewMode.EXPORT_DRAFT,
168
+ });
169
+ expect(rpcClient.close).toHaveBeenCalledOnce();
170
+ });
171
+
172
+ it("propagates an unexpected live-edit error instead of silently restoring a stale tree", async () => {
173
+ directoryContentsGet.mockResolvedValue("request");
174
+ unwrapResponseDto.mockRejectedValueOnce(new Error("unexpected boom"));
175
+
176
+ await expect(
177
+ getApplicationDirectoryHash(
178
+ "token",
179
+ "https://app.superblocks.com",
180
+ "app-123",
181
+ "main",
182
+ ),
183
+ ).rejects.toThrow("unexpected boom");
184
+
185
+ expect(directoryContentsGet).toHaveBeenCalledTimes(1);
186
+ expect(rpcClient.close).toHaveBeenCalledOnce();
187
+ });
188
+
189
+ it("falls back from live-edit to draft to live when neither working copy nor draft exists", async () => {
190
+ directoryContentsGet.mockResolvedValue("request");
191
+ unwrapResponseDto
192
+ .mockRejectedValueOnce(new NotFoundError("no live-edit working copy"))
193
+ .mockRejectedValueOnce(new NotFoundError("no draft"))
91
194
  .mockResolvedValueOnce({ hash: "live-hash" });
92
195
 
93
196
  await expect(
@@ -102,15 +205,20 @@ describe("getApplicationDirectoryHash", () => {
102
205
  expect(directoryContentsGet).toHaveBeenNthCalledWith(1, {
103
206
  applicationId: "app-123",
104
207
  branchName: "main",
105
- viewMode: ExportViewMode.EXPORT_DRAFT,
208
+ viewMode: ExportViewMode.EXPORT_LIVE_EDIT,
106
209
  });
107
210
  expect(directoryContentsGet).toHaveBeenNthCalledWith(2, {
211
+ applicationId: "app-123",
212
+ branchName: "main",
213
+ viewMode: ExportViewMode.EXPORT_DRAFT,
214
+ });
215
+ expect(directoryContentsGet).toHaveBeenNthCalledWith(3, {
108
216
  applicationId: "app-123",
109
217
  branchName: "main",
110
218
  viewMode: ExportViewMode.EXPORT_LIVE,
111
219
  });
112
220
  expect(warn).toHaveBeenCalledWith(
113
- "Draft state not found, using live edit hash: live-hash",
221
+ expect.stringContaining("falling back to the latest live commit"),
114
222
  );
115
223
  expect(rpcClient.close).toHaveBeenCalledOnce();
116
224
  });
@@ -39,44 +39,98 @@ export async function getApplicationDirectoryHash(
39
39
  }
40
40
  return commitHash;
41
41
  }
42
+ // Prefer the live-edit working copy. Clark's in-progress autosaves advance
43
+ // the working-copy pointer without creating a commit, so the commit-based
44
+ // draft/live views can lag behind it. Restoring an older commit on a
45
+ // cold/recycled pod is what made in-progress work appear lost (APPS-4873).
46
+ // Mirrors the vite-plugin-file-sync copy in download.ts.
42
47
  try {
43
48
  const response = await unwrapResponseDto(
44
49
  rpcClient.call.v3.application.directoryContents.get({
45
50
  applicationId,
46
51
  branchName: branch,
47
- viewMode: ExportViewMode.EXPORT_DRAFT,
52
+ viewMode: ExportViewMode.EXPORT_LIVE_EDIT,
48
53
  }),
49
54
  );
50
- const liveEditHash = response.hash;
51
- if (!liveEditHash) {
52
- throw new Error(
53
- `No directory contents hash found for the view mode ${ExportViewMode.EXPORT_DRAFT}`,
54
- );
55
+ if (response.hash) {
56
+ return response.hash;
55
57
  }
56
- return liveEditHash;
57
58
  } catch (e) {
58
- if (e instanceof NotFoundError) {
59
- const response = await unwrapResponseDto(
60
- rpcClient.call.v3.application.directoryContents.get({
61
- applicationId,
62
- branchName: branch,
63
- viewMode: ExportViewMode.EXPORT_LIVE,
64
- }),
59
+ // Fall back to the commit-based draft/live views only when this app has no
60
+ // live-edit working copy (NotFoundError) or the server predates the
61
+ // EXPORT_LIVE_EDIT view mode (deploy skew). Any other error surfaces.
62
+ if (!isMissingLiveEditOrUnsupportedViewMode(e)) {
63
+ throw e;
64
+ }
65
+ if (!options?.silent) {
66
+ getLogger().warn(
67
+ `Live-edit working copy unavailable (${
68
+ e instanceof Error ? e.message : String(e)
69
+ }), falling back to draft/live views`,
65
70
  );
66
- if (!options?.silent) {
67
- getLogger().warn(
68
- `Draft state not found, using live edit hash: ${response.hash}`,
69
- );
70
- }
71
- return response.hash;
72
71
  }
73
- throw e;
74
72
  }
73
+ return getDraftOrLiveCommitHash(rpcClient, applicationId, branch, options);
75
74
  } finally {
76
75
  rpcClient.close();
77
76
  }
78
77
  }
79
78
 
79
+ // The legacy restore path: the latest draft commit, falling back to the latest
80
+ // non-draft (live) commit. Used when no live-edit working copy is available, or
81
+ // against a server that predates the live-edit view mode.
82
+ async function getDraftOrLiveCommitHash(
83
+ rpcClient: Awaited<ReturnType<typeof connectToISocketRPCServer>>,
84
+ applicationId: string,
85
+ branch: string | undefined,
86
+ options?: { silent?: boolean },
87
+ ): Promise<string> {
88
+ try {
89
+ const response = await unwrapResponseDto(
90
+ rpcClient.call.v3.application.directoryContents.get({
91
+ applicationId,
92
+ branchName: branch,
93
+ viewMode: ExportViewMode.EXPORT_DRAFT,
94
+ }),
95
+ );
96
+ const hash = response.hash;
97
+ if (!hash) {
98
+ throw new Error(
99
+ `No directory contents hash found for the view mode ${ExportViewMode.EXPORT_DRAFT}`,
100
+ );
101
+ }
102
+ return hash;
103
+ } catch (e) {
104
+ if (e instanceof NotFoundError) {
105
+ const response = await unwrapResponseDto(
106
+ rpcClient.call.v3.application.directoryContents.get({
107
+ applicationId,
108
+ branchName: branch,
109
+ viewMode: ExportViewMode.EXPORT_LIVE,
110
+ }),
111
+ );
112
+ if (!options?.silent) {
113
+ getLogger().warn(
114
+ `Draft commit not found, falling back to the latest live commit: ${response.hash}`,
115
+ );
116
+ }
117
+ return response.hash;
118
+ }
119
+ throw e;
120
+ }
121
+ }
122
+
123
+ // True when the live-edit view should yield to the legacy draft/live path:
124
+ // either this app has no live-edit working copy (NotFoundError), or the server
125
+ // is too old to know the view mode and reports it as not implemented.
126
+ function isMissingLiveEditOrUnsupportedViewMode(e: unknown): boolean {
127
+ if (e instanceof NotFoundError) {
128
+ return true;
129
+ }
130
+ const message = e instanceof Error ? e.message : String(e);
131
+ return message.includes("is not implemented");
132
+ }
133
+
80
134
  export async function downloadApplicationDirectory(
81
135
  token: string,
82
136
  superblocksBaseUrl: string,
@@ -819,9 +819,9 @@ export async function createDevServer({
819
819
  }
820
820
 
821
821
  try {
822
- const payload = (await jwtVerifier.verify<{ scope: string }>(
823
- token,
824
- )) as SuperblocksScopedJwtPayload;
822
+ const payload = (await jwtVerifier.verify<{ scope: string }>(token, {
823
+ algorithms: ["ES256"],
824
+ })) as SuperblocksScopedJwtPayload;
825
825
 
826
826
  const scopes = payload.scope?.split(" ") ?? [];
827
827
  if (!scopes.includes(EDIT_APPLICATION_SCOPE)) {