@lunora/advisor 1.0.0-alpha.4 → 1.0.0-alpha.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +6 -0
- package/README.md +1 -1
- package/dist/index.d.mts +3459 -941
- package/dist/index.d.ts +3459 -941
- package/dist/index.mjs +1 -79
- package/dist/packem_shared/AE_METRIC_EVENTS-LP_0D7Uw.mjs +1 -0
- package/dist/packem_shared/actionFetchSsrf-BbWxy1Yf.mjs +1 -0
- package/dist/packem_shared/adminRouteWithoutGuard-DSo4t9j4.mjs +1 -0
- package/dist/packem_shared/aiRawRunEscapeHatch-DfhjYm43.mjs +1 -0
- package/dist/packem_shared/aiToolSideEffectPromptInjection-DnWQOoKi.mjs +1 -0
- package/dist/packem_shared/aiUnboundedGenerationPublic-D3HZrh5_.mjs +1 -0
- package/dist/packem_shared/allowUnauthenticatedShardAccessEnabled-D4aMNBb0.mjs +1 -0
- package/dist/packem_shared/argument-derived-sink-BpO3NPPM.mjs +1 -0
- package/dist/packem_shared/authApiCallWithoutHeaders-BItMMdZP.mjs +1 -0
- package/dist/packem_shared/authCsrfCheckDisabled-De9ammtV.mjs +1 -0
- package/dist/packem_shared/authEmailVerificationDisabled-BFKdJcx4.mjs +1 -0
- package/dist/packem_shared/authSecureCookiesDisabled-B4WIzvMm.mjs +1 -0
- package/dist/packem_shared/authSessionFreshageZero-Bpcaf_Pe.mjs +1 -0
- package/dist/packem_shared/authTrustedOriginsWildcard-DjU9NXEm.mjs +1 -0
- package/dist/packem_shared/browserAllowPrivateTargets-Dxyeh6lg.mjs +1 -0
- package/dist/packem_shared/browserUserUrlWithoutAllowlist-BHBS6b0N.mjs +1 -0
- package/dist/packem_shared/circularFk-BMuJKmuZ.mjs +1 -0
- package/dist/packem_shared/constraintValidator-N339mfyx.mjs +1 -0
- package/dist/packem_shared/containerInstanceKeyFromUserInput-C8S4T3ty.mjs +1 -0
- package/dist/packem_shared/containerOversizedInstance-B9wW_sU5.mjs +1 -0
- package/dist/packem_shared/containerPublicInternet-Bx2J7tha.mjs +1 -0
- package/dist/packem_shared/containerRuntimeEgressRelaxation-BQ2ZD1aV.mjs +1 -0
- package/dist/packem_shared/containerStartEnableInternetOverride-B62WUura.mjs +1 -0
- package/dist/packem_shared/dedupeCacheKeys-7JgxaQWV.mjs +1 -0
- package/dist/packem_shared/duplicateIndex-DFT7qLQD.mjs +1 -0
- package/dist/packem_shared/emptyIndex-BiXEfyfN.mjs +1 -0
- package/dist/packem_shared/exportSinkMisconfigured-DvpAaa6j.mjs +1 -0
- package/dist/packem_shared/externalSourceIncrementalNoDeletePath-DnoL64YY.mjs +1 -0
- package/dist/packem_shared/externalSourceOnGlobal-DNPLoNUg.mjs +1 -0
- package/dist/packem_shared/externalSourceUnscoped-DqBp5jYr.mjs +1 -0
- package/dist/packem_shared/filterWithoutIndex-ht2GZQWV.mjs +1 -0
- package/dist/packem_shared/finding-BAVgqnCF.mjs +1 -0
- package/dist/packem_shared/fk-index-Bmk19RvQ.mjs +1 -0
- package/dist/packem_shared/flagGatesSecurityWithUnsafeDefault-DPGFEHTt.mjs +1 -0
- package/dist/packem_shared/fromServerSchema-CJ-AQ2kw.mjs +1 -0
- package/dist/packem_shared/geoIndexFieldNotGeopoint-DAGLZtaH.mjs +1 -0
- package/dist/packem_shared/geoIndexUnused-BgDE6_AR.mjs +1 -0
- package/dist/packem_shared/hardcodedSecret-rIRc3aNk.mjs +1 -0
- package/dist/packem_shared/helpers-DwlvEUCh.mjs +1 -0
- package/dist/packem_shared/hotShard-DVs_U17R.mjs +1 -0
- package/dist/packem_shared/httpActionMissingAuthGuard-BZDdeW_D.mjs +1 -0
- package/dist/packem_shared/httpActionResponseHeaderInjection-D063rB7U.mjs +1 -0
- package/dist/packem_shared/hyperdriveOutsideAction-CRmOQz67.mjs +1 -0
- package/dist/packem_shared/identityUndeclaredClaimTrusted-BYRYGQ74.mjs +1 -0
- package/dist/packem_shared/imagesUrlSourceFromUserInput-Cy5pc_dA.mjs +1 -0
- package/dist/packem_shared/indexReferencesUnknownField-r3QHlaz6.mjs +1 -0
- package/dist/packem_shared/indexUtilization-QfbIH_w4.mjs +1 -0
- package/dist/packem_shared/insertManyUnsafeUserData-DrH7PdRs.mjs +1 -0
- package/dist/packem_shared/kvUnscopedUserKeyIdor-B4lGxRxh.mjs +1 -0
- package/dist/packem_shared/mailInboundDispatchWithoutVerify-BivYJ5F9.mjs +1 -0
- package/dist/packem_shared/mailRecipientFromRequestInput-BR_oSh65.mjs +1 -0
- package/dist/packem_shared/maskUncoveredPiiColumn-DqPfZM2O.mjs +1 -0
- package/dist/packem_shared/maskWeakHashStrategyOnPii-CEiiGgT7.mjs +1 -0
- package/dist/packem_shared/maskedRelationLeakViaWith-CxYk0MDW.mjs +1 -0
- package/dist/packem_shared/mutatorFullRowReplace-CEgecSGm.mjs +1 -0
- package/dist/packem_shared/nondeterministicQueryMutation-BaMfbAk_.mjs +1 -0
- package/dist/packem_shared/normalizeIdUsedAsAuthorization-FjISSXJm.mjs +1 -0
- package/dist/packem_shared/notifyMissingPushConfig-C_GbVw3S.mjs +1 -0
- package/dist/packem_shared/notifySendOutsideAction-B0jZuUP3.mjs +1 -0
- package/dist/packem_shared/outputProjectionMissingOnPublicRead-9Pm9766s.mjs +1 -0
- package/dist/packem_shared/ownerFieldFromArgsNotAuth-BoR9vvst.mjs +1 -0
- package/dist/packem_shared/paymentCreateWithoutAuthorize-CurtqN8K.mjs +1 -0
- package/dist/packem_shared/paymentWebhookWideTolerance-I7ingfR8.mjs +1 -0
- package/dist/packem_shared/plaintextSecretInWranglerVariables-DXjKIm8H.mjs +1 -0
- package/dist/packem_shared/policyReferencesUnknownTable-D3WelAQa.mjs +1 -0
- package/dist/packem_shared/privilegedDispatchUnvalidatedPayload-74GuGye1.mjs +1 -0
- package/dist/packem_shared/privilegedFanoutFromPublicProcedure-C2j2GSUN.mjs +1 -0
- package/dist/packem_shared/publicArgumentUsesAny-BJb4cTqL.mjs +1 -0
- package/dist/packem_shared/publicMutationWithoutRatelimit-D9phluHW.mjs +1 -0
- package/dist/packem_shared/publicTableRlsOptoutConfusion-CaWtPGAn.mjs +1 -0
- package/dist/packem_shared/queueWithoutDlq-BLY3iy9Q.mjs +1 -0
- package/dist/packem_shared/r2sqlOutsideAction-CKK_I1Ms.mjs +1 -0
- package/dist/packem_shared/ratelimitDefaultMemoryStore-mOdI3Jmb.mjs +1 -0
- package/dist/packem_shared/ratelimitKeySpoofableOrGlobal-DaGId_Fh.mjs +1 -0
- package/dist/packem_shared/ratelimitMiddlewareFailOpen-CTWVosEc.mjs +1 -0
- package/dist/packem_shared/relationReferencesUnknownField-clqyB69Y.mjs +1 -0
- package/dist/packem_shared/relationReferencesUnknownTable-CLdrhkmS.mjs +1 -0
- package/dist/packem_shared/rlsUncoveredTable-DYHgnoos.mjs +1 -0
- package/dist/packem_shared/shapeTargetsGlobalTable-Blz-aSkg.mjs +1 -0
- package/dist/packem_shared/shapeUnknownTable-DFxtyFio.mjs +1 -0
- package/dist/packem_shared/signupMutationWithoutDisposableGating-3ir808Vz.mjs +1 -0
- package/dist/packem_shared/softDeleteIncludeDeletedFromArgs-DgxpQHRA.mjs +1 -0
- package/dist/packem_shared/sqlInjectionRisk-D2NaT-5c.mjs +1 -0
- package/dist/packem_shared/storageGenerateUploadUrlNoContentTypePin-C9cjl6Jh.mjs +1 -0
- package/dist/packem_shared/storageKeyFromUserArgs-BeS5cp56.mjs +1 -0
- package/dist/packem_shared/storagePresignedUrlForPrivateContent-j7VhwIdu.mjs +1 -0
- package/dist/packem_shared/storageUploadWithoutContentTypeAllowlist-oxKLecuf.mjs +1 -0
- package/dist/packem_shared/storageUploadWithoutMaxSize-DUDxPutk.mjs +1 -0
- package/dist/packem_shared/tableWithoutInsert-D3AhaNzR.mjs +1 -0
- package/dist/packem_shared/ttlFieldNotTimestamp-BFiRkSZD.mjs +1 -0
- package/dist/packem_shared/unboundedStringArgument-AkkIcr-S.mjs +1 -0
- package/dist/packem_shared/unindexedForeignKey-DK4eWXkb.mjs +1 -0
- package/dist/packem_shared/unindexedRelationTarget-B5fFq1E8.mjs +1 -0
- package/dist/packem_shared/unrestrictedWhereBranch-YKiydie4.mjs +1 -0
- package/dist/packem_shared/userCreatingMutationWithoutCaptcha-iDjRkUFO.mjs +1 -0
- package/dist/packem_shared/vectorsNamespaceFromUserInput-Cyq5B2kF.mjs +1 -0
- package/dist/packem_shared/workflowDuplicateStepName-cXF6Rpvg.mjs +1 -0
- package/dist/packem_shared/workflowUnknownTarget-JF8rITLP.mjs +1 -0
- package/dist/packem_shared/workflowUnused-Dx_FQwGE.mjs +1 -0
- package/package.json +4 -3
- package/dist/packem_shared/AE_METRIC_EVENTS-DexctYv6.mjs +0 -85
- package/dist/packem_shared/adminRouteWithoutGuard-UUGBkAjU.mjs +0 -33
- package/dist/packem_shared/authApiCallWithoutHeaders-BeJhCZaf.mjs +0 -38
- package/dist/packem_shared/circularFk-B2freHrP.mjs +0 -84
- package/dist/packem_shared/constraintValidator-Dr9Py3FD.mjs +0 -186
- package/dist/packem_shared/containerOversizedInstance-5U1VKPRM.mjs +0 -36
- package/dist/packem_shared/containerPublicInternet-CuNerJE5.mjs +0 -30
- package/dist/packem_shared/duplicateIndex-BOublMSt.mjs +0 -57
- package/dist/packem_shared/emptyIndex-BX8EuEY7.mjs +0 -32
- package/dist/packem_shared/filterWithoutIndex-BYVeJaSs.mjs +0 -31
- package/dist/packem_shared/finding-Dm_zvzS1.mjs +0 -16
- package/dist/packem_shared/fk-index-IUK1ukgs.mjs +0 -7
- package/dist/packem_shared/fromServerSchema-DinF1nph.mjs +0 -50
- package/dist/packem_shared/hardcodedSecret-W2pz1UZB.mjs +0 -35
- package/dist/packem_shared/helpers-DNCkMWZQ.mjs +0 -4
- package/dist/packem_shared/hotShard-Ir5D0B6J.mjs +0 -48
- package/dist/packem_shared/hyperdriveOutsideAction-BgZqX7Xg.mjs +0 -30
- package/dist/packem_shared/indexReferencesUnknownField-DH0_dbUY.mjs +0 -36
- package/dist/packem_shared/indexUtilization-B5DMQ3bI.mjs +0 -45
- package/dist/packem_shared/maskUncoveredPiiColumn-DjGIPG6M.mjs +0 -61
- package/dist/packem_shared/nondeterministicQueryMutation-GXES1fLp.mjs +0 -35
- package/dist/packem_shared/policyReferencesUnknownTable-DtaIEovd.mjs +0 -38
- package/dist/packem_shared/publicArgumentUsesAny-C71b2NCf.mjs +0 -32
- package/dist/packem_shared/publicMutationWithoutRatelimit-xBpJ6GWK.mjs +0 -36
- package/dist/packem_shared/r2sqlOutsideAction-CtqxvMuV.mjs +0 -30
- package/dist/packem_shared/relationReferencesUnknownField-YznyXt_7.mjs +0 -54
- package/dist/packem_shared/relationReferencesUnknownTable-DrorpKYe.mjs +0 -33
- package/dist/packem_shared/rlsUncoveredTable-CxEfZ5eZ.mjs +0 -56
- package/dist/packem_shared/sqlInjectionRisk-zwytYGLt.mjs +0 -26
- package/dist/packem_shared/tableWithoutInsert-CbbaYIP4.mjs +0 -34
- package/dist/packem_shared/unboundedStringArgument-DThg2-wt.mjs +0 -32
- package/dist/packem_shared/unindexedForeignKey-BgJbKyqK.mjs +0 -45
- package/dist/packem_shared/unindexedRelationTarget-D6eyj6Xx.mjs +0 -53
- package/dist/packem_shared/userCreatingMutationWithoutCaptcha-CH31YsUZ.mjs +0 -42
- package/dist/packem_shared/workflowUnknownTarget-Cdd7WhKQ.mjs +0 -34
- package/dist/packem_shared/workflowUnused-D0jHxdz9.mjs +0 -38
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i}from"./finding-BAVgqnCF.mjs";const a={categories:["SECURITY"],description:"`new RateLimiter({...})` has no `store`, so it falls back to a per-instance in-memory store. On Workers each request may hit a different isolate, so the counter never sums across them — the limit silently under-counts and is effectively a no-op under load, giving false protection against brute-force and cost abuse.",facing:"EXTERNAL",level:"WARN",name:"ratelimit_default_memory_store",remediation:"Pass a shared, durable `store` to `new RateLimiter({...})` (a Durable-Object- or KV-backed store) so every Worker isolate reads and writes the same bucket. The default in-memory store is per-isolate and does not enforce a global limit.",run:t=>t.configCalls===void 0?[]:t.configCalls.filter(e=>e.callee==="RateLimiter"&&e.analyzable&&!e.presentKeys.includes("store")).map(e=>i(a,{cacheKey:`ratelimit_default_memory_store:${e.file}:${e.line.toString()}`,detail:`\`new RateLimiter({...})\` in ${e.file}:${e.line.toString()} has no \`store\` — it uses the per-instance in-memory store, which never sums across Worker isolates, so the limit silently under-counts under load. Pass a Durable-Object- or KV-backed \`store\` so the limit is enforced globally.`,metadata:{callee:e.callee,file:e.file,line:e.line}})),source:"static",title:"RateLimiter using the default in-memory store"};export{a as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i}from"./finding-BAVgqnCF.mjs";const a={categories:["SECURITY"],description:"A `rateLimit`/`dbRateLimit` middleware call's `key` selector is derived from the handler's `args` with no server-side scoping, so an attacker can rotate the key on every request (e.g. by resubmitting a different email/id) and never share a bucket with themselves — the limit never actually limits them.",facing:"EXTERNAL",level:"WARN",name:"ratelimit_key_spoofable_or_global",remediation:'Derive the rate-limit `key` from a server-trusted value — `ctx.auth.userId` for authenticated callers, `ctx.ip` for anonymous ones (e.g. `key: (ctx) => ctx.auth.userId ?? ctx.ip ?? "anon"`) — instead of an `args` field the caller controls.',run:t=>t.ratelimitKeySelectors===void 0?[]:t.ratelimitKeySelectors.map(e=>i(a,{cacheKey:`ratelimit_key_spoofable_or_global:${e.file}:${e.line.toString()}`,detail:`\`${e.callee}(…, "${e.limitName}", { key })\` in \`${e.exportName}\` (${e.file}:${e.line.toString()}) derives its \`key\` from \`args\` with no server-side scoping — an attacker can rotate the key per request and bypass the limit. Derive \`key\` from \`ctx.auth.userId\` / \`ctx.ip\` instead.`,metadata:{callee:e.callee,exportName:e.exportName,file:e.file,limitName:e.limitName,line:e.line}})),source:"static",title:"Rate-limit key derived from spoofable user input"};export{a as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as r}from"./finding-BAVgqnCF.mjs";const l=["signin","signup","login","register","reset","password","otp","verify","payment","checkout"],n=i=>i.toLowerCase().replaceAll(/[^a-z0-9]+/gu,""),t=i=>{const e=n(i);return l.some(a=>e.includes(a))},o=i=>t(i.exportName)||t(i.limitName),s={categories:["SECURITY"],description:"A rate-limit or Turnstile middleware guarding a sensitive auth/payment procedure is configured `failOpen: true`, so a limiter store outage or a failed Turnstile siteverify silently admits every request — turning a transient failure into an unthrottled brute-force / abuse window.",facing:"EXTERNAL",level:"WARN",name:"ratelimit_middleware_fail_open",remediation:"Drop `failOpen: true` (the default is fail-closed) on the rate-limit / Turnstile guard for auth and payment flows, or move the escape hatch to a non-sensitive procedure. Prefer rejecting traffic with a 503 over admitting unlimited unauthenticated attempts when the limiter or siteverify is unavailable.",run:i=>i.failOpenGuards===void 0?[]:i.failOpenGuards.filter(e=>e.failOpen&&o(e)).map(e=>r(s,{cacheKey:`ratelimit_middleware_fail_open:${e.file}:${e.line.toString()}`,detail:`\`${e.callee}(...)\` in \`${e.exportName}\` (${e.file}:${e.line.toString()}) sets \`failOpen: true\` while guarding a sensitive flow — a limiter/siteverify outage then admits every request instead of rejecting it.`,metadata:{callee:e.callee,exportName:e.exportName,file:e.file,limitName:e.limitName,line:e.line}})),source:"static",title:"Fail-open rate-limit / CAPTCHA guard on a sensitive procedure"};export{s as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as s}from"./finding-BAVgqnCF.mjs";import{t as c}from"./helpers-DwlvEUCh.mjs";const m=(o,i,r,t)=>{const e=o.get(t.table);if(!e)return[];const n=t.kind==="one"?r:e,a=t.kind==="one"?e:r,l=[];return i(n).has(t.field)||l.push({column:t.field,owner:n.name,side:"field"}),i(a).has(t.references)||l.push({column:t.references,owner:a.name,side:"references"}),l},d={categories:["SCHEMA"],description:"A relation references a foreign-key or referenced column that is not declared on its table, so the join can never resolve.",facing:"INTERNAL",level:"ERROR",name:"relation_references_unknown_field",remediation:"Fix the `field` / `references` column name in the relation, or add the missing column.",run:o=>{const i=new Map(o.schema.tables.map(e=>[e.name,e])),r=new Map,t=e=>{let n=r.get(e);return n||(n=c(e),r.set(e,n)),n};return o.schema.tables.flatMap(e=>e.relations.flatMap(n=>m(i,t,e,n).map(a=>s(d,{cacheKey:`relation_references_unknown_field:${e.name}:${n.name}:${a.side}`,detail:`Relation "${n.name}" on table "${e.name}" uses ${a.side} "${a.column}", which is not declared on table "${a.owner}".`,metadata:{column:a.column,owner:a.owner,relation:n.name,side:a.side,table:e.name}}))))},source:"static",title:"Relation references unknown field"};export{d as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as r}from"./finding-BAVgqnCF.mjs";const s={categories:["SCHEMA"],description:"A relation targets a table that does not exist in the schema. The relation can never load — the target is a typo or a removed/renamed table.",facing:"INTERNAL",level:"ERROR",name:"relation_references_unknown_table",remediation:"Fix the target table name in the relation, or add the missing table to the schema.",run:a=>{const n=[],o=new Set(a.schema.tables.map(e=>e.name));for(const e of a.schema.tables)for(const t of e.relations)o.has(t.table)||n.push(r(s,{cacheKey:`relation_references_unknown_table:${e.name}:${t.name}`,detail:`Relation "${t.name}" on table "${e.name}" targets table "${t.table}", which does not exist in the schema.`,metadata:{relation:t.name,table:e.name,target:t.table}}));return n},source:"static",title:"Relation references unknown table"};export{s as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as a}from"./finding-BAVgqnCF.mjs";const l={categories:["SECURITY"],description:"A public procedure reads or writes a table that is covered by an RLS policy elsewhere in the app, but this procedure's builder chain does not include `.use(rls(...))`. The raw, unwrapped `ctx.db` silently bypasses every policy in the list.",facing:"EXTERNAL",level:"WARN",name:"rls_uncovered_table",remediation:"Add `.use(rls(policies))` to the procedure's builder chain — e.g. `c.use(rls(myPolicies)).query(...)` — so its `ctx.db` is wrapped by the same policy evaluator as the rest of the app. If this procedure is intentionally privileged (e.g. a background job), declare it with `internalQuery` / `internalMutation` / `internalAction` instead of the public builder so the intent is explicit.",run:s=>{if(s.rlsProcedures===void 0)return[];const r=new Set;for(const e of s.rlsProcedures)for(const i of e.rlsTables)i!==""&&r.add(i);if(r.size===0)return[];const o=[];for(const e of s.rlsProcedures){if(e.usesRls||e.visibility==="internal")continue;const i=new Set;for(const t of[...e.tablesRead,...e.tablesWritten])t!==""&&r.has(t)&&i.add(t);for(const t of i)o.push(a(l,{cacheKey:`rls_uncovered_table:${e.file}:${e.exportName}:${t}`,detail:`\`${e.exportName}\` in ${e.file} accesses table \`${t}\` without \`.use(rls(...))\` — the table is policy-gated elsewhere in the app but this procedure bypasses those policies entirely.`,metadata:{exportName:e.exportName,file:e.file,table:t}}))}return o},source:"static",title:"RLS-gated table accessed without rls() middleware"};export{l as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as l}from"./finding-BAVgqnCF.mjs";const r={categories:["PERFORMANCE"],description:"A `defineShape` replicates from a `.global()` table. Global tables live outside the shard DO's SQLite op-log, so the shape is served through the cross-shard tier — coordinator/poll-refreshed and latency-tiered, not poke-live like a sharded shape.",facing:"EXTERNAL",level:"WARN",name:"shape_targets_global_table",remediation:"Expected if you want the cross-shard tier — just don't assume poke-live freshness; global shapes refresh on a poll. For live updates, replicate from a sharded table instead (denormalize the columns you need into the shard).",run:a=>{if(a.shapes===void 0)return[];const s=new Set(a.schema.tables.filter(e=>e.shardKind==="global").map(e=>e.name)),t=[];for(const e of a.shapes)e.table===void 0||!s.has(e.table)||t.push(l(r,{cacheKey:`shape_targets_global_table:${e.exportName}`,detail:`Shape \`${e.exportName}\` (${e.file}) replicates from the \`.global()\` table \`${e.table}\`. It is served through the cross-shard global tier — poll-refreshed and latency-tiered, not poke-live.`,metadata:{exportName:e.exportName,file:e.file,table:e.table}}));return t},source:"static",title:"Shape replicates from a global (cross-shard) table"};export{r as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as o}from"./finding-BAVgqnCF.mjs";const n={categories:["SCHEMA"],description:"A `defineShape` is bound to a `table` name that does not exist in the schema. The shape can never resolve a rowset — its subscription seeds empty and errors at the first flush.",facing:"INTERNAL",level:"ERROR",name:"shape_unknown_table",remediation:"Fix the shape's `table` to a real table name (check for a typo or a table that was renamed/removed).",run:a=>{if(a.shapes===void 0)return[];const s=new Set(a.schema.tables.map(e=>e.name)),t=[];for(const e of a.shapes)e.table===void 0||s.has(e.table)||t.push(o(n,{cacheKey:`shape_unknown_table:${e.exportName}`,detail:`Shape \`${e.exportName}\` (${e.file}) replicates from table \`${e.table}\`, which is not declared in the schema. The shape can never resolve a rowset.`,metadata:{exportName:e.exportName,file:e.file,table:e.table}}));return t},source:"static",title:"Shape bound to an unknown table"};export{n as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i}from"./finding-BAVgqnCF.mjs";import{i as s}from"./helpers-DwlvEUCh.mjs";const o={categories:["SECURITY"],description:"A public `mutation`/`action` that creates a user/session/account row has no disposable-email gate. Throwaway mailboxes farm free trials, evade bans, and pollute the user table.",facing:"EXTERNAL",level:"WARN",name:"signup_mutation_without_disposable_gating",remediation:"Add `.use(emailGateMiddleware({ email: (ctx) => ctx.args.email }))` from `@lunora/auth/email-guard` to reject disposable domains at signup, or gate better-auth's native signup with `withEmailGate(...)` / `emailGateDatabaseHooks(...)`.",run:a=>{if(a.procedureProtections===void 0)return[];const t=[];for(const e of a.procedureProtections)!s(e)||!e.writesUserTable||e.usesEmailGate||t.push(i(o,{cacheKey:`signup_mutation_without_disposable_gating:${e.file}:${e.exportName}`,detail:`Public ${e.kind} \`${e.exportName}\` (${e.file}) writes a user/session/account table but has no disposable-email gate. Add \`.use(emailGateMiddleware(...))\` from \`@lunora/auth/email-guard\`.`,metadata:{exportName:e.exportName,file:e.file,kind:e.kind,writesUserTable:e.writesUserTable}}));return t},source:"static",title:"Account-creating write without a disposable-email gate"};export{o as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as o}from"./finding-BAVgqnCF.mjs";const d={categories:["SECURITY"],description:"A `.public()` read passes `includeDeleted` (hardcoded `true` or derived from `args`) on a `.softDelete()` table, resurfacing soft-deleted rows to callers that should never see them.",facing:"EXTERNAL",level:"INFO",name:"soft_delete_include_deleted_from_args",remediation:"Drop `includeDeleted` from the public read so soft-deleted rows stay hidden, or move the trash/restore view behind an internal (or RLS/role-gated) procedure. Never wire `includeDeleted` from `args` on a public read — an attacker can set it to see rows the app treats as deleted.",run:t=>{if(t.softDeleteReads===void 0)return[];const r=new Set(t.schema.tables.filter(e=>e.softDelete!==void 0).map(e=>e.name));return r.size===0?[]:t.softDeleteReads.filter(e=>e.visibility==="public"&&e.table!==""&&r.has(e.table)&&(e.hardcodedTrue||e.fromArgs)).map(e=>{const a=`\`${e.exportName}\` (${e.file}:${e.line.toString()})`,l=e.fromArgs?`The public read in ${a} wires \`includeDeleted\` from request \`args\` on soft-delete table \`${e.table}\`, letting any caller resurface soft-deleted rows the app treats as gone.`:`The public read in ${a} hardcodes \`includeDeleted: true\` on soft-delete table \`${e.table}\`, returning soft-deleted rows to every caller.`;return o(d,{cacheKey:`soft_delete_include_deleted_from_args:${e.file}:${e.line.toString()}:${e.table}`,detail:l,metadata:{exportName:e.exportName,file:e.file,line:e.line,source:e.fromArgs?"args":"literal",table:e.table}})})},source:"static",title:"Soft-deleted rows resurfaced via includeDeleted on a public read"};export{d as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i}from"./finding-BAVgqnCF.mjs";const n={categories:["SECURITY"],description:"A `ctx.sql` tagged-template interpolates an unparameterized string-building expression (concatenation or nested template) instead of a bound value — splicing raw text into the query and reopening SQL injection.",facing:"EXTERNAL",level:"ERROR",name:"sql_injection_risk",remediation:"Pass the value through a bound placeholder so the driver parameterizes it — keep request input inside a `ctx.sql` placeholder instead of concatenating it into the query string. Never build SQL text from request input by hand.",run:t=>t.sqlInterpolations===void 0?[]:t.sqlInterpolations.map(e=>i(n,{cacheKey:`sql_injection_risk:${e.file}:${e.line.toString()}`,detail:`\`ctx.sql\` in \`${e.exportName}\` (${e.file}:${e.line.toString()}) interpolates a string-building expression instead of a bound value — a SQL-injection vector. Pass the value through a bound placeholder so the driver parameterizes it.`,metadata:{exportName:e.exportName,file:e.file,line:e.line}})),source:"static",title:"Possible SQL injection in ctx.sql interpolation"};export{n as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as n}from"./finding-BAVgqnCF.mjs";const a={categories:["SECURITY"],description:"A `ctx.storage.generateUploadUrl(key, …)` call has no `contentType` pin, so the signed PUT URL it mints authorizes any content-type/size the client chooses — `upload()`'s `allowedContentTypes`/`maxSize` guards never run for a client-side PUT against this URL.",facing:"EXTERNAL",level:"WARN",name:"storage_generate_upload_url_no_content_type_pin",remediation:"Pass `contentType` to `ctx.storage.generateUploadUrl(key, { contentType })` so the signature only authorizes a PUT with that exact content-type. If the client's upload size/type also needs bounding, prefer `upload()`/`store()` (with `allowedContentTypes`/`maxSize`) over a client-side signed PUT.",run:t=>t.storageUploads===void 0?[]:t.storageUploads.filter(e=>e.method==="generateUploadUrl"&&e.analyzable&&!e.presentKeys.includes("contentType")).map(e=>n(a,{cacheKey:`storage_generate_upload_url_no_content_type_pin:${e.file}:${e.line.toString()}`,detail:`\`ctx.storage.generateUploadUrl\` in \`${e.exportName}\` (${e.file}:${e.line.toString()}) has no \`contentType\` pin — the signed PUT it mints accepts any type/size, minted client-side and bypassing \`upload()\`'s guards.`,metadata:{exportName:e.exportName,file:e.file,line:e.line}})),source:"static",title:"generateUploadUrl signed PUT with no content-type pin"};export{a as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{m as t}from"./argument-derived-sink-BpO3NPPM.mjs";const o=t({cacheKey:e=>`storage_key_from_user_args:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `ctx.storage.*` call uses an R2 object key taken directly from the handler's `args` with no server-side scoping. The bucket methods key by the caller-supplied string, so any caller can read, overwrite, or delete another user's object — object-level IDOR.",detail:e=>`\`ctx.storage.*.${e.method}\` in \`${e.exportName}\` (${e.file}:${e.line.toString()}) uses an object key derived from \`args\` with no server-side scoping — any caller can read/overwrite/delete another user's object (IDOR). Prefix the key with a server-trusted identity (e.g. \`\${ctx.auth.userId}/…\`) or resolve the object through an owned record.`,facing:"EXTERNAL",getAccesses:e=>e.storageKeyAccesses,level:"ERROR",metadata:e=>({exportName:e.exportName,file:e.file,line:e.line,method:e.method}),name:"storage_key_from_user_args",remediation:"Prefix the object key with a server-trusted identity (e.g. `${ctx.auth.userId}/…`) or resolve the object through a record the caller is known to own. Never pass request input straight through as an R2 object key.",title:"R2 object key taken directly from user args (IDOR)"});export{o as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as l}from"./finding-BAVgqnCF.mjs";const g=new Set(["getPresignedUrl","getSignedUrl"]),d=8640*60,s=t=>t.method==="getPresignedUrl",a=t=>g.has(t.method)&&t.expiresInSeconds!==void 0&&t.expiresInSeconds>=d,c={categories:["SECURITY"],description:"`ctx.storage.getPresignedUrl(...)` mints a native S3 SigV4 URL that resolves directly against R2, bypassing the Worker's auth/RLS/rate-limit entirely — the wrong choice for private or policy-gated content. Either signer minting an `expiresInSeconds` near the shared 7-day ceiling also hands out a long-lived bearer credential.",facing:"EXTERNAL",level:"WARN",name:"storage_presigned_url_for_private_content",remediation:"For private/per-user/policy-gated content, use `getSignedUrl` (resolves back through the Worker, so your auth/RLS/rate-limit gates still run) instead of `getPresignedUrl`. Reserve `getPresignedUrl` for genuinely public or bulk content. On either signer, keep `expiresInSeconds` as short as the use case allows — well under the 7-day ceiling — rather than minting a near-maximum-lifetime URL.",run:t=>t.storageUploads===void 0?[]:t.storageUploads.filter(e=>s(e)||a(e)).map(e=>{const n=s(e),o=a(e),r=`\`${e.exportName}\` (${e.file}:${e.line.toString()})`;let i;return n&&o?i=`\`ctx.storage.getPresignedUrl\` in ${r} mints a native S3 SigV4 URL — bypasses the Worker's auth/RLS/rate-limit entirely — with an \`expiresInSeconds\` of ${(e.expiresInSeconds??0).toString()}s, near the 7-day signing ceiling.`:n?i=`\`ctx.storage.getPresignedUrl\` in ${r} mints a native S3 SigV4 URL that resolves directly against R2, bypassing the Worker's auth/RLS/rate-limit entirely. Use \`getSignedUrl\` for app-gated access to private content.`:i=`\`ctx.storage.${e.method}\` in ${r} requests an \`expiresInSeconds\` of ${(e.expiresInSeconds??0).toString()}s, near the 7-day signing ceiling — a leaked link stays valid for almost a week.`,l(c,{cacheKey:`storage_presigned_url_for_private_content:${e.file}:${e.line.toString()}`,detail:i,metadata:{expiresInSeconds:e.expiresInSeconds,exportName:e.exportName,file:e.file,line:e.line,method:e.method}})}),source:"static",title:"Native presigned URL or near-max-TTL signed URL for private content"};export{c as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as o}from"./finding-BAVgqnCF.mjs";const a=new Set(["store","upload"]),l={categories:["SECURITY"],description:"A `ctx.storage.upload`/`store` call has no `allowedContentTypes` allowlist, so an uploader can store any content-type — including `text/html` or `image/svg+xml` — which, served back from `publicBaseUrl`, is a stored-XSS vector against your own origin.",facing:"EXTERNAL",level:"WARN",name:"storage_upload_without_content_type_allowlist",remediation:'Pass `allowedContentTypes: ["image/png", "image/jpeg", …]` to `ctx.storage.upload`/`store` so an uploaded object\'s `contentType` must match an approved list. Reject anything HTML/SVG/script-executable unless the app genuinely needs to serve it.',run:t=>t.storageUploads===void 0?[]:t.storageUploads.filter(e=>a.has(e.method)&&e.analyzable&&!e.presentKeys.includes("allowedContentTypes")).map(e=>o(l,{cacheKey:`storage_upload_without_content_type_allowlist:${e.file}:${e.line.toString()}`,detail:`\`ctx.storage.${e.method}\` in \`${e.exportName}\` (${e.file}:${e.line.toString()}) has no \`allowedContentTypes\` allowlist — any content-type is accepted, including \`text/html\`/\`image/svg+xml\`, a stored-XSS risk if the object is served from \`publicBaseUrl\`.`,metadata:{exportName:e.exportName,file:e.file,line:e.line,method:e.method}})),source:"static",title:"Storage upload with no content-type allowlist"};export{l as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as t}from"./finding-BAVgqnCF.mjs";const o=new Set(["store","upload"]),i={categories:["SECURITY"],description:"A `ctx.storage.upload`/`store` call has no `maxSize` cap, so a caller can push an unbounded body straight into R2 through the Worker — unbounded storage/billing exhaustion, and for a streamed body, unbounded worker time.",facing:"EXTERNAL",level:"WARN",name:"storage_upload_without_max_size",remediation:"Pass a `maxSize` (bytes) to `ctx.storage.upload`/`store` sized to the largest legitimate object the app accepts. `ArrayBuffer`/`Blob` bodies are rejected up front when oversized; a `ReadableStream` body is aborted mid-transfer once the limit is exceeded.",run:a=>a.storageUploads===void 0?[]:a.storageUploads.filter(e=>o.has(e.method)&&e.analyzable&&!e.presentKeys.includes("maxSize")).map(e=>t(i,{cacheKey:`storage_upload_without_max_size:${e.file}:${e.line.toString()}`,detail:`\`ctx.storage.${e.method}\` in \`${e.exportName}\` (${e.file}:${e.line.toString()}) has no \`maxSize\` cap — a caller can push an unbounded body into R2, exhausting storage/billing.`,metadata:{exportName:e.exportName,file:e.file,line:e.line,method:e.method}})),source:"static",title:"Storage upload with no size cap"};export{i as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as n}from"./finding-BAVgqnCF.mjs";const s={categories:["SCHEMA"],description:'No function inserts into this table via `ctx.db.insert("<table>", …)`. It may be read-only by design (seeded by a migration, replicated, or written through a path the advisor can\'t see) — or it may be dead schema.',facing:"INTERNAL",level:"INFO",name:"table_without_insert",remediation:'If the table should be writable, add a mutation that calls `ctx.db.insert("<table>", …)`. If it is read-only or seeded elsewhere, this advisory can be ignored.',run:t=>{if(t.inserts===void 0)return[];const i=new Set(t.inserts.filter(e=>e.table!=="").map(e=>e.table)),a=[];for(const e of t.schema.tables)i.has(e.name)||e.externallyManaged===!0||a.push(n(s,{cacheKey:`table_without_insert:${e.name}`,detail:`No function calls \`ctx.db.insert("${e.name}", …)\` — table "${e.name}" has no discovered insert path.`,metadata:{table:e.name}}));return a},source:"static",title:"Table has no insert path"};export{s as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as o}from"./finding-BAVgqnCF.mjs";const l=new Set(["date","number","timestamp"]),m={categories:["SCHEMA"],description:"A `.ttl(field)` policy points at a column that is not an epoch-millisecond timestamp, so the alarm sweep's `field < now` comparison can't decide expiry correctly.",facing:"INTERNAL",level:"ERROR",name:"ttl_field_not_timestamp",remediation:"Point `.ttl(field)` at a `v.timestamp()` / `v.date()` / `v.number()` (epoch-ms) column, or add such a column.",run:n=>{const a=[];for(const e of n.schema.tables){const{ttl:t}=e;if(!t)continue;const i=e.columnKinds?.[t.field];i===void 0||l.has(i)||a.push(o(m,{cacheKey:`ttl_field_not_timestamp:${e.name}:${t.field}`,detail:`Table "${e.name}" declares \`.ttl("${t.field}")\`, but "${t.field}" is a ${i} column, not an epoch-millisecond timestamp.`,metadata:{field:t.field,kind:i,table:e.name}}))}return a},source:"static",title:"TTL field is not a timestamp"};export{m as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as r}from"./finding-BAVgqnCF.mjs";const i={categories:["SECURITY"],description:"A public `v.string()` argument has no maximum-length bound. An unbounded string lets a client submit arbitrarily large input — abusing storage and CPU on every request that processes it.",facing:"EXTERNAL",level:"INFO",name:"unbounded_string_arg",remediation:"Add a max-length bound via `.check(...)` / `.meta({ maxLength })` on the string validator (e.g. cap a name at 256, a body at a few KB). Size the cap to the field's real-world maximum.",run:a=>{if(a.argValidators===void 0)return[];const n=[];for(const e of a.argValidators)for(const t of e.unboundedStringArgs)n.push(r(i,{cacheKey:`unbounded_string_arg:${e.file}:${e.exportName}:${t}`,detail:`Arg \`${t}\` of public procedure \`${e.exportName}\` (${e.file}:${e.line.toString()}) is an unbounded \`v.string()\`. Add a max-length bound to cap payload size.`,metadata:{argument:t,exportName:e.exportName,file:e.file,line:e.line}}));return n},source:"static",title:"Public string argument has no length bound"};export{i as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as s}from"./finding-BAVgqnCF.mjs";import{l as r,P as d,s as c}from"./fk-index-Bmk19RvQ.mjs";const f={categories:["PERFORMANCE"],description:"A foreign-key column declared by a `one` relation has no index leading with it. Reads that filter or join on the column full-scan the table, which gets linearly slower as rows accumulate.",facing:"EXTERNAL",level:"INFO",name:"unindexed_foreign_key",remediation:'Add a secondary index leading with the FK column, e.g. `.index("byAuthorId", ["authorId"])`.',run:t=>{const o=[];for(const a of t.schema.tables){const i=r(a);for(const n of a.relations){if(n.kind!=="one")continue;const e=n.field;if(e===d||i.has(e))continue;const l=c(e);o.push(s(f,{cacheKey:`unindexed_foreign_key:${a.name}:${e}`,detail:`Relation "${n.name}" on table "${a.name}" references "${n.table}" via column "${e}", which is not the leading column of any index. Reads filtering or joining on "${e}" full-scan "${a.name}".`,metadata:{fkColumn:e,references:{column:n.references,table:n.table},relation:n.name,suggestedIndex:{fields:[e],name:l},table:a.name}}))}}return o},source:"static",title:"Unindexed foreign key"};export{f as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as d}from"./finding-BAVgqnCF.mjs";import{P as c,l as m,s as u}from"./fk-index-Bmk19RvQ.mjs";const f={categories:["PERFORMANCE"],description:"A `many` relation's foreign-key column on the target table has no index leading with it. Relation predicates (`some`/`none`/`every`) and `with:` child reads filter the target on that column, full-scanning it as rows accumulate.",facing:"EXTERNAL",level:"INFO",name:"unindexed_relation_target",remediation:'Add a secondary index on the target table leading with the FK column, e.g. `.index("byAuthorId", ["authorId"])`.',run:o=>{const i=[],s=new Map(o.schema.tables.map(t=>[t.name,t]));for(const t of o.schema.tables)for(const e of t.relations){if(e.kind!=="many")continue;const a=s.get(e.table);if(a===void 0)continue;const n=e.field;if(n===c||a.relations.some(l=>l.kind==="one"&&l.field===n)||m(a).has(n))continue;const r=u(n);i.push(d(f,{cacheKey:`unindexed_relation_target:${e.table}:${n}`,detail:`Relation "${e.name}" on table "${t.name}" is a to-many over "${e.table}" via its column "${n}", which is not the leading column of any index on "${e.table}". Relation predicates (\`some\`/\`none\`/\`every\`) and \`with:\` reads of "${e.name}" full-scan "${e.table}".`,metadata:{fkColumn:n,references:{column:e.references,table:t.name},relation:e.name,suggestedIndex:{fields:[n],name:r},table:e.table}}))}return i},source:"static",title:"Unindexed relation target"};export{f as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as n}from"./finding-BAVgqnCF.mjs";const t={"empty-object":"`{}`",undefined:"`undefined`"},a={categories:["SECURITY"],description:"A branching `defineShape({ where })` / `definePolicy({ when })` predicate returns `{}` or `undefined` from one arm. A row predicate is a filter, not a boolean: `{}` matches EVERY row, so an arm meant to deny instead replicates or exposes the whole table — silently.",facing:"EXTERNAL",level:"ERROR",name:"unrestricted_where_branch",remediation:"Return `deny()` (from `@lunora/server`) — or `false`, which compiles to the same vacuously-false `{ OR: [] }` predicate — from the denial arm. Reach for `allowAll()` only when that arm genuinely means 'no further restriction'. For an owner check, prefer `.ownedBy(field)` on the table plus `defineShape({ owner: true })`, which derives the predicate from the verified identity and cannot be written backwards.",run:r=>r.unrestrictedWhereBranches===void 0?[]:r.unrestrictedWhereBranches.map(e=>n(a,{cacheKey:`unrestricted_where_branch:${e.file}:${e.line.toString()}:${e.key}`,detail:`\`${e.owner}\` \`${e.exportName}\` (${e.file}:${e.line.toString()}) returns ${t[e.form]} from one arm of its \`${e.key}\` predicate. A predicate is a filter, not a boolean — ${t[e.form]} imposes no restriction, so this arm matches every row instead of none. Return \`deny()\` (or \`false\`) if it was meant to deny; use \`allowAll()\` to state an intentionally unrestricted arm.`,metadata:{exportName:e.exportName,file:e.file,form:e.form,key:e.key,line:e.line,owner:e.owner}})),source:"static",title:"Predicate arm returns an unrestricted filter"};export{a as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as s}from"./finding-BAVgqnCF.mjs";import{i as o}from"./helpers-DwlvEUCh.mjs";const c={categories:["SECURITY"],description:"A public `mutation`/`action` that creates a user/session or sends mail has no CAPTCHA / bot check. Account-creating and mail-sending endpoints are prime automated-abuse targets (credential stuffing, mailbox flooding, disposable-account farming).",facing:"EXTERNAL",level:"WARN",name:"user_creating_mutation_without_captcha",remediation:"Add a server-verified human check: `.use(verifyTurnstile({ secret, token }))` from `@lunora/auth`, or wrap it with `.use(protectPublic({ rateLimit, captcha }))` from `@lunora/server`. Pair with a rate limit for defense in depth.",run:t=>{if(t.procedureProtections===void 0)return[];const i=[];for(const e of t.procedureProtections){const a=e.writesUserTable||e.callsMail;if(!o(e)||!a||e.usesCaptcha)continue;const r=e.writesUserTable?"writes a user/session table":"sends mail";i.push(s(c,{cacheKey:`user_creating_mutation_without_captcha:${e.file}:${e.exportName}`,detail:`Public ${e.kind} \`${e.exportName}\` (${e.file}) ${r} but has no CAPTCHA check. Add \`.use(verifyTurnstile(...))\` or \`.use(protectPublic({ captcha }))\`.`,metadata:{callsMail:e.callsMail,exportName:e.exportName,file:e.file,kind:e.kind,writesUserTable:e.writesUserTable}}))}return i},source:"static",title:"Account-creating / mail-sending write without a CAPTCHA"};export{c as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{m as t}from"./argument-derived-sink-BpO3NPPM.mjs";const a=t({cacheKey:e=>`vectors_namespace_from_user_input:${e.file}:${e.line.toString()}`,categories:["SECURITY"],description:"A `ctx.vectors.query`/`upsert`/`upsertMany` call uses a `namespace` derived from the handler's `args` with no server-side scoping. A Vectorize namespace partitions one index into isolated sub-collections, so an unscoped namespace lets any caller read or poison another tenant's vectors.",detail:e=>`\`ctx.vectors.${e.method}\` in \`${e.exportName}\` (${e.file}:${e.line.toString()}) uses a Vectorize namespace derived from \`args\` with no server-side scoping — any caller can read or poison another tenant's vectors. Derive the namespace from a server-trusted identity (e.g. \`\${ctx.auth.orgId}\`), never from \`args\`.`,facing:"EXTERNAL",getAccesses:e=>e.vectorNamespaceAccesses,level:"WARN",metadata:e=>({exportName:e.exportName,file:e.file,line:e.line,method:e.method}),name:"vectors_namespace_from_user_input",remediation:"Derive the `namespace` from a server-trusted identity (e.g. `${ctx.auth.orgId}`), never from `args`.",title:"Vectorize namespace derived from unscoped user input"});export{a as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as l}from"./finding-BAVgqnCF.mjs";const p={categories:["SCHEMA"],description:"Two durable steps in this workflow share a name. Cloudflare memoizes a step by its name, so on replay the second call returns the first step's cached result instead of running its body — silently skipping the work without an error.",facing:"INTERNAL",level:"ERROR",name:"workflow_duplicate_step_name",remediation:"Give every `step.do` / `step.sleep` / `step.sleepUntil` / `step.waitForEvent` call in the workflow a unique name. If a step legitimately repeats (e.g. a loop), make the name distinct per iteration by interpolating the item id into the step name.",run:a=>{if(a.workflows===void 0)return[];const o=[];for(const t of a.workflows){const{steps:n}=t;if(n===void 0||n.length===0)continue;const s=new Map,r=new Set;for(const e of n){const i=s.get(e.name);if(i===void 0){s.set(e.name,e.line);continue}r.has(e.name)||(r.add(e.name),o.push(l(p,{cacheKey:`workflow_duplicate_step_name:${t.exportName}:${e.name}`,detail:`Workflow "${t.exportName}" reuses the durable step name "${e.name}" (first at line ${String(i)}, again at line ${String(e.line)}). The second call returns the first's cached result instead of running.`,metadata:{firstLine:i,line:e.line,stepName:e.name,workflow:t.exportName}})))}}return o},source:"static",title:"Duplicate durable step name in workflow"};export{p as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i as l}from"./finding-BAVgqnCF.mjs";const t={categories:["SCHEMA"],description:'A `ctx.workflows.get("<name>")` call references a workflow that is not declared by any `defineWorkflow` export in `lunora/workflows.ts`. The name is a typo or points at a removed/renamed workflow — the call throws at runtime.',facing:"INTERNAL",level:"ERROR",name:"workflow_unknown_target",remediation:'Fix the workflow name in the `ctx.workflows.get("…")` call, or add the missing `defineWorkflow` export to `lunora/workflows.ts`.',run:e=>{if(e.workflows===void 0||e.workflowCalls===void 0)return[];const w=new Set(e.workflows.map(o=>o.exportName)),r=[];for(const o of e.workflowCalls)o.workflow===""||w.has(o.workflow)||r.push(l(t,{cacheKey:`workflow_unknown_target:${o.file}:${o.exportName}:${o.workflow}`,detail:`\`ctx.workflows.get("${o.workflow}")\` in "${o.exportName}" (${o.file}) references workflow "${o.workflow}", which is not declared in lunora/workflows.ts.`,metadata:{exportName:o.exportName,file:o.file,line:o.line,workflow:o.workflow}}));return r},source:"static",title:"Workflow call references unknown workflow"};export{t as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{i}from"./finding-BAVgqnCF.mjs";const s={categories:["SCHEMA"],description:'No function starts this workflow via `ctx.workflows.get("<name>")`. It may be triggered through a path the advisor can\'t see (the Cloudflare API, a wrangler invocation, a cross-service binding) — or it may be dead code that still deploys as a billable WorkflowEntrypoint.',facing:"INTERNAL",level:"INFO",name:"workflow_unused",remediation:'If the workflow should be triggered in-app, start it from a mutation/action with `ctx.workflows.get("<name>").create({ params })`. If it is started externally or is no longer needed, this advisory can be ignored (or remove the `defineWorkflow` export).',run:o=>{if(o.workflows===void 0)return[];const t=o.workflowCalls??[];if(t.some(e=>e.workflow===""))return[];const a=new Set(t.map(e=>e.workflow)),r=[];for(const e of o.workflows)a.has(e.exportName)||r.push(i(s,{cacheKey:`workflow_unused:${e.exportName}`,detail:`No function calls \`ctx.workflows.get("${e.exportName}")\` — workflow "${e.exportName}" is declared but never started in app code.`,metadata:{workflow:e.exportName}}));return r},source:"static",title:"Workflow is never started"};export{s as default};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/advisor",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.41",
|
|
4
4
|
"description": "Schema & query lints (splinter-style advisors) for Lunora, feeding the Studio Advisors view",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"advisor",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"directory": "packages/advisor"
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
|
-
"dist",
|
|
28
|
+
"./dist",
|
|
29
29
|
"README.md",
|
|
30
30
|
"LICENSE.md",
|
|
31
31
|
"__assets__"
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@lunora/
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.8",
|
|
50
|
+
"@lunora/server": "1.0.0-alpha.38"
|
|
50
51
|
},
|
|
51
52
|
"engines": {
|
|
52
53
|
"node": "^22.15.0 || >=24.11.0"
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
const DATASET_NAME_PATTERN = /^[\w.-]+$/u;
|
|
2
|
-
const AE_METRIC_EVENTS = {
|
|
3
|
-
/** `lunora.index.hit` — one row per `(table, index)` use. `blob2`=table, `blob3`=index. */
|
|
4
|
-
indexHit: { event: "lunora.index.hit", index: "blob3", table: "blob2" },
|
|
5
|
-
/** `lunora.shard.request` — one row per shard dispatch. `blob2`=shardKey, `blob3`=group. */
|
|
6
|
-
shardRequest: { event: "lunora.shard.request", group: "blob3", shardKey: "blob2" },
|
|
7
|
-
/** `lunora.table.scan` — one row per full-scan. `blob2`=table. */
|
|
8
|
-
tableScan: { event: "lunora.table.scan", table: "blob2" }
|
|
9
|
-
};
|
|
10
|
-
const toCount = (value) => {
|
|
11
|
-
const numeric = typeof value === "number" ? value : Number(value);
|
|
12
|
-
return Number.isFinite(numeric) ? numeric : 0;
|
|
13
|
-
};
|
|
14
|
-
const toText = (value) => {
|
|
15
|
-
if (typeof value === "string") {
|
|
16
|
-
return value;
|
|
17
|
-
}
|
|
18
|
-
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
19
|
-
return String(value);
|
|
20
|
-
}
|
|
21
|
-
return "";
|
|
22
|
-
};
|
|
23
|
-
const assertDataset = (dataset) => {
|
|
24
|
-
if (!DATASET_NAME_PATTERN.test(dataset)) {
|
|
25
|
-
throw new Error(`@lunora/advisor: invalid Analytics Engine dataset name "${dataset}" — expected a bare table identifier.`);
|
|
26
|
-
}
|
|
27
|
-
};
|
|
28
|
-
const sqlString = (value) => `'${value.replaceAll("\\", "\\\\").replaceAll("'", "''")}'`;
|
|
29
|
-
const queryOrEmpty = async (source, sql) => {
|
|
30
|
-
try {
|
|
31
|
-
const result = await source.query(sql);
|
|
32
|
-
return result.rows;
|
|
33
|
-
} catch {
|
|
34
|
-
return [];
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
const loadShardTraffic = async (source, options) => {
|
|
38
|
-
const { event, group, shardKey } = AE_METRIC_EVENTS.shardRequest;
|
|
39
|
-
const groupFilter = options.group === void 0 ? "" : ` AND ${group} = ${sqlString(options.group)}`;
|
|
40
|
-
const sql = `SELECT ${shardKey} AS shardKey, ${group} AS shardGroup, sum(_sample_interval) AS requests FROM ${options.dataset} WHERE blob1 = ${sqlString(event)}${groupFilter} GROUP BY shardKey, shardGroup`;
|
|
41
|
-
const rows = await queryOrEmpty(source, sql);
|
|
42
|
-
return rows.map((row) => {
|
|
43
|
-
const shardGroup = toText(row.shardGroup);
|
|
44
|
-
return {
|
|
45
|
-
...shardGroup === "" ? {} : { group: shardGroup },
|
|
46
|
-
requests: toCount(row.requests),
|
|
47
|
-
shardKey: toText(row.shardKey)
|
|
48
|
-
};
|
|
49
|
-
});
|
|
50
|
-
};
|
|
51
|
-
const loadTableScans = async (source, options) => {
|
|
52
|
-
const { event, table } = AE_METRIC_EVENTS.tableScan;
|
|
53
|
-
const sql = `SELECT ${table} AS scanTable, sum(_sample_interval) AS scans FROM ${options.dataset} WHERE blob1 = ${sqlString(event)} GROUP BY scanTable`;
|
|
54
|
-
const rows = await queryOrEmpty(source, sql);
|
|
55
|
-
return rows.map((row) => {
|
|
56
|
-
return { scans: toCount(row.scans), table: toText(row.scanTable) };
|
|
57
|
-
});
|
|
58
|
-
};
|
|
59
|
-
const loadIndexHits = async (source, options) => {
|
|
60
|
-
const { event, index, table } = AE_METRIC_EVENTS.indexHit;
|
|
61
|
-
const sql = `SELECT ${table} AS hitTable, ${index} AS hitIndex, sum(_sample_interval) AS reads FROM ${options.dataset} WHERE blob1 = ${sqlString(event)} GROUP BY hitTable, hitIndex`;
|
|
62
|
-
const rows = await queryOrEmpty(source, sql);
|
|
63
|
-
const hits = rows.map((row) => {
|
|
64
|
-
return { index: toText(row.hitIndex), reads: toCount(row.reads), table: toText(row.hitTable) };
|
|
65
|
-
});
|
|
66
|
-
if (options.declaredIndexes === void 0) {
|
|
67
|
-
return hits;
|
|
68
|
-
}
|
|
69
|
-
const seen = new Set(hits.map((hit) => `${hit.table}\0${hit.index}`));
|
|
70
|
-
const zeros = options.declaredIndexes.filter((declared) => !seen.has(`${declared.table}\0${declared.index}`)).map((declared) => {
|
|
71
|
-
return { index: declared.index, reads: 0, table: declared.table };
|
|
72
|
-
});
|
|
73
|
-
return [...hits, ...zeros];
|
|
74
|
-
};
|
|
75
|
-
const loadAnalyticsRuntimeMetrics = async (source, options) => {
|
|
76
|
-
assertDataset(options.dataset);
|
|
77
|
-
const [shardTraffic, tableScans, indexHits] = await Promise.all([
|
|
78
|
-
loadShardTraffic(source, options),
|
|
79
|
-
loadTableScans(source, options),
|
|
80
|
-
loadIndexHits(source, options)
|
|
81
|
-
]);
|
|
82
|
-
return { indexHits, shardTraffic, tableScans };
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
export { AE_METRIC_EVENTS, loadAnalyticsRuntimeMetrics };
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const adminRouteWithoutGuard = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
description: "An `httpRoute` on an admin/privileged path has no visible auth/admin guard. REST routes aren't covered by RLS, so an unguarded `/admin/*` route is callable by anyone who reaches the URL.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "admin_route_without_guard",
|
|
9
|
-
remediation: "Assert an authenticated, authorized caller at the top of the handler — check `ctx.auth` / `getSession(...)`, or a `requireAdmin`-style guard — and reject (401/403) before doing privileged work. For machine callers, verify a secret with a timing-safe compare.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.adminRoutes === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const findings = [];
|
|
15
|
-
for (const route of context.adminRoutes) {
|
|
16
|
-
if (route.usesGuard) {
|
|
17
|
-
continue;
|
|
18
|
-
}
|
|
19
|
-
findings.push(
|
|
20
|
-
emit(adminRouteWithoutGuard, {
|
|
21
|
-
cacheKey: `admin_route_without_guard:${route.file}:${route.method}:${route.path}`,
|
|
22
|
-
detail: `Route \`${route.method} ${route.path}\` (\`${route.exportName}\` in ${route.file}) is on an admin/privileged path but its handler shows no auth/admin guard. Assert an authorized caller before doing privileged work.`,
|
|
23
|
-
metadata: { exportName: route.exportName, file: route.file, method: route.method, path: route.path }
|
|
24
|
-
})
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
return findings;
|
|
28
|
-
},
|
|
29
|
-
source: "static",
|
|
30
|
-
title: "Admin route without an auth guard"
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
export { adminRouteWithoutGuard as default };
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const authApiCallWithoutHeaders = {
|
|
4
|
-
categories: ["SECURITY"],
|
|
5
|
-
description: "A `ctx.authApi.<method>(...)` call omits `headers` — better-auth skips session authorization and runs the call with full server-to-server privileges, regardless of the caller's identity.",
|
|
6
|
-
facing: "EXTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "auth_api_call_without_headers",
|
|
9
|
-
remediation: "Pass the inbound `headers` from the incoming request into every `ctx.authApi.*` call: `ctx.authApi.<method>({ body, headers: request.headers })`. See the `withAuthPlugins` middleware doc for details.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
if (context.authApiCalls === void 0) {
|
|
12
|
-
return [];
|
|
13
|
-
}
|
|
14
|
-
const findings = [];
|
|
15
|
-
const occurrenceCount = /* @__PURE__ */ new Map();
|
|
16
|
-
for (const call of context.authApiCalls) {
|
|
17
|
-
if (call.hasHeaders) {
|
|
18
|
-
continue;
|
|
19
|
-
}
|
|
20
|
-
const baseKey = `${call.file}:${call.line.toString()}:${call.method}`;
|
|
21
|
-
const occurrence = (occurrenceCount.get(baseKey) ?? 0) + 1;
|
|
22
|
-
occurrenceCount.set(baseKey, occurrence);
|
|
23
|
-
const occurrenceSuffix = occurrence > 1 ? `:${occurrence.toString()}` : "";
|
|
24
|
-
findings.push(
|
|
25
|
-
emit(authApiCallWithoutHeaders, {
|
|
26
|
-
cacheKey: `auth_api_call_without_headers:${baseKey}${occurrenceSuffix}`,
|
|
27
|
-
detail: `\`ctx.authApi.${call.method || "<method>"}(…)\` in ${call.exportName} (${call.file}:${call.line.toString()}) is called without \`headers\` — better-auth skips session authorization, so this runs with full privileges. Pass the inbound \`headers\`.`,
|
|
28
|
-
metadata: { exportName: call.exportName, file: call.file, line: call.line, method: call.method }
|
|
29
|
-
})
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
return findings;
|
|
33
|
-
},
|
|
34
|
-
source: "static",
|
|
35
|
-
title: "Privileged ctx.authApi call missing headers"
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export { authApiCallWithoutHeaders as default };
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const circularFk = {
|
|
4
|
-
categories: ["SCHEMA"],
|
|
5
|
-
description: "A chain of foreign-key relations forms a cycle (e.g. A → B → C → A). Circular FK dependencies can cause unexpected behavior during DELETE operations — a CASCADE chain may loop indefinitely, and even a RESTRICT cycle prevents deletion of any row in the loop without temporarily disabling constraints.",
|
|
6
|
-
facing: "INTERNAL",
|
|
7
|
-
level: "WARN",
|
|
8
|
-
name: "circular_fk",
|
|
9
|
-
remediation: "Remove or break the cycle by dropping at least one FK relation from the loop. Consider replacing the circular dependency with a nullable FK and explicit application logic, or restructure the schema using a junction table.",
|
|
10
|
-
run: (context) => {
|
|
11
|
-
const findings = [];
|
|
12
|
-
const edges = /* @__PURE__ */ new Map();
|
|
13
|
-
for (const table of context.schema.tables) {
|
|
14
|
-
for (const relation of table.relations) {
|
|
15
|
-
if (relation.kind !== "one") {
|
|
16
|
-
continue;
|
|
17
|
-
}
|
|
18
|
-
let targets = edges.get(table.name);
|
|
19
|
-
if (targets === void 0) {
|
|
20
|
-
targets = /* @__PURE__ */ new Set();
|
|
21
|
-
edges.set(table.name, targets);
|
|
22
|
-
}
|
|
23
|
-
targets.add(relation.table);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
const onStack = /* @__PURE__ */ new Set();
|
|
27
|
-
const reported = /* @__PURE__ */ new Set();
|
|
28
|
-
const reportBackEdge = (neighbor, path) => {
|
|
29
|
-
const cycleStart = path.indexOf(neighbor);
|
|
30
|
-
if (cycleStart === -1) {
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
const cycle = path.slice(cycleStart);
|
|
34
|
-
if (cycle.length < 2) {
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
let minIndex = 0;
|
|
38
|
-
for (let index = 1; index < cycle.length; index += 1) {
|
|
39
|
-
if (cycle[index] < cycle[minIndex]) {
|
|
40
|
-
minIndex = index;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
const canonical = [...cycle.slice(minIndex), ...cycle.slice(0, minIndex)];
|
|
44
|
-
const key = canonical.join("→");
|
|
45
|
-
if (reported.has(key)) {
|
|
46
|
-
return;
|
|
47
|
-
}
|
|
48
|
-
reported.add(key);
|
|
49
|
-
const displayPath = [...canonical, canonical[0]].join(" → ");
|
|
50
|
-
findings.push(
|
|
51
|
-
emit(circularFk, {
|
|
52
|
-
cacheKey: `circular_fk:${key}`,
|
|
53
|
-
detail: `Circular foreign-key dependency detected: ${displayPath}. This cycle can cause unexpected behavior during DELETE operations.`,
|
|
54
|
-
metadata: {
|
|
55
|
-
cycle: canonical,
|
|
56
|
-
path: displayPath,
|
|
57
|
-
tables: canonical
|
|
58
|
-
}
|
|
59
|
-
})
|
|
60
|
-
);
|
|
61
|
-
};
|
|
62
|
-
const dfs = (node, path) => {
|
|
63
|
-
onStack.add(node);
|
|
64
|
-
path.push(node);
|
|
65
|
-
for (const neighbor of edges.get(node) ?? []) {
|
|
66
|
-
if (onStack.has(neighbor)) {
|
|
67
|
-
reportBackEdge(neighbor, path);
|
|
68
|
-
} else {
|
|
69
|
-
dfs(neighbor, path);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
path.pop();
|
|
73
|
-
onStack.delete(node);
|
|
74
|
-
};
|
|
75
|
-
for (const table of context.schema.tables) {
|
|
76
|
-
dfs(table.name, []);
|
|
77
|
-
}
|
|
78
|
-
return findings;
|
|
79
|
-
},
|
|
80
|
-
source: "static",
|
|
81
|
-
title: "Circular foreign-key dependency"
|
|
82
|
-
};
|
|
83
|
-
|
|
84
|
-
export { circularFk as default };
|
|
@@ -1,186 +0,0 @@
|
|
|
1
|
-
import { e as emit } from './finding-Dm_zvzS1.mjs';
|
|
2
|
-
|
|
3
|
-
const MAX_EXAMPLE_IDS = 5;
|
|
4
|
-
const rowId = (row) => typeof row["_id"] === "string" ? row["_id"] : "?";
|
|
5
|
-
const truncatedSuffix = (capped, cap, phrase) => capped ? ` (sample capped at ${cap.toString()} rows — ${phrase})` : "";
|
|
6
|
-
const formatExamples = (ids, total) => {
|
|
7
|
-
const examples = ids.slice(0, MAX_EXAMPLE_IDS);
|
|
8
|
-
const more = total - examples.length;
|
|
9
|
-
const list = examples.map((id) => `"${id}"`).join(", ");
|
|
10
|
-
return more > 0 ? `${list} (+${more.toString()} more)` : list;
|
|
11
|
-
};
|
|
12
|
-
const checkFkRelation = (lint, sample, relation, targetSample) => {
|
|
13
|
-
if (relation.kind !== "one") {
|
|
14
|
-
return void 0;
|
|
15
|
-
}
|
|
16
|
-
const fkColumn = relation.field;
|
|
17
|
-
const danglingIds = [];
|
|
18
|
-
for (const row of sample.rows) {
|
|
19
|
-
const fkValue = row[fkColumn];
|
|
20
|
-
if (fkValue === null || fkValue === void 0) {
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
const fkString = String(fkValue);
|
|
24
|
-
if (!targetSample.existingIds.has(fkString)) {
|
|
25
|
-
danglingIds.push(rowId(row));
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
if (danglingIds.length === 0) {
|
|
29
|
-
return void 0;
|
|
30
|
-
}
|
|
31
|
-
const isTruncated = sample.truncated || targetSample.truncated;
|
|
32
|
-
const cap = Math.min(sample.cap, targetSample.cap);
|
|
33
|
-
const detail = `Table "${sample.table}": ${danglingIds.length.toString()} sampled row(s) have a dangling FK value in column "${fkColumn}" — the referenced "${relation.table}" row does not exist. Row ids: ${formatExamples(danglingIds, danglingIds.length)}${truncatedSuffix(isTruncated, cap, "more rows may exist beyond the window")}.`;
|
|
34
|
-
return emit(lint, {
|
|
35
|
-
cacheKey: `constraint_validator:fk:${sample.table}:${fkColumn}`,
|
|
36
|
-
detail,
|
|
37
|
-
metadata: {
|
|
38
|
-
cap,
|
|
39
|
-
column: fkColumn,
|
|
40
|
-
count: danglingIds.length,
|
|
41
|
-
examples: danglingIds.slice(0, MAX_EXAMPLE_IDS),
|
|
42
|
-
kind: "fk",
|
|
43
|
-
referencesTable: relation.table,
|
|
44
|
-
table: sample.table,
|
|
45
|
-
truncated: isTruncated
|
|
46
|
-
}
|
|
47
|
-
});
|
|
48
|
-
};
|
|
49
|
-
const checkNullField = (lint, sample, field, isRequired) => {
|
|
50
|
-
if (!isRequired) {
|
|
51
|
-
return void 0;
|
|
52
|
-
}
|
|
53
|
-
const nullIds = [];
|
|
54
|
-
for (const row of sample.rows) {
|
|
55
|
-
const value = row[field];
|
|
56
|
-
if (value === null || value === void 0) {
|
|
57
|
-
nullIds.push(rowId(row));
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
if (nullIds.length === 0) {
|
|
61
|
-
return void 0;
|
|
62
|
-
}
|
|
63
|
-
const detail = `Table "${sample.table}": ${nullIds.length.toString()} sampled row(s) have a null/missing value in column "${field}", which is declared as non-optional. Row ids: ${formatExamples(nullIds, nullIds.length)}${truncatedSuffix(sample.truncated, sample.cap, "more rows may exist beyond the window")}.`;
|
|
64
|
-
return emit(lint, {
|
|
65
|
-
cacheKey: `constraint_validator:null:${sample.table}:${field}`,
|
|
66
|
-
detail,
|
|
67
|
-
metadata: {
|
|
68
|
-
cap: sample.cap,
|
|
69
|
-
column: field,
|
|
70
|
-
count: nullIds.length,
|
|
71
|
-
examples: nullIds.slice(0, MAX_EXAMPLE_IDS),
|
|
72
|
-
kind: "null",
|
|
73
|
-
table: sample.table,
|
|
74
|
-
truncated: sample.truncated
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
};
|
|
78
|
-
const compositeKey = (row, fields) => {
|
|
79
|
-
const parts = [];
|
|
80
|
-
for (const field of fields) {
|
|
81
|
-
const value = row[field];
|
|
82
|
-
if (value === null || value === void 0) {
|
|
83
|
-
return void 0;
|
|
84
|
-
}
|
|
85
|
-
parts.push(JSON.stringify(value));
|
|
86
|
-
}
|
|
87
|
-
return parts.join("|");
|
|
88
|
-
};
|
|
89
|
-
const recordDuplicate = (id, existingId, duplicateIds) => {
|
|
90
|
-
if (!duplicateIds.includes(existingId)) {
|
|
91
|
-
duplicateIds.push(existingId);
|
|
92
|
-
}
|
|
93
|
-
duplicateIds.push(id);
|
|
94
|
-
};
|
|
95
|
-
const checkUniqueIndex = (lint, sample, index) => {
|
|
96
|
-
if (index.kind !== "index" || index.unique !== true) {
|
|
97
|
-
return void 0;
|
|
98
|
-
}
|
|
99
|
-
const seen = /* @__PURE__ */ new Map();
|
|
100
|
-
const duplicateIds = [];
|
|
101
|
-
for (const row of sample.rows) {
|
|
102
|
-
const id = rowId(row);
|
|
103
|
-
const key = compositeKey(row, index.fields);
|
|
104
|
-
if (key === void 0) {
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
const existingId = seen.get(key);
|
|
108
|
-
if (existingId === void 0) {
|
|
109
|
-
seen.set(key, id);
|
|
110
|
-
} else {
|
|
111
|
-
recordDuplicate(id, existingId, duplicateIds);
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (duplicateIds.length === 0) {
|
|
115
|
-
return void 0;
|
|
116
|
-
}
|
|
117
|
-
const detail = `Table "${sample.table}": ${duplicateIds.length.toString()} sampled row(s) share duplicate values on unique index "${index.name}" (${index.fields.join(", ")}). Row ids: ${formatExamples(duplicateIds, duplicateIds.length)}${truncatedSuffix(sample.truncated, sample.cap, "duplicates beyond the window may exist")}.`;
|
|
118
|
-
return emit(lint, {
|
|
119
|
-
cacheKey: `constraint_validator:unique:${sample.table}:${index.name}`,
|
|
120
|
-
detail,
|
|
121
|
-
metadata: {
|
|
122
|
-
cap: sample.cap,
|
|
123
|
-
count: duplicateIds.length,
|
|
124
|
-
examples: duplicateIds.slice(0, MAX_EXAMPLE_IDS),
|
|
125
|
-
fields: index.fields,
|
|
126
|
-
index: index.name,
|
|
127
|
-
kind: "unique",
|
|
128
|
-
table: sample.table,
|
|
129
|
-
truncated: sample.truncated
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
};
|
|
133
|
-
const checkTable = (lint, sample, sampleByTable, tableByName) => {
|
|
134
|
-
const schemaTable = tableByName.get(sample.table);
|
|
135
|
-
if (!schemaTable) {
|
|
136
|
-
return [];
|
|
137
|
-
}
|
|
138
|
-
const findings = [];
|
|
139
|
-
for (const relation of schemaTable.relations) {
|
|
140
|
-
const targetSample = sampleByTable.get(relation.table);
|
|
141
|
-
if (!targetSample) {
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
const f = checkFkRelation(lint, sample, relation, targetSample);
|
|
145
|
-
if (f) {
|
|
146
|
-
findings.push(f);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
for (const field of schemaTable.fields) {
|
|
150
|
-
const isRequired = !schemaTable.optionalFields?.has(field);
|
|
151
|
-
const f = checkNullField(lint, sample, field, isRequired);
|
|
152
|
-
if (f) {
|
|
153
|
-
findings.push(f);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
for (const index of schemaTable.indexes) {
|
|
157
|
-
const f = checkUniqueIndex(lint, sample, index);
|
|
158
|
-
if (f) {
|
|
159
|
-
findings.push(f);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
return findings;
|
|
163
|
-
};
|
|
164
|
-
const constraintValidator = {
|
|
165
|
-
categories: ["SCHEMA"],
|
|
166
|
-
description: "Sampled rows violate one or more declared constraints: a foreign-key column references a non-existent row, a non-optional column contains null, or a unique-indexed column has duplicate values. These violations indicate data inserted before a constraint was enforced (e.g. a schema migration, a raw import, or a bug in a past data-migration transform).",
|
|
167
|
-
facing: "INTERNAL",
|
|
168
|
-
level: "WARN",
|
|
169
|
-
name: "constraint_validator",
|
|
170
|
-
remediation: "Inspect the listed rows and correct or remove the violating values. Run a data migration to backfill nulls, dedup unique violations, or re-link dangling FK references. For FK violations, confirm the target table rows exist before fixing the referencing rows.",
|
|
171
|
-
run: (context) => {
|
|
172
|
-
if (!context.tableSamples || context.tableSamples.length === 0) {
|
|
173
|
-
return [];
|
|
174
|
-
}
|
|
175
|
-
const sampleByTable = /* @__PURE__ */ new Map();
|
|
176
|
-
for (const sample of context.tableSamples) {
|
|
177
|
-
sampleByTable.set(sample.table, sample);
|
|
178
|
-
}
|
|
179
|
-
const tableByName = new Map(context.schema.tables.map((table) => [table.name, table]));
|
|
180
|
-
return context.tableSamples.flatMap((sample) => checkTable(constraintValidator, sample, sampleByTable, tableByName));
|
|
181
|
-
},
|
|
182
|
-
source: "runtime",
|
|
183
|
-
title: "Constraint violation"
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
export { constraintValidator as default };
|