regstack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- regstack/__init__.py +5 -0
- regstack/app.py +150 -0
- regstack/auth/__init__.py +21 -0
- regstack/auth/clock.py +29 -0
- regstack/auth/dependencies.py +102 -0
- regstack/auth/jwt.py +145 -0
- regstack/auth/lockout.py +59 -0
- regstack/auth/mfa.py +29 -0
- regstack/auth/password.py +20 -0
- regstack/auth/tokens.py +19 -0
- regstack/cli/__init__.py +0 -0
- regstack/cli/__main__.py +27 -0
- regstack/cli/_runtime.py +39 -0
- regstack/cli/admin.py +45 -0
- regstack/cli/doctor.py +186 -0
- regstack/cli/init.py +236 -0
- regstack/config/__init__.py +4 -0
- regstack/config/loader.py +114 -0
- regstack/config/schema.py +148 -0
- regstack/config/secrets.py +22 -0
- regstack/db/__init__.py +17 -0
- regstack/db/client.py +26 -0
- regstack/db/indexes.py +70 -0
- regstack/db/repositories/__init__.py +0 -0
- regstack/db/repositories/blacklist_repo.py +28 -0
- regstack/db/repositories/login_attempt_repo.py +27 -0
- regstack/db/repositories/mfa_code_repo.py +99 -0
- regstack/db/repositories/pending_repo.py +76 -0
- regstack/db/repositories/user_repo.py +169 -0
- regstack/email/__init__.py +12 -0
- regstack/email/base.py +23 -0
- regstack/email/composer.py +142 -0
- regstack/email/console.py +28 -0
- regstack/email/factory.py +23 -0
- regstack/email/ses.py +47 -0
- regstack/email/smtp.py +46 -0
- regstack/email/templates/email_change.html +15 -0
- regstack/email/templates/email_change.subject.txt +1 -0
- regstack/email/templates/email_change.txt +7 -0
- regstack/email/templates/password_reset.html +15 -0
- regstack/email/templates/password_reset.subject.txt +1 -0
- regstack/email/templates/password_reset.txt +7 -0
- regstack/email/templates/sms_login_mfa.txt +1 -0
- regstack/email/templates/sms_phone_setup.txt +1 -0
- regstack/email/templates/verification.html +15 -0
- regstack/email/templates/verification.subject.txt +1 -0
- regstack/email/templates/verification.txt +7 -0
- regstack/hooks/__init__.py +3 -0
- regstack/hooks/events.py +59 -0
- regstack/models/__init__.py +15 -0
- regstack/models/_objectid.py +30 -0
- regstack/models/login_attempt.py +31 -0
- regstack/models/mfa_code.py +40 -0
- regstack/models/pending_registration.py +38 -0
- regstack/models/user.py +104 -0
- regstack/routers/__init__.py +37 -0
- regstack/routers/_schemas.py +34 -0
- regstack/routers/account.py +274 -0
- regstack/routers/admin.py +187 -0
- regstack/routers/login.py +223 -0
- regstack/routers/logout.py +39 -0
- regstack/routers/password.py +114 -0
- regstack/routers/phone.py +242 -0
- regstack/routers/register.py +99 -0
- regstack/routers/verify.py +116 -0
- regstack/sms/__init__.py +5 -0
- regstack/sms/base.py +24 -0
- regstack/sms/factory.py +23 -0
- regstack/sms/null.py +26 -0
- regstack/sms/sns.py +42 -0
- regstack/sms/twilio.py +49 -0
- regstack/ui/__init__.py +3 -0
- regstack/ui/pages.py +148 -0
- regstack/ui/static/css/core.css +204 -0
- regstack/ui/static/css/theme.css +43 -0
- regstack/ui/static/js/regstack.js +411 -0
- regstack/ui/templates/auth/email_change_confirm.html +10 -0
- regstack/ui/templates/auth/forgot.html +14 -0
- regstack/ui/templates/auth/login.html +24 -0
- regstack/ui/templates/auth/me.html +110 -0
- regstack/ui/templates/auth/mfa_confirm.html +14 -0
- regstack/ui/templates/auth/register.html +23 -0
- regstack/ui/templates/auth/reset.html +13 -0
- regstack/ui/templates/auth/verify.html +10 -0
- regstack/ui/templates/base.html +46 -0
- regstack/version.py +1 -0
- regstack-0.1.0.dist-info/METADATA +209 -0
- regstack-0.1.0.dist-info/RECORD +92 -0
- regstack-0.1.0.dist-info/WHEEL +4 -0
- regstack-0.1.0.dist-info/entry_points.txt +2 -0
- regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
- regstack-0.1.0.dist-info/licenses/NOTICE +5 -0
regstack/cli/admin.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from regstack.cli._runtime import open_regstack
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command(
|
|
13
|
+
name="create-admin",
|
|
14
|
+
help="Create or promote a superuser. Idempotent — re-running flips an existing user to admin.",
|
|
15
|
+
)
|
|
16
|
+
@click.option("--email", required=True, help="Admin email address.")
|
|
17
|
+
@click.option(
|
|
18
|
+
"--password",
|
|
19
|
+
default=None,
|
|
20
|
+
help="Password. If omitted you will be prompted (with confirmation).",
|
|
21
|
+
)
|
|
22
|
+
@click.option(
|
|
23
|
+
"--config",
|
|
24
|
+
"toml_path",
|
|
25
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
26
|
+
default=None,
|
|
27
|
+
help="Path to regstack.toml (default: search cwd / $REGSTACK_CONFIG).",
|
|
28
|
+
)
|
|
29
|
+
def create_admin(email: str, password: str | None, toml_path: Path | None) -> None:
|
|
30
|
+
if password is None:
|
|
31
|
+
password = click.prompt("Password", hide_input=True, confirmation_prompt=True)
|
|
32
|
+
if len(password) < 8:
|
|
33
|
+
raise click.UsageError("Password must be at least 8 characters.")
|
|
34
|
+
|
|
35
|
+
asyncio.run(_run(email=email, password=password, toml_path=toml_path))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def _run(*, email: str, password: str, toml_path: Path | None) -> None:
|
|
39
|
+
async with open_regstack(toml_path) as rs:
|
|
40
|
+
user = await rs.bootstrap_admin(email, password)
|
|
41
|
+
verb = "promoted to admin" if user.is_superuser else "created"
|
|
42
|
+
click.echo(
|
|
43
|
+
click.style(f"User {user.email} {verb} (id={user.id}).", fg="green"),
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
regstack/cli/doctor.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import dns.resolver
|
|
10
|
+
from pymongo import AsyncMongoClient
|
|
11
|
+
|
|
12
|
+
from regstack.cli._runtime import load_runtime_config
|
|
13
|
+
from regstack.db.client import make_client
|
|
14
|
+
from regstack.email.factory import build_email_service
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class CheckResult:
|
|
19
|
+
name: str
|
|
20
|
+
ok: bool
|
|
21
|
+
detail: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command(
|
|
25
|
+
name="doctor",
|
|
26
|
+
help="Read-only validation of the loaded regstack configuration.",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--config",
|
|
30
|
+
"toml_path",
|
|
31
|
+
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
32
|
+
default=None,
|
|
33
|
+
help="Path to regstack.toml (default: search cwd / $REGSTACK_CONFIG).",
|
|
34
|
+
)
|
|
35
|
+
@click.option(
|
|
36
|
+
"--check-dns",
|
|
37
|
+
is_flag=True,
|
|
38
|
+
help="Run SPF / DKIM / MX lookups for the sender domain.",
|
|
39
|
+
)
|
|
40
|
+
@click.option(
|
|
41
|
+
"--send-test-email",
|
|
42
|
+
"test_recipient",
|
|
43
|
+
default=None,
|
|
44
|
+
help="Send a probe email to this address through the configured backend.",
|
|
45
|
+
)
|
|
46
|
+
def doctor(toml_path: Path | None, check_dns: bool, test_recipient: str | None) -> None:
|
|
47
|
+
results = asyncio.run(
|
|
48
|
+
_run(toml_path=toml_path, check_dns=check_dns, test_recipient=test_recipient)
|
|
49
|
+
)
|
|
50
|
+
failed = sum(1 for r in results if not r.ok)
|
|
51
|
+
for r in results:
|
|
52
|
+
symbol = click.style("✔", fg="green") if r.ok else click.style("✘", fg="red")
|
|
53
|
+
click.echo(f"{symbol} {r.name}: {r.detail}")
|
|
54
|
+
if failed:
|
|
55
|
+
click.echo(click.style(f"\n{failed} check(s) failed.", fg="red"), err=True)
|
|
56
|
+
sys.exit(failed)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _run(
|
|
60
|
+
*, toml_path: Path | None, check_dns: bool, test_recipient: str | None
|
|
61
|
+
) -> list[CheckResult]:
|
|
62
|
+
out: list[CheckResult] = []
|
|
63
|
+
|
|
64
|
+
config = load_runtime_config(toml_path)
|
|
65
|
+
|
|
66
|
+
secret_value = config.jwt_secret.get_secret_value()
|
|
67
|
+
if not secret_value:
|
|
68
|
+
out.append(CheckResult("jwt secret", False, "missing — run `regstack init`"))
|
|
69
|
+
elif len(secret_value) < 32:
|
|
70
|
+
out.append(
|
|
71
|
+
CheckResult("jwt secret", False, f"too short ({len(secret_value)} chars; need ≥32)")
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
out.append(CheckResult("jwt secret", True, f"present ({len(secret_value)} chars)"))
|
|
75
|
+
|
|
76
|
+
out.append(await _check_mongo(config))
|
|
77
|
+
|
|
78
|
+
out.append(await _check_indexes(config))
|
|
79
|
+
|
|
80
|
+
out.append(_check_email_factory(config))
|
|
81
|
+
|
|
82
|
+
if check_dns:
|
|
83
|
+
out.extend(_check_dns(config))
|
|
84
|
+
|
|
85
|
+
if test_recipient:
|
|
86
|
+
out.append(await _send_test_email(config, test_recipient))
|
|
87
|
+
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def _check_mongo(config) -> CheckResult:
|
|
92
|
+
client: AsyncMongoClient = make_client(config)
|
|
93
|
+
try:
|
|
94
|
+
await client.admin.command("ping")
|
|
95
|
+
names = await client.list_database_names()
|
|
96
|
+
present = config.mongodb_database in names
|
|
97
|
+
detail = (
|
|
98
|
+
f"reachable; database {config.mongodb_database!r} "
|
|
99
|
+
f"{'exists' if present else 'will be created on first use'}"
|
|
100
|
+
)
|
|
101
|
+
return CheckResult("mongodb", True, detail)
|
|
102
|
+
except Exception as exc:
|
|
103
|
+
return CheckResult("mongodb", False, f"unreachable: {exc}")
|
|
104
|
+
finally:
|
|
105
|
+
await client.aclose()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _check_indexes(config) -> CheckResult:
|
|
109
|
+
client: AsyncMongoClient = make_client(config)
|
|
110
|
+
try:
|
|
111
|
+
db = client[config.mongodb_database]
|
|
112
|
+
users_idx = await db[config.user_collection].index_information()
|
|
113
|
+
bl_idx = await db[config.blacklist_collection].index_information()
|
|
114
|
+
missing: list[str] = []
|
|
115
|
+
if "email_unique" not in users_idx:
|
|
116
|
+
missing.append(f"{config.user_collection}.email_unique")
|
|
117
|
+
if "jti_unique" not in bl_idx:
|
|
118
|
+
missing.append(f"{config.blacklist_collection}.jti_unique")
|
|
119
|
+
if missing:
|
|
120
|
+
return CheckResult(
|
|
121
|
+
"indexes", False, f"missing: {', '.join(missing)} (call install_indexes)"
|
|
122
|
+
)
|
|
123
|
+
return CheckResult("indexes", True, "core indexes present")
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
return CheckResult("indexes", False, f"check failed: {exc}")
|
|
126
|
+
finally:
|
|
127
|
+
await client.aclose()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _check_email_factory(config) -> CheckResult:
|
|
131
|
+
try:
|
|
132
|
+
service = build_email_service(config.email)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
return CheckResult(
|
|
135
|
+
"email backend", False, f"backend {config.email.backend!r} failed to instantiate: {exc}"
|
|
136
|
+
)
|
|
137
|
+
return CheckResult("email backend", True, f"{config.email.backend} → {type(service).__name__}")
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _check_dns(config) -> list[CheckResult]:
|
|
141
|
+
sender = config.email.from_address
|
|
142
|
+
try:
|
|
143
|
+
domain = sender.split("@", 1)[1]
|
|
144
|
+
except IndexError:
|
|
145
|
+
return [CheckResult("dns sender", False, f"invalid sender: {sender!r}")]
|
|
146
|
+
out: list[CheckResult] = []
|
|
147
|
+
out.append(_dig(domain, "MX", "dns mx"))
|
|
148
|
+
out.append(_dig(domain, "TXT", "dns spf", needle="v=spf1"))
|
|
149
|
+
out.append(_dig(f"_dmarc.{domain}", "TXT", "dns dmarc", needle="v=DMARC1"))
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _dig(name: str, rtype: str, label: str, *, needle: str | None = None) -> CheckResult:
|
|
154
|
+
try:
|
|
155
|
+
answers = dns.resolver.resolve(name, rtype, lifetime=5.0)
|
|
156
|
+
except dns.resolver.NXDOMAIN:
|
|
157
|
+
return CheckResult(label, False, f"{name} → NXDOMAIN")
|
|
158
|
+
except (dns.resolver.NoAnswer, dns.resolver.NoNameservers, dns.exception.DNSException) as exc:
|
|
159
|
+
return CheckResult(label, False, f"{name} → {exc}")
|
|
160
|
+
if needle is not None:
|
|
161
|
+
joined = "\n".join(str(rdata) for rdata in answers)
|
|
162
|
+
if needle not in joined:
|
|
163
|
+
return CheckResult(label, False, f"no {needle!r} record on {name}")
|
|
164
|
+
return CheckResult(label, True, f"{name} ok ({len(answers)} record(s))")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
async def _send_test_email(config, to: str) -> CheckResult:
|
|
168
|
+
from regstack.email.base import EmailMessage
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
service = build_email_service(config.email)
|
|
172
|
+
await service.send(
|
|
173
|
+
EmailMessage(
|
|
174
|
+
to=to,
|
|
175
|
+
subject=f"[{config.app_name}] regstack doctor probe",
|
|
176
|
+
html="<p>regstack doctor probe — if you can read this, your email backend works.</p>",
|
|
177
|
+
text="regstack doctor probe — if you can read this, your email backend works.",
|
|
178
|
+
from_address=config.email.from_address,
|
|
179
|
+
from_name=config.email.from_name,
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
return CheckResult(
|
|
183
|
+
"email send", True, f"probe delivered to {to} via {config.email.backend}"
|
|
184
|
+
)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
return CheckResult("email send", False, f"send failed: {exc}")
|
regstack/cli/init.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
|
|
9
|
+
from regstack.config.secrets import generate_secret
|
|
10
|
+
|
|
11
|
+
CONFIG_FILE = "regstack.toml"
|
|
12
|
+
SECRETS_FILE = "regstack.secrets.env"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command(help="Interactive wizard that writes regstack.toml + regstack.secrets.env.")
|
|
16
|
+
@click.option(
|
|
17
|
+
"--target",
|
|
18
|
+
"target_dir",
|
|
19
|
+
type=click.Path(file_okay=False, dir_okay=True, path_type=Path),
|
|
20
|
+
default=Path.cwd,
|
|
21
|
+
show_default="current directory",
|
|
22
|
+
help="Directory to write config files into.",
|
|
23
|
+
)
|
|
24
|
+
@click.option("--force", is_flag=True, help="Overwrite existing config files without prompting.")
|
|
25
|
+
def init(target_dir: Path, *, force: bool) -> None:
|
|
26
|
+
target_dir = Path(target_dir).resolve()
|
|
27
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
|
|
29
|
+
config_path = target_dir / CONFIG_FILE
|
|
30
|
+
secrets_path = target_dir / SECRETS_FILE
|
|
31
|
+
|
|
32
|
+
if (config_path.exists() or secrets_path.exists()) and not force:
|
|
33
|
+
click.confirm(
|
|
34
|
+
f"Config already exists at {config_path} or {secrets_path}. Overwrite?",
|
|
35
|
+
abort=True,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
click.echo(click.style("regstack init — app configuration only.\n", bold=True))
|
|
39
|
+
click.echo("This wizard never provisions infrastructure. It only writes config files.\n")
|
|
40
|
+
|
|
41
|
+
# --- App identity ---
|
|
42
|
+
app_name = click.prompt("App name", default="MyApp")
|
|
43
|
+
base_url = click.prompt("Public base URL", default="http://localhost:8000")
|
|
44
|
+
parsed = urlsplit(base_url)
|
|
45
|
+
cookie_default = parsed.hostname or ""
|
|
46
|
+
cookie_domain = click.prompt(
|
|
47
|
+
"Cookie domain (blank for none)", default=cookie_default, show_default=True
|
|
48
|
+
)
|
|
49
|
+
behind_proxy = click.confirm("Behind a reverse proxy (X-Forwarded-* headers)?", default=False)
|
|
50
|
+
|
|
51
|
+
# --- MongoDB ---
|
|
52
|
+
mongodb_url = click.prompt("MongoDB connection URL", default="mongodb://localhost:27017")
|
|
53
|
+
mongodb_database = click.prompt("MongoDB database", default=app_name.lower().replace(" ", "_"))
|
|
54
|
+
|
|
55
|
+
# --- JWT ---
|
|
56
|
+
if click.confirm("Auto-generate a 64-byte JWT secret?", default=True):
|
|
57
|
+
jwt_secret = generate_secret(64)
|
|
58
|
+
else:
|
|
59
|
+
jwt_secret = click.prompt("JWT secret", hide_input=True, confirmation_prompt=True)
|
|
60
|
+
jwt_ttl_seconds = int(click.prompt("JWT lifetime in seconds", default=7200, type=int))
|
|
61
|
+
transport = click.prompt(
|
|
62
|
+
"Token transport", type=click.Choice(["bearer", "cookie"]), default="bearer"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# --- Email ---
|
|
66
|
+
email_backend = click.prompt(
|
|
67
|
+
"Email backend",
|
|
68
|
+
type=click.Choice(["console", "smtp", "ses"]),
|
|
69
|
+
default="console",
|
|
70
|
+
)
|
|
71
|
+
if email_backend != "console":
|
|
72
|
+
click.echo(
|
|
73
|
+
click.style(
|
|
74
|
+
f"Note: {email_backend!r} backend lands in M2; the wizard will write your "
|
|
75
|
+
"config, but the running app will refuse to send mail until then.",
|
|
76
|
+
fg="yellow",
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
sender_default = cookie_default if cookie_default and "." in cookie_default else "example.com"
|
|
80
|
+
from_address = click.prompt("Sender email address", default=f"noreply@{sender_default}")
|
|
81
|
+
from_name = click.prompt("Sender display name", default=app_name)
|
|
82
|
+
smtp_host = smtp_port = smtp_user = smtp_pass = ses_region = None
|
|
83
|
+
smtp_starttls = True
|
|
84
|
+
if email_backend == "smtp":
|
|
85
|
+
smtp_host = click.prompt("SMTP host")
|
|
86
|
+
smtp_port = int(click.prompt("SMTP port", default=587, type=int))
|
|
87
|
+
smtp_starttls = click.confirm("Use STARTTLS?", default=True)
|
|
88
|
+
smtp_user = click.prompt("SMTP username", default="")
|
|
89
|
+
smtp_pass = click.prompt("SMTP password", default="", hide_input=True) or None
|
|
90
|
+
elif email_backend == "ses":
|
|
91
|
+
ses_region = click.prompt("AWS region", default="eu-west-1")
|
|
92
|
+
|
|
93
|
+
# --- SMS (skip unless 2FA wanted) ---
|
|
94
|
+
enable_sms_2fa = click.confirm("Enable SMS-based 2FA?", default=False)
|
|
95
|
+
sms_backend = "null"
|
|
96
|
+
if enable_sms_2fa:
|
|
97
|
+
sms_backend = click.prompt(
|
|
98
|
+
"SMS backend", type=click.Choice(["sns", "twilio"]), default="sns"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# --- Features ---
|
|
102
|
+
enable_admin_router = click.confirm("Enable JSON admin router?", default=False)
|
|
103
|
+
enable_ui_router = click.confirm("Enable server-rendered UI pages?", default=False)
|
|
104
|
+
allow_registration = click.confirm("Allow self-service registration?", default=True)
|
|
105
|
+
require_verification = click.confirm("Require email verification before login?", default=False)
|
|
106
|
+
|
|
107
|
+
config_text = _render_toml(
|
|
108
|
+
app_name=app_name,
|
|
109
|
+
base_url=base_url,
|
|
110
|
+
cookie_domain=cookie_domain or None,
|
|
111
|
+
behind_proxy=behind_proxy,
|
|
112
|
+
mongodb_database=mongodb_database,
|
|
113
|
+
jwt_ttl_seconds=jwt_ttl_seconds,
|
|
114
|
+
transport=transport,
|
|
115
|
+
require_verification=require_verification,
|
|
116
|
+
allow_registration=allow_registration,
|
|
117
|
+
enable_admin_router=enable_admin_router,
|
|
118
|
+
enable_ui_router=enable_ui_router,
|
|
119
|
+
enable_sms_2fa=enable_sms_2fa,
|
|
120
|
+
email_backend=email_backend,
|
|
121
|
+
from_address=from_address,
|
|
122
|
+
from_name=from_name,
|
|
123
|
+
smtp_host=smtp_host,
|
|
124
|
+
smtp_port=smtp_port,
|
|
125
|
+
smtp_starttls=smtp_starttls,
|
|
126
|
+
smtp_username=smtp_user,
|
|
127
|
+
ses_region=ses_region,
|
|
128
|
+
sms_backend=sms_backend,
|
|
129
|
+
)
|
|
130
|
+
secrets_text = _render_secrets(
|
|
131
|
+
jwt_secret=jwt_secret,
|
|
132
|
+
mongodb_url=mongodb_url,
|
|
133
|
+
smtp_password=smtp_pass,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
config_path.write_text(config_text, encoding="utf-8")
|
|
137
|
+
secrets_path.write_text(secrets_text, encoding="utf-8")
|
|
138
|
+
secrets_path.chmod(0o600)
|
|
139
|
+
|
|
140
|
+
click.echo()
|
|
141
|
+
click.echo(click.style("Wrote ", fg="green") + str(config_path))
|
|
142
|
+
click.echo(click.style("Wrote ", fg="green") + str(secrets_path) + " (chmod 600)")
|
|
143
|
+
click.echo()
|
|
144
|
+
click.echo(
|
|
145
|
+
"Next: load the config in your app with `RegStackConfig.load()` and "
|
|
146
|
+
"include `regstack.router` on a FastAPI app."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _render_toml(
|
|
151
|
+
*,
|
|
152
|
+
app_name: str,
|
|
153
|
+
base_url: str,
|
|
154
|
+
cookie_domain: str | None,
|
|
155
|
+
behind_proxy: bool,
|
|
156
|
+
mongodb_database: str,
|
|
157
|
+
jwt_ttl_seconds: int,
|
|
158
|
+
transport: str,
|
|
159
|
+
require_verification: bool,
|
|
160
|
+
allow_registration: bool,
|
|
161
|
+
enable_admin_router: bool,
|
|
162
|
+
enable_ui_router: bool,
|
|
163
|
+
enable_sms_2fa: bool,
|
|
164
|
+
email_backend: str,
|
|
165
|
+
from_address: str,
|
|
166
|
+
from_name: str,
|
|
167
|
+
smtp_host: str | None,
|
|
168
|
+
smtp_port: int | None,
|
|
169
|
+
smtp_starttls: bool,
|
|
170
|
+
smtp_username: str | None,
|
|
171
|
+
ses_region: str | None,
|
|
172
|
+
sms_backend: str,
|
|
173
|
+
) -> str:
|
|
174
|
+
lines = [
|
|
175
|
+
"# regstack.toml — generated by `regstack init`. Re-run to regenerate.",
|
|
176
|
+
f'app_name = "{app_name}"',
|
|
177
|
+
f'base_url = "{base_url}"',
|
|
178
|
+
]
|
|
179
|
+
if cookie_domain:
|
|
180
|
+
lines.append(f'cookie_domain = "{cookie_domain}"')
|
|
181
|
+
lines.extend(
|
|
182
|
+
[
|
|
183
|
+
f"behind_proxy = {str(behind_proxy).lower()}",
|
|
184
|
+
"",
|
|
185
|
+
f'mongodb_database = "{mongodb_database}"',
|
|
186
|
+
"",
|
|
187
|
+
f"jwt_ttl_seconds = {jwt_ttl_seconds}",
|
|
188
|
+
f'transport = "{transport}"',
|
|
189
|
+
"",
|
|
190
|
+
f"require_verification = {str(require_verification).lower()}",
|
|
191
|
+
f"allow_registration = {str(allow_registration).lower()}",
|
|
192
|
+
f"enable_admin_router = {str(enable_admin_router).lower()}",
|
|
193
|
+
f"enable_ui_router = {str(enable_ui_router).lower()}",
|
|
194
|
+
f"enable_sms_2fa = {str(enable_sms_2fa).lower()}",
|
|
195
|
+
"",
|
|
196
|
+
"[email]",
|
|
197
|
+
f'backend = "{email_backend}"',
|
|
198
|
+
f'from_address = "{from_address}"',
|
|
199
|
+
f'from_name = "{from_name}"',
|
|
200
|
+
]
|
|
201
|
+
)
|
|
202
|
+
if email_backend == "smtp":
|
|
203
|
+
lines.extend(
|
|
204
|
+
[
|
|
205
|
+
f'smtp_host = "{smtp_host}"',
|
|
206
|
+
f"smtp_port = {smtp_port}",
|
|
207
|
+
f"smtp_starttls = {str(smtp_starttls).lower()}",
|
|
208
|
+
]
|
|
209
|
+
)
|
|
210
|
+
if smtp_username:
|
|
211
|
+
lines.append(f'smtp_username = "{smtp_username}"')
|
|
212
|
+
elif email_backend == "ses":
|
|
213
|
+
lines.append(f'ses_region = "{ses_region}"')
|
|
214
|
+
lines.extend(["", "[sms]", f'backend = "{sms_backend}"', ""])
|
|
215
|
+
return "\n".join(lines)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _render_secrets(
|
|
219
|
+
*,
|
|
220
|
+
jwt_secret: str,
|
|
221
|
+
mongodb_url: str,
|
|
222
|
+
smtp_password: str | None,
|
|
223
|
+
) -> str:
|
|
224
|
+
lines = [
|
|
225
|
+
"# regstack.secrets.env — keep out of version control.",
|
|
226
|
+
f"REGSTACK_JWT_SECRET={jwt_secret}",
|
|
227
|
+
f"REGSTACK_MONGODB_URL={mongodb_url}",
|
|
228
|
+
]
|
|
229
|
+
if smtp_password:
|
|
230
|
+
lines.append(f"REGSTACK_EMAIL__SMTP_PASSWORD={smtp_password}")
|
|
231
|
+
return "\n".join(lines) + "\n"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
if __name__ == "__main__":
|
|
235
|
+
init(standalone_mode=True)
|
|
236
|
+
sys.exit(0)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from regstack.config.schema import RegStackConfig
|
|
10
|
+
|
|
11
|
+
_DEFAULT_TOML_NAMES = ("regstack.toml",)
|
|
12
|
+
_DEFAULT_SECRETS_NAMES = ("regstack.secrets.env",)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _find_first(names: tuple[str, ...]) -> Path | None:
|
|
16
|
+
cwd = Path.cwd()
|
|
17
|
+
for name in names:
|
|
18
|
+
candidate = cwd / name
|
|
19
|
+
if candidate.is_file():
|
|
20
|
+
return candidate
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _read_toml(path: Path) -> dict[str, Any]:
|
|
25
|
+
with path.open("rb") as fh:
|
|
26
|
+
return tomllib.load(fh)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _parse_dotenv(path: Path) -> dict[str, str]:
|
|
30
|
+
"""Minimal .env parser — `KEY=value` per line, no shell features."""
|
|
31
|
+
out: dict[str, str] = {}
|
|
32
|
+
for raw in path.read_text().splitlines():
|
|
33
|
+
line = raw.strip()
|
|
34
|
+
if not line or line.startswith("#"):
|
|
35
|
+
continue
|
|
36
|
+
if "=" not in line:
|
|
37
|
+
continue
|
|
38
|
+
key, _, value = line.partition("=")
|
|
39
|
+
key = key.strip()
|
|
40
|
+
value = value.strip()
|
|
41
|
+
if (value.startswith('"') and value.endswith('"')) or (
|
|
42
|
+
value.startswith("'") and value.endswith("'")
|
|
43
|
+
):
|
|
44
|
+
value = value[1:-1]
|
|
45
|
+
out[key] = value
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _flatten_for_env(d: Mapping[str, Any], prefix: str = "REGSTACK_") -> dict[str, str]:
|
|
50
|
+
out: dict[str, str] = {}
|
|
51
|
+
for key, value in d.items():
|
|
52
|
+
full_key = f"{prefix}{key.upper()}"
|
|
53
|
+
if isinstance(value, Mapping):
|
|
54
|
+
out.update(_flatten_for_env(value, prefix=f"{full_key}__"))
|
|
55
|
+
elif isinstance(value, list):
|
|
56
|
+
out[full_key] = ",".join(str(item) for item in value)
|
|
57
|
+
elif isinstance(value, bool):
|
|
58
|
+
out[full_key] = "true" if value else "false"
|
|
59
|
+
elif value is None:
|
|
60
|
+
continue
|
|
61
|
+
else:
|
|
62
|
+
out[full_key] = str(value)
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_config(
|
|
67
|
+
toml_path: Path | str | None = None,
|
|
68
|
+
secrets_env_path: Path | str | None = None,
|
|
69
|
+
**overrides: object,
|
|
70
|
+
) -> RegStackConfig:
|
|
71
|
+
"""Build a ``RegStackConfig`` by merging defaults, TOML, env, and kwargs.
|
|
72
|
+
|
|
73
|
+
Highest priority wins:
|
|
74
|
+
kwargs > os.environ > secrets.env > TOML > defaults.
|
|
75
|
+
"""
|
|
76
|
+
env_overlay: dict[str, str] = {}
|
|
77
|
+
|
|
78
|
+
toml_candidate: Path | None
|
|
79
|
+
if toml_path is not None:
|
|
80
|
+
toml_candidate = Path(toml_path)
|
|
81
|
+
elif (env_path := os.environ.get("REGSTACK_CONFIG")) is not None:
|
|
82
|
+
toml_candidate = Path(env_path)
|
|
83
|
+
else:
|
|
84
|
+
toml_candidate = _find_first(_DEFAULT_TOML_NAMES)
|
|
85
|
+
if toml_candidate is not None and toml_candidate.is_file():
|
|
86
|
+
env_overlay.update(_flatten_for_env(_read_toml(toml_candidate)))
|
|
87
|
+
|
|
88
|
+
secrets_candidate: Path | None
|
|
89
|
+
if secrets_env_path is not None:
|
|
90
|
+
secrets_candidate = Path(secrets_env_path)
|
|
91
|
+
else:
|
|
92
|
+
secrets_candidate = _find_first(_DEFAULT_SECRETS_NAMES)
|
|
93
|
+
if secrets_candidate is not None and secrets_candidate.is_file():
|
|
94
|
+
env_overlay.update(_parse_dotenv(secrets_candidate))
|
|
95
|
+
|
|
96
|
+
# Real environment wins over TOML and secrets file.
|
|
97
|
+
for key, value in os.environ.items():
|
|
98
|
+
if key.startswith("REGSTACK_"):
|
|
99
|
+
env_overlay[key] = value
|
|
100
|
+
|
|
101
|
+
# pydantic-settings reads from os.environ; we apply our merged overlay
|
|
102
|
+
# by patching it for the duration of construction.
|
|
103
|
+
saved: dict[str, str | None] = {}
|
|
104
|
+
try:
|
|
105
|
+
for key, value in env_overlay.items():
|
|
106
|
+
saved[key] = os.environ.get(key)
|
|
107
|
+
os.environ[key] = value
|
|
108
|
+
return RegStackConfig(**overrides) # type: ignore[arg-type]
|
|
109
|
+
finally:
|
|
110
|
+
for key, prev in saved.items():
|
|
111
|
+
if prev is None:
|
|
112
|
+
os.environ.pop(key, None)
|
|
113
|
+
else:
|
|
114
|
+
os.environ[key] = prev
|