@shapio/local 0.5.0 → 0.5.1
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 +9 -2
- package/dist/index.js +115 -37
- package/dist/index.js.map +1 -1
- package/package.json +4 -4
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.
|
|
18
|
+
const SHAPIO_VERSION$1 = "0.5.1";
|
|
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([
|
|
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
|
-
/**
|
|
2665
|
-
const
|
|
2666
|
-
|
|
2667
|
-
|
|
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
|
|
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
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
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
|
|
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([
|
|
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
|
-
|
|
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:
|
|
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,
|
|
4549
|
+
...createDeliveryClient(request, {
|
|
4550
|
+
...site !== void 0 ? { site } : {},
|
|
4551
|
+
...drafts !== void 0 ? { drafts } : {}
|
|
4552
|
+
}),
|
|
4475
4553
|
close: release
|
|
4476
4554
|
};
|
|
4477
4555
|
};
|