use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
use noxid_source::json_escape;
const TABLE_CONSTRUCTORS: [&str; 3] = ["pgTable", "mysqlTable", "sqliteTable"];
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum TablePolicyKind {
Scoped { principal_column: String },
Unscoped,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TablePolicy {
pub(crate) table: String,
pub(crate) kind: TablePolicyKind,
}
pub(crate) fn discover_project_schema_policies(
project_root: &Path,
) -> Result<Vec<TablePolicy>, String> {
let schema_path = project_root.join("server/utils/schema.ts");
if !schema_path.is_file() {
return Ok(Vec::new());
}
let source = fs::read_to_string(&schema_path).map_err(|error| {
format!(
"error[DATA_POLICY_INVALID]: cannot read {}: {error}",
schema_path.display()
)
})?;
discover_schema_policies(&schema_path, &source)
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct ScopedColumnSite {
pub(crate) owner: String,
pub(crate) where_: String,
pub(crate) field: String,
pub(crate) ty: String,
}
fn column_aliases(column: &str) -> [String; 2] {
let mut camel = String::with_capacity(column.len());
let mut upper_next = false;
for character in column.chars() {
if character == '_' {
upper_next = true;
continue;
}
if upper_next {
camel.extend(character.to_uppercase());
upper_next = false;
} else {
camel.push(character);
}
}
[column.to_string(), camel]
}
fn unwrap_declared_type(ty: &str) -> &str {
let ty = ty.trim();
for wrapper in ["Optional<", "Array<"] {
if let Some(inner) = ty.strip_prefix(wrapper)
&& let Some(inner) = inner.strip_suffix('>')
{
return unwrap_declared_type(inner);
}
}
ty
}
pub(crate) fn check_scoped_column_types(
policies: &[TablePolicy],
sites: &[ScopedColumnSite],
) -> Result<Vec<String>, String> {
let mut typed = Vec::new();
for policy in policies {
let TablePolicyKind::Scoped { principal_column } = &policy.kind else {
typed.push(String::new());
continue;
};
let aliases = column_aliases(principal_column);
let mut visible = false;
for site in sites
.iter()
.filter(|site| aliases.iter().any(|alias| alias == &site.field))
{
let declared = unwrap_declared_type(&site.ty);
if declared != noxid_ir::PRINCIPAL_ID_TYPE {
return Err(format!(
"error[SCOPED_COLUMN_REQUIRES_PRINCIPAL_ID]: {} declares `{}: {}`, and `{}` is the principal column of scoped table `{}`; declare it `PrincipalId` (or `Optional<PrincipalId>` / `Array<PrincipalId>`) so a raw {} cannot reach a scope predicate — `PrincipalId` erases to the same value on the wire, and `.base()` is the explicit way out. Symbol: {}",
site.where_,
site.field,
site.ty,
principal_column,
policy.table,
declared,
site.owner,
));
}
visible = true;
}
typed.push(if visible {
noxid_ir::PRINCIPAL_ID_TYPE.to_string()
} else {
String::new()
});
}
Ok(typed)
}
pub(crate) fn policies_json(policies: &[TablePolicy]) -> String {
let entries = policies
.iter()
.map(|policy| match &policy.kind {
TablePolicyKind::Scoped { principal_column } => format!(
"{{\"table\":\"{}\",\"policy\":\"scoped\",\"principalColumn\":\"{}\"}}",
json_escape(&policy.table),
json_escape(principal_column)
),
TablePolicyKind::Unscoped => format!(
"{{\"table\":\"{}\",\"policy\":\"unscoped\",\"principalColumn\":null}}",
json_escape(&policy.table)
),
})
.collect::<Vec<_>>()
.join(",");
format!("[{entries}]")
}
#[derive(Clone, Copy, Debug)]
struct Call<'a> {
name: &'a str,
open: usize,
close: usize,
}
fn is_identifier_byte(byte: u8) -> bool {
byte == b'_' || byte == b'$' || byte.is_ascii_alphanumeric()
}
fn skip_trivia(source: &[u8], mut index: usize) -> Result<usize, String> {
loop {
while source.get(index).is_some_and(u8::is_ascii_whitespace) {
index += 1;
}
if source.get(index..index + 2) == Some(b"//") {
index += 2;
while source
.get(index)
.is_some_and(|byte| !matches!(byte, b'\n' | b'\r'))
{
index += 1;
}
continue;
}
if source.get(index..index + 2) == Some(b"/*") {
let start = index;
index += 2;
let mut depth = 1_u32;
while index < source.len() && depth > 0 {
if source.get(index..index + 2) == Some(b"/*") {
depth += 1;
index += 2;
} else if source.get(index..index + 2) == Some(b"*/") {
depth -= 1;
index += 2;
} else {
index += 1;
}
}
if depth != 0 {
return Err(format!("unterminated block comment at byte {start}"));
}
continue;
}
return Ok(index);
}
}
fn quoted_end(source: &[u8], start: usize) -> Result<usize, String> {
let quote = source[start];
let mut index = start + 1;
while index < source.len() {
if source[index] == b'\\' {
index += 2;
} else if source[index] == quote {
return Ok(index + 1);
} else {
index += 1;
}
}
Err(format!("unterminated string at byte {start}"))
}
fn template_end(source: &[u8], start: usize) -> Result<usize, String> {
let mut index = start + 1;
while index < source.len() {
if source[index] == b'\\' {
index += 2;
} else if source[index] == b'`' {
return Ok(index + 1);
} else {
index += 1;
}
}
Err(format!("unterminated template at byte {start}"))
}
fn matching_paren(source: &[u8], open: usize) -> Result<usize, String> {
let mut depth = 1_u32;
let mut index = open + 1;
while index < source.len() {
match source[index] {
b'\'' | b'"' => index = quoted_end(source, index)?,
b'`' => index = template_end(source, index)?,
b'/' if source.get(index + 1) == Some(&b'/') => {
index = skip_trivia(source, index)?;
}
b'/' if source.get(index + 1) == Some(&b'*') => {
index = skip_trivia(source, index)?;
}
b'(' => {
depth += 1;
index += 1;
}
b')' => {
depth -= 1;
if depth == 0 {
return Ok(index);
}
index += 1;
}
_ => index += 1,
}
}
Err(format!("unterminated call at byte {open}"))
}
fn calls_named<'a>(source: &'a str, names: &BTreeSet<&str>) -> Result<Vec<Call<'a>>, String> {
let bytes = source.as_bytes();
let mut calls = Vec::new();
let mut index = 0;
while index < bytes.len() {
index = skip_trivia(bytes, index)?;
if index >= bytes.len() {
break;
}
if matches!(bytes[index], b'\'' | b'"') {
index = quoted_end(bytes, index)?;
continue;
}
if bytes[index] == b'`' {
index = template_end(bytes, index)?;
continue;
}
if !is_identifier_byte(bytes[index]) || bytes[index].is_ascii_digit() {
index += 1;
continue;
}
let start = index;
index += 1;
while bytes
.get(index)
.is_some_and(|byte| is_identifier_byte(*byte))
{
index += 1;
}
let name = &source[start..index];
let open = skip_trivia(bytes, index)?;
if names.contains(name) && bytes.get(open) == Some(&b'(') {
calls.push(Call {
name,
open,
close: matching_paren(bytes, open)?,
});
}
}
Ok(calls)
}
fn parse_literal(source: &str, start: usize, limit: usize) -> Result<(String, usize), String> {
let bytes = source.as_bytes();
let start = skip_trivia(bytes, start)?;
if start >= limit || !matches!(bytes[start], b'\'' | b'"') {
return Err(format!("expected a static quoted string at byte {start}"));
}
let end = quoted_end(bytes, start)?;
if end > limit {
return Err(format!("string at byte {start} crosses its call boundary"));
}
let mut value = String::new();
let quote = bytes[start];
let mut index = start + 1;
while index + 1 < end {
if bytes[index] == b'\\' {
index += 1;
let escaped = *bytes
.get(index)
.ok_or_else(|| format!("invalid escape at byte {index}"))?;
match escaped {
b'\\' | b'\'' | b'"' => value.push(escaped as char),
_ => {
return Err(format!(
"table policy strings may only escape quotes or backslashes (byte {index})"
));
}
}
} else if bytes[index] == quote {
return Err(format!("unexpected quote at byte {index}"));
} else if bytes[index].is_ascii() {
value.push(bytes[index] as char);
} else {
let tail = &source[index..end - 1];
let character = tail
.chars()
.next()
.ok_or_else(|| format!("invalid UTF-8 boundary at byte {index}"))?;
value.push(character);
index += character.len_utf8() - 1;
}
index += 1;
}
Ok((value, end))
}
fn first_argument_call<'a>(source: &'a str, wrapper: Call<'a>) -> Result<Call<'a>, String> {
let names = TABLE_CONSTRUCTORS.into_iter().collect::<BTreeSet<_>>();
let nested = calls_named(&source[wrapper.open + 1..wrapper.close], &names)?;
let Some(call) = nested.first() else {
return Err(format!(
"{} must wrap a direct pgTable/mysqlTable/sqliteTable declaration",
wrapper.name
));
};
let offset = wrapper.open + 1;
let call = Call {
name: call.name,
open: call.open + offset,
close: call.close + offset,
};
let prefix = skip_trivia(source.as_bytes(), wrapper.open + 1)?;
if prefix + call.name.len() != call.open {
return Err(format!(
"{} must receive the table declaration as its first argument",
wrapper.name
));
}
Ok(call)
}
fn argument_after(source: &str, call_close: usize, wrapper_close: usize) -> Result<usize, String> {
let bytes = source.as_bytes();
let comma = skip_trivia(bytes, call_close + 1)?;
if comma >= wrapper_close || bytes[comma] != b',' {
return Err(format!(
"expected a principal-column argument at byte {comma}"
));
}
Ok(comma + 1)
}
fn only_trailing_comma(source: &str, start: usize, close: usize) -> Result<bool, String> {
let bytes = source.as_bytes();
let mut index = skip_trivia(bytes, start)?;
if index < close && bytes[index] == b',' {
index = skip_trivia(bytes, index + 1)?;
}
Ok(index == close)
}
fn valid_sql_identifier(value: &str) -> bool {
let mut bytes = value.bytes();
bytes
.next()
.is_some_and(|byte| byte == b'_' || byte.is_ascii_alphabetic())
&& bytes.all(|byte| byte == b'_' || byte == b'$' || byte.is_ascii_alphanumeric())
}
pub(crate) fn discover_schema_policies(
schema_path: &Path,
source: &str,
) -> Result<Vec<TablePolicy>, String> {
let constructor_names = TABLE_CONSTRUCTORS.into_iter().collect::<BTreeSet<_>>();
let constructors = calls_named(source, &constructor_names).map_err(|error| {
format!(
"error[DATA_POLICY_INVALID]: cannot inspect {}: {error}",
schema_path.display()
)
})?;
let wrapper_names = ["scopedTable", "unscopedTable"]
.into_iter()
.collect::<BTreeSet<_>>();
let wrappers = calls_named(source, &wrapper_names).map_err(|error| {
format!(
"error[DATA_POLICY_INVALID]: cannot inspect {}: {error}",
schema_path.display()
)
})?;
let mut all_tables = BTreeMap::new();
for call in &constructors {
let (table, _) = parse_literal(source, call.open + 1, call.close).map_err(|error| {
format!(
"error[DATA_POLICY_INVALID]: {} {} declaration requires a static physical table name: {error}",
schema_path.display(),
call.name,
)
})?;
if !valid_sql_identifier(&table) {
return Err(format!(
"error[DATA_POLICY_INVALID]: {} declares table `{table}`; v1 data policies require a portable unqualified SQL identifier",
schema_path.display()
));
}
if all_tables.insert(table.clone(), call.open).is_some() {
return Err(format!(
"error[DATA_POLICY_INVALID]: {} declares physical table `{table}` more than once; each table needs one canonical policy",
schema_path.display()
));
}
}
let mut policies = BTreeMap::new();
for wrapper in wrappers {
let table_call = first_argument_call(source, wrapper).map_err(|error| {
format!(
"error[DATA_POLICY_INVALID]: {}: {error}",
schema_path.display()
)
})?;
let (table, _) = parse_literal(source, table_call.open + 1, table_call.close).map_err(
|error| {
format!(
"error[DATA_POLICY_INVALID]: {} {} requires a static physical table name: {error}",
schema_path.display(),
wrapper.name,
)
},
)?;
let kind = if wrapper.name == "scopedTable" {
let start = argument_after(source, table_call.close, wrapper.close).map_err(|error| {
format!(
"error[DATA_POLICY_INVALID]: {} scopedTable for `{table}` is invalid: {error}",
schema_path.display()
)
})?;
let (principal_column, end) =
parse_literal(source, start, wrapper.close).map_err(|error| {
format!(
"error[DATA_POLICY_INVALID]: {} scopedTable for `{table}` requires a static physical principal column: {error}",
schema_path.display()
)
})?;
if !valid_sql_identifier(&principal_column) {
return Err(format!(
"error[DATA_POLICY_INVALID]: {} scopes `{table}` by `{principal_column}`; use the physical SQL column name as a portable unqualified identifier",
schema_path.display()
));
}
if !only_trailing_comma(source, end, wrapper.close)? {
return Err(format!(
"error[DATA_POLICY_INVALID]: {} scopedTable for `{table}` accepts exactly the table and principal column",
schema_path.display()
));
}
TablePolicyKind::Scoped { principal_column }
} else {
if !only_trailing_comma(source, table_call.close + 1, wrapper.close)? {
return Err(format!(
"error[DATA_POLICY_INVALID]: {} unscopedTable for `{table}` accepts only the table declaration",
schema_path.display()
));
}
TablePolicyKind::Unscoped
};
if policies.insert(table.clone(), kind).is_some() {
return Err(format!(
"error[DATA_POLICY_INVALID]: {} gives table `{table}` more than one policy; keep exactly one scopedTable or unscopedTable declaration",
schema_path.display()
));
}
}
for table in all_tables.keys() {
if !policies.contains_key(table) {
return Err(format!(
"error[DATA_POLICY_UNDECLARED]: {} declares table `{table}` without a data policy; wrap it in scopedTable(table, \"principal_column\") or unscopedTable(table) so omission cannot grant access",
schema_path.display()
));
}
}
for table in policies.keys() {
if !all_tables.contains_key(table) {
return Err(format!(
"error[DATA_POLICY_INVALID]: {} declares a policy for unknown table `{table}`",
schema_path.display()
));
}
}
Ok(policies
.into_iter()
.map(|(table, kind)| TablePolicy { table, kind })
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn policies_are_static_canonical_and_exhaustive() {
let source = r#"
import { scopedTable, unscopedTable } from "adapter";
const decoy = `pgTable("ignored", {})`;
export const users = unscopedTable(pgTable("users", { id: text("id") }));
export const progress = scopedTable(
pgTable("progress", { userId: text("user_id") }),
"user_id",
);
"#;
let policies = discover_schema_policies(Path::new("server/utils/schema.ts"), source)
.expect("valid policies");
assert_eq!(
policies,
vec![
TablePolicy {
table: "progress".into(),
kind: TablePolicyKind::Scoped {
principal_column: "user_id".into(),
},
},
TablePolicy {
table: "users".into(),
kind: TablePolicyKind::Unscoped,
},
]
);
}
#[test]
fn bare_table_refuses_with_teaching_diagnostic() {
let error = discover_schema_policies(
Path::new("server/utils/schema.ts"),
"export const progress = pgTable(\"progress\", {});",
)
.expect_err("bare table must refuse");
assert!(error.contains("error[DATA_POLICY_UNDECLARED]"));
assert!(error.contains("scopedTable"));
assert!(error.contains("unscopedTable"));
}
fn scoped(table: &str, column: &str) -> TablePolicy {
TablePolicy {
table: table.into(),
kind: TablePolicyKind::Scoped {
principal_column: column.into(),
},
}
}
fn site(field: &str, ty: &str) -> ScopedColumnSite {
ScopedColumnSite {
owner: "endpoint:SaveNote@1".into(),
where_: "endpoint `SaveNote`".into(),
field: field.into(),
ty: ty.into(),
}
}
#[test]
fn a_scoped_column_declared_as_a_string_refuses() {
let error = check_scoped_column_types(
&[scoped("notes", "user_id")],
&[site("userId", "String"), site("note", "String")],
)
.expect_err("a String scoped column must refuse");
assert!(
error.contains("error[SCOPED_COLUMN_REQUIRES_PRINCIPAL_ID]"),
"{error}"
);
assert!(error.contains("`userId: String`"), "{error}");
assert!(error.contains("scoped table `notes`"), "{error}");
assert!(error.contains("PrincipalId"), "{error}");
}
#[test]
fn a_scoped_column_typed_principal_id_is_recorded_through_every_wrapper() {
for ty in ["PrincipalId", "Optional<PrincipalId>", "Array<PrincipalId>"] {
let typed =
check_scoped_column_types(&[scoped("notes", "user_id")], &[site("userId", ty)])
.expect("a PrincipalId scoped column validates");
assert_eq!(typed, vec!["PrincipalId".to_string()], "{ty}");
}
}
#[test]
fn a_scoped_column_no_declaration_names_records_no_type() {
let typed = check_scoped_column_types(
&[scoped("notes", "user_id"), scoped("audit", "actor_id")],
&[site("note", "String")],
)
.expect("an unnamed scoped column is not a compiler-visible seam");
assert_eq!(typed, vec![String::new(), String::new()]);
}
#[test]
fn the_physical_column_and_its_camel_case_spelling_are_the_same_seam() {
for field in ["user_id", "userId"] {
check_scoped_column_types(&[scoped("notes", "user_id")], &[site(field, "String")])
.expect_err("both spellings name the scoped column");
}
check_scoped_column_types(&[scoped("notes", "user_id")], &[site("userid", "String")])
.expect("an unrelated field name is not the scoped column");
}
#[test]
fn an_unscoped_table_has_no_principal_column_to_type() {
let typed = check_scoped_column_types(
&[TablePolicy {
table: "users".into(),
kind: TablePolicyKind::Unscoped,
}],
&[site("userId", "String")],
)
.expect("an unscoped table never constrains a field type");
assert_eq!(typed, vec![String::new()]);
}
#[test]
fn policy_handoff_json_is_sorted_and_explicit() {
let policies = vec![
TablePolicy {
table: "progress".into(),
kind: TablePolicyKind::Scoped {
principal_column: "user_id".into(),
},
},
TablePolicy {
table: "users".into(),
kind: TablePolicyKind::Unscoped,
},
];
assert_eq!(
policies_json(&policies),
r#"[{"table":"progress","policy":"scoped","principalColumn":"user_id"},{"table":"users","policy":"unscoped","principalColumn":null}]"#
);
}
}