@sanity/workbench 0.1.0-alpha.11 → 0.1.0-alpha.13

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.
@@ -1,40 +1,52 @@
1
1
  import type { SemVer } from "semver";
2
2
  import { coerce, gt, gte, lt, rsort, valid } from "semver";
3
+ import { z } from "zod";
3
4
 
4
5
  import type { Project } from "../../projects";
5
6
  import { UserApplication } from "../user-application";
6
7
  import {
8
+ type ClientManifest as ClientManifestType,
9
+ type ServerManifest as ServerManifestType,
7
10
  type Workspace,
8
11
  type StudioUserApplication,
9
12
  ClientManifest as ClientManifestSchema,
13
+ ServerManifest as ServerManifestSchema,
10
14
  } from "./schemas";
11
15
  import { StudioWorkspace } from "./workspace";
12
16
 
13
- const ClientManifest = ClientManifestSchema.superRefine((data, ctx) => {
14
- if (!data.version) {
15
- ctx.addIssue({
16
- code: "invalid_type",
17
- message: "Manifest version is too old",
18
- expected: "number",
19
- });
20
- }
17
+ /**
18
+ * Validated manifest — accepts either a refined client manifest (with
19
+ * version ≥ 2 and `studioVersion` set for v3+) or a server/deployment
20
+ * manifest. Parsing fails when the input matches neither shape.
21
+ */
22
+ const Manifest = z.union([
23
+ ClientManifestSchema.superRefine((data, ctx) => {
24
+ if (!data.version) {
25
+ ctx.addIssue({
26
+ code: "invalid_type",
27
+ message: "Manifest version is too old",
28
+ expected: "number",
29
+ });
30
+ }
21
31
 
22
- if (data.version < 2) {
23
- ctx.addIssue({
24
- code: "invalid_value",
25
- message: "Manifest version is too old",
26
- values: [2, 3],
27
- });
28
- }
32
+ if (data.version < 2) {
33
+ ctx.addIssue({
34
+ code: "invalid_value",
35
+ message: "Manifest version is too old",
36
+ values: [2, 3],
37
+ });
38
+ }
29
39
 
30
- if (data.version >= 3 && !data.studioVersion) {
31
- ctx.addIssue({
32
- code: "invalid_type",
33
- message: "Manifest version 3 or higher requires a `studioVersion`",
34
- expected: "string",
35
- });
36
- }
37
- });
40
+ if (data.version >= 3 && !data.studioVersion) {
41
+ ctx.addIssue({
42
+ code: "invalid_type",
43
+ message: "Manifest version 3 or higher requires a `studioVersion`",
44
+ expected: "string",
45
+ });
46
+ }
47
+ }),
48
+ ServerManifestSchema,
49
+ ]);
38
50
 
