@lunora/advisor 1.0.0-alpha.31 → 1.0.0-alpha.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -280,6 +280,39 @@ interface AdvisorContainer {
280
280
  /** Declared `sleepAfter` value, when a static literal. */
281
281
  sleepAfter?: number | string;
282
282
  }
283
+ /**
284
+ * One CDC export-sink construction discovered in a function body — the input the
285
+ * `export_sink_misconfigured` lint consumes. Produced by the codegen feeder,
286
+ * which walks the lunora source for the three sink factories the export tap
287
+ * (plan 170) ships: `defineExportSink({ name, deliver })`,
288
+ * `webhookExportSink({ name, url, … })`, and `r2Sink({ name, bucket, … })`.
289
+ *
290
+ * A sink missing a required field can never deliver a change batch — a webhook
291
+ * with no `url` would POST to `undefined`, an R2 sink with no `bucket` binding has
292
+ * nowhere to write, and a custom sink with no `deliver` has no delivery path. The
293
+ * runtime `defineExportSink` guard throws for a missing `name`/`deliver`, but the
294
+ * built-in `webhookExportSink`/`r2Sink` don't validate `url`/`bucket`, so catching
295
+ * the misconfiguration statically beats a silently-dead export tap at runtime.
296
+ * Runtime callers don't supply this, so the lint finds nothing there.
297
+ */
298
+ interface AdvisorExportSink {
299
+ /**
300
+ * True when the factory's config argument is a statically analyzable object
301
+ * literal. A non-literal config (a variable, a spread) is not decidable, so
302
+ * the lint skips it rather than raising a false alarm.
303
+ */
304
+ analyzable: boolean;
305
+ /** Present keys whose value is an empty-string literal (`""`) — treated as missing. */
306
+ emptyKeys: string[];
307
+ /** Which sink factory was called. */
308
+ factory: "defineExportSink" | "r2Sink" | "webhookExportSink";
309
+ /** Source file the construction appears in (relative to the lunora dir, no extension). */
310
+ file: string;
311
+ /** 1-based line of the factory call, or `0` when unknown. */
312
+ line: number;
313
+ /** Config keys present on the object literal, regardless of value. */
314
+ presentKeys: string[];
315
+ }
283
316
  /**
284
317
  * One rate-limit / Turnstile middleware call — the `ratelimit_middleware_fail_open`
285
318
  * lint input. `rateLimit`/`dbRateLimit` (`@lunora/ratelimit`) and
@@ -328,6 +361,27 @@ interface AdvisorFlagSecurityDefault {
328
361
  /** 1-based line of the `ctx.flags.boolean` call, or `0` when unknown. */
329
362
  line: number;
330
363
  }
364
+ /**
365
+ * One `withGeoIndex("name", …)` read discovered in a function body — the use-side
366
+ * input the `geo_index_unused` lint cross-references against the declared geo
367
+ * indexes in the lint context's schema. Produced by the codegen feeder, which
368
+ * walks the lunora source for `ctx.db.query("t").withGeoIndex(name, …)` /
369
+ * `ctx.db.<table>.withGeoIndex(name, …)` reads and records each referenced index
370
+ * name. Runtime callers don't supply it, so the lint finds nothing there.
371
+ *
372
+ * A `.geoIndex(name, { field })` maintains a geohash companion column on every
373
+ * write; if no handler ever reads it via `withGeoIndex(name, …)` the companion is
374
+ * dead overhead (maintained on every write, read by nothing) — the geo analogue
375
+ * of a dead regular index.
376
+ */
377
+ interface AdvisorGeoIndexUsage {
378
+ /** Source file the read appears in (relative to the lunora dir, no extension). */
379
+ file: string;
380
+ /** The referenced geo-index name; empty when the `withGeoIndex(...)` argument is not a string literal. */
381
+ indexName: string;
382
+ /** 1-based line of the `withGeoIndex(...)` call, or `0` when unknown. */
383
+ line: number;
384
+ }
331
385
  /**
332
386
  * One discovered `httpAction`/`httpRoute` handler that performs a side effect
333
387
  * (`ctx.runMutation` / `ctx.runAction` / a `ctx.db.{insert,patch,replace,delete,
@@ -688,6 +742,48 @@ interface AdvisorNormalizeIdAuthorization {
688
742
  /** `"internal"` for `internalQuery`/`internalMutation`; `"public"` for `query`/`mutation`. */
689
743
  visibility: "internal" | "public";
690
744
  }
745
+ /**
746
+ * One `ctx.notify` / `ctx.push` send discovered lexically inside a `query(...)`
747
+ * or `mutation(...)` handler body — the input the `notify_send_outside_action`
748
+ * lint consumes. Produced by the codegen feeder, which walks each exported
749
+ * function's handler with ts-morph and records the `@lunora/notify` send surface
750
+ * (`ctx.notify.send`, `ctx.notify.chat/inApp/webhook`, `ctx.push.send`,
751
+ * `ctx.push.broadcast`).
752
+ *
753
+ * A notification send is external I/O (a `fetch` to a push service / FCM): it is
754
+ * non-deterministic like `fetch`, so it breaks the determinism the coordinator
755
+ * relies on when re-running a query on subscription re-evaluation or a mutation on
756
+ * OCC retry (a retried mutation would re-send). It therefore belongs **only** in
757
+ * `action(...)` handlers. Calls inside `action(...)` are intentionally **not**
758
+ * recorded — actions are the escape hatch. Runtime callers don't supply this, so
759
+ * the lint finds nothing there.
760
+ */
761
+ interface AdvisorNotifyCall {
762
+ /** The send surface accessed, e.g. `ctx.push.broadcast` / `ctx.notify.send`. */
763
+ callee: string;
764
+ /** The exported function performing the send (e.g. `sendWelcome`). */
765
+ exportName: string;
766
+ /** Source file the send appears in (relative to the lunora dir, no extension). */
767
+ file: string;
768
+ /** Which procedure kind the send lives in — only `query`/`mutation` are flagged; actions are exempt. */
769
+ kind: "mutation" | "query";
770
+ /** 1-based line of the send, or `0` when unknown. */
771
+ line: number;
772
+ }
773
+ /**
774
+ * Whether an app uses `ctx.push` and which push channels `defineNotify(...)`
775
+ * configures — the input the `notify_missing_push_config` lint consumes. Produced
776
+ * by the codegen feeder from the resolved `lunora/notify.ts` definition and the
777
+ * discovered `ctx.push` usage; absent for runtime callers.
778
+ */
779
+ interface AdvisorNotifyConfig {
780
+ /** Whether `defineNotify` wired the FCM channel. */
781
+ hasFcm: boolean;
782
+ /** Whether `defineNotify` wired the Web Push channel. */
783
+ hasWebPush: boolean;
784
+ /** Whether any handler sends a push (`ctx.push.send` / `ctx.push.broadcast`). */
785
+ usesPush: boolean;
786
+ }
691
787
  /**
692
788
  * One `ctx.db` write (`insert` / `replace` / `patch` / `insertManyUnsafe`) that
693
789
  * sets an ownership / identity column — `userId`, `ownerId`, `tenantId`, and the
@@ -786,6 +882,8 @@ interface AdvisorProcedureProtection {
786
882
  unboundedAiGeneration: boolean;
787
883
  /** `true` when the chain carries `.use(verifyTurnstile(...))` or a `protectPublic({ captcha })` bundle. */
788
884
  usesCaptcha: boolean;
885
+ /** `true` when the chain carries `.use(emailGateMiddleware(...))` from `@lunora/auth`. Read by the `signup_mutation_without_disposable_gating` lint (paired with public visibility + a user-table write). */
886
+ usesEmailGate: boolean;
789
887
  /** `true` when the handler calls `ctx.db.insertManyUnsafe(...)`, which bypasses validators and triggers. Read by the `insert_many_unsafe_user_data` lint (paired with public visibility). */
790
888
  usesInsertManyUnsafe: boolean;
791
889
  /** `true` when the chain carries `.use(mask(...))`. */
@@ -1000,6 +1098,14 @@ interface AdvisorSchema {
1000
1098
  }
1001
1099
  /** A table plus the column/index/relation metadata lints inspect. */
1002
1100
  interface AdvisorTable {
1101
+ /**
1102
+ * Effective validator kind per declared column (a `v.optional(...)` is
1103
+ * unwrapped to its inner kind). Read by the schema-type lints
1104
+ * (`ttl_field_not_timestamp`, `geo_index_field_not_geopoint`) to check a
1105
+ * referenced column's type. Optional — a feeder that doesn't track column
1106
+ * kinds omits it, and the type lints then skip the check.
1107
+ */
1108
+ columnKinds?: Record<string, string>;
1003
1109
  /**
1004
1110
  * `true` when the table is written outside Lunora's discoverable insert path
1005
1111
  * — declared via `.externallyManaged()` (e.g. `@lunora/auth`'s better-auth
@@ -1067,6 +1173,15 @@ interface AdvisorTable {
1067
1173
  softDelete?: {
1068
1174
  field: string;
1069
1175
  };
1176
+ /**
1177
+ * Set when the table declared `.ttl(field, { after? })`. Read by the
1178
+ * `ttl_field_not_timestamp` lint to confirm the expiry column is time-typed.
1179
+ * Optional — a feeder that doesn't track TTL omits it.
1180
+ */
1181
+ ttl?: {
1182
+ after?: number;
1183
+ field: string;
1184
+ };
1070
1185
  }
1071
1186
  /**
1072
1187
  * One declared index, flattened across Lunora's index kinds so a single lint can
@@ -1079,7 +1194,7 @@ interface AdvisorTable {
1079
1194
  */
1080
1195
  interface AdvisorIndex {
1081
1196
  fields: ReadonlyArray<string>;
1082
- kind: "index" | "rank" | "search" | "vector";
1197
+ kind: "geo" | "index" | "rank" | "search" | "vector";
1083
1198
  name: string;
1084
1199
  unique?: boolean;
1085
1200
  }
