@openparachute/hub 0.5.13 → 0.5.14-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/account-home-ui.test.ts +163 -0
  3. package/src/__tests__/admin-handlers.test.ts +74 -0
  4. package/src/__tests__/admin-host-admin-token.test.ts +62 -0
  5. package/src/__tests__/admin-vault-admin-token.test.ts +44 -0
  6. package/src/__tests__/api-account.test.ts +191 -1
  7. package/src/__tests__/api-modules-ops.test.ts +97 -0
  8. package/src/__tests__/api-modules.test.ts +32 -32
  9. package/src/__tests__/api-users.test.ts +383 -11
  10. package/src/__tests__/chrome-strip.test.ts +15 -15
  11. package/src/__tests__/hub-db.test.ts +194 -29
  12. package/src/__tests__/hub-server.test.ts +23 -23
  13. package/src/__tests__/notes-redirect.test.ts +20 -20
  14. package/src/__tests__/oauth-handlers.test.ts +722 -28
  15. package/src/__tests__/serve.test.ts +9 -9
  16. package/src/__tests__/services-manifest.test.ts +40 -40
  17. package/src/__tests__/setup-wizard.test.ts +493 -25
  18. package/src/__tests__/setup.test.ts +1 -1
  19. package/src/__tests__/status.test.ts +39 -0
  20. package/src/__tests__/users.test.ts +396 -9
  21. package/src/__tests__/well-known.test.ts +9 -9
  22. package/src/account-home-ui.ts +434 -0
  23. package/src/admin-handlers.ts +49 -17
  24. package/src/admin-host-admin-token.ts +25 -0
  25. package/src/admin-vault-admin-token.ts +17 -0
  26. package/src/api-account.ts +72 -6
  27. package/src/api-modules-ops.ts +52 -16
  28. package/src/api-modules.ts +3 -3
  29. package/src/api-users.ts +468 -55
  30. package/src/bun-link.ts +55 -0
  31. package/src/chrome-strip.ts +6 -6
  32. package/src/commands/install.ts +8 -21
  33. package/src/commands/status.ts +10 -1
  34. package/src/help.ts +2 -2
  35. package/src/hub-db.ts +42 -0
  36. package/src/hub-server.ts +69 -10
  37. package/src/hub-settings.ts +2 -2
  38. package/src/hub.ts +6 -6
  39. package/src/notes-redirect.ts +5 -5
  40. package/src/oauth-handlers.ts +278 -173
  41. package/src/oauth-ui.ts +18 -2
  42. package/src/service-spec.ts +39 -18
  43. package/src/setup-wizard.ts +489 -42
  44. package/src/users.ts +307 -29
  45. package/web/ui/dist/assets/index-tRmPbbC7.js +61 -0
  46. package/web/ui/dist/index.html +1 -1
  47. package/web/ui/dist/assets/index-Dzrbe6EP.js +0 -61
@@ -13,7 +13,7 @@
13
13
  * (above that threshold the response is almost certainly not an HTML shell
14
14
  * anyway — SPA index.html files are < 16 KB in this ecosystem).
15
15
  *
