M0: backend skeleton — Quart factory, async DB, auth, ACL spine, migrations

Foundation & Identity backend for ThoughtSync:
- Quart app factory (create_app) with /api/health + SPA history-fallback
- async SQLAlchemy 2.0 + asyncpg engine/session (lazy; boots without a DB)
- native email+password auth via signed-cookie session (register/login/logout/me
  + login_required guard); bcrypt password hashing (72-byte safe)
- multi-user sharing-ACL spine (rule 47): users, groups, group_members, and a
  polymorphic shares table + visible_to_user() SQL predicate (owner OR direct
  share OR group share) that M1's notes will scope through
- Alembic async env (adapted from family pattern) + 0001 foundation migration
- DB-free unit tests (app/health/auth-guard, password roundtrip, ACL compile)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-19 13:09:37 -04:00
co-authored by Claude Opus 4.8
parent cefffa2bd6
commit 04e3ab20cf
21 changed files with 734 additions and 0 deletions
+48
View File
@@ -0,0 +1,48 @@
import asyncio
import os
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
from thoughtsync.models import Base
import thoughtsync.models.all # noqa: F401 — registers every model on Base.metadata
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option(
"sqlalchemy.url",
os.environ.get("THOUGHTSYNC_DATABASE_URL", config.get_main_option("sqlalchemy.url")),
)
target_metadata = Base.metadata
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
run_migrations_online()
+26
View File
@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+80
View File
@@ -0,0 +1,80 @@
"""foundation: users, groups, group_members, shares
Revision ID: 0001
Revises:
Create Date: 2026-07-19
The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the
shares table is polymorphic so notes reuse it without a schema retrofit.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import CITEXT, UUID
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS citext")
op.create_table(
"users",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("email", CITEXT(), nullable=False, unique=True),
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("password_hash", sa.Text(), nullable=True),
sa.Column("display_name", sa.Text(), nullable=False),
sa.Column("avatar_path", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_users_email", "users", ["email"])
op.create_table(
"groups",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_table(
"group_members",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("group_id", UUID(as_uuid=True), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("role", sa.Text(), nullable=False, server_default="member"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_group_user"),
)
op.create_index("ix_group_members_user", "group_members", ["user_id"])
op.create_table(
"shares",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("resource_type", sa.Text(), nullable=False),
sa.Column("resource_id", UUID(as_uuid=True), nullable=False),
sa.Column("shared_with_user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
sa.Column("shared_with_group_id", UUID(as_uuid=True), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=True),
sa.Column("permission", sa.Text(), nullable=False, server_default="view"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint(
"(shared_with_user_id IS NOT NULL) <> (shared_with_group_id IS NOT NULL)",
name="ck_shares_one_target",
),
)
op.create_index("ix_shares_resource", "shares", ["resource_type", "resource_id"])
def downgrade() -> None:
op.drop_index("ix_shares_resource", table_name="shares")
op.drop_table("shares")
op.drop_index("ix_group_members_user", table_name="group_members")
op.drop_table("group_members")
op.drop_table("groups")
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")
# citext extension is left installed — it may be in use elsewhere.