39
51
  const DEFAULT_WORKSPACE_DATA = {
40
52
  name: "default",
@@ -83,21 +95,18 @@ export class StudioApplication extends UserApplication<
83
95
  super(application, "studio", options);
84
96
 
85
97
  /**
86
- * If the application has a manifest, then we validate it and create
87
- * a workspace for each manifest entry. Otherwise, we will create a "default"
88
- * workspace which is pretty much identical to when a user has a single workspace
89
- * studio application using the term "default" in places.
98
+ * Derive workspaces from the application's most authoritative manifest.
99
+ * `this.manifest` already prefers the deployment manifest over the
100
+ * client manifest; the `Manifest` schema accepts either shape and
101
+ * rejects malformed ones (e.g. a v3 client manifest missing
102
+ * `studioVersion`). A parse failure logs a warning and falls through
103
+ * to the default-workspace path rather than crashing.
90
104
  */
91
105
  let workspaces: Workspace[] = [];
92
- if (this.hasManifest) {
93
- /**
94
- * If the workspaces fail to parse we dont want to throw an error because
95
- * this could potentially crash the app. Instead we log a warning and return an empty array.
96
- */
106
+ const manifest = this.manifest;
107
+ if (manifest) {
97
108
  try {
98
- workspaces = ClientManifest.parse(
99
- application.manifestData?.value,
100
- ).workspaces;
109
+ workspaces = Manifest.parse(manifest).workspaces ?? [];
101
110
  } catch (error) {
102
111
  console.warn(
103
112
  `Failed to parse manifest for application ${application.id}`,
@@ -106,34 +115,6 @@ export class StudioApplication extends UserApplication<
106
115
  }
107
116
  }
108
117
 
109
- if (workspaces.length === 0) {
110
- /**
111
- * If the user application does not have a valid manifest or no workspaces,
112
- * we attempt to get the manifest from the server-side maninfest first and then
113
- * fall back to the live-manifest.
114
- *
115
- * In the future this should happen even before checking the traditional manifest,
116
- * but for now we keep the current order to not introduce breaking changes.
117
- *
118
- * The live manifest is inherintly less reliable as it depends on which user has
119
- * uploaded it, which is why this is wrapped in a feature flag for now.
120
- */
121
- const deploymentManifest = application.activeDeployment?.manifest;
122
- // const liveManifest = growthbook.isOn('dashboard-use-live-manifest')
123
- // ? application.config['live-manifest']?.value
124
- // : null
125
-
126
- const serverManifest = deploymentManifest; /*?? liveManifest*/
127
- const serverManifestWorkspaces = serverManifest?.workspaces;
128
-
129
- if (
130
- Array.isArray(serverManifestWorkspaces) &&
131
- serverManifestWorkspaces.length
132
- ) {
133
- workspaces = serverManifestWorkspaces;
134
- }
135
- }
136
-
137
118
  /**
138
119
  * Filter all the workspaces that have a project the user does not have access to.
139
120
  */
@@ -236,24 +217,57 @@ export class StudioApplication extends UserApplication<
236
217
  return this.application[attr];
237
218
  }
238
219
 
239
- get hasManifest(): boolean {
220
+ /**
221
+ * Resolves the studio's most authoritative manifest. The lookup order is:
222
+ *
223
+ * 1. `activeDeployment.manifest` — deployment manifests are the new
224
+ * primitive within Sanity and are preferred whenever available.
225
+ * 2. `manifestData.value` — fallback to support older studios that
226
+ * haven't produced a deployment manifest yet.
227
+ * 3. `application.manifest` — last-resort legacy field.
228
+ *
229
+ * Returns `null` when no manifest is available. The return shape is a
230
+ * union because the deployment and client manifests are not
231
+ * interchangeable — consumers that depend on client-only fields (e.g.
232
+ * `studioVersion`, workspace `schema`) must narrow or read the raw
233
+ * field they need.
234
+ */
235
+ get manifest(): ClientManifestType | ServerManifestType | null {
240
236
  return (
241
- typeof this.application.manifestData?.value?.version === "number" &&
242
- this.application.manifestData?.value?.version >= 2
237
+ this.application.activeDeployment?.manifest ??
238
+ this.application.manifestData?.value ??
239
+ this.application.manifest ??
240
+ null
243
241
  );
244
242
  }
245
243
 
244
+ get hasManifest(): boolean {
245
+ const manifest = this.manifest;
246
+ if (!manifest) return false;
247
+
248
+ // `manifest` is a union: server manifests encode `version` as an optional
249
+ // string, client manifests as a required number. The typeof check doubles
250
+ // as a discriminator — if it's not a number, we're looking at a server
251
+ // manifest whose presence alone is authoritative.
252
+ return typeof manifest.version !== "number" || manifest.version >= 2;
253
+ }
254
+
246
255
  get hasSchema(): boolean {
247
- // In this case we can not look at `this.workspaces` because it won't contain workspaces the user does
248
- // not have access to. The application has a schema, even if the user can't access any of the workspaces,
249
- // otherwise it would be displayed as not having a manifest in the setup guide and therefore considered
256
+ // A deployment manifest tracks a schema descriptor for every workspace,
257
+ // so its presence is sufficient to short-circuit.
258
+ if (this.application.activeDeployment?.manifest) return true;
259
+
260
+ // Otherwise inspect the client manifest. We cannot look at
261
+ // `this.workspaces` because it won't contain workspaces the user does
262
+ // not have access to. The application has a schema even if the user
263
+ // can't access any of the workspaces, otherwise it would be displayed
264
+ // as not having a manifest in the setup guide and therefore considered
250
265
  // partially compatible.
251
- const workspaces = this.get("manifestData")?.value?.workspaces ?? [];
252
-
253
- if (workspaces.length === 0) {
254
- return false;
255
- }
266
+ const clientManifest =
267
+ this.application.manifestData?.value ?? this.application.manifest;
268
+ const workspaces = clientManifest?.workspaces ?? [];
256
269
 
270
+ if (workspaces.length === 0) return false;
257
271
  return workspaces.every((w) => Boolean(w.schema));
258
272
  }
259
273
 
@@ -265,12 +279,13 @@ export class StudioApplication extends UserApplication<
265
279
  if (this.get("urlType") === "internal") {
266
280
  version = this.get("activeDeployment")?.version;
267
281
  } else {
268
- const manifest = this.get("manifestData")?.value;
282
+ const clientManifest =
283
+ this.get("manifestData")?.value ?? this.get("manifest");
269
284
  // The property 'studioVersion' exists only on external studios,
270
285
  // starting from manifest.version 3
271
286
  version =
272
- manifest && "studioVersion" in manifest
273
- ? manifest.studioVersion
287
+ clientManifest && "studioVersion" in clientManifest
288
+ ? clientManifest.studioVersion
274
289
  : undefined;
275
290
  }
276
291
 
@@ -108,7 +108,12 @@ export class StudioWorkspace extends AbstractApplication<
108
108
  id: this.id,
109
109
  hasManifest: true,
110
110
  hasSchema: Boolean("schema" in this.workspace && this.workspace.schema),
111
+ // The protocol resource requires the client-shaped manifest (workspaces
112
+ // with `schema`). Read those sources directly rather than the unified
113
+ // `manifest` getter, which may return a `ServerManifest` for studios
114
+ // with a deployment manifest.
111
115
  manifest: (this.studio.get("manifestData")?.value ??
116
+ this.studio.get("manifest") ??
112
117
  null) as ProtocolStudioResource["manifest"],
113
118
  updatedAt: this.studio.get("updatedAt"),
114
119
  version: this.studio.get("activeDeployment")?.version,
@@ -8,7 +8,7 @@ import {
8
8
  } from "../applications/application";
9
9
  import { getSanityDomain, getSanityEnv } from "../env";
10
10
  import type { CoreAppUserApplicationManifest } from "./core-app";
11
- import type { ClientManifest } from "./studios/schemas";
11
+ import type { ClientManifest, ServerManifest } from "./studios/schemas";
12
12
 
13
13
  /**
14
14
  * User application ID schema, branded for type safety.
@@ -38,6 +38,13 @@ const LocalUserApplicationBase = z.object({
38
38
  port: z.number(),
39
39
  /** The `deployment.appId` from the application's `sanity.cli.ts`, when set. */
40
40
  id: z.string().optional(),
41
+ /**
42
+ * The `api.projectId` from the application's `sanity.cli.ts`. Available
43
+ * synchronously at dev-server startup (no manifest extraction required), so
44
+ * the studio's primary project is resolvable from the very first local
45
+ * application event.
46
+ */
47
+ projectId: z.string().optional(),
41
48
  });
42
49
 
43
50
  /**
@@ -179,4 +186,20 @@ export abstract class UserApplication<
179
186
 
180
187
  return new URL(this.application.appHost);
181
188
  }
189
+
190
+ /**
191
+ * Unified manifest accessor for user applications. Resolves the most
192
+ * authoritative manifest available for the application, preferring the
193
+ * deployment manifest (`activeDeployment.manifest`) before falling back to
194
+ * any client-side manifest. The returned shape depends on the application
195
+ * type and source: studios may yield either a `ServerManifest` (deployment)
196
+ * or a `ClientManifest` (fallback); core apps yield a
197
+ * `CoreAppUserApplicationManifest`. Returns `null` when no manifest is
198
+ * available.
199
+ */
200
+ abstract get manifest():
201
+ | ClientManifest
202
+ | ServerManifest
203
+ | CoreAppUserApplicationManifest
204
+ | null;
182
205
  }
