@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/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-COlJQc1r.js";
2
- import { i as isValidStorageKey, n as ObjectNotFoundError, r as encodeKeyPath, t as InvalidStorageKeyError } from "./errors-SK0PwPKC.js";
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.6";
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
- writeMask: EMPTY_MASK$1
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: SeqSchema,
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: ({ name, siteId, modelId, seq }) => sql`with ${sql.id(name)} as (
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: ({ name, siteId, modelId, seq }) => sql`with ${sql.id(name)} as (
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: ({ name, siteId, modelId, seq }) => sql`with ${sql.id(name)} as (
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
- return asset ? [asset] : [];
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
- const fieldCondition = (target, operator, value, dialect) => {
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.readMask, field)) return null;
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, fields, row, data) : data
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.isReadable(field)) throw queryForbidden("You may not search this model", { field: field.apiKey });
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, query } = read;
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.readMask, field));
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
- /** Meta every delivery response carries; draft reads add `publicationState` (published ones are unchanged). */
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.readMask
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
- if (query.from < 0 || query.from > to) throw queryInvalid("\"from\" must be between 0 and \"to\"");
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: query.from,
3490
+ from,
3071
3491
  to,
3072
3492
  after: query.after,
3073
3493
  limit: boundedLimit(query.limit),
3074
- modelIds: [...byId.keys()],
3075
- rowFilter: compileModelRowFilters(readable.map((model) => ({
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, [query.from, to], trx);
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: query.from,
3500
+ from,
3084
3501
  to,
3085
3502
  schemaVersions: {
3086
- from: versions.get(query.from) ?? null,
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
- const withRole = (trx) => trx.selectFrom("api_tokens").innerJoin("admin_roles", "admin_roles.id", "api_tokens.role_id").select(LIST_COLUMNS);
3336
- /** A usable token for this hash: not revoked. Expiry is checked by the caller. */
3337
- const findLiveByHash = (tokenHash, trx = db) => withRole(trx).where("api_tokens.token_hash", "=", tokenHash).where("api_tokens.revoked_at", "is", null).executeTakeFirst();
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 expired. */
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-CIKnDfZy.js");
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 state read. Admin-scope tokens are
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-BqCKtI3Y.js");
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. App users hold the custom app roles assigned
4036
- * to them plus the roles their site binds to `authenticated`; anonymous callers hold the roles the request's
4037
- * site binds to `public` (`site_app_roles`: a site that binds nothing grants nothing, and an anonymous caller
4038
- * outside a site holds no role). Both are delivery audiences: they see `public: false` fields only where a
4039
- * grant names them.
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: [...await grants.getSiteAppRoleIds(principal.siteId, "authenticated", executor, versions), ...principal.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