Skip to content
DocspackagesDocumentation

@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.

PackagesAdvisor

@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 single 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 run before the code ships, unlike 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 level (ERROR, 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; the value is 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
unbounded_collectWARNA .collect() with no index and no filter: the whole table, re-sent to every live subscriber on every write
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.

An Analytics-Engine-backed alternative feeder, loadAnalyticsRuntimeMetrics, exists in source (src/ae-metrics.ts) but is quarantined: not exported from @lunora/advisor's package root, and not importable by consumers of the published package. Nothing in the runtime writes the AE events it would read, so wiring it up today would silently read as "no dead indexes" rather than "no data" for the index_utilization lint. It stays internal, as groundwork for a future writer, until that gap closes. See the docblock atop ae-metrics.ts for the full read contract.

Health map — score, verdicts, baseline

The lints above answer "what is wrong?". scoreAdvisor() answers "how are we doing, and did it get worse?" by rolling a lint run up into a weighted score, a grade, and a per-procedure verdict you can commit and diff in CI.

It is a pure function over findings you already have, so it never re-runs a lint:

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

import schema from "./lunora/schema";

const context = { schema: fromServerSchema(schema) };
const map = scoreAdvisor(context.procedureProtections ?? [], runAdvisor(context, { source: "static" }));

console.log(map.score, map.grade); // e.g. 84 "good"

How a score is built

Every procedure starts at 100 and loses each fired rule's weight, charged once however many times that rule fires, so one lint hitting five call sites costs the same as one hitting a single call site.

LevelDefault weight
ERROR20
WARN10
INFO5

A lint may override its own penalty with Lint.weight. Those per-procedure scores roll into a weighted mean: public handlers count double, internal ones and queries count half. Findings that name no procedure (schema shape, wrangler.jsonc config) land in a project bucket weighted against the procedure population, so schema debt still moves the grade.

VerdictMeaning
cleanNo lint fired
warnedScored at or above 50
failingScored below 50
exemptOpted out; appears in the map, pulls no weight

Grades band the global score: excellent (≥ 90), good (≥ 70), needs-work (≥ 50), at-risk below that.

The verdicts are named for severity, not instrumentation, because every lint family feeds this score. A handler with a security finding is failing; that is not a statement about telemetry.

From the command line

lunora advisor does all of this for you: it scores the app, writes lunora.advisor.map.json, and gates on it.

lunora advisor                    # score, print a summary, write the artifact
lunora advisor --all              # every procedure, grouped by file
lunora advisor --entry m#send     # one procedure and the rules that fired
lunora advisor --min-score 80     # exit 1 below 80
lunora advisor --baseline         # exit 1 on any regression vs the committed map
lunora advisor --no-write         # score without touching the artifact

The artifact is stamped deterministically, so committing it and re-running leaves no diff unless something actually changed.

Gate CI on a committed baseline

Write the map to lunora.advisor.map.json, commit it, and compare against it on each run. compareToBaseline reports five independent regression signals: the global score fell, an existing procedure got worse, a procedure started failing, a procedure's findings grew without its score moving, or the project bucket gained findings.

import { compareToBaseline, parseAdvisorMap } from "@lunora/advisor";

const baseline = parseAdvisorMap(JSON.parse(await readFile("lunora.advisor.map.json", "utf8")));

if (baseline === undefined) {
    // Missing, hand-edited, or written by an older MAP_VERSION.
    throw new Error("advisor baseline is unreadable; regenerate it");
}

const diff = compareToBaseline(map, baseline);

if (!diff.comparable) {
    throw new Error(`baseline not comparable: ${diff.reason}`);
}

if (diff.regressed) {
    process.exitCode = 1;
}

parseAdvisorMap returns undefined for a missing, malformed, or older-version baseline, and compareToBaseline returns comparable: false on a version mismatch. Treat both as "cannot verify" and fail; reading them as "no regression" silently disables the gate.

The artifact is deterministic apart from its timestamp: pass generatedAt to make it byte-stable. @lunora/codegen exposes toAdvisorContext() to build the context straight from the feeder.

Some procedure-local lints (filter_without_index among them) currently report a file without an export name, so they land in the project bucket rather than their procedure's row. Closing that gap is tracked work on the codegen feeder.