@greensecurity/javascript-sdk 0.46.0-beta.10 → 0.46.0-beta.11

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 (183) hide show
  1. package/dist/commonjs/__tests__/apps/contract.test.js +288 -4
  2. package/dist/commonjs/__tests__/apps/contract.test.js.map +1 -1
  3. package/dist/commonjs/__tests__/apps/devpreview-routes.test.d.ts +2 -0
  4. package/dist/commonjs/__tests__/apps/devpreview-routes.test.d.ts.map +1 -0
  5. package/dist/commonjs/__tests__/apps/devpreview-routes.test.js +86 -0
  6. package/dist/commonjs/__tests__/apps/devpreview-routes.test.js.map +1 -0
  7. package/dist/commonjs/__tests__/apps/kit/example.test.js +84 -4
  8. package/dist/commonjs/__tests__/apps/kit/example.test.js.map +1 -1
  9. package/dist/commonjs/__tests__/apps/kit/pages.test.d.ts +2 -0
  10. package/dist/commonjs/__tests__/apps/kit/pages.test.d.ts.map +1 -0
  11. package/dist/commonjs/__tests__/apps/kit/pages.test.js +119 -0
  12. package/dist/commonjs/__tests__/apps/kit/pages.test.js.map +1 -0
  13. package/dist/commonjs/__tests__/apps/kit/render.test.js +24 -0
  14. package/dist/commonjs/__tests__/apps/kit/render.test.js.map +1 -1
  15. package/dist/commonjs/__tests__/apps/kit/store.test.d.ts +2 -0
  16. package/dist/commonjs/__tests__/apps/kit/store.test.d.ts.map +1 -0
  17. package/dist/commonjs/__tests__/apps/kit/store.test.js +123 -0
  18. package/dist/commonjs/__tests__/apps/kit/store.test.js.map +1 -0
  19. package/dist/commonjs/__tests__/apps/post-install-page.test.d.ts +2 -0
  20. package/dist/commonjs/__tests__/apps/post-install-page.test.d.ts.map +1 -0
  21. package/dist/commonjs/__tests__/apps/post-install-page.test.js +110 -0
  22. package/dist/commonjs/__tests__/apps/post-install-page.test.js.map +1 -0
  23. package/dist/commonjs/__tests__/apps/routes.test.d.ts +2 -0
  24. package/dist/commonjs/__tests__/apps/routes.test.d.ts.map +1 -0
  25. package/dist/commonjs/__tests__/apps/routes.test.js +238 -0
  26. package/dist/commonjs/__tests__/apps/routes.test.js.map +1 -0
  27. package/dist/commonjs/__tests__/vendors.test.js +1 -1
  28. package/dist/commonjs/__tests__/webhooks.test.js +1 -1
  29. package/dist/commonjs/__tests__/webhooks.test.js.map +1 -1
  30. package/dist/commonjs/__tests__/zones.test.js +2 -9
  31. package/dist/commonjs/__tests__/zones.test.js.map +1 -1
  32. package/dist/commonjs/apps/components.d.ts +321 -0
  33. package/dist/commonjs/apps/components.d.ts.map +1 -1
  34. package/dist/commonjs/apps/components.js +168 -2
  35. package/dist/commonjs/apps/components.js.map +1 -1
  36. package/dist/commonjs/apps/context.d.ts +24 -0
  37. package/dist/commonjs/apps/context.d.ts.map +1 -1
  38. package/dist/commonjs/apps/context.js.map +1 -1
  39. package/dist/commonjs/apps/devpreview.d.ts +20 -3
  40. package/dist/commonjs/apps/devpreview.d.ts.map +1 -1
  41. package/dist/commonjs/apps/devpreview.js +18 -7
  42. package/dist/commonjs/apps/devpreview.js.map +1 -1
  43. package/dist/commonjs/apps/hooks/index.d.ts +33 -2
  44. package/dist/commonjs/apps/hooks/index.d.ts.map +1 -1
  45. package/dist/commonjs/apps/hooks/index.js +41 -1
  46. package/dist/commonjs/apps/hooks/index.js.map +1 -1
  47. package/dist/commonjs/apps/hooks/store.d.ts +48 -0
  48. package/dist/commonjs/apps/hooks/store.d.ts.map +1 -0
  49. package/dist/commonjs/apps/hooks/store.js +87 -0
  50. package/dist/commonjs/apps/hooks/store.js.map +1 -0
  51. package/dist/commonjs/apps/manifest.d.ts +64 -3
  52. package/dist/commonjs/apps/manifest.d.ts.map +1 -1
  53. package/dist/commonjs/apps/manifest.js +128 -27
  54. package/dist/commonjs/apps/manifest.js.map +1 -1
  55. package/dist/commonjs/apps/protocol.d.ts +1 -1
  56. package/dist/commonjs/apps/protocol.d.ts.map +1 -1
  57. package/dist/commonjs/apps/route.d.ts +11 -1
  58. package/dist/commonjs/apps/route.d.ts.map +1 -1
  59. package/dist/commonjs/apps/route.js +13 -7
  60. package/dist/commonjs/apps/route.js.map +1 -1
  61. package/dist/commonjs/apps/routes.d.ts +65 -0
  62. package/dist/commonjs/apps/routes.d.ts.map +1 -0
  63. package/dist/commonjs/apps/routes.js +202 -0
  64. package/dist/commonjs/apps/routes.js.map +1 -0
  65. package/dist/commonjs/apps/surfaces.d.ts +20 -2
  66. package/dist/commonjs/apps/surfaces.d.ts.map +1 -1
  67. package/dist/commonjs/apps/surfaces.js +45 -7
  68. package/dist/commonjs/apps/surfaces.js.map +1 -1
  69. package/dist/commonjs/apps/testing/index.d.ts +17 -1
  70. package/dist/commonjs/apps/testing/index.d.ts.map +1 -1
  71. package/dist/commonjs/apps/testing/index.js +50 -1
  72. package/dist/commonjs/apps/testing/index.js.map +1 -1
  73. package/dist/commonjs/apps/ui/index.d.ts +13 -0
  74. package/dist/commonjs/apps/ui/index.d.ts.map +1 -1
  75. package/dist/commonjs/apps/ui/index.js +19 -2
  76. package/dist/commonjs/apps/ui/index.js.map +1 -1
  77. package/dist/commonjs/lib/config.d.ts +2 -2
  78. package/dist/commonjs/lib/config.js +2 -2
  79. package/dist/esm/__tests__/apps/contract.test.js +289 -5
  80. package/dist/esm/__tests__/apps/contract.test.js.map +1 -1
  81. package/dist/esm/__tests__/apps/devpreview-routes.test.d.ts +2 -0
  82. package/dist/esm/__tests__/apps/devpreview-routes.test.d.ts.map +1 -0
  83. package/dist/esm/__tests__/apps/devpreview-routes.test.js +84 -0
  84. package/dist/esm/__tests__/apps/devpreview-routes.test.js.map +1 -0
  85. package/dist/esm/__tests__/apps/kit/example.test.js +85 -5
  86. package/dist/esm/__tests__/apps/kit/example.test.js.map +1 -1
  87. package/dist/esm/__tests__/apps/kit/pages.test.d.ts +2 -0
  88. package/dist/esm/__tests__/apps/kit/pages.test.d.ts.map +1 -0
  89. package/dist/esm/__tests__/apps/kit/pages.test.js +117 -0
  90. package/dist/esm/__tests__/apps/kit/pages.test.js.map +1 -0
  91. package/dist/esm/__tests__/apps/kit/render.test.js +25 -1
  92. package/dist/esm/__tests__/apps/kit/render.test.js.map +1 -1
  93. package/dist/esm/__tests__/apps/kit/store.test.d.ts +2 -0
  94. package/dist/esm/__tests__/apps/kit/store.test.d.ts.map +1 -0
  95. package/dist/esm/__tests__/apps/kit/store.test.js +121 -0
  96. package/dist/esm/__tests__/apps/kit/store.test.js.map +1 -0
  97. package/dist/esm/__tests__/apps/post-install-page.test.d.ts +2 -0
  98. package/dist/esm/__tests__/apps/post-install-page.test.d.ts.map +1 -0
  99. package/dist/esm/__tests__/apps/post-install-page.test.js +108 -0
  100. package/dist/esm/__tests__/apps/post-install-page.test.js.map +1 -0
  101. package/dist/esm/__tests__/apps/routes.test.d.ts +2 -0
  102. package/dist/esm/__tests__/apps/routes.test.d.ts.map +1 -0
  103. package/dist/esm/__tests__/apps/routes.test.js +236 -0
  104. package/dist/esm/__tests__/apps/routes.test.js.map +1 -0
  105. package/dist/esm/__tests__/vendors.test.js +1 -1
  106. package/dist/esm/__tests__/webhooks.test.js +1 -1
  107. package/dist/esm/__tests__/webhooks.test.js.map +1 -1
  108. package/dist/esm/__tests__/zones.test.js +2 -9
  109. package/dist/esm/__tests__/zones.test.js.map +1 -1
  110. package/dist/esm/apps/components.d.ts +321 -0
  111. package/dist/esm/apps/components.d.ts.map +1 -1
  112. package/dist/esm/apps/components.js +163 -1
  113. package/dist/esm/apps/components.js.map +1 -1
  114. package/dist/esm/apps/context.d.ts +24 -0
  115. package/dist/esm/apps/context.d.ts.map +1 -1
  116. package/dist/esm/apps/context.js.map +1 -1
  117. package/dist/esm/apps/devpreview.d.ts +20 -3
  118. package/dist/esm/apps/devpreview.d.ts.map +1 -1
  119. package/dist/esm/apps/devpreview.js +19 -8
  120. package/dist/esm/apps/devpreview.js.map +1 -1
  121. package/dist/esm/apps/hooks/index.d.ts +33 -2
  122. package/dist/esm/apps/hooks/index.d.ts.map +1 -1
  123. package/dist/esm/apps/hooks/index.js +37 -1
  124. package/dist/esm/apps/hooks/index.js.map +1 -1
  125. package/dist/esm/apps/hooks/store.d.ts +48 -0
  126. package/dist/esm/apps/hooks/store.d.ts.map +1 -0
  127. package/dist/esm/apps/hooks/store.js +84 -0
  128. package/dist/esm/apps/hooks/store.js.map +1 -0
  129. package/dist/esm/apps/manifest.d.ts +64 -3
  130. package/dist/esm/apps/manifest.d.ts.map +1 -1
  131. package/dist/esm/apps/manifest.js +116 -26
  132. package/dist/esm/apps/manifest.js.map +1 -1
  133. package/dist/esm/apps/protocol.d.ts +1 -1
  134. package/dist/esm/apps/protocol.d.ts.map +1 -1
  135. package/dist/esm/apps/route.d.ts +11 -1
  136. package/dist/esm/apps/route.d.ts.map +1 -1
  137. package/dist/esm/apps/route.js +14 -8
  138. package/dist/esm/apps/route.js.map +1 -1
  139. package/dist/esm/apps/routes.d.ts +65 -0
  140. package/dist/esm/apps/routes.d.ts.map +1 -0
  141. package/dist/esm/apps/routes.js +194 -0
  142. package/dist/esm/apps/routes.js.map +1 -0
  143. package/dist/esm/apps/surfaces.d.ts +20 -2
  144. package/dist/esm/apps/surfaces.d.ts.map +1 -1
  145. package/dist/esm/apps/surfaces.js +39 -2
  146. package/dist/esm/apps/surfaces.js.map +1 -1
  147. package/dist/esm/apps/testing/index.d.ts +17 -1
  148. package/dist/esm/apps/testing/index.d.ts.map +1 -1
  149. package/dist/esm/apps/testing/index.js +49 -1
  150. package/dist/esm/apps/testing/index.js.map +1 -1
  151. package/dist/esm/apps/ui/index.d.ts +13 -0
  152. package/dist/esm/apps/ui/index.d.ts.map +1 -1
  153. package/dist/esm/apps/ui/index.js +17 -1
  154. package/dist/esm/apps/ui/index.js.map +1 -1
  155. package/dist/esm/lib/config.d.ts +2 -2
  156. package/dist/esm/lib/config.js +2 -2
  157. package/examples/apps/hello-green/src/store.ts +15 -0
  158. package/jsr.json +1 -1
  159. package/package.json +1 -1
  160. package/src/__tests__/apps/contract.test.ts +305 -4
  161. package/src/__tests__/apps/devpreview-routes.test.ts +103 -0
  162. package/src/__tests__/apps/kit/example.test.ts +94 -5
  163. package/src/__tests__/apps/kit/pages.test.ts +137 -0
  164. package/src/__tests__/apps/kit/render.test.ts +35 -1
  165. package/src/__tests__/apps/kit/store.test.ts +149 -0
  166. package/src/__tests__/apps/post-install-page.test.ts +126 -0
  167. package/src/__tests__/apps/routes.test.ts +318 -0
  168. package/src/__tests__/vendors.test.ts +1 -1
  169. package/src/__tests__/webhooks.test.ts +1 -1
  170. package/src/__tests__/zones.test.ts +2 -9
  171. package/src/apps/components.ts +183 -1
  172. package/src/apps/context.ts +25 -0
  173. package/src/apps/devpreview.ts +20 -7
  174. package/src/apps/hooks/index.ts +42 -2
  175. package/src/apps/hooks/store.ts +115 -0
  176. package/src/apps/manifest.ts +151 -27
  177. package/src/apps/protocol.ts +1 -1
  178. package/src/apps/route.ts +22 -10
  179. package/src/apps/routes.ts +230 -0
  180. package/src/apps/surfaces.ts +45 -12
  181. package/src/apps/testing/index.ts +48 -2
  182. package/src/apps/ui/index.ts +18 -1
  183. package/src/lib/config.ts +2 -2
