@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 declareddefineSchema(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
lunora/.lunora dev / lunora codegen runs the static lints through @lunora/codegen. Findings print in the terminal.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:
| Lint | Level | Flags |
|---|---|---|
hardcoded_secret | ERROR | A secret literal committed in source |
plaintext_secret_in_wrangler_vars | ERROR | A plaintext secret in wrangler.jsonc vars (belongs in the Secrets Store) |
sql_injection_risk | ERROR | Unsafe interpolation in a ctx.sql string |
action_fetch_ssrf | ERROR | A ctx.fetch URL derived from user args (SSRF) |
browser_allow_private_targets | ERROR | Browser rendering with the private-target SSRF guard disabled |
browser_user_url_without_allowlist | WARN | Browser 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):
| Lint | Level | Flags |
|---|---|---|
owner_field_from_args_not_auth | ERROR | An ownership column written from args instead of the server identity |
storage_key_from_user_args | ERROR | An R2 object key taken directly from user args |
kv_unscoped_user_key_idor | ERROR | A KV key derived from unscoped user args |
container_instance_key_from_user_input | WARN | A container instance key from arg-derived unscoped input (cross-tenant IDOR) |
images_url_source_from_user_input | WARN | An image-delivery URL built from unscoped user input |
mail_recipient_from_request_input | WARN | A mail recipient derived from request input |
vectors_namespace_from_user_input | WARN | A Vectorize namespace derived from unscoped user input |
normalize_id_used_as_authorization | INFO | A normalizeId() result gates access with no ownership/RLS check; it validates id shape, not ownership |
RLS, masking & output shape:
| Lint | Level | Flags |
|---|---|---|
external_source_unscoped | ERROR | A .source() + .shardBy() table with no tenantBy; every tenant DO replicates the whole multitenant table |
rls_uncovered_table | WARN | An RLS-gated table read without the rls() middleware |
policy_references_unknown_table | WARN | An RLS policy bound to a table that doesn't exist |
public_table_rls_optout_confusion | WARN | A .public() table that opts out of RLS but carries sensitive columns |
allow_unauthenticated_shard_access_enabled | WARN | Unauthenticated shard access enabled on an RLS-gapped schema |
mask_uncovered_pii_column | WARN | A maskable column returned without the mask() middleware |
mask_weak_hash_strategy_on_pii | WARN | A mask() "hash" strategy (unsalted FNV-1a) on a PII column; the value is recoverable |
masked_relation_leak_via_with | INFO | A masked table surfaced unmasked through a with-relation on a public read |
output_projection_missing_on_public_read | INFO | A public query returns raw rows with PII columns and no .output(...) projection |
soft_delete_include_deleted_from_args | INFO | Soft-deleted rows resurfaced via includeDeleted on a public read |
Auth configuration (createAuth):
| Lint | Level | Flags |
|---|---|---|
auth_csrf_check_disabled | ERROR | createAuth with the CSRF check disabled |
auth_secure_cookies_disabled | ERROR | createAuth with secure cookies disabled |
auth_trusted_origins_wildcard | ERROR | createAuth trustedOrigins set to a wildcard |
auth_email_verification_disabled | WARN | createAuth with email verification disabled |
auth_session_freshage_zero | WARN | createAuth session freshAge of zero |
auth_api_call_without_headers | WARN | A privileged ctx.authApi call missing request headers |
identity_undeclared_claim_trusted | WARN | Authorization trusts an undeclared (forgeable) identity claim |
HTTP handlers, procedures & rate limits:
| Lint | Level | Flags |
|---|---|---|
mail_inbound_dispatch_without_verify | ERROR | An inbound-email handler with no verify hook (runs under the admin bearer) |
privileged_dispatch_unvalidated_payload | ERROR | A queue/workflow forwards an untrusted payload into an RLS-gated function |
admin_route_without_guard | WARN | An admin route with no auth guard |
http_action_missing_auth_guard | WARN | An HTTP handler that does a side effect but never reads ctx.auth |
http_action_response_header_injection | WARN | A response header written from unsanitized request input (CRLF injection) |
insert_many_unsafe_user_data | WARN | A public procedure using insertManyUnsafe (bypasses validators + triggers) |
public_arg_uses_any | WARN | A public argument typed v.any() |
public_mutation_without_ratelimit | WARN | A public write with no rate limit |
user_creating_mutation_without_captcha | WARN | An account-creating / mail-sending write with no CAPTCHA |
ratelimit_default_memory_store | WARN | A RateLimiter using the per-isolate default memory store |
ratelimit_middleware_fail_open | WARN | A fail-open rate-limit / CAPTCHA guard on a sensitive procedure |
ratelimit_key_spoofable_or_global | WARN | A rate-limit key derived from spoofable user input |
flag_gates_security_with_unsafe_default | WARN | A security flag that fails open to the permissive branch |
unbounded_string_arg | INFO | A public string argument with no length bound |
Storage, AI, containers & payments:
| Lint | Level | Flags |
|---|---|---|
payment_create_without_authorize | ERROR | createPayment(...) with no authorize gate |
storage_upload_without_content_type_allowlist | WARN | A storage upload with no content-type allowlist (stored XSS) |
storage_upload_without_max_size | WARN | A storage upload with no size cap |
storage_generate_upload_url_no_content_type_pin | WARN | A signed upload URL with no content-type pin |
storage_presigned_url_for_private_content | WARN | A native presigned / near-max-TTL signed URL for private content |
privileged_fanout_from_public_procedure | WARN | A public procedure fanning out to a privileged dispatch surface |
ai_unbounded_generation_public | WARN | A public procedure running AI generation with no maxOutputTokens |
ai_raw_run_escape_hatch | WARN | A ctx.ai.run model selected from user args |
ai_tool_side_effect_prompt_injection | WARN | An AI tool side effect reachable via prompt injection |
container_start_enable_internet_override | WARN | A runtime .start() override re-enabling container internet |
container_runtime_egress_relaxation | WARN | A runtime egress mutation relaxing the container firewall |
payment_webhook_wide_tolerance | WARN | A payment-webhook replay-tolerance window that's implausibly wide |
container_public_internet | INFO | A container with public egress enabled by default |
Performance
| Lint | Level | Flags |
|---|---|---|
filter_without_index | WARN | A query filter on a column no index covers |
unbounded_collect | WARN | A .collect() with no index and no filter: the whole table, re-sent to every live subscriber on every write |
shape_targets_global_table | WARN | A shape replicating from a global (cross-shard) table |
unindexed_foreign_key | INFO | A foreign-key column with no index on the owning table |
unindexed_relation_target | INFO | The many-side foreign key of a relation is unindexed |
duplicate_index | INFO | A redundant index already covered by another |
container_oversized_instance | INFO | A container instance larger than its workload needs |
Schema
| Lint | Level | Flags |
|---|---|---|
index_references_unknown_field | ERROR | An index naming a field the table doesn't have |
relation_references_unknown_field | ERROR | A relation pointing at a field that doesn't exist |
relation_references_unknown_table | ERROR | A relation pointing at a table that doesn't exist |
shape_unknown_table | ERROR | A shape bound to a table that doesn't exist |
workflow_unknown_target | ERROR | A workflow call naming a workflow that doesn't exist |
workflow_duplicate_step_name | ERROR | A durable step name reused within one workflow (the second call returns the first's cached result) |
external_source_on_global | ERROR | A table that is both .source() and .global() (contradictory tiers) |
circular_fk | WARN | A circular foreign-key dependency between tables |
empty_index | WARN | An index declared with no fields |
nondeterministic_query_mutation | WARN | fetch / Date.now / Math.random in a query or mutation |
hyperdrive_outside_action | WARN | ctx.sql used outside an action |
r2sql_outside_action | WARN | ctx.r2sql used outside an action |
mutator_full_row_replace | WARN | A mutator server impl overwriting a whole row with replace |
queue_without_dlq | WARN | A defineQueue with no deadLetterQueue; exhausted messages are dropped, not captured |
table_without_insert | INFO | A table no function inserts into |
workflow_unused | INFO | A workflow that is never started |
Runtime lints
These read observed signal off a live deployment, so they only fire once a worker has traffic.
| Lint | Level | Category | Flags |
|---|---|---|---|
hot_shard | WARN | PERFORMANCE | A shard taking a disproportionate share of traffic |
index_utilization | INFO | PERFORMANCE | A declared index that observed queries never use |
constraint_validator | WARN | SCHEMA | A 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 toALL_LINTS;STATIC_LINTSandRUNTIME_LINTSare 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.
| Level | Default weight |
|---|---|
ERROR | 20 |
WARN | 10 |
INFO | 5 |
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.
| Verdict | Meaning |
|---|---|
clean | No lint fired |
warned | Scored at or above 50 |
failing | Scored below 50 |
exempt | Opted 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 artifactThe 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.