@@ -0,0 +1,223 @@
1
+ import {
2
+ type AuthState,
3
+ AuthStateType,
4
+ type CurrentUser,
5
+ getAuthState,
6
+ type LoggedInAuthState,
7
+ logout,
8
+ type SanityInstance,
9
+ } from "@sanity/sdk";
10
+ import { assign, fromObservable, fromPromise, setup } from "xstate";
11
+
12
+ import type { OSBaseInput } from "./root.machine";
13
+
14
+ /**
15
+ * @internal
16
+ */
17
+ export interface AuthInput extends OSBaseInput {}
18
+
19
+ const authStateLogic = fromObservable<AuthState, AuthInput>(
20
+ ({ input }) => getAuthState(input.instance).observable,
21
+ );
22
+
23
+ /**
24
+ * @internal
25
+ */
26
+ export interface LogoutInput extends OSBaseInput {}
27
+
28
+ const logoutActorLogic = fromPromise<void, LogoutInput>(async ({ input }) => {
29
+ await logout(input.instance);
30
+ });
31
+
32
+ type AuthContext = {
33
+ instance: SanityInstance;
34
+ token: string | null;
35
+ currentUser: CurrentUser | null;
36
+ error: unknown;
37
+ };
38
+
39
+ type AuthEvent = { type: "auth.logout" };
40
+
41
+ export const authLogic = setup({
42
+ types: {
43
+ input: {} as AuthInput,
44
+ context: {} as AuthContext,
45
+ events: {} as AuthEvent,
46
+ tags: {} as "authenticating" | "authenticated" | "error",
47
+ },
48
+ actors: {
49
+ authState: authStateLogic,
50
+ logoutActor: logoutActorLogic,
51
+ },
52
+ delays: {
53
+ authTimeout: 30_000,
54
+ },
55
+ guards: {
56
+ isLoggedInComplete: (_, params: { state: AuthState | undefined }) =>
57
+ params.state?.type === AuthStateType.LOGGED_IN &&
58
+ Boolean((params.state as LoggedInAuthState).token) &&
59
+ (params.state as LoggedInAuthState).currentUser !== null,
60
+ isAuthState: (
61
+ _,
62
+ params: { state: AuthState | undefined; type: AuthStateType },
63
+ ) => params.state?.type === params.type,
64
+ },
65
+ actions: {
66
+ setLoggedIn: assign({
67
+ token: (_, params: { token: string; currentUser: CurrentUser }) =>
68
+ params.token,
69
+ currentUser: (_, params: { token: string; currentUser: CurrentUser }) =>
70
+ params.currentUser,
71
+ error: () => null,
72
+ }),
73
+ clearAuth: assign({
74
+ token: () => null,
75
+ currentUser: () => null,
76
+ error: () => null,
77
+ }),
78
+ setError: assign({
79
+ token: () => null,
80
+ currentUser: () => null,
81
+ error: (_, params: { error: unknown }) => params.error,
82
+ }),
83
+ },
84
+ }).createMachine({
85
+ id: "auth",
86
+ initial: "init",
87
+ context: ({ input }) => ({
88
+ instance: input.instance,
89
+ token: null,
90
+ currentUser: null,
91
+ error: null,
92
+ }),
93
+ invoke: {
94
+ src: "authState",
95
+ input: ({ context }) => ({ instance: context.instance }),
96
+ onSnapshot: [
97
+ {
98
+ guard: {
99
+ type: "isLoggedInComplete",
100
+ params: ({ event }) => ({
101
+ state: event.snapshot.context,
102
+ }),
103
+ },
104
+ actions: [
105
+ {
106
+ type: "setLoggedIn",
107
+ params: ({ event }) => {
108
+ const state = event.snapshot.context as LoggedInAuthState;
109
+ return {
110
+ token: state.token,
111
+ currentUser: state.currentUser!,
112
+ };
113
+ },
114
+ },
115
+ ],
116
+ target: `.${AuthStateType.LOGGED_IN}`,
117
+ },
118
+ {
119
+ guard: {
120
+ type: "isAuthState",
121
+ params: ({ event }) => ({
122
+ state: event.snapshot.context,
123
+ type: AuthStateType.LOGGING_IN,
124
+ }),
125
+ },
126
+ actions: [{ type: "clearAuth" }],
127
+ target: `.${AuthStateType.LOGGING_IN}`,
128
+ },
129
+ {
130
+ guard: {
131
+ type: "isAuthState",
132
+ params: ({ event }) => ({
133
+ state: event.snapshot.context,
134
+ type: AuthStateType.ERROR,
135
+ }),
136
+ },
137
+ actions: [
138
+ {
139
+ type: "setError",
140
+ params: ({ event }) => ({
141
+ error:
142
+ event.snapshot.context?.type === AuthStateType.ERROR
143
+ ? event.snapshot.context.error
144
+ : null,
145
+ }),
146
+ },
147
+ ],
148
+ target: `.${AuthStateType.ERROR}`,
149
+ },
150
+ {
151
+ guard: {
152
+ type: "isAuthState",
153
+ params: ({ event }) => ({
154
+ state: event.snapshot.context,
155
+ type: AuthStateType.LOGGED_OUT,
156
+ }),
157
+ },
158
+ actions: [{ type: "clearAuth" }],
159
+ target: `.${AuthStateType.LOGGED_OUT}`,
160
+ },
161
+ ],
162
+ },
163
+ states: {
164
+ init: {
165
+ tags: ["authenticating"],
166
+ after: {
167
+ authTimeout: {
168
+ actions: [
169
+ {
170
+ type: "setError",
171
+ params: () => ({
172
+ error: new Error("Authentication timed out"),
173
+ }),
174
+ },
175
+ ],
176
+ target: AuthStateType.ERROR,
177
+ },
178
+ },
179
+ },
180
+ [AuthStateType.LOGGING_IN]: {
181
+ tags: ["authenticating"],
182
+ after: {
183
+ authTimeout: {
184
+ actions: [
185
+ {
186
+ type: "setError",
187
+ params: () => ({
188
+ error: new Error("Authentication timed out"),
189
+ }),
190
+ },
191
+ ],
192
+ target: AuthStateType.ERROR,
193
+ },
194
+ },
195
+ },
196
+ [AuthStateType.LOGGED_IN]: {
197
+ tags: ["authenticated"],
198
+ on: {
199
+ "auth.logout": { target: "logging-out" },
200
+ },
201
+ },
202
+ ["logging-out"]: {
203
+ invoke: {
204
+ src: "logoutActor",
205
+ input: ({ context }) => ({ instance: context.instance }),
206
+ onDone: {
207
+ target: AuthStateType.LOGGED_OUT,
208
+ },
209
+ onError: {
210
+ actions: [
211
+ {
212
+ type: "setError",
213
+ params: ({ event }) => ({ error: event.error }),
214
+ },
215
+ ],
216
+ target: AuthStateType.ERROR,
217
+ },
218
+ },
219
+ },
220
+ [AuthStateType.LOGGED_OUT]: {},
221
+ [AuthStateType.ERROR]: { tags: ["error"] },
222
+ },
223
+ });
@@ -0,0 +1,6 @@
1
+ export type { AuthInput, LogoutInput } from "./auth.machine";
2
+ export { os, createOSOptions } from "./root.machine";
3
+ export type {
4
+ TelemetryInput,
5
+ WorkbenchUserProperties,
6
+ } from "./telemetry.machine";
@@ -0,0 +1,40 @@
1
+ import { type ActorRefLike, type InspectionEvent } from "xstate";
2
+
3
+ import { logger } from "../core";
4
+
5
+ // Mirrors @statelyai/inspect's ActorRefLikeWithData — the inspect
6
+ // callback receives full Actor instances at runtime, but the type is
7
+ // narrowed for @xstate/store compat.
8
+ type ActorRefWithAncestry = ActorRefLike & {
9
+ id?: string;
10
+ _parent?: ActorRefWithAncestry;
11
+ };
12
+
13
+ const actorPath = (ref: ActorRefWithAncestry): string => {
14
+ const segments: string[] = [];
15
+ let current: ActorRefWithAncestry | undefined = ref;
16
+ while (current) {
17
+ segments.unshift(current.id ?? current.sessionId);
18
+ current = current._parent;
19
+ }
20
+ return segments.join(":");
21
+ };
22
+
23
+ /** @internal exported for testing */
24
+ export const inspect = (event: InspectionEvent): void => {
25
+ const ref = event.actorRef as ActorRefWithAncestry;
26
+ const log = logger.child(actorPath(ref));
27
+
28
+ switch (event.type) {
29
+ case "@xstate.snapshot": {
30
+ log.debug("snapshot", event.snapshot);
31
+ break;
32
+ }
33
+ case "@xstate.event":
34
+ log.debug("event", event.event);
35
+ break;
36
+ case "@xstate.action":
37
+ log.debug("action", event.action);
38
+ break;
39
+ }
40
+ };