@sanity-labs/client-fake-for-test 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.cts CHANGED
@@ -107,8 +107,9 @@ export declare interface CreateTestClientOptions {
107
107
  datasets?: Record<string, SanityDocumentLike[]>;
108
108
  /**
109
109
  * Opt-in access-control simulation. When set, the client exposes
110
- * `request({ url })` that serves `/users/me` + the configured ACL
111
- * path, AND every mutation is gated through the supplied `grants`
110
+ * `request({ url })` that serves `/users/me` (host-aware), the
111
+ * configured ACL path, and `/projects/<projectId>/users/<ids>`,
112
+ * AND every mutation is gated through the supplied `grants`
112
113
  * — writes that the grants don't permit throw a `WriteAccessDeniedError`
113
114
  * shaped like Sanity's 403. Lets tests demonstrate the full
114
115
  * "engine soft-gate + lake hard-gate" contract end-to-end without
@@ -165,6 +166,14 @@ export declare interface CreateTestClientOptions {
165
166
  * tests stay green.
166
167
  */
167
168
  perspective?: Perspective;
169
+ /**
170
+ * Which API host the root handle models: the project host (default
171
+ * `true`) or the global host (`false`) — same flag as the real
172
+ * `createClient` config. A global-hosted handle's `/users/me` serves
173
+ * the identity's account-global record. `withConfig({ useProjectHostname })`
174
+ * mints siblings on the other host.
175
+ */
176
+ useProjectHostname?: boolean;
168
177
  }
169
178
 
170
179
  /**
@@ -583,6 +592,19 @@ export declare type PatchOp =
583
592
 
584
593
  export declare type Perspective = "raw" | "published" | "drafts" | string[];
585
594
 
595
+ /**
596
+ * A row served by the project-users directory endpoint
597
+ * (`/projects/<projectId>/users/<id>`), mirroring Sanity's project user
598
+ * retrieval API: the project-scoped `id` alongside its account-global
599
+ * `sanityUserId` — the one sanctioned bridge between the two namespaces.
600
+ */
601
+ export declare interface ProjectUserRecord {
602
+ id: string;
603
+ sanityUserId: string;
604
+ displayName: string;
605
+ email?: string;
606
+ }
607
+
586
608
  /** Strip `drafts.` / `versions.<name>.` prefix to recover the published id. */
587
609
  export declare function publishedIdOf(id: string): string;
588
610
 
@@ -843,14 +865,16 @@ export declare interface TestClient {
843
865
  publishedId: string;
844
866
  }) => Promise<ActionResult>;
845
867
  /**
846
- * Current `(projectId, dataset)` and active perspective. `token` is
847
- * present iff the handle is bound to a registered identity.
868
+ * Current `(projectId, dataset)`, active perspective, and which API host
869
+ * the handle models (`useProjectHostname`). `token` is present iff the
870
+ * handle is bound to a registered identity.
848
871
  */
849
872
  config: () => {
850
873
  projectId: string;
851
874
  dataset: string;
852
875
  perspective: Perspective;
853
876
  token?: string;
877
+ useProjectHostname: boolean;
854
878
  };
855
879
  /**
856
880
  * Return a new client handle pointing at a different `(projectId, dataset)`
@@ -863,6 +887,14 @@ export declare interface TestClient {
863
887
  projectId: string;
864
888
  dataset: string;
865
889
  perspective: Perspective;
890
+ /**
891
+ * Model the global API host (`false`) instead of the project host
892
+ * (default `true`). A global-hosted sibling's `/users/me` serves the
893
+ * identity's account-global record (`globalUser`, falling back to the
894
+ * project record) — mirroring `@sanity/client`, where a non-project
895
+ * config routes requests to `api.sanity.io`.
896
+ */
897
+ useProjectHostname: boolean;
866
898
  /**
867
899
  * Rebind the acting identity (and its grants) on a sibling that shares
868
900
  * the same store — the test analogue of a token-scoped client. Lets two
@@ -873,10 +905,11 @@ export declare interface TestClient {
873
905
  /**
874
906
  * Bind the sibling to a token registered in the family's identity
875
907
  * registry (`createTestClient({ identities })` / `addIdentity`): its
876
- * `/users/me` serves that identity's user, its ACL path serves that
877
- * identity's grants, and reads/writes are gated through them — the
878
- * token determines identity, exactly like production. Unknown tokens
879
- * throw. Mutually exclusive with `accessControl`.
908
+ * `/users/me` serves that identity's record for the sibling's project
909
+ * and host, its ACL path serves that identity's grants, and
910
+ * reads/writes are gated through them — the token determines
911
+ * identity, exactly like production. Unknown tokens throw. Mutually
912
+ * exclusive with `accessControl`.
880
913
  */
