Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .env.docker
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ DATABASE_PATH=/opt/pabawi/data/pabawi.db

# JWT Secret for authentication (REQUIRED in production)
# Generate with: openssl rand -base64 32
JWT_SECRET=your-secure-random-secret-here # pragma: allowlist secret
# Must be at least 32 chars of random entropy. Placeholder strings (e.g.
# "your-secure-random-secret-here", "change-me") are rejected at startup.
JWT_SECRET= # pragma: allowlist secret

# CORS allowed origins (comma-separated)
# CORS_ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
Expand Down
208 changes: 208 additions & 0 deletions .kiro/html/202605221634-security-remediation-hardening.html

Large diffs are not rendered by default.

Large diffs are not rendered by default.

94 changes: 94 additions & 0 deletions .kiro/scripts/replace_schema_calls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""
Trim the local schema-init function from a test file and rewrite call sites
to use the shared helper.

Pass: file path, function name (initializeSchema | initializeRBACSchema |
initializeMinimalSchema). The script:
1. Locates the function definition (first `async function <name>` line).
2. Locates its closing brace (first `^}` line at column 0 after the def).
3. Deletes the inclusive line range.
4. Replaces all `await <name>(<arg>)` callsites with the canonical helper
`await initializeTestSchema(<arg>)`.
5. Adds an import for `initializeTestSchema` from the helpers path
(relative path is derived from the depth under backend/test/).
"""
import re
import sys
from pathlib import Path


def helper_import_path(file_path: Path) -> str:
"""Compute relative path from a test file to backend/test/helpers/schema."""
test_root = file_path.resolve().parent
while test_root.name != "test" and test_root.parent != test_root:
test_root = test_root.parent
rel = Path(*[".."] * (len(file_path.resolve().relative_to(test_root).parts) - 1)) / "helpers" / "schema"
return rel.as_posix()


def main() -> int:
if len(sys.argv) != 3:
print("usage: replace_schema_calls.py <file> <fn-name>", file=sys.stderr)
return 1

path = Path(sys.argv[1])
fn_name = sys.argv[2]
src = path.read_text()
lines = src.splitlines(keepends=True)

start = None
for i, line in enumerate(lines):
if line.startswith(f"async function {fn_name}"):
start = i
break
if start is None:
print(f"function not found: {fn_name}")
return 0

end = None
for j in range(start + 1, len(lines)):
if lines[j].rstrip("\n").rstrip() == "}":
end = j
break
if end is None:
print("closing brace not found")
return 1

new_lines = lines[:start] + lines[end + 1:]

# Rewrite call sites: any reference to the old function becomes
# `initializeTestSchema`.
new_src = "".join(new_lines)
new_src = re.sub(
rf"\b{fn_name}\(",
"initializeTestSchema(",
new_src,
)

# Insert import after the last existing import line.
helper_rel = helper_import_path(path)
import_line = f'import {{ initializeTestSchema }} from "{helper_rel}";\n'
if "initializeTestSchema" in src:
# already imported (rare); leave content as is (will still be valid).
pass
if 'from "../helpers/schema"' not in new_src and \
"from '../../helpers/schema'" not in new_src and \
'from "../../helpers/schema"' not in new_src and \
'from "../../../helpers/schema"' not in new_src:
last_import_idx = -1
for i, line in enumerate(new_src.splitlines(keepends=True)):
if line.startswith("import "):
last_import_idx = i
out_lines = new_src.splitlines(keepends=True)
if last_import_idx >= 0:
out_lines.insert(last_import_idx + 1, import_line)
new_src = "".join(out_lines)

path.write_text(new_src)
print(f"updated: {path}")
return 0


if __name__ == "__main__":
sys.exit(main())
163 changes: 163 additions & 0 deletions .kiro/scripts/snake_case_sql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""
Convert camelCase column references inside SQL string literals to snake_case.

Only operates inside string literals (`...`, "...", '...') that contain SQL
keywords. Outside strings (TS field accesses, types, variable names) are
untouched.

This script was written to migrate test fixtures that duplicate the schema
with camelCase columns. After running it on a test file, manually verify
the diff.
"""
import re
import sys

