@shapio/local 0.5.6 → 0.5.7
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/{advisoryLocks-COlJQc1r.js → advisoryLocks-ajVNBhfa.js} +15 -1
- package/dist/{advisoryLocks-COlJQc1r.js.map → advisoryLocks-ajVNBhfa.js.map} +1 -1
- package/dist/{errors-SK0PwPKC.js → errors-nQvt9bs7.js} +8 -2
- package/dist/{errors-SK0PwPKC.js.map → errors-nQvt9bs7.js.map} +1 -1
- package/dist/index.js +525 -157
- package/dist/index.js.map +1 -1
- package/dist/{migrator-CIKnDfZy.js → migrator-CO5sXosL.js} +2 -2
- package/dist/{migrator-CIKnDfZy.js.map → migrator-CO5sXosL.js.map} +1 -1
- package/dist/{s3Adapter-BqCKtI3Y.js → s3Adapter-BC5QP2rS.js} +2 -2
- package/dist/{s3Adapter-BqCKtI3Y.js.map → s3Adapter-BC5QP2rS.js.map} +1 -1
- package/package.json +4 -4
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { c as assertNotNested, d as toDatetimeText, f as AppError, i as createMysqlDb, l as withConnectionScope, n as createSqliteDb, o as DEFAULT_POOL_ACQUIRE_TIMEOUT_MS, s as pgAcquireError, t as lockKeyFromId, u as toStoredTimestamp } from "./advisoryLocks-
|
|
2
|
-
import { i as isValidStorageKey, n as ObjectNotFoundError, r as encodeKeyPath, t as InvalidStorageKeyError } from "./errors-
|
|
1
|
+
import { c as assertNotNested, d as toDatetimeText, f as AppError, i as createMysqlDb, l as withConnectionScope, n as createSqliteDb, o as DEFAULT_POOL_ACQUIRE_TIMEOUT_MS, s as pgAcquireError, t as lockKeyFromId, u as toStoredTimestamp } from "./advisoryLocks-ajVNBhfa.js";
|
|
2
|
+
import { a as visibilityOfKey, i as isValidStorageKey, n as ObjectNotFoundError, o as withVisibility, r as encodeKeyPath, t as InvalidStorageKeyError } from "./errors-nQvt9bs7.js";
|
|
3
3
|
import { t as assertNodeRuntime } from "./nodeRuntime-DzuiHUhY.js";
|
|
4
4
|
import { ShapioApiError, createClient, createDeliveryClient } from "@shapio/client";
|
|
5
5
|
import { Value } from "typebox/value";
|
|
@@ -9,13 +9,13 @@ import { Kysely, PostgresDialect, sql } from "kysely";
|
|
|
9
9
|
import { createHash, createHmac, randomBytes } from "node:crypto";
|
|
10
10
|
import pg from "pg";
|
|
11
11
|
import { dirname, relative, resolve, sep } from "node:path";
|
|
12
|
-
import { pino } from "pino";
|
|
13
12
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
13
|
+
import { pino } from "pino";
|
|
14
14
|
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.7";
|
|
19
19
|
//#endregion
|
|
20
20
|
//#region ../../apps/api/src/helpers/errorResponse.ts
|
|
21
21
|
const STATUS_CODES = {
|
|
@@ -255,13 +255,16 @@ const SITE_ACTIONS = [
|
|
|
255
255
|
const GLOBAL_ACTIONS = [...NETWORK_ACTIONS, ...SITE_ACTIONS];
|
|
256
256
|
const EMPTY_MASK$1 = {
|
|
257
257
|
mode: "only",
|
|
258
|
-
fieldIds: []
|
|
258
|
+
fieldIds: [],
|
|
259
|
+
componentFields: "public"
|
|
259
260
|
};
|
|
260
261
|
const DENIED_POLICY = Object.freeze({
|
|
261
262
|
allowed: false,
|
|
262
263
|
rowFilter: null,
|
|
263
264
|
readMask: EMPTY_MASK$1,
|
|
264
|
-
|
|
265
|
+
rowReadMasks: null,
|
|
266
|
+
writeMask: EMPTY_MASK$1,
|
|
267
|
+
rowWriteMasks: null
|
|
265
268
|
});
|
|
266
269
|
//#endregion
|
|
267
270
|
//#region ../../apps/api/src/routes/schemas/adminIdentity.ts
|
|
@@ -313,7 +316,13 @@ const AdminUserSchema = Type.Object({
|
|
|
313
316
|
roleIds: Type.Array(UuidSchema),
|
|
314
317
|
lastLoginAt: NullableDateTimeSchema,
|
|
315
318
|
createdAt: DateTimeSchema,
|
|
316
|
-
updatedAt: DateTimeSchema
|
|
319
|
+
updatedAt: DateTimeSchema,
|
|
320
|
+
/** Usable API tokens this admin created: refused while the account is disabled, revoked when it is deleted. */
|
|
321
|
+
apiTokens: Type.Array(Type.Object({
|
|
322
|
+
id: UuidSchema,
|
|
323
|
+
name: Type.String(),
|
|
324
|
+
siteId: Type.Union([UuidSchema, Type.Null()])
|
|
325
|
+
}, { additionalProperties: false }))
|
|
317
326
|
});
|
|
318
327
|
Type.Object({
|
|
319
328
|
user: AdminUserSchema,
|
|
@@ -357,7 +366,13 @@ const ERRORS = {
|
|
|
357
366
|
401: ErrorResponseSchema
|
|
358
367
|
};
|
|
359
368
|
const SnapshotChangesQuerySchema = Type.Object({
|
|
360
|
-
from
|
|
369
|
+
/** Exactly one of `from` and `recent`. */
|
|
370
|
+
from: Type.Optional(SeqSchema),
|
|
371
|
+
/** Start where the diff covers the last N entries the caller may read that went live or were updated. */
|
|
372
|
+
recent: Type.Optional(Type.Integer({
|
|
373
|
+
minimum: 1,
|
|
374
|
+
maximum: 100
|
|
375
|
+
})),
|
|
361
376
|
/** Defaults to the current snapshot. */
|
|
362
377
|
to: Type.Optional(SeqSchema),
|
|
363
378
|
/** `nextCursor` of the previous page. */
|
|
@@ -556,6 +571,117 @@ const canonicalBigInteger = (value) => {
|
|
|
556
571
|
*/
|
|
557
572
|
const COMPONENT_KEY = "__component";
|
|
558
573
|
//#endregion
|
|
574
|
+
//#region ../../apps/api/src/permissions/policy.ts
|
|
575
|
+
const EMPTY_MASK = {
|
|
576
|
+
mode: "only",
|
|
577
|
+
fieldIds: [],
|
|
578
|
+
componentFields: "public"
|
|
579
|
+
};
|
|
580
|
+
const ALLOW_ALL_POLICY = Object.freeze({
|
|
581
|
+
allowed: true,
|
|
582
|
+
rowFilter: null,
|
|
583
|
+
readMask: { mode: "all" },
|
|
584
|
+
rowReadMasks: null,
|
|
585
|
+
writeMask: { mode: "all" },
|
|
586
|
+
rowWriteMasks: null
|
|
587
|
+
});
|
|
588
|
+
const WRITE_ACTIONS = /* @__PURE__ */ new Set(["create", "update"]);
|
|
589
|
+
const grantsFor = (grants, action, modelId) => grants.filter((grant) => grant.action === action && (grant.modelId === null || grant.modelId === modelId));
|
|
590
|
+
/** Rows: the union of what each grant allows. Any unconditional grant means no restriction. */
|
|
591
|
+
const combineRowFilters = (grants) => {
|
|
592
|
+
if (grants.some((grant) => grant.condition === null)) return null;
|
|
593
|
+
const filters = [...new Set(grants.map((grant) => grant.condition))].filter((kind) => kind !== null).map((kind) => ({ kind }));
|
|
594
|
+
if (filters.length === 1 && filters[0]) return filters[0];
|
|
595
|
+
return {
|
|
596
|
+
kind: "or",
|
|
597
|
+
filters
|
|
598
|
+
};
|
|
599
|
+
};
|
|
600
|
+
/**
|
|
601
|
+
* Fields: the union of what each grant allows. For delivery audiences a wildcard grant expands to the
|
|
602
|
+
* model's public fields, so `public: false` fields stay hidden unless a grant names them. Delivery masks
|
|
603
|
+
* are always explicit lists, so a field added after evaluation is never exposed by accident. Grants name model
|
|
604
|
+
* fields only, so inside components delivery audiences see the `public` fields alone, whatever they hold.
|
|
605
|
+
*/
|
|
606
|
+
const combineFieldMasks = (grants, audience, fields) => {
|
|
607
|
+
if (grants.length === 0) return EMPTY_MASK;
|
|
608
|
+
const hasWildcard = grants.some((grant) => grant.fieldIds === null);
|
|
609
|
+
if (audience === "admin" && hasWildcard) return { mode: "all" };
|
|
610
|
+
const named = new Set(grants.flatMap((grant) => grant.fieldIds ?? []));
|
|
611
|
+
if (audience === "delivery") return {
|
|
612
|
+
mode: "only",
|
|
613
|
+
fieldIds: (fields ?? []).filter((field) => named.has(field.id) || hasWildcard && field.public).map((field) => field.id),
|
|
614
|
+
componentFields: "public"
|
|
615
|
+
};
|
|
616
|
+
return {
|
|
617
|
+
mode: "only",
|
|
618
|
+
fieldIds: [...named].sort(),
|
|
619
|
+
componentFields: "all"
|
|
620
|
+
};
|
|
621
|
+
};
|
|
622
|
+
/** Fields either mask allows. */
|
|
623
|
+
const unionMasks = (left, right) => left.mode === "all" || right.mode === "all" ? { mode: "all" } : {
|
|
624
|
+
mode: "only",
|
|
625
|
+
fieldIds: [.../* @__PURE__ */ new Set([...left.fieldIds, ...right.fieldIds])].sort(),
|
|
626
|
+
componentFields: left.componentFields === "all" || right.componentFields === "all" ? "all" : "public"
|
|
627
|
+
};
|
|
628
|
+
/** Fields both masks allow. */
|
|
629
|
+
const intersectMasks = (left, right) => {
|
|
630
|
+
if (left.mode === "all") return right;
|
|
631
|
+
if (right.mode === "all") return left;
|
|
632
|
+
const kept = new Set(right.fieldIds);
|
|
633
|
+
return {
|
|
634
|
+
mode: "only",
|
|
635
|
+
fieldIds: left.fieldIds.filter((id) => kept.has(id)),
|
|
636
|
+
componentFields: left.componentFields === "all" && right.componentFields === "all" ? "all" : "public"
|
|
637
|
+
};
|
|
638
|
+
};
|
|
639
|
+
/** Whether `outer` allows every field `inner` allows. */
|
|
640
|
+
const covers = (outer, inner) => outer.mode === "all" || inner.mode === "only" && inner.fieldIds.every((id) => outer.fieldIds.includes(id));
|
|
641
|
+
/**
|
|
642
|
+
* Field masks per row, for reads (issue #75) and writes (issue #80). Row access and field access are not
|
|
643
|
+
* independent across grants: a field allowed only by a grant with a row condition is allowed only on rows
|
|
644
|
+
* matching that condition. The mask for every row is that of the unconditioned grants; without any, the fields
|
|
645
|
+
* every condition allows (each row the principal acts on matches at least one). null when every row the
|
|
646
|
+
* principal acts on gets the same fields.
|
|
647
|
+
*/
|
|
648
|
+
const combineRowFieldMasks = (grants, audience, fields) => {
|
|
649
|
+
const kinds = [...new Set(grants.map((grant) => grant.condition))].filter((kind) => kind !== null);
|
|
650
|
+
if (kinds.length === 0) return null;
|
|
651
|
+
const byCondition = kinds.map((kind) => ({
|
|
652
|
+
condition: { kind },
|
|
653
|
+
mask: combineFieldMasks(grants.filter((grant) => grant.condition === kind), audience, fields)
|
|
654
|
+
}));
|
|
655
|
+
const unconditioned = grants.filter((grant) => grant.condition === null);
|
|
656
|
+
const everyRow = unconditioned.length > 0 ? combineFieldMasks(unconditioned, audience, fields) : byCondition.map((entry) => entry.mask).reduce(intersectMasks);
|
|
657
|
+
const when = byCondition.filter((entry) => !covers(everyRow, entry.mask));
|
|
658
|
+
return when.length === 0 ? null : {
|
|
659
|
+
everyRow,
|
|
660
|
+
when
|
|
661
|
+
};
|
|
662
|
+
};
|
|
663
|
+
/**
|
|
664
|
+
* The policy for one (action, model) request from a principal's grants. Pure: no I/O, so it is unit- and
|
|
665
|
+
* property-tested directly. Deny by default: no matching grant, no access.
|
|
666
|
+
*/
|
|
667
|
+
const buildPolicy = (grants, request, audience, fields) => {
|
|
668
|
+
if (audience === "delivery" && fields === void 0) return DENIED_POLICY;
|
|
669
|
+
const matching = grantsFor(grants, request.action, request.modelId);
|
|
670
|
+
if (matching.length === 0) return DENIED_POLICY;
|
|
671
|
+
const readGrants = request.action === "read" ? matching : grantsFor(grants, "read", request.modelId);
|
|
672
|
+
const writes = WRITE_ACTIONS.has(request.action);
|
|
673
|
+
return {
|
|
674
|
+
allowed: true,
|
|
675
|
+
rowFilter: combineRowFilters(matching),
|
|
676
|
+
readMask: combineFieldMasks(readGrants, audience, fields),
|
|
677
|
+
rowReadMasks: combineRowFieldMasks(readGrants, audience, fields),
|
|
678
|
+
writeMask: writes ? combineFieldMasks(matching, audience, fields) : EMPTY_MASK,
|
|
679
|
+
rowWriteMasks: writes ? combineRowFieldMasks(matching, audience, fields) : null
|
|
680
|
+
};
|
|
681
|
+
};
|
|
682
|
+
/** Global actions are granted only by a grant on that action with no model. */
|
|
683
|
+
const allowsGlobalAction = (grants, action) => grants.some((grant) => grant.action === action && grant.modelId === null);
|
|
684
|
+
//#endregion
|
|
559
685
|
//#region ../../apps/api/src/db/dialect.ts
|
|
560
686
|
const SQLITE_PREFIX = "sqlite:";
|
|
561
687
|
const MEMORY = ":memory:";
|
|
@@ -689,6 +815,37 @@ const COLUMN_TYPES = {
|
|
|
689
815
|
};
|
|
690
816
|
const addIndexColumn = ({ name, table, expression, cast, numericString }) => sql`alter table ${sql.table(table)} add column ${sql.id(fieldIndexColumnName(name))} ${numericString ? DECIMAL : COLUMN_TYPES[cast]}
|
|
691
817
|
generated always as (${expression}) virtual invisible, algorithm=inplace, lock=none`;
|
|
818
|
+
/** Published revisions of a model as of a publication sequence (`snapshotCte`, relation targets). */
|
|
819
|
+
const snapshotRows$2 = ({ siteId, modelId, seq }) => sql`
|
|
820
|
+
select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published' as state, r.data, 0 as version,
|
|
821
|
+
r.id as revision_id, pl.published_at as updated_at, null as autosaved_at,
|
|
822
|
+
en.created_at as entry_created_at
|
|
823
|
+
from publication_log pl
|
|
824
|
+
join content_revisions r on r.id = pl.revision_id
|
|
825
|
+
join entries en on en.id = pl.entry_id
|
|
826
|
+
where pl.site_id = ${siteId} and pl.model_id = ${modelId}
|
|
827
|
+
and pl.from_seq <= ${seq}
|
|
828
|
+
and (pl.to_seq is null or pl.to_seq > ${seq})`;
|
|
829
|
+
/**
|
|
830
|
+
* One row per field: a list stays one JSON array that the nested subquery tests with `member of` (a correlated
|
|
831
|
+
* `json_table` there loses its rows once the target query has the locale chain's `not exists`, on MySQL 8.4);
|
|
832
|
+
* a single ID is converted to the ASCII, case-insensitive form of UUID columns, so it matches them and their
|
|
833
|
+
* indexes.
|
|
834
|
+
*/
|
|
835
|
+
const referenceIds$3 = (fieldId, list) => {
|
|
836
|
+
if (list) {
|
|
837
|
+
const json = fieldJson$1(fieldId);
|
|
838
|
+
return {
|
|
839
|
+
from: sql`(select case when json_type(${json}) = 'ARRAY' collate utf8mb4_bin then ${json} end as ids) v`,
|
|
840
|
+
matches: (column) => sql`${column} member of (v.ids)`
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
const text = sql`json_value(${DATA$2}, ${path(fieldId)} returning char(36) null on error)`;
|
|
844
|
+
return {
|
|
845
|
+
from: sql`(select convert(${text} using ascii) collate ascii_general_ci as id) v`,
|
|
846
|
+
matches: (column) => sql`${column} = v.id`
|
|
847
|
+
};
|
|
848
|
+
};
|
|
692
849
|
const mysqlContentDialect = {
|
|
693
850
|
name: "mysql",
|
|
694
851
|
uuid: (value) => sql`${value}`,
|
|
@@ -728,17 +885,10 @@ const mysqlContentDialect = {
|
|
|
728
885
|
const order = direction === "desc" ? sql`desc` : sql`asc`;
|
|
729
886
|
return nullable ? sql`(${expression} is null) ${order}, ${expression} ${order}` : sql`${expression} ${order}`;
|
|
730
887
|
},
|
|
731
|
-
snapshotCte: (
|
|
732
|
-
select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published' as state, r.data, 0 as version,
|
|
733
|
-
r.id as revision_id, pl.published_at as updated_at, null as autosaved_at,
|
|
734
|
-
en.created_at as entry_created_at
|
|
735
|
-
from publication_log pl
|
|
736
|
-
join content_revisions r on r.id = pl.revision_id
|
|
737
|
-
join entries en on en.id = pl.entry_id
|
|
738
|
-
where pl.site_id = ${siteId} and pl.model_id = ${modelId}
|
|
739
|
-
and pl.from_seq <= ${seq}
|
|
740
|
-
and (pl.to_seq is null or pl.to_seq > ${seq})
|
|
888
|
+
snapshotCte: (source) => sql`with ${sql.id(source.name)} as (${snapshotRows$2(source)}
|
|
741
889
|
) `,
|
|
890
|
+
snapshotRows: snapshotRows$2,
|
|
891
|
+
referenceIds: referenceIds$3,
|
|
742
892
|
fieldIndexColumn: {
|
|
743
893
|
add: addIndexColumn,
|
|
744
894
|
drop: (name) => sql`alter table ${sql.table(CONTENT_HEADS_TABLE)} drop column ${sql.id(fieldIndexColumnName(name))}, algorithm=inplace, lock=none`
|
|
@@ -799,6 +949,30 @@ const castParameter$2 = (value, cast) => {
|
|
|
799
949
|
* so nothing from the request is ever spliced into the SQL text.
|
|
800
950
|
*/
|
|
801
951
|
const containment = (document) => sql`(${DATA$1} @> ${JSON.stringify(document)}::jsonb)`;
|
|
952
|
+
/** Published revisions of a model as of a publication sequence (`snapshotCte`, relation targets). */
|
|
953
|
+
const snapshotRows$1 = ({ siteId, modelId, seq }) => sql`
|
|
954
|
+
select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published'::text as state, r.data, 0 as version,
|
|
955
|
+
r.id as revision_id, pl.published_at as updated_at, null::timestamptz as autosaved_at,
|
|
956
|
+
en.created_at as entry_created_at
|
|
957
|
+
from publication_log pl
|
|
958
|
+
join content_revisions r on r.id = pl.revision_id
|
|
959
|
+
join entries en on en.id = pl.entry_id
|
|
960
|
+
where pl.site_id = ${siteId}::uuid and pl.model_id = ${modelId}
|
|
961
|
+
and pl.from_seq <= ${seq}::bigint
|
|
962
|
+
and (pl.to_seq is null or pl.to_seq > ${seq}::bigint)`;
|
|
963
|
+
/** A UUID-shaped text cast to uuid; anything else is no ID (a cast would fail the whole statement). */
|
|
964
|
+
const asUuid = (text) => sql`(case when ${text} ~ '^[0-9A-Fa-f-]{36}$' then (${text})::uuid end)`;
|
|
965
|
+
/** One row per stored ID: `jsonb_array_elements_text` for a list, a one-row select for a single ID. */
|
|
966
|
+
const referenceIds$2 = (fieldId, list) => {
|
|
967
|
+
const json = sql`(${DATA$1} -> ${sql.lit(fieldId)})`;
|
|
968
|
+
return list ? {
|
|
969
|
+
from: sql`jsonb_array_elements_text(case when jsonb_typeof(${json}) = 'array' then ${json} end) as v(id)`,
|
|
970
|
+
matches: (column) => sql`${column} = ${asUuid(sql`v.id`)}`
|
|
971
|
+
} : {
|
|
972
|
+
from: sql`(select ${asUuid(sql`(case when jsonb_typeof(${json}) = 'string' then ${fieldText$1(fieldId)} end)`)} as id) v`,
|
|
973
|
+
matches: (column) => sql`${column} = v.id`
|
|
974
|
+
};
|
|
975
|
+
};
|
|
802
976
|
const postgresContentDialect = {
|
|
803
977
|
name: "postgres",
|
|
804
978
|
uuid: (value) => sql`${value}::uuid`,
|
|
@@ -825,17 +999,10 @@ const postgresContentDialect = {
|
|
|
825
999
|
containsInsensitive: (fieldId, text) => sql`(${fieldText$1(fieldId)} ilike ${likePattern("$containsi", text)})`,
|
|
826
1000
|
localeRank: (chain, column) => sql`array_position(${[...chain]}::text[], ${column})`,
|
|
827
1001
|
sortDirection: (direction) => direction === "desc" ? sql`desc` : sql`asc`,
|
|
828
|
-
snapshotCte: (
|
|
829
|
-
select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published'::text as state, r.data, 0 as version,
|
|
830
|
-
r.id as revision_id, pl.published_at as updated_at, null::timestamptz as autosaved_at,
|
|
831
|
-
en.created_at as entry_created_at
|
|
832
|
-
from publication_log pl
|
|
833
|
-
join content_revisions r on r.id = pl.revision_id
|
|
834
|
-
join entries en on en.id = pl.entry_id
|
|
835
|
-
where pl.site_id = ${siteId}::uuid and pl.model_id = ${modelId}
|
|
836
|
-
and pl.from_seq <= ${seq}::bigint
|
|
837
|
-
and (pl.to_seq is null or pl.to_seq > ${seq}::bigint)
|
|
1002
|
+
snapshotCte: (source) => sql`with ${sql.id(source.name)} as (${snapshotRows$1(source)}
|
|
838
1003
|
) `,
|
|
1004
|
+
snapshotRows: snapshotRows$1,
|
|
1005
|
+
referenceIds: referenceIds$2,
|
|
839
1006
|
createFieldIndex: ({ name, table, leading, expression, modelIdColumn, modelId }) => sql`create index concurrently if not exists ${sql.id(name)} on ${sql.table(table)} (${leading}, ${expression}) where ${sql.ref(modelIdColumn)} = ${modelId}`,
|
|
840
1007
|
createFieldStatistics: (name, expression, table) => sql`create statistics if not exists ${sql.id(name)} on ${expression} from ${sql.table(table)}`,
|
|
841
1008
|
dropFieldIndex: (name) => sql`drop index concurrently if exists ${sql.id(name)}`,
|
|
@@ -876,6 +1043,28 @@ const castParameter$1 = (value, cast) => {
|
|
|
876
1043
|
default: return sql`${value}`;
|
|
877
1044
|
}
|
|
878
1045
|
};
|
|
1046
|
+
/** Published revisions of a model as of a publication sequence (`snapshotCte`, relation targets). */
|
|
1047
|
+
const snapshotRows = ({ siteId, modelId, seq }) => sql`
|
|
1048
|
+
select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published' as state, r.data, 0 as version,
|
|
1049
|
+
r.id as revision_id, pl.published_at as updated_at, null as autosaved_at,
|
|
1050
|
+
en.created_at as entry_created_at
|
|
1051
|
+
from publication_log pl
|
|
1052
|
+
join content_revisions r on r.id = pl.revision_id
|
|
1053
|
+
join entries en on en.id = pl.entry_id
|
|
1054
|
+
where pl.site_id = ${siteId} and pl.model_id = ${modelId}
|
|
1055
|
+
and pl.from_seq <= ${seq}
|
|
1056
|
+
and (pl.to_seq is null or pl.to_seq > ${seq})`;
|
|
1057
|
+
/** One row per stored ID: `json_each` for a list, a one-row select for a single ID (UUIDs are text). */
|
|
1058
|
+
const referenceIds$1 = (fieldId, list) => {
|
|
1059
|
+
const json = fieldJson(fieldId);
|
|
1060
|
+
return list ? {
|
|
1061
|
+
from: sql`json_each(case when json_type(${json}) = 'array' then ${json} end) v`,
|
|
1062
|
+
matches: (column) => sql`${column} = v.value`
|
|
1063
|
+
} : {
|
|
1064
|
+
from: sql`(select case when json_type(${json}) = 'text' then ${fieldText(fieldId)} end as id) v`,
|
|
1065
|
+
matches: (column) => sql`${column} = v.id`
|
|
1066
|
+
};
|
|
1067
|
+
};
|
|
879
1068
|
//#endregion
|
|
880
1069
|
//#region ../../apps/api/src/content/compiler/dialect/index.ts
|
|
881
1070
|
const DIALECTS = {
|
|
@@ -918,17 +1107,10 @@ const DIALECTS = {
|
|
|
918
1107
|
if (!nullable) return direction === "desc" ? sql`desc` : sql`asc`;
|
|
919
1108
|
return direction === "desc" ? sql`desc nulls first` : sql`asc nulls last`;
|
|
920
1109
|
},
|
|
921
|
-
snapshotCte: (
|
|
922
|
-
select pl.entry_id, pl.site_id, pl.model_id, pl.locale, 'published' as state, r.data, 0 as version,
|
|
923
|
-
r.id as revision_id, pl.published_at as updated_at, null as autosaved_at,
|
|
924
|
-
en.created_at as entry_created_at
|
|
925
|
-
from publication_log pl
|
|
926
|
-
join content_revisions r on r.id = pl.revision_id
|
|
927
|
-
join entries en on en.id = pl.entry_id
|
|
928
|
-
where pl.site_id = ${siteId} and pl.model_id = ${modelId}
|
|
929
|
-
and pl.from_seq <= ${seq}
|
|
930
|
-
and (pl.to_seq is null or pl.to_seq > ${seq})
|
|
1110
|
+
snapshotCte: (source) => sql`with ${sql.id(source.name)} as (${snapshotRows(source)}
|
|
931
1111
|
) `,
|
|
1112
|
+
snapshotRows,
|
|
1113
|
+
referenceIds: referenceIds$1,
|
|
932
1114
|
createFieldIndex: ({ name, table, leading, expression, modelIdColumn, modelId }) => sql`create index if not exists ${sql.id(name)} on ${sql.table(table)} (${leading}, ${expression}) where ${sql.ref(modelIdColumn)} = ${modelId}`,
|
|
933
1115
|
createFieldStatistics: () => null,
|
|
934
1116
|
dropFieldIndex: (name) => sql`drop index if exists ${sql.id(name)}`,
|
|
@@ -972,11 +1154,36 @@ const compileRowFilter = (filter, principal, dialect = contentDialect()) => {
|
|
|
972
1154
|
}
|
|
973
1155
|
}
|
|
974
1156
|
};
|
|
1157
|
+
/** The same row filter evaluated on one loaded entry (writes check it before touching the entry). */
|
|
1158
|
+
const matchesRowFilter = (filter, principal, entry) => {
|
|
1159
|
+
if (!filter) return true;
|
|
1160
|
+
switch (filter.kind) {
|
|
1161
|
+
case "ownedByPrincipal": return principal.kind === "admin" && entry.created_by_admin_id === principal.adminUserId || principal.kind === "appUser" && entry.owner_app_user_id === principal.appUserId;
|
|
1162
|
+
case "and": return filter.filters.every((child) => matchesRowFilter(child, principal, entry));
|
|
1163
|
+
case "or": return filter.filters.some((child) => matchesRowFilter(child, principal, entry));
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
975
1166
|
/**
|
|
976
1167
|
* Whether a mask lets the caller see a field. A wildcard mask covers every live field; deprecated fields
|
|
977
1168
|
* stay hidden unless a mask names them explicitly.
|
|
978
1169
|
*/
|
|
979
1170
|
const maskAllows = (mask, field) => mask.mode === "all" ? !field.deprecated : mask.fieldIds.includes(field.id);
|
|
1171
|
+
/** Whether a mask hides a component's `public: false` fields (delivery audiences, at any depth). */
|
|
1172
|
+
const hidesPrivateComponentFields = (mask) => mask.mode === "only" && mask.componentFields === "public";
|
|
1173
|
+
/** A per-row mask on one entry: the mask for every row, plus each conditioned mask whose condition holds. */
|
|
1174
|
+
const maskForRow = (base, rowMasks, principal, entry) => rowMasks ? rowMasks.when.reduce((mask, extra) => matchesRowFilter(extra.condition, principal, entry) ? unionMasks(mask, extra.mask) : mask, rowMasks.everyRow) : base;
|
|
1175
|
+
/**
|
|
1176
|
+
* The fields the caller may read on one loaded entry (issue #75): the mask for every row, plus each
|
|
1177
|
+
* row-conditioned mask whose condition holds for the entry. Evaluated with the same rule the row filter
|
|
1178
|
+
* compiles to, on the entry's loaded ownership columns, never re-queried.
|
|
1179
|
+
*/
|
|
1180
|
+
const rowReadMask = (policy, principal, entry) => maskForRow(policy.readMask, policy.rowReadMasks, principal, entry);
|
|
1181
|
+
/**
|
|
1182
|
+
* The fields the caller may read on every row it may read. Filters, sorts and searches use it (a filter on a
|
|
1183
|
+
* field hidden on some rows would reveal those rows' values), and so does any reader that shows values
|
|
1184
|
+
* without the entry's ownership at hand.
|
|
1185
|
+
*/
|
|
1186
|
+
const everyRowReadMask = (policy) => policy.rowReadMasks?.everyRow ?? policy.readMask;
|
|
980
1187
|
/**
|
|
981
1188
|
* One condition over the entries row `e` for reads spanning several models (the snapshot diff): an entry
|
|
982
1189
|
* passes when its model is readable without a row filter, or its model's row filter holds for it.
|
|
@@ -1031,7 +1238,7 @@ const projectRelation = (field, value, options, topLevel) => {
|
|
|
1031
1238
|
});
|
|
1032
1239
|
return field.settings.cardinality === "many" ? shown : shown[0] ?? null;
|
|
1033
1240
|
};
|
|
1034
|
-
const projectComponent = (component, value, options) => component && isRecord$2(value) ? projectFields(component.fields.filter((field) => !field.deprecated), value, options, false) : null;
|
|
1241
|
+
const projectComponent = (component, value, options) => component && isRecord$2(value) ? projectFields(component.fields.filter((field) => !field.deprecated && (field.public || !options.publicComponentFieldsOnly)), value, options, false) : null;
|
|
1035
1242
|
const isStringOrStrings = (value) => typeof value === "string" || Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1036
1243
|
/**
|
|
1037
1244
|
* Whether a stored value has the shape the field's current type produces. Old snapshots are served through
|
|
@@ -1074,7 +1281,8 @@ const projectValue = (field, value, options, topLevel) => {
|
|
|
1074
1281
|
if (!options.mediaAssets) return value;
|
|
1075
1282
|
const assets = (Array.isArray(value) ? value : [value]).flatMap((id) => {
|
|
1076
1283
|
const asset = typeof id === "string" ? options.mediaAssets?.get(id) : void 0;
|
|
1077
|
-
|
|
1284
|
+
if (asset) return [asset];
|
|
1285
|
+
return options.keepUnresolvedMedia && typeof id === "string" ? [id] : [];
|
|
1078
1286
|
});
|
|
1079
1287
|
return field.settings.multiple ? assets : assets[0] ?? null;
|
|
1080
1288
|
}
|
|
@@ -1162,6 +1370,11 @@ const fieldMissingExpression = (fieldId, dialect = contentDialect()) => {
|
|
|
1162
1370
|
assertStableId(fieldId, "a field ID");
|
|
1163
1371
|
return dialect.fieldMissing(fieldId);
|
|
1164
1372
|
};
|
|
1373
|
+
/** The IDs a top-level relation or media field stores, one row each (`v`; see `ContentSqlDialect`). */
|
|
1374
|
+
const referenceIdsExpression = (fieldId, list, dialect = contentDialect()) => {
|
|
1375
|
+
assertStableId(fieldId, "a field ID");
|
|
1376
|
+
return dialect.referenceIds(fieldId, list);
|
|
1377
|
+
};
|
|
1165
1378
|
/** Case-sensitive text match on a top-level field; `text` is a parameter, never SQL. */
|
|
1166
1379
|
const textMatchExpression = (fieldId, operator, text, dialect = contentDialect()) => {
|
|
1167
1380
|
assertStableId(fieldId, "a field ID");
|
|
@@ -1398,9 +1611,41 @@ const systemCondition = (name, operator, value, dialect) => {
|
|
|
1398
1611
|
}
|
|
1399
1612
|
}
|
|
1400
1613
|
};
|
|
1401
|
-
|
|
1614
|
+
/**
|
|
1615
|
+
* Whether the head `h` stores a reference the read shows: `exists` over the stored IDs (`v`), each looked up
|
|
1616
|
+
* as a target head of the same source, locale chain and row filter (relations: the target query reuses the
|
|
1617
|
+
* aliases `h` and `e`, so the row filter and the locale chain compile as for any head query), or as a live
|
|
1618
|
+
* asset of the site (media).
|
|
1619
|
+
*/
|
|
1620
|
+
const showsReference = ({ field }, shown, dialect) => {
|
|
1621
|
+
if (shown.kind === "none") return sql`false`;
|
|
1622
|
+
const ids = referenceIdsExpression(field.id, isListValued(field), dialect);
|
|
1623
|
+
if (shown.kind === "media") return sql`exists (select 1 from ${ids.from} where exists (select 1 from media_assets m
|
|
1624
|
+
where ${ids.matches(sql`m.id`)} and m.site_id = ${dialect.uuid(shown.siteId)} and m.deleted_at is null))`;
|
|
1625
|
+
const table = shown.source.kind === "heads" ? sql.table("entry_heads") : sql`(${dialect.snapshotRows({
|
|
1626
|
+
siteId: shown.siteId,
|
|
1627
|
+
modelId: modelIdLiteral(shown.modelId),
|
|
1628
|
+
seq: shown.source.seq
|
|
1629
|
+
})})`;
|
|
1630
|
+
const rowFilter = compileRowFilter(shown.rowFilter, shown.actor, dialect);
|
|
1631
|
+
const where = whereOf({
|
|
1632
|
+
siteId: shown.siteId,
|
|
1633
|
+
modelId: shown.modelId,
|
|
1634
|
+
source: shown.source,
|
|
1635
|
+
locales: shown.locales,
|
|
1636
|
+
conditions: [ids.matches(sql`h.entry_id`), ...rowFilter ? [rowFilter] : []],
|
|
1637
|
+
orderBy: []
|
|
1638
|
+
}, table, dialect);
|
|
1639
|
+
return sql`exists (select 1 from ${ids.from} where exists (select 1 from ${table} h
|
|
1640
|
+
join entries e on e.id = h.entry_id and e.deleted_at is null where ${where}))`;
|
|
1641
|
+
};
|
|
1642
|
+
const fieldCondition = (target, operator, value, dialect, shown) => {
|
|
1402
1643
|
const { field } = target;
|
|
1403
1644
|
const equals = (item) => fieldEqualsExpression(equalityTarget(target), item, dialect);
|
|
1645
|
+
if (shown && (operator === "$null" || operator === "$notNull")) {
|
|
1646
|
+
const present = showsReference(target, shown, dialect);
|
|
1647
|
+
return operator === "$null" === value ? sql`(not ${present})` : present;
|
|
1648
|
+
}
|
|
1404
1649
|
switch (operator) {
|
|
1405
1650
|
case "$eq": return equals(value);
|
|
1406
1651
|
case "$ne": return sql`(not ${equals(value)})`;
|
|
@@ -1428,8 +1673,8 @@ const compileFilter = (node, dialect = contentDialect()) => {
|
|
|
1428
1673
|
switch (node.kind) {
|
|
1429
1674
|
case "and": return sql`(${sql.join(node.nodes.map(compileChild), sql` and `)})`;
|
|
1430
1675
|
case "or": return sql`(${sql.join(node.nodes.map(compileChild), sql` or `)})`;
|
|
1431
|
-
case "not": return sql`(not ${compileChild(node.node)})`;
|
|
1432
|
-
case "condition": return node.target.kind === "system" ? systemCondition(node.target.name, node.operator, node.value, dialect) : fieldCondition(node.target, node.operator, node.value, dialect);
|
|
1676
|
+
case "not": return sql`(not coalesce(${compileChild(node.node)}, false))`;
|
|
1677
|
+
case "condition": return node.target.kind === "system" ? systemCondition(node.target.name, node.operator, node.value, dialect) : fieldCondition(node.target, node.operator, node.value, dialect, node.shownTargets);
|
|
1433
1678
|
}
|
|
1434
1679
|
};
|
|
1435
1680
|
/**
|
|
@@ -1749,6 +1994,7 @@ const MEDIA_SIGNED_URL_TTL_SECONDS = 3600;
|
|
|
1749
1994
|
* Shapio's stable route. Private: always an expiring signed URL; nothing else reaches private bytes.
|
|
1750
1995
|
*/
|
|
1751
1996
|
const resolveObjectUrl = async (storage, urls, object, ttlSeconds = MEDIA_SIGNED_URL_TTL_SECONDS) => {
|
|
1997
|
+
if (visibilityOfKey(object.key) !== object.visibility) return resolveMovingObjectUrl(storage, urls, object, ttlSeconds);
|
|
1752
1998
|
const adapter = storage.get(object.driver);
|
|
1753
1999
|
if (object.visibility === "public") return {
|
|
1754
2000
|
url: adapter.publicUrl(object.key) ?? urls.absoluteUrl(`/api/media/f/${encodeKeyPath(object.key)}`),
|
|
@@ -1763,6 +2009,28 @@ const resolveObjectUrl = async (storage, urls, object, ttlSeconds = MEDIA_SIGNED
|
|
|
1763
2009
|
expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
|
|
1764
2010
|
};
|
|
1765
2011
|
};
|
|
2012
|
+
/**
|
|
2013
|
+
* An object whose visibility changed while the relocate job has not moved it yet (its key still has the old
|
|
2014
|
+
* prefix). Neither the old key (purged after the move) nor the bucket or CDN at the new key (empty until the
|
|
2015
|
+
* copy) works at every moment, so the URL goes through Shapio's own file route with the key under the new
|
|
2016
|
+
* prefix: the route serves whichever copy the row points at, before and after the move. Private objects get
|
|
2017
|
+
* a route URL signed like the local adapter's (local storage is always configured, with the same secret).
|
|
2018
|
+
*/
|
|
2019
|
+
const resolveMovingObjectUrl = async (storage, urls, object, ttlSeconds) => {
|
|
2020
|
+
const key = withVisibility(object.key, object.visibility);
|
|
2021
|
+
if (object.visibility === "public") return {
|
|
2022
|
+
url: urls.absoluteUrl(`${MEDIA_FILE_ROUTE_PREFIX}${encodeKeyPath(key)}`),
|
|
2023
|
+
expiresAt: null
|
|
2024
|
+
};
|
|
2025
|
+
return {
|
|
2026
|
+
url: await storage.get("local").signedGetUrl(key, {
|
|
2027
|
+
expiresInSeconds: ttlSeconds,
|
|
2028
|
+
filename: object.filename,
|
|
2029
|
+
contentType: object.mimeType
|
|
2030
|
+
}),
|
|
2031
|
+
expiresAt: new Date(Date.now() + ttlSeconds * 1e3)
|
|
2032
|
+
};
|
|
2033
|
+
};
|
|
1766
2034
|
//#endregion
|
|
1767
2035
|
//#region ../../apps/api/src/repositories/mediaVariants.ts
|
|
1768
2036
|
const listForAssets = (assetIds, trx = db) => assetIds.length === 0 ? Promise.resolve([]) : trx.selectFrom("media_variants").selectAll().where("asset_id", "in", assetIds).orderBy("asset_id").orderBy("width").execute();
|
|
@@ -1832,7 +2100,7 @@ const toAssetViews = async (deps, assets, executor) => {
|
|
|
1832
2100
|
};
|
|
1833
2101
|
//#endregion
|
|
1834
2102
|
//#region ../../apps/api/src/content/compiler/populate.ts
|
|
1835
|
-
const idsOf = (rows, fieldId) => {
|
|
2103
|
+
const idsOf$1 = (rows, fieldId) => {
|
|
1836
2104
|
const ids = /* @__PURE__ */ new Set();
|
|
1837
2105
|
for (const row of rows) {
|
|
1838
2106
|
const value = row.data[fieldId];
|
|
@@ -1851,7 +2119,7 @@ const populateRelations = async (env, rows, fields, tree) => {
|
|
|
1851
2119
|
const target = resolveModelById(env.snapshot, field.settings.target);
|
|
1852
2120
|
if (!target) continue;
|
|
1853
2121
|
const policy = await env.policyFor(env, target.definition.id);
|
|
1854
|
-
const targetRows = await env.fetch(env, target, policy, idsOf(rows, fieldId));
|
|
2122
|
+
const targetRows = await env.fetch(env, target, policy, idsOf$1(rows, fieldId));
|
|
1855
2123
|
for (const { row, data } of await env.project(env, target, policy, targetRows, {
|
|
1856
2124
|
fields: null,
|
|
1857
2125
|
populate: subtree
|
|
@@ -1947,9 +2215,9 @@ const keysOf = (model) => {
|
|
|
1947
2215
|
return keys;
|
|
1948
2216
|
};
|
|
1949
2217
|
/** The entry's own title, only when the caller may read the title field (never a hidden value). */
|
|
1950
|
-
const fallbackTitleOf = (model, policy, row) => {
|
|
2218
|
+
const fallbackTitleOf = (env, model, policy, row) => {
|
|
1951
2219
|
const field = effectiveTitleField(model.definition);
|
|
1952
|
-
if (!field || !maskAllows(policy.
|
|
2220
|
+
if (!field || !maskAllows(rowReadMask(policy, env.actor, row), field)) return null;
|
|
1953
2221
|
const value = row.data[field.id];
|
|
1954
2222
|
return typeof value === "string" ? value : null;
|
|
1955
2223
|
};
|
|
@@ -1965,7 +2233,7 @@ const resolveSeoFields = (env, options, model, policy, fields, row, data) => {
|
|
|
1965
2233
|
const context = {
|
|
1966
2234
|
defaults: seoDefaultsForLocale(options.defaults, fallbackChain(env.snapshot, defaultsLocaleOf(env, model, row))),
|
|
1967
2235
|
image: options.image,
|
|
1968
|
-
fallbackTitle: fallbackTitleOf(model, policy, row),
|
|
2236
|
+
fallbackTitle: fallbackTitleOf(env, model, policy, row),
|
|
1969
2237
|
keys
|
|
1970
2238
|
};
|
|
1971
2239
|
const output = { ...data };
|
|
@@ -2109,6 +2377,15 @@ const systemAttributes = (env, row) => ({
|
|
|
2109
2377
|
updatedAt: row.updated_at.toISOString(),
|
|
2110
2378
|
...env.audience === "delivery" ? { publishedAt: row.updated_at.toISOString() } : {}
|
|
2111
2379
|
});
|
|
2380
|
+
/**
|
|
2381
|
+
* The selected fields this row shows: all of them unless the policy's read masks differ by row, then those
|
|
2382
|
+
* the row's own mask allows (a field granted only on the caller's own entries stays off everyone else's).
|
|
2383
|
+
*/
|
|
2384
|
+
const fieldsOnRow = (env, policy, fields, row) => {
|
|
2385
|
+
if (!policy.rowReadMasks) return fields;
|
|
2386
|
+
const mask = rowReadMask(policy, env.actor, row);
|
|
2387
|
+
return fields.filter((field) => maskAllows(mask, field));
|
|
2388
|
+
};
|
|
2112
2389
|
const projectRows = async (env, model, policy, rows, query) => {
|
|
2113
2390
|
const fields = selectFields(model, policy.readMask, query.fields);
|
|
2114
2391
|
const { visible: visibleTargets, fetched } = await relationTargets(env, model, rows, fields);
|
|
@@ -2122,24 +2399,132 @@ const projectRows = async (env, model, policy, rows, query) => {
|
|
|
2122
2399
|
const populated = await populateRelations(populateEnv, rows, fields, query.populate);
|
|
2123
2400
|
const media = await loadMediaViews(env, model, rows, fields);
|
|
2124
2401
|
return rows.map((row) => {
|
|
2402
|
+
const rowFields = fieldsOnRow(env, policy, fields, row);
|
|
2125
2403
|
const data = projectData(row.data, {
|
|
2126
2404
|
model,
|
|
2127
|
-
fields,
|
|
2405
|
+
fields: rowFields,
|
|
2128
2406
|
visibleTargets,
|
|
2129
2407
|
populated,
|
|
2130
|
-
...env.audience === "delivery" ? { richText: env.richText ?? "json" } : {},
|
|
2131
|
-
mediaAssets: media.byId
|
|
2408
|
+
...env.audience === "delivery" ? { richText: env.richText ?? "json" } : { keepUnresolvedMedia: true },
|
|
2409
|
+
mediaAssets: media.byId,
|
|
2410
|
+
publicComponentFieldsOnly: hidesPrivateComponentFields(policy.readMask)
|
|
2132
2411
|
});
|
|
2133
2412
|
return {
|
|
2134
2413
|
row,
|
|
2135
2414
|
data: env.seo ? resolveSeoFields(env, {
|
|
2136
2415
|
defaults: env.seo,
|
|
2137
2416
|
image: media.seoImage
|
|
2138
|
-
}, model, policy,
|
|
2417
|
+
}, model, policy, rowFields, row, data) : data
|
|
2139
2418
|
};
|
|
2140
2419
|
});
|
|
2141
2420
|
};
|
|
2142
2421
|
//#endregion
|
|
2422
|
+
//#region ../../apps/api/src/content/visibleReferences.ts
|
|
2423
|
+
const ID_OPERATORS = /* @__PURE__ */ new Set([
|
|
2424
|
+
"$eq",
|
|
2425
|
+
"$ne",
|
|
2426
|
+
"$in",
|
|
2427
|
+
"$nin"
|
|
2428
|
+
]);
|
|
2429
|
+
const referenceFieldOf = (node) => node.target.kind === "field" && (node.target.field.type === "relation" || node.target.field.type === "media") ? node.target.field : void 0;
|
|
2430
|
+
const conditionsOf = (node) => {
|
|
2431
|
+
switch (node.kind) {
|
|
2432
|
+
case "and":
|
|
2433
|
+
case "or": return node.nodes.flatMap(conditionsOf);
|
|
2434
|
+
case "not": return conditionsOf(node.node);
|
|
2435
|
+
case "condition": return [node];
|
|
2436
|
+
}
|
|
2437
|
+
};
|
|
2438
|
+
const idsOf = (node) => ID_OPERATORS.has(node.operator) ? (Array.isArray(node.value) ? node.value : [node.value]).map(String) : [];
|
|
2439
|
+
/** What the read shows of these conditions' fields: relation targets and live assets. */
|
|
2440
|
+
const visibleIds = async (env, conditions) => {
|
|
2441
|
+
const visible = /* @__PURE__ */ new Map();
|
|
2442
|
+
for (const node of conditions) {
|
|
2443
|
+
const field = referenceFieldOf(node);
|
|
2444
|
+
if (!field || visible.has(field.id)) continue;
|
|
2445
|
+
const ids = [...new Set(conditions.filter((other) => referenceFieldOf(other)?.id === field.id).flatMap(idsOf))];
|
|
2446
|
+
if (field.type === "media") {
|
|
2447
|
+
if (env.media) {
|
|
2448
|
+
const assets = await findLiveManyOnSite(env.siteId, ids, env.executor);
|
|
2449
|
+
visible.set(field.id, {
|
|
2450
|
+
ids: new Set(assets.map((asset) => asset.id)),
|
|
2451
|
+
shown: {
|
|
2452
|
+
kind: "media",
|
|
2453
|
+
siteId: env.siteId
|
|
2454
|
+
}
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
continue;
|
|
2458
|
+
}
|
|
2459
|
+
if (env.audience === "admin") continue;
|
|
2460
|
+
const policy = await readPolicy(env, field.settings.target);
|
|
2461
|
+
if (!policy.allowed) throw queryForbidden(`You may not filter on "${field.apiKey}": you may not read the entries it links to`, { field: field.apiKey });
|
|
2462
|
+
const target = resolveModelById(env.snapshot, field.settings.target);
|
|
2463
|
+
if (!target) {
|
|
2464
|
+
visible.set(field.id, {
|
|
2465
|
+
ids: /* @__PURE__ */ new Set(),
|
|
2466
|
+
shown: { kind: "none" }
|
|
2467
|
+
});
|
|
2468
|
+
continue;
|
|
2469
|
+
}
|
|
2470
|
+
const rows = await fetchHeadsByIds(env, target, policy, ids);
|
|
2471
|
+
visible.set(field.id, {
|
|
2472
|
+
ids: new Set(rows.map((row) => row.entry_id)),
|
|
2473
|
+
shown: {
|
|
2474
|
+
kind: "relation",
|
|
2475
|
+
siteId: env.siteId,
|
|
2476
|
+
modelId: target.definition.id,
|
|
2477
|
+
source: env.source,
|
|
2478
|
+
locales: readScopeFor(env.snapshot, target.definition, env.locale, { fallback: true }),
|
|
2479
|
+
rowFilter: policy.rowFilter,
|
|
2480
|
+
actor: env.actor
|
|
2481
|
+
}
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
return visible;
|
|
2485
|
+
};
|
|
2486
|
+
/**
|
|
2487
|
+
* A condition limited to what the read shows: `$eq hidden` matches nothing, `$ne hidden` everything, and
|
|
2488
|
+
* `$null`/`$notNull` carry the rule for what is shown.
|
|
2489
|
+
*/
|
|
2490
|
+
const rewrite = (node, visible) => {
|
|
2491
|
+
switch (node.kind) {
|
|
2492
|
+
case "and":
|
|
2493
|
+
case "or": return {
|
|
2494
|
+
...node,
|
|
2495
|
+
nodes: node.nodes.map((child) => rewrite(child, visible))
|
|
2496
|
+
};
|
|
2497
|
+
case "not": return {
|
|
2498
|
+
...node,
|
|
2499
|
+
node: rewrite(node.node, visible)
|
|
2500
|
+
};
|
|
2501
|
+
case "condition": {
|
|
2502
|
+
const field = node.target.kind === "field" ? visible.get(node.target.field.id) : void 0;
|
|
2503
|
+
if (!field) return node;
|
|
2504
|
+
if (node.operator === "$null" || node.operator === "$notNull") return {
|
|
2505
|
+
...node,
|
|
2506
|
+
shownTargets: field.shown
|
|
2507
|
+
};
|
|
2508
|
+
if (!ID_OPERATORS.has(node.operator)) return node;
|
|
2509
|
+
const ids = idsOf(node).filter((id) => field.ids.has(id));
|
|
2510
|
+
const negated = node.operator === "$ne" || node.operator === "$nin";
|
|
2511
|
+
if (ids.length === idsOf(node).length) return node;
|
|
2512
|
+
return {
|
|
2513
|
+
...node,
|
|
2514
|
+
operator: negated ? "$nin" : "$in",
|
|
2515
|
+
value: ids
|
|
2516
|
+
};
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
};
|
|
2520
|
+
/** The query's filter with relation and media conditions limited to what this read shows. */
|
|
2521
|
+
const visibleReferenceFilter = async (env, filter) => {
|
|
2522
|
+
if (!filter) return null;
|
|
2523
|
+
const conditions = conditionsOf(filter).filter((node) => referenceFieldOf(node) !== void 0);
|
|
2524
|
+
if (conditions.length === 0) return filter;
|
|
2525
|
+
return rewrite(filter, await visibleIds(env, conditions));
|
|
2526
|
+
};
|
|
2527
|
+
//#endregion
|
|
2143
2528
|
//#region ../../apps/api/src/content/compiler/querystring.ts
|
|
2144
2529
|
const SEGMENT = /^\[([^[\]]*)\]/;
|
|
2145
2530
|
const MAX_DEPTH = 10;
|
|
@@ -2233,14 +2618,14 @@ const positiveInteger = (value, name, max) => {
|
|
|
2233
2618
|
if (!/^\d{1,15}$/.test(text) || Number(text) < 1 || Number(text) > max) throw queryInvalid(`"${name}" must be an integer between 1 and ${max}`);
|
|
2234
2619
|
return Number(text);
|
|
2235
2620
|
};
|
|
2236
|
-
const resolveTarget = (context, key) => {
|
|
2621
|
+
const resolveTarget = (context, key, use = "query") => {
|
|
2237
2622
|
if (SYSTEM_ATTRIBUTES.includes(key)) return {
|
|
2238
2623
|
kind: "system",
|
|
2239
2624
|
name: key
|
|
2240
2625
|
};
|
|
2241
2626
|
const field = findFieldByApiKey(context.model.fields, key);
|
|
2242
2627
|
if (!field) throw queryInvalid(`Unknown field "${key}"`, { field: key });
|
|
2243
|
-
if (!context.isReadable(field)) throw queryForbidden(`You may not filter, sort or search on "${key}"`, { field: key });
|
|
2628
|
+
if (use === "select" ? !context.isReadable(field) : !context.isQueryable(field)) throw queryForbidden(use === "select" ? `You may not read the field "${key}"` : `You may not filter, sort or search on "${key}"`, { field: key });
|
|
2244
2629
|
return {
|
|
2245
2630
|
kind: "field",
|
|
2246
2631
|
field
|
|
@@ -2315,7 +2700,7 @@ const parseFields = (context, value) => {
|
|
|
2315
2700
|
if (value === void 0) return null;
|
|
2316
2701
|
return toList(value).map((item) => {
|
|
2317
2702
|
if (typeof item !== "string") throw queryInvalid("fields needs a list of field names");
|
|
2318
|
-
const target = resolveTarget(context, item);
|
|
2703
|
+
const target = resolveTarget(context, item, "select");
|
|
2319
2704
|
if (target.kind === "system") throw queryInvalid(`"${item}" is always returned; list only model fields`);
|
|
2320
2705
|
return target.field;
|
|
2321
2706
|
});
|
|
@@ -2354,7 +2739,7 @@ const parseSearch = (context, value) => {
|
|
|
2354
2739
|
if (!text) return null;
|
|
2355
2740
|
const field = effectiveTitleField(context.model);
|
|
2356
2741
|
if (!field || !TEXT_TITLE_FIELD_TYPES.has(field.type)) throw queryInvalid(`"${context.model.apiKey}" has no text title field to search`);
|
|
2357
|
-
if (!context.
|
|
2742
|
+
if (!context.isQueryable(field)) throw queryForbidden("You may not search this model", { field: field.apiKey });
|
|
2358
2743
|
return {
|
|
2359
2744
|
field,
|
|
2360
2745
|
text
|
|
@@ -2445,6 +2830,12 @@ sql`uuid[]`, sql`text[]`;
|
|
|
2445
2830
|
* `items` is the inside of `group_concat`: JSON text per row and its ORDER BY.
|
|
2446
2831
|
*/
|
|
2447
2832
|
const mysqlJsonArrayOf = (items) => sql`cast(concat('[', group_concat(${items} separator ','), ']') as json)`;
|
|
2833
|
+
//#endregion
|
|
2834
|
+
//#region ../../apps/api/src/repositories/publications.ts
|
|
2835
|
+
const currentSeq = async (siteId, executor = db) => {
|
|
2836
|
+
const row = await executor.selectFrom("publication_state").select("last_seq").where("site_id", "=", siteId).executeTakeFirstOrThrow();
|
|
2837
|
+
return Number(row.last_seq);
|
|
2838
|
+
};
|
|
2448
2839
|
sql`jsonb_build_object`, sql`jsonb_agg`, sql`'[]'::jsonb`, sql`json_build_object`, sql`json_agg`, sql`'[]'::json`;
|
|
2449
2840
|
sql``, sql` desc`;
|
|
2450
2841
|
sql` nulls first`, sql` nulls last`;
|
|
@@ -2467,6 +2858,7 @@ const parseQueryFor = (context, model, policy, rawQuery, options) => parseConten
|
|
|
2467
2858
|
model: model.definition,
|
|
2468
2859
|
locales: context.snapshot.locales.map((locale) => locale.code),
|
|
2469
2860
|
isReadable: (field) => maskAllows(policy.readMask, field),
|
|
2861
|
+
isQueryable: (field) => maskAllows(everyRowReadMask(policy), field),
|
|
2470
2862
|
allowSnapshot: options.allowSnapshot,
|
|
2471
2863
|
allowAdminFilters: options.allowAdminFilters ?? false,
|
|
2472
2864
|
allowRichText: options.allowRichText ?? false,
|
|
@@ -2622,9 +3014,13 @@ const toEntries = async (read, env, rows) => (await projectRows(env, read.model,
|
|
|
2622
3014
|
*/
|
|
2623
3015
|
const readEntryPage = async (unloaded, options = { total: true }) => {
|
|
2624
3016
|
const read = await withSeoDefaults(unloaded);
|
|
2625
|
-
const { context, model, policy
|
|
3017
|
+
const { context, model, policy } = read;
|
|
3018
|
+
const query = {
|
|
3019
|
+
...read.query,
|
|
3020
|
+
filter: await visibleReferenceFilter(environmentOf(read, context.db), read.query.filter)
|
|
3021
|
+
};
|
|
2626
3022
|
const { limit, offset } = toLimitOffset(query.page, query.pageSize);
|
|
2627
|
-
const sort = query.sort.length > 0 ? query.sort : defaultSortTerms(model.definition, (field) => maskAllows(policy
|
|
3023
|
+
const sort = query.sort.length > 0 ? query.sort : defaultSortTerms(model.definition, (field) => maskAllows(everyRowReadMask(policy), field));
|
|
2628
3024
|
const plan = {
|
|
2629
3025
|
siteId: context.site.id,
|
|
2630
3026
|
modelId: model.definition.id,
|
|
@@ -2780,10 +3176,13 @@ const DELIVERY_PARSE = {
|
|
|
2780
3176
|
allowSeo: true,
|
|
2781
3177
|
allowPublicationState: true
|
|
2782
3178
|
};
|
|
2783
|
-
/**
|
|
3179
|
+
/**
|
|
3180
|
+
* Meta every delivery response carries. Draft reads report `snapshot: null` (drafts cannot be pinned, as in
|
|
3181
|
+
* GraphQL) and add `publicationState`; published ones are unchanged.
|
|
3182
|
+
*/
|
|
2784
3183
|
const metaOf = (locale, snapshot, drafts) => drafts ? {
|
|
2785
3184
|
locale,
|
|
2786
|
-
snapshot,
|
|
3185
|
+
snapshot: null,
|
|
2787
3186
|
publicationState: "draft"
|
|
2788
3187
|
} : {
|
|
2789
3188
|
locale,
|
|
@@ -2860,12 +3259,6 @@ const getDelivery = async (context, modelKey, id, rawQuery, options = {}) => {
|
|
|
2860
3259
|
};
|
|
2861
3260
|
};
|
|
2862
3261
|
//#endregion
|
|
2863
|
-
//#region ../../apps/api/src/repositories/publications.ts
|
|
2864
|
-
const currentSeq = async (siteId, executor = db) => {
|
|
2865
|
-
const row = await executor.selectFrom("publication_state").select("last_seq").where("site_id", "=", siteId).executeTakeFirstOrThrow();
|
|
2866
|
-
return Number(row.last_seq);
|
|
2867
|
-
};
|
|
2868
|
-
//#endregion
|
|
2869
3262
|
//#region ../../apps/api/src/repositories/schemaVersions.ts
|
|
2870
3263
|
/** The global schema version: the durable value every instance compares its cached snapshot against. */
|
|
2871
3264
|
const getSchemaVersion = async (executor = db) => {
|
|
@@ -3000,6 +3393,20 @@ const schemaVersionsAt = async (siteId, seqs, executor = db) => {
|
|
|
3000
3393
|
};
|
|
3001
3394
|
/** When the site's snapshot `seq` was created, from the ledger (undefined without a ledger row). */
|
|
3002
3395
|
const snapshotCreatedAt = async (siteId, seq, executor = db) => (await executor.selectFrom("publication_snapshots").select("created_at").where("site_id", "=", siteId).where("seq", "=", String(seq)).executeTakeFirst())?.created_at;
|
|
3396
|
+
/**
|
|
3397
|
+
* Where a diff ending at `to` must start to cover the last `count` entries that went live or were updated
|
|
3398
|
+
* (by the snapshot their live revision opened at), counting only entries in the given models that pass the
|
|
3399
|
+
* row filter and are still live at `to`. 0 when fewer than `count` exist, so the diff covers everything.
|
|
3400
|
+
* Schema-only snapshots open no period, so they never use up the window.
|
|
3401
|
+
*/
|
|
3402
|
+
const recentWindowStart = async (query, executor = db) => {
|
|
3403
|
+
if (query.modelIds.length === 0) return 0;
|
|
3404
|
+
const to = String(query.to);
|
|
3405
|
+
const rowFilter = query.rowFilter?.$castTo();
|
|
3406
|
+
const rows = await executor.selectFrom("publication_log as p").innerJoin("entries as e", "e.id", "p.entry_id").select((eb) => ["p.entry_id", eb.fn.max("p.from_seq").as("last_seq")]).where("p.site_id", "=", query.siteId).where("p.model_id", "in", query.modelIds).where("p.from_seq", "<=", to).where((eb) => eb.or([eb("p.to_seq", "is", null), eb("p.to_seq", ">", to)])).$call((qb) => rowFilter ? qb.where(rowFilter) : qb).groupBy("p.entry_id").orderBy("last_seq", "desc").limit(query.count).execute();
|
|
3407
|
+
if (rows.length < query.count) return 0;
|
|
3408
|
+
return Math.max(0, Math.min(...rows.map((row) => Number(row.last_seq))) - 1);
|
|
3409
|
+
};
|
|
3003
3410
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
3004
3411
|
/**
|
|
3005
3412
|
* Models the caller may read. An authenticated caller (session, token, app user) with none (or an instance
|
|
@@ -3017,7 +3424,7 @@ const readableModels = async (context) => {
|
|
|
3017
3424
|
if (policy.allowed) readable.push({
|
|
3018
3425
|
definition,
|
|
3019
3426
|
rowFilter: policy.rowFilter,
|
|
3020
|
-
readMask: policy
|
|
3427
|
+
readMask: everyRowReadMask(policy)
|
|
3021
3428
|
});
|
|
3022
3429
|
}
|
|
3023
3430
|
if (readable.length === 0 && context.actor.kind === "anonymous") throw new AppError(401, "UNAUTHENTICATED", "Snapshots need a delivery token");
|
|
@@ -3058,32 +3465,42 @@ const boundedLimit = (limit) => Math.min(Math.max(limit ?? 100, 1), 500);
|
|
|
3058
3465
|
/** `GET /api/snapshots/changes`, GraphQL `_changes`: one page of entries changed between `from` and `to`. */
|
|
3059
3466
|
const listSnapshotChanges = async (context, query) => {
|
|
3060
3467
|
if (query.after !== void 0 && !UUID.test(query.after)) throw queryInvalid("\"after\" must be a cursor returned by a previous page");
|
|
3468
|
+
if (query.from === void 0 === (query.recent === void 0)) throw queryInvalid("Pass exactly one of \"from\" and \"recent\"");
|
|
3061
3469
|
const readable = await readableModels(context);
|
|
3062
3470
|
const byId = new Map(readable.map((model) => [model.definition.id, model]));
|
|
3471
|
+
const modelIds = [...byId.keys()];
|
|
3472
|
+
const rowFilter = compileModelRowFilters(readable.map((model) => ({
|
|
3473
|
+
modelId: model.definition.id,
|
|
3474
|
+
rowFilter: model.rowFilter
|
|
3475
|
+
})), context.actor);
|
|
3063
3476
|
return withConsistentRead(async (trx) => {
|
|
3064
3477
|
const current = await currentSeq(context.site.id, trx);
|
|
3065
3478
|
const to = query.to ?? current;
|
|
3066
3479
|
if (to > current) throw snapshotInvalid(to, current);
|
|
3067
|
-
|
|
3480
|
+
const from = query.from ?? await recentWindowStart({
|
|
3481
|
+
siteId: context.site.id,
|
|
3482
|
+
to,
|
|
3483
|
+
count: query.recent ?? 0,
|
|
3484
|
+
modelIds,
|
|
3485
|
+
rowFilter
|
|
3486
|
+
}, trx);
|
|
3487
|
+
if (from < 0 || from > to) throw queryInvalid("\"from\" must be between 0 and \"to\"");
|
|
3068
3488
|
const page = await listChanges({
|
|
3069
3489
|
siteId: context.site.id,
|
|
3070
|
-
from
|
|
3490
|
+
from,
|
|
3071
3491
|
to,
|
|
3072
3492
|
after: query.after,
|
|
3073
3493
|
limit: boundedLimit(query.limit),
|
|
3074
|
-
modelIds
|
|
3075
|
-
rowFilter
|
|
3076
|
-
modelId: model.definition.id,
|
|
3077
|
-
rowFilter: model.rowFilter
|
|
3078
|
-
})), context.actor)
|
|
3494
|
+
modelIds,
|
|
3495
|
+
rowFilter
|
|
3079
3496
|
}, trx);
|
|
3080
|
-
const versions = await schemaVersionsAt(context.site.id, [
|
|
3497
|
+
const versions = await schemaVersionsAt(context.site.id, [from, to], trx);
|
|
3081
3498
|
const describe = await describeEntries(page.items, byId, context.snapshot.defaultLocale, trx);
|
|
3082
3499
|
return {
|
|
3083
|
-
from
|
|
3500
|
+
from,
|
|
3084
3501
|
to,
|
|
3085
3502
|
schemaVersions: {
|
|
3086
|
-
from: versions.get(
|
|
3503
|
+
from: versions.get(from) ?? null,
|
|
3087
3504
|
to: versions.get(to) ?? null
|
|
3088
3505
|
},
|
|
3089
3506
|
items: page.items.flatMap((item) => {
|
|
@@ -3271,10 +3688,11 @@ const readForDelivery = async (input, trx = db) => {
|
|
|
3271
3688
|
"sites.id as site_id",
|
|
3272
3689
|
"sites.key as site_key",
|
|
3273
3690
|
"system_settings.value as descriptor"
|
|
3274
|
-
]).$if(tokenId !== void 0, (query) => query.leftJoin("api_tokens", (join) => join.on("api_tokens.id", "=", tokenId ?? "")).select([
|
|
3691
|
+
]).$if(tokenId !== void 0, (query) => query.leftJoin("api_tokens", (join) => join.on("api_tokens.id", "=", tokenId ?? "")).leftJoin("admin_users", "admin_users.id", "api_tokens.created_by").select([
|
|
3275
3692
|
"api_tokens.id as token_id",
|
|
3276
3693
|
"api_tokens.revoked_at as token_revoked_at",
|
|
3277
|
-
"api_tokens.expires_at as token_expires_at"
|
|
3694
|
+
"api_tokens.expires_at as token_expires_at",
|
|
3695
|
+
"admin_users.status as token_creator_status"
|
|
3278
3696
|
])).executeTakeFirstOrThrow();
|
|
3279
3697
|
return {
|
|
3280
3698
|
schemaVersion: row.schema_version,
|
|
@@ -3287,7 +3705,8 @@ const readForDelivery = async (input, trx = db) => {
|
|
|
3287
3705
|
descriptor: row.descriptor,
|
|
3288
3706
|
token: row.token_id === void 0 || row.token_id === null ? null : {
|
|
3289
3707
|
revokedAt: row.token_revoked_at ?? null,
|
|
3290
|
-
expiresAt: row.token_expires_at ?? null
|
|
3708
|
+
expiresAt: row.token_expires_at ?? null,
|
|
3709
|
+
creatorDisabled: row.token_creator_status === "disabled"
|
|
3291
3710
|
}
|
|
3292
3711
|
};
|
|
3293
3712
|
};
|
|
@@ -3330,18 +3749,23 @@ const LIST_COLUMNS = [
|
|
|
3330
3749
|
"api_tokens.revoked_at",
|
|
3331
3750
|
"api_tokens.created_at",
|
|
3332
3751
|
"api_tokens.site_id",
|
|
3333
|
-
"admin_roles.kind as role_kind"
|
|
3752
|
+
"admin_roles.kind as role_kind",
|
|
3753
|
+
"admin_users.status as creator_status"
|
|
3334
3754
|
];
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3755
|
+
/** Tokens with their role's kind and their creator's status (null when no admin is recorded as creator). */
|
|
3756
|
+
const withRole = (trx) => trx.selectFrom("api_tokens").innerJoin("admin_roles", "admin_roles.id", "api_tokens.role_id").leftJoin("admin_users", "admin_users.id", "api_tokens.created_by").select(LIST_COLUMNS);
|
|
3757
|
+
/**
|
|
3758
|
+
* A usable token for this hash: not revoked, and its creator (if one is recorded) is not disabled. Expiry is
|
|
3759
|
+
* checked by the caller.
|
|
3760
|
+
*/
|
|
3761
|
+
const findLiveByHash = (tokenHash, trx = db) => withRole(trx).where("api_tokens.token_hash", "=", tokenHash).where("api_tokens.revoked_at", "is", null).where((eb) => eb.or([eb("admin_users.id", "is", null), eb("admin_users.status", "=", "active")])).executeTakeFirst();
|
|
3338
3762
|
const touchLastUsed = (id, now, staleBefore, trx = db) => trx.updateTable("api_tokens").set({ last_used_at: now }).where("id", "=", id).where((eb) => eb.or([eb("last_used_at", "is", null), eb("last_used_at", "<", staleBefore)])).execute();
|
|
3339
3763
|
//#endregion
|
|
3340
3764
|
//#region ../../apps/api/src/services/apiTokens.ts
|
|
3341
3765
|
const scopeOf = (roleKind) => roleKind === "delivery" ? "delivery" : "admin";
|
|
3342
3766
|
/** Whether a token with this expiry has expired at `now`. */
|
|
3343
3767
|
const isTokenExpired = (expiresAt, now) => expiresAt !== null && expiresAt.getTime() <= now.getTime();
|
|
3344
|
-
/** The principal for a bearer API token, or undefined when unknown, revoked or
|
|
3768
|
+
/** The principal for a bearer API token, or undefined when unknown, revoked, expired or its creator is disabled. */
|
|
3345
3769
|
const resolveApiToken = async (value, now = /* @__PURE__ */ new Date(), { recordUse = true, executor = db } = {}) => {
|
|
3346
3770
|
const row = await findLiveByHash(hashToken(value), executor);
|
|
3347
3771
|
if (!row || isTokenExpired(row.expires_at, now)) return;
|
|
@@ -3477,7 +3901,7 @@ const guardingSkew = async (runtime, statement) => {
|
|
|
3477
3901
|
return await statement();
|
|
3478
3902
|
} catch (error) {
|
|
3479
3903
|
if (error instanceof AppError) throw error;
|
|
3480
|
-
const { getPendingMigrations } = await import("./migrator-
|
|
3904
|
+
const { getPendingMigrations } = await import("./migrator-CO5sXosL.js");
|
|
3481
3905
|
if ((await getPendingMigrations(runtime.db)).length > 0) throw new ShapioVersionSkewError$1(null, SHAPIO_VERSION$1, { cause: error });
|
|
3482
3906
|
throw error;
|
|
3483
3907
|
}
|
|
@@ -3489,8 +3913,8 @@ const readState = (runtime, site, tokenId) => guardingSkew(runtime, () => readFo
|
|
|
3489
3913
|
}, runtime.db));
|
|
3490
3914
|
/**
|
|
3491
3915
|
* The caller: anonymous, or a delivery token resolved once per runtime (read-only: in-process reads never
|
|
3492
|
-
* record a token's use) and checked for liveness on every call by the
|
|
3493
|
-
* refused: in-process reads are delivery reads only (rule 7).
|
|
3916
|
+
* record a token's use) and checked for liveness (revoked, expired, creator disabled) on every call by the
|
|
3917
|
+
* state read. Admin-scope tokens are refused: in-process reads are delivery reads only (rule 7).
|
|
3494
3918
|
*/
|
|
3495
3919
|
const callerOf = async (runtime, token) => {
|
|
3496
3920
|
if (token === void 0) return { principal: ANONYMOUS };
|
|
@@ -3532,7 +3956,7 @@ const openCallScope = async (runtime, { token, site: requestedKey }) => {
|
|
|
3532
3956
|
if (!state) throw new Error("The site was resolved without reading the request state");
|
|
3533
3957
|
if (caller.tokenKey !== void 0) {
|
|
3534
3958
|
const live = state.token;
|
|
3535
|
-
if (!live || live.revokedAt !== null || isTokenExpired(live.expiresAt, /* @__PURE__ */ new Date())) {
|
|
3959
|
+
if (!live || live.revokedAt !== null || live.creatorDisabled || isTokenExpired(live.expiresAt, /* @__PURE__ */ new Date())) {
|
|
3536
3960
|
runtime.tokens.delete(caller.tokenKey);
|
|
3537
3961
|
throw invalidToken();
|
|
3538
3962
|
}
|
|
@@ -3830,7 +4254,7 @@ const createMediaStorage = async (config, deps) => {
|
|
|
3830
4254
|
publicBaseUrl: config.publicBaseUrl
|
|
3831
4255
|
}));
|
|
3832
4256
|
if (config.s3) {
|
|
3833
|
-
const { createS3Adapter } = await import("./s3Adapter-
|
|
4257
|
+
const { createS3Adapter } = await import("./s3Adapter-BC5QP2rS.js");
|
|
3834
4258
|
adapters.set("s3", createS3Adapter({
|
|
3835
4259
|
config: config.s3,
|
|
3836
4260
|
publicBaseUrl: config.publicBaseUrl
|
|
@@ -3964,67 +4388,6 @@ const APP_CONTENT_ACTIONS = [
|
|
|
3964
4388
|
"publish"
|
|
3965
4389
|
];
|
|
3966
4390
|
//#endregion
|
|
3967
|
-
//#region ../../apps/api/src/permissions/policy.ts
|
|
3968
|
-
const EMPTY_MASK = {
|
|
3969
|
-
mode: "only",
|
|
3970
|
-
fieldIds: []
|
|
3971
|
-
};
|
|
3972
|
-
const ALLOW_ALL_POLICY = Object.freeze({
|
|
3973
|
-
allowed: true,
|
|
3974
|
-
rowFilter: null,
|
|
3975
|
-
readMask: { mode: "all" },
|
|
3976
|
-
writeMask: { mode: "all" }
|
|
3977
|
-
});
|
|
3978
|
-
const WRITE_ACTIONS = /* @__PURE__ */ new Set(["create", "update"]);
|
|
3979
|
-
const grantsFor = (grants, action, modelId) => grants.filter((grant) => grant.action === action && (grant.modelId === null || grant.modelId === modelId));
|
|
3980
|
-
/** Rows: the union of what each grant allows. Any unconditional grant means no restriction. */
|
|
3981
|
-
const combineRowFilters = (grants) => {
|
|
3982
|
-
if (grants.some((grant) => grant.condition === null)) return null;
|
|
3983
|
-
const filters = [...new Set(grants.map((grant) => grant.condition))].filter((kind) => kind !== null).map((kind) => ({ kind }));
|
|
3984
|
-
if (filters.length === 1 && filters[0]) return filters[0];
|
|
3985
|
-
return {
|
|
3986
|
-
kind: "or",
|
|
3987
|
-
filters
|
|
3988
|
-
};
|
|
3989
|
-
};
|
|
3990
|
-
/**
|
|
3991
|
-
* Fields: the union of what each grant allows. For delivery audiences a wildcard grant expands to the
|
|
3992
|
-
* model's public fields, so `public: false` fields stay hidden unless a grant names them. Delivery masks
|
|
3993
|
-
* are always explicit lists, so a field added after evaluation is never exposed by accident.
|
|
3994
|
-
*/
|
|
3995
|
-
const combineFieldMasks = (grants, audience, fields) => {
|
|
3996
|
-
if (grants.length === 0) return EMPTY_MASK;
|
|
3997
|
-
const hasWildcard = grants.some((grant) => grant.fieldIds === null);
|
|
3998
|
-
if (audience === "admin" && hasWildcard) return { mode: "all" };
|
|
3999
|
-
const named = new Set(grants.flatMap((grant) => grant.fieldIds ?? []));
|
|
4000
|
-
if (audience === "delivery") return {
|
|
4001
|
-
mode: "only",
|
|
4002
|
-
fieldIds: (fields ?? []).filter((field) => named.has(field.id) || hasWildcard && field.public).map((field) => field.id)
|
|
4003
|
-
};
|
|
4004
|
-
return {
|
|
4005
|
-
mode: "only",
|
|
4006
|
-
fieldIds: [...named].sort()
|
|
4007
|
-
};
|
|
4008
|
-
};
|
|
4009
|
-
/**
|
|
4010
|
-
* The policy for one (action, model) request from a principal's grants. Pure: no I/O, so it is unit- and
|
|
4011
|
-
* property-tested directly. Deny by default: no matching grant, no access.
|
|
4012
|
-
*/
|
|
4013
|
-
const buildPolicy = (grants, request, audience, fields) => {
|
|
4014
|
-
if (audience === "delivery" && fields === void 0) return DENIED_POLICY;
|
|
4015
|
-
const matching = grantsFor(grants, request.action, request.modelId);
|
|
4016
|
-
if (matching.length === 0) return DENIED_POLICY;
|
|
4017
|
-
const readGrants = request.action === "read" ? matching : grantsFor(grants, "read", request.modelId);
|
|
4018
|
-
return {
|
|
4019
|
-
allowed: true,
|
|
4020
|
-
rowFilter: combineRowFilters(matching),
|
|
4021
|
-
readMask: combineFieldMasks(readGrants, audience, fields),
|
|
4022
|
-
writeMask: WRITE_ACTIONS.has(request.action) ? combineFieldMasks(matching, audience, fields) : EMPTY_MASK
|
|
4023
|
-
};
|
|
4024
|
-
};
|
|
4025
|
-
/** Global actions are granted only by a grant on that action with no model. */
|
|
4026
|
-
const allowsGlobalAction = (grants, action) => grants.some((grant) => grant.action === action && grant.modelId === null);
|
|
4027
|
-
//#endregion
|
|
4028
4391
|
//#region ../../apps/api/src/permissions/evaluator.ts
|
|
4029
4392
|
const NETWORK_ACTION_SET = new Set(NETWORK_ACTIONS);
|
|
4030
4393
|
/** Delivery tokens read, and read drafts where their role grants `readDrafts`. */
|
|
@@ -4032,11 +4395,12 @@ const DELIVERY_TOKEN_ACTIONS = new Set(DELIVERY_ACTIONS);
|
|
|
4032
4395
|
/** App users and anonymous callers: never `readDrafts`, whatever a role says. */
|
|
4033
4396
|
const APP_ACTIONS = new Set(APP_CONTENT_ACTIONS);
|
|
4034
4397
|
/**
|
|
4035
|
-
* Which roles a principal holds and how its masks are computed.
|
|
4036
|
-
*
|
|
4037
|
-
* site
|
|
4038
|
-
*
|
|
4039
|
-
* grant names
|
|
4398
|
+
* Which roles a principal holds and how its masks are computed. Anonymous callers hold the roles the
|
|
4399
|
+
* request's site binds to `public` (`site_app_roles`: a site that binds nothing grants nothing, and an
|
|
4400
|
+
* anonymous caller outside a site holds no role). `public` is a floor: app users hold those roles too, plus
|
|
4401
|
+
* the roles their site binds to `authenticated` and the custom app roles assigned to them, so signing in never
|
|
4402
|
+
* takes anything away. Both are delivery audiences: they see `public: false` fields only where a grant names
|
|
4403
|
+
* them.
|
|
4040
4404
|
*/
|
|
4041
4405
|
const rolesOf = async (principal, grants, executor, versions) => {
|
|
4042
4406
|
switch (principal.kind) {
|
|
@@ -4059,7 +4423,11 @@ const rolesOf = async (principal, grants, executor, versions) => {
|
|
|
4059
4423
|
actions: DELIVERY_TOKEN_ACTIONS
|
|
4060
4424
|
};
|
|
4061
4425
|
case "appUser": return {
|
|
4062
|
-
roleIds: [
|
|
4426
|
+
roleIds: [.../* @__PURE__ */ new Set([
|
|
4427
|
+
...await grants.getSiteAppRoleIds(principal.siteId, "public", executor, versions),
|
|
4428
|
+
...await grants.getSiteAppRoleIds(principal.siteId, "authenticated", executor, versions),
|
|
4429
|
+
...principal.roleIds
|
|
4430
|
+
])],
|
|
4063
4431
|
networkRoleIds: [],
|
|
4064
4432
|
audience: "delivery",
|
|
4065
4433
|
actions: APP_ACTIONS
|