881
914
  token: string;
882
915
  }>,
@@ -970,8 +1003,18 @@ export declare interface TestClient {
970
1003
  }
971
1004
 
972
1005
  export declare interface TestClientAccessControl {
973
- /** What `client.request({ url: "/users/me" })` returns. */
1006
+ /** What `client.request({ url: "/users/me" })` returns on a project-hosted handle. */
974
1007
  currentUser: CurrentUser;
1008
+ /**
1009
+ * What `/users/me` returns on a global-hosted handle
1010
+ * (`useProjectHostname: false`, at creation or via `withConfig`).
1011
+ * Sanity serves the
1012
+ * account-global user there (`g*` id, no roles) while the project host
1013
+ * serves the project-scoped record (`p*` id, project roles). Omit to
1014
+ * serve `currentUser` from both — the single-id collapse that is also
1015
+ * exactly how robot tokens behave in production.
1016
+ */
1017
+ globalUser?: CurrentUser;
975
1018
  /** What `client.request({ url: aclPath })` returns. */
976
1019
  grants: Grant[];
977
1020
  /**
@@ -984,13 +1027,36 @@ export declare interface TestClientAccessControl {
984
1027
  /**
985
1028
  * One entry in the token→identity registry (`createTestClient({ identities })`).
986
1029
  * A handle bound to the entry's token behaves exactly like one configured
987
- * with `accessControl: { currentUser: user, grants }` — `/users/me` serves
988
- * `user`, the default ACL path serves `grants`, and reads/writes are gated
989
- * through them.
1030
+ * with `accessControl: { currentUser: projectUserOf(identity, projectId),
1031
+ * globalUser, grants }` — `/users/me` serves the record for the handle's
1032
+ * project and host, the default ACL path serves `grants`, and reads/writes
1033
+ * are gated through them.
990
1034
  */
991
1035
  export declare interface TestClientIdentity {
992
- /** What `/users/me` returns for clients bound to this token. */
1036
+ /**
1037
+ * The project-scoped user record: what `/users/me` returns on a
1038
+ * project-hosted handle, and the id `identity()` resolves to in grant
1039
+ * filters and mutation guards. Per-project variants go in
1040
+ * `usersByProject`; this is the record for any project without one.
1041
+ */
993
1042
  user: CurrentUser;
1043
+ /**
1044
+ * The account-global user record, served by `/users/me` on a
1045
+ * global-hosted handle (`useProjectHostname: false`, at creation or
1046
+ * via `withConfig`).
1047
+ * The same person carries a different id per project (`p*`) and one
1048
+ * global id (`g*`); registering both lets tests model that split.
1049
+ * Omit for single-id principals (robot tokens resolve to one `p-*` id
1050
+ * on every host).
1051
+ */
1052
+ globalUser?: CurrentUser;
1053
+ /**
1054
+ * Per-project user records, keyed by projectId — the same token
1055
+ * resolves to a different project-scoped id in each project, which is
1056
+ * what these model. A handle's project falls back to `user` when its
1057
+ * projectId has no entry.
1058
+ */
1059
+ usersByProject?: Record<string, CurrentUser>;
994
1060
  /** Grants served at the ACL path and enforced on reads/writes. */
995
1061
  grants: Grant[];
996
1062
  }
package/dist/index.d.ts CHANGED
@@ -107,8 +107,9 @@ export declare interface CreateTestClientOptions {
107
107
  datasets?: Record<string, SanityDocumentLike[]>;
108
108
  /**
109
109
  * Opt-in access-control simulation. When set, the client exposes
110
- * `request({ url })` that serves `/users/me` + the configured ACL
111
- * path, AND every mutation is gated through the supplied `grants`
110
+ * `request({ url })` that serves `/users/me` (host-aware), the
111
+ * configured ACL path, and `/projects/<projectId>/users/<ids>`,
112
+ * AND every mutation is gated through the supplied `grants`
112
113
  * — writes that the grants don't permit throw a `WriteAccessDeniedError`
113
114
  * shaped like Sanity's 403. Lets tests demonstrate the full
114
115
  * "engine soft-gate + lake hard-gate" contract end-to-end without
@@ -165,6 +166,14 @@ export declare interface CreateTestClientOptions {
165
166
  * tests stay green.
166
167
  */
167
168
  perspective?: Perspective;
169
+ /**
170
+ * Which API host the root handle models: the project host (default
171
+ * `true`) or the global host (`false`) — same flag as the real
172
+ * `createClient` config. A global-hosted handle's `/users/me` serves
173
+ * the identity's account-global record. `withConfig({ useProjectHostname })`
174
+ * mints siblings on the other host.
175
+ */
176
+ useProjectHostname?: boolean;
168
177
  }
169
178
 
170
179
  /**
@@ -583,6 +592,19 @@ export declare type PatchOp =
583
592
 
584
593
  export declare type Perspective = "raw" | "published" | "drafts" | string[];
585
594
 
595
+ /**
596
+ * A row served by the project-users directory endpoint
597
+ * (`/projects/<projectId>/users/<id>`), mirroring Sanity's project user
598
+ * retrieval API: the project-scoped `id` alongside its account-global
599
+ * `sanityUserId` — the one sanctioned bridge between the two namespaces.
600
+ */
601
+ export declare interface ProjectUserRecord {
602
+ id: string;
603
+ sanityUserId: string;
604
+ displayName: string;
605
+ email?: string;
606
+ }
607
+
586
608
  /** Strip `drafts.` / `versions.<name>.` prefix to recover the published id. */
587
609
  export declare function publishedIdOf(id: string): string;
588
610
 
@@ -843,14 +865,16 @@ export declare interface TestClient {
843
865
  publishedId: string;
844
866
  }) => Promise<ActionResult>;
