@mohasinac/appkit 4.75.0 → 4.77.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "stampVersion": 1,
3
- "version": "4.75.0",
4
- "head": "c5426d7535f8b4331464486d52298a6f668168ee",
3
+ "version": "4.77.0",
4
+ "head": "ee1768658b3ff57e36d0b1dee443bb5cdb9dcea7",
5
5
  "branch": "main",
6
- "dirty": true,
7
- "builtAt": "2026-11-09T07:08:58.762Z"
6
+ "dirty": false,
7
+ "builtAt": "2026-11-09T10:56:48.056Z"
8
8
  }
@@ -58,5 +58,37 @@ export interface SitemapOptions {
58
58
  * below exists to catch.
59
59
  */
60
60
  excludeSections?: readonly SitemapSection[];
61
+ /**
62
+ * Skip the SHOP's surface-visibility policy — for a host that OWNS the
63
+ * sections it is emitting.
64
+ *
65
+ * ## 🛑 Why this is necessary and not a loophole
66
+ *
67
+ * `keep()` below withholds any URL under a closed browse section, an
68
+ * unlisted listing-type route, or a `closedSurfacePrefix` derived from
69
+ * `siteSettings.disabledRoutes`. That is exactly right for the shop: a
70
+ * surface it has switched off must not be advertised.
71
+ *
72
+ * It is exactly WRONG for an extracted subdomain, and the reason is almost
73
+ * funny: the shop disables `/blog` **because the blog moved to
74
+ * `blogs.<domain>`**. The subdomain then reads the same settings document,
75
+ * sees `/blog` disabled, and withholds its own content.
76
+ *
77
+ * Measured on 2026-11-09, from the blogs build log:
78
+ *
79
+ * sitemap: built {"sections":{"blog":16},"total":0}
80
+ *
81
+ * Sixteen posts fetched, every one filtered out, and a valid empty
82
+ * `<urlset>` served with HTTP 200. The pages themselves rendered perfectly
83
+ * throughout — only the sitemap was gone.
84
+ *
85
+ * 🛑 It does NOT skip the section scope. `sections`/`excludeSections` still
86
+ * apply, so this cannot be used to make one host emit another's URLs. It
87
+ * only stops the SHOP's visibility flags from governing a host the shop does
88
+ * not serve.
89
+ *
90
+ * 🛑 Leave it UNSET on the shop. There, those flags are the whole point.
91
+ */
92
+ ownsSections?: boolean;
61
93
  }
62
- export declare function buildSitemap({ baseUrl, sections: only, excludeSections, }: SitemapOptions): Promise<MetadataRoute.Sitemap>;
94
+ export declare function buildSitemap({ baseUrl, sections: only, excludeSections, ownsSections, }: SitemapOptions): Promise<MetadataRoute.Sitemap>;
@@ -536,7 +536,7 @@ async function fetchScammerUrls(baseUrl) {
536
536
  return sitemapSectionFailed("scammer", err);
537
537
  }
538
538
  }
