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.
Files changed (92) hide show
  1. regstack/__init__.py +5 -0
  2. regstack/app.py +150 -0
  3. regstack/auth/__init__.py +21 -0
  4. regstack/auth/clock.py +29 -0
  5. regstack/auth/dependencies.py +102 -0
  6. regstack/auth/jwt.py +145 -0
  7. regstack/auth/lockout.py +59 -0
  8. regstack/auth/mfa.py +29 -0
  9. regstack/auth/password.py +20 -0
  10. regstack/auth/tokens.py +19 -0
  11. regstack/cli/__init__.py +0 -0
  12. regstack/cli/__main__.py +27 -0
  13. regstack/cli/_runtime.py +39 -0
  14. regstack/cli/admin.py +45 -0
  15. regstack/cli/doctor.py +186 -0
  16. regstack/cli/init.py +236 -0
  17. regstack/config/__init__.py +4 -0
  18. regstack/config/loader.py +114 -0
  19. regstack/config/schema.py +148 -0
  20. regstack/config/secrets.py +22 -0
  21. regstack/db/__init__.py +17 -0
  22. regstack/db/client.py +26 -0
  23. regstack/db/indexes.py +70 -0
  24. regstack/db/repositories/__init__.py +0 -0
  25. regstack/db/repositories/blacklist_repo.py +28 -0
  26. regstack/db/repositories/login_attempt_repo.py +27 -0
  27. regstack/db/repositories/mfa_code_repo.py +99 -0
  28. regstack/db/repositories/pending_repo.py +76 -0
  29. regstack/db/repositories/user_repo.py +169 -0
  30. regstack/email/__init__.py +12 -0
  31. regstack/email/base.py +23 -0
  32. regstack/email/composer.py +142 -0
  33. regstack/email/console.py +28 -0
  34. regstack/email/factory.py +23 -0
  35. regstack/email/ses.py +47 -0
  36. regstack/email/smtp.py +46 -0
  37. regstack/email/templates/email_change.html +15 -0
  38. regstack/email/templates/email_change.subject.txt +1 -0
  39. regstack/email/templates/email_change.txt +7 -0
  40. regstack/email/templates/password_reset.html +15 -0
  41. regstack/email/templates/password_reset.subject.txt +1 -0
  42. regstack/email/templates/password_reset.txt +7 -0
  43. regstack/email/templates/sms_login_mfa.txt +1 -0
  44. regstack/email/templates/sms_phone_setup.txt +1 -0
  45. regstack/email/templates/verification.html +15 -0
  46. regstack/email/templates/verification.subject.txt +1 -0
  47. regstack/email/templates/verification.txt +7 -0
  48. regstack/hooks/__init__.py +3 -0
  49. regstack/hooks/events.py +59 -0
  50. regstack/models/__init__.py +15 -0
  51. regstack/models/_objectid.py +30 -0
  52. regstack/models/login_attempt.py +31 -0
  53. regstack/models/mfa_code.py +40 -0
  54. regstack/models/pending_registration.py +38 -0
  55. regstack/models/user.py +104 -0
  56. regstack/routers/__init__.py +37 -0
  57. regstack/routers/_schemas.py +34 -0
  58. regstack/routers/account.py +274 -0
  59. regstack/routers/admin.py +187 -0
  60. regstack/routers/login.py +223 -0
  61. regstack/routers/logout.py +39 -0
  62. regstack/routers/password.py +114 -0
  63. regstack/routers/phone.py +242 -0
  64. regstack/routers/register.py +99 -0
  65. regstack/routers/verify.py +116 -0
  66. regstack/sms/__init__.py +5 -0
  67. regstack/sms/base.py +24 -0
  68. regstack/sms/factory.py +23 -0
  69. regstack/sms/null.py +26 -0
  70. regstack/sms/sns.py +42 -0
  71. regstack/sms/twilio.py +49 -0
  72. regstack/ui/__init__.py +3 -0
  73. regstack/ui/pages.py +148 -0
  74. regstack/ui/static/css/core.css +204 -0
  75. regstack/ui/static/css/theme.css +43 -0
  76. regstack/ui/static/js/regstack.js +411 -0
  77. regstack/ui/templates/auth/email_change_confirm.html +10 -0
  78. regstack/ui/templates/auth/forgot.html +14 -0
  79. regstack/ui/templates/auth/login.html +24 -0
  80. regstack/ui/templates/auth/me.html +110 -0
  81. regstack/ui/templates/auth/mfa_confirm.html +14 -0
  82. regstack/ui/templates/auth/register.html +23 -0
  83. regstack/ui/templates/auth/reset.html +13 -0
  84. regstack/ui/templates/auth/verify.html +10 -0
  85. regstack/ui/templates/base.html +46 -0
  86. regstack/version.py +1 -0
  87. regstack-0.1.0.dist-info/METADATA +209 -0
  88. regstack-0.1.0.dist-info/RECORD +92 -0
  89. regstack-0.1.0.dist-info/WHEEL +4 -0
  90. regstack-0.1.0.dist-info/entry_points.txt +2 -0
  91. regstack-0.1.0.dist-info/licenses/LICENSE +202 -0
  92. 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,4 @@
1
+ from regstack.config.loader import load_config
2
+ from regstack.config.schema import EmailConfig, RegStackConfig, SmsConfig
3
+
4
+ __all__ = ["EmailConfig", "RegStackConfig", "SmsConfig", "load_config"]
@@ -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