@undefineds.co/xpod 0.4.16 → 0.4.18

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.
Files changed (134) hide show
  1. package/config/xpod.base.json +3 -0
  2. package/dist/components/context.jsonld +14 -0
  3. package/dist/identity/ValidatingIdentityProviderHttpHandler.d.ts +32 -1
  4. package/dist/identity/ValidatingIdentityProviderHttpHandler.js +53 -1
  5. package/dist/identity/ValidatingIdentityProviderHttpHandler.jsonld +53 -0
  6. package/node_modules/@undefineds.co/ai-connections/dist/AiClientConfigurationSection.js +39 -10
  7. package/node_modules/@undefineds.co/ai-connections/dist/AiConnectionsList.js +15 -4
  8. package/node_modules/@undefineds.co/ai-connections/dist/AiConnectionsPanel.d.ts +1 -1
  9. package/node_modules/@undefineds.co/ai-connections/dist/AiConnectionsPanel.js +4 -1
  10. package/node_modules/@undefineds.co/ai-connections/dist/AiGatewayKeyRow.d.ts +5 -1
  11. package/node_modules/@undefineds.co/ai-connections/dist/AiGatewayKeyRow.js +2 -2
  12. package/node_modules/@undefineds.co/ai-connections/dist/AiGatewayKeysSection.js +5 -1
  13. package/node_modules/@undefineds.co/ai-connections/dist/controller.d.ts +4 -0
  14. package/node_modules/@undefineds.co/ai-connections/dist/controller.js +3 -1
  15. package/node_modules/@undefineds.co/extension-sdk/dist/layout.d.ts +39 -0
  16. package/node_modules/@undefineds.co/extension-sdk/dist/layout.js +40 -0
  17. package/node_modules/@undefineds.co/extension-sdk/dist/react/app-layout.d.ts +13 -1
  18. package/node_modules/@undefineds.co/extension-sdk/dist/react/app-layout.js +11 -3
  19. package/node_modules/@undefineds.co/extension-sdk/dist/react/workspace-layout.d.ts +13 -4
  20. package/node_modules/@undefineds.co/extension-sdk/dist/react/workspace-layout.js +68 -11
  21. package/node_modules/@undefineds.co/shared-ui/dist/auth-surface.js +6 -6
  22. package/node_modules/@undefineds.co/shared-ui/dist/dialog.js +4 -4
  23. package/node_modules/@undefineds.co/shared-ui/dist/login.js +1 -1
  24. package/node_modules/@undefineds.co/shared-ui/dist/theme.css +128 -79
  25. package/node_modules/@undefineds.co/shared-ui/dist/toast.js +1 -1
  26. package/node_modules/@undefineds.co/shared-ui/dist/tooltip.js +1 -1
  27. package/node_modules/@undefineds.co/solid-sdk/dist/access-route.cjs +87 -9
  28. package/node_modules/@undefineds.co/solid-sdk/dist/access-route.d.ts +31 -3
  29. package/node_modules/@undefineds.co/solid-sdk/dist/access-route.js +156 -14
  30. package/node_modules/@undefineds.co/solid-sdk/dist/index.cjs +86 -8
  31. package/package.json +2 -2
  32. package/static/app/assets/global.css +1 -0
  33. package/static/app/assets/{index-BCZVzMpj.js → index-BeAi28iA.js} +1 -1
  34. package/static/app/assets/inrupt-smoke.js +37 -36
  35. package/static/app/assets/main.js +3 -3
  36. package/static/app/index.html +2 -2
  37. package/static/app/inrupt-smoke.html +2 -1
  38. package/static/dashboard/assets/{AiConfigContext-BedwuLTE.js → AiConfigContext-DelSqO6L.js} +1 -1
  39. package/static/dashboard/assets/{AiConfigPage-hNTayf8G.js → AiConfigPage-dbg0aDvd.js} +1 -1
  40. package/static/dashboard/assets/DocumentProcessingPanel-0-3nlwQf.js +1 -0
  41. package/static/dashboard/assets/IndexLifecyclePanel-DOI3IxOD.js +1 -0
  42. package/static/dashboard/assets/{IndexSubjectPanel-Bv97-ouZ.js → IndexSubjectPanel-ClDgj0SY.js} +1 -1
  43. package/static/dashboard/assets/ModelAssignmentsPanel-BSWH19oT.js +1 -0
  44. package/static/dashboard/assets/ModelsPage-BrzxCkEl.js +11 -0
  45. package/static/{settings/assets/NetworkPage-D50kdbEu.js → dashboard/assets/NetworkPage-CxEKeaPz.js} +2 -2
  46. package/static/dashboard/assets/{PaneListHeader-ByqK_C6i.js → PaneListHeader-xEjcBu9g.js} +1 -1
  47. package/static/dashboard/assets/SearchIndexingPanel-BGlSvEl5.js +1 -0
  48. package/static/dashboard/assets/{StatusSubjectPanel-DPegcFpA.js → StatusSubjectPanel-6s3JXbOd.js} +1 -1
  49. package/static/dashboard/assets/StatusWorkspace-LYMDa4b0.js +1 -0
  50. package/static/dashboard/assets/{SystemSettingsPage-LyXmd6uP.js → SystemSettingsPage-CF_vH-qe.js} +1 -1
  51. package/static/dashboard/assets/SystemSettingsSubjectPanel-hsfHcJRV.js +33 -0
  52. package/static/dashboard/assets/{XpodAiConnectionsPodStore-DXNERI2-.js → XpodAiConnectionsPodStore-CHM-ZwI3.js} +1 -1
  53. package/static/dashboard/assets/{activity-BwRbcTha.js → activity-B7UdTZ1N.js} +1 -1
  54. package/static/dashboard/assets/{badge-BbRb9MsQ.js → badge-B6RuxNt1.js} +1 -1
  55. package/static/dashboard/assets/collection-runtime-CKBQm3Fl.js +1 -0
  56. package/static/dashboard/assets/dashboard-1zrtbMhM.css +1 -0
  57. package/static/dashboard/assets/{dashboard-D2ozrATv.js → dashboard-Dm_h60lA.js} +175 -135
  58. package/static/dashboard/assets/{download-SIHe8Y5t.js → download-BW035kpj.js} +1 -1
  59. package/static/dashboard/assets/{external-link-CiYw5Q4U.js → external-link-BMrNvf3c.js} +1 -1
  60. package/static/dashboard/assets/{index-DC1jRaVA.js → index-2jlFzexm.js} +1 -1
  61. package/static/dashboard/assets/index-CQDQ8WVr.js +4 -0
  62. package/static/dashboard/assets/{key-round-nKtxP6pb.js → key-round-C9vxhn7T.js} +1 -1
  63. package/static/dashboard/assets/{rotate-ccw-CQm5hHMN.js → rotate-ccw-8Vm0-INc.js} +1 -1
  64. package/static/dashboard/assets/{skeleton-TKyLbiPs.js → skeleton-CY_602wn.js} +1 -1
  65. package/static/dashboard/assets/{waypoints-BQS9t0ky.js → waypoints-DdEdgkxD.js} +1 -1
  66. package/static/dashboard/assets/workspace-layout-Wed840qR.js +1 -0
  67. package/static/dashboard/dashboard.html +2 -2
  68. package/static/settings/assets/{AiConfigContext-eLRkXFtH.js → AiConfigContext-CR3YhTA-.js} +1 -1
  69. package/static/settings/assets/AiConfigPage-DnOvX2Kx.js +1 -0
  70. package/static/settings/assets/DocumentProcessingPanel-CccpHBs3.js +1 -0
  71. package/static/settings/assets/IndexLifecyclePanel-BrECzBoM.js +1 -0
  72. package/static/settings/assets/{IndexSubjectPanel-DT0c11VQ.js → IndexSubjectPanel-CP84xXye.js} +1 -1
  73. package/static/settings/assets/ModelAssignmentsPanel-DbFi67Kk.js +1 -0
  74. package/static/settings/assets/ModelsPage-xjHA88qy.js +11 -0
  75. package/static/settings/assets/NetworkPage-Bj4wc0bh.js +2 -0
  76. package/static/settings/assets/{PaneListHeader-Da5jZFvG.js → PaneListHeader-Bz0UAhZr.js} +1 -1
  77. package/static/settings/assets/SearchIndexingPanel-ykhvw1Vl.js +1 -0
  78. package/static/settings/assets/{StatusSubjectPanel-Upqqlvon.js → StatusSubjectPanel-B2Y6ib3d.js} +1 -1
  79. package/static/settings/assets/StatusWorkspace-tx3Xg56J.js +1 -0
  80. package/static/settings/assets/SystemSettingsPage-BbFkv_Wn.js +1 -0
  81. package/static/settings/assets/SystemSettingsSubjectPanel-CcbpQmTc.js +33 -0
  82. package/static/settings/assets/{XpodAiConnectionsPodStore-nFgD_usD.js → XpodAiConnectionsPodStore-B11u0BOi.js} +2 -2
  83. package/static/settings/assets/{activity-CWy1W2W0.js → activity-BE8Hz8WF.js} +1 -1
  84. package/static/settings/assets/auth-callback-5waKJF0n.js +1 -0
  85. package/static/settings/assets/{badge-v30yFaaj.js → badge-xeRbYBMf.js} +1 -1
  86. package/static/settings/assets/collection-runtime-DIBObcT5.js +1 -0
  87. package/static/settings/assets/{download-C3Dzb60R.js → download-xI5yyfk7.js} +1 -1
  88. package/static/settings/assets/{external-link-DnkJGKNc.js → external-link-DmbiCMuc.js} +1 -1
  89. package/static/settings/assets/global-1zrtbMhM.css +1 -0
  90. package/static/settings/assets/{global-B_WO-aYY.js → global-BOl78fr8.js} +160 -120
  91. package/static/settings/assets/index-CFVXiUnV.js +4 -0
  92. package/static/settings/assets/index-DK6qlahe.js +1 -0
  93. package/static/settings/assets/{index-browser-DzhsXZHm.js → index-browser-Bk7jaLCd.js} +1 -1
  94. package/static/settings/assets/{key-round-DoDbsLae.js → key-round-B3VIp8oI.js} +1 -1
  95. package/static/settings/assets/{rotate-ccw-DNR_R3oT.js → rotate-ccw-DRzTN49Z.js} +1 -1
  96. package/static/settings/assets/{settings-BOznrBjy.js → settings-CCM17CMX.js} +1 -1
  97. package/static/settings/assets/{skeleton-BjHhu9qI.js → skeleton-Ckhw5HG7.js} +1 -1
  98. package/static/settings/assets/{waypoints-BXNph9Fd.js → waypoints-BFMI-qS6.js} +1 -1
  99. package/static/settings/assets/workspace-layout-Co0KpMKd.js +1 -0
  100. package/static/settings/auth-callback.html +3 -3
  101. package/static/settings/settings.html +3 -3
  102. package/static/app/assets/main.css +0 -1
  103. package/static/dashboard/assets/DocumentProcessingPanel-BSb_vOj7.js +0 -1
  104. package/static/dashboard/assets/IndexLifecyclePanel-DAHx-PBG.js +0 -1
  105. package/static/dashboard/assets/ModelAssignmentsPanel-CEJZs9At.js +0 -1
  106. package/static/dashboard/assets/ModelsPage-DkBVXmKT.js +0 -11
  107. package/static/dashboard/assets/NetworkPage-BDqKgRZQ.js +0 -2
  108. package/static/dashboard/assets/SearchIndexingPanel-M-WYaQP3.js +0 -1
  109. package/static/dashboard/assets/StatusWorkspace-CPIkjO9S.js +0 -1
  110. package/static/dashboard/assets/SystemSettingsSubjectPanel-CYHqNIn2.js +0 -33
  111. package/static/dashboard/assets/collection-runtime-BRfZD5oS.js +0 -1
  112. package/static/dashboard/assets/dashboard-rk080mvG.css +0 -1
  113. package/static/dashboard/assets/dialog-BbRbILBP.js +0 -1
  114. package/static/dashboard/assets/index-CExLoewO.js +0 -41
  115. package/static/dashboard/assets/index-CTgU8YW1.js +0 -4
  116. package/static/dashboard/assets/workspace-layout-Csbbr3vZ.js +0 -1
  117. package/static/settings/assets/AiConfigPage-BMO-ytdK.js +0 -1
  118. package/static/settings/assets/DocumentProcessingPanel-CH-HBRzQ.js +0 -1
  119. package/static/settings/assets/IndexLifecyclePanel-ChEMn9D8.js +0 -1
  120. package/static/settings/assets/ModelAssignmentsPanel-T59L_wOk.js +0 -1
  121. package/static/settings/assets/ModelsPage-m_SP4PdE.js +0 -11
  122. package/static/settings/assets/SearchIndexingPanel-DQQd3-Jw.js +0 -1
  123. package/static/settings/assets/StatusWorkspace-CPB49bGd.js +0 -1
  124. package/static/settings/assets/SystemSettingsPage-C0wrxcNw.js +0 -1
  125. package/static/settings/assets/SystemSettingsSubjectPanel-BOm7tUH1.js +0 -33
  126. package/static/settings/assets/auth-callback-HhgEuohz.js +0 -1
  127. package/static/settings/assets/collection-runtime-B7oomxic.js +0 -1
  128. package/static/settings/assets/dialog-BAe7moyL.js +0 -1
  129. package/static/settings/assets/global-rk080mvG.css +0 -1
  130. package/static/settings/assets/index-B51ot_m_.js +0 -1
  131. package/static/settings/assets/index-CHBQVfuZ.js +0 -41
  132. package/static/settings/assets/index-DbqRFton.js +0 -4
  133. package/static/settings/assets/workspace-layout-TZ7fteel.js +0 -1
  134. /package/static/app/assets/{_commonjsHelpers-B-UnjaXt.js → global-BGMlqT2c.js} +0 -0