845
867
  /**
846
- * Current `(projectId, dataset)` and active perspective. `token` is
847
- * present iff the handle is bound to a registered identity.
868
+ * Current `(projectId, dataset)`, active perspective, and which API host
869
+ * the handle models (`useProjectHostname`). `token` is present iff the
870
+ * handle is bound to a registered identity.
848
871
  */
849
872
  config: () => {
850
873
  projectId: string;
851
874
  dataset: string;
852
875
  perspective: Perspective;
853
876
  token?: string;
877
+ useProjectHostname: boolean;
854
878
  };
855
879
  /**
856
880
  * Return a new client handle pointing at a different `(projectId, dataset)`
@@ -863,6 +887,14 @@ export declare interface TestClient {
863
887
  projectId: string;
864
888
  dataset: string;
865
889
  perspective: Perspective;
890
+ /**
891
+ * Model the global API host (`false`) instead of the project host
892
+ * (default `true`). A global-hosted sibling's `/users/me` serves the
893
+ * identity's account-global record (`globalUser`, falling back to the
894
+ * project record) — mirroring `@sanity/client`, where a non-project
895
+ * config routes requests to `api.sanity.io`.
896
+ */
897
+ useProjectHostname: boolean;
866
898
  /**
867
899
  * Rebind the acting identity (and its grants) on a sibling that shares
868
900
  * the same store — the test analogue of a token-scoped client. Lets two
@@ -873,10 +905,11 @@ export declare interface TestClient {
873
905
  /**
874
906
  * Bind the sibling to a token registered in the family's identity
875
907
  * registry (`createTestClient({ identities })` / `addIdentity`): its
876
- * `/users/me` serves that identity's user, its ACL path serves that
877
- * identity's grants, and reads/writes are gated through them — the
878
- * token determines identity, exactly like production. Unknown tokens
879
- * throw. Mutually exclusive with `accessControl`.
908
+ * `/users/me` serves that identity's record for the sibling's project
909
+ * and host, its ACL path serves that identity's grants, and
910
+ * reads/writes are gated through them — the token determines
911
+ * identity, exactly like production. Unknown tokens throw. Mutually
912
+ * exclusive with `accessControl`.
880
913
  */