CAMEL_TO_SNAKE = {
"passwordHash": "password_hash",
"firstName": "first_name",
"lastName": "last_name",
"isActive": "is_active",
"isAdmin": "is_admin",
"createdAt": "created_at",
"updatedAt": "updated_at",
"lastLoginAt": "last_login_at",
"isBuiltIn": "is_built_in",
"userId": "user_id",
"roleId": "role_id",
"groupId": "group_id",
"permissionId": "permission_id",
"assignedAt": "assigned_at",
"revokedAt": "revoked_at",
"expiresAt": "expires_at",
"attemptedAt": "attempted_at",
"ipAddress": "ip_address",
"lockoutType": "lockout_type",
"lockedAt": "locked_at",
"lockedUntil": "locked_until",
"failedAttempts": "failed_attempts",
"lastAttemptAt": "last_attempt_at",
"cumulativeFailedAttempts": "cumulative_failed_attempts",
"lastFailedAt": "last_failed_at",
"eventType": "event_type",
"targetUserId": "target_user_id",
"targetResourceType": "target_resource_type",
"targetResourceId": "target_resource_id",
"userAgent": "user_agent",
"nodeId": "node_id",
"nodeUri": "node_uri",
}

SQL_KEYWORDS = re.compile(
r"\b(INSERT\s+(?:OR\s+\w+\s+)?INTO|UPDATE\s+\w+|SELECT\s+|DELETE\s+FROM|"
r"CREATE\s+TABLE|CREATE\s+INDEX|CREATE\s+UNIQUE\s+INDEX|ALTER\s+TABLE|"
r"FROM\s+\w+)",
re.IGNORECASE,
)


def replace_in_sql(s: str) -> str:
"""Replace camelCase column refs with snake_case, but preserve alias
targets in `AS "..."` (those are the camelCase TS interface fields and
must stay as-is).
"""
# Find all `AS "..."` segments and protect their content from rewrite.
# Strategy: replace each match with a placeholder, do the global rewrite,
# then restore.
placeholders: list[str] = []

def stash(m: "re.Match[str]") -> str:
placeholders.append(m.group(0))
return f"__AS_ALIAS_{len(placeholders) - 1}__"

protected = re.sub(r'\bAS\s+"[^"]+"', stash, s, flags=re.IGNORECASE)

out = protected
for camel, snake in CAMEL_TO_SNAKE.items():
out = re.sub(rf"\b{camel}\b", snake, out)

# Restore the AS "..." segments verbatim.
for i, original in enumerate(placeholders):
out = out.replace(f"__AS_ALIAS_{i}__", original)

return out


def process(text: str) -> str:
out = []
i = 0
n = len(text)
while i < n:
ch = text[i]
if ch in ("`", '"', "'"):
quote = ch
i += 1
buf = [ch]
while i < n:
c = text[i]
if c == "\\":
buf.append(c)
if i + 1 < n:
buf.append(text[i + 1])
i += 2
continue
i += 1
continue
if c == "$" and quote == "`" and i + 1 < n and text[i + 1] == "{":
buf.append(c)
buf.append("{")
i += 2
depth = 1
while i < n and depth > 0:
cc = text[i]
if cc == "{":
depth += 1
elif cc == "}":
depth -= 1
buf.append(cc)
i += 1
continue
buf.append(c)
if c == quote:
i += 1
break
i += 1
literal = "".join(buf)
if SQL_KEYWORDS.search(literal):
literal = replace_in_sql(literal)
out.append(literal)
continue
if ch == "/" and i + 1 < n and text[i + 1] == "/":
j = text.find("\n", i)
if j < 0:
out.append(text[i:])
i = n
else:
out.append(text[i:j + 1])
i = j + 1
continue
if ch == "/" and i + 1 < n and text[i + 1] == "*":
j = text.find("*/", i + 2)
if j < 0:
out.append(text[i:])
i = n
else:
out.append(text[i:j + 2])
i = j + 2
continue
out.append(ch)
i += 1
return "".join(out)