@@ -64,5 +64,5 @@ function getSnapshot() {
64
64
  }
65
65
  export function Toaster({ className }) {
66
66
  const toasts = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
67
- return (_jsx("div", { "aria-live": "polite", role: "status", className: cn('pointer-events-none fixed bottom-4 right-4 z-[100] flex w-full max-w-sm flex-col gap-2', className), children: toasts.map((item) => (_jsxs("div", { className: cn(toastVariants({ variant: item.variant }), item.className), children: [_jsxs("div", { className: "min-w-0 flex-1", children: [item.title ? _jsx("p", { className: "font-medium", children: item.title }) : null, item.description ? _jsx("p", { className: cn(item.title ? 'mt-0.5' : undefined), children: item.description }) : null] }), _jsx("button", { type: "button", "aria-label": "\u5173\u95ED\u901A\u77E5", className: cn('shrink-0 rounded-sm opacity-60 transition-opacity hover:opacity-100', interactiveFocusClass), onClick: () => dismissToast(item.id), children: _jsx(X, { "aria-hidden": "true", className: "h-3.5 w-3.5" }) })] }, item.id))) }));
67
+ return (_jsx("div", { "aria-live": "polite", role: "status", className: cn('pointer-events-none fixed bottom-4 right-4 z-[var(--layer-toast)] flex w-full max-w-sm flex-col gap-2', className), children: toasts.map((item) => (_jsxs("div", { className: cn(toastVariants({ variant: item.variant }), item.className), children: [_jsxs("div", { className: "min-w-0 flex-1", children: [item.title ? _jsx("p", { className: "font-medium", children: item.title }) : null, item.description ? _jsx("p", { className: cn(item.title ? 'mt-0.5' : undefined), children: item.description }) : null] }), _jsx("button", { type: "button", "aria-label": "\u5173\u95ED\u901A\u77E5", className: cn('shrink-0 rounded-sm opacity-60 transition-opacity hover:opacity-100', interactiveFocusClass), onClick: () => dismissToast(item.id), children: _jsx(X, { "aria-hidden": "true", className: "h-3.5 w-3.5" }) })] }, item.id))) }));
68
68
  }
