@shapio/local 0.5.0 → 0.5.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.
package/dist/index.d.ts CHANGED
@@ -36,6 +36,12 @@ type LocalClientOptions = {
36
36
  fallbackUrl?: string;
37
37
  /** Where failures are logged (pino). Default: warnings and errors to stdout. */
38
38
  logger?: Logger;
39
+ /**
40
+ * Drafts mode, for a site's development server: every delivery read asks for drafts instead of published
41
+ * content (as `@shapio/client`'s `drafts`). Needs a delivery token whose role grants Read drafts; never set
42
+ * it on a production build.
43
+ */
44
+ drafts?: boolean;
39
45
  };
40
46
  /** The server runs another Shapio release (503 `VERSION_SKEW`); null when the database predates this one. */
41
47
  type ShapioVersionSkewError$1 = ShapioApiError & {
@@ -71,12 +77,13 @@ export type LocalClient = ShapioDeliveryClient & {
71
77
  * Shapio's delivery API as function calls in this process: the `delivery`, `site` and `snapshots` groups of
72
78
  * `@shapio/client`, with the same paths, permissions, response shapes and `ShapioApiError`s, answered from the
73
79
  * database without an HTTP hop. Reads as `token` (a delivery API token) or, without one, as an anonymous
74
- * caller (the site's `public` role). Model changes made in the admin show on the next call.
80
+ * caller (the site's `public` role). Model changes made in the admin show on the next call. `drafts: true`
81
+ * reads drafts instead (drafts mode), when the token's role grants Read drafts.
75
82
  *
76
83
  * Needs PostgreSQL or MySQL, the Node.js runtime, and the same Shapio release as the server (`SHAPIO_VERSION`);
77
84
  * see `onVersionSkew`. Nothing is read until the first call.
78
85
  */
79
- export declare const createLocalClient: ({ token, site, ...runtimeOptions }: LocalClientOptions) => LocalClient;
86
+ export declare const createLocalClient: ({ token, site, drafts, ...runtimeOptions }: LocalClientOptions) => LocalClient;
80
87
  //#endregion
81
88
  export type { LocalClientOptions };
82
89
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ import { mkdir, rename, rm, rmdir, stat, writeFile } from "node:fs/promises";
15
15
  import { pipeline } from "node:stream/promises";
16
16
  //#endregion
17
17
  //#region ../../apps/api/src/constants/version.ts
18
- const SHAPIO_VERSION$1 = "0.5.0";
18
+ const SHAPIO_VERSION$1 = "0.5.2";
19
19
  //#endregion
20
20
  //#region ../../apps/api/src/helpers/errorResponse.ts
21
21
  const STATUS_CODES = {
@@ -206,6 +206,14 @@ const CONTENT_ACTIONS = [
206
206
  "schemaManage"
207
207
  ];
208
208
  /**
209
+ * What a delivery role may grant (plan drafts-mode §1): `read`, and `readDrafts`, which lets a delivery token
210
+ * read draft heads (`?publicationState=draft`, GraphQL `publicationState: DRAFT`) on top of `read`. A
211
+ * `readDrafts` grant always covers every model (no model, condition or field list), so one check per request
212
+ * covers relation targets too. Admin and app roles never hold it; admin principals read drafts without it.
213
+ */
214
+ const DELIVERY_ACTIONS = ["read", "readDrafts"];
215
+ const READ_DRAFTS_ACTION = "readDrafts";
216
+ /**
209
217
  * Content actions about the schema rather than one site's entries: network roles only for a shared
210
218
  * definition; for a site's own definition, the roles that apply on that site (when the principal acts there).
211
219
  */
@@ -317,7 +325,11 @@ Type.Enum(NETWORK_ACTIONS);
317
325
  Type.Enum(SITE_ACTIONS);
318
326
  Type.Enum([...SITE_ACTIONS, ...SITE_GRANTABLE_ACTION_LIST]);
319
327
  Type.Object({
320
- action: Type.Enum([...CONTENT_ACTIONS, ...GLOBAL_ACTIONS]),
328
+ action: Type.Enum([
329
+ ...CONTENT_ACTIONS,
330
+ READ_DRAFTS_ACTION,
331
+ ...GLOBAL_ACTIONS
332
+ ]),
321
333
  modelId: Type.Union([Type.String({
322
334
  minLength: 1,
323
335
  maxLength: 100
@@ -427,6 +439,8 @@ const RICH_TEXT_MODES = [
427
439
  "both"
428
440
  ];
429
441
  /** `?seo=`: SEO fields as stored, or merged with the site's SEO defaults (plan seo-fields). */
442
+ /** Which heads a delivery read serves (`?publicationState=`, GraphQL's `publicationState`). */
443
+ const PUBLICATION_STATES = ["published", "draft"];
430
444
  const SEO_MODES = ["raw", "resolved"];
431
445
  const ENTRY_LIST_STATUSES = [
432
446
  "draft",
@@ -2201,6 +2215,7 @@ const TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
2201
2215
  "snapshot",
2202
2216
  "richText",
2203
2217
  "seo",
2218
+ "publicationState",
2204
2219
  "q",
2205
2220
  "status",
2206
2221
  "author"
@@ -2380,6 +2395,18 @@ const parseSeo = (tree, context) => {
2380
2395
  if (value === "resolved" && !context.model.fields.some((field) => !field.deprecated && isSeoField(field) && context.isReadable(field))) throw queryInvalid(`"${context.model.apiKey}" has no SEO field to resolve`);
2381
2396
  return { seo: value };
2382
2397
  };
2398
+ const PUBLICATION_STATE_SET = new Set(PUBLICATION_STATES);
2399
+ /**
2400
+ * `?publicationState=published|draft` on delivery reads; whether the caller may read drafts is the service's
2401
+ * check.
2402
+ */
2403
+ const parsePublicationState = (tree, context) => {
2404
+ const value = single(tree.publicationState, "publicationState");
2405
+ if (value === void 0) return {};
2406
+ if (!context.allowPublicationState) throw queryInvalid("publicationState is only available on the delivery API");
2407
+ if (!PUBLICATION_STATE_SET.has(value)) throw queryInvalid(`"publicationState" must be one of ${PUBLICATION_STATES.join(", ")}`);
2408
+ return { publicationState: value };
2409
+ };
2383
2410
  const parseContentQuery = (tree, context) => {
2384
2411
  for (const key of Object.keys(tree)) if (!TOP_LEVEL_KEYS.has(key)) throw queryInvalid(`Unknown query parameter "${key}"`);
2385
2412
  const filters = tree.filters;
@@ -2403,6 +2430,7 @@ const parseContentQuery = (tree, context) => {
2403
2430
  snapshot,
2404
2431
  ...parseRichText(tree, context),
2405
2432
  ...parseSeo(tree, context),
2433
+ ...parsePublicationState(tree, context),
2406
2434
  ...parseAdminFilters(tree, context)
2407
2435
  };
2408
2436
  };
@@ -2443,6 +2471,7 @@ const parseQueryFor = (context, model, policy, rawQuery, options) => parseConten
2443
2471
  allowAdminFilters: options.allowAdminFilters ?? false,
2444
2472
  allowRichText: options.allowRichText ?? false,
2445
2473
  allowSeo: options.allowSeo ?? false,
2474
+ allowPublicationState: options.allowPublicationState ?? false,
2446
2475
  resolveModel: (modelId) => resolveModelById(context.snapshot, modelId)?.definition
2447
2476
  });
2448
2477
  //#endregion
@@ -2648,6 +2677,26 @@ const readOneEntry = async (unloaded, id, options = {}) => {
2648
2677
  });
2649
2678
  };
2650
2679
  //#endregion
2680
+ //#region ../../apps/api/src/services/draftAccess.ts
2681
+ /**
2682
+ * Who may read draft heads through the delivery API (plan drafts-mode §1). Admin users, admin-scope tokens
2683
+ * and Shapio itself always could (GraphQL previews). A delivery token may when its role grants `readDrafts`,
2684
+ * which always covers every model, so this one check per request also covers relation targets. App users and
2685
+ * anonymous callers never may, whatever a role says (the evaluator denies them too). The caller has already
2686
+ * checked `read` on the model.
2687
+ */
2688
+ const isAdminPrincipal = (principal) => principal.kind === "system" || principal.kind === "admin" || principal.kind === "token" && principal.scope === "admin";
2689
+ const draftsForbidden = (message) => new AppError(403, "DRAFTS_FORBIDDEN", message);
2690
+ const assertMayReadDrafts = async (context, modelId, modelKey) => {
2691
+ const { actor } = context;
2692
+ if (isAdminPrincipal(actor)) return;
2693
+ if (actor.kind !== "token") throw draftsForbidden("Drafts need an admin user or a delivery token granted Read drafts");
2694
+ if (!(await context.permissions.evaluate(actor, {
2695
+ action: "readDrafts",
2696
+ modelId
2697
+ })).allowed) throw draftsForbidden(`This token may not read drafts of "${modelKey}"; grant Read drafts on its delivery role`);
2698
+ };
2699
+ //#endregion
2651
2700
  //#region ../../apps/api/src/services/contentDelivery.ts
2652
2701
  const readableModel = async (context, modelKey) => {
2653
2702
  const model = resolveModel(context.snapshot, modelKey);
@@ -2661,10 +2710,19 @@ const readableModel = async (context, modelKey) => {
2661
2710
  policy
2662
2711
  };
2663
2712
  };
2664
- /** The heads a read serves: drafts (GraphQL previews), a pinned snapshot, or the published heads. */
2665
- const sourceOf = (query, options) => {
2666
- if (query.snapshot !== void 0 && options.drafts) throw queryInvalid("snapshot reads published content; it cannot be combined with drafts");
2667
- return options.drafts ? {
2713
+ /** Whether a read asks for drafts: GraphQL's option, or `?publicationState=draft`. */
2714
+ const readsDrafts = (query, options) => options.drafts === true || query.publicationState === "draft";
2715
+ /**
2716
+ * A draft read must be allowed: `read` was checked already; `readDrafts` (or an admin principal) is checked
2717
+ * here.
2718
+ */
2719
+ const authorizeDrafts = async (context, model, modelKey, drafts) => {
2720
+ if (drafts) await assertMayReadDrafts(context, model.definition.id, modelKey);
2721
+ };
2722
+ /** The heads a read serves: drafts, a pinned snapshot, or the published heads. */
2723
+ const sourceOf = (query, drafts) => {
2724
+ if (query.snapshot !== void 0 && drafts) throw queryInvalid("snapshot reads published content; it cannot be combined with drafts");
2725
+ return drafts ? {
2668
2726
  kind: "heads",
2669
2727
  state: "draft"
2670
2728
  } : query.snapshot !== void 0 ? {
@@ -2685,7 +2743,7 @@ const snapshotCheck = (query) => (current) => {
2685
2743
  */
2686
2744
  const recordUsage = async (context, options, model, policy, query) => {
2687
2745
  const principalKey = usagePrincipalKey(context.actor);
2688
- if (!options.usage || options.drafts || principalKey === null) return;
2746
+ if (!options.usage || readsDrafts(query, options) || principalKey === null) return;
2689
2747
  const reads = await restFieldReads({
2690
2748
  snapshot: context.snapshot,
2691
2749
  model,
@@ -2713,8 +2771,24 @@ const ITEM_PARAMETERS = /* @__PURE__ */ new Set([
2713
2771
  "locale",
2714
2772
  "snapshot",
2715
2773
  "richText",
2716
- "seo"
2774
+ "seo",
2775
+ "publicationState"
2717
2776
  ]);
2777
+ const DELIVERY_PARSE = {
2778
+ allowSnapshot: true,
2779
+ allowRichText: true,
2780
+ allowSeo: true,
2781
+ allowPublicationState: true
2782
+ };
2783
+ /** Meta every delivery response carries; draft reads add `publicationState` (published ones are unchanged). */
2784
+ const metaOf = (locale, snapshot, drafts) => drafts ? {
2785
+ locale,
2786
+ snapshot,
2787
+ publicationState: "draft"
2788
+ } : {
2789
+ locale,
2790
+ snapshot
2791
+ };
2718
2792
  /** Reading one entry takes only these query parameters; anything else is refused, not ignored. */
2719
2793
  const assertItemParameters = (rawQuery, allowed) => {
2720
2794
  const unsupported = [...new URLSearchParams(rawQuery).keys()].find((key) => !allowed.has(key.split("[")[0] ?? key));
@@ -2723,16 +2797,15 @@ const assertItemParameters = (rawQuery, allowed) => {
2723
2797
  /** `GET /api/content/:modelKey`: a page of a collection, or a singleton's one entry. */
2724
2798
  const listDelivery = async (context, modelKey, rawQuery, options = {}) => {
2725
2799
  const { model, policy } = await readableModel(context, modelKey);
2726
- const query = parseQueryFor(context, model, policy, rawQuery, {
2727
- allowSnapshot: true,
2728
- allowRichText: true,
2729
- allowSeo: true
2730
- });
2800
+ const query = parseQueryFor(context, model, policy, rawQuery, DELIVERY_PARSE);
2801
+ const drafts = readsDrafts(query, options);
2802
+ const source = sourceOf(query, drafts);
2803
+ await authorizeDrafts(context, model, modelKey, drafts);
2731
2804
  const locale = query.locale ?? context.snapshot.defaultLocale;
2732
2805
  const singleton = model.definition.kind === "singleton";
2733
2806
  const { entries, limit, total, seq } = await readEntryPage({
2734
2807
  context,
2735
- source: sourceOf(query, options),
2808
+ source,
2736
2809
  model,
2737
2810
  policy,
2738
2811
  query,
@@ -2748,34 +2821,32 @@ const listDelivery = async (context, modelKey, rawQuery, options = {}) => {
2748
2821
  if (!entry) throw entryNotFound(modelKey);
2749
2822
  result = {
2750
2823
  data: entry,
2751
- meta: {
2752
- locale,
2753
- snapshot
2754
- }
2824
+ meta: metaOf(locale, snapshot, drafts)
2755
2825
  };
2756
2826
  } else result = {
2757
2827
  data: entries,
2758
2828
  meta: {
2759
- locale,
2760
- snapshot,
2829
+ ...metaOf(locale, snapshot, drafts),
2761
2830
  pagination: paginationMeta(query.page, limit, total)
2762
2831
  }
2763
2832
  };
2764
2833
  await afterRead(options, () => recordUsage(context, options, model, policy, query));
2765
2834
  return result;
2766
2835
  };
2767
- /** `GET /api/content/:modelKey/:id`: one published entry (404 for drafts, unknown or unreadable entries). */
2836
+ /**
2837
+ * `GET /api/content/:modelKey/:id`: one published entry (404 for unpublished, unknown or unreadable entries);
2838
+ * with drafts, the entry's draft.
2839
+ */
2768
2840
  const getDelivery = async (context, modelKey, id, rawQuery, options = {}) => {
2769
2841
  assertItemParameters(rawQuery, ITEM_PARAMETERS);
2770
2842
  const { model, policy } = await readableModel(context, modelKey);
2771
- const query = parseQueryFor(context, model, policy, rawQuery, {
2772
- allowSnapshot: true,
2773
- allowRichText: true,
2774
- allowSeo: true
2775
- });
2843
+ const query = parseQueryFor(context, model, policy, rawQuery, DELIVERY_PARSE);
2844
+ const drafts = readsDrafts(query, options);
2845
+ const source = sourceOf(query, drafts);
2846
+ await authorizeDrafts(context, model, modelKey, drafts);
2776
2847
  const { entry, seq } = await readOneEntry({
2777
2848
  context,
2778
- source: sourceOf(query, options),
2849
+ source,
2779
2850
  model,
2780
2851
  policy,
2781
2852
  query,
@@ -2785,10 +2856,7 @@ const getDelivery = async (context, modelKey, id, rawQuery, options = {}) => {
2785
2856
  await afterRead(options, () => recordUsage(context, options, model, policy, query));
2786
2857
  return {
2787
2858
  data: entry,
2788
- meta: {
2789
- locale: query.locale ?? context.snapshot.defaultLocale,
2790
- snapshot: query.snapshot ?? seq
2791
- }
2859
+ meta: metaOf(query.locale ?? context.snapshot.defaultLocale, query.snapshot ?? seq, drafts)
2792
2860
  };
2793
2861
  };
2794
2862
  //#endregion
@@ -3809,7 +3877,11 @@ const groupBindings = (rows) => {
3809
3877
  }
3810
3878
  return bindings;
3811
3879
  };
3812
- const KNOWN_ACTIONS = /* @__PURE__ */ new Set([...CONTENT_ACTIONS, ...GLOBAL_ACTIONS]);
3880
+ const KNOWN_ACTIONS = /* @__PURE__ */ new Set([
3881
+ ...CONTENT_ACTIONS,
3882
+ ...DELIVERY_ACTIONS,
3883
+ ...GLOBAL_ACTIONS
3884
+ ]);
3813
3885
  const KNOWN_CONDITIONS = /* @__PURE__ */ new Set(["ownedByPrincipal"]);
3814
3886
  /**
3815
3887
  * A stored grant as this build understands it. Rows written by a newer Shapio may name an action or a row
@@ -3955,7 +4027,9 @@ const allowsGlobalAction = (grants, action) => grants.some((grant) => grant.acti
3955
4027
  //#endregion
3956
4028
  //#region ../../apps/api/src/permissions/evaluator.ts
3957
4029
  const NETWORK_ACTION_SET = new Set(NETWORK_ACTIONS);
3958
- const READ_ONLY = /* @__PURE__ */ new Set(["read"]);
4030
+ /** Delivery tokens read, and read drafts where their role grants `readDrafts`. */
4031
+ const DELIVERY_TOKEN_ACTIONS = new Set(DELIVERY_ACTIONS);
4032
+ /** App users and anonymous callers: never `readDrafts`, whatever a role says. */
3959
4033
  const APP_ACTIONS = new Set(APP_CONTENT_ACTIONS);
3960
4034
  /**
3961
4035
  * Which roles a principal holds and how its masks are computed. App users hold the custom app roles assigned
@@ -3982,7 +4056,7 @@ const rolesOf = async (principal, grants, executor, versions) => {
3982
4056
  roleIds: [principal.roleId],
3983
4057
  networkRoleIds: [],
3984
4058
  audience: "delivery",
3985
- actions: READ_ONLY
4059
+ actions: DELIVERY_TOKEN_ACTIONS
3986
4060
  };
3987
4061
  case "appUser": return {
3988
4062
  roleIds: [...await grants.getSiteAppRoleIds(principal.siteId, "authenticated", executor, versions), ...principal.roleIds],
@@ -4457,12 +4531,13 @@ const DeliveryRuntimeError = DeliveryRuntimeError$1;
4457
4531
  * Shapio's delivery API as function calls in this process: the `delivery`, `site` and `snapshots` groups of
4458
4532
  * `@shapio/client`, with the same paths, permissions, response shapes and `ShapioApiError`s, answered from the
4459
4533
  * database without an HTTP hop. Reads as `token` (a delivery API token) or, without one, as an anonymous
4460
- * caller (the site's `public` role). Model changes made in the admin show on the next call.
4534
+ * caller (the site's `public` role). Model changes made in the admin show on the next call. `drafts: true`
4535
+ * reads drafts instead (drafts mode), when the token's role grants Read drafts.
4461
4536
  *
4462
4537
  * Needs PostgreSQL or MySQL, the Node.js runtime, and the same Shapio release as the server (`SHAPIO_VERSION`);
4463
4538
  * see `onVersionSkew`. Nothing is read until the first call.
4464
4539
  */
4465
- const createLocalClient = ({ token, site, ...runtimeOptions }) => {
4540
+ const createLocalClient = ({ token, site, drafts, ...runtimeOptions }) => {
4466
4541
  assertNodeRuntime();
4467
4542
  const runtime = createDeliveryRuntime(runtimeOptions);
4468
4543
  const release = runtime.retain();
@@ -4471,7 +4546,10 @@ const createLocalClient = ({ token, site, ...runtimeOptions }) => {
4471
4546
  ...site !== void 0 ? { site } : {}
4472
4547
  });
4473
4548
  return {
4474
- ...createDeliveryClient(request, site !== void 0 ? { site } : {}),
4549
+ ...createDeliveryClient(request, {
4550
+ ...site !== void 0 ? { site } : {},
4551
+ ...drafts !== void 0 ? { drafts } : {}
4552
+ }),
4475
4553
  close: release
4476
4554
  };
4477
4555
  };