16
- * Opt-out: hub-side path-prefix deny list. The Notes PWA at `/app/notes/*`
16
+ * Opt-out: hub-side path-prefix deny list. The Notes PWA at `/surface/notes/*`
17
17
  * is the canonical opt-out — it owns its own chrome (see design-system §7
18
18
  * "Where NOT to inject" + AUDIT §4: "Notes is the proof this can work: own
19
19
  * application, looks distinctively Notes, reads as Parachute because the
@@ -22,7 +22,7 @@
22
22
  * Why path-based and not module-declared:
23
23
  * - Notes is a `uis[]` sub-unit of parachute-app, not its own module —
24
24
  * adding `chrome: "off"` to parachute-app's module.json would suppress
25
- * chrome on `/app/admin/*` too (wrong: that surface SHOULD get chrome).
25
+ * chrome on `/surface/admin/*` too (wrong: that surface SHOULD get chrome).
26
26
  * - The per-uis well-known fan-out (workstream C/4) is in flight but the
27
27
  * hub side doesn't yet thread per-uis metadata into proxy dispatch.
28
28
  * - HTML meta-tag peeking adds parsing overhead on every response.
@@ -46,10 +46,10 @@ import { CSRF_FIELD_NAME, ensureCsrfToken } from "./csrf.ts";
46
46
  * prefix" or "pathname startsWith prefix" — the same shape as
47
47
  * `findServiceUpstream`'s mount comparison.
48
48
  *
49
- * `/app/notes/` covers the Notes PWA bundled by parachute-app. Notes is a
49
+ * `/surface/notes/` covers the Notes PWA bundled by parachute-app. Notes is a
50
50
  * destination, not chrome; it owns its own header (see design-system.md §7).
51
51
  */
52
- export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/app/notes/"];
52
+ export const CHROME_OPT_OUT_PREFIXES: readonly string[] = ["/surface/notes/"];
53
53
 
54
54
  /**
55
55
  * Buffer size cap. Responses larger than this are passed through unchanged.
@@ -208,8 +208,8 @@ function renderSignedOutCluster(nextPath: string): string {
208
208
  * when any opt-out prefix matches (`pathname === prefix` or
209
209
  * `pathname startsWith prefix`).
210
210
  *
211
- * Match shape mirrors `findServiceUpstream` so an opt-out for `"/app/notes/"`
212
- * suppresses chrome for `/app/notes`, `/app/notes/`, and every sub-path.
211
+ * Match shape mirrors `findServiceUpstream` so an opt-out for `"/surface/notes/"`
212
+ * suppresses chrome for `/surface/notes`, `/surface/notes/`, and every sub-path.
213
213
  */
214
214
  export function shouldInjectChrome(
215
215
  pathname: string,
@@ -1,8 +1,8 @@
1
- import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { createConnection } from "node:net";
3
- import { homedir } from "node:os";
4
3
  import { dirname, join } from "node:path";
5
4
  import { autoWireScribeAuth } from "../auto-wire.ts";
5
+ import { bunGlobalPrefixes, isLinked as defaultIsLinkedShared } from "../bun-link.ts";
6
6
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "../config.ts";
7
7
  import {
8
8
  type ModuleManifest,
@@ -260,25 +260,12 @@ async function defaultRunner(cmd: readonly string[]): Promise<number> {
260
260
  return await proc.exited;
261
261
  }
262
262
 
263
- function bunGlobalPrefixes(): string[] {
264
- const prefixes: string[] = [];
265
- const fromEnv = process.env.BUN_INSTALL;
266
- if (fromEnv) prefixes.push(join(fromEnv, "install", "global", "node_modules"));
267
- prefixes.push(join(homedir(), ".bun", "install", "global", "node_modules"));
268
- return prefixes;
269
- }
270
-
271
- function defaultIsLinked(pkg: string): boolean {
272
- for (const prefix of bunGlobalPrefixes()) {
273
- const path = join(prefix, ...pkg.split("/"));
274
- try {
275
- if (lstatSync(path).isSymbolicLink()) return true;
276
- } catch {
277
- // Not present at this prefix; try the next.
278
- }
279
- }
280
- return false;
281
- }
263
+ // `bunGlobalPrefixes` + `defaultIsLinked` were extracted to `src/bun-link.ts`
264
+ // so the wizard's parallel install path (`api-modules-ops.ts:runInstall`) can
265
+ // reuse the same detection — the two paths diverging is the bug class hub#433
266
+ // fixed (smoke 2026-05-27, finding 1). `defaultIsLinkedShared` is imported at
267
+ // module scope; alias kept for the in-function local-shadow convention below.
268
+ const defaultIsLinked = defaultIsLinkedShared;
282
269
 
283
270
  function defaultLinkedPath(pkg: string): string | null {
284
271
  // bun has two install shapes for "linked-style" globals:
@@ -56,9 +56,18 @@ export async function probe(
56
56
  try {
57
57
  const res = await fetchImpl(url, { signal: controller.signal });
58
58
  const latencyMs = Math.round(performance.now() - start);
59
+ // A 401 is the service replying "I'm up but this endpoint requires auth"
60
+ // — that's strictly healthy from a liveness perspective. Vault's
61
+ // canonical health path `/vault/<name>/health` is auth-gated; without
62
+ // this carve-out, `parachute status` shows vault as "failing" on every
63
+ // fresh install (first impression UX disaster despite vault being fine).
64
+ // 5xx → unhealthy; 200-class → healthy; 401 → healthy + auth-gated.
65
+ // Other 4xx (404 / 400 / etc.) still count as unhealthy — those mean
66
+ // the configured health path doesn't exist or is shaped wrong.
67
+ const healthy = res.ok || res.status === 401;
59
68
  return {
60
69
  entry,
61
- healthy: res.ok,
70
+ healthy,
62
71
  statusCode: res.status,
63
72
  latencyMs,
64
73
  };
package/src/help.ts CHANGED
@@ -82,7 +82,7 @@ Environment:
82
82
 
83
83
  Examples:
84
84
  parachute install vault # installs, runs init, starts vault
85
- parachute install app # installs app (auto-bootstraps Notes)
85
+ parachute install surface # installs surface (auto-bootstraps Notes)
86
86
  parachute install notes # back-compat: legacy notes-daemon (Phase 2 deprecating)
87
87
  parachute install scribe # installs, prompts for provider, starts scribe
88
88
  parachute install scribe --scribe-provider groq --scribe-key gsk_…
@@ -188,7 +188,7 @@ Example:
188
188
  parachute-vault 1940 0.2.4 active 12345 2h 13m 2ms bun-linked → parachute-vault @ 8aa167b
189
189
  → http://127.0.0.1:1940/vault/default/mcp
190
190
  parachute-app 1946 0.2.0 active 12346 2h 12m 3ms npm (0.2.0-rc.4)
191
- → http://127.0.0.1:1946/app/notes
191
+ → http://127.0.0.1:1946/surface/notes
192
192
  `;
193
193
  }
194
194
 
package/src/hub-db.ts CHANGED
@@ -278,6 +278,48 @@ const MIGRATIONS: readonly Migration[] = [
278
278
  UPDATE clients SET same_hub = 0;
279
279
  `,
280
280
  },
281
+ {
282
+ version: 10,
283
+ sql: `
284
+ -- Multi-user Phase 2 PR 2 (hub#252 follow-up, design
285
+ -- 2026-05-20-multi-user-phase-1.md §Phase 2). Lifts the single
286
+ -- \`users.assigned_vault TEXT\` column into a many-to-many
287
+ -- \`user_vaults\` table so one user can have access to multiple
288
+ -- vaults (e.g. a personal vault + a family-shared vault).
289
+ --
290
+ -- Schema:
291
+ -- * (user_id, vault_name) composite PK — one row per (user, vault).
292
+ -- ON DELETE CASCADE on user_id so user deletion drops the
293
+ -- assignments without us having to clean up manually.
294
+ -- * \`role\` TEXT DEFAULT 'write' — reserved for forward-compat per-
295
+ -- vault role granularity. Phase 1 had no role model; this column
296
+ -- gives later PRs a column to land scope-narrowing in without a
297
+ -- second migration. All backfilled rows default to 'write'.
298
+ -- * index on \`vault_name\` for the inverse lookup ("who has access
299
+ -- to vault X?") — useful when admin removes a vault and we want
300
+ -- to warn about pinned users.
301
+ --
302
+ -- Backfill: every existing row in \`users\` with a non-null
303
+ -- \`assigned_vault\` becomes a single (user_id, vault_name) row in
304
+ -- \`user_vaults\`. Rows with NULL \`assigned_vault\` (admin posture)
305
+ -- get no \`user_vaults\` entry — they remain "no narrowing" per
306
+ -- vaultScopeForUser semantics. After backfill the \`assigned_vault\`
307
+ -- column is dropped.
308
+ CREATE TABLE user_vaults (
309
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
310
+ vault_name TEXT NOT NULL,
311
+ role TEXT NOT NULL DEFAULT 'write',
312
+ created_at TEXT NOT NULL,
313
+ PRIMARY KEY (user_id, vault_name)
314
+ );
315
+ CREATE INDEX user_vaults_vault ON user_vaults (vault_name);
316
+ INSERT INTO user_vaults (user_id, vault_name, role, created_at)
317
+ SELECT id, assigned_vault, 'write', created_at
318
+ FROM users
319
+ WHERE assigned_vault IS NOT NULL;
320
+ ALTER TABLE users DROP COLUMN assigned_vault;
321
+ `,
322
+ },
281
323
  ];
282
324
 
283
325
  export function openHubDb(path: string = hubDbPath()): Database {
package/src/hub-server.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * /admin/login, /admin/logout → 301 → /login, /logout
24
24
  *
25
25
  * # Notes-as-app migration Phase 2 (parachute-app design doc §16).
26
- * /notes, /notes/, /notes/* → 301 → /app/notes[/...]
26
+ * /notes, /notes/, /notes/* → 301 → /surface/notes[/...]
27
27
  * (opt-out via
28
28
  * hub_settings.notes_redirect_disabled)
29
29
  *
@@ -65,6 +65,7 @@
65
65
  * /api/users (GET + POST) → list / create user (host:admin)
66
66
  * /api/users/vaults (GET) → vault-name list for assigned-vault picker (host:admin)
67
67
  * /api/users/<id> (DELETE) → hard-delete user + revoke tokens (host:admin)
68
+ * /api/users/<id>/reset-password (POST) → admin-initiated password reset (host:admin)
68
69
  * /login (GET + POST) → operator password login
69
70
  * /logout (POST) → end admin session
70
71
  * /account/change-password (GET + POST) → user self-service change-password
@@ -117,7 +118,11 @@ import {
117
118
  import { handleHostAdminToken } from "./admin-host-admin-token.ts";
118
119
  import { handleVaultAdminToken } from "./admin-vault-admin-token.ts";
119
120
  import { handleCreateVault } from "./admin-vaults.ts";
120
- import { handleAccountChangePasswordGet, handleAccountChangePasswordPost } from "./api-account.ts";
121
+ import {
122
+ handleAccountChangePasswordGet,
123
+ handleAccountChangePasswordPost,
124
+ handleAccountHomeGet,
125
+ } from "./api-account.ts";
121
126
  import { handleApiHub } from "./api-hub.ts";
122
127
  import { handleApiMe } from "./api-me.ts";
123
128
  import { handleApiMintToken } from "./api-mint-token.ts";
@@ -141,6 +146,8 @@ import {
141
146
  handleDeleteUser,
142
147
  handleListUsers,
143
148
  handleListVaults,
149
+ handleResetUserPassword,
150
+ handleUpdateUserVaults,
144
151
  } from "./api-users.ts";
145
152
  import { buildChromeForRequest, injectChromeIntoResponse } from "./chrome-strip.ts";
146
153
  import { CONFIG_DIR, SERVICES_MANIFEST_PATH } from "./config.ts";
@@ -266,10 +273,7 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
266
273
  if (hostname === undefined) hostname = env.PARACHUTE_BIND_HOST || "127.0.0.1";
267
274
  if (wellKnownDir === undefined) wellKnownDir = WELL_KNOWN_DIR;
268
275
  if (issuer === undefined) {
269
- const fromEnv =
270
- env.PARACHUTE_HUB_ORIGIN ??
271
- env.RENDER_EXTERNAL_URL ??
272
- flyDefaultOrigin(env);
276
+ const fromEnv = env.PARACHUTE_HUB_ORIGIN ?? env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(env);
273
277
  if (fromEnv) issuer = fromEnv.replace(/\/+$/, "") || undefined;
274
278
  }
275
279
  return { port, hostname, wellKnownDir, dbPath: dbPath ?? hubDbPath(), issuer };
@@ -1118,8 +1122,7 @@ export function hubFetch(
1118
1122
  // browser POSTs and must be trusted even when the operator's
1119
1123
  // configured issuer points elsewhere. See origin-check.ts
1120
1124
  // jsdoc for the failure case this closes.
1121
- platformOrigin:
1122
- process.env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(process.env),
1125
+ platformOrigin: process.env.RENDER_EXTERNAL_URL ?? flyDefaultOrigin(process.env),
1123
1126
  }),
1124
1127
  };
1125
1128
  };
@@ -1199,7 +1202,7 @@ export function hubFetch(
1199
1202
  }
1200
1203
 
1201
1204
  // Notes-as-app migration Phase 2 (parachute-app design doc §16).
1202
- // `/notes/*` 301-redirects to `/app/notes/*` so legacy bookmarks land on
1205
+ // `/notes/*` 301-redirects to `/surface/notes/*` so legacy bookmarks land on
1203
1206
  // the apps-hosted Notes. Default-on; operators on notes-as-module-only
1204
1207
  // installs can opt out via `hub_settings.notes_redirect_disabled = true`
1205
1208
  // (see hub-settings.ts). The opt-out exists so a legacy operator
@@ -1908,6 +1911,44 @@ export function hubFetch(
1908
1911
  manifestPath,
1909
1912
  });
1910
1913
  }
1914
+ // Phase 2 PR 1 — `/api/users/:id/reset-password` (admin-initiated
1915
+ // password reset for non-admin users). Routed BEFORE the per-id DELETE
1916
+ // catch-all so the trailing `/reset-password` segment isn't mistaken
1917
+ // for part of a user id. Same `host:admin` Bearer gate as the other
1918
+ // /api/users surfaces.
1919
+ {
1920
+ const resetMatch = pathname.match(/^\/api\/users\/([^/]+)\/reset-password$/);
1921
+ if (resetMatch) {
1922
+ if (!getDb) return dbNotConfigured();
1923
+ const id = decodeURIComponent(resetMatch[1] ?? "");
1924
+ if (!id) {
1925
+ return new Response("not found", { status: 404 });
1926
+ }
1927
+ return handleResetUserPassword(req, id, {
1928
+ db: getDb(),
1929
+ issuer: oauthDeps(req).issuer,
1930
+ manifestPath,
1931
+ });
1932
+ }
1933
+ }
1934
+ // Phase 2 PR 2 — `/api/users/:id/vaults` (replace a user's vault
1935
+ // assignments). Routed before the per-id DELETE catch-all so the
1936
+ // trailing `/vaults` segment isn't mistaken for part of a user id.
1937
+ {
1938
+ const vaultsMatch = pathname.match(/^\/api\/users\/([^/]+)\/vaults$/);
1939
+ if (vaultsMatch) {
1940
+ if (!getDb) return dbNotConfigured();
1941
+ const id = decodeURIComponent(vaultsMatch[1] ?? "");
1942
+ if (!id) {
1943
+ return new Response("not found", { status: 404 });
1944
+ }
1945
+ return handleUpdateUserVaults(req, id, {
1946
+ db: getDb(),
1947
+ issuer: oauthDeps(req).issuer,
1948
+ manifestPath,
1949
+ });
1950
+ }
1951
+ }
1911
1952
  if (pathname.startsWith("/api/users/")) {
1912
1953
  if (!getDb) return dbNotConfigured();
1913
1954
  const id = decodeURIComponent(pathname.slice("/api/users/".length));
@@ -1961,6 +2002,24 @@ export function hubFetch(
1961
2002
  return new Response("method not allowed", { status: 405 });
1962
2003
  }
1963
2004
 
2005
+ // /account/ — friend-facing user home (multi-user Phase 1 follow-up).
2006
+ // Companion to the first-admin gate on `/admin/host-admin-token`: a
2007
+ // signed-in non-admin (friend) lands here instead of bouncing against
2008
+ // a 403 wall on the admin SPA. Admin users also land here when they
2009
+ // hit `/account/` directly, with a "you're the administrator → /admin/"
2010
+ // exit ramp. Bare `/account` 301-redirects to `/account/` so links
2011
+ // without the trailing slash work.
2012
+ if (pathname === "/account" || pathname === "/account/") {
2013
+ if (req.method !== "GET") return new Response("method not allowed", { status: 405 });
2014
+ if (pathname === "/account") {
2015
+ return new Response(null, { status: 301, headers: { location: "/account/" } });
2016
+ }
2017
+ if (!getDb) return dbNotConfigured();
2018
+ const db = getDb();
2019
+ const hubOrigin = resolveIssuer(req, db, configuredIssuer);
2020
+ return handleAccountHomeGet(req, { db, hubOrigin });
2021
+ }
2022
+
1964
2023
  // Legacy `/admin/config` (server-rendered module-config portal, #46)
1965
2024
  // retired post-SPA-rework. 301 → the SPA home so any bookmark or stale
1966
2025
  // post-login redirect lands somewhere useful. The route stays here in
@@ -2032,7 +2091,7 @@ export function hubFetch(
2032
2091
  * Inject the persistent chrome strip (workstream G) into a proxied response.
2033
2092
  *
2034
2093
  * Skips the rewrite when the response is non-200, non-HTML, on an opt-out
2035
- * path (e.g. `/app/notes/*`), or larger than `MAX_INJECT_SIZE_BYTES`.
2094
+ * path (e.g. `/surface/notes/*`), or larger than `MAX_INJECT_SIZE_BYTES`.
2036
2095
  * `injectChromeIntoResponse` is the no-side-effects implementation; this
2037
2096
  * wrapper threads in the session-aware chrome HTML and a `set-cookie`
2038
2097
  * append when a fresh CSRF cookie was minted.
@@ -71,7 +71,7 @@ export type HubSettingKey =
71
71
  | "hub_origin"
72
72
  // Notes-as-app migration Phase 2 (parachute-app design doc §16).
73
73
  // When unset (default) or "false", hub serves a 301 redirect from
74
- // `/notes/*` → `/app/notes/*` so existing bookmarks transparently
74
+ // `/notes/*` → `/surface/notes/*` so existing bookmarks transparently
75
75
  // follow the operator to the apps-hosted Notes. When "true", the
76
76
  // redirect is skipped and `/notes/*` falls through to the existing
77
77
  // services.json-driven proxy — the escape hatch for operators
@@ -372,7 +372,7 @@ export function setHubOrigin(db: Database, value: string | null): void {
372
372
  // --- domain helpers: notes-as-app redirect (parachute-app §16 Phase 2) ----
373
373
 
374
374
  /**
375
- * Read whether the `/notes/*` → `/app/notes/*` redirect is disabled. Default
375
+ * Read whether the `/notes/*` → `/surface/notes/*` redirect is disabled. Default
376
376
  * is `false` (redirect on) — Phase 2 migrates operators to apps-hosted
377
377
  * Notes, so the bookmark-friendly path is the default-on behavior. Only an
378
378
  * operator running notes-as-a-module without parachute-app installed should
package/src/hub.ts CHANGED
@@ -471,9 +471,9 @@ const HTML_TEMPLATE = `<!doctype html>
471
471
  * Render the "Get started" section (hub#342) above the Services grid.
472
472
  *
473
473
  * One hardcoded target, conditional on its prerequisite being installed:
474
- * - "Open Notes" → /app/notes/ (requires parachute-app installed;
475
- * App auto-bootstraps Notes-as-UI per parachute-app §17, so the
476
- * mere presence of App means /app/notes/ is live)
474
+ * - "Open Notes" → /surface/notes/ (requires parachute-surface installed;
475
+ * Surface auto-bootstraps Notes-as-UI per parachute-surface §17, so the
476
+ * mere presence of Surface means /surface/notes/ is live)
477
477
  *
478
478
  * The earlier "Browse Vault" tile retired in workstream C (2026-05-25)
479
479
  * once vault declared uiUrl in its module.json (per patterns#96). With
@@ -500,12 +500,12 @@ const HTML_TEMPLATE = `<!doctype html>
500
500
  function renderGetStarted(services) {
501
501
  if (!getStartedGrid || !getStartedSection) return;
502
502
  const tiles = [];
503
- const hasApp = services.some((s) => s && s.name === 'parachute-app');
504
- if (hasApp) {
503
+ const hasSurface = services.some((s) => s && s.name === 'parachute-surface');
504
+ if (hasSurface) {
505
505
  tiles.push({
506
506
  title: 'Open Notes',
507
507
  desc: 'Browse + capture in the Notes app — reads from your vault.',
508
- href: '/app/notes/',
508
+ href: '/surface/notes/',
509
509
  });
510
510
  }
511
511
  if (tiles.length === 0) {
@@ -2,9 +2,9 @@
2
2
  * Notes-as-app migration Phase 2 (parachute-app design doc §16).
3
3
  *
4
4
  * When parachute-app ships and Notes installs as `parachute-app add
5
- * @openparachute/notes-ui --name notes --path /app/notes`, operators with
5
+ * @openparachute/notes-ui --name notes --path /surface/notes`, operators with
6
6
  * existing `/notes/*` bookmarks need a transparent bridge. The hub serves a
7
- * 301 redirect from `/notes/*` → `/app/notes/*` so:
7
+ * 301 redirect from `/notes/*` → `/surface/notes/*` so:
8
8
  *
9
9
  * - cached operator URLs (notes PWA install banners, browser history,
10
10
  * in-vault links) keep working
@@ -44,14 +44,14 @@ export function isLegacyNotesPath(pathname: string): boolean {
44
44
  * string. The query is preserved verbatim; the fragment isn't visible
45
45
  * server-side (clients reassemble it after following the redirect).
46
46
  *
47
- * The transform is purely path-rewrite — `/notes` → `/app/notes`, `/notes/`
48
- * → `/app/notes/`, `/notes/foo/bar` → `/app/notes/foo/bar`.
47
+ * The transform is purely path-rewrite — `/notes` → `/surface/notes`, `/notes/`
48
+ * → `/surface/notes/`, `/notes/foo/bar` → `/surface/notes/foo/bar`.
49
49
  */
50
50
  export function buildNotesRedirectTarget(pathname: string, search: string): string {
51
51
  // Slice off the leading "/notes" — what remains is either "" (bare /notes),
52
52
  // "/" (trailing slash), or "/<rest>" (sub-path).
53
53
  const tail = pathname.slice("/notes".length);
54
- return `/app/notes${tail}${search}`;
54
+ return `/surface/notes${tail}${search}`;
55
55
  }
56
56
 
57
57
  /**