539
- export async function buildSitemap({ baseUrl, sections: only, excludeSections, }) {
539
+ export async function buildSitemap({ baseUrl, sections: only, excludeSections, ownsSections = false, }) {
540
540
  /*
541
541
  * Which sections this host emits. See `SitemapOptions.sections` for why a
542
542
  * host must be able to answer this at all.
@@ -697,6 +697,12 @@ export async function buildSitemap({ baseUrl, sections: only, excludeSections, }
697
697
  */
698
698
  const closedPrefixes = closedSurfacePrefixes(settings);
699
699
  const keep = (entry) => {
700
+ /*
701
+ * A host that OWNS its sections is not governed by the shop's visibility
702
+ * flags — see `ownsSections`. The section scope above still applies.
703
+ */
704
+ if (ownsSections)
705
+ return true;
700
706
  const path = entry.url.startsWith("http")
701
707
  ? new URL(entry.url).pathname
702
708
  : entry.url;
@@ -1,9 +1,3 @@
1
- /**
2
- * Event Domain Actions (appkit/features/events)
3
- *
4
- * Business logic for event CRUD, status changes, entries, and public access.
5
- * Auth, rate-limiting, and input validation are handled by the calling server action.
6
- */
7
1
  import type { JsonValue } from "../../../schemas/types";
8
2
  import type { EventDocument, EventEntryDocument, EventEntryMedia } from "../schemas";
9
3
  import type { EventStatus } from "../types";
@@ -1,3 +1,4 @@
1
+ import { resolveEntryApproval } from "../schemas/firestore.js";
1
2
  /**
2
3
  * Event Domain Actions (appkit/features/events)
3
4
  *
@@ -510,11 +511,43 @@ export async function enterEvent(eventId, input, user, request) {
510
511
  * pool — that is the moderation path, not a standing review queue of every
511
512
  * entrant.
512
513
  */
513
- const autoApprove = event.type === "poll" ||
514
+ /*
515
+ * ⚠ LEGACY FALLBACK ONLY — do not add a type to this expression.
516
+ *
517
+ * This was the whole decision until 2026-11-09, and it is `event.type ===`
518
+ * branching: the exact shape the governing principle calls a defect. It also
519
+ * meant a new type could not choose — `contest` is absent, so contest
520
+ * entries were moderated by accident, and changing that needed a release.
521
+ *
522
+ * It survives ONLY so events already in the database behave exactly as they
523
+ * did. `resolveEntryApproval` consults it last. A NEW type declares its
524
+ * default in `event-types-seed-data.ts`; a single event overrides with
525
+ * `entryApproval` on its own row. Delete this once every type has a default.
526
+ */
527
+ const legacyAutoApprove = event.type === "poll" ||
514
528
  event.type === "feedback" ||
515
529
  eventType === "giveaway" ||
516
530
  (event.type === "survey" &&
517
531
  !event.surveyConfig?.entryReviewRequired);
532
+ /*
533
+ * The event's own setting wins, then the type's default, then the legacy
534
+ * expression. Anything unrecognised resolves to MODERATED — fail closed, so
535
+ * newer config against an older deploy cannot start publishing unreviewed
536
+ * user content.
537
+ */
538
+ /*
539
+ * ⚠ The TYPE-level default is passed as `undefined` for now. The precedence
540
+ * chain is built for three levels — event row, then type default, then the
541
+ * legacy expression — and `event-types-seed-data.ts` is where a type's
542
+ * default belongs, beside the `ruleSets` it already carries.
543
+ *
544
+ * Reading it here needs the type definition loaded at this point, which this
545
+ * action does not currently do. Stated rather than faked: the EVENT-level
546
+ * override the owner asked for works completely; the type-level default is
547
+ * the next step and slots into the middle argument with no other change.
548
+ */
549
+ const approvalMode = resolveEntryApproval(event, undefined, legacyAutoApprove);
550
+ const autoApprove = approvalMode === "auto";
518
551
  const reviewStatus = autoApprove ? "approved" : "pending";
519
552
  const entry = await eventEntryRepository.createEntry({
520
553
  eventId: resolvedEventId,
@@ -124,6 +124,38 @@ export interface EventDocument extends BaseDocument {
124
124
  */
125
125
  isPinned?: boolean;
126
126
  /** Test-data flag — set on the shared admin-seeded test event; swept by testDataCleanup. */
127
+ /**
128
+ * Who may publish an entry: nobody (`"moderated"`) or the entrant
129
+ * (`"auto"`). Set ON THE EVENT, per owner 2026-11-09.
130
+ *
131
+ * ## 🛑 This replaces a hardcoded type switch
132
+ *
133
+ * `enterEvent` decided this with:
134
+ *
135
+ * const autoApprove =
136
+ * event.type === "poll" || event.type === "feedback" ||
137
+ * eventType === "giveaway" ||
138
+ * (event.type === "survey" && !surveyConfig?.entryReviewRequired);
139
+ *
140
+ * That is `event.type === …` branching, which the governing principle names
141
+ * as a defect rather than a safeguard: *"a mechanism never asks what TYPE is
142
+ * this — it asks what CAPABILITY does this have"*. It also meant a NEW event
143
+ * type could not choose: `contest` is absent from that list, so contest
144
+ * entries were moderated by accident rather than by decision, and changing
145
+ * it would have required an appkit release.
146
+ *
147
+ * Now it is a value on the row. A moderated photo contest and an
148
+ * auto-approving one are two seed rows, not two code paths.
149
+ *
150
+ * 🛑 OPEN union, deliberately — `(string & {})` keeps autocomplete while
151
+ * allowing a mode this release has never heard of. `resolveEntryApproval`
152
+ * treats anything unknown as MODERATED, which is the fail-closed direction:
153
+ * an unrecognised setting must not publish unreviewed user content.
154
+ *
155
+ * Unset falls back to the type's default, then to the legacy expression, so
156
+ * no existing event changes behaviour. See `resolveEntryApproval`.
157
+ */
158
+ entryApproval?: EntryApprovalMode;
127
159
  isTestData?: boolean;
128
160
  /** When isTestData, the cutoff after which testDataCleanup deletes this doc. */
129
161
  testDataExpiresAt?: Date;
@@ -492,4 +524,35 @@ export declare const eventQueryHelpers: {
492
524
  readonly withRaffle: () => readonly ["hasRaffle", "==", true];
493
525
  readonly raffleNotYetDrawn: () => readonly ["raffleWinnerUserId", "==", null];
494
526
  };
527
+ /**
528
+ * How an event decides whether a submitted entry is published immediately.
529
+ *
530
+ * 🛑 OPEN, not a closed enum. The platform ships two modes; a deployment
531
+ * that needs a third ("auto below N entries", "auto for verified buyers")
532
+ * writes its own slug and a resolver rule rather than waiting for a release.
533
+ */
534
+ export type EntryApprovalMode = "auto" | "moderated" | (string & {});
535
+ /**
536
+ * Resolve the approval mode for one entry submission.
537
+ *
538
+ * Order, most specific first:
539
+ * 1. `event.entryApproval` — this event's own decision
540
+ * 2. `typeDefault` — the event TYPE's default, from seed config
541
+ * 3. the legacy expression — see below
542
+ *
543
+ * 🛑 FAILS CLOSED. Anything not recognised as `"auto"` resolves to
544
+ * moderated. An unknown mode arriving from newer config against an older
545
+ * deploy must not start publishing unreviewed user content — degrading to a
546
+ * review queue is recoverable, publishing is not.
547
+ *
548
+ * ⚠ The third fallback is a MIGRATION PATH, not a design. It reproduces the
549
+ * hardcoded type switch this field replaced so that events already in the
550
+ * database keep behaving exactly as they did. It should be deleted once every
551
+ * event type declares its own default in `event-types-seed-data.ts`.
552
+ */
553
+ export declare function resolveEntryApproval(event: {
554
+ entryApproval?: EntryApprovalMode;
555
+ type?: string;
556
+ surveyConfig?: unknown;
557
+ }, typeDefault?: EntryApprovalMode, legacyAutoApprove?: boolean): "auto" | "moderated";
495
558
  export {};
@@ -140,3 +140,27 @@ export const eventQueryHelpers = {
140
140
  withRaffle: () => ["hasRaffle", "==", true],
141
141
  raffleNotYetDrawn: () => ["raffleWinnerUserId", "==", null],
142
142
  };
143
+ /**
144
+ * Resolve the approval mode for one entry submission.
145
+ *
146
+ * Order, most specific first:
147
+ * 1. `event.entryApproval` — this event's own decision
148
+ * 2. `typeDefault` — the event TYPE's default, from seed config
149
+ * 3. the legacy expression — see below
150
+ *
151
+ * 🛑 FAILS CLOSED. Anything not recognised as `"auto"` resolves to
152
+ * moderated. An unknown mode arriving from newer config against an older
153
+ * deploy must not start publishing unreviewed user content — degrading to a
154
+ * review queue is recoverable, publishing is not.
155
+ *
156
+ * ⚠ The third fallback is a MIGRATION PATH, not a design. It reproduces the
157
+ * hardcoded type switch this field replaced so that events already in the
158
+ * database keep behaving exactly as they did. It should be deleted once every
159
+ * event type declares its own default in `event-types-seed-data.ts`.
160
+ */
161
+ export function resolveEntryApproval(event, typeDefault, legacyAutoApprove) {
162
+ const chosen = event.entryApproval ?? typeDefault;
163
+ if (chosen !== undefined)
164
+ return chosen === "auto" ? "auto" : "moderated";
165
+ return legacyAutoApprove ? "auto" : "moderated";
166
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mohasinac/appkit",
3
- "version": "4.75.0",
3
+ "version": "4.77.0",
4
4
  "license": "MIT",
5
5
  "publishConfig": {
6
6
  "access": "public"