PackagesAdvisor

@lunora/advisor

Schema and query lints — splinter-style advisors that surface in the Studio Advisors view, most of them at codegen time before you ship.

@lunora/advisor is a set of lints over your Lunora app, modeled on Supabase's splinter. Each lint is a pure rule over a normalized LintContext; runAdvisor() runs a set of them and flattens the results into a flat list of findings that the CLI, the Vite plugin, and the Studio Advisors view all render.

You rarely call this package directly. @lunora/codegen runs the static lints during lunora dev and lunora codegen, so a problem shows up in your terminal and in Studio while you work — most of them before the code ships, which is the edge over an advisor that can only inspect a live database.

Two evidence tiers

A lint draws its evidence from one of two sources:

  • static — runs against the declared defineSchema (tables, indexes, relations) plus the query reads and inserts the codegen feeder discovers in your function bodies. Deterministic and runnable at build time. Most lints are static.
  • runtime — reads observed signal from a running deployment (per-shard traffic, table scans, index hits, row samples). Three lints are runtime-only; they need a live worker.

Every finding carries a levelERROR, WARN, or INFO — and a category of SECURITY, PERFORMANCE, or SCHEMA.

How findings surface

Write or change a schema, query, or mutation in lunora/.
lunora dev / lunora codegen runs the static lints through @lunora/codegen. Findings print in the terminal.
The Studio Advisors view renders the same findings, grouped by category and level, with the remediation text for each.

The runtime tier is filled by the Studio backend from each shard's durable counters; it appears in the same Advisors view once a deployment has traffic.

Static lints

Security

Secrets, injection & SSRF:

LintLevelFlags
hardcoded_secretERRORA secret literal committed in source
plaintext_secret_in_wrangler_varsERRORA plaintext secret in wrangler.jsonc vars (belongs in the Secrets Store)
sql_injection_riskERRORUnsafe interpolation in a ctx.sql string
action_fetch_ssrfERRORA ctx.fetch URL derived from user args (SSRF)
browser_allow_private_targetsERRORBrowser rendering with the private-target SSRF guard disabled
browser_user_url_without_allowlistWARNBrowser navigation to an arg-derived URL with no allowlist

IDOR & unscoped access (a user-derived key/id reaches a resource without a server-scoped check):

LintLevelFlags
owner_field_from_args_not_authERRORAn ownership column written from args instead of the server identity
storage_key_from_user_argsERRORAn R2 object key taken directly from user args
kv_unscoped_user_key_idorERRORA KV key derived from unscoped user args
container_instance_key_from_user_inputWARNA container instance key from arg-derived unscoped input (cross-tenant IDOR)
images_url_source_from_user_inputWARNAn image-delivery URL built from unscoped user input
mail_recipient_from_request_inputWARNA mail recipient derived from request input
vectors_namespace_from_user_inputWARNA Vectorize namespace derived from unscoped user input
normalize_id_used_as_authorizationINFOA normalizeId() result gates access with no ownership/RLS check — it validates id shape, not ownership

RLS, masking & output shape:

LintLevelFlags
external_source_unscopedERRORA .source() + .shardBy() table with no tenantBy — every tenant DO replicates the whole multitenant table
rls_uncovered_tableWARNAn RLS-gated table read without the rls() middleware
policy_references_unknown_tableWARNAn RLS policy bound to a table that doesn't exist
public_table_rls_optout_confusionWARNA .public() table that opts out of RLS but carries sensitive columns
allow_unauthenticated_shard_access_enabledWARNUnauthenticated shard access enabled on an RLS-gapped schema
mask_uncovered_pii_columnWARNA maskable column returned without the mask() middleware
mask_weak_hash_strategy_on_piiWARNA mask() "hash" strategy (unsalted FNV-1a) on a PII column — recoverable
masked_relation_leak_via_withINFOA masked table surfaced unmasked through a with-relation on a public read
output_projection_missing_on_public_readINFOA public query returns raw rows with PII columns and no .output(...) projection
soft_delete_include_deleted_from_argsINFOSoft-deleted rows resurfaced via includeDeleted on a public read

Auth configuration (createAuth):

LintLevelFlags
auth_csrf_check_disabledERRORcreateAuth with the CSRF check disabled
auth_secure_cookies_disabledERRORcreateAuth with secure cookies disabled
auth_trusted_origins_wildcardERRORcreateAuth trustedOrigins set to a wildcard
auth_email_verification_disabledWARNcreateAuth with email verification disabled
auth_session_freshage_zeroWARNcreateAuth session freshAge of zero
auth_api_call_without_headersWARNA privileged ctx.authApi call missing request headers
identity_undeclared_claim_trustedWARNAuthorization trusts an undeclared (forgeable) identity claim

HTTP handlers, procedures & rate limits:

LintLevelFlags
mail_inbound_dispatch_without_verifyERRORAn inbound-email handler with no verify hook (runs under the admin bearer)
privileged_dispatch_unvalidated_payloadERRORA queue/workflow forwards an untrusted payload into an RLS-gated function
admin_route_without_guardWARNAn admin route with no auth guard
http_action_missing_auth_guardWARNAn HTTP handler that does a side effect but never reads ctx.auth
http_action_response_header_injectionWARNA response header written from unsanitized request input (CRLF injection)
insert_many_unsafe_user_dataWARNA public procedure using insertManyUnsafe (bypasses validators + triggers)
public_arg_uses_anyWARNA public argument typed v.any()
public_mutation_without_ratelimitWARNA public write with no rate limit
user_creating_mutation_without_captchaWARNAn account-creating / mail-sending write with no CAPTCHA
ratelimit_default_memory_storeWARNA RateLimiter using the per-isolate default memory store
ratelimit_middleware_fail_openWARNA fail-open rate-limit / CAPTCHA guard on a sensitive procedure
ratelimit_key_spoofable_or_globalWARNA rate-limit key derived from spoofable user input
flag_gates_security_with_unsafe_defaultWARNA security flag that fails open to the permissive branch
unbounded_string_argINFOA public string argument with no length bound