@@ -1558,6 +1673,16 @@ interface LintContext {
1558
1673
  * the container lints find nothing.
1559
1674
  */
1560
1675
  containers?: ReadonlyArray<AdvisorContainer>;
1676
+ /**
1677
+ * CDC export-sink constructions (`defineExportSink` / `webhookExportSink` /
1678
+ * `r2Sink`) discovered in function bodies — the `export_sink_misconfigured`
1679
+ * input. Each carries which config keys were present (and which were an empty
1680
+ * string), so the lint can flag a sink missing a required field (a webhook
1681
+ * with no `url`, an R2 sink with no `bucket`, a sink with no `name`/`deliver`).
1682
+ * Supplied by the codegen feeder; absent for runtime callers, where the lint
1683
+ * finds nothing.
1684
+ */
1685
+ exportSinks?: ReadonlyArray<AdvisorExportSink>;
1561
1686
  /**
1562
1687
  * `rateLimit`/`dbRateLimit` (`@lunora/ratelimit`) and `verifyTurnstileMiddleware`
1563
1688
  * (`@lunora/auth`) middleware calls, each with whether its options literal set
@@ -1579,6 +1704,15 @@ interface LintContext {
1579
1704
  * codegen feeder; absent for runtime callers, where the lint finds nothing.
1580
1705
  */
1581
1706
  flagSecurityDefaults?: ReadonlyArray<AdvisorFlagSecurityDefault>;
1707
+ /**
1708
+ * `withGeoIndex("name", …)` reads discovered in function bodies — the use-side
1709
+ * input the `geo_index_unused` lint cross-references against the declared geo
1710
+ * indexes in {@link LintContext.schema}. A declared `.geoIndex(name, …)` with
1711
+ * no matching read is dead overhead (its geohash companion is maintained on
1712
+ * every write and read by nothing). Supplied by the codegen feeder; absent for
1713
+ * runtime callers, where the lint finds nothing.
1714
+ */
1715
+ geoIndexUsages?: ReadonlyArray<AdvisorGeoIndexUsage>;
1582
1716
  /**
1583
1717
  * `httpAction`/`httpRoute` handlers that perform a side effect
1584
1718
  * (`ctx.runMutation` / `ctx.runAction` / a `ctx.db` write) from the HTTP edge,
@@ -1706,6 +1840,21 @@ interface LintContext {
1706
1840
  * for runtime callers, where the lint finds nothing.
1707
1841
  */
1708
1842
  normalizeIdAuthorizations?: ReadonlyArray<AdvisorNormalizeIdAuthorization>;
1843
+ /**
1844
+ * `ctx.notify` / `ctx.push` sends discovered lexically inside `query`/`mutation`
1845
+ * handler bodies — the `notify_send_outside_action` input. Supplied by the
1846
+ * codegen feeder, which omits `action` handlers (where these facades are the
1847
+ * typed, intended surface); absent for runtime callers, where the lint finds
1848
+ * nothing.
1849
+ */
1850
+ notifyCalls?: ReadonlyArray<AdvisorNotifyCall>;
1851
+ /**
1852
+ * Whether the app uses `ctx.push` and which push channels `defineNotify(...)`
1853
+ * wires — the `notify_missing_push_config` input. Supplied by the codegen
1854
+ * feeder from the resolved `lunora/notify.ts` definition; absent for runtime
1855
+ * callers, where the lint finds nothing.
1856
+ */
1857
+ notifyConfig?: AdvisorNotifyConfig;
1709
1858
  /**
1710
1859
  * `ctx.db` writes (`insert` / `replace` / `patch` / `insertManyUnsafe`) that set
1711
1860
  * an ownership / identity column (`userId`, `ownerId`, `tenantId`, …) from the
@@ -2469,6 +2618,24 @@ declare const duplicateIndex: Lint;
2469
2618
  * least one field by construction, so only `kind: "index"` is checked.)
2470
2619
  */
2471
2620
  declare const emptyIndex: Lint;
2621
+ /**
2622
+ * Flags a misconfigured CDC export sink (plan 170) — a `defineExportSink` /
2623
+ * `webhookExportSink` / `r2Sink` construction with a required config field
2624
+ * missing or set to an empty string.
2625
+ *
2626
+ * The runtime `defineExportSink` guard throws for a missing `name`/`deliver`, but
2627
+ * the built-in `webhookExportSink` / `r2Sink` never validate their `url` /
2628
+ * `bucket`, so a webhook sink with no URL or an R2 sink with no bucket binding
2629
+ * ships and silently fails to drain — the export tap advances no cursor and the
2630
+ * warehouse never sees a row. Catching it at codegen time beats a dead tap in
2631
+ * production.
2632
+ *
2633
+ * A non-literal config (a variable, a spread that could supply the key) is not
2634
+ * statically decidable, so those constructions are skipped rather than flagged.
2635
+ * Only runs when the codegen feeder supplied evidence (`context.exportSinks`
2636
+ * present); a runtime caller flags nothing.
2637
+ */
2638
+ declare const exportSinkMisconfigured: Lint;
2472
2639
  /**
2473
2640
  * Flags a `.source({ mode: "incremental" })` table that declares neither a
2474
2641
  * `reconcileEveryMs` sweep nor a `softDeleteColumn` (plan 136).
@@ -2559,6 +2726,33 @@ declare const filterWithoutIndex: Lint;
2559
2726
  * low. One finding per read.
2560
2727
  */
2561
2728
  declare const flagGatesSecurityWithUnsafeDefault: Lint;
2729
+ /**
2730
+ * A correctness lint exploiting Lunora's static edge: a `.geoIndex(name, { field })`
2731
+ * maintains a geohash companion over a `v.geoPoint()` column, and
2732
+ * `withGeoIndex(...).near()/.within()` reads its `lat`/`lng`. If `field` is any
2733
+ * other type, the companion can't be built from `{ lat, lng }` and the index is
2734
+ * dead — it can never answer a geo query. Catching it at codegen time beats a
2735
+ * silently-empty proximity result at runtime.
2736
+ */
2737
+ declare const geoIndexFieldNotGeopoint: Lint;
2738
+ /**
2739
+ * Flags a declared `.geoIndex(name, { field })` that no handler queries via
2740
+ * `withGeoIndex(name, …)`.
2741
+ *
2742
+ * A geo index maintains a geohash companion column on the row: every write stamps
2743
+ * it, every byte of storage holds it. If nothing ever reads it through
2744
+ * `withGeoIndex(name, q => q.near(…) | q.within(…))` the companion is pure dead
2745
+ * overhead — the geo analogue of a dead regular index. Cross-references every geo
2746
+ * index in the schema against the set of index names some handler references via
2747
+ * `withGeoIndex("&lt;name>")`.
2748
+ *
2749
+ * Suppressed entirely when any usage passes a non-literal name
2750
+ * (`withGeoIndex(someVariable, …)`), because a dynamic reference could target any
2751
+ * declared geo index — flagging "unused" then would be a false positive. Only
2752
+ * runs when the usage feeder supplied evidence (`context.geoIndexUsages`
2753
+ * present); a runtime caller with no evidence flags nothing.
2754
+ */
2755
+ declare const geoIndexUnused: Lint;
2562
2756
  /**
2563
2757
  * Flags a secret-shaped string literal checked into the lunora source.
2564
2758
  *
@@ -2911,6 +3105,38 @@ declare const nondeterministicQueryMutation: Lint;
2911
3105
  * runtime caller flags nothing.
2912
3106
  */
2913
3107
  declare const normalizeIdUsedAsAuthorization: Lint;
3108
+ /**
3109
+ * Flags an app that sends push notifications (`ctx.push.send` / `ctx.push.broadcast`)
3110
+ * but whose `defineNotify(...)` wires **neither** the Web Push nor the FCM channel.
3111
+ *
3112
+ * With no push channel configured, every `ctx.push` send fails at runtime (the
3113
+ * routing push provider has nothing to dispatch to) — a silent, deploy-time-only
3114
+ * misconfiguration. Web Push additionally needs the `VAPID_*` secrets and FCM the
3115
+ * `FCM_*` secrets; those are scaffolded into `.dev.vars` from `@lunora/config`'s
3116
+ * package-secrets registry, but the channel must still be wired in `defineNotify`.
3117
+ *
3118
+ * This lint runs when the codegen feeder has supplied config evidence
3119
+ * (`context.notifyConfig` present); a runtime caller with no evidence flags
3120
+ * nothing.
3121
+ */
3122
+ declare const notifyMissingPushConfig: Lint;
3123
+ /**
3124
+ * Flags a `@lunora/notify` send (`ctx.notify.*` / `ctx.push.*`) inside a
3125
+ * `query(...)` or `mutation(...)` handler body.
3126
+ *
3127
+ * A notification send is external I/O — a `fetch` to a Web Push service or FCM.
3128
+ * Like `fetch`/`ctx.sql`, it is non-deterministic, so it breaks the determinism
3129
+ * the coordinator relies on when it re-runs a query on subscription re-evaluation
3130
+ * or a mutation on OCC retry — a retried mutation would fire the notification
3131
+ * again (duplicate pushes). `ctx.notify` / `ctx.push` are therefore wired onto
3132
+ * `ActionCtx` only and belong exclusively in `action(...)` handlers.
3133
+ *
3134
+ * This lint runs when the codegen feeder has supplied send evidence
3135
+ * (`context.notifyCalls` present); a runtime caller with no evidence flags
3136
+ * nothing rather than raising false alarms. The feeder records sends only inside
3137
+ * `query`/`mutation` handlers, so `action(...)` bodies never reach here.
3138
+ */
3139
+ declare const notifySendOutsideAction: Lint;
2914
3140
  /**
2915
3141
  * Nudges a `.public()` `query` whose handler returns raw table rows — with no
2916
3142
  * `.output(...)` projection and no `.use(mask(...))` — when that table carries
@@ -3328,6 +3554,24 @@ declare const shapeTargetsGlobalTable: Lint;
3328
3554
  * rather than raising false alarms.
3329
3555
  */
3330
3556
  declare const shapeUnknownTable: Lint;
3557
+ /**
3558
+ * Flags a public `mutation`/`action` that creates a user/session/account row but
3559
+ * installs no email-domain gate.
3560
+ *
3561
+ * Account-minting endpoints are the classic throwaway-signup surface: disposable
3562
+ * mailboxes farm free trials, evade bans, and pollute the user table. Lunora
3563
+ * ships `emailGateMiddleware` (`@lunora/auth/email-guard`) — pure-data on the
3564
+ * default (edge-safe) path — that rejects disposable domains at signup. This lint
3565
+ * fires when a public procedure writes a user/session/account-shaped table with
3566
+ * no email gate, pairing with the existing `user_creating_mutation_without_captcha`
3567
+ * lint (a CAPTCHA stops bots; the email gate stops throwaway domains — both are
3568
+ * worth having).
3569
+ *
3570
+ * Runs only when the codegen feeder supplies protection evidence
3571
+ * (`context.procedureProtections`); a runtime caller with no evidence flags
3572
+ * nothing.
3573
+ */
3574
+ declare const signupMutationWithoutDisposableGating: Lint;
3331
3575
  /**
3332
3576
  * Flags a public read that resurfaces soft-deleted rows via `includeDeleted` —
3333
3577
  * either hardcoded `true` or wired from the handler's `args`.
@@ -3487,6 +3731,15 @@ declare const storageUploadWithoutMaxSize: Lint;
3487
3731
  * a runtime caller with no insert signal flags nothing rather than every table.
3488
3732
  */
3489
3733
  declare const tableWithoutInsert: Lint;
3734
+ /**
3735
+ * A correctness lint with no splinter analogue — Lunora's static edge again: the
3736
+ * `.ttl(field)` policy names an epoch-millisecond expiry column, and the DO alarm
3737
+ * sweep compares `field < now`. If `field` is a non-time column (a string token, a
3738
+ * boolean flag, an object), the comparison is meaningless and rows either never
3739
+ * expire or expire unpredictably — a bug the fully-declared schema catches at
3740
+ * codegen time rather than at 3am when the sweep silently misbehaves.
3741
+ */
3742
+ declare const ttlFieldNotTimestamp: Lint;
3490
3743
  /**
3491
3744
  * Flags a public `v.string()` argument with no length bound.
3492
3745
  *
@@ -3653,4 +3906,4 @@ interface RunAdvisorOptions {
3653
3906
  * `static` lints at build time and defer `runtime` lints to a live shard.
3654
3907
  */
3655
3908
  declare const runAdvisor: (context: LintContext, options?: RunAdvisorOptions) => Finding[];
3656
- export { AE_METRIC_EVENTS, ALL_LINTS, type AdvisorAdminRoute, type AdvisorAiRawRun, type AdvisorAiToolSideEffect, type AdvisorArgumentDerivedFetch, type AdvisorArgumentValidator, type AdvisorAuthApiCall, type AdvisorAuthConfig, type AdvisorBrowserUrlAccess, type AdvisorConfigCall, type AdvisorContainer, type AdvisorContainerKeyAccess, type AdvisorContainerOverride, type AdvisorFailOpenGuard, type AdvisorFlagSecurityDefault, type AdvisorHttpActionGuard, type AdvisorHttpHeaderWrite, type AdvisorHyperdriveCall, type AdvisorIdentityClaimRead, type AdvisorImageDeliveryUrlAccess, type AdvisorIndex, type AdvisorIndexHit, type AdvisorInsertWrite, type AdvisorKvKeyAccess, type AdvisorMailRecipientAccess, type AdvisorMaskProcedure, type AdvisorMaskStrategy, type AdvisorMutatorWrite, type AdvisorNondeterministicCall, type AdvisorNormalizeIdAuthorization, type AdvisorOwnerFieldWrite, type AdvisorPaymentWebhook, type AdvisorPrivilegedDispatch, type AdvisorProcedureProtection, type AdvisorQueryRead, type AdvisorQueue, type AdvisorQueueTuning, type AdvisorR2sqlCall, type AdvisorRatelimitKeySelector, type AdvisorRawRowReturn, type AdvisorRelation, type AdvisorRelationLoad, type AdvisorRlsProcedure, type AdvisorSchema, type AdvisorSecretLiteral, type AdvisorShape, type AdvisorShardTraffic, type AdvisorSoftDeleteRead, type AdvisorSqlInterpolation, type AdvisorStorageKeyAccess, type AdvisorStorageUpload, type AdvisorTable, type AdvisorTableSample, type AdvisorTableScan, type AdvisorVectorNamespaceAccess, type AdvisorWorkflow, type AdvisorWorkflowCall, type AdvisorWranglerVariable, type AnalyticsMetricsOptions, type AnalyticsMetricsSource, type AnalyticsRuntimeMetrics, type Category, type Facing, type Finding, type Level, type Lint, type LintContext, type LintSource, RUNTIME_LINTS, RunAdvisorOptions, STATIC_LINTS, actionFetchSsrf, adminRouteWithoutGuard, aiRawRunEscapeHatch, aiToolSideEffectPromptInjection, aiUnboundedGenerationPublic, allowUnauthenticatedShardAccessEnabled, authApiCallWithoutHeaders, authCsrfCheckDisabled, authEmailVerificationDisabled, authSecureCookiesDisabled, authSessionFreshageZero, authTrustedOriginsWildcard, browserAllowPrivateTargets, browserUserUrlWithoutAllowlist, circularFk, constraintValidator, containerInstanceKeyFromUserInput, containerOversizedInstance, containerPublicInternet, containerRuntimeEgressRelaxation, containerStartEnableInternetOverride, dedupeCacheKeys, duplicateIndex, emptyIndex, externalSourceIncrementalNoDeletePath, externalSourceOnGlobal, externalSourceUnscoped, filterWithoutIndex, flagGatesSecurityWithUnsafeDefault, fromServerSchema, hardcodedSecret, hotShard, httpActionMissingAuthGuard, httpActionResponseHeaderInjection, hyperdriveOutsideAction, identityUndeclaredClaimTrusted, imagesUrlSourceFromUserInput, indexReferencesUnknownField, indexUtilization, insertManyUnsafeUserData, kvUnscopedUserKeyIdor, loadAnalyticsRuntimeMetrics, mailInboundDispatchWithoutVerify, mailRecipientFromRequestInput, maskUncoveredPiiColumn, maskWeakHashStrategyOnPii, maskedRelationLeakViaWith, mutatorFullRowReplace, nondeterministicQueryMutation, normalizeIdUsedAsAuthorization, outputProjectionMissingOnPublicRead, ownerFieldFromArgsNotAuth, paymentCreateWithoutAuthorize, paymentWebhookWideTolerance, plaintextSecretInWranglerVariables, policyReferencesUnknownTable, privilegedDispatchUnvalidatedPayload, privilegedFanoutFromPublicProcedure, publicArgumentUsesAny, publicMutationWithoutRatelimit, publicTableRlsOptoutConfusion, queueWithoutDlq, r2sqlOutsideAction, ratelimitDefaultMemoryStore, ratelimitKeySpoofableOrGlobal, ratelimitMiddlewareFailOpen, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, softDeleteIncludeDeletedFromArgs, sqlInjectionRisk, storageGenerateUploadUrlNoContentTypePin, storageKeyFromUserArgs, storagePresignedUrlForPrivateContent, storageUploadWithoutContentTypeAllowlist, storageUploadWithoutMaxSize, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, vectorsNamespaceFromUserInput, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
3909
+ export { AE_METRIC_EVENTS, ALL_LINTS, type AdvisorAdminRoute, type AdvisorAiRawRun, type AdvisorAiToolSideEffect, type AdvisorArgumentDerivedFetch, type AdvisorArgumentValidator, type AdvisorAuthApiCall, type AdvisorAuthConfig, type AdvisorBrowserUrlAccess, type AdvisorConfigCall, type AdvisorContainer, type AdvisorContainerKeyAccess, type AdvisorContainerOverride, type AdvisorExportSink, type AdvisorFailOpenGuard, type AdvisorFlagSecurityDefault, type AdvisorGeoIndexUsage, type AdvisorHttpActionGuard, type AdvisorHttpHeaderWrite, type AdvisorHyperdriveCall, type AdvisorIdentityClaimRead, type AdvisorImageDeliveryUrlAccess, type AdvisorIndex, type AdvisorIndexHit, type AdvisorInsertWrite, type AdvisorKvKeyAccess, type AdvisorMailRecipientAccess, type AdvisorMaskProcedure, type AdvisorMaskStrategy, type AdvisorMutatorWrite, type AdvisorNondeterministicCall, type AdvisorNormalizeIdAuthorization, type AdvisorNotifyCall, type AdvisorNotifyConfig, type AdvisorOwnerFieldWrite, type AdvisorPaymentWebhook, type AdvisorPrivilegedDispatch, type AdvisorProcedureProtection, type AdvisorQueryRead, type AdvisorQueue, type AdvisorQueueTuning, type AdvisorR2sqlCall, type AdvisorRatelimitKeySelector, type AdvisorRawRowReturn, type AdvisorRelation, type AdvisorRelationLoad, type AdvisorRlsProcedure, type AdvisorSchema, type AdvisorSecretLiteral, type AdvisorShape, type AdvisorShardTraffic, type AdvisorSoftDeleteRead, type AdvisorSqlInterpolation, type AdvisorStorageKeyAccess, type AdvisorStorageUpload, type AdvisorTable, type AdvisorTableSample, type AdvisorTableScan, type AdvisorVectorNamespaceAccess, type AdvisorWorkflow, type AdvisorWorkflowCall, type AdvisorWranglerVariable, type AnalyticsMetricsOptions, type AnalyticsMetricsSource, type AnalyticsRuntimeMetrics, type Category, type Facing, type Finding, type Level, type Lint, type LintContext, type LintSource, RUNTIME_LINTS, RunAdvisorOptions, STATIC_LINTS, actionFetchSsrf, adminRouteWithoutGuard, aiRawRunEscapeHatch, aiToolSideEffectPromptInjection, aiUnboundedGenerationPublic, allowUnauthenticatedShardAccessEnabled, authApiCallWithoutHeaders, authCsrfCheckDisabled, authEmailVerificationDisabled, authSecureCookiesDisabled, authSessionFreshageZero, authTrustedOriginsWildcard, browserAllowPrivateTargets, browserUserUrlWithoutAllowlist, circularFk, constraintValidator, containerInstanceKeyFromUserInput, containerOversizedInstance, containerPublicInternet, containerRuntimeEgressRelaxation, containerStartEnableInternetOverride, dedupeCacheKeys, duplicateIndex, emptyIndex, exportSinkMisconfigured, externalSourceIncrementalNoDeletePath, externalSourceOnGlobal, externalSourceUnscoped, filterWithoutIndex, flagGatesSecurityWithUnsafeDefault, fromServerSchema, geoIndexFieldNotGeopoint, geoIndexUnused, hardcodedSecret, hotShard, httpActionMissingAuthGuard, httpActionResponseHeaderInjection, hyperdriveOutsideAction, identityUndeclaredClaimTrusted, imagesUrlSourceFromUserInput, indexReferencesUnknownField, indexUtilization, insertManyUnsafeUserData, kvUnscopedUserKeyIdor, loadAnalyticsRuntimeMetrics, mailInboundDispatchWithoutVerify, mailRecipientFromRequestInput, maskUncoveredPiiColumn, maskWeakHashStrategyOnPii, maskedRelationLeakViaWith, mutatorFullRowReplace, nondeterministicQueryMutation, normalizeIdUsedAsAuthorization, notifyMissingPushConfig, notifySendOutsideAction, outputProjectionMissingOnPublicRead, ownerFieldFromArgsNotAuth, paymentCreateWithoutAuthorize, paymentWebhookWideTolerance, plaintextSecretInWranglerVariables, policyReferencesUnknownTable, privilegedDispatchUnvalidatedPayload, privilegedFanoutFromPublicProcedure, publicArgumentUsesAny, publicMutationWithoutRatelimit, publicTableRlsOptoutConfusion, queueWithoutDlq, r2sqlOutsideAction, ratelimitDefaultMemoryStore, ratelimitKeySpoofableOrGlobal, ratelimitMiddlewareFailOpen, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, signupMutationWithoutDisposableGating, softDeleteIncludeDeletedFromArgs, sqlInjectionRisk, storageGenerateUploadUrlNoContentTypePin, storageKeyFromUserArgs, storagePresignedUrlForPrivateContent, storageUploadWithoutContentTypeAllowlist, storageUploadWithoutMaxSize, tableWithoutInsert, ttlFieldNotTimestamp, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, vectorsNamespaceFromUserInput, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
package/dist/index.d.ts CHANGED
@@ -280,6 +280,39 @@ interface AdvisorContainer {
280
280
  /** Declared `sleepAfter` value, when a static literal. */
281
281
  sleepAfter?: number | string;
282
282
  }
283
+ /**
284
+ * One CDC export-sink construction discovered in a function body — the input the
285
+ * `export_sink_misconfigured` lint consumes. Produced by the codegen feeder,
286
+ * which walks the lunora source for the three sink factories the export tap
287
+ * (plan 170) ships: `defineExportSink({ name, deliver })`,
288
+ * `webhookExportSink({ name, url, … })`, and `r2Sink({ name, bucket, … })`.
289
+ *
290
+ * A sink missing a required field can never deliver a change batch — a webhook
291
+ * with no `url` would POST to `undefined`, an R2 sink with no `bucket` binding has
292
+ * nowhere to write, and a custom sink with no `deliver` has no delivery path. The
293
+ * runtime `defineExportSink` guard throws for a missing `name`/`deliver`, but the
294
+ * built-in `webhookExportSink`/`r2Sink` don't validate `url`/`bucket`, so catching
295
+ * the misconfiguration statically beats a silently-dead export tap at runtime.
296
+ * Runtime callers don't supply this, so the lint finds nothing there.
297
+ */
298
+ interface AdvisorExportSink {
299
+ /**
300
+ * True when the factory's config argument is a statically analyzable object
301
+ * literal. A non-literal config (a variable, a spread) is not decidable, so
302
+ * the lint skips it rather than raising a false alarm.
303
+ */
304
+ analyzable: boolean;
305
+ /** Present keys whose value is an empty-string literal (`""`) — treated as missing. */
306
+ emptyKeys: string[];
307
+ /** Which sink factory was called. */
308
+ factory: "defineExportSink" | "r2Sink" | "webhookExportSink";
309
+ /** Source file the construction appears in (relative to the lunora dir, no extension). */
310
+ file: string;
311
+ /** 1-based line of the factory call, or `0` when unknown. */
312
+ line: number;
313
+ /** Config keys present on the object literal, regardless of value. */
314
+ presentKeys: string[];
315
+ }
283
316
  /**
284
317
  * One rate-limit / Turnstile middleware call — the `ratelimit_middleware_fail_open`
285
318
  * lint input. `rateLimit`/`dbRateLimit` (`@lunora/ratelimit`) and
@@ -328,6 +361,27 @@ interface AdvisorFlagSecurityDefault {
328
361
  /** 1-based line of the `ctx.flags.boolean` call, or `0` when unknown. */
329
362
  line: number;
330
363
  }
364
+ /**
365
+ * One `withGeoIndex("name", …)` read discovered in a function body — the use-side
366
+ * input the `geo_index_unused` lint cross-references against the declared geo
367
+ * indexes in the lint context's schema. Produced by the codegen feeder, which
368
+ * walks the lunora source for `ctx.db.query("t").withGeoIndex(name, …)` /
369
+ * `ctx.db.&lt;table>.withGeoIndex(name, …)` reads and records each referenced index
370
+ * name. Runtime callers don't supply it, so the lint finds nothing there.
371
+ *
372
+ * A `.geoIndex(name, { field })` maintains a geohash companion column on every
373
+ * write; if no handler ever reads it via `withGeoIndex(name, …)` the companion is
374
+ * dead overhead (maintained on every write, read by nothing) — the geo analogue
375
+ * of a dead regular index.
376
+ */
377
+ interface AdvisorGeoIndexUsage {
378
+ /** Source file the read appears in (relative to the lunora dir, no extension). */
379
+ file: string;
380
+ /** The referenced geo-index name; empty when the `withGeoIndex(...)` argument is not a string literal. */
381
+ indexName: string;
382
+ /** 1-based line of the `withGeoIndex(...)` call, or `0` when unknown. */
383
+ line: number;
384
+ }
331
385
  /**
332
386
  * One discovered `httpAction`/`httpRoute` handler that performs a side effect
333
387
  * (`ctx.runMutation` / `ctx.runAction` / a `ctx.db.{insert,patch,replace,delete,
@@ -688,6 +742,48 @@ interface AdvisorNormalizeIdAuthorization {
688
742
  /** `"internal"` for `internalQuery`/`internalMutation`; `"public"` for `query`/`mutation`. */
689
743
  visibility: "internal" | "public";
690
744
  }
745
+ /**
746
+ * One `ctx.notify` / `ctx.push` send discovered lexically inside a `query(...)`
747
+ * or `mutation(...)` handler body — the input the `notify_send_outside_action`
748
+ * lint consumes. Produced by the codegen feeder, which walks each exported
749
+ * function's handler with ts-morph and records the `@lunora/notify` send surface
750
+ * (`ctx.notify.send`, `ctx.notify.chat/inApp/webhook`, `ctx.push.send`,
751
+ * `ctx.push.broadcast`).
752
+ *
753
+ * A notification send is external I/O (a `fetch` to a push service / FCM): it is
754
+ * non-deterministic like `fetch`, so it breaks the determinism the coordinator
755
+ * relies on when re-running a query on subscription re-evaluation or a mutation on
756
+ * OCC retry (a retried mutation would re-send). It therefore belongs **only** in
757
+ * `action(...)` handlers. Calls inside `action(...)` are intentionally **not**
758
+ * recorded — actions are the escape hatch. Runtime callers don't supply this, so
759
+ * the lint finds nothing there.
760
+ */
761
+ interface AdvisorNotifyCall {
762
+ /** The send surface accessed, e.g. `ctx.push.broadcast` / `ctx.notify.send`. */
763
+ callee: string;
764
+ /** The exported function performing the send (e.g. `sendWelcome`). */
765
+ exportName: string;
766
+ /** Source file the send appears in (relative to the lunora dir, no extension). */
767
+ file: string;
768
+ /** Which procedure kind the send lives in — only `query`/`mutation` are flagged; actions are exempt. */
769
+ kind: "mutation" | "query";
770
+ /** 1-based line of the send, or `0` when unknown. */
771
+ line: number;
772
+ }
773
+ /**
774
+ * Whether an app uses `ctx.push` and which push channels `defineNotify(...)`
775
+ * configures — the input the `notify_missing_push_config` lint consumes. Produced
776
+ * by the codegen feeder from the resolved `lunora/notify.ts` definition and the
777
+ * discovered `ctx.push` usage; absent for runtime callers.
778
+ */
779
+ interface AdvisorNotifyConfig {
780
+ /** Whether `defineNotify` wired the FCM channel. */
781
+ hasFcm: boolean;
782
+ /** Whether `defineNotify` wired the Web Push channel. */
783
+ hasWebPush: boolean;
784
+ /** Whether any handler sends a push (`ctx.push.send` / `ctx.push.broadcast`). */
785
+ usesPush: boolean;
786
+ }
691
787
  /**
692
788
  * One `ctx.db` write (`insert` / `replace` / `patch` / `insertManyUnsafe`) that
693
789
  * sets an ownership / identity column — `userId`, `ownerId`, `tenantId`, and the
@@ -786,6 +882,8 @@ interface AdvisorProcedureProtection {
786
882
  unboundedAiGeneration: boolean;
787
883
  /** `true` when the chain carries `.use(verifyTurnstile(...))` or a `protectPublic({ captcha })` bundle. */
788
884
  usesCaptcha: boolean;
885
+ /** `true` when the chain carries `.use(emailGateMiddleware(...))` from `@lunora/auth`. Read by the `signup_mutation_without_disposable_gating` lint (paired with public visibility + a user-table write). */
886
+ usesEmailGate: boolean;
789
887
  /** `true` when the handler calls `ctx.db.insertManyUnsafe(...)`, which bypasses validators and triggers. Read by the `insert_many_unsafe_user_data` lint (paired with public visibility). */
790
888
  usesInsertManyUnsafe: boolean;
791
889
  /** `true` when the chain carries `.use(mask(...))`. */
@@ -1000,6 +1098,14 @@ interface AdvisorSchema {
1000
1098
  }
1001
1099
  /** A table plus the column/index/relation metadata lints inspect. */
1002
1100
  interface AdvisorTable {
1101
+ /**
1102
+ * Effective validator kind per declared column (a `v.optional(...)` is
1103
+ * unwrapped to its inner kind). Read by the schema-type lints
1104
+ * (`ttl_field_not_timestamp`, `geo_index_field_not_geopoint`) to check a
1105
+ * referenced column's type. Optional — a feeder that doesn't track column
1106
+ * kinds omits it, and the type lints then skip the check.
1107
+ */
1108
+ columnKinds?: Record<string, string>;
1003
1109
  /**
1004
1110
  * `true` when the table is written outside Lunora's discoverable insert path
1005
1111
  * — declared via `.externallyManaged()` (e.g. `@lunora/auth`'s better-auth
@@ -1067,6 +1173,15 @@ interface AdvisorTable {
1067
1173
  softDelete?: {
1068
1174
  field: string;
1069
1175
  };
1176
+ /**
1177
+ * Set when the table declared `.ttl(field, { after? })`. Read by the
1178
+ * `ttl_field_not_timestamp` lint to confirm the expiry column is time-typed.
1179
+ * Optional — a feeder that doesn't track TTL omits it.
1180
+ */
1181
+ ttl?: {
1182
+ after?: number;
1183
+ field: string;
1184
+ };
1070
1185
  }
1071
1186
  /**
1072
1187
  * One declared index, flattened across Lunora's index kinds so a single lint can
@@ -1079,7 +1194,7 @@ interface AdvisorTable {
1079
1194
  */
1080
1195
  interface AdvisorIndex {
1081
1196
  fields: ReadonlyArray<string>;
1082
- kind: "index" | "rank" | "search" | "vector";
1197
+ kind: "geo" | "index" | "rank" | "search" | "vector";
1083
1198
  name: string;
1084
1199
  unique?: boolean;
1085
1200
  }
@@ -1558,6 +1673,16 @@ interface LintContext {
1558
1673
  * the container lints find nothing.
1559
1674
  */
1560
1675
  containers?: ReadonlyArray<AdvisorContainer>;
1676
+ /**
1677
+ * CDC export-sink constructions (`defineExportSink` / `webhookExportSink` /
1678
+ * `r2Sink`) discovered in function bodies — the `export_sink_misconfigured`
1679
+ * input. Each carries which config keys were present (and which were an empty
1680
+ * string), so the lint can flag a sink missing a required field (a webhook
1681
+ * with no `url`, an R2 sink with no `bucket`, a sink with no `name`/`deliver`).
1682
+ * Supplied by the codegen feeder; absent for runtime callers, where the lint
1683
+ * finds nothing.
1684
+ */
1685
+ exportSinks?: ReadonlyArray<AdvisorExportSink>;
1561
1686
  /**
1562
1687
  * `rateLimit`/`dbRateLimit` (`@lunora/ratelimit`) and `verifyTurnstileMiddleware`
1563
1688
  * (`@lunora/auth`) middleware calls, each with whether its options literal set
@@ -1579,6 +1704,15 @@ interface LintContext {
1579
1704
  * codegen feeder; absent for runtime callers, where the lint finds nothing.
1580
1705
  */
1581
1706
  flagSecurityDefaults?: ReadonlyArray<AdvisorFlagSecurityDefault>;
1707
+ /**
1708
+ * `withGeoIndex("name", …)` reads discovered in function bodies — the use-side
1709
+ * input the `geo_index_unused` lint cross-references against the declared geo
1710
+ * indexes in {@link LintContext.schema}. A declared `.geoIndex(name, …)` with
1711
+ * no matching read is dead overhead (its geohash companion is maintained on
1712
+ * every write and read by nothing). Supplied by the codegen feeder; absent for
1713
+ * runtime callers, where the lint finds nothing.
1714
+ */
1715
+ geoIndexUsages?: ReadonlyArray<AdvisorGeoIndexUsage>;
1582
1716
  /**
1583
1717
  * `httpAction`/`httpRoute` handlers that perform a side effect
1584
1718
  * (`ctx.runMutation` / `ctx.runAction` / a `ctx.db` write) from the HTTP edge,
@@ -1706,6 +1840,21 @@ interface LintContext {
1706
1840
  * for runtime callers, where the lint finds nothing.
1707
1841
  */
1708
1842
  normalizeIdAuthorizations?: ReadonlyArray<AdvisorNormalizeIdAuthorization>;
1843
+ /**
1844
+ * `ctx.notify` / `ctx.push` sends discovered lexically inside `query`/`mutation`
1845
+ * handler bodies — the `notify_send_outside_action` input. Supplied by the
1846
+ * codegen feeder, which omits `action` handlers (where these facades are the
1847
+ * typed, intended surface); absent for runtime callers, where the lint finds
1848
+ * nothing.
1849
+ */
1850
+ notifyCalls?: ReadonlyArray<AdvisorNotifyCall>;
1851
+ /**
1852
+ * Whether the app uses `ctx.push` and which push channels `defineNotify(...)`
1853
+ * wires — the `notify_missing_push_config` input. Supplied by the codegen
1854
+ * feeder from the resolved `lunora/notify.ts` definition; absent for runtime
1855
+ * callers, where the lint finds nothing.
1856
+ */
1857
+ notifyConfig?: AdvisorNotifyConfig;
1709
1858
  /**
1710
1859
  * `ctx.db` writes (`insert` / `replace` / `patch` / `insertManyUnsafe`) that set
1711
1860
  * an ownership / identity column (`userId`, `ownerId`, `tenantId`, …) from the
@@ -2469,6 +2618,24 @@ declare const duplicateIndex: Lint;
2469
2618
  * least one field by construction, so only `kind: "index"` is checked.)
2470
2619
  */
2471
2620
  declare const emptyIndex: Lint;
2621
+ /**
2622
+ * Flags a misconfigured CDC export sink (plan 170) — a `defineExportSink` /
2623
+ * `webhookExportSink` / `r2Sink` construction with a required config field
2624
+ * missing or set to an empty string.
2625
+ *
2626
+ * The runtime `defineExportSink` guard throws for a missing `name`/`deliver`, but
2627
+ * the built-in `webhookExportSink` / `r2Sink` never validate their `url` /
2628
+ * `bucket`, so a webhook sink with no URL or an R2 sink with no bucket binding
2629
+ * ships and silently fails to drain — the export tap advances no cursor and the
2630
+ * warehouse never sees a row. Catching it at codegen time beats a dead tap in
2631
+ * production.
2632
+ *
2633
+ * A non-literal config (a variable, a spread that could supply the key) is not
2634
+ * statically decidable, so those constructions are skipped rather than flagged.
2635
+ * Only runs when the codegen feeder supplied evidence (`context.exportSinks`
2636
+ * present); a runtime caller flags nothing.
2637
+ */
2638
+ declare const exportSinkMisconfigured: Lint;
2472
2639
  /**
2473
2640
  * Flags a `.source({ mode: "incremental" })` table that declares neither a
2474
2641
  * `reconcileEveryMs` sweep nor a `softDeleteColumn` (plan 136).
@@ -2559,6 +2726,33 @@ declare const filterWithoutIndex: Lint;
2559
2726
  * low. One finding per read.
2560
2727
  */
2561
2728
  declare const flagGatesSecurityWithUnsafeDefault: Lint;
2729
+ /**
2730
+ * A correctness lint exploiting Lunora's static edge: a `.geoIndex(name, { field })`
2731
+ * maintains a geohash companion over a `v.geoPoint()` column, and
2732
+ * `withGeoIndex(...).near()/.within()` reads its `lat`/`lng`. If `field` is any
2733
+ * other type, the companion can't be built from `{ lat, lng }` and the index is
2734
+ * dead — it can never answer a geo query. Catching it at codegen time beats a
2735
+ * silently-empty proximity result at runtime.
2736
+ */
2737
+ declare const geoIndexFieldNotGeopoint: Lint;
2738
+ /**
2739
+ * Flags a declared `.geoIndex(name, { field })` that no handler queries via
2740
+ * `withGeoIndex(name, …)`.
2741
+ *
2742
+ * A geo index maintains a geohash companion column on the row: every write stamps
2743
+ * it, every byte of storage holds it. If nothing ever reads it through
2744
+ * `withGeoIndex(name, q => q.near(…) | q.within(…))` the companion is pure dead
2745
+ * overhead — the geo analogue of a dead regular index. Cross-references every geo
2746
+ * index in the schema against the set of index names some handler references via
2747
+ * `withGeoIndex("&lt;name>")`.
2748
+ *
2749
+ * Suppressed entirely when any usage passes a non-literal name
2750
+ * (`withGeoIndex(someVariable, …)`), because a dynamic reference could target any
2751
+ * declared geo index — flagging "unused" then would be a false positive. Only
2752
+ * runs when the usage feeder supplied evidence (`context.geoIndexUsages`
2753
+ * present); a runtime caller with no evidence flags nothing.
2754
+ */
2755
+ declare const geoIndexUnused: Lint;
2562
2756
  /**
2563
2757
  * Flags a secret-shaped string literal checked into the lunora source.
2564
2758
  *
@@ -2911,6 +3105,38 @@ declare const nondeterministicQueryMutation: Lint;
2911
3105
  * runtime caller flags nothing.
2912
3106
  */
2913
3107
  declare const normalizeIdUsedAsAuthorization: Lint;
3108
+ /**
3109
+ * Flags an app that sends push notifications (`ctx.push.send` / `ctx.push.broadcast`)
3110
+ * but whose `defineNotify(...)` wires **neither** the Web Push nor the FCM channel.
3111
+ *
3112
+ * With no push channel configured, every `ctx.push` send fails at runtime (the
3113
+ * routing push provider has nothing to dispatch to) — a silent, deploy-time-only
3114
+ * misconfiguration. Web Push additionally needs the `VAPID_*` secrets and FCM the
3115
+ * `FCM_*` secrets; those are scaffolded into `.dev.vars` from `@lunora/config`'s
3116
+ * package-secrets registry, but the channel must still be wired in `defineNotify`.
3117
+ *
3118
+ * This lint runs when the codegen feeder has supplied config evidence
3119
+ * (`context.notifyConfig` present); a runtime caller with no evidence flags
3120
+ * nothing.
3121
+ */
3122
+ declare const notifyMissingPushConfig: Lint;
3123
+ /**
3124
+ * Flags a `@lunora/notify` send (`ctx.notify.*` / `ctx.push.*`) inside a
3125
+ * `query(...)` or `mutation(...)` handler body.
3126
+ *
3127
+ * A notification send is external I/O — a `fetch` to a Web Push service or FCM.
3128
+ * Like `fetch`/`ctx.sql`, it is non-deterministic, so it breaks the determinism
3129
+ * the coordinator relies on when it re-runs a query on subscription re-evaluation
3130
+ * or a mutation on OCC retry — a retried mutation would fire the notification
3131
+ * again (duplicate pushes). `ctx.notify` / `ctx.push` are therefore wired onto
3132
+ * `ActionCtx` only and belong exclusively in `action(...)` handlers.
3133
+ *
3134
+ * This lint runs when the codegen feeder has supplied send evidence
3135
+ * (`context.notifyCalls` present); a runtime caller with no evidence flags
3136
+ * nothing rather than raising false alarms. The feeder records sends only inside
3137
+ * `query`/`mutation` handlers, so `action(...)` bodies never reach here.
3138
+ */
3139
+ declare const notifySendOutsideAction: Lint;
2914
3140
  /**
2915
3141
  * Nudges a `.public()` `query` whose handler returns raw table rows — with no
2916
3142
  * `.output(...)` projection and no `.use(mask(...))` — when that table carries
@@ -3328,6 +3554,24 @@ declare const shapeTargetsGlobalTable: Lint;
3328
3554
  * rather than raising false alarms.
3329
3555
  */
3330
3556
  declare const shapeUnknownTable: Lint;
3557
+ /**
3558
+ * Flags a public `mutation`/`action` that creates a user/session/account row but
3559
+ * installs no email-domain gate.
3560
+ *
3561
+ * Account-minting endpoints are the classic throwaway-signup surface: disposable
3562
+ * mailboxes farm free trials, evade bans, and pollute the user table. Lunora
3563
+ * ships `emailGateMiddleware` (`@lunora/auth/email-guard`) — pure-data on the
3564
+ * default (edge-safe) path — that rejects disposable domains at signup. This lint
3565
+ * fires when a public procedure writes a user/session/account-shaped table with
3566
+ * no email gate, pairing with the existing `user_creating_mutation_without_captcha`
3567
+ * lint (a CAPTCHA stops bots; the email gate stops throwaway domains — both are
3568
+ * worth having).
3569
+ *
3570
+ * Runs only when the codegen feeder supplies protection evidence
3571
+ * (`context.procedureProtections`); a runtime caller with no evidence flags
3572
+ * nothing.
3573
+ */
3574
+ declare const signupMutationWithoutDisposableGating: Lint;
3331
3575
  /**
3332
3576
  * Flags a public read that resurfaces soft-deleted rows via `includeDeleted` —
3333
3577
  * either hardcoded `true` or wired from the handler's `args`.
@@ -3487,6 +3731,15 @@ declare const storageUploadWithoutMaxSize: Lint;
3487
3731
  * a runtime caller with no insert signal flags nothing rather than every table.
3488
3732
  */
3489
3733
  declare const tableWithoutInsert: Lint;
3734
+ /**
3735
+ * A correctness lint with no splinter analogue — Lunora's static edge again: the
3736
+ * `.ttl(field)` policy names an epoch-millisecond expiry column, and the DO alarm
3737
+ * sweep compares `field < now`. If `field` is a non-time column (a string token, a
3738
+ * boolean flag, an object), the comparison is meaningless and rows either never
3739
+ * expire or expire unpredictably — a bug the fully-declared schema catches at
3740
+ * codegen time rather than at 3am when the sweep silently misbehaves.
3741
+ */
3742
+ declare const ttlFieldNotTimestamp: Lint;
3490
3743
  /**
3491
3744
  * Flags a public `v.string()` argument with no length bound.
3492
3745
  *
@@ -3653,4 +3906,4 @@ interface RunAdvisorOptions {
3653
3906
  * `static` lints at build time and defer `runtime` lints to a live shard.
3654
3907
  */
3655
3908
  declare const runAdvisor: (context: LintContext, options?: RunAdvisorOptions) => Finding[];
3656
- export { AE_METRIC_EVENTS, ALL_LINTS, type AdvisorAdminRoute, type AdvisorAiRawRun, type AdvisorAiToolSideEffect, type AdvisorArgumentDerivedFetch, type AdvisorArgumentValidator, type AdvisorAuthApiCall, type AdvisorAuthConfig, type AdvisorBrowserUrlAccess, type AdvisorConfigCall, type AdvisorContainer, type AdvisorContainerKeyAccess, type AdvisorContainerOverride, type AdvisorFailOpenGuard, type AdvisorFlagSecurityDefault, type AdvisorHttpActionGuard, type AdvisorHttpHeaderWrite, type AdvisorHyperdriveCall, type AdvisorIdentityClaimRead, type AdvisorImageDeliveryUrlAccess, type AdvisorIndex, type AdvisorIndexHit, type AdvisorInsertWrite, type AdvisorKvKeyAccess, type AdvisorMailRecipientAccess, type AdvisorMaskProcedure, type AdvisorMaskStrategy, type AdvisorMutatorWrite, type AdvisorNondeterministicCall, type AdvisorNormalizeIdAuthorization, type AdvisorOwnerFieldWrite, type AdvisorPaymentWebhook, type AdvisorPrivilegedDispatch, type AdvisorProcedureProtection, type AdvisorQueryRead, type AdvisorQueue, type AdvisorQueueTuning, type AdvisorR2sqlCall, type AdvisorRatelimitKeySelector, type AdvisorRawRowReturn, type AdvisorRelation, type AdvisorRelationLoad, type AdvisorRlsProcedure, type AdvisorSchema, type AdvisorSecretLiteral, type AdvisorShape, type AdvisorShardTraffic, type AdvisorSoftDeleteRead, type AdvisorSqlInterpolation, type AdvisorStorageKeyAccess, type AdvisorStorageUpload, type AdvisorTable, type AdvisorTableSample, type AdvisorTableScan, type AdvisorVectorNamespaceAccess, type AdvisorWorkflow, type AdvisorWorkflowCall, type AdvisorWranglerVariable, type AnalyticsMetricsOptions, type AnalyticsMetricsSource, type AnalyticsRuntimeMetrics, type Category, type Facing, type Finding, type Level, type Lint, type LintContext, type LintSource, RUNTIME_LINTS, RunAdvisorOptions, STATIC_LINTS, actionFetchSsrf, adminRouteWithoutGuard, aiRawRunEscapeHatch, aiToolSideEffectPromptInjection, aiUnboundedGenerationPublic, allowUnauthenticatedShardAccessEnabled, authApiCallWithoutHeaders, authCsrfCheckDisabled, authEmailVerificationDisabled, authSecureCookiesDisabled, authSessionFreshageZero, authTrustedOriginsWildcard, browserAllowPrivateTargets, browserUserUrlWithoutAllowlist, circularFk, constraintValidator, containerInstanceKeyFromUserInput, containerOversizedInstance, containerPublicInternet, containerRuntimeEgressRelaxation, containerStartEnableInternetOverride, dedupeCacheKeys, duplicateIndex, emptyIndex, externalSourceIncrementalNoDeletePath, externalSourceOnGlobal, externalSourceUnscoped, filterWithoutIndex, flagGatesSecurityWithUnsafeDefault, fromServerSchema, hardcodedSecret, hotShard, httpActionMissingAuthGuard, httpActionResponseHeaderInjection, hyperdriveOutsideAction, identityUndeclaredClaimTrusted, imagesUrlSourceFromUserInput, indexReferencesUnknownField, indexUtilization, insertManyUnsafeUserData, kvUnscopedUserKeyIdor, loadAnalyticsRuntimeMetrics, mailInboundDispatchWithoutVerify, mailRecipientFromRequestInput, maskUncoveredPiiColumn, maskWeakHashStrategyOnPii, maskedRelationLeakViaWith, mutatorFullRowReplace, nondeterministicQueryMutation, normalizeIdUsedAsAuthorization, outputProjectionMissingOnPublicRead, ownerFieldFromArgsNotAuth, paymentCreateWithoutAuthorize, paymentWebhookWideTolerance, plaintextSecretInWranglerVariables, policyReferencesUnknownTable, privilegedDispatchUnvalidatedPayload, privilegedFanoutFromPublicProcedure, publicArgumentUsesAny, publicMutationWithoutRatelimit, publicTableRlsOptoutConfusion, queueWithoutDlq, r2sqlOutsideAction, ratelimitDefaultMemoryStore, ratelimitKeySpoofableOrGlobal, ratelimitMiddlewareFailOpen, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, softDeleteIncludeDeletedFromArgs, sqlInjectionRisk, storageGenerateUploadUrlNoContentTypePin, storageKeyFromUserArgs, storagePresignedUrlForPrivateContent, storageUploadWithoutContentTypeAllowlist, storageUploadWithoutMaxSize, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, vectorsNamespaceFromUserInput, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
3909
+ export { AE_METRIC_EVENTS, ALL_LINTS, type AdvisorAdminRoute, type AdvisorAiRawRun, type AdvisorAiToolSideEffect, type AdvisorArgumentDerivedFetch, type AdvisorArgumentValidator, type AdvisorAuthApiCall, type AdvisorAuthConfig, type AdvisorBrowserUrlAccess, type AdvisorConfigCall, type AdvisorContainer, type AdvisorContainerKeyAccess, type AdvisorContainerOverride, type AdvisorExportSink, type AdvisorFailOpenGuard, type AdvisorFlagSecurityDefault, type AdvisorGeoIndexUsage, type AdvisorHttpActionGuard, type AdvisorHttpHeaderWrite, type AdvisorHyperdriveCall, type AdvisorIdentityClaimRead, type AdvisorImageDeliveryUrlAccess, type AdvisorIndex, type AdvisorIndexHit, type AdvisorInsertWrite, type AdvisorKvKeyAccess, type AdvisorMailRecipientAccess, type AdvisorMaskProcedure, type AdvisorMaskStrategy, type AdvisorMutatorWrite, type AdvisorNondeterministicCall, type AdvisorNormalizeIdAuthorization, type AdvisorNotifyCall, type AdvisorNotifyConfig, type AdvisorOwnerFieldWrite, type AdvisorPaymentWebhook, type AdvisorPrivilegedDispatch, type AdvisorProcedureProtection, type AdvisorQueryRead, type AdvisorQueue, type AdvisorQueueTuning, type AdvisorR2sqlCall, type AdvisorRatelimitKeySelector, type AdvisorRawRowReturn, type AdvisorRelation, type AdvisorRelationLoad, type AdvisorRlsProcedure, type AdvisorSchema, type AdvisorSecretLiteral, type AdvisorShape, type AdvisorShardTraffic, type AdvisorSoftDeleteRead, type AdvisorSqlInterpolation, type AdvisorStorageKeyAccess, type AdvisorStorageUpload, type AdvisorTable, type AdvisorTableSample, type AdvisorTableScan, type AdvisorVectorNamespaceAccess, type AdvisorWorkflow, type AdvisorWorkflowCall, type AdvisorWranglerVariable, type AnalyticsMetricsOptions, type AnalyticsMetricsSource, type AnalyticsRuntimeMetrics, type Category, type Facing, type Finding, type Level, type Lint, type LintContext, type LintSource, RUNTIME_LINTS, RunAdvisorOptions, STATIC_LINTS, actionFetchSsrf, adminRouteWithoutGuard, aiRawRunEscapeHatch, aiToolSideEffectPromptInjection, aiUnboundedGenerationPublic, allowUnauthenticatedShardAccessEnabled, authApiCallWithoutHeaders, authCsrfCheckDisabled, authEmailVerificationDisabled, authSecureCookiesDisabled, authSessionFreshageZero, authTrustedOriginsWildcard, browserAllowPrivateTargets, browserUserUrlWithoutAllowlist, circularFk, constraintValidator, containerInstanceKeyFromUserInput, containerOversizedInstance, containerPublicInternet, containerRuntimeEgressRelaxation, containerStartEnableInternetOverride, dedupeCacheKeys, duplicateIndex, emptyIndex, exportSinkMisconfigured, externalSourceIncrementalNoDeletePath, externalSourceOnGlobal, externalSourceUnscoped, filterWithoutIndex, flagGatesSecurityWithUnsafeDefault, fromServerSchema, geoIndexFieldNotGeopoint, geoIndexUnused, hardcodedSecret, hotShard, httpActionMissingAuthGuard, httpActionResponseHeaderInjection, hyperdriveOutsideAction, identityUndeclaredClaimTrusted, imagesUrlSourceFromUserInput, indexReferencesUnknownField, indexUtilization, insertManyUnsafeUserData, kvUnscopedUserKeyIdor, loadAnalyticsRuntimeMetrics, mailInboundDispatchWithoutVerify, mailRecipientFromRequestInput, maskUncoveredPiiColumn, maskWeakHashStrategyOnPii, maskedRelationLeakViaWith, mutatorFullRowReplace, nondeterministicQueryMutation, normalizeIdUsedAsAuthorization, notifyMissingPushConfig, notifySendOutsideAction, outputProjectionMissingOnPublicRead, ownerFieldFromArgsNotAuth, paymentCreateWithoutAuthorize, paymentWebhookWideTolerance, plaintextSecretInWranglerVariables, policyReferencesUnknownTable, privilegedDispatchUnvalidatedPayload, privilegedFanoutFromPublicProcedure, publicArgumentUsesAny, publicMutationWithoutRatelimit, publicTableRlsOptoutConfusion, queueWithoutDlq, r2sqlOutsideAction, ratelimitDefaultMemoryStore, ratelimitKeySpoofableOrGlobal, ratelimitMiddlewareFailOpen, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, signupMutationWithoutDisposableGating, softDeleteIncludeDeletedFromArgs, sqlInjectionRisk, storageGenerateUploadUrlNoContentTypePin, storageKeyFromUserArgs, storagePresignedUrlForPrivateContent, storageUploadWithoutContentTypeAllowlist, storageUploadWithoutMaxSize, tableWithoutInsert, ttlFieldNotTimestamp, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, vectorsNamespaceFromUserInput, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
package/dist/index.mjs CHANGED
@@ -24,11 +24,14 @@ import containerRuntimeEgressRelaxation from './packem_shared/containerRuntimeEg
24
24
  import containerStartEnableInternetOverride from './packem_shared/containerStartEnableInternetOverride-DDaHZQ1L.mjs';
25
25
  import duplicateIndex from './packem_shared/duplicateIndex-BOublMSt.mjs';
26
26
  import emptyIndex from './packem_shared/emptyIndex-BX8EuEY7.mjs';
27
+ import exportSinkMisconfigured from './packem_shared/exportSinkMisconfigured-897sVMUa.mjs';
27
28
  import externalSourceIncrementalNoDeletePath from './packem_shared/externalSourceIncrementalNoDeletePath-DvX9cRBD.mjs';
28
29
  import externalSourceOnGlobal from './packem_shared/externalSourceOnGlobal-Bg-NfCX9.mjs';
29
30
  import externalSourceUnscoped from './packem_shared/externalSourceUnscoped-5vT-Bup3.mjs';
30
31
  import filterWithoutIndex from './packem_shared/filterWithoutIndex-BYVeJaSs.mjs';
31
32
  import flagGatesSecurityWithUnsafeDefault from './packem_shared/flagGatesSecurityWithUnsafeDefault-BhIs0shr.mjs';
33
+ import geoIndexFieldNotGeopoint from './packem_shared/geoIndexFieldNotGeopoint-CcaInLhY.mjs';
34
+ import geoIndexUnused from './packem_shared/geoIndexUnused-D7I0UyND.mjs';
32
35
  import hardcodedSecret from './packem_shared/hardcodedSecret-Be-pKVdn.mjs';
33
36
  import httpActionMissingAuthGuard from './packem_shared/httpActionMissingAuthGuard-CxipddNx.mjs';
34
37
  import httpActionResponseHeaderInjection from './packem_shared/httpActionResponseHeaderInjection-DOFS7pFT.mjs';
@@ -46,6 +49,8 @@ import maskedRelationLeakViaWith from './packem_shared/maskedRelationLeakViaWith
46
49
  import mutatorFullRowReplace from './packem_shared/mutatorFullRowReplace-BJnNDaIV.mjs';
47
50
  import nondeterministicQueryMutation from './packem_shared/nondeterministicQueryMutation-GXES1fLp.mjs';
48
51
  import normalizeIdUsedAsAuthorization from './packem_shared/normalizeIdUsedAsAuthorization-BVPtCpzT.mjs';
52
+ import notifyMissingPushConfig from './packem_shared/notifyMissingPushConfig-Ca9EzRzy.mjs';
53
+ import notifySendOutsideAction from './packem_shared/notifySendOutsideAction-BUZSIZa4.mjs';
49
54
  import outputProjectionMissingOnPublicRead from './packem_shared/outputProjectionMissingOnPublicRead-Bl5IMx0k.mjs';
50
55
  import ownerFieldFromArgsNotAuth from './packem_shared/ownerFieldFromArgsNotAuth-mOw3hE5z.mjs';
51
56
  import paymentCreateWithoutAuthorize from './packem_shared/paymentCreateWithoutAuthorize-BYm4JLxo.mjs';
@@ -67,6 +72,7 @@ import relationReferencesUnknownTable from './packem_shared/relationReferencesUn
67
72
  import rlsUncoveredTable from './packem_shared/rlsUncoveredTable-CxEfZ5eZ.mjs';
68
73
  import shapeTargetsGlobalTable from './packem_shared/shapeTargetsGlobalTable-DHrf4Koi.mjs';
69
74
  import shapeUnknownTable from './packem_shared/shapeUnknownTable-C8aDWFoe.mjs';
75
+ import signupMutationWithoutDisposableGating from './packem_shared/signupMutationWithoutDisposableGating-Dnbol2CS.mjs';
70
76
  import softDeleteIncludeDeletedFromArgs from './packem_shared/softDeleteIncludeDeletedFromArgs-BLqDKrkM.mjs';
71
77
  import sqlInjectionRisk from './packem_shared/sqlInjectionRisk-zwytYGLt.mjs';
72
78
  import storageGenerateUploadUrlNoContentTypePin from './packem_shared/storageGenerateUploadUrlNoContentTypePin-Da4L9Ge8.mjs';
@@ -75,6 +81,7 @@ import storagePresignedUrlForPrivateContent from './packem_shared/storagePresign
75
81
  import storageUploadWithoutContentTypeAllowlist from './packem_shared/storageUploadWithoutContentTypeAllowlist-BV-gF1lT.mjs';
76
82
  import storageUploadWithoutMaxSize from './packem_shared/storageUploadWithoutMaxSize-DpxO59wU.mjs';
77
83
  import tableWithoutInsert from './packem_shared/tableWithoutInsert-CbbaYIP4.mjs';
84
+ import ttlFieldNotTimestamp from './packem_shared/ttlFieldNotTimestamp-Dpaj63Z6.mjs';
78
85
  import unboundedStringArgument from './packem_shared/unboundedStringArgument-DThg2-wt.mjs';
79
86
  import unindexedForeignKey from './packem_shared/unindexedForeignKey-BgJbKyqK.mjs';
80
87
  import unindexedRelationTarget from './packem_shared/unindexedRelationTarget-D6eyj6Xx.mjs';
@@ -84,7 +91,7 @@ import workflowDuplicateStepName from './packem_shared/workflowDuplicateStepName
84
91
  import workflowUnknownTarget from './packem_shared/workflowUnknownTarget-Cdd7WhKQ.mjs';
85
92
  import workflowUnused from './packem_shared/workflowUnused-D0jHxdz9.mjs';
86
93
  export { AE_METRIC_EVENTS, loadAnalyticsRuntimeMetrics } from './packem_shared/AE_METRIC_EVENTS-BM14d0lm.mjs';
87
- export { fromServerSchema } from './packem_shared/fromServerSchema-BjAZdvJ6.mjs';
94
+ export { fromServerSchema } from './packem_shared/fromServerSchema-CVnbSf8j.mjs';
88
95
 
89
96
  const STATIC_LINTS = [
90
97
  indexReferencesUnknownField,
@@ -97,6 +104,10 @@ const STATIC_LINTS = [
97
104
  externalSourceOnGlobal,
98
105
  externalSourceUnscoped,
99
106
  emptyIndex,
107
+ geoIndexFieldNotGeopoint,
108
+ geoIndexUnused,
109
+ exportSinkMisconfigured,
110
+ ttlFieldNotTimestamp,
100
111
  circularFk,
101
112
  unindexedForeignKey,
102
113
  unindexedRelationTarget,
@@ -119,6 +130,7 @@ const STATIC_LINTS = [
119
130
  containerPublicInternet,
120
131
  publicMutationWithoutRatelimit,
121
132
  userCreatingMutationWithoutCaptcha,
133
+ signupMutationWithoutDisposableGating,
122
134
  publicArgumentUsesAny,
123
135
  unboundedStringArgument,
124
136
  hardcodedSecret,
@@ -167,6 +179,8 @@ const STATIC_LINTS = [
167
179
  maskedRelationLeakViaWith,
168
180
  outputProjectionMissingOnPublicRead,
169
181
  normalizeIdUsedAsAuthorization,
182
+ notifySendOutsideAction,
183
+ notifyMissingPushConfig,
170
184
  plaintextSecretInWranglerVariables
171
185
  ];
172
186
  const RUNTIME_LINTS = [hotShard, indexUtilization, constraintValidator];
@@ -183,4 +197,4 @@ const runAdvisor = (context, options = {}) => {
183
197
  return dedupeCacheKeys(findings);
184
198
  };
185
199
 
186
- export { ALL_LINTS, RUNTIME_LINTS, STATIC_LINTS, actionFetchSsrf, adminRouteWithoutGuard, aiRawRunEscapeHatch, aiToolSideEffectPromptInjection, aiUnboundedGenerationPublic, allowUnauthenticatedShardAccessEnabled, authApiCallWithoutHeaders, authCsrfCheckDisabled, authEmailVerificationDisabled, authSecureCookiesDisabled, authSessionFreshageZero, authTrustedOriginsWildcard, browserAllowPrivateTargets, browserUserUrlWithoutAllowlist, circularFk, constraintValidator, containerInstanceKeyFromUserInput, containerOversizedInstance, containerPublicInternet, containerRuntimeEgressRelaxation, containerStartEnableInternetOverride, dedupeCacheKeys, duplicateIndex, emptyIndex, externalSourceIncrementalNoDeletePath, externalSourceOnGlobal, externalSourceUnscoped, filterWithoutIndex, flagGatesSecurityWithUnsafeDefault, hardcodedSecret, hotShard, httpActionMissingAuthGuard, httpActionResponseHeaderInjection, hyperdriveOutsideAction, identityUndeclaredClaimTrusted, imagesUrlSourceFromUserInput, indexReferencesUnknownField, indexUtilization, insertManyUnsafeUserData, kvUnscopedUserKeyIdor, mailInboundDispatchWithoutVerify, mailRecipientFromRequestInput, maskUncoveredPiiColumn, maskWeakHashStrategyOnPii, maskedRelationLeakViaWith, mutatorFullRowReplace, nondeterministicQueryMutation, normalizeIdUsedAsAuthorization, outputProjectionMissingOnPublicRead, ownerFieldFromArgsNotAuth, paymentCreateWithoutAuthorize, paymentWebhookWideTolerance, plaintextSecretInWranglerVariables, policyReferencesUnknownTable, privilegedDispatchUnvalidatedPayload, privilegedFanoutFromPublicProcedure, publicArgumentUsesAny, publicMutationWithoutRatelimit, publicTableRlsOptoutConfusion, queueWithoutDlq, r2sqlOutsideAction, ratelimitDefaultMemoryStore, ratelimitKeySpoofableOrGlobal, ratelimitMiddlewareFailOpen, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, softDeleteIncludeDeletedFromArgs, sqlInjectionRisk, storageGenerateUploadUrlNoContentTypePin, storageKeyFromUserArgs, storagePresignedUrlForPrivateContent, storageUploadWithoutContentTypeAllowlist, storageUploadWithoutMaxSize, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, vectorsNamespaceFromUserInput, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
200
+ export { ALL_LINTS, RUNTIME_LINTS, STATIC_LINTS, actionFetchSsrf, adminRouteWithoutGuard, aiRawRunEscapeHatch, aiToolSideEffectPromptInjection, aiUnboundedGenerationPublic, allowUnauthenticatedShardAccessEnabled, authApiCallWithoutHeaders, authCsrfCheckDisabled, authEmailVerificationDisabled, authSecureCookiesDisabled, authSessionFreshageZero, authTrustedOriginsWildcard, browserAllowPrivateTargets, browserUserUrlWithoutAllowlist, circularFk, constraintValidator, containerInstanceKeyFromUserInput, containerOversizedInstance, containerPublicInternet, containerRuntimeEgressRelaxation, containerStartEnableInternetOverride, dedupeCacheKeys, duplicateIndex, emptyIndex, exportSinkMisconfigured, externalSourceIncrementalNoDeletePath, externalSourceOnGlobal, externalSourceUnscoped, filterWithoutIndex, flagGatesSecurityWithUnsafeDefault, geoIndexFieldNotGeopoint, geoIndexUnused, hardcodedSecret, hotShard, httpActionMissingAuthGuard, httpActionResponseHeaderInjection, hyperdriveOutsideAction, identityUndeclaredClaimTrusted, imagesUrlSourceFromUserInput, indexReferencesUnknownField, indexUtilization, insertManyUnsafeUserData, kvUnscopedUserKeyIdor, mailInboundDispatchWithoutVerify, mailRecipientFromRequestInput, maskUncoveredPiiColumn, maskWeakHashStrategyOnPii, maskedRelationLeakViaWith, mutatorFullRowReplace, nondeterministicQueryMutation, normalizeIdUsedAsAuthorization, notifyMissingPushConfig, notifySendOutsideAction, outputProjectionMissingOnPublicRead, ownerFieldFromArgsNotAuth, paymentCreateWithoutAuthorize, paymentWebhookWideTolerance, plaintextSecretInWranglerVariables, policyReferencesUnknownTable, privilegedDispatchUnvalidatedPayload, privilegedFanoutFromPublicProcedure, publicArgumentUsesAny, publicMutationWithoutRatelimit, publicTableRlsOptoutConfusion, queueWithoutDlq, r2sqlOutsideAction, ratelimitDefaultMemoryStore, ratelimitKeySpoofableOrGlobal, ratelimitMiddlewareFailOpen, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, signupMutationWithoutDisposableGating, softDeleteIncludeDeletedFromArgs, sqlInjectionRisk, storageGenerateUploadUrlNoContentTypePin, storageKeyFromUserArgs, storagePresignedUrlForPrivateContent, storageUploadWithoutContentTypeAllowlist, storageUploadWithoutMaxSize, tableWithoutInsert, ttlFieldNotTimestamp, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, vectorsNamespaceFromUserInput, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
@@ -0,0 +1,46 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const REQUIRED_KEYS = {
4
+ defineExportSink: ["name", "deliver"],
5
+ r2Sink: ["bucket", "name"],
6
+ webhookExportSink: ["name", "url"]
7
+ };
8
+ const exportSinkMisconfigured = {
9
+ categories: ["SCHEMA"],
10
+ description: "A CDC export sink (`defineExportSink` / `webhookExportSink` / `r2Sink`) is missing a required config field (a webhook with no `url`, an R2 sink with no `bucket`, or a sink with no `name`/`deliver`). The sink can never deliver a change batch, so the export tap silently drains nothing.",
11
+ facing: "INTERNAL",
12
+ level: "ERROR",
13
+ name: "export_sink_misconfigured",
14
+ remediation: "Supply the missing field: a non-empty `url` for `webhookExportSink`, a `bucket` R2 binding for `r2Sink`, or `name` + `deliver` for `defineExportSink`.",
15
+ run: (context) => {
16
+ if (context.exportSinks === void 0) {
17
+ return [];
18
+ }
19
+ const findings = [];
20
+ for (const sink of context.exportSinks) {
21
+ if (!sink.analyzable) {
22
+ continue;
23
+ }
24
+ const present = new Set(sink.presentKeys);
25
+ const empty = new Set(sink.emptyKeys);
26
+ for (const key of REQUIRED_KEYS[sink.factory] ?? []) {
27
+ if (present.has(key) && !empty.has(key)) {
28
+ continue;
29
+ }
30
+ const reason = empty.has(key) ? "is set to an empty string" : "is missing";
31
+ findings.push(
32
+ emit(exportSinkMisconfigured, {
33
+ cacheKey: `export_sink_misconfigured:${sink.file}:${sink.line.toString()}:${key}`,
34
+ detail: `\`${sink.factory}({ … })\` in ${sink.file}:${sink.line.toString()} ${reason} its required \`${key}\` field — the sink can never deliver a change batch, so the export tap drains nothing.`,
35
+ metadata: { factory: sink.factory, field: key, file: sink.file, line: sink.line }
36
+ })
37
+ );
38
+ }
39
+ }
40
+ return findings;
41
+ },
42
+ source: "static",
43
+ title: "Export sink is misconfigured"
44
+ };
45
+
46
+ export { exportSinkMisconfigured as default };
@@ -14,8 +14,16 @@ const fromServerSchema = (schema) => {
14
14
  }),
15
15
  ...table.vectorIndexes.map((index) => {
16
16
  return { fields: [index.field], kind: "vector", name: index.name };
17
+ }),
18
+ ...table.geoIndexes.map((index) => {
19
+ return { fields: [index.field], kind: "geo", name: index.name };
17
20
  })
18
21
  ];
22
+ const columnKinds = {};
23
+ for (const [fieldName, validator] of Object.entries(table.shape)) {
24
+ const inner = validator.kind === "optional" ? validator._meta?.inner : validator;
25
+ columnKinds[fieldName] = inner?.kind ?? validator.kind;
26
+ }
19
27
  const optionalFields = /* @__PURE__ */ new Set();
20
28
  for (const [fieldName, validator] of Object.entries(table.shape)) {
21
29
  if (validator.kind === "optional") {
@@ -35,6 +43,7 @@ const fromServerSchema = (schema) => {
35
43
  hasTenantBy: table.externalSource.tenantBy !== void 0,
36
44
  mode: table.externalSource.mode
37
45
  } : void 0,
46
+ columnKinds,
38
47
  fields: Object.keys(table.shape),
39
48
  indexes,
40
49
  isPublic: table.isPublic ?? false,
@@ -42,6 +51,7 @@ const fromServerSchema = (schema) => {
42
51
  optionalFields,
43
52
  shardKind: table.shardMode.kind,
44
53
  softDelete: table.softDeleteMode,
54
+ ttl: table.ttlPolicy,
45
55
  relations: Object.entries(table.relationMap).map(([accessor, relation]) => {
46
56
  return {
47
57
  field: relation.field,
@@ -0,0 +1,40 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const geoIndexFieldNotGeopoint = {
4
+ categories: ["SCHEMA"],
5
+ description: "A `.geoIndex(name, { field })` points at a column that is not a `v.geoPoint()`, so its geohash companion can't be built and `withGeoIndex(...)` can never match.",
6
+ facing: "INTERNAL",
7
+ level: "ERROR",
8
+ name: "geo_index_field_not_geopoint",
9
+ remediation: "Point the geo index at a `v.geoPoint()` column, or change the column's type to `v.geoPoint()`.",
10
+ run: (context) => {
11
+ const findings = [];
12
+ for (const table of context.schema.tables) {
13
+ for (const index of table.indexes) {
14
+ if (index.kind !== "geo") {
15
+ continue;
16
+ }
17
+ const field = index.fields[0];
18
+ if (field === void 0) {
19
+ continue;
20
+ }
21
+ const kind = table.columnKinds?.[field];
22
+ if (kind === void 0 || kind === "geoPoint") {
23
+ continue;
24
+ }
25
+ findings.push(
26
+ emit(geoIndexFieldNotGeopoint, {
27
+ cacheKey: `geo_index_field_not_geopoint:${table.name}:${index.name}:${field}`,
28
+ detail: `Geo index "${index.name}" on table "${table.name}" indexes column "${field}", which is a ${kind}, not a v.geoPoint().`,
29
+ metadata: { field, index: index.name, kind, table: table.name }
30
+ })
31
+ );
32
+ }
33
+ }
34
+ return findings;
35
+ },
36
+ source: "static",
37
+ title: "Geo index field is not a geoPoint"
38
+ };
39
+
40
+ export { geoIndexFieldNotGeopoint as default };
@@ -0,0 +1,40 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const geoIndexUnused = {
4
+ categories: ["SCHEMA"],
5
+ description: "A `.geoIndex(name, { field })` is declared but no handler reads it via `withGeoIndex(name, …)`. The geohash companion column is maintained on every write and read by nothing — dead overhead, the geo analogue of a dead index.",
6
+ facing: "INTERNAL",
7
+ level: "INFO",
8
+ name: "geo_index_unused",
9
+ remediation: 'Query the index from a handler with `ctx.db.query("<table>").withGeoIndex("<name>", q => q.near(point, radius))`, or drop the `.geoIndex(...)` declaration so writes stop maintaining its geohash companion.',
10
+ run: (context) => {
11
+ if (context.geoIndexUsages === void 0) {
12
+ return [];
13
+ }
14
+ const usages = context.geoIndexUsages;
15
+ if (usages.some((usage) => usage.indexName === "")) {
16
+ return [];
17
+ }
18
+ const used = new Set(usages.map((usage) => usage.indexName));
19
+ const findings = [];
20
+ for (const table of context.schema.tables) {
21
+ for (const index of table.indexes) {
22
+ if (index.kind !== "geo" || used.has(index.name)) {
23
+ continue;
24
+ }
25
+ findings.push(
26
+ emit(geoIndexUnused, {
27
+ cacheKey: `geo_index_unused:${table.name}:${index.name}`,
28
+ detail: `No handler reads geo index "${index.name}" on table "${table.name}" via \`withGeoIndex("${index.name}", …)\` — it's declared but never queried, so its geohash companion is maintained on every write for nothing.`,
29
+ metadata: { index: index.name, table: table.name }
30
+ })
31
+ );
32
+ }
33
+ }
34
+ return findings;
35
+ },
36
+ source: "static",
37
+ title: "Geo index is declared but never queried"
38
+ };
39
+
40
+ export { geoIndexUnused as default };
@@ -0,0 +1,27 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const notifyMissingPushConfig = {
4
+ categories: ["SCHEMA"],
5
+ description: "The app sends push notifications via `ctx.push` but `defineNotify(...)` configures neither a Web Push nor an FCM channel, so every send fails at runtime. Wire at least one push channel (`webPush` and/or `fcm`) and provide its secrets (`VAPID_*` / `FCM_*`).",
6
+ facing: "INTERNAL",
7
+ level: "WARN",
8
+ name: "notify_missing_push_config",
9
+ remediation: "In `lunora/notify.ts`, wire a push channel: `defineNotify({ webPush: (env) => webPushFromEnv(env) })` and set `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` in `.dev.vars` (or configure `fcm` with `FCM_PROJECT_ID`). Generate a VAPID keypair with `npx web-push generate-vapid-keys`.",
10
+ run: (context) => {
11
+ const config = context.notifyConfig;
12
+ if (config === void 0 || !config.usesPush || config.hasWebPush || config.hasFcm) {
13
+ return [];
14
+ }
15
+ return [
16
+ emit(notifyMissingPushConfig, {
17
+ cacheKey: "notify_missing_push_config",
18
+ detail: "`ctx.push` is used to send notifications, but `defineNotify(...)` wires neither `webPush` nor `fcm` — every push send will fail at runtime. Configure at least one push channel and its secrets.",
19
+ metadata: { hasFcm: config.hasFcm, hasWebPush: config.hasWebPush, usesPush: config.usesPush }
20
+ })
21
+ ];
22
+ },
23
+ source: "static",
24
+ title: "Push used with no Web Push / FCM channel configured"
25
+ };
26
+
27
+ export { notifyMissingPushConfig as default };
@@ -0,0 +1,30 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const notifySendOutsideAction = {
4
+ categories: ["SCHEMA"],
5
+ description: "A `query`/`mutation` handler sends a notification via `ctx.notify`/`ctx.push`. A send is external I/O (a `fetch` to a push service / FCM): it is non-deterministic like `fetch`, and a mutation re-run on OCC retry would re-send it (duplicate pushes). These facades are available on `ActionCtx` only and must be confined to `action` handlers.",
6
+ facing: "EXTERNAL",
7
+ level: "WARN",
8
+ name: "notify_send_outside_action",
9
+ remediation: "Move the `ctx.notify`/`ctx.push` send into an `action(...)`, where external I/O is allowed. If a query/mutation must trigger a notification, have it enqueue the send (`enqueuePushBroadcast` via `@lunora/queue`) or schedule an action — the queue/scheduler runs the send exactly once, off the transactional path.",
10
+ run: (context) => {
11
+ if (context.notifyCalls === void 0) {
12
+ return [];
13
+ }
14
+ const findings = [];
15
+ for (const call of context.notifyCalls) {
16
+ findings.push(
17
+ emit(notifySendOutsideAction, {
18
+ cacheKey: `notify_send_outside_action:${call.file}:${call.line.toString()}:${call.callee}`,
19
+ detail: `\`${call.callee}(…)\` in ${call.exportName} (${call.file}:${call.line.toString()}) runs inside a ${call.kind} handler — a notification send is non-deterministic external I/O and a retried ${call.kind} would re-send it. Move it into an \`action\`, or enqueue/schedule the send.`,
20
+ metadata: { callee: call.callee, exportName: call.exportName, file: call.file, kind: call.kind, line: call.line }
21
+ })
22
+ );
23
+ }
24
+ return findings;
25
+ },
26
+ source: "static",
27
+ title: "Notification send used outside an action"
28
+ };
29
+
30
+ export { notifySendOutsideAction as default };
@@ -0,0 +1,39 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+ import { i as isPublicWrite } from './helpers-BySnKhVB.mjs';
3
+
4
+ const signupMutationWithoutDisposableGating = {
5
+ categories: ["SECURITY"],
6
+ 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.",
7
+ facing: "EXTERNAL",
8
+ level: "WARN",
9
+ name: "signup_mutation_without_disposable_gating",
10
+ 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(...)`.",
11
+ run: (context) => {
12
+ if (context.procedureProtections === void 0) {
13
+ return [];
14
+ }
15
+ const findings = [];
16
+ for (const procedure of context.procedureProtections) {
17
+ if (!isPublicWrite(procedure) || !procedure.writesUserTable || procedure.usesEmailGate) {
18
+ continue;
19
+ }
20
+ findings.push(
21
+ emit(signupMutationWithoutDisposableGating, {
22
+ cacheKey: `signup_mutation_without_disposable_gating:${procedure.file}:${procedure.exportName}`,
23
+ detail: `Public ${procedure.kind} \`${procedure.exportName}\` (${procedure.file}) writes a user/session/account table but has no disposable-email gate. Add \`.use(emailGateMiddleware(...))\` from \`@lunora/auth/email-guard\`.`,
24
+ metadata: {
25
+ exportName: procedure.exportName,
26
+ file: procedure.file,
27
+ kind: procedure.kind,
28
+ writesUserTable: procedure.writesUserTable
29
+ }
30
+ })
31
+ );
32
+ }
33
+ return findings;
34
+ },
35
+ source: "static",
36
+ title: "Account-creating write without a disposable-email gate"
37
+ };
38
+
39
+ export { signupMutationWithoutDisposableGating as default };
@@ -0,0 +1,36 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const TIME_KINDS = /* @__PURE__ */ new Set(["date", "number", "timestamp"]);
4
+ const ttlFieldNotTimestamp = {
5
+ categories: ["SCHEMA"],
6
+ 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.",
7
+ facing: "INTERNAL",
8
+ level: "ERROR",
9
+ name: "ttl_field_not_timestamp",
10
+ remediation: "Point `.ttl(field)` at a `v.timestamp()` / `v.date()` / `v.number()` (epoch-ms) column, or add such a column.",
11
+ run: (context) => {
12
+ const findings = [];
13
+ for (const table of context.schema.tables) {
14
+ const { ttl } = table;
15
+ if (!ttl) {
16
+ continue;
17
+ }
18
+ const kind = table.columnKinds?.[ttl.field];
19
+ if (kind === void 0 || TIME_KINDS.has(kind)) {
20
+ continue;
21
+ }
22
+ findings.push(
23
+ emit(ttlFieldNotTimestamp, {
24
+ cacheKey: `ttl_field_not_timestamp:${table.name}:${ttl.field}`,
25
+ detail: `Table "${table.name}" declares \`.ttl("${ttl.field}")\`, but "${ttl.field}" is a ${kind} column, not an epoch-millisecond timestamp.`,
26
+ metadata: { field: ttl.field, kind, table: table.name }
27
+ })
28
+ );
29
+ }
30
+ return findings;
31
+ },
32
+ source: "static",
33
+ title: "TTL field is not a timestamp"
34
+ };
35
+
36
+ export { ttlFieldNotTimestamp as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/advisor",
3
- "version": "1.0.0-alpha.31",
3
+ "version": "1.0.0-alpha.33",
4
4
  "description": "Schema & query lints (splinter-style advisors) for Lunora, feeding the Studio Advisors view",
5
5
  "keywords": [
6
6
  "advisor",
@@ -46,8 +46,8 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/errors": "1.0.0-alpha.6",
50
- "@lunora/server": "1.0.0-alpha.28"
49
+ "@lunora/errors": "1.0.0-alpha.7",
50
+ "@lunora/server": "1.0.0-alpha.30"
51
51
  },
52
52
  "engines": {
53
53
  "node": "^22.15.0 || >=24.11.0"