881
914
  token: string;
882
915
  }>,
@@ -970,8 +1003,18 @@ export declare interface TestClient {
970
1003
  }
971
1004
 
972
1005
  export declare interface TestClientAccessControl {
973
- /** What `client.request({ url: "/users/me" })` returns. */
1006
+ /** What `client.request({ url: "/users/me" })` returns on a project-hosted handle. */
974
1007
  currentUser: CurrentUser;
1008
+ /**
1009
+ * What `/users/me` returns on a global-hosted handle
1010
+ * (`useProjectHostname: false`, at creation or via `withConfig`).
1011
+ * Sanity serves the
1012
+ * account-global user there (`g*` id, no roles) while the project host
1013
+ * serves the project-scoped record (`p*` id, project roles). Omit to
1014
+ * serve `currentUser` from both — the single-id collapse that is also
1015
+ * exactly how robot tokens behave in production.
1016
+ */
1017
+ globalUser?: CurrentUser;
975
1018
  /** What `client.request({ url: aclPath })` returns. */
976
1019
  grants: Grant[];
977
1020
  /**
@@ -984,13 +1027,36 @@ export declare interface TestClientAccessControl {
984
1027
  /**
985
1028
  * One entry in the token→identity registry (`createTestClient({ identities })`).
986
1029
  * A handle bound to the entry's token behaves exactly like one configured
987
- * with `accessControl: { currentUser: user, grants }` — `/users/me` serves
988
- * `user`, the default ACL path serves `grants`, and reads/writes are gated
989
- * through them.
1030
+ * with `accessControl: { currentUser: projectUserOf(identity, projectId),
1031
+ * globalUser, grants }` — `/users/me` serves the record for the handle's
1032
+ * project and host, the default ACL path serves `grants`, and reads/writes
1033
+ * are gated through them.
990
1034
  */
991
1035
  export declare interface TestClientIdentity {
992
- /** What `/users/me` returns for clients bound to this token. */
1036
+ /**
1037
+ * The project-scoped user record: what `/users/me` returns on a
1038
+ * project-hosted handle, and the id `identity()` resolves to in grant
1039
+ * filters and mutation guards. Per-project variants go in
1040
+ * `usersByProject`; this is the record for any project without one.
1041
+ */
993
1042
  user: CurrentUser;
1043
+ /**
1044
+ * The account-global user record, served by `/users/me` on a
1045
+ * global-hosted handle (`useProjectHostname: false`, at creation or
1046
+ * via `withConfig`).
1047
+ * The same person carries a different id per project (`p*`) and one
1048
+ * global id (`g*`); registering both lets tests model that split.
1049
+ * Omit for single-id principals (robot tokens resolve to one `p-*` id
1050
+ * on every host).
1051
+ */
1052
+ globalUser?: CurrentUser;
1053
+ /**
1054
+ * Per-project user records, keyed by projectId — the same token
1055
+ * resolves to a different project-scoped id in each project, which is
1056
+ * what these model. A handle's project falls back to `user` when its
1057
+ * projectId has no entry.
1058
+ */
1059
+ usersByProject?: Record<string, CurrentUser>;
994
1060
  /** Grants served at the ACL path and enforced on reads/writes. */
995
1061
  grants: Grant[];
996
1062
  }
package/dist/index.js CHANGED
@@ -59,15 +59,62 @@ async function enforceWriteAccess(args) {
59
59
  function defaultAclPath(projectId, dataset) {
60
60
  return `/projects/${projectId}/datasets/${dataset}/acl`;
61
61
  }
62
- function makeRequestImpl(getAccess, projectId, dataset) {
62
+ function projectUserOf(identity, projectId) {
63
+ return identity.usersByProject?.[projectId] ?? identity.user;
64
+ }
65
+ function serveProjectUsers(args) {
66
+ const { requestedIds, pathProjectId, identities } = args, toRecord = (identity) => {
67
+ const projectUser = projectUserOf(identity, pathProjectId);
68
+ return {
69
+ id: projectUser.id,
70
+ sanityUserId: identity.globalUser?.id ?? projectUser.id,
71
+ displayName: projectUser.name ?? projectUser.id,
72
+ ...projectUser.email !== void 0 ? { email: projectUser.email } : {}
73
+ };
74
+ }, byId = /* @__PURE__ */ new Map();
75
+ for (const identity of identities) {
76
+ const resolvedId = projectUserOf(identity, pathProjectId).id, existing = byId.get(resolvedId);
77
+ if (existing !== void 0 && JSON.stringify(toRecord(existing)) !== JSON.stringify(toRecord(identity)))
78
+ throw fakeError(
79
+ `request(): two registered identities resolve to project user "${resolvedId}" in project "${pathProjectId}" with different records. The directory cannot answer unambiguously \u2014 fix the identity registrations so duplicate ids carry identical records.`
80
+ );
81
+ byId.set(resolvedId, identity);
82
+ }
83
+ const ids = requestedIds.split(","), [firstId] = ids;
84
+ if (ids.length === 1 && firstId !== void 0) {
85
+ const identity = byId.get(firstId);
86
+ if (identity === void 0)
87
+ throw Object.assign(
88
+ fakeError(
89
+ `request(): no registered identity resolves to project user "${firstId}" in project "${pathProjectId}". Register it via createTestClient({ identities }) or addIdentity().`
90
+ ),
91
+ { statusCode: 404 }
92
+ );
93
+ return toRecord(identity);
94
+ }
95
+ return ids.flatMap((id) => {
96
+ const identity = byId.get(id);
97
+ return identity === void 0 ? [] : [toRecord(identity)];
98
+ });
99
+ }
100
+ const PROJECT_USERS_PATH = /^\/projects\/([^/]+)\/users\/([^/]+)$/;
101
+ function makeRequestImpl(ctx) {
102
+ const { getAccess, projectId, dataset, useProjectHostname, listIdentities } = ctx;
63
103
  return async (opts) => {
64
104
  const accessControl = getAccess(), aclPath = accessControl.aclPath ?? defaultAclPath(projectId, dataset), path = opts.uri ?? opts.url;
65
105
  if (path === "/users/me")
66
- return accessControl.currentUser;
106
+ return useProjectHostname ? accessControl.currentUser : accessControl.globalUser ?? accessControl.currentUser;
67
107
  if (path === aclPath)
68
108
  return accessControl.grants;
109
+ const projectUsers = path?.match(PROJECT_USERS_PATH), [, pathProjectId, requestedIds] = projectUsers ?? [];
110
+ if (pathProjectId !== void 0 && requestedIds !== void 0)
111
+ return serveProjectUsers({
112
+ requestedIds,
113
+ pathProjectId,
114
+ identities: listIdentities()
115
+ });
69
116
  throw fakeError(
70
- `request(): no handler for path "${path}". With access control configured, the fake only serves "/users/me" and "${aclPath}". For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
117
+ `request(): no handler for path "${path}". With access control configured, the fake serves "/users/me", "${aclPath}", and "/projects/<projectId>/users/<ids>". For any other endpoint, register a stub (client.stub("request", fn)) or intercept the HTTP call with a tool like MSW.`
71
118
  );
72
119
  };
73
120
  }
@@ -778,7 +825,7 @@ const ISSUES_URL = "https://github.com/sanity-labs/client-fake-for-test/issues",
778
825
  },
779
826
  request: {
780
827
  what: "generic HTTP request hook \u2014 only wired up when the handle has an acting identity",
781
- workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me + the ACL path). Or register a stub."
828
+ workaround: "Create the client with `accessControl`, or bind a registered token (`identities` + `token` / `withConfig({ token })`), to enable request() (it serves /users/me, the ACL path, and /projects/<projectId>/users/<ids>). Or register a stub."
782
829
  },
783
830
  dryRun: {
784
831
  what: "not a method \u2014 did you mean the `{ dryRun }` mutation option?",
@@ -961,15 +1008,16 @@ function createTestClient(options = {}) {
961
1008
  const { projectId, dataset } = parseRegistryKey(key);
962
1009
  ensureStore(projectId, dataset).seed(docs);
963
1010
  }
964
- return makeHandle(
1011
+ return makeHandle({
965
1012
  backend,
966
- defaultProjectId,
967
- defaultDataset,
968
- options.accessControl,
969
- options.perspective ?? "raw",
970
- options.mutationGuard,
971
- options.token
972
- );
1013
+ projectId: defaultProjectId,
1014
+ dataset: defaultDataset,
1015
+ accessControl: options.accessControl,
1016
+ perspective: options.perspective ?? "raw",
1017
+ mutationGuard: options.mutationGuard,
1018
+ token: options.token,
1019
+ useProjectHostname: options.useProjectHostname ?? !0
1020
+ });
973
1021
  }
974
1022
  function dedupeIds(results) {
975
1023
  const seen = /* @__PURE__ */ new Set(), out = [];
@@ -1021,13 +1069,26 @@ function toTxnMutations(operations) {
1021
1069
  }
1022
1070
  ];
1023
1071
  }
1024
- function makeHandle(backend, projectId, dataset, accessControl, perspective, mutationGuard, token) {
1025
- const { registry } = backend, activeAccess = () => {
1072
+ function makeHandle(handleArgs) {
1073
+ const {
1074
+ backend,
1075
+ projectId,
1076
+ dataset,
1077
+ accessControl,
1078
+ perspective,
1079
+ mutationGuard,
1080
+ token,
1081
+ useProjectHostname
1082
+ } = handleArgs, { registry } = backend, activeAccess = () => {
1026
1083
  if (token === void 0) return accessControl;
1027
1084
  const identity = backend.identities.get(token);
1028
1085
  if (identity === void 0)
1029
1086
  throw fakeError(`no identity is registered for token "${token}" (was it removed?).`);
1030
- return { currentUser: identity.user, grants: identity.grants };
1087
+ return {
1088
+ currentUser: projectUserOf(identity, projectId),
1089
+ ...identity.globalUser !== void 0 ? { globalUser: identity.globalUser } : {},
1090
+ grants: identity.grants
1091
+ };
1031
1092
  }, requireActiveAccess = () => {
1032
1093
  const access = activeAccess();
1033
1094
  if (access === void 0)
@@ -1348,20 +1409,35 @@ function makeHandle(backend, projectId, dataset, accessControl, perspective, mut
1348
1409
  versionId: `versions.${releaseId}.${publishedId}`,
1349
1410
  publishedId
1350
1411
  }),
1351
- ...token !== void 0 || accessControl !== void 0 ? { request: makeRequestImpl(requireActiveAccess, projectId, dataset) } : {},
1352
- config: () => ({ projectId, dataset, perspective, ...token !== void 0 ? { token } : {} }),
1412
+ ...token !== void 0 || accessControl !== void 0 ? {
1413
+ request: makeRequestImpl({
1414
+ getAccess: requireActiveAccess,
1415
+ projectId,
1416
+ dataset,
1417
+ useProjectHostname,
1418
+ listIdentities: () => [...backend.identities.values()]
1419
+ })
1420
+ } : {},
1421
+ config: () => ({
1422
+ projectId,
1423
+ dataset,
1424
+ perspective,
1425
+ ...token !== void 0 ? { token } : {},
1426
+ useProjectHostname
1427
+ }),
1353
1428
  withConfig: (config) => {
1354
1429
  rejectAmbiguousIdentity("withConfig()", config), config.token !== void 0 && requireIdentity(backend, config.token, "withConfig()");
1355
1430
  const rebinds = config.token !== void 0 || config.accessControl !== void 0;
1356
- return makeHandle(
1431
+ return makeHandle({
1357
1432
  backend,
1358
- config.projectId ?? projectId,
1359
- config.dataset ?? dataset,
1360
- rebinds ? config.accessControl : accessControl,
1361
- config.perspective ?? perspective,
1433
+ projectId: config.projectId ?? projectId,
1434
+ dataset: config.dataset ?? dataset,
1435
+ accessControl: rebinds ? config.accessControl : accessControl,
1436
+ perspective: config.perspective ?? perspective,
1362
1437
  mutationGuard,
1363
- rebinds ? config.token : token
1364
- );
1438
+ token: rebinds ? config.token : token,
1439
+ useProjectHostname: config.useProjectHostname ?? useProjectHostname
1440
+ });
1365
1441
  },
1366
1442
  seed: (documents) => store.seed(documents),
1367
1443
  add: (documents) => store.add(documents),