@@ -11,6 +11,9 @@ import * as z from "zod/v4";
11
11
  import {
12
12
  addCspBudgetIssue,
13
13
  addDuplicateViewportIssues,
14
+ addRouteIssues,
15
+ APP_ROUTES_MAX,
16
+ appRouteSchema,
14
17
  CSP_SOURCES_MAX_TOTAL_CHARS,
15
18
  cspSourcesTotalChars,
16
19
  appConstantsSchema,
@@ -44,7 +47,9 @@ const devCspSourceSchema = z.union([
44
47
  ]);
45
48
 
46
49
  /**
47
- * `green-app.dev.json`. It may override constants, CSP sources and views while iterating.
50
+ * `green-app.dev.json`. It may override constants, CSP sources, views and routes (app pages) while
51
+ * iterating, so a new view or page can be previewed before it is published. Views and routes are
52
+ * UI placements and grant nothing, and a dev session is shown only to its developer.
48
53
  * Permissions and the bundle cannot be overridden: permissions are granted to the app's role on
49
54
  * `upload`, and the bundle comes from the dev server.
50
55
  */
@@ -54,24 +59,27 @@ export const devManifestSchema = z
54
59
  constants: appConstantsSchema.optional(),
55
60
  ui_extensions: z.strictObject({
56
61
  views: z.array(appViewSchema).max(200).optional(),
62
+ routes: z.array(appRouteSchema).max(APP_ROUTES_MAX).optional(),
57
63
  csp: z.strictObject({
58
64
  connect_src: z.array(devCspSourceSchema).max(CSP_SOURCES_MAX_PER_DIRECTIVE).optional(),
59
65
  image_src: z.array(devCspSourceSchema).max(CSP_SOURCES_MAX_PER_DIRECTIVE).optional(),
60
66
  }).optional(),
61
67
  }).optional(),
62
68
  })
