From 10ffc49bcce5ea908233392026ee5b852eaed380 Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Mon, 3 Aug 2026 09:01:58 -0600 Subject: [PATCH 1/2] fix(core,content): stop generated SvelteKit routes dirtying the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy `.gitignore` migration matched the header only when the line beneath it equalled a pattern derived from the *current* `routesDir`. `@happyvertical/smrt-content` adopted the plugin at `src/routes/api` and later moved to `src/routes/api/v1`, so its pair never matched and survived every regeneration; the bounded block was then appended after the surviving `!src/routes/api/v1/**/+server.ts` negation, leaving contradictory rules. Migrate by shape instead: take the recognized header plus the contiguous run of recursive `+server.ts` wildcards beneath it, negations included, stopping at the first line that is neither (a comment always ends the run). Migration now also runs once the bounded block exists, because a project generated by the first #2185 release kept its unmatched pair, and that stale negation silently re-includes whatever the block stops listing. An application-owned broad rule elsewhere in the file is still preserved. Stop tracking the 96 generated route files in `packages/content`. They were swept into #1863 alongside the handwritten dev-app pages and the negation that exposed them; nothing described them as intentional output. They are not Biome-formatted — regenerating produces 117 `format` errors across 99 files against 6 today — so tracking them means a Lint job gated on output no one edits, and emitting byte-exact formatted code for arbitrary model and field names is not something the generator can promise. `packages/assets` and `packages/images` already track none, `files: ["dist"]` ships none, and `.claude/rules/testing.md` says to test generators, not generated output. `contents-api.test.ts` imports two of them; the `test` script runs `svelte-kit sync` first, which regenerates the tree, so all 302 content tests pass with none committed. The 5 handwritten handlers under `src/routes/api/v1` stay tracked and gain lint coverage: the bounded block lists only generator-owned paths, where the blanket negation covered everything. Regenerate the bounded block for all three packages. Regeneration is now idempotent — a second dev run rewrites nothing. `packages/content/.gitignore` keeps a documented spillover guard: `svelte-check` runs a second generation pass at the default `routesDir`, emitting a duplicate API surface under `src/routes/api/` and deleting the v1 tree. That is pre-existing and tracked separately in #2199; the guard keeps route files out of `git status` under both shapes so this change does not unmask it. Closes #2198 Refs #2199 --- packages/assets/.gitignore | 50 +++- packages/content/.gitignore | 114 ++++++++- packages/content/AGENTS.md | 3 + .../contentcontributionattachments/+server.ts | 221 ---------------- .../[id]/+server.ts | 240 ------------------ .../listForContribution/+server.ts | 147 ----------- .../listForRevision/+server.ts | 142 ----------- .../contentcontributionrevisions/+server.ts | 221 ---------------- .../[id]/+server.ts | 240 ------------------ .../getLatestForContribution/+server.ts | 147 ----------- .../listForContribution/+server.ts | 147 ----------- .../api/v1/contentcontributions/+server.ts | 221 ---------------- .../v1/contentcontributions/[id]/+server.ts | 240 ------------------ .../[id]/approve/+server.ts | 135 ---------- .../[id]/promote/+server.ts | 135 ---------- .../[id]/reject/+server.ts | 135 ---------- .../[id]/request-changes/+server.ts | 138 ---------- .../[id]/revisions/+server.ts | 138 ---------- .../[id]/withdraw/+server.ts | 135 ---------- .../by-contributor/+server.ts | 151 ----------- .../v1/contentcontributions/inbox/+server.ts | 151 ----------- .../ingest-email/+server.ts | 139 ---------- .../v1/contentcontributions/submit/+server.ts | 139 ---------- .../v1/contentcontributions/types/+server.ts | 157 ------------ .../v1/contentcontributiontypes/+server.ts | 221 ---------------- .../contentcontributiontypes/[id]/+server.ts | 240 ------------------ .../getByKey/+server.ts | 140 ---------- .../api/v1/contentcontributors/+server.ts | 221 ---------------- .../v1/contentcontributors/[id]/+server.ts | 240 ------------------ .../findOrCreateByEmail/+server.ts | 141 ---------- .../contentcontributors/getByEmail/+server.ts | 139 ---------- .../getByProfileId/+server.ts | 139 ---------- .../api/v1/contentcorrections/+server.ts | 221 ---------------- .../api/v1/contentcorrections/[id]/+server.ts | 216 ---------------- .../getPublishedForContent/+server.ts | 146 ----------- .../v1/contentcorrections/issue/+server.ts | 136 ---------- .../listForContent/+server.ts | 139 ---------- .../api/v1/contentfeedsources/+server.ts | 221 ---------------- .../api/v1/contentfeedsources/[id]/+server.ts | 231 ----------------- .../contentfeedsources/findActive/+server.ts | 139 ---------- .../findByFeedUrl/+server.ts | 143 ----------- .../findByStatus/+server.ts | 143 ----------- .../contentgovernanceassignments/+server.ts | 221 ---------------- .../[id]/+server.ts | 240 ------------------ .../getByKey/+server.ts | 142 ----------- .../resolveForContent/+server.ts | 139 ---------- .../v1/contentgovernancepolicies/+server.ts | 221 ---------------- .../contentgovernancepolicies/[id]/+server.ts | 240 ------------------ .../getByKey/+server.ts | 140 ---------- .../v1/contentgovernanceprofiles/+server.ts | 221 ---------------- .../contentgovernanceprofiles/[id]/+server.ts | 240 ------------------ .../getByKey/+server.ts | 140 ---------- .../api/v1/contentreferences/+server.ts | 221 ---------------- .../api/v1/contentreferences/[id]/+server.ts | 231 ----------------- .../v1/contentreferences/attach/+server.ts | 145 ----------- .../v1/contentreferences/byLeft/+server.ts | 140 ---------- .../v1/contentreferences/byRight/+server.ts | 140 ---------- .../v1/contentreferences/detach/+server.ts | 145 ----------- .../contentreferences/getForSource/+server.ts | 139 ---------- .../contentreferences/getForTarget/+server.ts | 139 ---------- .../v1/contentreferences/setLinks/+server.ts | 145 ----------- .../v1/contentreferences/unlink/+server.ts | 143 ----------- .../routes/api/v1/contentreviews/+server.ts | 221 ---------------- .../api/v1/contentreviews/[id]/+server.ts | 216 ---------------- .../createFromResult/+server.ts | 136 ---------- .../getLatestForContent/+server.ts | 146 ----------- .../getLatestForPolicyKey/+server.ts | 148 ----------- .../contentreviews/listForContent/+server.ts | 143 ----------- .../listForContentByPolicyKey/+server.ts | 148 ----------- .../src/routes/api/v1/contents/+server.ts | 227 ----------------- .../routes/api/v1/contents/[id]/+server.ts | 233 ----------------- .../v1/contents/[id]/corrections/+server.ts | 152 ----------- .../v1/contents/[id]/fact-audit/+server.ts | 134 ---------- .../[id]/fact-audit/claims/recheck/+server.ts | 134 ---------- .../fact-audit/evidence/repair/+server.ts | 134 ---------- .../fact-audit/evidence/status/+server.ts | 134 ---------- .../[id]/fact-audit/repair/+server.ts | 134 ---------- .../api/v1/contents/[id]/facts/+server.ts | 153 ----------- .../v1/contents/[id]/governance/+server.ts | 134 ---------- .../contents/[id]/review-profiles/+server.ts | 134 ---------- .../review-profiles/[profileKey]/+server.ts | 142 ----------- .../api/v1/contents/[id]/reviews/+server.ts | 153 ----------- .../v1/contents/[id]/transparency/+server.ts | 134 ---------- .../[id]/transparency/preview/+server.ts | 134 ---------- .../api/v1/contents/[id]/versions/+server.ts | 152 ----------- .../routes/api/v1/contents/by-slug/+server.ts | 151 ----------- .../routes/api/v1/contents/facts/+server.ts | 151 ----------- .../api/v1/contents/governance/+server.ts | 155 ----------- .../v1/contents/governance/resolve/+server.ts | 154 ----------- .../routes/api/v1/contentversions/+server.ts | 221 ---------------- .../api/v1/contentversions/[id]/+server.ts | 170 ------------- .../[id]/transparency/+server.ts | 135 ---------- .../contentversions/createSnapshot/+server.ts | 143 ----------- .../getLatestForContent/+server.ts | 142 ----------- .../getLatestPublishedForContent/+server.ts | 146 ----------- .../getNextVersionNumber/+server.ts | 144 ----------- .../v1/contentversions/getVersion/+server.ts | 143 ----------- .../contentversions/listForContent/+server.ts | 139 ---------- .../restoreIntoContent/+server.ts | 143 ----------- packages/core/README.md | 25 +- .../vite-plugin/sveltekit-generator.test.ts | 164 ++++++++++++ .../src/vite-plugin/sveltekit-generator.ts | 74 ++++-- packages/images/.gitignore | 20 +- 103 files changed, 422 insertions(+), 16008 deletions(-) delete mode 100644 packages/content/src/routes/api/v1/contentcontributionattachments/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributionattachments/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributionattachments/listForContribution/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributionattachments/listForRevision/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributionrevisions/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributionrevisions/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributionrevisions/getLatestForContribution/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributionrevisions/listForContribution/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/[id]/approve/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/[id]/promote/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/[id]/reject/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/[id]/request-changes/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/[id]/revisions/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/[id]/withdraw/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/by-contributor/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/inbox/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/ingest-email/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/submit/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributions/types/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributiontypes/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributiontypes/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributiontypes/getByKey/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributors/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributors/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributors/findOrCreateByEmail/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributors/getByEmail/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcontributors/getByProfileId/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcorrections/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcorrections/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcorrections/getPublishedForContent/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcorrections/issue/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentcorrections/listForContent/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentfeedsources/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentfeedsources/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentfeedsources/findActive/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentfeedsources/findByFeedUrl/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentfeedsources/findByStatus/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernanceassignments/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernanceassignments/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernanceassignments/getByKey/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernanceassignments/resolveForContent/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernancepolicies/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernancepolicies/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernancepolicies/getByKey/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernanceprofiles/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernanceprofiles/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentgovernanceprofiles/getByKey/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/attach/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/byLeft/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/byRight/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/detach/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/getForSource/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/getForTarget/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/setLinks/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreferences/unlink/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreviews/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreviews/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreviews/createFromResult/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreviews/getLatestForContent/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreviews/getLatestForPolicyKey/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreviews/listForContent/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentreviews/listForContentByPolicyKey/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/corrections/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/fact-audit/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/fact-audit/claims/recheck/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/fact-audit/evidence/repair/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/fact-audit/evidence/status/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/fact-audit/repair/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/facts/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/governance/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/review-profiles/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/review-profiles/[profileKey]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/reviews/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/transparency/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/transparency/preview/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/[id]/versions/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/by-slug/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/facts/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/governance/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contents/governance/resolve/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/[id]/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/[id]/transparency/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/createSnapshot/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/getLatestForContent/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/getLatestPublishedForContent/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/getNextVersionNumber/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/getVersion/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/listForContent/+server.ts delete mode 100644 packages/content/src/routes/api/v1/contentversions/restoreIntoContent/+server.ts diff --git a/packages/assets/.gitignore b/packages/assets/.gitignore index 513fb3e61e..b6e80ae77e 100644 --- a/packages/assets/.gitignore +++ b/packages/assets/.gitignore @@ -1,2 +1,48 @@ -# SMRT auto-generated routes (from Vite plugin) -src/routes/api/**/+server.ts +# BEGIN SMRT auto-generated routes (Vite plugin) +src/routes/api/_changes/+server.ts +src/routes/api/_events/+server.ts +src/routes/api/assetassociations/\[id\]/+server.ts +src/routes/api/assetassociations/+server.ts +src/routes/api/assetassociations/attach/+server.ts +src/routes/api/assetassociations/byLeft/+server.ts +src/routes/api/assetassociations/byRight/+server.ts +src/routes/api/assetassociations/detach/+server.ts +src/routes/api/assetassociations/setLinks/+server.ts +src/routes/api/assetmetafields/\[id\]/+server.ts +src/routes/api/assetmetafields/+server.ts +src/routes/api/assetmetafields/getOrCreate/+server.ts +src/routes/api/assetmetafields/initializeCommonMetafields/+server.ts +src/routes/api/assets/\[id\]/+server.ts +src/routes/api/assets/+server.ts +src/routes/api/assets/addTag/+server.ts +src/routes/api/assets/createNewVersion/+server.ts +src/routes/api/assets/findByTenant/+server.ts +src/routes/api/assets/findGlobal/+server.ts +src/routes/api/assets/findWithGlobals/+server.ts +src/routes/api/assets/getByFolder/+server.ts +src/routes/api/assets/getByMimeType/+server.ts +src/routes/api/assets/getByOwner/+server.ts +src/routes/api/assets/getByStatus/+server.ts +src/routes/api/assets/getByTag/+server.ts +src/routes/api/assets/getByType/+server.ts +src/routes/api/assets/getDerivatives/+server.ts +src/routes/api/assets/getLatestVersion/+server.ts +src/routes/api/assets/listVersions/+server.ts +src/routes/api/assets/removeTag/+server.ts +src/routes/api/assets/rollbackToVersion/+server.ts +src/routes/api/assetstatuses/\[id\]/+server.ts +src/routes/api/assetstatuses/+server.ts +src/routes/api/assetstatuses/getOrCreate/+server.ts +src/routes/api/assetstatuses/initializeCommonStatuses/+server.ts +src/routes/api/assettypes/\[id\]/+server.ts +src/routes/api/assettypes/+server.ts +src/routes/api/assettypes/getOrCreate/+server.ts +src/routes/api/assettypes/initializeCommonTypes/+server.ts +src/routes/api/folders/\[id\]/+server.ts +src/routes/api/folders/+server.ts +src/routes/api/folders/getContents/+server.ts +src/routes/api/folders/getPath/+server.ts +src/routes/api/folders/getTree/+server.ts +src/routes/api/folders/moveAsset/+server.ts +src/routes/api/sync/apply/+server.ts +# END SMRT auto-generated routes (Vite plugin) diff --git a/packages/content/.gitignore b/packages/content/.gitignore index d49106439f..3718d555a5 100644 --- a/packages/content/.gitignore +++ b/packages/content/.gitignore @@ -1,3 +1,115 @@ -# SMRT auto-generated routes (from Vite plugin) +# Spillover guard (#2199). `svelte-check`, via `pnpm typecheck`, runs a second +# route-generation pass using the plugin's DEFAULT routesDir, emitting a +# duplicate API surface directly under src/routes/api/. This package's real +# generated routes live under src/routes/api/v1 and are listed in the +# generator-managed block below; the handwritten handlers there are re-included +# by the negation, so they stay tracked and linted. Delete both lines when +# #2199 removes the duplicate pass. +# +# Write handwritten handlers under src/routes/api/v1 only. One placed directly +# under src/routes/api/ is indistinguishable from the duplicate surface and +# would be ignored here without warning. src/routes/api/**/+server.ts !src/routes/api/v1/**/+server.ts + +# BEGIN SMRT auto-generated routes (Vite plugin) +src/routes/api/v1/_changes/+server.ts +src/routes/api/v1/_events/+server.ts +src/routes/api/v1/contentcontributionattachments/\[id\]/+server.ts +src/routes/api/v1/contentcontributionattachments/+server.ts +src/routes/api/v1/contentcontributionattachments/listForContribution/+server.ts +src/routes/api/v1/contentcontributionattachments/listForRevision/+server.ts +src/routes/api/v1/contentcontributionrevisions/\[id\]/+server.ts +src/routes/api/v1/contentcontributionrevisions/+server.ts +src/routes/api/v1/contentcontributionrevisions/getLatestForContribution/+server.ts +src/routes/api/v1/contentcontributionrevisions/listForContribution/+server.ts +src/routes/api/v1/contentcontributions/\[id\]/+server.ts +src/routes/api/v1/contentcontributions/\[id\]/approve/+server.ts +src/routes/api/v1/contentcontributions/\[id\]/promote/+server.ts +src/routes/api/v1/contentcontributions/\[id\]/reject/+server.ts +src/routes/api/v1/contentcontributions/\[id\]/request-changes/+server.ts +src/routes/api/v1/contentcontributions/\[id\]/revisions/+server.ts +src/routes/api/v1/contentcontributions/\[id\]/withdraw/+server.ts +src/routes/api/v1/contentcontributions/+server.ts +src/routes/api/v1/contentcontributions/by-contributor/+server.ts +src/routes/api/v1/contentcontributions/inbox/+server.ts +src/routes/api/v1/contentcontributions/ingest-email/+server.ts +src/routes/api/v1/contentcontributions/submit/+server.ts +src/routes/api/v1/contentcontributions/types/+server.ts +src/routes/api/v1/contentcontributiontypes/\[id\]/+server.ts +src/routes/api/v1/contentcontributiontypes/+server.ts +src/routes/api/v1/contentcontributiontypes/getByKey/+server.ts +src/routes/api/v1/contentcontributors/\[id\]/+server.ts +src/routes/api/v1/contentcontributors/+server.ts +src/routes/api/v1/contentcontributors/findOrCreateByEmail/+server.ts +src/routes/api/v1/contentcontributors/getByEmail/+server.ts +src/routes/api/v1/contentcontributors/getByProfileId/+server.ts +src/routes/api/v1/contentcorrections/\[id\]/+server.ts +src/routes/api/v1/contentcorrections/+server.ts +src/routes/api/v1/contentcorrections/getPublishedForContent/+server.ts +src/routes/api/v1/contentcorrections/issue/+server.ts +src/routes/api/v1/contentcorrections/listForContent/+server.ts +src/routes/api/v1/contentfeedsources/\[id\]/+server.ts +src/routes/api/v1/contentfeedsources/+server.ts +src/routes/api/v1/contentfeedsources/findActive/+server.ts +src/routes/api/v1/contentfeedsources/findByFeedUrl/+server.ts +src/routes/api/v1/contentfeedsources/findByStatus/+server.ts +src/routes/api/v1/contentgovernanceassignments/\[id\]/+server.ts +src/routes/api/v1/contentgovernanceassignments/+server.ts +src/routes/api/v1/contentgovernanceassignments/getByKey/+server.ts +src/routes/api/v1/contentgovernanceassignments/resolveForContent/+server.ts +src/routes/api/v1/contentgovernancepolicies/\[id\]/+server.ts +src/routes/api/v1/contentgovernancepolicies/+server.ts +src/routes/api/v1/contentgovernancepolicies/getByKey/+server.ts +src/routes/api/v1/contentgovernanceprofiles/\[id\]/+server.ts +src/routes/api/v1/contentgovernanceprofiles/+server.ts +src/routes/api/v1/contentgovernanceprofiles/getByKey/+server.ts +src/routes/api/v1/contentreferences/\[id\]/+server.ts +src/routes/api/v1/contentreferences/+server.ts +src/routes/api/v1/contentreferences/attach/+server.ts +src/routes/api/v1/contentreferences/byLeft/+server.ts +src/routes/api/v1/contentreferences/byRight/+server.ts +src/routes/api/v1/contentreferences/detach/+server.ts +src/routes/api/v1/contentreferences/getForSource/+server.ts +src/routes/api/v1/contentreferences/getForTarget/+server.ts +src/routes/api/v1/contentreferences/setLinks/+server.ts +src/routes/api/v1/contentreferences/unlink/+server.ts +src/routes/api/v1/contentreviews/\[id\]/+server.ts +src/routes/api/v1/contentreviews/+server.ts +src/routes/api/v1/contentreviews/createFromResult/+server.ts +src/routes/api/v1/contentreviews/getLatestForContent/+server.ts +src/routes/api/v1/contentreviews/getLatestForPolicyKey/+server.ts +src/routes/api/v1/contentreviews/listForContent/+server.ts +src/routes/api/v1/contentreviews/listForContentByPolicyKey/+server.ts +src/routes/api/v1/contents/\[id\]/+server.ts +src/routes/api/v1/contents/\[id\]/corrections/+server.ts +src/routes/api/v1/contents/\[id\]/fact-audit/+server.ts +src/routes/api/v1/contents/\[id\]/fact-audit/claims/recheck/+server.ts +src/routes/api/v1/contents/\[id\]/fact-audit/evidence/repair/+server.ts +src/routes/api/v1/contents/\[id\]/fact-audit/evidence/status/+server.ts +src/routes/api/v1/contents/\[id\]/fact-audit/repair/+server.ts +src/routes/api/v1/contents/\[id\]/facts/+server.ts +src/routes/api/v1/contents/\[id\]/governance/+server.ts +src/routes/api/v1/contents/\[id\]/review-profiles/\[profileKey\]/+server.ts +src/routes/api/v1/contents/\[id\]/review-profiles/+server.ts +src/routes/api/v1/contents/\[id\]/reviews/+server.ts +src/routes/api/v1/contents/\[id\]/transparency/+server.ts +src/routes/api/v1/contents/\[id\]/transparency/preview/+server.ts +src/routes/api/v1/contents/\[id\]/versions/+server.ts +src/routes/api/v1/contents/+server.ts +src/routes/api/v1/contents/by-slug/+server.ts +src/routes/api/v1/contents/facts/+server.ts +src/routes/api/v1/contents/governance/+server.ts +src/routes/api/v1/contents/governance/resolve/+server.ts +src/routes/api/v1/contentversions/\[id\]/+server.ts +src/routes/api/v1/contentversions/\[id\]/transparency/+server.ts +src/routes/api/v1/contentversions/+server.ts +src/routes/api/v1/contentversions/createSnapshot/+server.ts +src/routes/api/v1/contentversions/getLatestForContent/+server.ts +src/routes/api/v1/contentversions/getLatestPublishedForContent/+server.ts +src/routes/api/v1/contentversions/getNextVersionNumber/+server.ts +src/routes/api/v1/contentversions/getVersion/+server.ts +src/routes/api/v1/contentversions/listForContent/+server.ts +src/routes/api/v1/contentversions/restoreIntoContent/+server.ts +src/routes/api/v1/sync/apply/+server.ts +# END SMRT auto-generated routes (Vite plugin) diff --git a/packages/content/AGENTS.md b/packages/content/AGENTS.md index 9c52f52391..8c2f676dcd 100644 --- a/packages/content/AGENTS.md +++ b/packages/content/AGENTS.md @@ -190,5 +190,8 @@ import { - **Publish readiness enforcement**: `save()` throws `ValidationError` if blocking requirements aren't met when setting status to `'published'` - **Transparency snapshots**: published transparency is frozen into `ContentVersion.metadata.transparency` — use published for public display, preview for editors - **Reference pinning**: `ContentReference` is keyed on `(source_id, target_id)`; `targetVersion` is an attribute of the edge, not part of identity. Re-calling `addReference(target, { targetVersion })` updates the pin in place. Unpinned references (`targetVersion: null`) report `isDrifted: false` regardless of how stale the target is — pin them only when you want drift to be detectable. +- **Generated API routes are not tracked**: everything the vite plugin emits under `src/routes/api/v1` is build output, listed in the bounded `.gitignore` block and regenerated on `npm run dev`. The handwritten handlers beside them (`contents/[id]/chat/**`, `images/**`) stay tracked and linted. Do not commit generated routes — they are not Biome-formatted, so tracking them turns the Lint job red (#2198) +- **`pnpm typecheck` still rewrites the `.gitignore` block**: `svelte-check` runs a second generation pass at the plugin's default `routesDir`, so the managed block flips between the `src/routes/api/v1/*` shape (`pnpm dev`) and the `src/routes/api/*` shape. The spillover guard above the block keeps route files out of `git status` in both shapes; only the `.gitignore` itself churns. Discard that churn until #2199 lands +- **Tests import generated routes and that is fine**: `contents-api.test.ts` imports `contents/+server.ts` and `contents/[id]/+server.ts` to exercise the generated CRUD handlers against a real SQLite database. The `test` script runs `svelte-kit sync` first, which regenerates the whole route tree, so those files exist by the time Vitest loads — no committed copy needed. Run `pnpm test`, never bare `vitest`, or the imports resolve against whatever happens to be on disk - **Chat tables**: chat endpoint requires `@happyvertical/smrt-chat` tables; dev server handles missing tables gracefully - **Dev server bootstraps all classes**: `hooks.server.ts` generates schemas for all 13 local `@smrt()` classes plus cross-package manifests diff --git a/packages/content/src/routes/api/v1/contentcontributionattachments/+server.ts b/packages/content/src/routes/api/v1/contentcontributionattachments/+server.ts deleted file mode 100644 index 65fd49692d..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributionattachments/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionAttachment } from '../../../../content-contribution-attachment'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentContributionAttachment is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentcontributionattachments -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionAttachment', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentcontributionattachment -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionAttachment', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributionattachments/[id]/+server.ts b/packages/content/src/routes/api/v1/contentcontributionattachments/[id]/+server.ts deleted file mode 100644 index 4f1fca9bab..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributionattachments/[id]/+server.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionAttachment } from '../../../../../content-contribution-attachment'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentcontributionattachment -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionAttachment', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributionAttachment not found', - ); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentcontributionattachment -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionAttachment', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributionAttachment not found', - ); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentcontributionattachment -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionAttachment', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributionAttachment not found', - ); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributionattachments/listForContribution/+server.ts b/packages/content/src/routes/api/v1/contentcontributionattachments/listForContribution/+server.ts deleted file mode 100644 index 02799732ed..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributionattachments/listForContribution/+server.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionAttachment } from '../../../../../content-contribution-attachment'; -import type { ContentContributionAttachmentCollection } from '../../../../../content-contribution-attachments'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: listForContribution -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionAttachment', - ); - const typedCollection = - collection as unknown as ContentContributionAttachmentCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContributionAttachment collection is not registered', - ); - - type ActionArgs = Parameters< - ContentContributionAttachmentCollection['listForContribution'] - >; - type ActionOptions = { - contributionId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.listForContribution( - options.contributionId, - ); - - return json({ - action: 'listForContribution', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributionattachments/listForRevision/+server.ts b/packages/content/src/routes/api/v1/contentcontributionattachments/listForRevision/+server.ts deleted file mode 100644 index c66333ad3f..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributionattachments/listForRevision/+server.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionAttachment } from '../../../../../content-contribution-attachment'; -import type { ContentContributionAttachmentCollection } from '../../../../../content-contribution-attachments'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: listForRevision -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionAttachment', - ); - const typedCollection = - collection as unknown as ContentContributionAttachmentCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContributionAttachment collection is not registered', - ); - - type ActionArgs = Parameters< - ContentContributionAttachmentCollection['listForRevision'] - >; - type ActionOptions = { - revisionId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.listForRevision(options.revisionId); - - return json({ action: 'listForRevision', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributionrevisions/+server.ts b/packages/content/src/routes/api/v1/contentcontributionrevisions/+server.ts deleted file mode 100644 index bccd3d4123..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributionrevisions/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionRevision } from '../../../../content-contribution-revision'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentContributionRevision is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentcontributionrevisions -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionRevision', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentcontributionrevision -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionRevision', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributionrevisions/[id]/+server.ts b/packages/content/src/routes/api/v1/contentcontributionrevisions/[id]/+server.ts deleted file mode 100644 index 9742d6cfa1..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributionrevisions/[id]/+server.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionRevision } from '../../../../../content-contribution-revision'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentcontributionrevision -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionRevision', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributionRevision not found', - ); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentcontributionrevision -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionRevision', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributionRevision not found', - ); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentcontributionrevision -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionRevision', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributionRevision not found', - ); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributionrevisions/getLatestForContribution/+server.ts b/packages/content/src/routes/api/v1/contentcontributionrevisions/getLatestForContribution/+server.ts deleted file mode 100644 index 38c01ed6ae..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributionrevisions/getLatestForContribution/+server.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionRevision } from '../../../../../content-contribution-revision'; -import type { ContentContributionRevisionCollection } from '../../../../../content-contribution-revisions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getLatestForContribution -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionRevision', - ); - const typedCollection = - collection as unknown as ContentContributionRevisionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContributionRevision collection is not registered', - ); - - type ActionArgs = Parameters< - ContentContributionRevisionCollection['getLatestForContribution'] - >; - type ActionOptions = { - contributionId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getLatestForContribution( - options.contributionId, - ); - - return json({ - action: 'getLatestForContribution', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributionrevisions/listForContribution/+server.ts b/packages/content/src/routes/api/v1/contentcontributionrevisions/listForContribution/+server.ts deleted file mode 100644 index 604ebb7a5f..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributionrevisions/listForContribution/+server.ts +++ /dev/null @@ -1,147 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionRevision } from '../../../../../content-contribution-revision'; -import type { ContentContributionRevisionCollection } from '../../../../../content-contribution-revisions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: listForContribution -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionRevision', - ); - const typedCollection = - collection as unknown as ContentContributionRevisionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContributionRevision collection is not registered', - ); - - type ActionArgs = Parameters< - ContentContributionRevisionCollection['listForContribution'] - >; - type ActionOptions = { - contributionId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.listForContribution( - options.contributionId, - ); - - return json({ - action: 'listForContribution', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/+server.ts deleted file mode 100644 index 1bc57c2b43..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../content-contribution'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentContribution is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentcontributions -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentcontribution -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/[id]/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/[id]/+server.ts deleted file mode 100644 index 30e1b12068..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/[id]/+server.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../content-contribution'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentcontribution -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContribution not found', - ); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentcontribution -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContribution not found', - ); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentcontribution -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContribution not found', - ); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/[id]/approve/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/[id]/approve/+server.ts deleted file mode 100644 index 595cd8c355..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/[id]/approve/+server.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../../content-contribution'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: approveAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContribution not found', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.approveAction(options); - - return json({ action: 'approveAction', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/[id]/promote/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/[id]/promote/+server.ts deleted file mode 100644 index 86ad98a477..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/[id]/promote/+server.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../../content-contribution'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: promoteAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContribution not found', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.promoteAction(options); - - return json({ action: 'promoteAction', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/[id]/reject/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/[id]/reject/+server.ts deleted file mode 100644 index 0ae17ebf49..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/[id]/reject/+server.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../../content-contribution'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: rejectAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContribution not found', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.rejectAction(options); - - return json({ action: 'rejectAction', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/[id]/request-changes/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/[id]/request-changes/+server.ts deleted file mode 100644 index 43842852ce..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/[id]/request-changes/+server.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../../content-contribution'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: requestChangesAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContribution not found', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.requestChangesAction(options); - - return json({ - action: 'requestChangesAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/[id]/revisions/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/[id]/revisions/+server.ts deleted file mode 100644 index 731f19ccf5..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/[id]/revisions/+server.ts +++ /dev/null @@ -1,138 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../../content-contribution'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: appendRevisionAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContribution not found', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.appendRevisionAction(options); - - return json({ - action: 'appendRevisionAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/[id]/withdraw/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/[id]/withdraw/+server.ts deleted file mode 100644 index c9ce0e95ea..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/[id]/withdraw/+server.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../../content-contribution'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: withdrawAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContribution not found', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.withdrawAction(options); - - return json({ action: 'withdrawAction', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/by-contributor/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/by-contributor/+server.ts deleted file mode 100644 index 715c6b3c95..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/by-contributor/+server.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../content-contribution'; -import type { ContentContributions } from '../../../../../content-contributions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - getCurrentTenant, - hasTenantContext, - isSuperAdminBypass, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -function tenantReadOptionsScope(): { tenantId: string | null } | undefined { - if (!isTenancyEnabled() || isSuperAdminBypass()) { - return undefined; - } - return { tenantId: getCurrentTenant()?.tenantId ?? null }; -} - -// Custom collection method: listForContributor -export const GET: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const typedCollection = collection as unknown as ContentContributions; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContribution collection is not registered', - ); - - type ActionArgs = Parameters; - const options = Object.fromEntries( - new URL(request.url).searchParams.entries(), - ) as ActionArgs[0]; - const readScope = tenantReadOptionsScope(); - const scopedOptions = readScope - ? ({ ...options, ...readScope } as ActionArgs[0]) - : options; - - const result = await typedCollection.listForContributor(scopedOptions); - - return json({ action: 'listForContributor', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/inbox/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/inbox/+server.ts deleted file mode 100644 index b27cb00056..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/inbox/+server.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../content-contribution'; -import type { ContentContributions } from '../../../../../content-contributions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - getCurrentTenant, - hasTenantContext, - isSuperAdminBypass, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -function tenantReadOptionsScope(): { tenantId: string | null } | undefined { - if (!isTenancyEnabled() || isSuperAdminBypass()) { - return undefined; - } - return { tenantId: getCurrentTenant()?.tenantId ?? null }; -} - -// Custom collection method: listInboxAction -export const GET: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const typedCollection = collection as unknown as ContentContributions; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContribution collection is not registered', - ); - - type ActionArgs = Parameters; - const options = Object.fromEntries( - new URL(request.url).searchParams.entries(), - ) as ActionArgs[0]; - const readScope = tenantReadOptionsScope(); - const scopedOptions = readScope - ? ({ ...options, ...readScope } as ActionArgs[0]) - : options; - - const result = await typedCollection.listInboxAction(scopedOptions); - - return json({ action: 'listInboxAction', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/ingest-email/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/ingest-email/+server.ts deleted file mode 100644 index 32079ef22c..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/ingest-email/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../content-contribution'; -import type { ContentContributions } from '../../../../../content-contributions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: ingestEmailContribution -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const typedCollection = collection as unknown as ContentContributions; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContribution collection is not registered', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await typedCollection.ingestEmailContribution(options); - - return json({ - action: 'ingestEmailContribution', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/submit/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/submit/+server.ts deleted file mode 100644 index 76cece1c86..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/submit/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../content-contribution'; -import type { ContentContributions } from '../../../../../content-contributions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: submitWebContribution -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const typedCollection = collection as unknown as ContentContributions; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContribution collection is not registered', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await typedCollection.submitWebContribution(options); - - return json({ - action: 'submitWebContribution', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributions/types/+server.ts b/packages/content/src/routes/api/v1/contentcontributions/types/+server.ts deleted file mode 100644 index b2df6abae9..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributions/types/+server.ts +++ /dev/null @@ -1,157 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContribution } from '../../../../../content-contribution'; -import type { ContentContributions } from '../../../../../content-contributions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - getCurrentTenant, - hasTenantContext, - isSuperAdminBypass, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -function tenantReadOptionsScope(): { tenantId: string | null } | undefined { - if (!isTenancyEnabled() || isSuperAdminBypass()) { - return undefined; - } - return { tenantId: getCurrentTenant()?.tenantId ?? null }; -} - -// Custom collection method: getContributionTypesAction -export const GET: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContribution', - ); - const typedCollection = collection as unknown as ContentContributions; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContribution collection is not registered', - ); - - type ActionArgs = Parameters< - ContentContributions['getContributionTypesAction'] - >; - const options = Object.fromEntries( - new URL(request.url).searchParams.entries(), - ) as ActionArgs[0]; - const readScope = tenantReadOptionsScope(); - const scopedOptions = readScope - ? ({ ...options, ...readScope } as ActionArgs[0]) - : options; - - const result = - await typedCollection.getContributionTypesAction(scopedOptions); - - return json({ - action: 'getContributionTypesAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributiontypes/+server.ts b/packages/content/src/routes/api/v1/contentcontributiontypes/+server.ts deleted file mode 100644 index d86b2f8af3..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributiontypes/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionType } from '../../../../content-contribution-type'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentContributionType is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentcontributiontypes -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionType', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentcontributiontype -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionType', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributiontypes/[id]/+server.ts b/packages/content/src/routes/api/v1/contentcontributiontypes/[id]/+server.ts deleted file mode 100644 index 3b98ebae52..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributiontypes/[id]/+server.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionType } from '../../../../../content-contribution-type'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentcontributiontype -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionType', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributionType not found', - ); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentcontributiontype -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionType', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributionType not found', - ); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentcontributiontype -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionType', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributionType not found', - ); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributiontypes/getByKey/+server.ts b/packages/content/src/routes/api/v1/contentcontributiontypes/getByKey/+server.ts deleted file mode 100644 index 320034ea13..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributiontypes/getByKey/+server.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributionType } from '../../../../../content-contribution-type'; -import type { ContentContributionTypeCollection } from '../../../../../content-contribution-types'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getByKey -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributionType', - ); - const typedCollection = - collection as unknown as ContentContributionTypeCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContributionType collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - key: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getByKey(options.key); - - return json({ action: 'getByKey', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributors/+server.ts b/packages/content/src/routes/api/v1/contentcontributors/+server.ts deleted file mode 100644 index c0e1f94a54..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributors/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributor } from '../../../../content-contributor'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentContributor is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentcontributors -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributor', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentcontributor -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributor', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributors/[id]/+server.ts b/packages/content/src/routes/api/v1/contentcontributors/[id]/+server.ts deleted file mode 100644 index 669e483b17..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributors/[id]/+server.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributor } from '../../../../../content-contributor'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentcontributor -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributor', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributor not found', - ); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentcontributor -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributor', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributor not found', - ); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentcontributor -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributor', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentContributor not found', - ); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributors/findOrCreateByEmail/+server.ts b/packages/content/src/routes/api/v1/contentcontributors/findOrCreateByEmail/+server.ts deleted file mode 100644 index 342a87ebd0..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributors/findOrCreateByEmail/+server.ts +++ /dev/null @@ -1,141 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributor } from '../../../../../content-contributor'; -import type { ContentContributorCollection } from '../../../../../content-contributors'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: findOrCreateByEmail -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributor', - ); - const typedCollection = collection as unknown as ContentContributorCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContributor collection is not registered', - ); - - type ActionArgs = Parameters< - ContentContributorCollection['findOrCreateByEmail'] - >; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await typedCollection.findOrCreateByEmail(options); - - return json({ - action: 'findOrCreateByEmail', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributors/getByEmail/+server.ts b/packages/content/src/routes/api/v1/contentcontributors/getByEmail/+server.ts deleted file mode 100644 index dcec3cc881..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributors/getByEmail/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributor } from '../../../../../content-contributor'; -import type { ContentContributorCollection } from '../../../../../content-contributors'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getByEmail -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributor', - ); - const typedCollection = collection as unknown as ContentContributorCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContributor collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - email: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getByEmail(options.email); - - return json({ action: 'getByEmail', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcontributors/getByProfileId/+server.ts b/packages/content/src/routes/api/v1/contentcontributors/getByProfileId/+server.ts deleted file mode 100644 index f0e3d4c7dc..0000000000 --- a/packages/content/src/routes/api/v1/contentcontributors/getByProfileId/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentContributor } from '../../../../../content-contributor'; -import type { ContentContributorCollection } from '../../../../../content-contributors'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getByProfileId -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentContributor', - ); - const typedCollection = collection as unknown as ContentContributorCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentContributor collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - profileId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getByProfileId(options.profileId); - - return json({ action: 'getByProfileId', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcorrections/+server.ts b/packages/content/src/routes/api/v1/contentcorrections/+server.ts deleted file mode 100644 index b67a58c2fd..0000000000 --- a/packages/content/src/routes/api/v1/contentcorrections/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentCorrection } from '../../../../content-correction'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentCorrection is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentcorrections -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentCorrection', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentcorrection -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentCorrection', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentcorrections/[id]/+server.ts b/packages/content/src/routes/api/v1/contentcorrections/[id]/+server.ts deleted file mode 100644 index 984d58ae44..0000000000 --- a/packages/content/src/routes/api/v1/contentcorrections/[id]/+server.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentCorrection } from '../../../../../content-correction'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentcorrection -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentCorrection', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentCorrection not found'); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentcorrection -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentCorrection', - ); - const item = await collection.get(params.id); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentCorrection not found'); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; diff --git a/packages/content/src/routes/api/v1/contentcorrections/getPublishedForContent/+server.ts b/packages/content/src/routes/api/v1/contentcorrections/getPublishedForContent/+server.ts deleted file mode 100644 index e77f34fa3c..0000000000 --- a/packages/content/src/routes/api/v1/contentcorrections/getPublishedForContent/+server.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentCorrection } from '../../../../../content-correction'; -import type { ContentCorrectionCollection } from '../../../../../content-corrections'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getPublishedForContent -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentCorrection', - ); - const typedCollection = collection as unknown as ContentCorrectionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentCorrection collection is not registered', - ); - - type ActionArgs = Parameters< - ContentCorrectionCollection['getPublishedForContent'] - >; - type ActionOptions = { - contentId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getPublishedForContent( - options.contentId, - ); - - return json({ - action: 'getPublishedForContent', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentcorrections/issue/+server.ts b/packages/content/src/routes/api/v1/contentcorrections/issue/+server.ts deleted file mode 100644 index 2397526db9..0000000000 --- a/packages/content/src/routes/api/v1/contentcorrections/issue/+server.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentCorrection } from '../../../../../content-correction'; -import type { ContentCorrectionCollection } from '../../../../../content-corrections'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: issue -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentCorrection', - ); - const typedCollection = collection as unknown as ContentCorrectionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentCorrection collection is not registered', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await typedCollection.issue(options); - - return json({ action: 'issue', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentcorrections/listForContent/+server.ts b/packages/content/src/routes/api/v1/contentcorrections/listForContent/+server.ts deleted file mode 100644 index 40f777101d..0000000000 --- a/packages/content/src/routes/api/v1/contentcorrections/listForContent/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentCorrection } from '../../../../../content-correction'; -import type { ContentCorrectionCollection } from '../../../../../content-corrections'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: listForContent -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentCorrection', - ); - const typedCollection = collection as unknown as ContentCorrectionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentCorrection collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - contentId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.listForContent(options.contentId); - - return json({ action: 'listForContent', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentfeedsources/+server.ts b/packages/content/src/routes/api/v1/contentfeedsources/+server.ts deleted file mode 100644 index 88ead7a674..0000000000 --- a/packages/content/src/routes/api/v1/contentfeedsources/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentFeedSource } from '../../../../content-feed-source'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentFeedSource is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentfeedsources -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentFeedSource', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentfeedsource -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentFeedSource', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentfeedsources/[id]/+server.ts b/packages/content/src/routes/api/v1/contentfeedsources/[id]/+server.ts deleted file mode 100644 index 918d853a97..0000000000 --- a/packages/content/src/routes/api/v1/contentfeedsources/[id]/+server.ts +++ /dev/null @@ -1,231 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentFeedSource } from '../../../../../content-feed-source'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentfeedsource -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentFeedSource', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentFeedSource not found'); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentfeedsource -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentFeedSource', - ); - const item = await collection.get(params.id); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentFeedSource not found'); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentfeedsource -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentFeedSource', - ); - const item = await collection.get(params.id); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentFeedSource not found'); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentfeedsources/findActive/+server.ts b/packages/content/src/routes/api/v1/contentfeedsources/findActive/+server.ts deleted file mode 100644 index 584759c72d..0000000000 --- a/packages/content/src/routes/api/v1/contentfeedsources/findActive/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentFeedSource } from '../../../../../content-feed-source'; -import type { ContentFeedSourceCollection } from '../../../../../content-feed-sources'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: findActive -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentFeedSource', - ); - const typedCollection = collection as unknown as ContentFeedSourceCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentFeedSource collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - tenantId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.findActive(options.tenantId); - - return json({ action: 'findActive', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentfeedsources/findByFeedUrl/+server.ts b/packages/content/src/routes/api/v1/contentfeedsources/findByFeedUrl/+server.ts deleted file mode 100644 index 67fe4cad97..0000000000 --- a/packages/content/src/routes/api/v1/contentfeedsources/findByFeedUrl/+server.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentFeedSource } from '../../../../../content-feed-source'; -import type { ContentFeedSourceCollection } from '../../../../../content-feed-sources'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: findByFeedUrl -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentFeedSource', - ); - const typedCollection = collection as unknown as ContentFeedSourceCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentFeedSource collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - feedUrl: ActionArgs[0]; - tenantId: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.findByFeedUrl( - options.feedUrl, - options.tenantId, - ); - - return json({ action: 'findByFeedUrl', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentfeedsources/findByStatus/+server.ts b/packages/content/src/routes/api/v1/contentfeedsources/findByStatus/+server.ts deleted file mode 100644 index dcafbfb425..0000000000 --- a/packages/content/src/routes/api/v1/contentfeedsources/findByStatus/+server.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentFeedSource } from '../../../../../content-feed-source'; -import type { ContentFeedSourceCollection } from '../../../../../content-feed-sources'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: findByStatus -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentFeedSource', - ); - const typedCollection = collection as unknown as ContentFeedSourceCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentFeedSource collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - status: ActionArgs[0]; - tenantId: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.findByStatus( - options.status, - options.tenantId, - ); - - return json({ action: 'findByStatus', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernanceassignments/+server.ts b/packages/content/src/routes/api/v1/contentgovernanceassignments/+server.ts deleted file mode 100644 index c10f068a14..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernanceassignments/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernanceAssignment } from '../../../../content-governance-assignment'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentGovernanceAssignment is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentgovernanceassignments -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceAssignment', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentgovernanceassignment -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceAssignment', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernanceassignments/[id]/+server.ts b/packages/content/src/routes/api/v1/contentgovernanceassignments/[id]/+server.ts deleted file mode 100644 index 339e6ae5c1..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernanceassignments/[id]/+server.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernanceAssignment } from '../../../../../content-governance-assignment'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentgovernanceassignment -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceAssignment', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentGovernanceAssignment not found', - ); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentgovernanceassignment -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceAssignment', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentGovernanceAssignment not found', - ); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentgovernanceassignment -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceAssignment', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentGovernanceAssignment not found', - ); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernanceassignments/getByKey/+server.ts b/packages/content/src/routes/api/v1/contentgovernanceassignments/getByKey/+server.ts deleted file mode 100644 index c69528f27e..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernanceassignments/getByKey/+server.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernanceAssignment } from '../../../../../content-governance-assignment'; -import type { ContentGovernanceAssignmentCollection } from '../../../../../content-governance-assignments'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getByKey -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceAssignment', - ); - const typedCollection = - collection as unknown as ContentGovernanceAssignmentCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentGovernanceAssignment collection is not registered', - ); - - type ActionArgs = Parameters< - ContentGovernanceAssignmentCollection['getByKey'] - >; - type ActionOptions = { - key: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getByKey(options.key); - - return json({ action: 'getByKey', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernanceassignments/resolveForContent/+server.ts b/packages/content/src/routes/api/v1/contentgovernanceassignments/resolveForContent/+server.ts deleted file mode 100644 index a9bf2c745a..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernanceassignments/resolveForContent/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernanceAssignment } from '../../../../../content-governance-assignment'; -import type { ContentGovernanceAssignmentCollection } from '../../../../../content-governance-assignments'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: resolveForContent -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceAssignment', - ); - const typedCollection = - collection as unknown as ContentGovernanceAssignmentCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentGovernanceAssignment collection is not registered', - ); - - type ActionArgs = Parameters< - ContentGovernanceAssignmentCollection['resolveForContent'] - >; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await typedCollection.resolveForContent(options); - - return json({ action: 'resolveForContent', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernancepolicies/+server.ts b/packages/content/src/routes/api/v1/contentgovernancepolicies/+server.ts deleted file mode 100644 index 6c63f512c6..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernancepolicies/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernancePolicy } from '../../../../content-governance-policy'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentGovernancePolicy is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentgovernancepolicys -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernancePolicy', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentgovernancepolicy -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernancePolicy', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernancepolicies/[id]/+server.ts b/packages/content/src/routes/api/v1/contentgovernancepolicies/[id]/+server.ts deleted file mode 100644 index 44ebcc55bc..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernancepolicies/[id]/+server.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernancePolicy } from '../../../../../content-governance-policy'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentgovernancepolicy -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernancePolicy', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentGovernancePolicy not found', - ); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentgovernancepolicy -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernancePolicy', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentGovernancePolicy not found', - ); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentgovernancepolicy -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernancePolicy', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentGovernancePolicy not found', - ); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernancepolicies/getByKey/+server.ts b/packages/content/src/routes/api/v1/contentgovernancepolicies/getByKey/+server.ts deleted file mode 100644 index 6f92563507..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernancepolicies/getByKey/+server.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernancePolicyCollection } from '../../../../../content-governance-policies'; -import type { ContentGovernancePolicy } from '../../../../../content-governance-policy'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getByKey -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernancePolicy', - ); - const typedCollection = - collection as unknown as ContentGovernancePolicyCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentGovernancePolicy collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - key: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getByKey(options.key); - - return json({ action: 'getByKey', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernanceprofiles/+server.ts b/packages/content/src/routes/api/v1/contentgovernanceprofiles/+server.ts deleted file mode 100644 index ed49569aae..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernanceprofiles/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernanceProfile } from '../../../../content-governance-profile'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentGovernanceProfile is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentgovernanceprofiles -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceProfile', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentgovernanceprofile -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceProfile', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernanceprofiles/[id]/+server.ts b/packages/content/src/routes/api/v1/contentgovernanceprofiles/[id]/+server.ts deleted file mode 100644 index 3d125b420f..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernanceprofiles/[id]/+server.ts +++ /dev/null @@ -1,240 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernanceProfile } from '../../../../../content-governance-profile'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentgovernanceprofile -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceProfile', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentGovernanceProfile not found', - ); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentgovernanceprofile -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceProfile', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentGovernanceProfile not found', - ); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentgovernanceprofile -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceProfile', - ); - const item = await collection.get(params.id); - if (!item) - throw error( - 404, - '@happyvertical/smrt-content:ContentGovernanceProfile not found', - ); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentgovernanceprofiles/getByKey/+server.ts b/packages/content/src/routes/api/v1/contentgovernanceprofiles/getByKey/+server.ts deleted file mode 100644 index 181432be64..0000000000 --- a/packages/content/src/routes/api/v1/contentgovernanceprofiles/getByKey/+server.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentGovernanceProfile } from '../../../../../content-governance-profile'; -import type { ContentGovernanceProfileCollection } from '../../../../../content-governance-profiles'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getByKey -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentGovernanceProfile', - ); - const typedCollection = - collection as unknown as ContentGovernanceProfileCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentGovernanceProfile collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - key: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getByKey(options.key); - - return json({ action: 'getByKey', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/+server.ts b/packages/content/src/routes/api/v1/contentreferences/+server.ts deleted file mode 100644 index 14dd0590aa..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../content-reference'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentReference is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentreferences -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentreference -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/[id]/+server.ts b/packages/content/src/routes/api/v1/contentreferences/[id]/+server.ts deleted file mode 100644 index 67d6a58305..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/[id]/+server.ts +++ /dev/null @@ -1,231 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../../content-reference'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentreference -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentReference not found'); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentreference -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const item = await collection.get(params.id); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentReference not found'); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; - -// Delete contentreference -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const item = await collection.get(params.id); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentReference not found'); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/attach/+server.ts b/packages/content/src/routes/api/v1/contentreferences/attach/+server.ts deleted file mode 100644 index 6125788f6d..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/attach/+server.ts +++ /dev/null @@ -1,145 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../../content-reference'; -import type { ContentReferences } from '../../../../../content-references'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: attach -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const typedCollection = collection as unknown as ContentReferences; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReference collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - sourceId: ActionArgs[0]; - targetId: ActionArgs[1]; - opts: ActionArgs[2]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.attach( - options.sourceId, - options.targetId, - options.opts, - ); - - return json({ action: 'attach', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/byLeft/+server.ts b/packages/content/src/routes/api/v1/contentreferences/byLeft/+server.ts deleted file mode 100644 index 1e34fcc9be..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/byLeft/+server.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../../content-reference'; -import type { ContentReferences } from '../../../../../content-references'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: byLeft -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const typedCollection = collection as unknown as ContentReferences; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReference collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - leftId: ActionArgs[0]; - opts: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.byLeft(options.leftId, options.opts); - - return json({ action: 'byLeft', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/byRight/+server.ts b/packages/content/src/routes/api/v1/contentreferences/byRight/+server.ts deleted file mode 100644 index 2bbb7ae08b..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/byRight/+server.ts +++ /dev/null @@ -1,140 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../../content-reference'; -import type { ContentReferences } from '../../../../../content-references'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: byRight -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const typedCollection = collection as unknown as ContentReferences; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReference collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - rightId: ActionArgs[0]; - opts: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.byRight(options.rightId, options.opts); - - return json({ action: 'byRight', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/detach/+server.ts b/packages/content/src/routes/api/v1/contentreferences/detach/+server.ts deleted file mode 100644 index 2b613c528e..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/detach/+server.ts +++ /dev/null @@ -1,145 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../../content-reference'; -import type { ContentReferences } from '../../../../../content-references'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: detach -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const typedCollection = collection as unknown as ContentReferences; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReference collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - leftId: ActionArgs[0]; - rightId: ActionArgs[1]; - opts: ActionArgs[2]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.detach( - options.leftId, - options.rightId, - options.opts, - ); - - return json({ action: 'detach', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/getForSource/+server.ts b/packages/content/src/routes/api/v1/contentreferences/getForSource/+server.ts deleted file mode 100644 index 36156eb32c..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/getForSource/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../../content-reference'; -import type { ContentReferences } from '../../../../../content-references'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getForSource -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const typedCollection = collection as unknown as ContentReferences; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReference collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - sourceId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getForSource(options.sourceId); - - return json({ action: 'getForSource', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/getForTarget/+server.ts b/packages/content/src/routes/api/v1/contentreferences/getForTarget/+server.ts deleted file mode 100644 index 4a62028299..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/getForTarget/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../../content-reference'; -import type { ContentReferences } from '../../../../../content-references'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getForTarget -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const typedCollection = collection as unknown as ContentReferences; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReference collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - targetId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getForTarget(options.targetId); - - return json({ action: 'getForTarget', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/setLinks/+server.ts b/packages/content/src/routes/api/v1/contentreferences/setLinks/+server.ts deleted file mode 100644 index 621b31cefc..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/setLinks/+server.ts +++ /dev/null @@ -1,145 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../../content-reference'; -import type { ContentReferences } from '../../../../../content-references'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: setLinks -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const typedCollection = collection as unknown as ContentReferences; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReference collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - leftId: ActionArgs[0]; - rightIds: ActionArgs[1]; - opts: ActionArgs[2]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.setLinks( - options.leftId, - options.rightIds, - options.opts, - ); - - return json({ action: 'setLinks', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreferences/unlink/+server.ts b/packages/content/src/routes/api/v1/contentreferences/unlink/+server.ts deleted file mode 100644 index 53aa0469c9..0000000000 --- a/packages/content/src/routes/api/v1/contentreferences/unlink/+server.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReference } from '../../../../../content-reference'; -import type { ContentReferences } from '../../../../../content-references'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: unlink -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReference', - ); - const typedCollection = collection as unknown as ContentReferences; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReference collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - sourceId: ActionArgs[0]; - targetId: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.unlink( - options.sourceId, - options.targetId, - ); - - return json({ action: 'unlink', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreviews/+server.ts b/packages/content/src/routes/api/v1/contentreviews/+server.ts deleted file mode 100644 index 9d794ded6f..0000000000 --- a/packages/content/src/routes/api/v1/contentreviews/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReview } from '../../../../content-review'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentReview is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentreviews -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReview', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentreview -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReview', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentreviews/[id]/+server.ts b/packages/content/src/routes/api/v1/contentreviews/[id]/+server.ts deleted file mode 100644 index 24b922e120..0000000000 --- a/packages/content/src/routes/api/v1/contentreviews/[id]/+server.ts +++ /dev/null @@ -1,216 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReview } from '../../../../../content-review'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentreview -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReview', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentReview not found'); - - return conditionalJson(request, item.toPublicJSON()); -}; - -// Update contentreview -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReview', - ); - const item = await collection.get(params.id); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentReview not found'); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - return json(item.toPublicJSON()); -}; diff --git a/packages/content/src/routes/api/v1/contentreviews/createFromResult/+server.ts b/packages/content/src/routes/api/v1/contentreviews/createFromResult/+server.ts deleted file mode 100644 index 78b07e6c4b..0000000000 --- a/packages/content/src/routes/api/v1/contentreviews/createFromResult/+server.ts +++ /dev/null @@ -1,136 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReview } from '../../../../../content-review'; -import type { ContentReviewCollection } from '../../../../../content-reviews'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: createFromResult -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReview', - ); - const typedCollection = collection as unknown as ContentReviewCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReview collection is not registered', - ); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await typedCollection.createFromResult(options); - - return json({ action: 'createFromResult', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreviews/getLatestForContent/+server.ts b/packages/content/src/routes/api/v1/contentreviews/getLatestForContent/+server.ts deleted file mode 100644 index a30a906c8b..0000000000 --- a/packages/content/src/routes/api/v1/contentreviews/getLatestForContent/+server.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReview } from '../../../../../content-review'; -import type { ContentReviewCollection } from '../../../../../content-reviews'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getLatestForContent -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReview', - ); - const typedCollection = collection as unknown as ContentReviewCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReview collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - contentId: ActionArgs[0]; - kind: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getLatestForContent( - options.contentId, - options.kind, - ); - - return json({ - action: 'getLatestForContent', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentreviews/getLatestForPolicyKey/+server.ts b/packages/content/src/routes/api/v1/contentreviews/getLatestForPolicyKey/+server.ts deleted file mode 100644 index 1900ad6227..0000000000 --- a/packages/content/src/routes/api/v1/contentreviews/getLatestForPolicyKey/+server.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReview } from '../../../../../content-review'; -import type { ContentReviewCollection } from '../../../../../content-reviews'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getLatestForPolicyKey -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReview', - ); - const typedCollection = collection as unknown as ContentReviewCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReview collection is not registered', - ); - - type ActionArgs = Parameters< - ContentReviewCollection['getLatestForPolicyKey'] - >; - type ActionOptions = { - contentId: ActionArgs[0]; - policyKey: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getLatestForPolicyKey( - options.contentId, - options.policyKey, - ); - - return json({ - action: 'getLatestForPolicyKey', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentreviews/listForContent/+server.ts b/packages/content/src/routes/api/v1/contentreviews/listForContent/+server.ts deleted file mode 100644 index 172f8fe952..0000000000 --- a/packages/content/src/routes/api/v1/contentreviews/listForContent/+server.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReview } from '../../../../../content-review'; -import type { ContentReviewCollection } from '../../../../../content-reviews'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: listForContent -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReview', - ); - const typedCollection = collection as unknown as ContentReviewCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReview collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - contentId: ActionArgs[0]; - kind: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.listForContent( - options.contentId, - options.kind, - ); - - return json({ action: 'listForContent', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentreviews/listForContentByPolicyKey/+server.ts b/packages/content/src/routes/api/v1/contentreviews/listForContentByPolicyKey/+server.ts deleted file mode 100644 index c1a62a0454..0000000000 --- a/packages/content/src/routes/api/v1/contentreviews/listForContentByPolicyKey/+server.ts +++ /dev/null @@ -1,148 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentReview } from '../../../../../content-review'; -import type { ContentReviewCollection } from '../../../../../content-reviews'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: listForContentByPolicyKey -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentReview', - ); - const typedCollection = collection as unknown as ContentReviewCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentReview collection is not registered', - ); - - type ActionArgs = Parameters< - ContentReviewCollection['listForContentByPolicyKey'] - >; - type ActionOptions = { - contentId: ActionArgs[0]; - policyKey: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.listForContentByPolicyKey( - options.contentId, - options.policyKey, - ); - - return json({ - action: 'listForContentByPolicyKey', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/+server.ts b/packages/content/src/routes/api/v1/contents/+server.ts deleted file mode 100644 index f2bde2adb8..0000000000 --- a/packages/content/src/routes/api/v1/contents/+server.ts +++ /dev/null @@ -1,227 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { serializeContent as serializeItemResponse } from '$lib/server/content-api-serializers'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../content'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:Content is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contents -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const serializedItems = await Promise.all( - items.map((item) => serializeItemResponse(item)), - ); - - return conditionalJson(request, { - items: serializedItems, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:content -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.create(data); - await item.save(); - - const serializedItem = await serializeItemResponse(item); - - return json(serializedItem, { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/+server.ts deleted file mode 100644 index 3a93c51cb9..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/+server.ts +++ /dev/null @@ -1,233 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { serializeContent as serializeItemResponse } from '$lib/server/content-api-serializers'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single content -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - const serializedItem = await serializeItemResponse(item); - - return conditionalJson(request, serializedItem); -}; - -// Update content -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - Object.assign(item, data); - await item.save(); - - const serializedItem = await serializeItemResponse(item); - - return json(serializedItem); -}; - -// Delete content -export const DELETE: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - await item.delete(); - return json({ success: true }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/corrections/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/corrections/+server.ts deleted file mode 100644 index f715ebf560..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/corrections/+server.ts +++ /dev/null @@ -1,152 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: listCorrections -export const GET: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - const result = await item.listCorrections(); - - return json({ action: 'listCorrections', result: toPublicResult(result) }); -}; - -// Custom action: issueCorrectionAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.issueCorrectionAction(options); - - return json({ - action: 'issueCorrectionAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/fact-audit/+server.ts deleted file mode 100644 index e152e80f00..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/+server.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: getFactAuditStateAction -export const GET: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - const result = await item.getFactAuditStateAction(); - - return json({ - action: 'getFactAuditStateAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/claims/recheck/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/fact-audit/claims/recheck/+server.ts deleted file mode 100644 index f58a451df9..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/claims/recheck/+server.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: recheckFactClaimsAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.recheckFactClaimsAction(options); - - return json({ - action: 'recheckFactClaimsAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/evidence/repair/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/fact-audit/evidence/repair/+server.ts deleted file mode 100644 index eb16d808b0..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/evidence/repair/+server.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: repairFactEvidenceAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.repairFactEvidenceAction(options); - - return json({ - action: 'repairFactEvidenceAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/evidence/status/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/fact-audit/evidence/status/+server.ts deleted file mode 100644 index 0b5a90442b..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/evidence/status/+server.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: updateFactEvidenceStatusAction -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.updateFactEvidenceStatusAction(options); - - return json({ - action: 'updateFactEvidenceStatusAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/repair/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/fact-audit/repair/+server.ts deleted file mode 100644 index 6c7b1b84ff..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/fact-audit/repair/+server.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: repairFactAuditAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.repairFactAuditAction(options); - - return json({ - action: 'repairFactAuditAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/facts/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/facts/+server.ts deleted file mode 100644 index 7450b2c3f0..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/facts/+server.ts +++ /dev/null @@ -1,153 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: getFactsState -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const options = Object.fromEntries( - new URL(request.url).searchParams.entries(), - ) as ActionArgs[0]; - const result = await item.getFactsState(options); - - return json({ action: 'getFactsState', result: toPublicResult(result) }); -}; - -// Custom action: syncFactsState -export const PUT: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.syncFactsState(options); - - return json({ action: 'syncFactsState', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/governance/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/governance/+server.ts deleted file mode 100644 index 405fde19d8..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/governance/+server.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: getGovernanceStateAction -export const GET: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - const result = await item.getGovernanceStateAction(); - - return json({ - action: 'getGovernanceStateAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/review-profiles/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/review-profiles/+server.ts deleted file mode 100644 index 5eb86d8dd1..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/review-profiles/+server.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: listReviewProfilesAction -export const GET: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - const result = await item.listReviewProfilesAction(); - - return json({ - action: 'listReviewProfilesAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/review-profiles/[profileKey]/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/review-profiles/[profileKey]/+server.ts deleted file mode 100644 index 8639afde45..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/review-profiles/[profileKey]/+server.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: evaluateReviewProfileAction -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const pathParams = { - profileKey: params.profileKey, - }; - const options = { - ...Object.fromEntries(new URL(request.url).searchParams.entries()), - ...pathParams, - } as ActionArgs[0]; - const result = await item.evaluateReviewProfileAction(options); - - return json({ - action: 'evaluateReviewProfileAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/reviews/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/reviews/+server.ts deleted file mode 100644 index acda20a112..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/reviews/+server.ts +++ /dev/null @@ -1,153 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: listReviews -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const options = Object.fromEntries( - new URL(request.url).searchParams.entries(), - ) as ActionArgs[0]; - const result = await item.listReviews(options); - - return json({ action: 'listReviews', result: toPublicResult(result) }); -}; - -// Custom action: runReviewAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.runReviewAction(options); - - return json({ action: 'runReviewAction', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/transparency/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/transparency/+server.ts deleted file mode 100644 index b52e38d240..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/transparency/+server.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: getPublishedTransparencyAction -export const GET: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - const result = await item.getPublishedTransparencyAction(); - - return json({ - action: 'getPublishedTransparencyAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/transparency/preview/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/transparency/preview/+server.ts deleted file mode 100644 index 93910c0255..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/transparency/preview/+server.ts +++ /dev/null @@ -1,134 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: previewTransparencyAction -export const GET: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - const result = await item.previewTransparencyAction(); - - return json({ - action: 'previewTransparencyAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/[id]/versions/+server.ts b/packages/content/src/routes/api/v1/contents/[id]/versions/+server.ts deleted file mode 100644 index b5811ffcec..0000000000 --- a/packages/content/src/routes/api/v1/contents/[id]/versions/+server.ts +++ /dev/null @@ -1,152 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../content'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: listVersions -export const GET: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - const result = await item.listVersions(); - - return json({ action: 'listVersions', result: toPublicResult(result) }); -}; - -// Custom action: mutateVersionAction -export const POST: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const item = await collection.get(params.id); - if (!item) throw error(404, '@happyvertical/smrt-content:Content not found'); - - type ActionArgs = Parameters; - const body: unknown = await request.json(); - const options = body as ActionArgs[0]; - const result = await item.mutateVersionAction(options); - - return json({ - action: 'mutateVersionAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/by-slug/+server.ts b/packages/content/src/routes/api/v1/contents/by-slug/+server.ts deleted file mode 100644 index 7409e58608..0000000000 --- a/packages/content/src/routes/api/v1/contents/by-slug/+server.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../content'; -import type { Contents } from '../../../../../contents'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - getCurrentTenant, - hasTenantContext, - isSuperAdminBypass, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -function tenantReadOptionsScope(): { tenantId: string | null } | undefined { - if (!isTenancyEnabled() || isSuperAdminBypass()) { - return undefined; - } - return { tenantId: getCurrentTenant()?.tenantId ?? null }; -} - -// Custom collection method: getBySlug -export const GET: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const typedCollection = collection as unknown as Contents; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:Content collection is not registered', - ); - - type ActionArgs = Parameters; - const options = Object.fromEntries( - new URL(request.url).searchParams.entries(), - ) as ActionArgs[0]; - const readScope = tenantReadOptionsScope(); - const scopedOptions = readScope - ? ({ ...options, ...readScope } as ActionArgs[0]) - : options; - - const result = await typedCollection.getBySlug(scopedOptions); - - return json({ action: 'getBySlug', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contents/facts/+server.ts b/packages/content/src/routes/api/v1/contents/facts/+server.ts deleted file mode 100644 index ea8612f3ac..0000000000 --- a/packages/content/src/routes/api/v1/contents/facts/+server.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../content'; -import type { Contents } from '../../../../../contents'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - getCurrentTenant, - hasTenantContext, - isSuperAdminBypass, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -function tenantReadOptionsScope(): { tenantId: string | null } | undefined { - if (!isTenancyEnabled() || isSuperAdminBypass()) { - return undefined; - } - return { tenantId: getCurrentTenant()?.tenantId ?? null }; -} - -// Custom collection method: browseFacts -export const GET: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const typedCollection = collection as unknown as Contents; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:Content collection is not registered', - ); - - type ActionArgs = Parameters; - const options = Object.fromEntries( - new URL(request.url).searchParams.entries(), - ) as ActionArgs[0]; - const readScope = tenantReadOptionsScope(); - const scopedOptions = readScope - ? ({ ...options, ...readScope } as ActionArgs[0]) - : options; - - const result = await typedCollection.browseFacts(scopedOptions); - - return json({ action: 'browseFacts', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contents/governance/+server.ts b/packages/content/src/routes/api/v1/contents/governance/+server.ts deleted file mode 100644 index e395bbfea3..0000000000 --- a/packages/content/src/routes/api/v1/contents/governance/+server.ts +++ /dev/null @@ -1,155 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../content'; -import type { Contents } from '../../../../../contents'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - getCurrentTenant, - hasTenantContext, - isSuperAdminBypass, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -function tenantReadOptionsScope(): { tenantId: string | null } | undefined { - if (!isTenancyEnabled() || isSuperAdminBypass()) { - return undefined; - } - return { tenantId: getCurrentTenant()?.tenantId ?? null }; -} - -// Custom collection method: getGovernanceDefinitionsAction -export const GET: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const typedCollection = collection as unknown as Contents; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:Content collection is not registered', - ); - - type ActionArgs = Parameters; - const options = Object.fromEntries( - new URL(request.url).searchParams.entries(), - ) as ActionArgs[0]; - const readScope = tenantReadOptionsScope(); - const scopedOptions = readScope - ? ({ ...options, ...readScope } as ActionArgs[0]) - : options; - - const result = - await typedCollection.getGovernanceDefinitionsAction(scopedOptions); - - return json({ - action: 'getGovernanceDefinitionsAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contents/governance/resolve/+server.ts b/packages/content/src/routes/api/v1/contents/governance/resolve/+server.ts deleted file mode 100644 index 79f43f7bf1..0000000000 --- a/packages/content/src/routes/api/v1/contents/governance/resolve/+server.ts +++ /dev/null @@ -1,154 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { Content } from '../../../../../../content'; -import type { Contents } from '../../../../../../contents'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - getCurrentTenant, - hasTenantContext, - isSuperAdminBypass, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -function tenantReadOptionsScope(): { tenantId: string | null } | undefined { - if (!isTenancyEnabled() || isSuperAdminBypass()) { - return undefined; - } - return { tenantId: getCurrentTenant()?.tenantId ?? null }; -} - -// Custom collection method: resolveGovernanceAction -export const GET: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:Content', - ); - const typedCollection = collection as unknown as Contents; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:Content collection is not registered', - ); - - type ActionArgs = Parameters; - const options = Object.fromEntries( - new URL(request.url).searchParams.entries(), - ) as ActionArgs[0]; - const readScope = tenantReadOptionsScope(); - const scopedOptions = readScope - ? ({ ...options, ...readScope } as ActionArgs[0]) - : options; - - const result = await typedCollection.resolveGovernanceAction(scopedOptions); - - return json({ - action: 'resolveGovernanceAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/+server.ts b/packages/content/src/routes/api/v1/contentversions/+server.ts deleted file mode 100644 index aca32907fa..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/+server.ts +++ /dev/null @@ -1,221 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../content-version'; -import type { RequestHandler } from './$types'; - -// Note: @happyvertical/smrt-content:ContentVersion is auto-registered by the Vite plugin scanner - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Mass-assignment guard (#1540): strip framework/server-managed + read-only -// fields from create/update request bodies before they reach the model. -const WRITABLE_ALLOWLIST: string[] | null = null; -const READONLY_FIELDS: string[] = []; -const SERVER_MANAGED_FIELDS = [ - 'id', - 'tenantId', - 'tenant_id', - 'createdAt', - 'created_at', - 'updatedAt', - 'updated_at', -]; - -function applyWritablePolicy(data: unknown): Record { - if (!data || typeof data !== 'object') return {}; - const result: Record = {}; - for (const [key, value] of Object.entries(data as Record)) { - if (key.startsWith('_')) continue; - if (SERVER_MANAGED_FIELDS.includes(key)) continue; - if (READONLY_FIELDS.includes(key)) continue; - if (WRITABLE_ALLOWLIST && !WRITABLE_ALLOWLIST.includes(key)) continue; - result[key] = value; - } - return result; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// List all @happyvertical/smrt-content:contentversions -export const GET: RequestHandler = async ({ locals, url, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const limit = Number(url.searchParams.get('limit')) || 50; - const offset = Number(url.searchParams.get('offset')) || 0; - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const readScope = tenantReadScope(); - const items = await collection.list({ limit, offset, where: readScope }); - const count = await collection.count({ where: readScope }); - - const items_public = items.map((item) => item.toPublicJSON()); - return conditionalJson(request, { - items: items_public, - count, - limit, - offset, - }); -}; - -// Create new @happyvertical/smrt-content:contentversion -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const body: unknown = await request.json(); - const data = applyWritablePolicy(body); - - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const item = await collection.create(data); - await item.save(); - - return json(item.toPublicJSON(), { status: 201 }); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/[id]/+server.ts b/packages/content/src/routes/api/v1/contentversions/[id]/+server.ts deleted file mode 100644 index df74efb7fa..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/[id]/+server.ts +++ /dev/null @@ -1,170 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../../content-version'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Conditional GET (#1757): strong body-hash ETag + If-None-Match → 304 with an -// empty body. Reads stay private unless the model is public AND opts into -// shared caching via @smrt({ api: { cache: { sMaxage } } }). -import { createHash } from 'node:crypto'; - -const READ_CACHE_CONTROL = 'private, no-cache'; - -function bodyEtag(body: string): string { - return `"${createHash('sha256').update(body).digest('base64url')}"`; -} - -function ifNoneMatchSatisfied(header: string | null, etag: string): boolean { - if (!header) return false; - if (header.trim() === '*') return true; - return header.split(',').some((candidate) => { - const tag = candidate.trim(); - const opaque = tag.startsWith('W/') ? tag.slice(2) : tag; - return opaque === etag; - }); -} - -function conditionalJson(request: Request, payload: unknown): Response { - const body = JSON.stringify(payload); - const etag = bodyEtag(body); - if (ifNoneMatchSatisfied(request.headers.get('if-none-match'), etag)) { - return new Response(null, { - status: 304, - headers: { 'cache-control': READ_CACHE_CONTROL, etag }, - }); - } - return new Response(body, { - status: 200, - headers: { - 'cache-control': READ_CACHE_CONTROL, - 'content-type': 'application/json', - etag, - }, - }); -} - -// Get single contentversion -export const GET: RequestHandler = async ({ locals, params, request }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentVersion not found'); - - return conditionalJson(request, item.toPublicJSON()); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/[id]/transparency/+server.ts b/packages/content/src/routes/api/v1/contentversions/[id]/transparency/+server.ts deleted file mode 100644 index 6c90566ae8..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/[id]/transparency/+server.ts +++ /dev/null @@ -1,135 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../../../content-version'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom action: getTransparencyAction -export const GET: RequestHandler = async ({ locals, params }) => { - requireRouteAuth(locals, false); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const readScope = tenantReadScope(); - const item = await collection.get( - readScope ? { id: params.id, ...readScope } : params.id, - ); - if (!item) - throw error(404, '@happyvertical/smrt-content:ContentVersion not found'); - - const result = await item.getTransparencyAction(); - - return json({ - action: 'getTransparencyAction', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/createSnapshot/+server.ts b/packages/content/src/routes/api/v1/contentversions/createSnapshot/+server.ts deleted file mode 100644 index f06340b20a..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/createSnapshot/+server.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../../content-version'; -import type { ContentVersionCollection } from '../../../../../content-versions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: createSnapshot -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const typedCollection = collection as unknown as ContentVersionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentVersion collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - content: ActionArgs[0]; - options: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.createSnapshot( - options.content, - options.options, - ); - - return json({ action: 'createSnapshot', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/getLatestForContent/+server.ts b/packages/content/src/routes/api/v1/contentversions/getLatestForContent/+server.ts deleted file mode 100644 index 446faf8168..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/getLatestForContent/+server.ts +++ /dev/null @@ -1,142 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../../content-version'; -import type { ContentVersionCollection } from '../../../../../content-versions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getLatestForContent -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const typedCollection = collection as unknown as ContentVersionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentVersion collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - contentId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getLatestForContent(options.contentId); - - return json({ - action: 'getLatestForContent', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/getLatestPublishedForContent/+server.ts b/packages/content/src/routes/api/v1/contentversions/getLatestPublishedForContent/+server.ts deleted file mode 100644 index e7138ae37a..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/getLatestPublishedForContent/+server.ts +++ /dev/null @@ -1,146 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../../content-version'; -import type { ContentVersionCollection } from '../../../../../content-versions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getLatestPublishedForContent -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const typedCollection = collection as unknown as ContentVersionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentVersion collection is not registered', - ); - - type ActionArgs = Parameters< - ContentVersionCollection['getLatestPublishedForContent'] - >; - type ActionOptions = { - contentId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getLatestPublishedForContent( - options.contentId, - ); - - return json({ - action: 'getLatestPublishedForContent', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/getNextVersionNumber/+server.ts b/packages/content/src/routes/api/v1/contentversions/getNextVersionNumber/+server.ts deleted file mode 100644 index 118bf96432..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/getNextVersionNumber/+server.ts +++ /dev/null @@ -1,144 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../../content-version'; -import type { ContentVersionCollection } from '../../../../../content-versions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getNextVersionNumber -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const typedCollection = collection as unknown as ContentVersionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentVersion collection is not registered', - ); - - type ActionArgs = Parameters< - ContentVersionCollection['getNextVersionNumber'] - >; - type ActionOptions = { - contentId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getNextVersionNumber(options.contentId); - - return json({ - action: 'getNextVersionNumber', - result: toPublicResult(result), - }); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/getVersion/+server.ts b/packages/content/src/routes/api/v1/contentversions/getVersion/+server.ts deleted file mode 100644 index 5ea5d0b3a3..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/getVersion/+server.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../../content-version'; -import type { ContentVersionCollection } from '../../../../../content-versions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: getVersion -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const typedCollection = collection as unknown as ContentVersionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentVersion collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - contentId: ActionArgs[0]; - versionNumber: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.getVersion( - options.contentId, - options.versionNumber, - ); - - return json({ action: 'getVersion', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/listForContent/+server.ts b/packages/content/src/routes/api/v1/contentversions/listForContent/+server.ts deleted file mode 100644 index 7d692edbd7..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/listForContent/+server.ts +++ /dev/null @@ -1,139 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../../content-version'; -import type { ContentVersionCollection } from '../../../../../content-versions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: listForContent -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const typedCollection = collection as unknown as ContentVersionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentVersion collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - contentId: ActionArgs[0]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.listForContent(options.contentId); - - return json({ action: 'listForContent', result: toPublicResult(result) }); -}; diff --git a/packages/content/src/routes/api/v1/contentversions/restoreIntoContent/+server.ts b/packages/content/src/routes/api/v1/contentversions/restoreIntoContent/+server.ts deleted file mode 100644 index 62757dea2b..0000000000 --- a/packages/content/src/routes/api/v1/contentversions/restoreIntoContent/+server.ts +++ /dev/null @@ -1,143 +0,0 @@ -// Auto-generated by @smrt/core vite plugin -// DO NOT EDIT - changes will be overwritten - -import { error, json } from '@sveltejs/kit'; -import { getCollection } from '$lib/server/smrt'; -import type { ContentVersion } from '../../../../../content-version'; -import type { ContentVersionCollection } from '../../../../../content-versions'; -import type { RequestHandler } from './$types'; - -// Fail-closed authorization (#1540): generated routes require an authenticated -// principal on `locals` unless explicitly marked `@smrt({ api: { public } })`. -const PUBLIC_ACCESS: boolean | 'read' = false; - -function hasAuthenticatedPrincipal(locals: unknown): boolean { - if (!locals || typeof locals !== 'object') return false; - const l = locals as Record; - // Only a resolved, object-shaped principal counts. We intentionally do NOT - // treat `locals.auth` as a signal: Auth.js/SvelteKit put a callable - // `auth()` helper on every request (including anonymous ones), so honoring - // it would fail OPEN. Booleans don't count either (no convention sets - // `locals.user = true`); the only boolean accepted is the explicit - // `smrtAuth` opt-in marker. - const isResolvedPrincipal = (v: unknown) => - typeof v === 'object' && v !== null; - return ( - isResolvedPrincipal(l.user) || - isResolvedPrincipal(l.session) || - l.smrtAuth === true - ); -} - -function requireRouteAuth(locals: unknown, mutating: boolean): void { - if (PUBLIC_ACCESS === true) return; - if (PUBLIC_ACCESS === 'read' && !mutating) return; - if (!hasAuthenticatedPrincipal(locals)) { - throw error(401, 'Authentication required'); - } -} - -// Sensitive-field-safe serialization for custom-action results (#1540): a -// custom method may return a SmrtObject (or one nested in an array/plain -// object), so recurse and route each through toPublicJSON() rather than letting -// JSON.stringify call toJSON(). Non-plain instances (Date, etc.) and primitives -// pass through; a cycle guard prevents infinite loops. -interface PublicJsonSource { - toPublicJSON(): unknown; -} - -function hasPublicJson(value: object): value is PublicJsonSource { - return ( - 'toPublicJSON' in value && - typeof (value as { toPublicJSON?: unknown }).toPublicJSON === 'function' - ); -} - -function readJsonRecord(value: unknown): Record { - if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; - return value as Record; -} - -function toPublicResult( - value: unknown, - seen: WeakSet = new WeakSet(), -): unknown { - if (value === null || typeof value !== 'object') return value; - if (seen.has(value)) return null; - if (hasPublicJson(value)) { - seen.add(value); - return toPublicResult(value.toPublicJSON(), seen); - } - if (Array.isArray(value)) { - seen.add(value); - return value.map((entry) => toPublicResult(entry, seen)); - } - const proto = Object.getPrototypeOf(value); - if (proto !== Object.prototype && proto !== null) return value; - seen.add(value); - const out: Record = {}; - for (const [key, entry] of Object.entries(value as Record)) { - out[key] = toPublicResult(entry, seen); - } - return out; -} - -import { - enterTenantContext, - hasTenantContext, - isTenancyEnabled, -} from '@happyvertical/smrt-tenancy'; - -function establishTenantContext(locals: unknown): void { - if (hasTenantContext()) return; - if (!locals || typeof locals !== 'object') return; - const l = locals as Record; - const user = l.user as Record | undefined; - const session = l.session as Record | undefined; - const tenantId = l.tenantId ?? user?.tenantId ?? session?.tenantId; - if (typeof tenantId === 'string' && tenantId) { - enterTenantContext({ tenantId }); - } -} - -// Fail-closed read scope (#1782): a public/anonymous read on a @TenantScoped -// model has no tenant context, so the tenancy interceptor (optional mode) would -// pass the query through UNFILTERED and return every tenant's rows. When tenancy -// is enabled but no context was established, restrict reads to NULL-tenant -// (global) rows only — mirroring the dispatch resolver + _changes convention: -// tenancy enforced with no context => global rows only. Returns undefined when a -// context is active (the interceptor filters by it) or tenancy is disabled. -function tenantReadScope(): { tenantId: null } | undefined { - return isTenancyEnabled() && !hasTenantContext() - ? { tenantId: null } - : undefined; -} - -// Custom collection method: restoreIntoContent -export const POST: RequestHandler = async ({ locals, request }) => { - requireRouteAuth(locals, true); - establishTenantContext(locals); - const collection = await getCollection( - '@happyvertical/smrt-content:ContentVersion', - ); - const typedCollection = collection as unknown as ContentVersionCollection; - if (!collection) - throw error( - 500, - '@happyvertical/smrt-content:ContentVersion collection is not registered', - ); - - type ActionArgs = Parameters; - type ActionOptions = { - content: ActionArgs[0]; - versionNumber: ActionArgs[1]; - }; - const body: unknown = await request.json(); - const options = readJsonRecord(body) as ActionOptions; - const result = await typedCollection.restoreIntoContent( - options.content, - options.versionNumber, - ); - - return json({ action: 'restoreIntoContent', result: toPublicResult(result) }); -}; diff --git a/packages/core/README.md b/packages/core/README.md index 8f2811ded3..873916db85 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -110,17 +110,32 @@ visible to Git, including routes that live beside generated resource handlers. Generation refreshes that block, so stale generated paths stop being ignored when the generator no longer owns them. -**Migration note:** the first generation after this release replaces the -legacy adjacent pair below with the bounded exact-path block: +**Migration note:** generation replaces the legacy block below with the bounded +exact-path block: ```gitignore # SMRT auto-generated routes (from Vite plugin) src/routes/api/**/+server.ts +!src/routes/api/v1/**/+server.ts ``` -Only that recognized SMRT pair is migrated. A broad rule that you added or -moved yourself is left unchanged; remove or narrow it manually if it hides a -handwritten route. +Migration takes the recognized SMRT header plus the contiguous run of +recursive `+server.ts` wildcards directly beneath it, negations included. The +run is matched by shape rather than against your current `routesDir`, so a +project that moved `routesDir` after adopting the plugin still migrates, and +migration still runs once the bounded block exists — leaving a stale negation +in place would silently re-include whatever the bounded block stops listing. + +The first line that is not a recursive `+server.ts` wildcard ends the run. A +broad rule you added or moved yourself is left unchanged, including an +identical pattern elsewhere in the file; remove or narrow it manually if it +hides a handwritten route. + +Generated routes are build output and are not meant to be committed: they are +regenerated on every dev-server start and are not formatted to your Biome or +Prettier configuration, so tracking them makes a lint job fail on output no +one edits. Track the handwritten handlers beside them — the bounded block +lists only generator-owned paths, so they stay visible to Git. ### AI operations diff --git a/packages/core/src/vite-plugin/sveltekit-generator.test.ts b/packages/core/src/vite-plugin/sveltekit-generator.test.ts index a0e98ccbd0..0880774e0a 100644 --- a/packages/core/src/vite-plugin/sveltekit-generator.test.ts +++ b/packages/core/src/vite-plugin/sveltekit-generator.test.ts @@ -1646,6 +1646,170 @@ describe('SvelteKit Route Generator', () => { expect(content).toContain('src/routes/api/testobjects/+server.ts'); expect(content).not.toContain('src/routes/api/**/+server.ts'); }); + + it('should migrate a legacy pair written under an earlier routesDir', async () => { + // #2198: `@happyvertical/smrt-content` adopted the plugin at + // `src/routes/api` and later moved to `src/routes/api/v1`, so the legacy + // pair never matched a routesDir-derived pattern and survived every + // regeneration. + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + [ + 'node_modules/', + '# SMRT auto-generated routes (from Vite plugin)', + 'src/routes/api/**/+server.ts', + '!src/routes/api/v1/**/+server.ts', + '', + ].join('\n'), + ); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const manifest: SmartObjectManifest = { + objects: { + TestObject: { + className: 'TestObject', + collection: 'testobjects', + fields: {}, + methods: {}, + decoratorConfig: { api: true }, + }, + }, + }; + + await generateSvelteKitRoutes(projectRoot, manifest, { + enabled: true, + routesDir: 'src/routes/api/v1', + objectsDir: 'src/lib/objects', + }); + + const gitignoreWrite = vi + .mocked(writeFileSync) + .mock.calls.find((call) => call[0].toString().endsWith('.gitignore')); + const content = gitignoreWrite?.[1] as string; + + expect(gitignoreWrite).toBeDefined(); + expect(content).not.toContain( + '# SMRT auto-generated routes (from Vite plugin)', + ); + expect(content).not.toContain('src/routes/api/**/+server.ts'); + // The negation is generator-owned idiom directly under the generator's + // own header, and leaving it re-includes whatever the bounded block + // stops listing. + expect(content).not.toContain('!src/routes/api/v1/**/+server.ts'); + expect(content).toContain('src/routes/api/v1/testobjects/+server.ts'); + expect(content).toContain('node_modules/'); + + consoleSpy.mockRestore(); + }); + + it('should migrate a surviving legacy pair alongside an existing bounded block', async () => { + // #2198: the first #2185 release appended its bounded block without + // migrating an unmatched legacy pair, leaving both shapes in the file. + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + [ + '# SMRT auto-generated routes (from Vite plugin)', + 'src/routes/api/**/+server.ts', + '!src/routes/api/v1/**/+server.ts', + '', + '# BEGIN SMRT auto-generated routes (Vite plugin)', + 'src/routes/api/v1/stale/+server.ts', + '# END SMRT auto-generated routes (Vite plugin)', + '', + ].join('\n'), + ); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const manifest: SmartObjectManifest = { + objects: { + TestObject: { + className: 'TestObject', + collection: 'testobjects', + fields: {}, + methods: {}, + decoratorConfig: { api: true }, + }, + }, + }; + + await generateSvelteKitRoutes(projectRoot, manifest, { + enabled: true, + routesDir: 'src/routes/api/v1', + objectsDir: 'src/lib/objects', + }); + + const gitignoreWrite = vi + .mocked(writeFileSync) + .mock.calls.find((call) => call[0].toString().endsWith('.gitignore')); + const content = gitignoreWrite?.[1] as string; + + expect(gitignoreWrite).toBeDefined(); + expect(content).not.toContain( + '# SMRT auto-generated routes (from Vite plugin)', + ); + expect(content).not.toContain('!src/routes/api/v1/**/+server.ts'); + expect(content).not.toContain('src/routes/api/v1/stale/+server.ts'); + expect(content).toContain('src/routes/api/v1/testobjects/+server.ts'); + expect( + content + .split('\n') + .filter( + (line) => + line === '# BEGIN SMRT auto-generated routes (Vite plugin)', + ), + ).toHaveLength(1); + + consoleSpy.mockRestore(); + }); + + it('should leave a legacy header with no recognizable pattern beneath it', async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFileSync).mockReturnValue( + [ + '# SMRT auto-generated routes (from Vite plugin)', + '# the project rewrote this block by hand', + 'src/routes/api/legacy-handwritten/+server.ts', + '', + ].join('\n'), + ); + + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const manifest: SmartObjectManifest = { + objects: { + TestObject: { + className: 'TestObject', + collection: 'testobjects', + fields: {}, + methods: {}, + decoratorConfig: { api: true }, + }, + }, + }; + + await generateSvelteKitRoutes(projectRoot, manifest, { + enabled: true, + routesDir: 'src/routes/api', + objectsDir: 'src/lib/objects', + }); + + const gitignoreWrite = vi + .mocked(writeFileSync) + .mock.calls.find((call) => call[0].toString().endsWith('.gitignore')); + const content = gitignoreWrite?.[1] as string; + + expect(gitignoreWrite).toBeDefined(); + expect(content).toContain( + '# SMRT auto-generated routes (from Vite plugin)', + ); + expect(content).toContain('# the project rewrote this block by hand'); + expect(content).toContain('src/routes/api/legacy-handwritten/+server.ts'); + expect(content).toContain('src/routes/api/testobjects/+server.ts'); + + consoleSpy.mockRestore(); + }); }); describe('Main Generation Flow', () => { diff --git a/packages/core/src/vite-plugin/sveltekit-generator.ts b/packages/core/src/vite-plugin/sveltekit-generator.ts index e33498fbfb..0faef4509b 100644 --- a/packages/core/src/vite-plugin/sveltekit-generator.ts +++ b/packages/core/src/vite-plugin/sveltekit-generator.ts @@ -1207,7 +1207,7 @@ export async function generateSvelteKitRoutes( // Ignore only the concrete route files generated in this pass. This keeps // handwritten handlers below routesDir visible to Git. - updateGitignore(projectRoot, options, generatedRoutePaths); + updateGitignore(projectRoot, generatedRoutePaths); const skippedMsg = skippedCollections > 0 @@ -2819,7 +2819,6 @@ function rolesFrom(value: unknown): string[] { */ function updateGitignore( projectRoot: string, - options: SvelteKitOptions, generatedRoutePaths: readonly string[], ): void { const gitignorePath = join(projectRoot, '.gitignore'); @@ -2839,7 +2838,6 @@ function updateGitignore( ].sort((a, b) => a.localeCompare(b)); const updatedContent = updateGeneratedRouteIgnoreBlock( gitignoreContent, - options.routesDir, generatedPaths, ); @@ -2856,21 +2854,71 @@ function gitignorePatternForPath(path: string): string { return path.replaceAll('\\', '/').replaceAll(/([*?[\]\\!#])/g, '\\$1'); } +/** + * Whether a line has the shape versions before #2185 wrote beneath the legacy + * header: a recursive `+server.ts` wildcard, optionally negated. The pattern is + * matched by shape rather than against the currently configured `routesDir`, + * because a project that moved `routesDir` after adopting the plugin (as + * `@happyvertical/smrt-content` did, `src/routes/api` -> `src/routes/api/v1`) + * still carries the pair its earlier `routesDir` produced. + */ +function isLegacyGeneratedRoutePattern(line: string): boolean { + // A comment is a boundary, never part of the run — otherwise a note that + // happens to end in the same suffix would be swallowed with it. + if (line.startsWith('#')) { + return false; + } + return /^!?[^\s].*\/\*\*\/\+server\.ts$/.test(line); +} + +/** + * Drop the generator-owned legacy header together with the contiguous run of + * legacy-shaped patterns directly beneath it. Only that recognized run is + * migrated: the first line that is not legacy-shaped ends it, so an application + * rule — including an identical broad pattern the project owns elsewhere in the + * file — survives untouched. + */ +function stripLegacyGeneratedRouteIgnores(lines: string[]): void { + const headerIndex = lines.indexOf(LEGACY_GITIGNORE_HEADER); + if (headerIndex === -1) { + return; + } + + let end = headerIndex + 1; + while (end < lines.length && isLegacyGeneratedRoutePattern(lines[end])) { + end += 1; + } + + // A header with nothing recognizable beneath it is left alone rather than + // guessed at, matching the bounded-marker handling above. + if (end > headerIndex + 1) { + lines.splice(headerIndex, end - headerIndex); + } +} + function updateGeneratedRouteIgnoreBlock( gitignoreContent: string, - routesDir: string, generatedPaths: readonly string[], ): string { const lines = gitignoreContent.split('\n'); - const startIndex = lines.indexOf(GITIGNORE_MANAGED_BLOCK_START); - const endIndex = lines.indexOf(GITIGNORE_MANAGED_BLOCK_END); const managedBlock = [ GITIGNORE_MANAGED_BLOCK_START, ...generatedPaths, GITIGNORE_MANAGED_BLOCK_END, ]; - if (startIndex !== -1 || endIndex !== -1) { + if ( + lines.includes(GITIGNORE_MANAGED_BLOCK_START) || + lines.includes(GITIGNORE_MANAGED_BLOCK_END) + ) { + // Migration runs even once the bounded block exists: a project generated by + // the first #2185 release kept its unmatched legacy pair, and the negation + // in that pair silently re-includes anything the bounded block stops + // listing. Strip it before locating the markers so the indices stay valid. + stripLegacyGeneratedRouteIgnores(lines); + + const startIndex = lines.indexOf(GITIGNORE_MANAGED_BLOCK_START); + const endIndex = lines.indexOf(GITIGNORE_MANAGED_BLOCK_END); if (startIndex === -1 || endIndex < startIndex) { // An incomplete marker pair may have been authored by a consumer. Do // not guess at its boundary or remove any user-managed ignore rules. @@ -2885,17 +2933,7 @@ function updateGeneratedRouteIgnoreBlock( return lines.join('\n'); } - // Versions before #2185 wrote exactly this header followed by a recursive - // wildcard. Remove only that adjacent, generator-owned pair; an equivalent - // user rule elsewhere is intentionally preserved. - const legacyPattern = `${routesDir.replaceAll('\\', '/')}/**/+server.ts`; - const legacyIndex = lines.findIndex( - (line, index) => - line === LEGACY_GITIGNORE_HEADER && lines[index + 1] === legacyPattern, - ); - if (legacyIndex !== -1) { - lines.splice(legacyIndex, 2); - } + stripLegacyGeneratedRouteIgnores(lines); if (generatedPaths.length === 0) { return lines.join('\n'); diff --git a/packages/images/.gitignore b/packages/images/.gitignore index 513fb3e61e..386c08d5b6 100644 --- a/packages/images/.gitignore +++ b/packages/images/.gitignore @@ -1,2 +1,18 @@ -# SMRT auto-generated routes (from Vite plugin) -src/routes/api/**/+server.ts +# BEGIN SMRT auto-generated routes (Vite plugin) +src/routes/api/_changes/+server.ts +src/routes/api/_events/+server.ts +src/routes/api/assets/\[id\]/+server.ts +src/routes/api/assets/+server.ts +src/routes/api/assets/findByTenant/+server.ts +src/routes/api/assets/findGlobal/+server.ts +src/routes/api/assets/findWithGlobals/+server.ts +src/routes/api/assets/getByAspectRatio/+server.ts +src/routes/api/assets/getByMaxDimensions/+server.ts +src/routes/api/assets/getByMinDimensions/+server.ts +src/routes/api/assets/getHighResolution/+server.ts +src/routes/api/assets/getLandscape/+server.ts +src/routes/api/assets/getMissingAltText/+server.ts +src/routes/api/assets/getPortrait/+server.ts +src/routes/api/assets/getSquare/+server.ts +src/routes/api/sync/apply/+server.ts +# END SMRT auto-generated routes (Vite plugin) From 6a3f3d99bca1758027d94d2736df5cd149c251b3 Mon Sep 17 00:00:00 2001 From: Will Griffin Date: Mon, 3 Aug 2026 10:02:27 -0600 Subject: [PATCH 2/2] fix(content): give the coverage gate a generate:test hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/check-coverage.mjs` runs `pnpm exec vitest run --coverage` directly, bypassing the package's `test` script and therefore its `svelte-kit sync` step. With the generated routes no longer committed, `contents-api.test.ts` cannot resolve `./routes/api/v1/contents/+server`, the suite fails to load, no coverage summary is written, and the gate reports `content (T2): no coverage produced` instead of a percentage. The gate already runs `pnpm run --if-present generate:test` first for exactly this reason — core uses it to build its test manifest. Point content's at `svelte-kit sync`, which regenerates the whole route tree. Mark the task `cache: false` for this package: the root `generate:test` declares core's manifest paths as its `outputs`, so a turbo cache hit would restore nothing for content and silently skip the sync. Coverage gate now reports content (T2) 72.07% against a 70% floor. Refs #2198 --- packages/content/AGENTS.md | 3 ++- packages/content/package.json | 1 + packages/content/turbo.json | 10 ++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 packages/content/turbo.json diff --git a/packages/content/AGENTS.md b/packages/content/AGENTS.md index 8c2f676dcd..577cf7dd66 100644 --- a/packages/content/AGENTS.md +++ b/packages/content/AGENTS.md @@ -192,6 +192,7 @@ import { - **Reference pinning**: `ContentReference` is keyed on `(source_id, target_id)`; `targetVersion` is an attribute of the edge, not part of identity. Re-calling `addReference(target, { targetVersion })` updates the pin in place. Unpinned references (`targetVersion: null`) report `isDrifted: false` regardless of how stale the target is — pin them only when you want drift to be detectable. - **Generated API routes are not tracked**: everything the vite plugin emits under `src/routes/api/v1` is build output, listed in the bounded `.gitignore` block and regenerated on `npm run dev`. The handwritten handlers beside them (`contents/[id]/chat/**`, `images/**`) stay tracked and linted. Do not commit generated routes — they are not Biome-formatted, so tracking them turns the Lint job red (#2198) - **`pnpm typecheck` still rewrites the `.gitignore` block**: `svelte-check` runs a second generation pass at the plugin's default `routesDir`, so the managed block flips between the `src/routes/api/v1/*` shape (`pnpm dev`) and the `src/routes/api/*` shape. The spillover guard above the block keeps route files out of `git status` in both shapes; only the `.gitignore` itself churns. Discard that churn until #2199 lands -- **Tests import generated routes and that is fine**: `contents-api.test.ts` imports `contents/+server.ts` and `contents/[id]/+server.ts` to exercise the generated CRUD handlers against a real SQLite database. The `test` script runs `svelte-kit sync` first, which regenerates the whole route tree, so those files exist by the time Vitest loads — no committed copy needed. Run `pnpm test`, never bare `vitest`, or the imports resolve against whatever happens to be on disk +- **Tests import generated routes and that is fine**: `contents-api.test.ts` imports `contents/+server.ts` and `contents/[id]/+server.ts` to exercise the generated CRUD handlers against a real SQLite database. The `test` script runs `svelte-kit sync` first, which regenerates the whole route tree, so those files exist by the time Vitest loads — no committed copy needed. Run `pnpm test`, never bare `vitest`, or the imports fail to resolve +- **`generate:test` exists for the callers that bypass `test`**: `scripts/check-coverage.mjs` runs bare `vitest --coverage`, so without a `generate:test` hook the route imports above fail and the Coverage Gate reports `no coverage produced` rather than a real percentage. It is `cache: false` in `packages/content/turbo.json` — the root task's `outputs` describe core's manifest, so a turbo cache hit would restore nothing and silently skip `svelte-kit sync` - **Chat tables**: chat endpoint requires `@happyvertical/smrt-chat` tables; dev server handles missing tables gracefully - **Dev server bootstraps all classes**: `hooks.server.ts` generates schemas for all 13 local `@smrt()` classes plus cross-package manifests diff --git a/packages/content/package.json b/packages/content/package.json index ef0fc730d2..e8e57ecaff 100644 --- a/packages/content/package.json +++ b/packages/content/package.json @@ -43,6 +43,7 @@ "clean": "rm -rf dist", "dev": "vite dev", "prepack": "node ../../scripts/prepack-package.js", + "generate:test": "svelte-kit sync", "test": "svelte-kit sync && vitest run", "test:watch": "svelte-kit sync && vitest", "test:e2e": "pnpm exec playwright test -c playwright.config.ts", diff --git a/packages/content/turbo.json b/packages/content/turbo.json new file mode 100644 index 0000000000..4d38e471b5 --- /dev/null +++ b/packages/content/turbo.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://turborepo.com/schema.json", + "extends": ["//"], + "tasks": { + "generate:test": { + "dependsOn": ["^build"], + "cache": false + } + } +}