Storage, AI, containers & payments:

LintLevelFlags
payment_create_without_authorizeERRORcreatePayment(...) with no authorize gate
storage_upload_without_content_type_allowlistWARNA storage upload with no content-type allowlist (stored XSS)
storage_upload_without_max_sizeWARNA storage upload with no size cap
storage_generate_upload_url_no_content_type_pinWARNA signed upload URL with no content-type pin
storage_presigned_url_for_private_contentWARNA native presigned / near-max-TTL signed URL for private content
privileged_fanout_from_public_procedureWARNA public procedure fanning out to a privileged dispatch surface
ai_unbounded_generation_publicWARNA public procedure running AI generation with no maxOutputTokens
ai_raw_run_escape_hatchWARNA ctx.ai.run model selected from user args
ai_tool_side_effect_prompt_injectionWARNAn AI tool side effect reachable via prompt injection
container_start_enable_internet_overrideWARNA runtime .start() override re-enabling container internet
container_runtime_egress_relaxationWARNA runtime egress mutation relaxing the container firewall
payment_webhook_wide_toleranceWARNA payment-webhook replay-tolerance window that's implausibly wide
container_public_internetINFOA container with public egress enabled by default

Performance

LintLevelFlags
filter_without_indexWARNA query filter on a column no index covers
shape_targets_global_tableWARNA shape replicating from a global (cross-shard) table
unindexed_foreign_keyINFOA foreign-key column with no index on the owning table
unindexed_relation_targetINFOThe many-side foreign key of a relation is unindexed
duplicate_indexINFOA redundant index already covered by another
container_oversized_instanceINFOA container instance larger than its workload needs

Schema

LintLevelFlags
index_references_unknown_fieldERRORAn index naming a field the table doesn't have
relation_references_unknown_fieldERRORA relation pointing at a field that doesn't exist
relation_references_unknown_tableERRORA relation pointing at a table that doesn't exist
shape_unknown_tableERRORA shape bound to a table that doesn't exist
workflow_unknown_targetERRORA workflow call naming a workflow that doesn't exist
workflow_duplicate_step_nameERRORA durable step name reused within one workflow (the second call returns the first's cached result)
external_source_on_globalERRORA table that is both .source() and .global() — contradictory tiers
circular_fkWARNA circular foreign-key dependency between tables
empty_indexWARNAn index declared with no fields
nondeterministic_query_mutationWARNfetch / Date.now / Math.random in a query or mutation
hyperdrive_outside_actionWARNctx.sql used outside an action
r2sql_outside_actionWARNctx.r2sql used outside an action
mutator_full_row_replaceWARNA mutator server impl overwriting a whole row with replace
queue_without_dlqWARNA defineQueue with no deadLetterQueue — exhausted messages are dropped, not captured
table_without_insertINFOA table no function inserts into
workflow_unusedINFOA workflow that is never started

Runtime lints

These read observed signal off a live deployment, so they only fire once a worker has traffic.

LintLevelCategoryFlags
hot_shardWARNPERFORMANCEA shard taking a disproportionate share of traffic
index_utilizationINFOPERFORMANCEA declared index that observed queries never use
constraint_validatorWARNSCHEMAA constraint violated by rows already in the store

Run the lints yourself

Adapt your schema with fromServerSchema and pass it to runAdvisor. The source option restricts to one tier — pass "static" to skip the runtime lints, which need a live deployment:

import { fromServerSchema, runAdvisor } from "@lunora/advisor";

import schema from "./lunora/schema";

const findings = runAdvisor({ schema: fromServerSchema(schema) }, { source: "static" });

for (const finding of findings) {
    console.log(`[${finding.level}] ${finding.name}: ${finding.detail}`);
}

runAdvisor(context, options) returns a flat Finding[] in lint-declaration order. Each finding has level, name, title, detail, description, remediation, categories, source, and metadata. Options:

  • lints — the set to run. Defaults to ALL_LINTS; STATIC_LINTS and RUNTIME_LINTS are also exported, as is each lint by name (e.g. unindexedForeignKey).
  • source — restrict to "static" or "runtime". Omit to run both.

Feeding the runtime lints

The runtime tier reads shardTraffic, tableScans, indexHits, and tableSamples off the LintContext. The Studio backend fills those from each shard's durable counters. As an alternative feeder, loadAnalyticsRuntimeMetrics reconstructs the same arrays from the Analytics Engine SQL API:

import { fromServerSchema, loadAnalyticsRuntimeMetrics, runAdvisor } from "@lunora/advisor";

import schema from "./lunora/schema";

// `client` is an `@lunora/bindings/analytics` SQL client (anything with `query(sql)`).
const metrics = await loadAnalyticsRuntimeMetrics(client, { dataset: "ANALYTICS" });
const findings = runAdvisor({ schema: fromServerSchema(schema), ...metrics }, { source: "runtime" });

A missing metric degrades to an empty array rather than throwing, so a partially configured read path still returns what it can.

See also