if __name__ == "__main__":
for path in sys.argv[1:]:
with open(path, "r") as f:
src = f.read()
new = process(src)
if new != src:
with open(path, "w") as f:
f.write(new)
print(f"updated: {path}")
else:
print(f"unchanged: {path}")
89 changes: 89 additions & 0 deletions .kiro/steering/database-conventions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
inclusion: always
---

# Database & SQL Conventions

These conventions are non-negotiable. They exist because PostgreSQL folds
unquoted identifiers to lowercase while SQLite preserves case as written, so
camelCase column names silently behave differently between dialects. Past
violations of this rule caused login to fail on PostgreSQL with the bcrypt
error `data and hash arguments required` (every camelCase column in `users`
came back lowercased and `user.passwordHash` was `undefined`).

## Schema (DDL)

- All table and column names are `snake_case` in every migration file.
- Never declare a camelCase column in a CREATE TABLE or ALTER TABLE.
- Never quote identifiers to preserve case (e.g. `"passwordHash"`). The only
acceptable quoted identifiers are reserved words such as `"action"`.
- Index, constraint, and trigger names are also `snake_case`.
Comment on lines +14 to +20

## Queries (DML)

- INSERT, UPDATE, DELETE statements use `snake_case` columns. Parameters bind
positionally, so the TS side does not see column names here.
- SELECT statements that hydrate a TypeScript interface MUST alias every
`snake_case` column to its `camelCase` interface field:

```ts
this.db.queryOne<User>(
`SELECT id, username, email,
password_hash AS "passwordHash",
first_name AS "firstName",
last_name AS "lastName",
is_active AS "isActive",
is_admin AS "isAdmin",
created_at AS "createdAt",
updated_at AS "updatedAt",
last_login_at AS "lastLoginAt"
FROM users WHERE username = ?`,
[username],
);
```

- Never use `SELECT *` in code paths that hydrate a typed interface. It works
on SQLite, breaks on PostgreSQL, and hides the column contract.
- The alias target MUST be wrapped in double quotes (`AS "camelName"`) so
PostgreSQL preserves the case of the output column.
- A SELECT that does not feed a typed interface (e.g. existence checks
returning `count(*)`) does not need aliasing.

## TypeScript row shapes

- Internal row interfaces (`User`, `AuditLogEntry`, etc.) stay in `camelCase`.
The aliasing in SELECTs hides the snake_case DB layer from the rest of the
application.
- DTOs returned to the frontend are also `camelCase`.

## Migrations

- One migration per logical change. Filename: `NNN_description.sql` (shared)
or `NNN_description.sqlite.sql` / `NNN_description.postgres.sql` for
dialect-specific variants.
- Migrations are applied automatically at server startup by `MigrationRunner`.
- Each migration file is run inside a transaction. The runner provides this
for both dialects — do not add explicit `BEGIN/COMMIT` inside migration
files (it would nest transactions and break PostgreSQL).
- Schema changes that drop or rename data must be idempotent so an
interrupted upgrade can be retried safely.

## Adding new tables or columns

When introducing new schema:

1. Use `snake_case` for everything in the migration file.
2. In the TypeScript repository or service that reads the table, write
SELECTs with explicit `column AS "camelCase"` aliases for every column
the typed interface expects.
3. INSERT and UPDATE statements list snake_case columns explicitly.
4. Run the test suite for both SQLite (default) and PostgreSQL
(`scripts/docker-postgres-test.sh`) before merging.

## Why not snake_case everywhere (DB, TS, frontend)?

We considered it. The rename of column names alone was already a 70+ file
change. Renaming the TS interfaces and frontend DTOs would have multiplied
the surface area without changing what runs on the wire. We kept the DB
honest (snake_case, dialect-portable) and kept the TS surface stable
(camelCase) by paying the alias tax in SELECTs only.
11 changes: 11 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"kirograph": {
"command": "kirograph",
"args": [
"serve",
"--mcp"
]
}
}
}
Loading
Loading