Directory
Directory engineStableTome local-directory layer — markets, listings, claims, reviews, favorites, promotions, and an inbound menu-item seam, plus a defineListingType registry and a DirectoryTenantPolicy contract so vertical rules live in the consuming site, never in the layer.
Get a registry token from your credentials page. You need a purchase that includes this package, or a Craft Library membership.
Add the registry and your token to the
.npmrcat the root of your project, with your token in place ofYOUR_TOKEN:@wabbit:registry=https://npm.wabbit.com/ //npm.wabbit.com/:_authToken=YOUR_TOKENThen install:
npm install @wabbit/tome-directory
Overview
@wabbit/tome-directory
Tome local-directory layer — markets, listings, claims, reviews, favorites, promotions, and an inbound menu-item seam, plus a defineListingType registry so a consumer site adds real per-type schema without touching the layer, and a DirectoryTenantPolicy contract so vertical rules live in the consuming site, never in the layer. Description copied verbatim from package.json.
Layer: domain (per ARCHITECTURE.md), family directory, tier pro. Depends on @wabbit/tome-core (identity, auth, fields/address, fields/slug), @wabbit/tome-accounts (the claimed-owner tenant), @wabbit/tome-workflow (claimTransition drives claim/review/promotion state), @wabbit/tome-local (the open-state utility); optional @wabbit/tome-economy (tier subscriptions — paid listing tiers require the Commerce engine), optional @wabbit/tome-forms / @wabbit/tome-intake for a guided claim-submission front end.
Install
pnpm add @wabbit/tome-directoryPeer ranges, copied from package.json:
| Peer | Range | Optional? | |---|---|---| | payload | >=3.67.0 | no | | @wabbit/tome-core | >=1.17.0 <2.0.0 | no | | @wabbit/tome-local | >=0.1.0 <1.0.0 | no — computeOpenState is @wabbit/tome-local/hours' open-state utility, re-exported from ./server. | | @wabbit/tome-accounts | >=0.3.0 <1.0.0 | no | | @wabbit/tome-economy | >=0.10.0 <1.0.0 | yes — needed for paid listing tiers, which require the Commerce engine. Only the ./economy subpath imports it; the root entry and every other subpath load without it. | | @wabbit/tome-workflow | >=0.3.0 <1.0.0 | no | | @wabbit/tome-forms | >=0.3.0 <1.0.0 | yes | | @wabbit/tome-intake | >=0.3.0 <1.0.0 | yes | | react | >=19.0.0 | yes — type-only (DirectoryMapProvider.MapView's ComponentType). The layer renders nothing itself; concrete map providers ship in @wabbit/tome-blocks-directory-pack. |
60-second quickstart
The example below is a generic trades directory (plumbers, electricians, HVAC — no licensing or age-gate compliance weight) on purpose. A regulated vertical (cannabis, medical, alcohol) supplies its own DirectoryTenantPolicy with the age gate turned on and its real registry and content rules.
import { buildConfig } from 'payload'
import {
createDirectoryLayer,
defineListingType,
defineDirectoryTenantPolicy,
} from '@wabbit/tome-directory'
// Paid listing tiers only — requires the Commerce engine (@wabbit/tome-economy
// installed). A directory with no paid tiers drops this import and the
// registerEconomySubscriptionHandlers call below.
import { registerDirectorySubscriptionHandlers } from '@wabbit/tome-directory/economy'
const tradesShop = defineListingType({
value: 'trades-shop',
label: 'Trades Shop',
labelPlural: 'Trades Shops',
typeFields: [
{ name: 'trade', type: 'select', options: ['plumbing', 'electrical', 'hvac'], required: true },
{ name: 'licensedInsured', type: 'checkbox' },
],
requiredRegistry: false,
detailRoute: '/trades-shop/[slug]',
})
const tenantPolicy = defineDirectoryTenantPolicy({
// sponsoredLabel defaults to 'Sponsored'
ageGate: { enabled: false, minAge: 18, mode: 'dob', logResultNotDob: true },
registry: {
name: 'State Contractor License Board',
sourceUrl: 'https://example.gov/contractor-lookup',
requireVerifiedLicenseForTypes: ['trades-shop'],
reverifyEveryDays: 180,
},
content: { prohibitedPatterns: [], requiredDisclaimers: [], allowPriceDisplay: true, imageReviewQueue: false },
reviews: {
requireLogin: true,
requireVerifiedVisitForBadge: false,
insiderDisclosureRequired: true,
moderation: 'post',
maxPerAuthorPerListingPerDays: 30,
},
promotions: { requireEndDate: true, paidSurfacesOwnerSourceOnly: true },
privacy: { storeDob: false, preciseGeolocation: false, adPixelsOnListingPages: false },
dataRetention: { menuItemTtlHours: 24, gateEventRetentionDays: 90 },
// billing.graceDays defaults to 7
})
const directory = createDirectoryLayer({
membersSlug: 'members',
accountsSlug: 'accounts',
membershipsSlug: 'memberships',
listingTypes: [tradesShop],
tenantPolicy,
// REQUIRED — the version `claimBeforeValidate`/the claim-submission flow
// requires acceptedTerms.version to equal exactly.
currentTermsVersion: '1',
// REQUIRED only when tenantPolicy.ageGate.enabled is true — this trades
// example leaves the gate off, so it is omitted here. A regulated vertical
// supplies a real secret from server env, never a literal in source.
// ageGateSecret: process.env.DIRECTORY_AGE_GATE_SECRET,
})
directory.registerHandlers.onListingClaimed(async (event) => {
// send a welcome email, unlock upsell copy, etc.
})
export default buildConfig({
// db, secret, editor, admin: as in any Payload config
collections: [...existingCollections, ...directory.collections],
endpoints: [...existingEndpoints, ...directory.endpoints],
onInit: async (payload) => {
// Paid listing tiers: wires the four economy subscription-lifecycle
// handlers so a checkout/renewal/cancel/payment-failed event stamps
// `tier` on the right listing. See "Subscription checkout contract"
// below for the `metadata` shape a directory checkout MUST pass.
directory.registerHandlers.registerEconomySubscriptionHandlers(payload, registerDirectorySubscriptionHandlers)
},
})Server-side data access comes from the /server subpath:
import { findListingsNear, resolveListingEntitlements } from '@wabbit/tome-directory/server'API surface
Six export subpaths: . (config-time — factory, access, claims, moderation, age-gate, event bus, hooks context/geohash, jobs, fields, photo attestation, tenant-policy registry, types), ./server (server-only data access, marked with server-only), ./maps (map/geocoder provider types only — no runtime code), ./menu (the inbound menu-publish HMAC seam), ./endpoints (one factory, createDirectoryEndpoints, which returns the layer's four Payload endpoints), ./economy (subscription-lifecycle tier stamping — the only subpath that imports the optional @wabbit/tome-economy peer; it is not re-exported from the root).
| Group | Exports | |---|---| | Layer factory | createDirectoryLayer, composeTypeGroups | | Collection factories | createMarketsCollection, createListingsCollection, createClaimsCollection, createReviewsCollection, createFavoritesCollection, createPromotionsCollection, createMenuItemsCollection, createListingStatsCollection, createPhotoAttestationsCollection (+ their *CollectionConfig types). Called without an access bundle, every factory fails closed: each operation is staff-only (directoryStaff). Public reads (status-filtered listings, markets, reviews, promotions) come from the real bundle — pass access (e.g. from createDirectoryAccess) or use createDirectoryLayer, which always does. | | Listing-type registry | defineListingType, listingTypeRegistry, registerListingType, getListingType, hasListingType, getAllListingTypes, resetListingTypeRegistry | | Tenant policy | defineDirectoryTenantPolicy, DEFAULT_ENTITLEMENTS, pickEntitlementTable (the per-listing-type ladder lookup) (+ DirectoryTenantPolicyInput, DirectoryEntitlementTable) | | Tenant-policy registry | setActiveTenantPolicy, getActiveTenantPolicy, resetActiveTenantPolicy — createDirectoryLayer calls setActiveTenantPolicy once; server query functions that omit their own tenantPolicy argument fall back to it | | Event bus | onListingClaimed/dispatchListingClaimed, onTierChanged/dispatchTierChanged, onReviewApproved/dispatchReviewApproved, onPromotionPublished/dispatchPromotionPublished, onListingSuspended/dispatchListingSuspended, resetDirectoryEventBus | | Access | directoryStaff, directoryStaffFieldLevel, directoryStaffAccess(ctx?) and lockedFieldAccessFor(ctx?) (the context-aware factories that honour a custom staffPredicate), isStaffUser, isSystemWrite, listingOwnerAccess, lockedFieldAccess, publicStatusOrStaff, createDirectoryAccess, DIRECTORY_LOCKED_LISTING_FIELDS, buildListingFieldLocks, publicListingProjection, ownership resolvers (resolveCallerMember, resolveOwnedAccountIds, resolveOwnedListingIds, resolvePublishedListingIds), helpers relId(value) (a relationship's id from a raw id, { id } or populated doc; null when absent) and sha256Hex(input), types DirectoryLayerContext, DirectoryLockedListingField, DirectoryPublicListing, DirectoryAccessBundle, DirectoryAccessBundles | | Claims | submitClaim, verifyClaim, approveClaim, rejectClaim, revokeClaim, requestOwnershipTransfer (+ their arg/result types) | | Moderation | approveReview, rejectReview, removeReview, respondToReview, reportReview, plus the takedown shape/transitions/stores — see "Takedown store" below | | Age gate | verifyAgeGate, createAgeGateCookie/readAgeGateCookie, createAgeGateVerifyHandler, isAgeGatePassed | | Hooks (context + geohash only) | resolveHookContext, isStaffOrAdmin (async — delegates to isStaffUser, so super-admin, un-populated role ids and a custom staffPredicate all count; await it), isHookSystemWrite (aliased — ./access's isSystemWrite is canonical), FALLBACK_TENANT_POLICY, encodeGeohashLocal. Everything else under src/hooks/** (fieldLock, hours, contentScan, counters, the per-collection hook factories) is internal collection-schema plumbing, not part of the public surface. | | Jobs | createDirectoryJobs (the one-call factory; see Scheduled jobs for which jobs are conditional) + each individual create*Job/build*Handler, incl. createPruneListingStatsJob/buildPruneListingStatsHandler/computeRetentionCutoff | | Fields | directoryHoursField, directoryHoursOverridesField, directoryGeoField, directoryRegistryRefField, directoryMenuPublishField | | Photo attestation | attachListingPhoto, detachListingPhoto, approveListingPhoto, rejectListingPhoto (+ their arg/result types) | | Content scanner | scanForProhibitedContent, assertNoProhibitedContent | | Version | DIRECTORY_LAYER_VERSION | | Slugs | DIRECTORY_DEFAULT_SLUGS, DirectorySlugs (incl. listingStats, photoAttestations) | | Types | DirectoryListingType, DirectoryMarket, DirectoryMarketSeed, DirectoryListing, DirectoryClaim, DirectoryReview, DirectoryFavorite, DirectoryPromotion, DirectoryMenuItem, DirectoryEntitlements, DirectoryTenantPolicy (+ nested policy shapes, incl. advertiserTermsVersion), DirectoryLayerConfig (incl. currentTermsVersion, ageGateSecret, isVerifiedCrawler, registryVerify), DirectoryLayer, JobConfig, event payload types, DirectoryEventHandlers (incl. registerEconomySubscriptionHandlers), DirectoryEconomySubscriptionRegistrar, DirectoryEventType, DirectoryEventSource, DirectoryEventSurface, DirectoryAnalyticsConfig, DirectoryListingStats, DirectoryPhotoAttestation, and every literal-union alias (DirectoryTier, DirectoryClaimStatus, etc.) | | ./server | Queries: findListingsNear, getListingBySlug, getOpenNowCount (both read in bounded pages through core's findPaged, never one limit: 0 scan; findListingsNear also narrows each query to a lat/lng bounding box around the radius, and its haversine post-filter still decides membership, so results are unchanged), getLivePromotions, getListingReviews, toggleFavorite, submitReview, submitClaim, publishMenu, getListingStats, getListingPlacement, getRotationShare. Entitlements: resolveListingEntitlements (reads the listing and subscription) and computeListingEntitlements (the same decision as a pure function, for a caller that already holds the listing and a batch-resolved subscription). Geo: encodeGeohash, decodeGeohashBounds, geohashNeighbors, geohashPrefixesForRadius (the center cell plus its eight neighbours at a radius-appropriate precision, local math only), haversineDistanceMiles, DIRECTORY_GEOHASH_PRECISION. Hours: computeOpenState (re-exported from @wabbit/tome-local/hours: every weekly row for a day counts, so split shifts work, and an override matches its date whether stored as YYYY-MM-DD or as the ISO instant Payload persists; the result adds closesAt, current, next and closedLabel to openNow / closesInMinutes / opensAt). Browser code imports @wabbit/tome-local/hours directly. Ranking: rankListings, rotationKey ((dayOfYear + hash(listingId)) % bandSize), hashListingId (FNV-1a 32-bit), dayOfYearUTC, assertPremierSlotAvailable (throws PremierSlotUnavailableError when the market already has another premier listing; call it before stamping premier), assertTierSlotAvailable, assertTierCapsForListing, capsForTier, TierSlotUnavailableError, TierSlotScopeMissingError, TierScopeTooSmallError. Each query's *Args/*Result types are exported alongside, plus LatLng, GeohashBounds, OpenState, OpenWindow, RankableListing, ListingBillingSnapshot, SubscriptionStatusSnapshot. | | ./maps | DirectoryMapProvider, DirectoryGeocoder, DirectoryMapPin (incl. slug/href — the listing's detail route, for a map provider's default click-to-navigate) (+ their supporting arg/result types) | | ./menu | mintMenuPublishKey, revokeMenuPublishKey, fingerprintMenuPublishKey, verifyMenuSignature, validatePublishMenuItems, rateLimit, createInMemoryRateLimitStore | | ./endpoints | createDirectoryEndpoints (now four endpoints — see below) | | ./economy | registerDirectorySubscriptionHandlers — requires @wabbit/tome-economy (the Commerce engine) |
Endpoints
createDirectoryEndpoints returns four Payload Endpoints, spread into DirectoryLayer.endpoints:
| Endpoint | Purpose | |---|---| | POST /directory/menus/:listingId/publish | inbound menu push — per-listing HMAC key |
Menu-publish hardening. Pass DirectoryLayerConfig.menuPublish (all options optional; omitted, the endpoint behaves as before) to harden the inbound push. entitlementGate(entitlements, listing) refuses a push with a public 403 (menu-publish-not-entitled) unless it returns true; it runs after the signature is verified. validateItem(item, { listingId, index }) runs per item after the structural checks and may return a reason string, a string array, or false to reject that item, for example from a prohibited-content scan. maxItemsPerPush and maxBodyBytes refuse oversized pushes with a 413. itemLimits adds maxNameLength, maxVariantsPerItem and integerPrices. mode: 'replace' makes a push a snapshot: that listing's push-sourced rows (source: 'api') absent from the push are deleted by id, owner-sourced rows are never touched, and a push whose every item was rejected deletes nothing. One bad item no longer aborts a push: invalid items come back in a rejected list ({ externalId, index, reasons }) beside accepted and skipped, and the rest are written. rejected appears in the response only when non-empty, removed only in replace mode. revokeMenuPublishKey({ payload, listingId }) is the counterpart to mintMenuPublishKey: it disables publishing and clears the key and secret. menuPublish.ttlSources limits the purge job to rows whose source carries a TTL (for example ['api']), so owner-typed rows are never purged; omitted, the job purges every expired row as before. | POST /directory/age-gate/verify | server-side DOB check → signed directory_ag cookie | | POST /directory/claims | claim submission — requires a signed-in session (see below) | | POST /directory/events | analytics event ingestion — { events: [{ listing, type, source?, surface?, kind? }] } (kind is required for the outbound type), max 20 events, always 204. Bot user-agent deny-list plus the optional DirectoryLayerConfig.isVerifiedCrawler(req) hook; drops sponsored events for a listing that isn't currently sponsored; per-IP in-memory rate limit (the IP is never persisted). |
Claims auth. POST /directory/claims resolves the claimant from the session, never from the body: req.user → the members row whose user points at it (core's resolveMemberFromSession, against DirectoryLayerConfig.membersSlug — createDirectoryEndpoints takes it as membersSlug, default 'members'). No session user → 401. A signed-in user with no member row → 403. The body may still carry claimantMember, but it must equal the resolved member id; anything else is refused with 403, never silently rewritten. The body supplies listing, verificationMethod, optional licenseNumberSubmitted/evidence, and acceptedTerms.version. The rest of acceptedTerms is server-derived: acceptedAt is stamped at submission and ip is read from x-forwarded-for (first hop), then x-real-ip, else 'unknown'. Body-supplied acceptedAt/ip are ignored. A caller that posts from the server on a visitor's behalf (a Next server action, say) must forward the visitor's cookie and x-forwarded-for, or the claim records the server's own address. The other three endpoints are deliberately session-free: menu publish is HMAC-authenticated, while the age gate and the events beacon are anonymous by design. Each one says so in a // public-endpoint: comment, which pnpm assert:endpoint-auth enforces.
Scheduled jobs
createDirectoryJobs returns the jobs below as JobConfig[], ready to spread into DirectoryLayer.jobs; each is also available individually as create*Job. It returns four jobs unconditionally (expirePromotions, purgeExpiredMenuItems, recomputeRatingAggregates, expireTierGrace), adds reverifyRegistry only when you pass a registryVerify lookup, and adds pruneListingStats only when you pass listingStatsSlug. createDirectoryLayer always passes listingStatsSlug and passes registryVerify through from DirectoryLayerConfig.registryVerify, so DirectoryLayer.jobs has five jobs, or six when you supply a verifier. Every endpoint.path is mounted under Payload's config-root endpoints, which Payload itself serves under /api — so the path below is relative to that, e.g. expirePromotions's default resolves to POST /api/directory/jobs/expire-promotions, never /api/api/directory/jobs/expire-promotions. Pass path to override any one of them.
| Job | Default endpoint.path | Default schedule | |---|---|---| | createExpirePromotionsJob | /directory/jobs/expire-promotions | hourly | | createExpireTierGraceJob | /directory/jobs/expire-tier-grace | daily at 04:00 | | createPurgeExpiredMenuItemsJob | /directory/jobs/purge-expired-menu-items | hourly | | createRecomputeRatingAggregatesJob | /directory/jobs/recompute-rating-aggregates | nightly at 02:00 | | createReverifyRegistryJob | /directory/jobs/reverify-registry | daily at 03:00; re-checks each listing every tenantPolicy.registry.reverifyEveryDays days. Included only when DirectoryLayerConfig.registryVerify (your licence-register lookup) is supplied — see below | | createPruneListingStatsJob | /directory/jobs/prune-listing-stats | daily — deletes directory-listing-stats rows older than 400 days |
Each endpoint is CRON_SECRET-guarded (@wabbit/tome-core/jobs's asCronEndpoint) for an external scheduler; a consumer on Payload's own jobs framework instead wraps that job module's build*Handler with asPayloadTask. The schedules are the intended cadence; nothing registers them for you.
Registry re-verification needs a verifier. The reverify-registry job checks licences through a registryVerify lookup (your call to the licensing register). Pass it as DirectoryLayerConfig.registryVerify (or createDirectoryJobs({ ..., registryVerify })); without one the job is omitted from DirectoryLayer.jobs entirely rather than shipped as a job that scans nothing, and createDirectoryLayer logs a one-time warning when tenantPolicy.registry.requireVerifiedLicenseForTypes is non-empty, because licences would then never be re-verified. requireVerifiedLicenseForTypes still gates claims at submission time either way; only the periodic re-check depends on the verifier.
Per-type ladders, scoped caps, analytics keys and photo pre-review
Five opt-in options for a directory that lists more than one kind of listing. Every one is optional; omit them and a layer behaves exactly as before.
A separate entitlement ladder per listing type. tenantPolicy.entitlementsByListingType maps a listing type value to its own complete tier table (all five tiers; defineDirectoryTenantPolicy rejects a partial one, and createDirectoryLayer rejects a key that is not a registered type). A listing resolves against the table for its type, else tenantPolicy.entitlements, else DEFAULT_ENTITLEMENTS. Every internal caller reads the listing's listingType off the listing document, so pass the whole document to computeListingEntitlements (the listing argument now carries an optional listingType). When a per-type ladder is configured and a lookup is handed a listing with no listingType, it falls back to the default ladder and logs a one-time development warning. respondToReview takes tenantPolicy (or entitlementsByListingType) for the same reason.
tenantPolicy: defineDirectoryTenantPolicy({
// ...
entitlementsByListingType: { maker: makerLadder }, // every tier defined
})Type-scoped, multiple caps per tier. A tierCaps value may be one cap or a list, and a cap may name listingTypes. A cap with listingTypes applies only to listings of those types and counts only listings of those types, so featured listings of another type never use up its slots. Every cap that applies to the listing being written is enforced, on both write paths (the listings hook for staff and manual stamps, and the subscription handlers). assertTierCapsForListing runs them in one place and assertTierSlotAvailable takes a listingTypes filter.
tierCaps: {
premier: [
{ listingTypes: ['venue'], scope: { field: 'address.city' }, max: 1 },
{ listingTypes: ['maker'], scope: { field: 'makerDetails.category' }, max: 1 },
],
featured: [{ listingTypes: ['venue'], scope: { field: 'address.city' }, max: 6 }],
}A field-scoped cap fails closed: when the listing has no value at the scope's field, the write throws TierSlotScopeMissingError (code: 'tier-slot-scope-missing') instead of counting listings that also have no value. A cap with no listingTypes still applies to, and counts, every type, so existing configs are unchanged.
A minimum population per scope. Give a cap minInScope to sell its tier only where the scope is big enough: the tier can be newly taken only when the scope holds at least that many published listings (status: 'published'), counting only the cap's listingTypes when set and counting the listing being written when it is itself published. Below the minimum the write throws TierScopeTooSmallError (code: 'tier-scope-too-small', carrying tier, scope, min and count). The check is for acquisition only: a renewal or re-stamp of a tier the listing already holds skips it (the max check still runs), so a sitting holder is never evicted when its scope shrinks. Both write paths pass the listing's stored tier as currentTier to assertTierCapsForListing.
tierCaps: {
premier: { scope: { field: 'address.city' }, max: 1, minInScope: 2 },
}Analytics keys. DirectoryLayerConfig.analytics adds to the built-in vocabulary: extraViewSources (accepted as a view event's source), extraSponsoredSurfaces (accepted as a sponsored event's surface) and outboundKinds (accepted as an outbound event's kind, each counted in an outboundClicks.<kind> group). Keys must be plain identifiers because they become stored field names. directory-listing-stats builds its groups from the built-ins plus your extras, and outboundClicks exists only when you set outboundKinds. getListingStats returns the extras, plus sponsoredImpressions, sponsoredClicks and outboundClicks; pass it the same analytics object to get zeroed entries for keys with no traffic. getListingPlacement and getRotationShare keep reporting the four built-in surfaces. Because the stored groups change, a SQL-backed consumer needs a migration when it first adds keys.
Listing-photo pre-review. tenantPolicy.content.listingPhotoReview: { listingTypes: string[] | 'all' } holds new photos for staff review on those listing types. attachListingPhoto then writes the media to a locked pendingPhotos field (an upload field the layer adds only when this option is set) and marks the attestation reviewStatus: 'pending'; the photo is not public. approveListingPhoto moves it into photos and rejectListingPhoto drops it, each stamping the attestation approved or rejected and keeping the row. maxPhotos counts live and pending photos together, and for a reviewed type an owner's direct write to photos can only remove photos. attachListingPhoto accepts bypassReview for a trusted staff or system caller. Pass req and ctx to the review functions to require a staff caller.
The prohibited-content scan the layer's own hooks use is exported as scanForProhibitedContent and assertNoProhibitedContent, so a server action can apply the same tenantPolicy.content rules to text the layer's collections never see.
Owner analytics, placement reporting, and photo attestation
Three collections' worth of surface, additive to v1:
- `directory-listing-stats` — one row per listing per LOCAL calendar day (market timezone), counts only (no IP/UA/cookie/member/session id ever stored). Written exclusively via
POST /directory/events; read = staff, or the owning membership whenresolveListingEntitlements().analyticsis true (Listed tier and up). - `directory-photo-attestations` — the licence record for a claim-time photo grant:
{ listing, media, member, termsVersion, attestedAt }. Create-only viaattachListingPhoto, which checks ownership, themaxPhotosentitlement, and thatattestation.termsVersionmatchestenantPolicy.advertiserTermsVersion.detachListingPhotoremoves the photo fromlisting.photosbut keeps the attestation row. - `getListingStats`/`getListingPlacement`/`getRotationShare` (
./server) — the owner dashboard's Analytics and Placement panels.getListingStatsandgetListingPlacementare entitlement-checked and returnnull(not an error) when the listing's plan doesn't include the relevant capability.getRotationSharenever returnsnull: it reports{ tier: 'premier', exclusive: true }for Premier (capped at one live subscription per market),{ tier, sharedWith: 0, share: 0 }for any listing that is not a sponsored Featured listing, and otherwise the listing's share of sponsored impressions against its Featured siblings.
A tenant wiring this in must: (1) set tenantPolicy.advertiserTermsVersion before any photo attachment is possible; (2) optionally supply DirectoryLayerConfig.isVerifiedCrawler for a stronger crawler signal than the built-in UA deny-list; (3) mount @wabbit/tome-blocks-directory-pack/track's beacon on any custom listing-detail/grid page not built from this pack's own blocks; (4) describe the counters (aggregate, no identifiers, GPC honoured) in the site's privacy policy.
Listing types are schema contributions, not dropdown labels
defineListingType + the registry (Symbol.for('@wabbit/tome-directory/listing-type-registry')) close the gap @wabbit/tome-catalog's ProductTypeRegistry leaves open: a registered type there is a label; here, typeFields is composed into a real ${value}Details group on directory-listings (composeTypeGroups, called by createDirectoryLayer). The layer ships zero built-in types — registering a bare delivery type is a modeling error, because delivery is a tags attribute, never a listing type.
Tenant policy is where vertical rules live
Every cannabis-specific (or alcohol-, or firearms-, or any regulated-vertical-specific) rule is data on a DirectoryTenantPolicy object the CONSUMING SITE builds with defineDirectoryTenantPolicy — never an if (vertical === 'x') branch in this layer. A handful of fields carry literal-typed compliance invariants the type system will not let a tenant relax: ageGate.mode: 'dob', ageGate.logResultNotDob: true, promotions.requireEndDate: true, promotions.paidSurfacesOwnerSourceOnly: true, privacy.storeDob: false, privacy.preciseGeolocation: false, privacy.adPixelsOnListingPages: false. defineDirectoryTenantPolicy adds the runtime checks TypeScript cannot express (ageGate.minAge integer ≥ 13, ageGate.alternateClass.minAge ≤ minAge, ageGate.rememberDays in 1–365, billing.graceDays ≥ 0) and fills the documented defaults (sponsoredLabel: 'Sponsored', ageGate.rememberDays: 30, billing.graceDays: 7, entitlements: DEFAULT_ENTITLEMENTS).
createDirectoryLayer also publishes the resolved policy to a globalThis-anchored singleton (setActiveTenantPolicy) — any server query function that omits its own tenantPolicy argument (a route handler that only has payload/listingId in hand) still resolves the REAL policy via getActiveTenantPolicy(), falling back to DEFAULT_ENTITLEMENTS/'Sponsored' only when no layer has been constructed in that module instance at all.
Fail-secure entitlements
resolveListingEntitlements resolves in a fixed order — tier → subscription/manual confirmation → tenantPolicy.entitlements[tier] (or DEFAULT_ENTITLEMENTS) → fail-secure to the unpaid floor (tier: 'unpaid') on anything missing, expired, or suspended. A lapsed subscription silently keeping a "Sponsored" placement is treated as a compliance failure, not just a billing one.
unpaid is the ONLY tier this ever fails secure to. claimed used to BE that floor (hardcoded free, exempt from the billing check) — since 1.0.0, claimed is a real paid tier, resolved through the exact same active-subscription/manual-billing gate as listed/featured/premier. Ownership (ownerAccount) is a separate axis entirely and is NOT billing-gated: an owner can always correct factual fields (address, phone, hours, licence number) with no subscription, even at tier: 'unpaid' — only the MARKETING surface (photos, promotions, owner review responses, rank boost, menu sync, sponsored placement) is entitlement-gated. See ../server/entitlements.ts's module header and ../claims/approveClaim.ts's "THE CLAIM-PAYMENT SEAM" header for the full design.
A tenant with free claiming (the only mode before 1.0.0) is unaffected: approveClaim still grants claimed in the same call that establishes ownership, by stamping a brand-new owner account billing.provider: 'manual' — the same staff-stamped bypass this module already had for a comped account. Nothing changes for that tenant unless it opts into approveClaim({ requirePayment: true }).
Subscription checkout contract
registerDirectorySubscriptionHandlers (import it from @wabbit/tome-directory/economy and pass it to registerHandlers.registerEconomySubscriptionHandlers(payload, registerDirectorySubscriptionHandlers) once from your Payload onInit, see the quickstart; that call binds your listings slug, economy.subscriptionsSlug and tenantPolicy.billing.graceDays. Called without it, paid-tier stamping is disabled with a one-time console warning, never a crash) resolves the listing and tier being purchased off the economy Subscriptions row's own metadata field, not off the event object. A directory checkout must pass:
createSubscriptionCheckoutAction({
// ...price/customer args per @wabbit/tome-economy
metadata: {
userId, // required by economy
productId, // required by economy
productType, // required by economy
listingId, // REQUIRED by this layer — the listing being upgraded
tier, // REQUIRED — 'claimed' | 'listed' | 'featured' | 'premier', never 'unpaid'
},
})Missing metadata.listingId/tier, or tier: 'unpaid', means the lifecycle handler silently no-ops (it cannot stamp a listing it cannot resolve) — verify this contract in an integration test before wiring a real checkout. claimed is sellable since 1.0.0 — a tenant that charges for its entry tier (e.g. a $49/mo cannabis-directory claim fee) configures economy.tierPriceMap.claimed and calls approveClaim({ requirePayment: true }); the owner then completes checkout with metadata.tier: 'claimed' exactly like any other tier, and this handler's subscription.complete branch stamps it.
Takedown store
The moderation takedown shape has no eighth collection backing it — the layer ships the TakedownStore interface plus two implementations:
createInMemoryTakedownStore()— a plain-Map-backed store, for tests or a single-process demo. Not persisted.createPayloadTakedownStore({ payload, slug })— persists to a consumer-supplied Payload collection. That collection needs fields matchingDirectoryTakedownNotice:noticeId(text, unique),listingId(relationship or text),accountId(relationship or text, optional),claimant(text),status(select:received | actioned | counter-noticed | reinstated | rejected),receivedAt(date),actionedAt(date, optional).
import { createPayloadTakedownStore, recordTakedownNotice } from '@wabbit/tome-directory'
const store = createPayloadTakedownStore({ payload, slug: 'directory-takedown-notices' })
await recordTakedownNotice({ store, payload, listingsSlug: 'directory-listings', listingId, claimant: 'Rights Holder LLC' })No vertical-specific code
Nothing in src/ references cannabis, THC, or dispensaries by name — the examples above use a generic trades directory on purpose. A regulated tenant supplies its whole policy through DirectoryTenantPolicy in its own repo.
Exports
@wabbit/tome-directory@wabbit/tome-directory/server@wabbit/tome-directory/maps@wabbit/tome-directory/menu@wabbit/tome-directory/endpoints@wabbit/tome-directory/economy
Changelog
99c0490: Tier caps can now require a minimum population: give a `tierCaps` entry `minInScope` and the tier can only be newly taken where the cap's scope holds at least that many published listings. The count covers published listings (`status: 'published'`) in the same market and, for a field-scoped cap, the same field value; it counts only the cap's `listingTypes` when set, and includes the listing being written when it is itself published. Below the minimum the write throws the new `TierScopeTooSmallError` (`code: 'tier-scope-too-small'`, HTTP 409, carrying `tier`, `scope`, `min` and `count`), exported from `@wabbit/tome-directory/server`. The rule applies to acquisition only: `assertTierCapsForListing` takes an optional `currentTier`, and when it equals the tier being written (a renewal or a re-stamp) the minimum is skipped while the `max` check still runs, so a sitting holder is never evicted when its scope shrinks. Both write paths (the listings hook and the subscription handlers) pass the listing's stored tier. Caps without `minInScope` behave exactly as before.
- 99c0490: Tier caps can now require a minimum population: give a `tierCaps` entry `minInScope` and the tier can only be newly taken where the cap's scope holds at least that many published listings. The count covers published listings (`status: 'published'`) in the same market and, for a field-scoped cap, the same field value; it counts only the cap's `listingTypes` when set, and includes the listing being written when it is itself published. Below the minimum the write throws the new `TierScopeTooSmallError` (`code: 'tier-scope-too-small'`, HTTP 409, carrying `tier`, `scope`, `min` and `count`), exported from `@wabbit/tome-directory/server`. The rule applies to acquisition only: `assertTierCapsForListing` takes an optional `currentTier`, and when it equals the tier being written (a renewal or a re-stamp) the minimum is skipped while the `max` check still runs, so a sitting holder is never evicted when its scope shrinks. Both write paths (the listings hook and the subscription handlers) pass the listing's stored tier. Caps without `minInScope` behave exactly as before.
775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
- 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
09f80cb: A menu push can now be hardened without breaking existing integrations: one bad item no longer aborts the push, and a site can gate, validate, size-limit and snapshot-replace what a store publishes. Every addition is optional, so an existing config behaves identically; the response only gains a `rejected` list when something was rejected. - Per-item validation. Each pushed item is checked on its own, and the response lists rejected items with reasons (`{ externalId, index, reasons }`) beside `accepted` and `skipped`. The new `validatePublishMenuItems` is exported from `./menu`. - `DirectoryLayerConfig.menuPublish` takes `entitlementGate` (a public 403 unless it returns true), `validateItem` (a per-item callback, e.g. a content scan), `maxItemsPerPush` and `maxBodyBytes` (413), `itemLimits`, and `mode: 'replace'`, where a push replaces that listing's push-sourced rows and never touches owner-sourced ones. - `revokeMenuPublishKey` disables publishing and clears the key, the counterpart to `mintMenuPublishKey`. - `publishMenu` throws a public `APIError` (403, `menu-publish-disabled`) when publishing is off, and the endpoint now returns public errors with their own status and code. - `menuPublish.ttlSources` limits the hourly purge to rows whose `source` carries a TTL, so owner-typed rows with a consumer-managed retention date are never purged. Omitted, the job still purges every expired row. * An unchanged re-pushed item now refreshes its `publishedAt` and `expiresAt` (still counted as `skipped`), so a store pushing the same menu daily no longer has it age out at the TTL. * Security: the client address can no longer be spoofed through `x-forwarded-for`. The events rate limit and the claims terms-acceptance IP used the leftmost entry, which the caller chooses. They now use `trustedProxyHops` entries from the right (default 1, the rightmost, which the nearest proxy appended). New `DirectoryLayerConfig.clientIp(req)` lets a site supply the address from a header its edge guarantees, and takes precedence; `trustedProxyHops: 0` ignores forwarding headers. Behaviour change: behind a CDN plus a load balancer the default now sees the CDN-side address, so set `trustedProxyHops: 2` or a `clientIp` callback to keep per-client rate limiting and an accurate terms-acceptance IP. The default is 1 hop because it is the only value that is safe without knowing the deployment, and a wrong guess towards too few hops only coarsens the limiter, while too many would trust client input. * Security: the menu-publish body is now capped before it is buffered. `Content-Length` is checked first, then the body is read as a stream and aborted with a 413 the moment it passes the cap. When `menuPublish.maxBodyBytes` is unset the endpoint now applies a built-in 5 MB ceiling instead of being unbounded; a site that pushes larger menus must raise `maxBodyBytes`. * Security: the menu-publish endpoint no longer reveals which authentication check failed. An unknown listing (was 404), publishing disabled or no key set, a wrong key id (was 403) and a bad or stale signature (was 401 with a `reason`) all return the same 401 `{ "error": "authentication failed" }`, and the early exits do the same HMAC work so timing does not differ either. The reason is logged server-side. The entitlement gate stays a 403 (it runs after authentication), and 413 and 429 are unchanged. Publishers that branched on the old 404 or 403 must treat 401 as "check the listing id, key and signature".
- 09f80cb: A menu push can now be hardened without breaking existing integrations: one bad item no longer aborts the push, and a site can gate, validate, size-limit and snapshot-replace what a store publishes. Every addition is optional, so an existing config behaves identically; the response only gains a `rejected` list when something was rejected. - Per-item validation. Each pushed item is checked on its own, and the response lists rejected items with reasons (`{ externalId, index, reasons }`) beside `accepted` and `skipped`. The new `validatePublishMenuItems` is exported from `./menu`. - `DirectoryLayerConfig.menuPublish` takes `entitlementGate` (a public 403 unless it returns true), `validateItem` (a per-item callback, e.g. a content scan), `maxItemsPerPush` and `maxBodyBytes` (413), `itemLimits`, and `mode: 'replace'`, where a push replaces that listing's push-sourced rows and never touches owner-sourced ones. - `revokeMenuPublishKey` disables publishing and clears the key, the counterpart to `mintMenuPublishKey`. - `publishMenu` throws a public `APIError` (403, `menu-publish-disabled`) when publishing is off, and the endpoint now returns public errors with their own status and code. - `menuPublish.ttlSources` limits the hourly purge to rows whose `source` carries a TTL, so owner-typed rows with a consumer-managed retention date are never purged. Omitted, the job still purges every expired row. * An unchanged re-pushed item now refreshes its `publishedAt` and `expiresAt` (still counted as `skipped`), so a store pushing the same menu daily no longer has it age out at the TTL. * Security: the client address can no longer be spoofed through `x-forwarded-for`. The events rate limit and the claims terms-acceptance IP used the leftmost entry, which the caller chooses. They now use `trustedProxyHops` entries from the right (default 1, the rightmost, which the nearest proxy appended). New `DirectoryLayerConfig.clientIp(req)` lets a site supply the address from a header its edge guarantees, and takes precedence; `trustedProxyHops: 0` ignores forwarding headers. Behaviour change: behind a CDN plus a load balancer the default now sees the CDN-side address, so set `trustedProxyHops: 2` or a `clientIp` callback to keep per-client rate limiting and an accurate terms-acceptance IP. The default is 1 hop because it is the only value that is safe without knowing the deployment, and a wrong guess towards too few hops only coarsens the limiter, while too many would trust client input. * Security: the menu-publish body is now capped before it is buffered. `Content-Length` is checked first, then the body is read as a stream and aborted with a 413 the moment it passes the cap. When `menuPublish.maxBodyBytes` is unset the endpoint now applies a built-in 5 MB ceiling instead of being unbounded; a site that pushes larger menus must raise `maxBodyBytes`. * Security: the menu-publish endpoint no longer reveals which authentication check failed. An unknown listing (was 404), publishing disabled or no key set, a wrong key id (was 403) and a bad or stale signature (was 401 with a `reason`) all return the same 401 `{ "error": "authentication failed" }`, and the early exits do the same HMAC work so timing does not differ either. The reason is logged server-side. The entitlement gate stays a 403 (it runs after authentication), and 413 and 429 are unchanged. Publishers that branched on the old 404 or 403 must treat 401 as "check the listing id, key and signature".
- ab5a194: Tier-cap, entitlement and content-scan refusals now reach the person who triggered them instead of a generic 500 "Something went wrong". These refusals threw plain `Error`s, which Payload masks. They are now Payload `APIError`s with `isPublic: true` and a status: 409 for a full tier slot, a taken slug, or a photo or live-promotion cap; 403 for a tier that is not eligible for Deal of the Day; 400 for a missing end date, an unscoped cap field, or prohibited content. Each carries a machine-readable `data.code`: `photo-cap-exceeded`, `promotion-cap-exceeded`, `deal-of-day-ineligible`, `promotion-end-date-required`, `prohibited-content`, `slug-taken`. `TierSlotUnavailableError`, `TierSlotScopeMissingError` and `PremierSlotUnavailableError` now extend `APIError`. They keep their names, `code`s, fields and `instanceof` behaviour, and every message is unchanged. No exports or signatures change.
e5ee256: A directory with several listing types can now give each type its own entitlement ladder and its own, type-scoped slot caps, and can hold listing photos for staff review. Every addition is optional, so an existing config behaves identically. - **Per-type ladders.** `tenantPolicy.entitlementsByListingType` maps a listing type to its own complete tier table. A listing resolves against its type's table, then `entitlements`, then `DEFAULT_ENTITLEMENTS`. `ListingBillingSnapshot` gains an optional `listingType`; every internal caller passes it, and a lookup that is handed a listing without one logs a one-time development warning when per-type ladders are configured. `respondToReview` accepts `tenantPolicy` or `entitlementsByListingType`. - **Type-scoped, multiple caps.** `DirectoryTierCap` gains `listingTypes`, and a `tierCaps` value may be one cap or a list. A scoped cap applies to, and counts, only its own listing types. This also fixes a latent over-count: featured listings of one type no longer used up another type's slots once the cap is scoped. New exports: `assertTierCapsForListing`, `capsForTier`, and a `listingTypes` filter on `assertTierSlotAvailable`. - **Fail-closed field scope.** A field-scoped cap whose field is empty on the listing now throws `TierSlotScopeMissingError` (`code: 'tier-slot-scope-missing'`) instead of querying for a missing value. - **Analytics keys.** `DirectoryLayerConfig.analytics` accepts `extraViewSources`, `extraSponsoredSurfaces` and `outboundKinds`. The events endpoint accepts them and a new `outbound` event type with a validated `kind`; `directory-listing-stats` builds its groups from the built-ins plus your keys and adds `outboundClicks` only when you set `outboundKinds`. `getListingStats` returns the extra keys plus `sponsoredImpressions`, `sponsoredClicks` and `outboundClicks`. - **Photo pre-review.** `tenantPolicy.content.listingPhotoReview` holds new photos for the named listing types in a locked `pendingPhotos` field until staff call the new `approveListingPhoto` or `rejectListingPhoto`. `maxPhotos` counts both fields, and an owner's direct write to `photos` can only remove photos for a reviewed type. `attachListingPhoto` gains `bypassReview` and a `placement` result. - **Content scanner.** `scanForProhibitedContent` and `assertNoProhibitedContent` are now exported. Type note: `DirectoryTierCaps` values widen from `DirectoryTierCap` to `DirectoryTierCap | readonly DirectoryTierCap[]`. Config literals still compile; code that reads a cap's fields straight off `tierCaps[tier]` must now handle the list form (use `capsForTier`). The `viewSources` type on stats rows and results widens to include any registered key. Storage note: the new `pendingPhotos`, review and `outboundClicks` fields exist only when you opt in, so an existing schema is unchanged. A SQL-backed consumer needs a migration when it first adds them.
- e5ee256: A directory with several listing types can now give each type its own entitlement ladder and its own, type-scoped slot caps, and can hold listing photos for staff review. Every addition is optional, so an existing config behaves identically. - **Per-type ladders.** `tenantPolicy.entitlementsByListingType` maps a listing type to its own complete tier table. A listing resolves against its type's table, then `entitlements`, then `DEFAULT_ENTITLEMENTS`. `ListingBillingSnapshot` gains an optional `listingType`; every internal caller passes it, and a lookup that is handed a listing without one logs a one-time development warning when per-type ladders are configured. `respondToReview` accepts `tenantPolicy` or `entitlementsByListingType`. - **Type-scoped, multiple caps.** `DirectoryTierCap` gains `listingTypes`, and a `tierCaps` value may be one cap or a list. A scoped cap applies to, and counts, only its own listing types. This also fixes a latent over-count: featured listings of one type no longer used up another type's slots once the cap is scoped. New exports: `assertTierCapsForListing`, `capsForTier`, and a `listingTypes` filter on `assertTierSlotAvailable`. - **Fail-closed field scope.** A field-scoped cap whose field is empty on the listing now throws `TierSlotScopeMissingError` (`code: 'tier-slot-scope-missing'`) instead of querying for a missing value. - **Analytics keys.** `DirectoryLayerConfig.analytics` accepts `extraViewSources`, `extraSponsoredSurfaces` and `outboundKinds`. The events endpoint accepts them and a new `outbound` event type with a validated `kind`; `directory-listing-stats` builds its groups from the built-ins plus your keys and adds `outboundClicks` only when you set `outboundKinds`. `getListingStats` returns the extra keys plus `sponsoredImpressions`, `sponsoredClicks` and `outboundClicks`. - **Photo pre-review.** `tenantPolicy.content.listingPhotoReview` holds new photos for the named listing types in a locked `pendingPhotos` field until staff call the new `approveListingPhoto` or `rejectListingPhoto`. `maxPhotos` counts both fields, and an owner's direct write to `photos` can only remove photos for a reviewed type. `attachListingPhoto` gains `bypassReview` and a `placement` result. - **Content scanner.** `scanForProhibitedContent` and `assertNoProhibitedContent` are now exported. Type note: `DirectoryTierCaps` values widen from `DirectoryTierCap` to `DirectoryTierCap | readonly DirectoryTierCap[]`. Config literals still compile; code that reads a cap's fields straight off `tierCaps[tier]` must now handle the list form (use `capsForTier`). The `viewSources` type on stats rows and results widens to include any registered key. Storage note: the new `pendingPhotos`, review and `outboundClicks` fields exist only when you opt in, so an existing schema is unchanged. A SQL-backed consumer needs a migration when it first adds them.
0181593: **BREAKING:** `@wabbit/tome-local` is a new required peer. `computeOpenState` now comes from `@wabbit/tome-local/hours`, the shared open-state utility. **Migration:** install `@wabbit/tome-local` (`>=0.1.0 <1.0.0`) alongside `@wabbit/tome-directory`. No code change is needed: - `computeOpenState`, `ComputeOpenStateArgs`, `OpenState` and `normalizeOverrideDate` are still exported. - Every call site keeps its signature. - The stored `hours` / `hoursOverrides` shape is unchanged. - **Relation to 1.4.1:** the split-shift and override-date fixes shipped in 1.4.1 carry over unchanged. All of 1.4.1's open-state tests pass against the shared utility. - **Fix — DST spring-forward gap.** A window that crossed the spring-forward gap (for example 18:00–02:00 on the change night) closed an hour early. It now resolves by checking the offsets on both sides of the change. Ambiguous fall-back times resolve as before. - **Fix — touching windows.** Windows that meet at midnight merge, so a place open through midnight reports its real closing time. - **Additive:** - `OpenState` gains `closesAt`, `current`, `next` and `closedLabel`. - `OpenWindow` is exported from `./server`. - `DirectoryDayOfWeek` is now an alias of the identical `LocalDayOfWeek` union. - **Unchanged:** save-time hours validation keeps its current lenient semantics. The stricter wrap-aware `validateWeeklyHours` in `@wabbit/tome-local/hours` is opt-in.
- 0181593: **BREAKING:** `@wabbit/tome-local` is a new required peer. `computeOpenState` now comes from `@wabbit/tome-local/hours`, the shared open-state utility. **Migration:** install `@wabbit/tome-local` (`>=0.1.0 <1.0.0`) alongside `@wabbit/tome-directory`. No code change is needed: - `computeOpenState`, `ComputeOpenStateArgs`, `OpenState` and `normalizeOverrideDate` are still exported. - Every call site keeps its signature. - The stored `hours` / `hoursOverrides` shape is unchanged. - **Relation to 1.4.1:** the split-shift and override-date fixes shipped in 1.4.1 carry over unchanged. All of 1.4.1's open-state tests pass against the shared utility. - **Fix — DST spring-forward gap.** A window that crossed the spring-forward gap (for example 18:00–02:00 on the change night) closed an hour early. It now resolves by checking the offsets on both sides of the change. Ambiguous fall-back times resolve as before. - **Fix — touching windows.** Windows that meet at midnight merge, so a place open through midnight reports its real closing time. - **Additive:** - `OpenState` gains `closesAt`, `current`, `next` and `closedLabel`. - `OpenWindow` is exported from `./server`. - `DirectoryDayOfWeek` is now an alias of the identical `LocalDayOfWeek` union. - **Unchanged:** save-time hours validation keeps its current lenient semantics. The stricter wrap-aware `validateWeeklyHours` in `@wabbit/tome-local/hours` is opt-in.
615b309: Fixes split-shift hours and holiday-override date matching in listing open/closed status. A listing with split-shift hours (e.g. a lunch break) now correctly shows open during EVERY window for the day, not just the first one. Holiday/exception overrides now correctly match the calendar day the admin UI's date picker actually stores, instead of silently never matching. Both bugs were in `computeOpenState` (`server/openNow.ts`), which every open-now computation in this package funnels through — `findListingsNear`, `getListingBySlug`, and `getOpenNowCount`. No API or field-shape change; existing `hours`/`hoursOverrides` data does not need to be migrated. A new exported helper, `normalizeOverrideDate`, handles the override-date normalization and is documented for anyone matching `hoursOverrides` dates directly. The admin UI's `hoursOverrides.date` field now also hides its time picker (`pickerAppearance: 'dayOnly'`), since the stored value was never meant to carry a time of day.
- 615b309: Fixes split-shift hours and holiday-override date matching in listing open/closed status. A listing with split-shift hours (e.g. a lunch break) now correctly shows open during EVERY window for the day, not just the first one. Holiday/exception overrides now correctly match the calendar day the admin UI's date picker actually stores, instead of silently never matching. Both bugs were in `computeOpenState` (`server/openNow.ts`), which every open-now computation in this package funnels through — `findListingsNear`, `getListingBySlug`, and `getOpenNowCount`. No API or field-shape change; existing `hours`/`hoursOverrides` data does not need to be migrated. A new exported helper, `normalizeOverrideDate`, handles the override-date normalization and is documented for anyone matching `hoursOverrides` dates directly. The admin UI's `hoursOverrides.date` field now also hides its time picker (`pickerAppearance: 'dayOnly'`), since the stored value was never meant to carry a time of day.
2651355: **BREAKING:** promotion cap/eligibility now apply to every actor, including staff. New `DirectoryLayerConfig.tierCaps` generalizes the Premier-only slot cap to any tier and scope. `assertTierSlotAvailable` (new export, `@wabbit/tome-directory/server`) generalizes the previously-uncalled `assertPremierSlotAvailable` (kept, unchanged, as a thin wrapper) to any tier, scoped to a market or to one dot-path field within a market (e.g. one Featured slot per city), with any max. `DirectoryLayerConfig.tierCaps` configures it — default when omitted: `{ premier: { scope: 'market', max: 1 } }`, matching today's documented intent. The check now actually runs, in two write paths: `registerDirectorySubscriptionHandlers` (before stamping a subscription-driven tier change) and the listings `beforeChange` hook (whenever `tier` changes to a capped tier — staff admin stamps, manual billing). Both paths enforce the same `tierCaps` object; `createDirectoryLayer` forwards it to both automatically. **BREAKING:** `createPromotionBeforeValidateHook`'s live-count cap (`maxLivePromotions`) and `dealOfDay` eligibility check used to be skipped outright for a staff or system-authored write. They now apply to every actor — a staff admin approving or publishing an owner's promotion must respect the owner's own tier cap. The only bypass is an explicit `req.context.bypassPromotionCap === true`, set on purpose (a migration or one-off internal script), never inferred from `systemWrite` or a staff role. **Migration:** a consumer with a staff/admin flow that publishes promotions on an owner's behalf and relies on it skipping the cap must now set `context: { bypassPromotionCap: true }` on that specific write if bypassing is still wanted; otherwise the cap now applies as it always should have. Promotion-cap/eligibility and review-response entitlement checks are now billing-resolved: both `createPromotionBeforeValidateHook` and `respondToReview` resolve entitlements through `resolveListingEntitlements` (the same billing-aware path photos/analytics already used), instead of a static lookup keyed by the stored `tier` field. A lapsed subscription now loses these perks immediately, even though the listing's stored `tier` is still stamped high, rather than waiting for the grace-expiry job to catch up. `findListingsNear`'s decorated listings (and `@wabbit/tome-blocks-directory-pack`'s map pins, via `resolveDirectoryMapPins`) now carry an additive `resolvedTier` field — the billing-resolved tier, which can differ from the stored `tier`. A consumer reading a decorated listing's tier for display should prefer `resolvedTier ?? tier`.
- 2651355: **BREAKING:** promotion cap/eligibility now apply to every actor, including staff. New `DirectoryLayerConfig.tierCaps` generalizes the Premier-only slot cap to any tier and scope. `assertTierSlotAvailable` (new export, `@wabbit/tome-directory/server`) generalizes the previously-uncalled `assertPremierSlotAvailable` (kept, unchanged, as a thin wrapper) to any tier, scoped to a market or to one dot-path field within a market (e.g. one Featured slot per city), with any max. `DirectoryLayerConfig.tierCaps` configures it — default when omitted: `{ premier: { scope: 'market', max: 1 } }`, matching today's documented intent. The check now actually runs, in two write paths: `registerDirectorySubscriptionHandlers` (before stamping a subscription-driven tier change) and the listings `beforeChange` hook (whenever `tier` changes to a capped tier — staff admin stamps, manual billing). Both paths enforce the same `tierCaps` object; `createDirectoryLayer` forwards it to both automatically. **BREAKING:** `createPromotionBeforeValidateHook`'s live-count cap (`maxLivePromotions`) and `dealOfDay` eligibility check used to be skipped outright for a staff or system-authored write. They now apply to every actor — a staff admin approving or publishing an owner's promotion must respect the owner's own tier cap. The only bypass is an explicit `req.context.bypassPromotionCap === true`, set on purpose (a migration or one-off internal script), never inferred from `systemWrite` or a staff role. **Migration:** a consumer with a staff/admin flow that publishes promotions on an owner's behalf and relies on it skipping the cap must now set `context: { bypassPromotionCap: true }` on that specific write if bypassing is still wanted; otherwise the cap now applies as it always should have. Promotion-cap/eligibility and review-response entitlement checks are now billing-resolved: both `createPromotionBeforeValidateHook` and `respondToReview` resolve entitlements through `resolveListingEntitlements` (the same billing-aware path photos/analytics already used), instead of a static lookup keyed by the stored `tier` field. A lapsed subscription now loses these perks immediately, even though the listing's stored `tier` is still stamped high, rather than waiting for the grace-expiry job to catch up. `findListingsNear`'s decorated listings (and `@wabbit/tome-blocks-directory-pack`'s map pins, via `resolveDirectoryMapPins`) now carry an additive `resolvedTier` field — the billing-resolved tier, which can differ from the stored `tier`. A consumer reading a decorated listing's tier for display should prefer `resolvedTier ?? tier`.
77651c7: Directory listing pages work on a site without the Commerce engine. `getListingBySlug`, `findListingsNear`, `getLivePromotions` and `resolveListingEntitlements` read economy's `subscriptions` collection to decide whether a paid tier is live. Economy has been an optional peer since 1.3.0, so that collection may not exist, and the three queries threw on any listing with a paid tier. They now skip the lookup when the collection isn't registered. A paid tier then resolves as it does with no subscription row: the unpaid floor, unless the owner account's billing provider is `manual`, which keeps the tier.
- 77651c7: Directory listing pages work on a site without the Commerce engine. `getListingBySlug`, `findListingsNear`, `getLivePromotions` and `resolveListingEntitlements` read economy's `subscriptions` collection to decide whether a paid tier is live. Economy has been an optional peer since 1.3.0, so that collection may not exist, and the three queries threw on any listing with a paid tier. They now skip the lookup when the collection isn't registered. A paid tier then resolves as it does with no subscription row: the unpaid floor, unless the owner account's billing provider is `manual`, which keeps the tier.
cbd3961: `@wabbit/tome-economy` is now an optional peer, so you can install the directory without the Commerce engine; paid listing tiers require it. **If you sell paid listing tiers (one-line change on upgrade):** install `@wabbit/tome-economy` yourself, import `registerDirectorySubscriptionHandlers` (and its `RegisterDirectorySubscriptionHandlersArgs`/`RegisterDirectorySubscriptionHandlersResult` types) from `@wabbit/tome-directory/economy` instead of the package root, and pass it as the second argument: `directory.registerHandlers.registerEconomySubscriptionHandlers(payload, registerDirectorySubscriptionHandlers)`. If you don't sell paid tiers, remove that call. - The package root no longer re-exports `registerDirectorySubscriptionHandlers` or its types, and nothing reachable from the root (or from `./server`, `./maps`, `./menu`, `./endpoints`) imports `@wabbit/tome-economy`. Only `./economy` does. - `registerEconomySubscriptionHandlers` now takes the registrar as a required second argument (typed as the new `DirectoryEconomySubscriptionRegistrar`). Called without one, it logs a one-time warning that paid listing tiers are disabled and returns a no-op `unsubscribe`, rather than throwing. - Manually stamped tiers and fail-secure entitlement resolution are unchanged.
- cbd3961: `@wabbit/tome-economy` is now an optional peer, so you can install the directory without the Commerce engine; paid listing tiers require it. **If you sell paid listing tiers (one-line change on upgrade):** install `@wabbit/tome-economy` yourself, import `registerDirectorySubscriptionHandlers` (and its `RegisterDirectorySubscriptionHandlersArgs`/`RegisterDirectorySubscriptionHandlersResult` types) from `@wabbit/tome-directory/economy` instead of the package root, and pass it as the second argument: `directory.registerHandlers.registerEconomySubscriptionHandlers(payload, registerDirectorySubscriptionHandlers)`. If you don't sell paid tiers, remove that call. - The package root no longer re-exports `registerDirectorySubscriptionHandlers` or its types, and nothing reachable from the root (or from `./server`, `./maps`, `./menu`, `./endpoints`) imports `@wabbit/tome-economy`. Only `./economy` does. - `registerEconomySubscriptionHandlers` now takes the registrar as a required second argument (typed as the new `DirectoryEconomySubscriptionRegistrar`). Called without one, it logs a one-time warning that paid listing tiers are disabled and returns a no-op `unsubscribe`, rather than throwing. - Manually stamped tiers and fail-secure entitlement resolution are unchanged.
c8335a3: Nearby-listing search and the live-promotions query no longer issue unbounded reads; results are unchanged. Promotions and subscriptions are read in bounded pages, and the listing fallback lookup is capped at the number of ids requested. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
- c8335a3: Nearby-listing search and the live-promotions query no longer issue unbounded reads; results are unchanged. Promotions and subscriptions are read in bounded pages, and the listing fallback lookup is capped at the number of ids requested. Internal: collection slugs are typed through the shared `typedSlug()` helper instead of inline casts.
- 3fdb656: customer-facing wording: internal references removed from admin descriptions, error messages and block metadata.
c3215e4: **BREAKING for anonymous callers:** `POST /directory/claims` now requires a signed-in session and takes the claimant from it, never from the request body. **The defect (P0):** the handler never read `req.user`. It took `claimantMember` and `acceptedTerms.ip` from the JSON body and passed them to `submitClaim`, which writes with `overrideAccess: true`. `createDirectoryLayer` mounts the route by default, so any anonymous caller could file a pending claim on any listing in any member's name. Only one pending claim is allowed per listing, so that fake claim then locked the real owner out. **The fix:** - The claimant is resolved with `resolveCallerMember` (`src/access/context.ts`), the same wrapper over `@wabbit/tome-core/identity`'s `resolveMemberFromSession` that every directory access rule uses. It looks up the `members` row whose `user` is `req.user.id`. `createDirectoryEndpoints` gains an optional `membersSlug`, default `'members'` (core's default), and `createDirectoryLayer` passes `DirectoryLayerConfig.membersSlug`. - No session user → `401`. A session user with no member row → `403`. A body `claimantMember` that is not the resolved member id → `403`. The endpoint refuses the mismatch and never rewrites it. Omitting `claimantMember` is fine. - `acceptedTerms.ip` comes from `x-forwarded-for` (first hop), then `x-real-ip`, else `'unknown'`. - `acceptedTerms.acceptedAt` is now stamped by the server at submission. The submission is the moment of acceptance, and a client clock can be backdated, so the stamp is better evidence. Body-supplied `acceptedAt` and `ip` are ignored. Only `acceptedTerms.version` still comes from the caller, and it is still checked against the current agreement version. **Why minor, not patch:** the request contract changes for one class of caller. Anonymous requests that used to get `201` now get `401`. `acceptedTerms.ip`/`acceptedAt` are no longer honored from the body. The endpoint also sends `403` in cases where it used to write. The package's own history flags runtime-contract changes (`1.0.0` for `claimed`/`unpaid`), so shipping this silently as a patch would understate it. It is not `major`, because no legitimate caller loses anything. An authenticated caller that sends its own member id, like the first consumer tenant's `submitClaimAction`, keeps working unchanged. The only TypeScript change is the new optional `membersSlug`. **Consumer action:** a server-side caller that posts to this endpoint on a visitor's behalf must forward the visitor's `cookie` header, as it already had to for the claim to belong to anyone. It should also forward `x-forwarded-for`, or `acceptedTerms.ip` records the server's own egress address. Also in this change: the other three directory endpoints (menu publish, age gate, events) are marked `// public-endpoint: <reason>`, and a new repo gate, `pnpm assert:endpoint-auth`, fails any endpoint under `packages/*/src/**/endpoints/` that neither reads the session nor declares itself public.
- c3215e4: **BREAKING for anonymous callers:** `POST /directory/claims` now requires a signed-in session and takes the claimant from it, never from the request body. **The defect (P0):** the handler never read `req.user`. It took `claimantMember` and `acceptedTerms.ip` from the JSON body and passed them to `submitClaim`, which writes with `overrideAccess: true`. `createDirectoryLayer` mounts the route by default, so any anonymous caller could file a pending claim on any listing in any member's name. Only one pending claim is allowed per listing, so that fake claim then locked the real owner out. **The fix:** - The claimant is resolved with `resolveCallerMember` (`src/access/context.ts`), the same wrapper over `@wabbit/tome-core/identity`'s `resolveMemberFromSession` that every directory access rule uses. It looks up the `members` row whose `user` is `req.user.id`. `createDirectoryEndpoints` gains an optional `membersSlug`, default `'members'` (core's default), and `createDirectoryLayer` passes `DirectoryLayerConfig.membersSlug`. - No session user → `401`. A session user with no member row → `403`. A body `claimantMember` that is not the resolved member id → `403`. The endpoint refuses the mismatch and never rewrites it. Omitting `claimantMember` is fine. - `acceptedTerms.ip` comes from `x-forwarded-for` (first hop), then `x-real-ip`, else `'unknown'`. - `acceptedTerms.acceptedAt` is now stamped by the server at submission. The submission is the moment of acceptance, and a client clock can be backdated, so the stamp is better evidence. Body-supplied `acceptedAt` and `ip` are ignored. Only `acceptedTerms.version` still comes from the caller, and it is still checked against the current agreement version. **Why minor, not patch:** the request contract changes for one class of caller. Anonymous requests that used to get `201` now get `401`. `acceptedTerms.ip`/`acceptedAt` are no longer honored from the body. The endpoint also sends `403` in cases where it used to write. The package's own history flags runtime-contract changes (`1.0.0` for `claimed`/`unpaid`), so shipping this silently as a patch would understate it. It is not `major`, because no legitimate caller loses anything. An authenticated caller that sends its own member id, like the first consumer tenant's `submitClaimAction`, keeps working unchanged. The only TypeScript change is the new optional `membersSlug`. **Consumer action:** a server-side caller that posts to this endpoint on a visitor's behalf must forward the visitor's `cookie` header, as it already had to for the claim to belong to anyone. It should also forward `x-forwarded-for`, or `acceptedTerms.ip` records the server's own egress address. Also in this change: the other three directory endpoints (menu publish, age gate, events) are marked `// public-endpoint: <reason>`, and a new repo gate, `pnpm assert:endpoint-auth`, fails any endpoint under `packages/*/src/**/endpoints/` that neither reads the session nor declares itself public.
- 36ec595: **BREAKING:** `DEFAULT_DIRECTORY_ACCESS` — what every directory collection factory falls back to when called without `access` — now fails closed: every operation is staff-only (`directoryStaff`: `super-admin`/`admin`/`staff`). It was all `() => true`, so a consumer who composed the exported factories without `createDirectoryLayer` shipped an open CRUD surface. `createDirectoryLayer` is unaffected (it always passes the real bundle with its status-filtered public reads). A standalone factory caller that relied on the open default must now pass `access`.
- b06a193: **BREAKING:** `isStaffOrAdmin(req, context?)` is now async (`Promise<boolean>`) and delegates to `isStaffUser` — the same staff definition access control uses. It previously used the deprecated sync `checkRole(['staff','admin'])`, which excluded `super-admin` (a super-admin's edits to locked listing fields were silently stripped, and super-admins hit owner photo caps and content scans), ignored `staffPredicate`, and returned `false` whenever roles were not already populated. `DirectoryHookContext`, `ResolveHookContextArgs`, and the listings/promotions collection configs gain an optional `staffPredicate`, which `createDirectoryLayer` threads through so hook-side decisions honor the consumer override. Callers of the exported `isStaffOrAdmin` must `await` it.
- f7486a8: **BREAKING:** licence re-verification is now wired through the layer, and the job is omitted rather than silently no-opping. Decision: `DirectoryLayerConfig` gains an optional `registryVerify` (the site's licence-register lookup, type `DirectoryRegistryVerifier`), which `createDirectoryLayer` threads to `createDirectoryJobs`. When it is absent, `createDirectoryJobs` (and therefore the layer) no longer includes the `reverifyRegistry` job at all — previously the layer always shipped it with no verifier, so every run skipped while the job list implied licences were being re-verified — and `createDirectoryLayer` warns once at creation if `tenantPolicy.registry.requireVerifiedLicenseForTypes` is non-empty. A consumer that mounted the `reverifyRegistry` endpoint from `layer.jobs` must supply `registryVerify` to keep it.
- 44b39f3: Relationship ids are now read with core's `relationId` / `relationIdRaw` (`@wabbit/tome-core/utilities/relationId`) instead of seven local copies; each site keeps its return shape, and the public `relId` export stays as a thin alias of `relationIdRaw`. The `@wabbit/tome-core` peer floor rises to `>=1.17.0` (the release that adds `relationIdRaw`).
- 4314473: `findListingsNear` and `getOpenNowCount` no longer issue unbounded `limit: 0` reads on public request paths. `findListingsNear` adds a portable lat/lng bounding-box pre-filter (new `boundingBoxForRadius` in `server/geo`) on every path and pages the listings read with core's `findPaged` (200 rows per query, capped and logged); `getOpenNowCount` pages the same way. Results are unchanged — the box strictly contains the search circle and the haversine post-filter still decides membership.
6d54319: Add owner analytics, placement reporting, and photo attestation. New public API: `directory-listing-stats` and `directory-photo-attestations` collections; `POST /directory/events` ingestion endpoint (bot deny-list, a consumer-pluggable `DirectoryLayerConfig.isVerifiedCrawler` hook, per-IP in-memory rate limiting, always 204); server queries `getListingStats`, `getListingPlacement`, and `getRotationShare` (all entitlement-checked, `null` when the listing's plan lacks the relevant entitlement); `attachListingPhoto`/`detachListingPhoto` for the claim-time photo licence grant; a daily `pruneListingStats` job (400-day retention). Privacy by construction: no IP, user-agent, cookie id, member id, or session id is ever stored with an event — counts only, keyed on `[listing, day]` in the listing's local timezone. A sponsored impression/click can only be recorded for a listing that is currently `sponsored` per its resolved entitlements, so the counters can never say a surface was labelled Sponsored when it wasn't. This makes the `analytics` entitlement (declared since v1 but previously unbacked by any data) real for the first time.
- 6d54319: Add owner analytics, placement reporting, and photo attestation. New public API: `directory-listing-stats` and `directory-photo-attestations` collections; `POST /directory/events` ingestion endpoint (bot deny-list, a consumer-pluggable `DirectoryLayerConfig.isVerifiedCrawler` hook, per-IP in-memory rate limiting, always 204); server queries `getListingStats`, `getListingPlacement`, and `getRotationShare` (all entitlement-checked, `null` when the listing's plan lacks the relevant entitlement); `attachListingPhoto`/`detachListingPhoto` for the claim-time photo licence grant; a daily `pruneListingStats` job (400-day retention). Privacy by construction: no IP, user-agent, cookie id, member id, or session id is ever stored with an event — counts only, keyed on `[listing, day]` in the listing's local timezone. A sponsored impression/click can only be recorded for a listing that is currently `sponsored` per its resolved entitlements, so the counters can never say a surface was labelled Sponsored when it wasn't. This makes the `analytics` entitlement (declared since v1 but previously unbacked by any data) real for the first time.
42046fa: Fix the five scheduled jobs' (`createDirectoryJobs` / each `create*Job`) default `endpoint.path` being double-prefixed with `/api` at request time. **Root cause:** every job defaulted its `endpoint.path` to `/api/directory/jobs/<job>`, but Payload mounts config-root `endpoints` under `/api` itself — the layer's other endpoints (`/directory/claims`, `/directory/age-gate/verify`, `/directory/menus/:listingId/publish`) never included the `/api` prefix themselves. Verified empirically on tome-starter (`next start`, 2026-09-13): `POST /api/directory/jobs/expire-promotions` → "Route not found"; `POST /api/api/directory/jobs/expire-promotions` → 401 without the bearer, 200 with it. Every consumer spreading `directoryLayer.jobs.map(j => j.endpoint)` into a cron/scheduler config (a production consumer does exactly this) got double-prefixed cron URLs. **The fix:** `expirePromotions`, `expireTierGrace`, `purgeExpiredMenuItems`, `recomputeRatingAggregates`, and `reverifyRegistry` now default to `/directory/jobs/<job>` — matching every other endpoint this layer mounts. `createDirectoryJobs`'s aggregate output changes accordingly; a caller who already set an explicit `path` override is unaffected. **Consumer-visible change:** the default cron endpoint URLs move from `/api/api/directory/jobs/*` (broken) to `/api/directory/jobs/*` (working). A consumer that had already worked around the double-prefix (e.g. by pointing its scheduler at the broken `/api/api/...` URL, or by passing an explicit `path`) should update its scheduler config to the corrected URL, or keep its explicit `path` override — either continues to work.
- 42046fa: Fix the five scheduled jobs' (`createDirectoryJobs` / each `create*Job`) default `endpoint.path` being double-prefixed with `/api` at request time. **Root cause:** every job defaulted its `endpoint.path` to `/api/directory/jobs/<job>`, but Payload mounts config-root `endpoints` under `/api` itself — the layer's other endpoints (`/directory/claims`, `/directory/age-gate/verify`, `/directory/menus/:listingId/publish`) never included the `/api` prefix themselves. Verified empirically on tome-starter (`next start`, 2026-09-13): `POST /api/directory/jobs/expire-promotions` → "Route not found"; `POST /api/api/directory/jobs/expire-promotions` → 401 without the bearer, 200 with it. Every consumer spreading `directoryLayer.jobs.map(j => j.endpoint)` into a cron/scheduler config (a production consumer does exactly this) got double-prefixed cron URLs. **The fix:** `expirePromotions`, `expireTierGrace`, `purgeExpiredMenuItems`, `recomputeRatingAggregates`, and `reverifyRegistry` now default to `/directory/jobs/<job>` — matching every other endpoint this layer mounts. `createDirectoryJobs`'s aggregate output changes accordingly; a caller who already set an explicit `path` override is unaffected. **Consumer-visible change:** the default cron endpoint URLs move from `/api/api/directory/jobs/*` (broken) to `/api/directory/jobs/*` (working). A consumer that had already worked around the double-prefix (e.g. by pointing its scheduler at the broken `/api/api/...` URL, or by passing an explicit `path`) should update its scheduler config to the corrected URL, or keep its explicit `path` override — either continues to work.
7050ab3: Introduce `unpaid` as a real unpaid floor tier beneath `claimed`, and make `claimed` sellable. This is a breaking behavioral change flagged `major` for review — the public API surface is purely additive, but the RUNTIME semantics of the `claimed` tier change. **The problem:** `claimed` was hardcoded as the layer's free floor in several billing-blind places — `resolveListingEntitlements` short-circuited on `tier === 'claimed'` and returned its entitlements BEFORE any subscription/billing check, the fail-secure `catch` returned full `claimed` entitlements on any error, and `SELLABLE_TIERS` excluded `claimed` entirely so nothing could ever stamp it via checkout. That made a paid entry tier (e.g. a $49/mo cannabis-directory claim fee) impossible to sell, and worse, meant every fail-secure path handed out `claimed`'s entitlements for free, forever. **The fix:** 1. New `DirectoryTier` member `'unpaid'` — zero marketing entitlements (`maxPhotos: 0`, `maxLivePromotions: 0`, `rankBoost: 0`, `ownerResponses: false`, etc.). It is the ONLY tier every fail-secure path (`resolveListingEntitlements`'s error catch and no-active-billing branch, `registerDirectorySubscriptionHandlers`'s cancel handler, `revokeClaim`, `expireTierGrace`) now lands on — never `claimed`. 2. `claimed` is added to `SELLABLE_TIERS` and its short-circuit in `resolveListingEntitlements` is removed — it now resolves through the EXACT same active-subscription/manual-billing gate as `listed`/`featured`/`premier`. 3. `directory-listings.tier`'s schema default changes from `'claimed'` to `'unpaid'` — a brand-new listing starts with zero entitlements until claimed and/or subscribed. 4. Ownership vs. entitlements is now an explicit split: `ownerAccount` (established by `approveClaim`) grants the right to correct FACTUAL fields (address, phone, hours, licence number) with NO billing check, ever. Entitlements (photos, promotions, owner review responses, rank boost, menu sync, sponsored placement) are billing-gated. New `DirectoryEntitlements.ownerResponses: boolean` field — `moderation/respondToReview` now checks it (statically, off the listing's stored `tier`, mirroring the existing photo-cap pattern) before allowing a response write; `unpaid` is `false`, `claimed` and above are `true`. 5. New claim-payment seam: `approveClaim({ requirePayment })`. Default `false` preserves EVERY existing tenant's behavior byte-for-byte — the listing is stamped `tier: 'claimed'` in the same call that establishes ownership, and a brand-new owner account is stamped `billing.provider: 'manual'` so `claimed`'s entitlements actually resolve (reusing the pre-existing manual-billing bypass rather than inventing a parallel "free tier" concept). `requirePayment: true` leaves the listing at its existing tier (normally `unpaid`) and defers promotion to `claimed` to a subsequent `createSubscriptionCheckoutAction` + the (now claimed-aware) subscription-complete handler — no new economy wiring required, since adding `claimed` to `SELLABLE_TIERS` was the only change the checkout path needed. **Backward compatibility:** A tenant that never passes `requirePayment` and never configures `economy.tierPriceMap.claimed` sees IDENTICAL behavior to before this change — free claiming still grants `claimed`'s entitlements immediately, with no code changes required on the consumer side. **Migration flag for any tenant with PRE-EXISTING `claimed` listings from before this change ships:** those listings' owner accounts were never stamped `billing.provider: 'manual'` (the old code never checked it). After this deploy, `resolveListingEntitlements` will fail those listings secure to the `unpaid` floor until either (a) a backfill stamps `billing.provider: 'manual'` on their owner accounts, or (b) they're re-approved through the new `approveClaim` path. Flagging for the operator/consumer sites to run a one-time backfill before/alongside this deploy if any tenant already has claimed listings in production. Also updated `@wabbit/tome-blocks-directory-pack`'s `maps/shared/pins.ts` (`TIER_BACKGROUND`/`TIER_FOREGROUND` `Record<DirectoryTier, string>`) to add an `unpaid` entry so it keeps compiling against the widened `DirectoryTier` union — same muted styling `claimed` already had, since a pin's fill is a rank cue, not a claim-status cue.
- 7050ab3: Introduce `unpaid` as a real unpaid floor tier beneath `claimed`, and make `claimed` sellable. This is a breaking behavioral change flagged `major` for review — the public API surface is purely additive, but the RUNTIME semantics of the `claimed` tier change. **The problem:** `claimed` was hardcoded as the layer's free floor in several billing-blind places — `resolveListingEntitlements` short-circuited on `tier === 'claimed'` and returned its entitlements BEFORE any subscription/billing check, the fail-secure `catch` returned full `claimed` entitlements on any error, and `SELLABLE_TIERS` excluded `claimed` entirely so nothing could ever stamp it via checkout. That made a paid entry tier (e.g. a $49/mo cannabis-directory claim fee) impossible to sell, and worse, meant every fail-secure path handed out `claimed`'s entitlements for free, forever. **The fix:** 1. New `DirectoryTier` member `'unpaid'` — zero marketing entitlements (`maxPhotos: 0`, `maxLivePromotions: 0`, `rankBoost: 0`, `ownerResponses: false`, etc.). It is the ONLY tier every fail-secure path (`resolveListingEntitlements`'s error catch and no-active-billing branch, `registerDirectorySubscriptionHandlers`'s cancel handler, `revokeClaim`, `expireTierGrace`) now lands on — never `claimed`. 2. `claimed` is added to `SELLABLE_TIERS` and its short-circuit in `resolveListingEntitlements` is removed — it now resolves through the EXACT same active-subscription/manual-billing gate as `listed`/`featured`/`premier`. 3. `directory-listings.tier`'s schema default changes from `'claimed'` to `'unpaid'` — a brand-new listing starts with zero entitlements until claimed and/or subscribed. 4. Ownership vs. entitlements is now an explicit split: `ownerAccount` (established by `approveClaim`) grants the right to correct FACTUAL fields (address, phone, hours, licence number) with NO billing check, ever. Entitlements (photos, promotions, owner review responses, rank boost, menu sync, sponsored placement) are billing-gated. New `DirectoryEntitlements.ownerResponses: boolean` field — `moderation/respondToReview` now checks it (statically, off the listing's stored `tier`, mirroring the existing photo-cap pattern) before allowing a response write; `unpaid` is `false`, `claimed` and above are `true`. 5. New claim-payment seam: `approveClaim({ requirePayment })`. Default `false` preserves EVERY existing tenant's behavior byte-for-byte — the listing is stamped `tier: 'claimed'` in the same call that establishes ownership, and a brand-new owner account is stamped `billing.provider: 'manual'` so `claimed`'s entitlements actually resolve (reusing the pre-existing manual-billing bypass rather than inventing a parallel "free tier" concept). `requirePayment: true` leaves the listing at its existing tier (normally `unpaid`) and defers promotion to `claimed` to a subsequent `createSubscriptionCheckoutAction` + the (now claimed-aware) subscription-complete handler — no new economy wiring required, since adding `claimed` to `SELLABLE_TIERS` was the only change the checkout path needed. **Backward compatibility:** A tenant that never passes `requirePayment` and never configures `economy.tierPriceMap.claimed` sees IDENTICAL behavior to before this change — free claiming still grants `claimed`'s entitlements immediately, with no code changes required on the consumer side. **Migration flag for any tenant with PRE-EXISTING `claimed` listings from before this change ships:** those listings' owner accounts were never stamped `billing.provider: 'manual'` (the old code never checked it). After this deploy, `resolveListingEntitlements` will fail those listings secure to the `unpaid` floor until either (a) a backfill stamps `billing.provider: 'manual'` on their owner accounts, or (b) they're re-approved through the new `approveClaim` path. Flagging for the operator/consumer sites to run a one-time backfill before/alongside this deploy if any tenant already has claimed listings in production. Also updated `@wabbit/tome-blocks-directory-pack`'s `maps/shared/pins.ts` (`TIER_BACKGROUND`/`TIER_FOREGROUND` `Record<DirectoryTier, string>`) to add an `unpaid` entry so it keeps compiling against the widened `DirectoryTier` union — same muted styling `claimed` already had, since a pin's fill is a rank cue, not a claim-status cue.
- 0636540: Fix a compliance defect in the age gate: the alternate age class (e.g. an 18+ medical-patient exception under a 21+ `minAge`) was granted to any visitor whose DOB fell in the alternate age band, with no assertion that they actually qualify for it — a plain "Enter" submission from an 18-20-year-old silently passed as `class: "alternate"`, making a 21+ gate behave as an 18+ gate for anyone who didn't notice the second button. `verifyAgeGate` now requires the caller to pass `claimAlternateClass: true` (asserted only when the visitor pressed the dedicated alternate-class control) in addition to meeting `policy.alternateClass.minAge`; an unclaimed under-`minAge` DOB is denied, never silently admitted. `createAgeGateVerifyHandler`'s request body gained an optional `claimAlternateClass` field (strict `=== true`, so a truthy-but-non-boolean value is never mistaken for a claim) — additive, so any pre-existing caller that omits it keeps getting `{ ok: false, class: null }` for an unclaimed under-`minAge` DOB, same as after this fix (they were never able to reach the alternate class via that path anyway, since the old code inferred it from age alone with no per-request signal at all). Also fixes the "Remember me on this device" checkbox being silently ignored: the handler's request body gained an optional `remember` field (default `true`, matching the prior always-persistent behaviour) — `false` now issues a session cookie (no `Max-Age`/`Expires`) instead of always issuing a `policy.rememberDays`-lifetime cookie regardless of the visitor's choice. Both new fields are additive to the endpoint's request body — no existing caller's request shape needs to change, and the response shape (`{ ok, class }`) and `directory_ag` cookie payload shape are unchanged. `verifyAgeGate`'s `VerifyAgeGateArgs` gained an optional `claimAlternateClass` field — also additive. Tenants that configure no `ageGate.alternateClass` at all (a straight 21+ gate) are unaffected either way: the alternate-class branch has always been (and remains) a no-op when `policy.alternateClass` is undefined, claim or no claim — covered by dedicated tests since this is the exact configuration a production consumer is shipping.
- f248c05: SECURITY: fix `directory-reviews` letting a review's own author self-approve their own pending review, and the same-shape gap in `directory-promotions`. **The vulnerability:** `buildReviewsAccess()` (`access/createDirectoryAccess.ts`) declares field-level locks for FOUR fields — `status`, `moderation`, `reports`, `ownerResponse` — but `createReviewsCollection` (`collections/reviews.ts`) only ever wired ONE of them (`moderation`) onto a real Payload field. The other three carried no `access` at all and fell through to the collection-level `update` access, which a review's own author legitimately holds on their OWN pending review (so they can edit `title`/`body` before moderation). Concretely: `PATCH /api/directory-reviews/<id> { status: 'approved' }` succeeded for the review's own author, publishing it unmoderated. `directory-promotions` had the identical shape: `buildPromotionsAccess()` declares a `fields.status` lock, but `createPromotionsCollection` never called `resolveDirectoryFieldAccess` for it at all — an owner updating their own `draft` promotion (access they legitimately hold) could set `status: 'live'` in the same PATCH, bypassing the staff/system-only publish transition. **The fix:** every key each bundle's `fields` map declares is now wired via `resolveDirectoryFieldAccess`, exactly as `moderation` already was. `ownerResponse` is wired to the bundle's existing (correct) predicate — system write, staff, OR the caller holding an owner seat on the review's own listing — deliberately NOT staff-only, since owner responses are a paid-tier product feature. **Also fixed, same class, opposite direction:** `directory-listings`' `ownerAccount` field has always called `resolveDirectoryFieldAccess(config.access, 'ownerAccount')`, but `DIRECTORY_LOCKED_LISTING_FIELDS` (`access/fieldLock.ts`) — the bundle's only source for its `fields` keys — never included `'ownerAccount'`, so that call always silently resolved to the permissive default. An owner could rewrite their own listing's `ownerAccount` directly (silent ownership/billing reassignment), with neither a field lock nor the `beforeChange` strip hook (which shares the same list) catching it. Now locked, same as every other server-stamped listing field. **Made the whole class loud, not just these four fields:** `resolveDirectoryFieldAccess` now THROWS at construction time if it's asked for a field key against a bundle whose `fields` map is populated but doesn't declare that key — the exact shape of the `ownerAccount` gap. A new generic test suite (`tests/collections/fieldLockWiring.test.ts`) builds every collection this package ships against its real access bundle and asserts, by function reference, that every key the bundle declares in `fields` is actually wired onto a real field — the exact shape of the `status`/`reports`/`ownerResponse` gap. Together these make "a bundle declares a field lock the collection never applies" and "a collection asks for a lock the bundle never declares" both fail immediately, for every current and future locked field, instead of silently doing nothing. **Backward compatibility — read carefully:** any consumer whose code was directly PATCHing `directory-reviews.status`/`.reports`/`.ownerResponse` or `directory-promotions.status` as an ordinary authenticated caller will now be denied where it previously succeeded. That is the point — those are exactly the unintended-write paths this closes. A consumer whose OWN code writes these fields via `overrideAccess: true` with `context: { systemWrite: true }` (the pattern this package's own `reportReview`/`respondToReview`/`approveReview`/`rejectReview`/`removeReview` already use) is unaffected; verified by reading each of those functions and by a spy-based test asserting every write they make carries both flags. `draft -> live`/`live -> expired` promotion transitions and `pending -> approved`/`rejected` review transitions go through `@wabbit/tome-workflow`'s `claimTransition`, which writes via the DB adapter's raw atomic primitive (bypassing Payload access entirely) or `overrideAccess: true` on its non-Mongo fallback — neither path is gated by Payload field access, so cron jobs (`expirePromotions`) and moderation helpers are unaffected either way.
- e032576: Fix the staff access gate (`isStaffUser`/`directoryStaff` in `access/context.ts`), which was dead for every user of every role on every consumer. **The problem:** the gate read `req.user.role` — SINGULAR — via the deprecated `@wabbit/tome-core/access/checkRole`. Tome's real Users shape has no such field; roles live on `roles`, a relationship ARRAY resolved by BetterAuth's customSession enricher, never eagerly populated onto a flat `role` string. Every call resolved to `checkRole(undefined, [...])`, unconditionally `false`. Proven live in a consumer: an `admin`-role account still got `totalDocs: 0` and "Nothing found" against a six-document directory collection. Every collection's staff bypass, and every locked-field staff override (`tier`, `registryRef`, review moderation fields, promotion `status`, etc.), was silently inert — staff could not moderate, could not hand-correct a listing, could not bypass a scoping WHERE, regardless of role. **The fix:** 1. `isStaffUser` now delegates role resolution to `@wabbit/tome-core/auth/rbac`'s `checkRoleAsync` — the same helper core's own async admin gates (`auth/guards.ts`) use. It reads `_populatedRoles` when already request-cached, else `roles` (handling both bare relationship ids and already-populated role docs), fetching and caching on a miss. This makes the gate `async`; every call site was already inside an `async` `Access`/`FieldAccess` function, so nothing further up needed to change. 2. The default vocabulary no longer hardcodes `['staff', 'admin']` — that excluded `super-admin`, the platform's most privileged role, independent of the field-name bug. The default is now `super-admin` or `admin` (core's `ROLES` constants) plus a role literally slugged `staff` (kept only for a consumer who already defined one — never assumed to exist). 3. New `DirectoryLayerConfig.staffPredicate?: (req) => boolean | Promise<boolean>` lets a consumer replace the staff gate outright (a different vocabulary, an org-scoped check, whatever) — threaded through `createDirectoryLayer` into every `DirectoryAccessBundle` `createDirectoryAccess` builds, including the field-level locks. New exports `directoryStaffAccess(ctx)`/`lockedFieldAccessFor(ctx)` are the ctx-aware factories that honor it; the existing ctx-free `directoryStaff`/`directoryStaffFieldLevel`/`lockedFieldAccess` exports are unchanged in shape and always use the corrected default. **Backward compatibility:** essentially none of today's consumers are affected in practice, because the broken gate matched nobody — there is no live behavior to regress. Any consumer whose staff/admin/super-admin accounts start correctly bypassing scoping WHEREs and correctly editing locked fields is receiving the FIX, not a regression. A consumer that happens to have defined a role literally slugged `staff` continues to work unchanged (still in the default vocabulary). Field-level lock behavior for `directory-listings` locked fields (`buildListingFieldLocks`) is threaded through the same `ctx`/`staffPredicate` seam. Hook-side gating in `hooks/context.ts`'s `isStaffOrAdmin` (used by `beforeChange` field-strip enforcement) is a SEPARATE, already-correct implementation (it reads `roles`/`_populatedRoles` via core's sync `checkRole`, not a singular `role` field) — untouched here since it lives in files another in-flight PR (`feat/directory-unclaimed-floor-tier`) owns; it still hardcodes `['staff', 'admin']` (missing `super-admin`) and is flagged as a follow-up, not fixed in this change.
e886c1b: Fix `validateAndNormalizeHours` rejecting `closes: '24:00'`: ISO 8601 permits `24:00` as an end-of-day value, and real register data closes stores at midnight this way, so the consumer import aborted on the first such listing. `24:00` is now accepted as a `closes` value only (never `opens`, and never any other value past `23:59`) and is treated as 1440 minutes — the day's final instant — for ordering and overlap validation. `computeOpenState` (`server/openNow.ts`) already resolved genuinely overnight windows (`closes` earlier than `opens`, e.g. `20:00`-`02:00`) correctly across the midnight wrap; it now also carries explicit documentation and test coverage for the `24:00` case, which resolves correctly via the same `Date.UTC` hour-24 overflow used for overnight wraps. The stored `hours` values are unchanged by this fix — no migration needed.
- e886c1b: Fix `validateAndNormalizeHours` rejecting `closes: '24:00'`: ISO 8601 permits `24:00` as an end-of-day value, and real register data closes stores at midnight this way, so the consumer import aborted on the first such listing. `24:00` is now accepted as a `closes` value only (never `opens`, and never any other value past `23:59`) and is treated as 1440 minutes — the day's final instant — for ordering and overlap validation. `computeOpenState` (`server/openNow.ts`) already resolved genuinely overnight windows (`closes` earlier than `opens`, e.g. `20:00`-`02:00`) correctly across the midnight wrap; it now also carries explicit documentation and test coverage for the `24:00` case, which resolves correctly via the same `Date.UTC` hour-24 overflow used for overnight wraps. The stored `hours` values are unchanged by this fix — no migration needed.
fa06026: New package: `@wabbit/tome-directory` — a reusable, market-agnostic, vertical-agnostic local-directory layer (markets, listings, claims, reviews, favorites, promotions, an inbound menu-item seam). Wave 1 (W1-INFRA) ships the full public type surface, the `defineListingType` registry, `defineDirectoryTenantPolicy` + `DEFAULT_ENTITLEMENTS`, the five-channel event bus, `createDirectoryLayer`'s config validation/slug resolution/listing-type-group composition/layer registration, and the `./server` + `./maps` export subpaths. The seven collection factories and every `./server` data-access function are typed stubs pending W1-COLL/W1-SERVER (Wave 1 continues in parallel). First tenant: a production consumer, via its own `DirectoryTenantPolicy` — no vertical-specific code ships in this package.
- fa06026: New package: `@wabbit/tome-directory` — a reusable, market-agnostic, vertical-agnostic local-directory layer (markets, listings, claims, reviews, favorites, promotions, an inbound menu-item seam). Wave 1 (W1-INFRA) ships the full public type surface, the `defineListingType` registry, `defineDirectoryTenantPolicy` + `DEFAULT_ENTITLEMENTS`, the five-channel event bus, `createDirectoryLayer`'s config validation/slug resolution/listing-type-group composition/layer registration, and the `./server` + `./maps` export subpaths. The seven collection factories and every `./server` data-access function are typed stubs pending W1-COLL/W1-SERVER (Wave 1 continues in parallel). First tenant: a production consumer, via its own `DirectoryTenantPolicy` — no vertical-specific code ships in this package.