63
- // Same invariant as published manifests: dev views replace the published list, so a duplicate
64
- // here would make view resolution order-dependent.
69
+ // Same invariants as published manifests: dev views and routes replace the published lists, so a
70
+ // duplicate here would make view resolution or route matching order-dependent.
65
71
  .superRefine((dev, ctx) => {
66
72
  addDuplicateViewportIssues(dev.ui_extensions?.views, ctx, ["ui_extensions", "views"]);
73
+ addRouteIssues(dev.ui_extensions?.routes, ctx, ["ui_extensions", "routes"]);
67
74
  addCspBudgetIssue(dev.ui_extensions?.csp, ctx, ["ui_extensions", "csp"]);
68
75
  });
69
76
 
70
77
  export type DevManifest = z.infer<typeof devManifestSchema>;
71
78
 
72
79
  /**
73
- * Applies a dev manifest over the published one. Object fields merge; arrays (`views`, CSP
74
- * sources) replace, so a developer can drop a view locally without editing the base file.
80
+ * Applies a dev manifest over the published one. Object fields merge; arrays (`views`, `routes`,
81
+ * CSP sources) replace, so a developer can drop a view or a page locally without editing the base
82
+ * file.
75
83
  * Throws {@link DevManifestError} if the merged CSP sources exceed the header budget.
76
84
  */