@@ -5,6 +5,6 @@ import { cn } from "./utils.js";
5
5
  const TooltipProvider = TooltipPrimitive.Provider;
6
6
  const Tooltip = TooltipPrimitive.Root;
7
7
  const TooltipTrigger = TooltipPrimitive.Trigger;
8
- const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (_jsx(TooltipPrimitive.Content, { ref: ref, sideOffset: sideOffset, className: cn("z-50 overflow-hidden rounded-md border border-border/50 bg-popover", "px-2 py-1 text-xs text-popover-foreground shadow-md", "animate-in fade-in-0 zoom-in-95", "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props })));
8
+ const TooltipContent = React.forwardRef(({ className, sideOffset = 4, ...props }, ref) => (_jsx(TooltipPrimitive.Content, { ref: ref, sideOffset: sideOffset, className: cn("z-[var(--layer-popover)] overflow-hidden rounded-md border border-border/50 bg-popover", "px-2 py-1 text-xs text-popover-foreground shadow-md", "animate-in fade-in-0 zoom-in-95", "data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95", "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", className), ...props })));
9
9
  TooltipContent.displayName = TooltipPrimitive.Content.displayName;
10
10
  export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -41,7 +41,8 @@ var exports_access_route = {};
41
41
  __export(exports_access_route, {
42
42
  usableRoutes: () => usableRoutes,
43
43
  createSolidAccessRouteFetch: () => createSolidAccessRouteFetch,
44
- chooseAccessRoute: () => chooseAccessRoute
44
+ chooseAccessRoute: () => chooseAccessRoute,
45
+ NoUsableAccessRouteError: () => NoUsableAccessRouteError
45
46
  });
46
47
  module.exports = __toCommonJS(exports_access_route);
47
48
 
@@ -148,24 +149,34 @@ async function chooseAccessRoute(routeSet, options = {}) {
148
149
  if (candidates.length === 0) {
149
150
  return null;
150
151
  }
152
+ const best = candidates[0];
153
+ if (isSelfProvenRoute(best)) {
154
+ return best;
155
+ }
151
156
  const probe = options.probe ?? defaultProbe;
152
157
  const timeoutMs = options.probeTimeoutMs ?? 1000;
153
158
  const results = await Promise.all(candidates.map(async (route) => ({
154
159
  route,
155
- ok: await probeWithTimeout(route, probe, timeoutMs)
160
+ ok: isSelfProvenRoute(route) || await probeWithTimeout(route, probe, timeoutMs)
156
161
  })));
157
162
  return results.find((result) => result.ok)?.route ?? null;
158
163
  }
164
+ function isSelfProvenRoute(route) {
165
+ return route.health === "healthy";
166
+ }
159
167
  function usableRoutes(routeSet, managedClient, allowLocalOnlyRoutes) {
160
168
  return routeSet.routes.filter((route) => route.health !== "unreachable").filter((route) => managedClient || !route.requiresManagedClient).filter((route) => allowLocalOnlyRoutes || route.visibility !== "local-only").slice().sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id));
161
169
  }
162
170
  function createSolidAccessRouteFetch(options) {
163
171
  const managedClient = options.managedClient ?? true;
164
172
  let selected;
173
+ let failed;
165
174
  const selectRoute = async () => {
175
+ const all = options.routes();
176
+ const routes = failed === undefined ? all : all.filter((route) => routeKey(route) !== failed);
166
177
  const routeSet = {
167
- canonicalUrl: options.routes()[0]?.canonicalUrl ?? "",
168
- routes: options.routes()
178
+ canonicalUrl: routes[0]?.canonicalUrl ?? "",
179
+ routes
169
180
  };
170
181
  const candidate = await chooseAccessRoute(routeSet, {
171
182
  managedClient,
@@ -189,24 +200,88 @@ function createSolidAccessRouteFetch(options) {
189
200
  localBaseUrl: route.targetUrl
190
201
  }]
191
202
  });
203
+ const routeCovers = (url) => Boolean(resolveSolidLocalRouteUrl(url, options.routes().map(toLocalRoute)));
204
+ const unreachablePassThrough = (input) => {
205
+ const url = requestUrl2(input);
206
+ if (!url || !routeCovers(url)) {
207
+ return;
208
+ }
209
+ const routes = options.routes();
210
+ const preferred = usableRoutes({ canonicalUrl: routes[0]?.canonicalUrl ?? "", routes }, managedClient, options.allowLocalOnlyRoutes ?? false)[0];
211
+ if (!preferred || sameOrigin(preferred.targetUrl, url)) {
212
+ return;
213
+ }
214
+ return url;
215
+ };
192
216
  return async (input, init) => {
193
217
  const route = await routeFor();
194
218
  if (!route) {
219
+ const unreachable = unreachablePassThrough(input);
220
+ if (unreachable) {
221
+ throw new NoUsableAccessRouteError(unreachable.href, options.routes());
222
+ }
195
223
  return init === undefined ? options.fetch.call(globalThis, input) : options.fetch.call(globalThis, input, init);
196
224
  }
197
225
  try {
198
- return await routedFetchFor(route)(input, init);
226
+ const response = await routedFetchFor(route)(input, init);
227
+ failed = undefined;
228
+ return response;
199
229
  } catch (error) {
230
+ failed = routeKey(route);
200
231
  selected = undefined;
201
232
  await options.refreshRoutes?.();
202
233
  const retryRoute = await selectRoute();
203
- if (!retryRoute || retryRoute.id === route.id) {
204
- throw error;
234
+ if (retryRoute && routeKey(retryRoute) !== routeKey(route)) {
235
+ return routedFetchFor(retryRoute)(input, init);
205
236
  }
206
- return routedFetchFor(retryRoute)(input, init);
237
+ const url = requestUrl2(input);
238
+ if (url && routeCovers(url)) {
239
+ throw new NoUsableAccessRouteError(url.href, options.routes(), error);
240
+ }
241
+ throw error;
207
242
  }
208
243
  };
209
244
  }
245
+ function routeKey(route) {
246
+ return `${route.id}|${route.targetUrl}`;
247
+ }
248
+
249
+ class NoUsableAccessRouteError extends Error {
250
+ url;
251
+ routes;
252
+ reason;
253
+ constructor(url, routes, reason) {
254
+ super(`No usable access route can reach ${url}`);
255
+ this.url = url;
256
+ this.routes = routes;
257
+ this.reason = reason;
258
+ this.name = "NoUsableAccessRouteError";
259
+ }
260
+ }
261
+ function toLocalRoute(route) {
262
+ return { canonicalBaseUrl: route.canonicalUrl, localBaseUrl: route.targetUrl };
263
+ }
264
+ function sameOrigin(value, url) {
265
+ try {
266
+ return new URL(value).origin === url.origin;
267
+ } catch {
268
+ return false;
269
+ }
270
+ }
271
+ function requestUrl2(input) {
272
+ if (input instanceof Request) {
273
+ try {
274
+ return new URL(input.url);
275
+ } catch {
276
+ return;
277
+ }
278
+ }
279
+ try {
280
+ return new URL(String(input));
281
+ } catch {
282
+ return;
283
+ }
284
+ }
210
285
  async function probeWithTimeout(route, probe, timeoutMs) {
211
286
  const controller = new AbortController;
212
287
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -218,6 +293,9 @@ async function probeWithTimeout(route, probe, timeoutMs) {
218
293
  clearTimeout(timer);
219
294
  }
220
295
  }
296
+ function answersAccessRoute(response) {
297
+ return response.status < 500;
298
+ }
221
299
  async function defaultProbe(route, signal) {
222
300
  if (!route.targetUrl.startsWith("http://") && !route.targetUrl.startsWith("https://")) {
223
301
  return route.health === "healthy";
@@ -226,5 +304,5 @@ async function defaultProbe(route, signal) {
226
304
  method: "HEAD",
227
305
  signal
228
306
  });
229
- return response.ok || response.status === 401 || response.status === 403;
307
+ return answersAccessRoute(response);
230
308
  }
@@ -43,9 +43,20 @@ export interface ChooseAccessRouteOptions {
43
43
  probe?: (route: AccessRoute, signal: AbortSignal) => Promise<boolean> | boolean;
44
44
  }
45
45
  /**
46
- * Pick the best route that actually answers: candidates are ordered by priority
47
- * and probed in parallel, so a same-machine client lands on loopback while a
48
- * remote one skips it without waiting for it to time out.
46
+ * Pick the best route that is known to work: candidates are ordered by priority,
47
+ * and only the ones this client cannot vouch for are probed.
48
+ *
49
+ * A route the client already knows is up is never probed. The current origin
50
+ * served this very document, and the node's own runtime reports the health of
51
+ * the routes beside it, so a probe could only replace a known fact with a guess.
52
+ * That guess is what made local login fail: the local gateway answers the probe
53
+ * path with 405 (CSS does not implement the optional `/.well-known/solid`), the
54
+ * probe called the one reachable route dead, and the client fell back to a
55
+ * canonical URL it could not reach.
56
+ *
57
+ * Probes remain for candidates whose health is unknown, and are run in parallel
58
+ * so a same-machine client still lands on loopback without waiting for a remote
59
+ * one to time out.
49
60
  */
50
61
  export declare function chooseAccessRoute(routeSet: AccessRouteSet, options?: ChooseAccessRouteOptions): Promise<AccessRoute | null>;
51
62
  export declare function usableRoutes(routeSet: AccessRouteSet, managedClient: boolean, allowLocalOnlyRoutes: boolean): AccessRoute[];
@@ -74,3 +85,20 @@ export interface CreateSolidAccessRouteFetchOptions {
74
85
  * routes are refreshed and the request is retried once against the new best one.
75
86
  */
76
87
  export declare function createSolidAccessRouteFetch(options: CreateSolidAccessRouteFetchOptions): typeof globalThis.fetch;
88
+ /**
89
+ * A URL this client can only reach through a route, with no route usable.
90
+ *
91
+ * Thrown instead of sending the request to its canonical address, because that
92
+ * address is not the path this client meant to take: something is wrong with the
93
+ * routes themselves (the node's own gateway is down, a tunnel is gone), and the
94
+ * caller has to say so rather than report whatever the canonical URL answers.
95
+ */
96
+ export declare class NoUsableAccessRouteError extends Error {
97
+ readonly url: string;
98
+ readonly routes: readonly AccessRoute[];
99
+ /** The transport failure that left no route usable, when there was one. */
100
+ readonly reason?: unknown | undefined;
101
+ constructor(url: string, routes: readonly AccessRoute[],
102
+ /** The transport failure that left no route usable, when there was one. */
103
+ reason?: unknown | undefined);
104
+ }
@@ -1,22 +1,45 @@
1
- import { createSolidLocalRouteFetch } from './local-route-fetch.js';
1
+ import { createSolidLocalRouteFetch, resolveSolidLocalRouteUrl } from './local-route-fetch.js';
2
2
  /**
3
- * Pick the best route that actually answers: candidates are ordered by priority
4
- * and probed in parallel, so a same-machine client lands on loopback while a
5
- * remote one skips it without waiting for it to time out.
3
+ * Pick the best route that is known to work: candidates are ordered by priority,
4
+ * and only the ones this client cannot vouch for are probed.
5
+ *
6
+ * A route the client already knows is up is never probed. The current origin
7
+ * served this very document, and the node's own runtime reports the health of
8
+ * the routes beside it, so a probe could only replace a known fact with a guess.
9
+ * That guess is what made local login fail: the local gateway answers the probe
10
+ * path with 405 (CSS does not implement the optional `/.well-known/solid`), the
11
+ * probe called the one reachable route dead, and the client fell back to a
12
+ * canonical URL it could not reach.
13
+ *
14
+ * Probes remain for candidates whose health is unknown, and are run in parallel
15
+ * so a same-machine client still lands on loopback without waiting for a remote
16
+ * one to time out.
6
17
  */
7
18
  export async function chooseAccessRoute(routeSet, options = {}) {
8
19
  const candidates = usableRoutes(routeSet, options.managedClient ?? true, options.allowLocalOnlyRoutes ?? false);
9
20
  if (candidates.length === 0) {
10
21
  return null;
11
22
  }
23
+ const best = candidates[0];
24
+ if (isSelfProvenRoute(best)) {
25
+ return best;
26
+ }
12
27
  const probe = options.probe ?? defaultProbe;
13
28
  const timeoutMs = options.probeTimeoutMs ?? 1000;
14
29
  const results = await Promise.all(candidates.map(async (route) => ({
15
30
  route,
16
- ok: await probeWithTimeout(route, probe, timeoutMs),
31
+ ok: isSelfProvenRoute(route) || await probeWithTimeout(route, probe, timeoutMs),
17
32
  })));
18
33
  return results.find((result) => result.ok)?.route ?? null;
19
34
  }
35
+ /**
36
+ * A route this client can vouch for without asking: `healthy` is the runtime's
37
+ * own report about its own node, and the current-origin route is the document
38
+ * this client is already running in. `unknown` and `degraded` still get probed.
39
+ */
40
+ function isSelfProvenRoute(route) {
41
+ return route.health === 'healthy';
42
+ }
20
43
  export function usableRoutes(routeSet, managedClient, allowLocalOnlyRoutes) {
21
44
  return routeSet.routes
22
45
  .filter((route) => route.health !== 'unreachable')
@@ -37,10 +60,16 @@ export function usableRoutes(routeSet, managedClient, allowLocalOnlyRoutes) {
37
60
  export function createSolidAccessRouteFetch(options) {
38
61
  const managedClient = options.managedClient ?? true;
39
62
  let selected;
63
+ // A route that just failed the request itself is the one route we must not
64
+ // pick again for the retry: a trusted route that turns out to be down has to
65
+ // be able to fail over to a probed one. Cleared by the next success.
66
+ let failed;
40
67
  const selectRoute = async () => {
68
+ const all = options.routes();
69
+ const routes = failed === undefined ? all : all.filter((route) => routeKey(route) !== failed);
41
70
  const routeSet = {
42
- canonicalUrl: options.routes()[0]?.canonicalUrl ?? '',
43
- routes: options.routes(),
71
+ canonicalUrl: routes[0]?.canonicalUrl ?? '',
72
+ routes,
44
73
  };
45
74
  const candidate = await chooseAccessRoute(routeSet, {
46
75
  managedClient,
@@ -66,29 +95,126 @@ export function createSolidAccessRouteFetch(options) {
66
95
  localBaseUrl: route.targetUrl,
67
96
  }],
68
97
  });
98
+ /**
99
+ * Whether this client knows a route for the URL: the URLs it may rewrite and
100
+ * therefore the only ones it is entitled to judge. Everything else — the IdP,
101
+ * another Pod — is sent unchanged, whatever the routes are doing.
102
+ */
103
+ const routeCovers = (url) => Boolean(resolveSolidLocalRouteUrl(url, options.routes().map(toLocalRoute)));
104
+ /**
105
+ * The URL a request would have to reach if it is sent unchanged, when sending
106
+ * it unchanged is *not* the path this client meant to take.
107
+ *
108
+ * A URL no route covers is none of our business — the IdP, another Pod — and is
109
+ * passed through as before. A URL whose origin is where the best route would
110
+ * have sent it is already its own physical address, so passing it through is
111
+ * exactly what that route would have done. Anything else means the client
112
+ * knows a shorter path and has none usable: sending the request to the
113
+ * canonical URL anyway would leave over a path this client cannot reach, and
114
+ * that has to be reported (with the candidates) instead of quietly attempted.
115
+ */
116
+ const unreachablePassThrough = (input) => {
117
+ const url = requestUrl(input);
118
+ if (!url || !routeCovers(url)) {
119
+ return undefined;
120
+ }
121
+ const routes = options.routes();
122
+ const preferred = usableRoutes({ canonicalUrl: routes[0]?.canonicalUrl ?? '', routes }, managedClient, options.allowLocalOnlyRoutes ?? false)[0];
123
+ if (!preferred || sameOrigin(preferred.targetUrl, url)) {
124
+ return undefined;
125
+ }
126
+ return url;
127
+ };
69
128
  return async (input, init) => {
70
129
  const route = await routeFor();
71
130
  if (!route) {
131
+ const unreachable = unreachablePassThrough(input);
132
+ if (unreachable) {
133
+ throw new NoUsableAccessRouteError(unreachable.href, options.routes());
134
+ }
72
135
  return init === undefined
73
136
  ? options.fetch.call(globalThis, input)
74
137
  : options.fetch.call(globalThis, input, init);
75
138
  }
76
139
  try {
77
- return await routedFetchFor(route)(input, init);
140
+ const response = await routedFetchFor(route)(input, init);
141
+ failed = undefined;
142
+ return response;
78
143
  }
79
144
  catch (error) {
80
- // The route went away (laptop moved networks, tunnel restarted). Ask for
81
- // the current set once and retry against whatever is best now.
145
+ // The route went away (laptop moved networks, tunnel restarted, or the
146
+ // runtime reported a health it cannot back up). Ask for the current set
147
+ // once and retry against whatever is best now, without this one.
148
+ failed = routeKey(route);
82
149
  selected = undefined;
83
150
  await options.refreshRoutes?.();
84
151
  const retryRoute = await selectRoute();
85
- if (!retryRoute || retryRoute.id === route.id) {
86
- throw error;
152
+ if (retryRoute && routeKey(retryRoute) !== routeKey(route)) {
153
+ return routedFetchFor(retryRoute)(input, init);
87
154
  }
88
- return routedFetchFor(retryRoute)(input, init);
155
+ // Nothing else can serve this request. When the URL was one this client
156
+ // only reaches through a route, the failure *is* "no usable route" — say
157
+ // so, with the transport failure kept as the reason, instead of letting
158
+ // the caller report whatever the canonical URL would have answered. A URL
159
+ // no route covers (the IdP, another Pod) keeps its own error.
160
+ const url = requestUrl(input);
161
+ if (url && routeCovers(url)) {
162
+ throw new NoUsableAccessRouteError(url.href, options.routes(), error);
163
+ }
164
+ throw error;
89
165
  }
90
166
  };
91
167
  }
168
+ /** Identity of one physical path: the same route id can point at a new target. */
169
+ function routeKey(route) {
170
+ return `${route.id}|${route.targetUrl}`;
171
+ }
172
+ /**
173
+ * A URL this client can only reach through a route, with no route usable.
174
+ *
175
+ * Thrown instead of sending the request to its canonical address, because that
176
+ * address is not the path this client meant to take: something is wrong with the
177
+ * routes themselves (the node's own gateway is down, a tunnel is gone), and the
178
+ * caller has to say so rather than report whatever the canonical URL answers.
179
+ */
180
+ export class NoUsableAccessRouteError extends Error {
181
+ constructor(url, routes,
182
+ /** The transport failure that left no route usable, when there was one. */
183
+ reason) {
184
+ super(`No usable access route can reach ${url}`);
185
+ this.url = url;
186
+ this.routes = routes;
187
+ this.reason = reason;
188
+ this.name = 'NoUsableAccessRouteError';
189
+ }
190
+ }
191
+ function toLocalRoute(route) {
192
+ return { canonicalBaseUrl: route.canonicalUrl, localBaseUrl: route.targetUrl };
193
+ }
194
+ function sameOrigin(value, url) {
195
+ try {
196
+ return new URL(value).origin === url.origin;
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ }
202
+ function requestUrl(input) {
203
+ if (input instanceof Request) {
204
+ try {
205
+ return new URL(input.url);
206
+ }
207
+ catch {
208
+ return undefined;
209
+ }
210
+ }
211
+ try {
212
+ return new URL(String(input));
213
+ }
214
+ catch {
215
+ return undefined;
216
+ }
217
+ }
92
218
  async function probeWithTimeout(route, probe, timeoutMs) {
93
219
  const controller = new AbortController();
94
220
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -102,6 +228,22 @@ async function probeWithTimeout(route, probe, timeoutMs) {
102
228
  clearTimeout(timer);
103
229
  }
104
230
  }
231
+ /**
232
+ * Whether a route's host answered at all.
233
+ *
234
+ * The probe asks the target host for one small document; what it measures is
235
+ * reachability, not whether that host implements this optional discovery path.
236
+ * A Pod server that answers `HEAD /.well-known/solid` with 404/405/501 (CSS does:
237
+ * 405 for `HEAD`, 501 for `GET`) is up and serving this route, and the request
238
+ * that follows carries the canonical URL plus its own authorization. Rejecting
239
+ * those answers leaves a same-machine client with no route whenever the public
240
+ * one is down — the one case local login has to survive
241
+ * (`docs/multi-channel-access.md`). A failing host (5xx, or a closed socket,
242
+ * which throws) still reports no route.
243
+ */
244
+ function answersAccessRoute(response) {
245
+ return response.status < 500;
246
+ }
105
247
  async function defaultProbe(route, signal) {
106
248
  if (!route.targetUrl.startsWith('http://') && !route.targetUrl.startsWith('https://')) {
107
249
  // Non-HTTP routes (p2p sockets) report their own readiness.
@@ -111,5 +253,5 @@ async function defaultProbe(route, signal) {
111
253
  method: 'HEAD',
112
254
  signal,
113
255
  });
114
- return response.ok || response.status === 401 || response.status === 403;
256
+ return answersAccessRoute(response);
115
257
  }
@@ -67,6 +67,7 @@ __export(exports_src, {
67
67
  SOLID_LOCAL_ROUTE_CANONICAL_URL_HEADER: () => SOLID_LOCAL_ROUTE_CANONICAL_URL_HEADER,
68
68
  SOLID_LOCAL_ROUTE_CANONICAL_ORIGIN_HEADER: () => SOLID_LOCAL_ROUTE_CANONICAL_ORIGIN_HEADER,
69
69
  SOLID_LOCAL_ROUTE_CANONICAL_HOST_HEADER: () => SOLID_LOCAL_ROUTE_CANONICAL_HOST_HEADER,
70
+ NoUsableAccessRouteError: () => NoUsableAccessRouteError,
70
71
  DEFAULT_PROVIDERS: () => DEFAULT_PROVIDERS
71
72
  });
72
73
  module.exports = __toCommonJS(exports_src);
@@ -1163,24 +1164,34 @@ async function chooseAccessRoute(routeSet, options = {}) {
1163
1164
  if (candidates.length === 0) {
1164
1165
  return null;
1165
1166
  }
1167
+ const best = candidates[0];
1168
+ if (isSelfProvenRoute(best)) {
1169
+ return best;
1170
+ }
1166
1171
  const probe = options.probe ?? defaultProbe;
1167
1172
  const timeoutMs = options.probeTimeoutMs ?? 1000;
1168
1173
  const results = await Promise.all(candidates.map(async (route) => ({
1169
1174
  route,
1170
- ok: await probeWithTimeout(route, probe, timeoutMs)
1175
+ ok: isSelfProvenRoute(route) || await probeWithTimeout(route, probe, timeoutMs)
1171
1176
  })));
1172
1177
  return results.find((result) => result.ok)?.route ?? null;
1173
1178
  }
1179
+ function isSelfProvenRoute(route) {
1180
+ return route.health === "healthy";
1181
+ }
1174
1182
  function usableRoutes(routeSet, managedClient, allowLocalOnlyRoutes) {
1175
1183
  return routeSet.routes.filter((route) => route.health !== "unreachable").filter((route) => managedClient || !route.requiresManagedClient).filter((route) => allowLocalOnlyRoutes || route.visibility !== "local-only").slice().sort((left, right) => left.priority - right.priority || left.id.localeCompare(right.id));
1176
1184
  }
1177
1185
  function createSolidAccessRouteFetch(options) {
1178
1186
  const managedClient = options.managedClient ?? true;
1179
1187
  let selected;
1188
+ let failed;
1180
1189
  const selectRoute = async () => {
1190
+ const all = options.routes();
1191
+ const routes = failed === undefined ? all : all.filter((route) => routeKey(route) !== failed);
1181
1192
  const routeSet = {
1182
- canonicalUrl: options.routes()[0]?.canonicalUrl ?? "",
1183
- routes: options.routes()
1193
+ canonicalUrl: routes[0]?.canonicalUrl ?? "",
1194
+ routes
1184
1195
  };
1185
1196
  const candidate = await chooseAccessRoute(routeSet, {
1186
1197
  managedClient,
@@ -1204,24 +1215,88 @@ function createSolidAccessRouteFetch(options) {
1204
1215
  localBaseUrl: route.targetUrl
1205
1216
  }]
1206
1217
  });
1218
+ const routeCovers = (url) => Boolean(resolveSolidLocalRouteUrl(url, options.routes().map(toLocalRoute)));
1219
+ const unreachablePassThrough = (input) => {
1220
+ const url = requestUrl2(input);
1221
+ if (!url || !routeCovers(url)) {
1222
+ return;
1223
+ }
1224
+ const routes = options.routes();
1225
+ const preferred = usableRoutes({ canonicalUrl: routes[0]?.canonicalUrl ?? "", routes }, managedClient, options.allowLocalOnlyRoutes ?? false)[0];
1226
+ if (!preferred || sameOrigin(preferred.targetUrl, url)) {
1227
+ return;
1228
+ }
1229
+ return url;
1230
+ };
1207
1231
  return async (input, init) => {
1208
1232
  const route = await routeFor();
1209
1233
  if (!route) {
1234
+ const unreachable = unreachablePassThrough(input);
1235
+ if (unreachable) {
1236
+ throw new NoUsableAccessRouteError(unreachable.href, options.routes());
1237
+ }
1210
1238
  return init === undefined ? options.fetch.call(globalThis, input) : options.fetch.call(globalThis, input, init);
1211
1239
  }
1212
1240
  try {
1213
- return await routedFetchFor(route)(input, init);
1241
+ const response = await routedFetchFor(route)(input, init);
1242
+ failed = undefined;
1243
+ return response;
1214
1244
  } catch (error) {
1245
+ failed = routeKey(route);
1215
1246
  selected = undefined;
1216
1247
  await options.refreshRoutes?.();
1217
1248
  const retryRoute = await selectRoute();
1218
- if (!retryRoute || retryRoute.id === route.id) {
1219
- throw error;
1249
+ if (retryRoute && routeKey(retryRoute) !== routeKey(route)) {
1250
+ return routedFetchFor(retryRoute)(input, init);
1251
+ }
1252
+ const url = requestUrl2(input);
1253
+ if (url && routeCovers(url)) {
1254
+ throw new NoUsableAccessRouteError(url.href, options.routes(), error);
1220
1255
  }
1221
- return routedFetchFor(retryRoute)(input, init);
1256
+ throw error;
1222
1257
  }
1223
1258
  };
1224
1259
  }
1260
+ function routeKey(route) {
1261
+ return `${route.id}|${route.targetUrl}`;
1262
+ }
1263
+
1264
+ class NoUsableAccessRouteError extends Error {
1265
+ url;
1266
+ routes;
1267
+ reason;
1268
+ constructor(url, routes, reason) {
1269
+ super(`No usable access route can reach ${url}`);
1270
+ this.url = url;
1271
+ this.routes = routes;
1272
+ this.reason = reason;
1273
+ this.name = "NoUsableAccessRouteError";
1274
+ }
1275
+ }
1276
+ function toLocalRoute(route) {
1277
+ return { canonicalBaseUrl: route.canonicalUrl, localBaseUrl: route.targetUrl };
1278
+ }
1279
+ function sameOrigin(value, url) {
1280
+ try {
1281
+ return new URL(value).origin === url.origin;
1282
+ } catch {
1283
+ return false;
1284
+ }
1285
+ }
1286
+ function requestUrl2(input) {
1287
+ if (input instanceof Request) {
1288
+ try {
1289
+ return new URL(input.url);
1290
+ } catch {
1291
+ return;
1292
+ }
1293
+ }
1294
+ try {
1295
+ return new URL(String(input));
1296
+ } catch {
1297
+ return;
1298
+ }
1299
+ }
1225
1300
  async function probeWithTimeout(route, probe, timeoutMs) {
1226
1301
  const controller = new AbortController;
1227
1302
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -1233,6 +1308,9 @@ async function probeWithTimeout(route, probe, timeoutMs) {
1233
1308
  clearTimeout(timer);
1234
1309
  }
1235
1310
  }
1311
+ function answersAccessRoute(response) {
1312
+ return response.status < 500;
1313
+ }
1236
1314
  async function defaultProbe(route, signal) {
1237
1315
  if (!route.targetUrl.startsWith("http://") && !route.targetUrl.startsWith("https://")) {
1238
1316
  return route.health === "healthy";
@@ -1241,5 +1319,5 @@ async function defaultProbe(route, signal) {
1241
1319
  method: "HEAD",
1242
1320
  signal
1243
1321
  });
1244
- return response.ok || response.status === 401 || response.status === 403;
1322
+ return answersAccessRoute(response);
1245
1323
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@undefineds.co/xpod",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "description": "Xpod is an extended Community Solid Server, offering rich-feature, production-level Solid Pod and identity management.",
5
5
  "repository": "https://github.com/undefinedsco/xpod",
6
6
  "author": "developer@undefineds.co",
@@ -385,7 +385,7 @@
385
385
  }
386
386
  },
387
387
  "optionalDependencies": {
388
- "@undefineds.co/xpod-darwin-arm64": "0.4.16"
388
+ "@undefineds.co/xpod-darwin-arm64": "0.4.18"
389
389
  },
390
390
  "resolutions": {
391
391
  "@inrupt/solid-client-authn-browser": "^3.1.1",