@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 |
unrestricted_where_branch | ERROR | A shape/policy predicate arm returning {} or undefined — a filter matching EVERY row, where a deny was meant |
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_scim_without_transactions | ERROR | scim() on a database adapter with no native transactions; every SCIM request fails |
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 |
signup_mutation_without_disposable_gating | WARN | An account-creating write with no disposable-email gate |
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 |
filter_on_primary_key | WARN | A query filtering on _id; ctx.db.get(id) fetches the row directly instead of walking the table |
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 |
ai_run_without_logging | INFO | An AI generation with no structured event; spend can't be attributed and a bad answer can't be traced |
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 across two call sites in one workflow (which cache each gets becomes positional) |
external_source_on_global | ERROR | A table that is both .source() and .global() (contradictory tiers) |
external_source_incremental_no_delete_path | ERROR | An incremental .source() table with no reconcileEveryMs/softDeleteColumn; upstream deletes never apply |
export_sink_misconfigured | ERROR | A CDC export sink missing a required config field, so the tap silently drains nothing |
geo_index_field_not_geopoint | ERROR | A .geoIndex(name, { field }) whose field is not a v.geoPoint() |
ttl_field_not_timestamp | ERROR | A .ttl(field) pointing at a column that is not an epoch-millisecond timestamp |
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 |
flag_read_in_subscription | WARN | ctx.flags read in a query; a flag flip re-runs no subscription, so the branch goes stale |
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 |
global_table_near_column_limit | WARN | A .global() table approaching D1's 100-column ceiling, past which it cannot be created |
error_without_catalog | WARN | A bare new Error(...) thrown from a procedure; opaque to the caller and unfingerprintable |
commit_ordered_hard_delete | WARN | A .commitOrdered() table with no .softDelete(); a hard delete emits no event a consumer sees |
action_without_error_handling | WARN | An action doing outbound I/O (fetch, mail, queues, storage, sql, ai) with no try/catch |
notify_send_outside_action | WARN | ctx.notify/ctx.push used in a query or mutation — external I/O on a re-runnable path |
notify_missing_push_config | WARN | ctx.push with neither a Web Push nor an FCM channel configured, so every send fails |
migration_stale_import | WARN | A migrated-away platform's SDK still imported from lunora/ source |
table_without_insert | INFO | A table no function inserts into |
workflow_unused | INFO | A workflow that is never started |
geo_index_unused | INFO | A .geoIndex(...) no handler reads via withGeoIndex(...) — a companion column written for nothing |
procedure_without_structured_event | INFO | A public write that emits no structured event, so a failure is visible but not searchable |
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 |
fan_out_breadth | WARN | PERFORMANCE | A shard set wide enough to strain a cross-shard read (it would approach the per-invocation subrequest ceiling) |
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, and indexHits off the
LintContext. The Studio backend fills all three from the shards' admin signal.
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 |
Severity is the only input — a lint carries no per-lint weight of its own, so
two rules at the same level cost the same. 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, or shaped in a way this build can't read (hand-edited, truncated).
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 or malformed baseline; version policy is compareToBaseline's alone, which returns comparable: false
on a MAP_VERSION mismatch (a baseline from another version parses fine). 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.