77
85
  export function mergeDevManifest(base: AppManifestV2, dev: DevManifest): AppManifestV2 {
@@ -85,14 +93,19 @@ export function mergeDevManifest(base: AppManifestV2, dev: DevManifest): AppMani
85
93
 
86
94
  if (base.ui_extensions === undefined && dev.ui_extensions === undefined) return merged;
87
95
 
88
- // A preview renders views, and every rendered view needs a version (`ExtensionContext.app.version`).
89
- // A dev file cannot supply one, so a versionless (v1) base cannot be previewed with views.
96
+ // A preview renders views and pages, and every rendered one needs a version
97
+ // (`ExtensionContext.app.version`). A dev file cannot supply one, so a versionless (v1) base
98
+ // cannot be previewed with views or routes.
90
99
  if (devUi.views !== undefined && devUi.views.length > 0 && base.version === undefined) {
91
100
  throw new DevManifestError("the base manifest needs a version before its views can be previewed");
92
101
  }
102
+ if (devUi.routes !== undefined && devUi.routes.length > 0 && base.version === undefined) {
103
+ throw new DevManifestError("the base manifest needs a version before its routes can be previewed");
104
+ }
93
105
 
94
106
  const ui: NonNullable<AppManifestV2["ui_extensions"]> = { ...baseUi };
95
107
  if (devUi.views !== undefined) ui.views = devUi.views;
108
+ if (devUi.routes !== undefined) ui.routes = devUi.routes;
96
109
  if (devUi.csp !== undefined) {
97
110
  ui.csp = {
98
111
  ...(baseUi.csp ?? { purpose: "Local development" }),
@@ -6,11 +6,13 @@
6
6
  */
7
7
 
8
8
  import { useContext } from "react";
9
- import type { ExtensionContext } from "../context.js";
9
+ import type { ExtensionContext, PageRoute } from "../context.js";
10
10
  import { TOAST_MAX_LENGTH, type HostApi, type ToastType } from "../protocol.js";
11
11
  import { isRouteDescriptor, type RouteDescriptor } from "../route.js";
12
12
  import { AppSessionContext, currentSession, type AppSessionValue } from "../runtime/session.js";
13
13
 
14
+ export { createAppStore, type AppStore, type AppStoreListener, type AppStoreUpdate } from "./store.js";
15
+
14
16
  const TOAST_TYPES: readonly ToastType[] = ["success", "caution", "pending"];
15
17
 
16
18
  function useSessionValue(hook: string): AppSessionValue {
@@ -26,6 +28,40 @@ export function useExtensionContext(): ExtensionContext {
26
28
  return useSessionValue("useExtensionContext").context;
27
29
  }
28
30
 
31
+ /**
32
+ * The app page being shown, on views rendered as one of the app's `ui_extensions.routes`
33
+ * (`environment.route`): the declared `path` (`/reports/:id`), the concrete `pathname`
34
+ * (`/reports/7`) and the decoded `params` (`{ id: "7" }`, always strings). `undefined` on every
35
+ * other view. Re-renders with the new route when the host moves to another path of the same page
36
+ * (an `updateContext`), so read params here rather than once on mount.
37
+ *
38
+ * ```tsx
39
+ * const route = usePageRoute();
40
+ * const id = Number(route?.params["id"]);
41
+ * ```
42
+ */
43
+ export function usePageRoute(): PageRoute | undefined {
44
+ return useSessionValue("usePageRoute").context.environment.route;
45
+ }
46
+
47
+ const NO_PARAMS: Readonly<Record<string, string>> = Object.freeze({});
48
+
49
+ /**
50
+ * The `envParams` this app passed to the `navigate` call that opened this view
51
+ * (`environment.navParams`). Only this app can set them, and they never appear in an address, so
52
+ * they are the place for navigation data the view relies on. The page's URL query
53
+ * (`environment.queryParams`) is different: anyone can put anything in a link, so treat it as
54
+ * untrusted input. Empty (`{}`) when the view did not open from a navigation.
55
+ *
56
+ * ```tsx
57
+ * // VendorPanel: navigate({ type: "appPage", path: "/reports", envParams: { vendorId: "42" } })
58
+ * const { vendorId } = useNavParams();
59
+ * ```
60
+ */
61
+ export function useNavParams(): Readonly<Record<string, string>> {
62
+ return useSessionValue("useNavParams").context.environment.navParams ?? NO_PARAMS;
63
+ }
64
+
29
65
  /** The host API proxy for the view being rendered. Every method is async. */
30
66
  export function useHostApi(): HostApi {
31
67
  return useSessionValue("useHostApi").host;
@@ -68,7 +104,11 @@ export function showToast(message: string, opts: ShowToastOptions = {}): Promise
68
104
  }
69
105
  }
70
106
 
71
- /** Asks the host to navigate. Apps name a destination; the host owns its routes. */
107
+ /**
108
+ * Asks the host to navigate. Apps name a destination; the host owns its routes. To open one of
109
+ * the app's own pages, pass a concrete path in the app's space:
110
+ * `navigate({ type: "appPage", path: "/reports/7" })` (not `/reports/:id`).
111
+ */
72
112
  export function navigate(to: RouteDescriptor, opts: { host?: HostApi } = {}): Promise<void> {
73
113
  if (!isRouteDescriptor(to)) return Promise.reject(new TypeError("navigate() needs a valid RouteDescriptor"));
74
114
  try {
@@ -0,0 +1,115 @@
1
+ /*
2
+ * Hand-written (not generated by Speakeasy). Third-party app developer kit (C10); see
3
+ * docs/third-party-apps/plan.md.
4
+ *
5
+ * State shared by the views of one app. The host runs one sandbox (one worker) per app and mounts
6
+ * every view of that app into it, each as its own React root. React context therefore stops at a
7
+ * view's edge, but module state does not: a store created at module level is the same object in
8
+ * every view.
9
+ */
10
+
11
+ import { useCallback, useRef, useSyncExternalStore } from "react";
12
+
13
+ export type AppStoreListener<T> = (state: T, previous: T) => void;
14
+
15
+ /** Fields to change, or a function of the current state that returns them. */
16
+ export type AppStoreUpdate<T> = Partial<T> | ((state: T) => Partial<T>);
17
+
18
+ export interface AppStore<T extends object> {
19
+ /** The current state. The same object until a `setState` changes a field. */
20
+ getState(): T;
21
+ /**
22
+ * Shallow-merges `update` into the state, like React's class `setState`: fields left out keep
23
+ * their values. A new state object is made, and listeners run, only when a field changes
24
+ * (`Object.is`), so setting a field to its current value re-renders nothing.
25
+ */
26
+ setState(update: AppStoreUpdate<T>): void;
27
+ /** Calls `listener` after each change, with the new and the previous state. Returns the unsubscribe. */
28
+ subscribe(listener: AppStoreListener<T>): () => void;
29
+ /**
30
+ * Reads the store in a component and re-renders it when the value changes. Without a selector
31
+ * the component re-renders on every change; with one, only when the selected value changes
32
+ * (`Object.is`), so select a field (or derive a primitive) rather than build a new object.
33
+ * The subscription ends when the component unmounts.
34
+ */
35
+ useStore(): T;
36
+ useStore<S>(selector: (state: T) => S): S;
37
+ }
38
+
39
+ /**
40
+ * Creates a store that every view of this app can read and update. Create it once, at module
41
+ * level, in a file the views import:
42
+ *
43
+ * ```ts
44
+ * // src/store.ts
45
+ * export const notes = createAppStore({ saved: 0 });
46
+ * // in a view
47
+ * const saved = notes.useStore((s) => s.saved);
48
+ * notes.setState((s) => ({ saved: s.saved + 1 }));
49
+ * ```
50
+ *
51
+ * What it guarantees, and what it does not:
52
+ * - Shared by every view of this app that is open in the same browser tab (web) or the same app
53
+ * session (iOS, Android): a ContextView, its FocusView, the settings page and the app's pages
54
+ * all see one state, and an update in one re-renders the others.
55
+ * - In memory only. Nothing is written to storage or sent anywhere.
56
+ * - Lost whenever the sandbox restarts: after a crash, a rebuild in a dev preview, sign-out, a
57
+ * reload of the web page, or a restart of the mobile app. Each restart begins again from
58
+ * `initial`. Keep anything that must last in your own backend or the Secret Store.
59
+ * - Never shared with other apps: each app has its own sandbox.
60
+ * - Not shared across devices, browser tabs or users.
61
+ */
62
+ export function createAppStore<T extends object>(initial: T): AppStore<T> {
63
+ let state = initial;
64
+ const listeners = new Set<AppStoreListener<T>>();
65
+
66
+ const store: AppStore<T> = {
67
+ getState: () => state,
68
+
69
+ setState(update) {
70
+ const patch = typeof update === "function" ? update(state) : update;
71
+ const keys = Object.keys(patch) as Array<keyof T>;
72
+ if (!keys.some((key) => !Object.is(state[key], patch[key]))) return;
73
+ const previous = state;
74
+ state = { ...state, ...patch };
75
+ for (const listener of [...listeners]) listener(state, previous);
76
+ },
77
+
78
+ subscribe(listener) {
79
+ listeners.add(listener);
80
+ return () => {
81
+ listeners.delete(listener);
82
+ };
83
+ },
84
+
85
+ useStore: function useStore<S>(selector?: (state: T) => S): S | T {
86
+ return useStoreValue(store, selector);
87
+ } as AppStore<T>["useStore"],
88
+ };
89
+ return store;
90
+ }
91
+
92
+ /**
93
+ * `useSyncExternalStore` over `store`. `store.subscribe` is read at render time, so it is the
94
+ * store's own function (or whatever replaced it), and the subscription follows it.
95
+ */
96
+ function useStoreValue<T extends object, S>(store: AppStore<T>, selector?: (state: T) => S): S | T {
97
+ // The last selection, so a selector that derives a new object from an unchanged state still
98
+ // gives React the same snapshot (React requires a stable snapshot between changes).
99
+ const cache = useRef<{ state: T; selector: ((state: T) => S) | undefined; value: S | T } | null>(null);
100
+ const subscribe = store.subscribe;
101
+ const onStoreChange = useCallback((notify: () => void) => subscribe(() => notify()), [subscribe]);
102
+
103
+ const getSnapshot = (): S | T => {
104
+ const current = store.getState();
105
+ const cached = cache.current;
106
+ if (cached && cached.state === current && cached.selector === selector) return cached.value;
107
+ const value = selector ? selector(current) : current;
108
+ // A changed state whose selection is equal keeps the previous value, so the view skips a render.
109
+ const stable = cached && Object.is(cached.value, value) ? cached.value : value;
110
+ cache.current = { state: current, selector, value: stable };
111
+ return stable;
112
+ };
113
+
114
+ return useSyncExternalStore(onStoreChange, getSnapshot, getSnapshot);
115
+ }
@@ -10,7 +10,16 @@
10
10
  */
11
11
 
12
12
  import * as z from "zod/v4";
13
- import { SURFACES } from "./surfaces.js";
13
+ import {
14
+ APP_ROUTES_MAX,
15
+ CONCRETE_ROUTE_PATH_PATTERN,
16
+ isConcreteRoutePath,
17
+ matchAppRoute,
18
+ ROUTE_PATH_MAX_CHARS,
19
+ ROUTE_PATH_PATTERN,
20
+ routeSetIssues,
21
+ } from "./routes.js";
22
+ import { SURFACES, type Portal } from "./surfaces.js";
14
23
  import {
15
24
  CSP_SOURCE_MAX_CHARS,
16
25
  CSP_SOURCE_PATTERN,
@@ -37,6 +46,21 @@ export {
37
46
  isParsableHttpsUrl,
38
47
  } from "./url.js";
39
48
 
49
+ // The route grammar and matcher live in the zod-free `routes.ts` for the same reason.
50
+ export {
51
+ APP_ROUTES_MAX,
52
+ CONCRETE_ROUTE_PATH_PATTERN,
53
+ isConcreteRoutePath,
54
+ isRoutePath,
55
+ matchAppRoute,
56
+ normalizeRoutePath,
57
+ ROUTE_PATH_MAX_CHARS,
58
+ ROUTE_PATH_MAX_SEGMENTS,
59
+ ROUTE_PATH_PATTERN,
60
+ routeSetIssues,
61
+ type AppRouteMatch,
62
+ type RouteSetIssue,
63
+ } from "./routes.js";
40
64
 
41
65
  /** A single SRI hash with the digest length its algorithm produces (32, 48 or 64 bytes, base64). */
42
66
  export const SRI_PATTERN = "^(sha256-[A-Za-z0-9+/]{43}=|sha384-[A-Za-z0-9+/]{64}|sha512-[A-Za-z0-9+/]{86}==)$";
@@ -78,7 +102,11 @@ const PLATFORM_PERMISSION_NAMES: ReadonlySet<string> = new Set(PLATFORM_PERMISSI
78
102
  export function isPlatformPermission(value: unknown): value is PlatformPermission {
79
103
  return typeof value === "string" && PLATFORM_PERMISSION_NAMES.has(value);
80
104
  }
81
- const SURFACE_IDS = SURFACES.map((s) => s.id) as [string, ...string[]];
105
+ // A view's viewport. `page` is not one: an app declares its pages in `routes`, not in `views`.
106
+ const SURFACE_IDS = SURFACES.filter((s) => s.kind !== "page").map((s) => s.id) as [string, ...string[]];
107
+
108
+ /** Portals an app page may be offered in (`routes[].portals`). */
109
+ const ROUTE_PORTALS = ["company", "vendor", "facility", "system"] as const satisfies readonly Portal[];
82
110
 
83
111
  /** Lifecycle events an app's own webhook endpoint can receive (see C19). */
84
112
  export const APP_WEBHOOK_EVENTS = [
@@ -148,6 +176,50 @@ export const appViewSchema = z.strictObject({
148
176
  component: z.string().max(100).regex(new RegExp(COMPONENT_NAME_PATTERN)),
149
177
  });
150
178
 
179
+ /** Reports {@link routeSetIssues} (segment cap, repeated params, duplicate paths, repeated portals). */
180
+ export function addRouteIssues(
181
+ routes: ReadonlyArray<{ path: string; portals?: readonly string[] | undefined; navigation?: unknown }> | undefined,
182
+ ctx: z.RefinementCtx,
183
+ path: ReadonlyArray<string | number>,
184
+ ): void {
185
+ for (const issue of routeSetIssues(routes ?? [])) {
186
+ ctx.addIssue({ code: "custom", path: [...path, issue.index, issue.field], message: issue.message });
187
+ }
188
+ }
189
+
190
+ /**
191
+ * One app page. The host renders `component` at `/{portal}/apps/{appId}/pages{path}`.
192
+ *
193
+ * `title` is required here, for new manifests. The server keeps accepting title-less entries in a
194
+ * bundle-less v1 manifest, which stored `routes[{ path, component }]` before pages were rendered;
195
+ * such a manifest has nothing to render them with, and the host never shows them.
196
+ */
197
+ /**
198
+ * Text a person reads (a page title, a sidebar label): at least one character that isn't
199
+ * whitespace. The host never shows a blank one, so the manifest can't publish one.
200
+ *
201
+ * This is JavaScript's `\S` spelled out (ASCII whitespace, the Unicode space separators,
202
+ * U+2028/U+2029 and U+FEFF), because the generated schema's patterns also run as Ruby regexps, and
203
+ * Ruby's `\S` is ASCII-only: there, a title of no-break spaces would pass.
204
+ */
205
+ export const NON_BLANK_PATTERN =
206
+ "[^\\t\\n\\v\\f\\r \\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff]";
207
+
208
+ export const appRouteSchema = z.strictObject({
209
+ path: z.string().max(ROUTE_PATH_MAX_CHARS).regex(new RegExp(ROUTE_PATH_PATTERN)),
210
+ component: z.string().max(100).regex(new RegExp(COMPONENT_NAME_PATTERN)),
211
+ /** Page header title. */
212
+ title: z.string().min(1).max(100).regex(new RegExp(NON_BLANK_PATTERN), { message: "must not be blank" }),
213
+ /** Portals the page is offered in. Absent = every portal the host renders. */
214
+ portals: z.array(z.enum(ROUTE_PORTALS)).min(1).max(ROUTE_PORTALS.length).optional(),
215
+ /** Offer the page in the sidebar / mobile drawer menu under this label (default: `title`). */
216
+ navigation: z
217
+ .strictObject({
218
+ label: z.string().min(1).max(40).regex(new RegExp(NON_BLANK_PATTERN), { message: "must not be blank" }).optional(),
219
+ })
220
+ .optional(),
221
+ });
222
+
151
223
  /** Constants are delivered to every view as `environment.constants`. */
152
224
  export const appConstantsSchema = z.record(
153
225
  z.string().max(100).regex(/^[A-Za-z_][A-Za-z0-9_]*$/),
@@ -192,21 +264,20 @@ export const appManifestSchema = z
192
264
  integrity: z.string().max(200).regex(new RegExp(SRI_PATTERN)),
193
265
  }).optional(),
194
266
  views: z.array(appViewSchema).max(200).optional(),
195
- /** Reserved for a future FullPageView. Stored and validated, not rendered. */
196
- routes: z
197
- .array(z.strictObject({
198
- path: z.string().max(200).regex(/^\/[a-z0-9\-/:]*$/),
199
- component: z.string().max(100).regex(new RegExp(COMPONENT_NAME_PATTERN)),
200
- }))
201
- .max(50)
202
- .optional(),
267
+ /** App pages, each a full screen under `/{portal}/apps/{appId}/pages`. */
268
+ routes: z.array(appRouteSchema).max(APP_ROUTES_MAX).optional(),
203
269
  csp: appCspSchema.optional(),
204
270
  })
205
271
  .optional(),
206
272
  post_install_action: z
207
273
  .strictObject({
208
- type: z.enum(["url", "app_settings_view", "app_main_view"]),
274
+ type: z.enum(["url", "app_settings_view", "app_main_view", "app_page"]),
209
275
  url: httpsUrlSchema.optional(),
276
+ /**
277
+ * For `app_page` only: the page to open, a concrete path (no `:params`) that one of
278
+ * `ui_extensions.routes` matches, such as `/welcome` or `/reports/7`.
279
+ */
280
+ path: z.string().max(ROUTE_PATH_MAX_CHARS).regex(new RegExp(CONCRETE_ROUTE_PATH_PATTERN)).optional(),
210
281
  })
211
282
  .optional(),
212
283
  // The app's own webhook endpoint: registered by the catalog when the manifest is published, not
@@ -220,34 +291,54 @@ export const appManifestSchema = z
220
291
  .optional(),
221
292
  })
222
293
  .superRefine((manifest, ctx) => {
223
- addDuplicateViewportIssues(manifest.ui_extensions?.views, ctx, ["ui_extensions", "views"]);
224
- addCspBudgetIssue(manifest.ui_extensions?.csp, ctx, ["ui_extensions", "csp"]);
225
294
  const events = manifest.webhooks?.events ?? [];
226
295
  if (new Set(events).size !== events.length) {
227
296
  ctx.addIssue({ code: "custom", path: ["webhooks", "events"], message: "events must not repeat" });
228
297
  }
298
+ if (manifest.post_install_action?.type === "url" && !manifest.post_install_action.url) {
299
+ ctx.addIssue({ code: "custom", path: ["post_install_action", "url"], message: "url is required for type=url" });
300
+ }
301
+ if (manifest.post_install_action?.type !== "app_page" && manifest.post_install_action?.path !== undefined) {
302
+ ctx.addIssue({ code: "custom", path: ["post_install_action", "path"], message: "path is only allowed for type=app_page" });
303
+ }
229
304
 
230
- if (manifest.ui_extensions?.views?.length && !manifest.ui_extensions.bundle) {
305
+ // When a field under `ui_extensions` fails its own schema, zod hands this refinement the
306
+ // manifest without `ui_extensions`, so every rule below would report a problem that isn't
307
+ // there (a missing settings view, an unmatched post-install page). Report the real one only.
308
+ if (ctx.issues.some((issue) => issue.path?.[0] === "ui_extensions")) return;
309
+
310
+ addDuplicateViewportIssues(manifest.ui_extensions?.views, ctx, ["ui_extensions", "views"]);
311
+ addRouteIssues(manifest.ui_extensions?.routes, ctx, ["ui_extensions", "routes"]);
312
+ addCspBudgetIssue(manifest.ui_extensions?.csp, ctx, ["ui_extensions", "csp"]);
313
+
314
+ const hasViews = Boolean(manifest.ui_extensions?.views?.length);
315
+ const hasRoutes = Boolean(manifest.ui_extensions?.routes?.length);
316
+ if (hasViews && !manifest.ui_extensions?.bundle) {
231
317
  ctx.addIssue({
232
318
  code: "custom",
233
319
  path: ["ui_extensions", "bundle"],
234
320
  message: "bundle is required when views are declared",
235
321
  });
236
322
  }
323
+ if (hasRoutes && !manifest.ui_extensions?.bundle) {
324
+ ctx.addIssue({
325
+ code: "custom",
326
+ path: ["ui_extensions", "bundle"],
327
+ message: "bundle is required when routes are declared",
328
+ });
329
+ }
237
330
 
238
331
  // `version` stays optional only for legacy (v1) manifests. Anything the host renders needs one:
239
332
  // it names the runtime URL and reaches views as `ExtensionContext.app.version`.
240
- if ((manifest.ui_extensions?.bundle || manifest.ui_extensions?.views?.length) && !manifest.version) {
333
+ if ((manifest.ui_extensions?.bundle || hasViews || hasRoutes) && !manifest.version) {
241
334
  ctx.addIssue({
242
335
  code: "custom",
243
336
  path: ["version"],
244
- message: "version is required when ui_extensions declares a bundle or views",
337
+ message: "version is required when ui_extensions declares a bundle, views or routes",
245
338
  });
246
339
  }
247
340
 
248
- if (manifest.post_install_action?.type === "url" && !manifest.post_install_action.url) {
249
- ctx.addIssue({ code: "custom", path: ["post_install_action", "url"], message: "url is required for type=url" });
250
- }
341
+ addAppPageActionIssues(manifest, ctx);
251
342
 
252
343
  // A back-end-only app has no views, so it cannot land installers on one. Checked only for
253
344
  // versioned (v2) manifests: v1 apps may carry a post-install view type from before views existed.
@@ -261,34 +352,67 @@ export const appManifestSchema = z
261
352
  });
262
353
  }
263
354
  // The settings page is not a main view: a settings-only app should land on app_settings_view.
264
- const mainViewports = [...viewports].filter((v) => v !== "settings");
265
- if (versioned && manifest.post_install_action?.type === "app_main_view" && mainViewports.length === 0) {
355
+ // A root page (route `/`) is one: installing opens `/{portal}/apps/{id}/pages`.
356
+ if (versioned && manifest.post_install_action?.type === "app_main_view" && !hasMainView(manifest)) {
266
357
  ctx.addIssue({
267
358
  code: "custom",
268
359
  path: ["post_install_action", "type"],
269
- message: "app_main_view requires a view other than settings",
360
+ message: "app_main_view requires a view other than settings, or a root route",
270
361
  });
271
362
  }
272
363
  });
273
364
 
274
365
  export type AppManifestV2 = z.infer<typeof appManifestSchema>;
366
+ export type AppRoute = z.infer<typeof appRouteSchema>;
367
+
368
+ /**
369
+ * The rules for `post_install_action.path`: required for `app_page` and allowed nowhere else, a
370
+ * concrete path within the route limits, and one that a declared route matches (`/reports/7` for a
371
+ * `/reports/:id` route), so installing never opens a page the app does not have.
372
+ */
373
+ function addAppPageActionIssues(
374
+ manifest: Pick<AppManifestV2, "post_install_action" | "ui_extensions">,
375
+ ctx: z.RefinementCtx,
376
+ ): void {
377
+ const action = manifest.post_install_action;
378
+ if (action === undefined) return;
379
+ const path = ["post_install_action", "path"];
380
+
381
+ // `path` on another type is reported by the manifest refinement, before the rules that need
382
+ // `ui_extensions`.
383
+ if (action.type !== "app_page") return;
384
+ if (action.path === undefined) {
385
+ ctx.addIssue({ code: "custom", path, message: "path is required for type=app_page" });
386
+ } else if (!isConcreteRoutePath(action.path)) {
387
+ ctx.addIssue({ code: "custom", path, message: "path must be a page path without :params, such as /reports/7" });
388
+ } else if (matchAppRoute(manifest.ui_extensions?.routes ?? [], action.path) === null) {
389
+ ctx.addIssue({ code: "custom", path, message: "path must match one of ui_extensions.routes" });
390
+ }
391
+ }
392
+
393
+ /** Whether a manifest has a main view to land on: a view other than settings, or a root page `/`. */
394
+ function hasMainView(manifest: { ui_extensions?: AppManifestV2["ui_extensions"] }): boolean {
395
+ const views = manifest.ui_extensions?.views ?? [];
396
+ const routes = manifest.ui_extensions?.routes ?? [];
397
+ return views.some((v) => v.viewport !== "settings") || routes.some((r) => r.path === "/");
398
+ }
275
399
 
276
400
  export type PostInstallAction = NonNullable<AppManifestV2["post_install_action"]>;
277
401
 
278
402
  /**
279
403
  * What installing the app does when `post_install_action` is left out, so the server and the host
280
404
  * never land an installer on a view the app does not have: nothing for a back-end-only app, the
281
- * settings page for a settings-only app, and the app's main view otherwise. `null` means
282
- * "no post-install step". An explicit action is returned unchanged.
405
+ * settings page for a settings-only app, and the app's main view otherwise. A root page (route
406
+ * `/`) counts as a main view. `null` means "no post-install step". An explicit action, including
407
+ * `app_page`, is returned unchanged.
283
408
  */
284
409
  export function effectivePostInstallAction(
285
410
  manifest: Pick<AppManifestV2, "post_install_action" | "ui_extensions">,
286
411
  ): PostInstallAction | null {
287
412
  if (manifest.post_install_action) return manifest.post_install_action;
288
413
 
289
- const viewports = (manifest.ui_extensions?.views ?? []).map((v) => v.viewport);
290
- if (viewports.some((v) => v !== "settings")) return { type: "app_main_view" };
291
- if (viewports.includes("settings")) return { type: "app_settings_view" };
414
+ if (hasMainView(manifest)) return { type: "app_main_view" };
415
+ if ((manifest.ui_extensions?.views ?? []).some((v) => v.viewport === "settings")) return { type: "app_settings_view" };
292
416
  return null;
293
417
  }
294
418
  export type AppView = z.infer<typeof appViewSchema>;
@@ -30,7 +30,7 @@ export type RemoteMutationRecord =
30
30
  | readonly [type: 2, textId: string, text: string]
31
31
  | readonly [type: 3, id: string, name: string, value: unknown, updateType?: 1 | 2 | 3];
32
32
 
33
- export type ViewType = "context" | "focus" | "settings";
33
+ export type ViewType = "context" | "focus" | "settings" | "page";
34
34
 
35
35
  /** Implemented inside the worker by the SDK runtime (`defineApp`). */
36
36
  export interface SandboxExports {
package/src/apps/route.ts CHANGED
@@ -2,20 +2,28 @@
2
2
  * Hand-written (not generated by Speakeasy). Part of the third-party app platform contract;
3
3
  * see docs/third-party-apps/plan.md.
4
4
  *
5
- * Apps never navigate by path. They name a destination and the host maps it to its own routes,
6
- * which keeps host URLs free to change and stops an app from steering users to arbitrary pages.
5
+ * Apps never navigate by host path. They name a destination and the host maps it to its own
6
+ * routes, which keeps host URLs free to change and stops an app from steering users to arbitrary
7
+ * pages. The one path an app writes, `appPage`, is a path inside its own pages space.
7
8
  */
8
9
 
9
- import { isSurfaceId, type SurfaceId } from "./surfaces.js";
10
+ import { isConcreteRoutePath } from "./routes.js";
11
+ import { getSurface, type SurfaceId } from "./surfaces.js";
10
12
 
11
- /** Values the destination view receives as `environment.queryParams`. Strings only. */
13
+ /** Values the destination view receives as `environment.navParams`, never as `queryParams`. Strings only. */
12
14
  export type EnvParams = Record<string, string>;
13
15
 
14
16
  export type RouteDescriptor =
15
17
  /** The screen a surface lives on, optionally for one record (detail surfaces). */
16
18
  | { type: "surface"; surfaceId: SurfaceId; objectId?: number; envParams?: EnvParams }
17
19
  /** The calling app's own settings page (the `settings` surface). */
18
- | { type: "appSettings"; envParams?: EnvParams };
20
+ | { type: "appSettings"; envParams?: EnvParams }
21
+ /**
22
+ * One of the calling app's own pages, by concrete path in the app's space (`/reports/7`, not
23
+ * `/reports/:id`). The host checks that it matches one of the app's routes offered in the
24
+ * current portal. There is no `appId`: an app can only open its own pages.
25
+ */
26
+ | { type: "appPage"; path: string; envParams?: EnvParams };
19
27
 
20
28
  export function isRouteDescriptor(value: unknown): value is RouteDescriptor {
21
29
  if (typeof value !== "object" || value === null || !("type" in value)) return false;
@@ -25,14 +33,18 @@ export function isRouteDescriptor(value: unknown): value is RouteDescriptor {
25
33
 
26
34
  switch (v["type"]) {
27
35
  case "surface":
28
- // Only catalog surfaces: the host has no route for anything else. Safe integers only, so a
29
- // record id is never silently rounded on its way through JSON. `settings` is reachable only
30
- // as `appSettings`, which always means the calling app: as a plain surface its `objectId`
31
- // would name another app's settings page.
32
- return isSurfaceId(v["surfaceId"]) && v["surfaceId"] !== "settings" &&
36
+ // Only catalog surfaces of a portal: the host has no route for anything else. Safe integers
37
+ // only, so a record id is never silently rounded on its way through JSON. The portal-less
38
+ // surfaces belong to an app and are reachable only through descriptors that always mean the
39
+ // calling app (`appSettings`, `appPage`): as a plain surface, `objectId` would name another
40
+ // app's settings page or pages.
41
+ return typeof v["surfaceId"] === "string" && (getSurface(v["surfaceId"])?.portal ?? null) !== null &&
33
42
  (v["objectId"] === undefined || (Number.isSafeInteger(v["objectId"]) && (v["objectId"] as number) > 0));
34
43
  case "appSettings":
35
44
  return true;
45
+ case "appPage":
46
+ // Concrete paths only, in the route grammar: no params, dots, escapes or empty segments.
47
+ return isConcreteRoutePath(v["path"]);
36
48
  default:
37
49
  return false;
38
50
  }