simple-module-settings 0.0.32__tar.gz → 0.0.33__tar.gz

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 (95) hide show
  1. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/PKG-INFO +4 -4
  2. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/package.json +3 -1
  3. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/pyproject.toml +4 -4
  4. simple_module_settings-0.0.33/settings/_field_meta.py +81 -0
  5. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/_module_settings.py +79 -106
  6. simple_module_settings-0.0.33/settings/_module_settings_props.py +56 -0
  7. simple_module_settings-0.0.33/settings/_secrets.py +60 -0
  8. simple_module_settings-0.0.33/settings/browse_query.py +49 -0
  9. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/constants.py +29 -1
  10. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/endpoints/module_api.py +24 -4
  11. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/endpoints/views.py +61 -44
  12. simple_module_settings-0.0.33/settings/known_keys.py +83 -0
  13. simple_module_settings-0.0.33/settings/locales/en.json +141 -0
  14. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/module.py +32 -1
  15. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/module_registry.py +19 -2
  16. simple_module_settings-0.0.33/settings/pages/Browse.tsx +171 -0
  17. simple_module_settings-0.0.33/settings/pages/Create.tsx +163 -0
  18. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/pages/Edit.tsx +6 -22
  19. simple_module_settings-0.0.33/settings/pages/ModulesEdit.tsx +147 -0
  20. simple_module_settings-0.0.33/settings/pages/components/FieldInput.tsx +152 -0
  21. simple_module_settings-0.0.33/settings/pages/components/FieldSource.tsx +77 -0
  22. simple_module_settings-0.0.33/settings/pages/components/KeyField.tsx +144 -0
  23. simple_module_settings-0.0.33/settings/pages/components/ModuleFieldRow.tsx +69 -0
  24. simple_module_settings-0.0.33/settings/pages/components/ModuleForm.tsx +179 -0
  25. simple_module_settings-0.0.33/settings/pages/components/ResolvedValue.tsx +89 -0
  26. simple_module_settings-0.0.33/settings/pages/components/ScopeTabs.tsx +37 -0
  27. simple_module_settings-0.0.33/settings/pages/components/StoreCards.tsx +93 -0
  28. simple_module_settings-0.0.33/settings/pages/components/StoreTable.tsx +117 -0
  29. simple_module_settings-0.0.33/settings/pages/components/TestConnectionButton.tsx +122 -0
  30. simple_module_settings-0.0.33/settings/pages/components/ValueInput.tsx +87 -0
  31. simple_module_settings-0.0.33/settings/pages/components/last-test.ts +48 -0
  32. simple_module_settings-0.0.33/settings/pages/routes.ts +10 -0
  33. simple_module_settings-0.0.33/settings/pages/types.ts +25 -0
  34. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/registration.py +8 -2
  35. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/reload.py +7 -2
  36. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/service.py +121 -14
  37. simple_module_settings-0.0.33/settings/settings.py +15 -0
  38. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_cli_import.py +8 -0
  39. simple_module_settings-0.0.33/tests/test_known_keys_meta.py +214 -0
  40. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_api.py +22 -0
  41. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_settings.py +1 -0
  42. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_settings_render.py +55 -3
  43. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_settings_serialize.py +1 -1
  44. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_field_sources.py +55 -4
  45. simple_module_settings-0.0.33/tests/test_settings_secret_masking.py +186 -0
  46. simple_module_settings-0.0.33/tests/test_settings_secret_urls.py +119 -0
  47. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_view_authz.py +2 -2
  48. simple_module_settings-0.0.33/tests/test_store_filters.py +200 -0
  49. simple_module_settings-0.0.33/tests/test_testable_checks.py +65 -0
  50. simple_module_settings-0.0.33/tests-js/ModuleForm.test.tsx +237 -0
  51. simple_module_settings-0.0.33/tests-js/ScopeTabs.test.tsx +67 -0
  52. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tsconfig.json +1 -1
  53. simple_module_settings-0.0.32/settings/locales/en.json +0 -82
  54. simple_module_settings-0.0.32/settings/pages/Browse.tsx +0 -157
  55. simple_module_settings-0.0.32/settings/pages/Create.tsx +0 -164
  56. simple_module_settings-0.0.32/settings/pages/ModulesEdit.tsx +0 -113
  57. simple_module_settings-0.0.32/settings/pages/components/FieldInput.tsx +0 -102
  58. simple_module_settings-0.0.32/settings/pages/components/FieldSource.tsx +0 -59
  59. simple_module_settings-0.0.32/settings/pages/components/KeyField.tsx +0 -104
  60. simple_module_settings-0.0.32/settings/pages/components/ModuleForm.tsx +0 -167
  61. simple_module_settings-0.0.32/settings/pages/components/TestConnectionButton.tsx +0 -70
  62. simple_module_settings-0.0.32/settings/pages/components/ValueInput.tsx +0 -92
  63. simple_module_settings-0.0.32/settings/pages/routes.ts +0 -10
  64. simple_module_settings-0.0.32/settings/settings.py +0 -11
  65. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/.gitignore +0 -0
  66. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/LICENSE +0 -0
  67. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/README.md +0 -0
  68. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/__init__.py +0 -0
  69. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/cli.py +0 -0
  70. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/__init__.py +0 -0
  71. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/accessor.py +0 -0
  72. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/events.py +0 -0
  73. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/registry.py +0 -0
  74. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/contracts/schemas.py +0 -0
  75. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/deps.py +0 -0
  76. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/endpoints/__init__.py +0 -0
  77. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/endpoints/api.py +0 -0
  78. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/env_vars.py +0 -0
  79. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/hydrate.py +0 -0
  80. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/models.py +0 -0
  81. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/py.typed +0 -0
  82. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/services.py +0 -0
  83. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/settings/store.py +0 -0
  84. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_concurrent_overrides.py +0 -0
  85. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_hydrate.py +0 -0
  86. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_module_registry.py +0 -0
  87. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_registration.py +0 -0
  88. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_reload.py +0 -0
  89. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_accessor.py +0 -0
  90. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_api.py +0 -0
  91. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_events.py +0 -0
  92. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_module.py +0 -0
  93. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_schemas.py +0 -0
  94. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_settings_service.py +0 -0
  95. {simple_module_settings-0.0.32 → simple_module_settings-0.0.33}/tests/test_store.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.32
3
+ Version: 0.0.33
4
4
  Summary: Runtime settings UI — modules plug their own settings panels into a shared admin view
5
5
  Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
6
  Project-URL: Repository, https://github.com/antosubash/simple_module_python
@@ -21,9 +21,9 @@ Classifier: Topic :: Internet :: WWW/HTTP
21
21
  Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.12
24
- Requires-Dist: simple-module-core==0.0.32
25
- Requires-Dist: simple-module-db==0.0.32
26
- Requires-Dist: simple-module-hosting==0.0.32
24
+ Requires-Dist: simple-module-core==0.0.33
25
+ Requires-Dist: simple-module-db==0.0.33
26
+ Requires-Dist: simple-module-hosting==0.0.33
27
27
  Requires-Dist: typer>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -12,5 +12,7 @@
12
12
  "devDependencies": {
13
13
  "@simple-module-py/tsconfig": "*"
14
14
  },
15
- "dependencies": {}
15
+ "dependencies": {
16
+ "sonner": "^2.0.7"
17
+ }
16
18
  }
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.32"
3
+ version = "0.0.33"
4
4
  description = "Runtime settings UI — modules plug their own settings panels into a shared admin view"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -21,9 +21,9 @@ classifiers = [
21
21
  "Typing :: Typed",
22
22
  ]
23
23
  dependencies = [
24
- "simple_module_core==0.0.32",
25
- "simple_module_db==0.0.32",
26
- "simple_module_hosting==0.0.32",
24
+ "simple_module_core==0.0.33",
25
+ "simple_module_db==0.0.33",
26
+ "simple_module_hosting==0.0.33",
27
27
  "typer>=0.12",
28
28
  ]
29
29
 
@@ -0,0 +1,81 @@
1
+ """Pure metadata readers for a single pydantic settings field.
2
+
3
+ Split out of ``_module_settings`` so that file keeps one job — walking the app
4
+ for settings classes — while the fiddly "what did pydantic actually record for
5
+ this field?" questions live together and are testable without a FastAPI app.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import Any
12
+
13
+ from pydantic_settings import BaseSettings
14
+
15
+ # ``^(console|smtp)$`` and nothing more adventurous: an alternation of literals
16
+ # anchored at both ends is the only pattern shape that maps cleanly onto a
17
+ # select. Anything else (character classes, quantifiers, sub-groups) stays a
18
+ # free-text input rather than becoming a list that silently omits valid values.
19
+ _ENUM_PATTERN = re.compile(r"^\^\(([^()|]+(?:\|[^()|]+)+)\)\$$")
20
+
21
+
22
+ def resolve_default(info) -> Any:
23
+ """Return the effective default, handling ``default_factory`` fields.
24
+
25
+ Pydantic sets ``info.default`` to ``PydanticUndefined`` when
26
+ ``default_factory`` is used. We call the factory to get the concrete
27
+ default so the settings UI can serialize it.
28
+ """
29
+ from pydantic_core import PydanticUndefined
30
+
31
+ if info.default is not PydanticUndefined:
32
+ return info.default
33
+ if info.default_factory is not None:
34
+ return info.default_factory()
35
+ return None
36
+
37
+
38
+ def env_readable_var(settings: BaseSettings, name: str) -> str | None:
39
+ """Env var pydantic would actually read for ``name``, or ``None``.
40
+
41
+ ``env_var`` on the view is a *label* — the ``SM_<PACKAGE>_<FIELD>`` name the
42
+ ``smpy settings import-from-env`` CLI looks for, kept from before settings
43
+ moved into the DB. It is not evidence that pydantic reads it: the bundled
44
+ module settings classes subclass ``DbBackedSettings``, which drops the env
45
+ source entirely, so ``SM_FILE_STORAGE_BACKEND`` has no effect on
46
+ ``FileStorageSettings()``. (Until GH #283 those classes subclassed
47
+ ``BaseSettings`` and merely omitted ``env_prefix``, which left pydantic
48
+ reading each field from its *bare* name instead of not at all.) Deriving
49
+ env-readability from the class's own ``env_prefix`` keeps the "From
50
+ environment" badge honest, and works as-is for the classes that do declare
51
+ one — the host's ``Settings`` (``SM_``), ``BackgroundTasksSettings``
52
+ (``SM_BG_TASKS_``), and every module built from the scaffold, whose
53
+ template ships ``env_prefix="SM_<PACKAGE>_"``.
54
+ """
55
+ env_prefix = str(type(settings).model_config.get("env_prefix") or "")
56
+ if not env_prefix:
57
+ return None
58
+ return f"{env_prefix}{name.upper()}"
59
+
60
+
61
+ def choices_for(info) -> list[str] | None:
62
+ """The closed set of values this field accepts, or ``None`` if open.
63
+
64
+ Read from an explicit ``json_schema_extra={"choices": [...]}`` first, then
65
+ inferred from a simple alternation ``pattern``. A field constrained to
66
+ ``console`` or ``smtp`` renders as a select; without this it is a text box
67
+ whose only feedback on a typo is a 422 after Save.
68
+ """
69
+ extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
70
+ declared = extra.get("choices")
71
+ if isinstance(declared, (list, tuple)) and declared:
72
+ return [str(choice) for choice in declared]
73
+
74
+ for constraint in info.metadata:
75
+ pattern = getattr(constraint, "pattern", None)
76
+ if not isinstance(pattern, str):
77
+ continue
78
+ match = _ENUM_PATTERN.match(pattern)
79
+ if match:
80
+ return match.group(1).split("|")
81
+ return None
@@ -7,31 +7,26 @@ Each module stores a services dataclass on ``app.state.<package>`` whose
7
7
  from __future__ import annotations
8
8
 
9
9
  import os
10
- import re
11
10
  from dataclasses import dataclass
12
11
  from typing import Any
13
12
 
14
13
  from fastapi import FastAPI
15
- from fastapi.encoders import jsonable_encoder
16
14
  from pydantic_settings import BaseSettings
17
15
 
16
+ from settings._field_meta import choices_for, env_readable_var, resolve_default
17
+
18
+ # Re-exported: callers reach for ``SECRET_MASK`` / ``is_secret_field`` here.
19
+ from settings._secrets import ( # noqa: F401
20
+ _NEVER_SECRET_TYPES,
21
+ SECRET_MASK,
22
+ embeds_credential,
23
+ is_secret_field,
24
+ mask,
25
+ )
18
26
  from settings.env_vars import env_prefix_for
19
27
  from settings.hydrate import value_type_for_field
20
28
  from settings.service import SettingService
21
29
 
22
- # We intentionally DON'T match the bare word "token" (would mask
23
- # `verification_token_lifetime_seconds` — just an int) or "key" alone (would
24
- # mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
25
- _SECRET_PATTERNS = re.compile(
26
- r"(password|secret|api[_-]?key|private[_-]?key|token[_-]?secret)", re.I
27
- )
28
- SECRET_MASK = "••••••••"
29
-
30
-
31
- def is_secret_field(name: str) -> bool:
32
- """True if a field name suggests it holds credential material."""
33
- return bool(_SECRET_PATTERNS.search(name))
34
-
35
30
 
36
31
  @dataclass(frozen=True, slots=True)
37
32
  class ModuleSettingField:
@@ -58,6 +53,22 @@ class ModuleSettingField:
58
53
  """
59
54
  db_override: bool = False
60
55
  """A stored setting overrides this field."""
56
+ env_readable: bool = False
57
+ """The declaring class reads env at all, i.e. it declares an ``env_prefix``.
58
+
59
+ :attr:`env_set` collapses two very different answers into one ``False``:
60
+ "there is no env fallback for this field" and "there is one and nobody set
61
+ it". The Resolved value panel has to tell them apart, or it labels a row
62
+ "env fallback" for a class that will never look at the environment.
63
+ """
64
+ env_value: str | None = None
65
+ """The env var's current contents, masked for secrets, or ``None``.
66
+
67
+ Only populated when the field is genuinely env-readable *and* set — it is
68
+ the fallback the panel shows, not a second copy of the live value.
69
+ """
70
+ choices: list[str] | None = None
71
+ """Closed set of accepted values, or ``None`` when the field is free text."""
61
72
 
62
73
  @property
63
74
  def source(self) -> str:
@@ -84,12 +95,9 @@ class ModuleSettingsView:
84
95
  env_prefix: str
85
96
  class_name: str
86
97
  fields: list[ModuleSettingField]
87
-
88
-
89
- def _mask(value: Any) -> Any:
90
- if value in (None, "", [], {}):
91
- return value
92
- return SECRET_MASK
98
+ manage_url: str | None = None
99
+ """The module's own management page. When set, the generic editor renders
100
+ a link there instead of a second editor for the same fields."""
93
101
 
94
102
 
95
103
  def _package_of(mod: Any) -> str:
@@ -111,42 +119,6 @@ def _extract_settings(app: FastAPI, package: str) -> BaseSettings | None:
111
119
  return inner if isinstance(inner, BaseSettings) else None
112
120
 
113
121
 
114
- def _resolve_default(info) -> Any:
115
- """Return the effective default, handling ``default_factory`` fields.
116
-
117
- Pydantic sets ``info.default`` to ``PydanticUndefined`` when
118
- ``default_factory`` is used. We call the factory to get the concrete
119
- default so the settings UI can serialize it.
120
- """
121
- from pydantic_core import PydanticUndefined
122
-
123
- if info.default is not PydanticUndefined:
124
- return info.default
125
- if info.default_factory is not None:
126
- return info.default_factory()
127
- return None
128
-
129
-
130
- def _env_readable_var(settings: BaseSettings, name: str) -> str | None:
131
- """Env var pydantic would actually read for ``name``, or ``None``.
132
-
133
- ``env_var`` on the view is a *label* — the ``SM_<PACKAGE>_<FIELD>`` name the
134
- ``smpy settings import-from-env`` CLI looks for, kept from before settings
135
- moved into the DB. It is not evidence that pydantic reads it: the bundled
136
- module settings classes declare ``SettingsConfigDict(extra="ignore")`` with
137
- no ``env_prefix``, so ``SM_FILE_STORAGE_BACKEND`` has no effect on
138
- ``FileStorageSettings()``. Deriving env-readability from the class's own
139
- ``env_prefix`` keeps the "From environment" badge honest, and works as-is
140
- for the classes that do declare one — the host's ``Settings`` (``SM_``) and
141
- every module built from the scaffold, whose template ships
142
- ``env_prefix="SM_<PACKAGE>_"``.
143
- """
144
- env_prefix = str(type(settings).model_config.get("env_prefix") or "")
145
- if not env_prefix:
146
- return None
147
- return f"{env_prefix}{name.upper()}"
148
-
149
-
150
122
  def _field_view(
151
123
  name: str,
152
124
  settings: BaseSettings,
@@ -156,23 +128,52 @@ def _field_view(
156
128
  cls = type(settings)
157
129
  info = cls.model_fields[name]
158
130
  raw_value = getattr(settings, name)
159
- secret = is_secret_field(name)
131
+ value_type = value_type_for_field(cls, name)
132
+ # A numeric field whose name merely contains a secret-ish word was being
133
+ # masked and made uneditable — `reset_password_token_lifetime_seconds` is
134
+ # an int, but it matches on "password" exactly as the real secrets do.
135
+ # Phrased as "exempt the types that cannot hold a credential" rather than
136
+ # "mask only strings" so the failure direction is safe: an unexpected type
137
+ # (e.g. `str | None`, which resolves to "json") stays masked instead of
138
+ # silently exposing a secret.
139
+ named_secret = value_type not in _NEVER_SECRET_TYPES and is_secret_field(name)
160
140
  extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
161
- default = _resolve_default(info)
141
+ default = resolve_default(info)
162
142
  env_var = f"{prefix}{name.upper()}"
163
- live_env_var = _env_readable_var(settings, name)
143
+ live_env_var = env_readable_var(settings, name)
144
+ env_set = live_env_var is not None and live_env_var in os.environ
145
+ # ``live_env_var`` is not None whenever env_set is True, but the narrowing
146
+ # is spelled out so a future edit cannot turn this into ``os.environ[None]``.
147
+ raw_env = os.environ[live_env_var] if env_set and live_env_var is not None else None
148
+
149
+ def hidden(value: Any) -> bool:
150
+ """Mask per *value*, not just per name: a DSN carrying a password is a
151
+ secret even on a field called ``broker_url``, while the same field's
152
+ password-free default is still worth showing."""
153
+ return named_secret or embeds_credential(value)
154
+
155
+ # ``is_secret`` drives the editor's input type and the "reveal" affordance,
156
+ # so it has to be true when *any* of the three readings is being hidden —
157
+ # otherwise the form offers to edit a value it is showing as dots.
158
+ secret = named_secret or any(embeds_credential(v) for v in (raw_value, default, raw_env))
164
159
  return ModuleSettingField(
165
160
  name=name,
166
161
  env_var=env_var,
167
- value=_mask(raw_value) if secret else raw_value,
168
- default=_mask(default) if secret else default,
162
+ value=mask(raw_value) if hidden(raw_value) else raw_value,
163
+ default=mask(default) if hidden(default) else default,
169
164
  description=info.description or "",
170
165
  is_secret=secret,
171
- type=value_type_for_field(cls, name),
166
+ type=value_type,
172
167
  requires_restart=bool(extra.get("requires_restart", False)),
173
168
  group=extra.get("group"),
174
- env_set=live_env_var is not None and live_env_var in os.environ,
169
+ env_set=env_set,
175
170
  db_override=name in overridden,
171
+ env_readable=live_env_var is not None,
172
+ # Masked on the same rule as ``value`` and ``default``: a secret stays
173
+ # hidden, everything else is shown. Masking unconditionally turned the
174
+ # Resolved value panel's env row into a row of dots for every key.
175
+ env_value=(mask(raw_env) if hidden(raw_env) else raw_env) if raw_env is not None else None,
176
+ choices=choices_for(info),
176
177
  )
177
178
 
178
179
 
@@ -194,16 +195,22 @@ def collect_module_settings(
194
195
  views: list[ModuleSettingsView] = []
195
196
  seen: set[str] = set()
196
197
 
198
+ settings_services = getattr(app.state, "settings", None)
199
+ registry = getattr(settings_services, "module_registry", None)
200
+
201
+ def _manage_url(package: str) -> str | None:
202
+ return registry.manage_url(package) if registry is not None else None
203
+
197
204
  for mod in getattr(app.state.sm, "modules", ()):
198
205
  package = _package_of(mod)
199
206
  settings = _extract_settings(app, package)
200
207
  if settings is None:
201
208
  continue
202
- views.append(_build_view(mod.meta.name, package, settings, by_package))
209
+ views.append(
210
+ _build_view(mod.meta.name, package, settings, by_package, _manage_url(package))
211
+ )
203
212
  seen.add(package)
204
213
 
205
- settings_services = getattr(app.state, "settings", None)
206
- registry = getattr(settings_services, "module_registry", None)
207
214
  if registry is not None:
208
215
  for package in registry.all_packages():
209
216
  if package in seen:
@@ -211,7 +218,9 @@ def collect_module_settings(
211
218
  settings = _extract_settings(app, package)
212
219
  if settings is None:
213
220
  continue
214
- views.append(_build_view(package.title(), package, settings, by_package))
221
+ views.append(
222
+ _build_view(package.title(), package, settings, by_package, _manage_url(package))
223
+ )
215
224
  seen.add(package)
216
225
 
217
226
  views.sort(key=lambda v: v.module_name)
@@ -223,6 +232,7 @@ def _build_view(
223
232
  package: str,
224
233
  settings: BaseSettings,
225
234
  overrides: dict[str, frozenset[str]] | None = None,
235
+ manage_url: str | None = None,
226
236
  ) -> ModuleSettingsView:
227
237
  prefix = env_prefix_for(package)
228
238
  overridden = (overrides or {}).get(package, frozenset())
@@ -235,6 +245,7 @@ def _build_view(
235
245
  env_prefix=prefix,
236
246
  class_name=type(settings).__name__,
237
247
  fields=fields,
248
+ manage_url=manage_url,
238
249
  )
239
250
 
240
251
 
@@ -249,41 +260,3 @@ async def overrides_by_package(service: SettingService) -> dict[str, frozenset[s
249
260
  from settings.store import SettingsStore
250
261
 
251
262
  return await SettingsStore(service).all_override_fields()
252
-
253
-
254
- def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
255
- """Convert dataclass views to plain dicts for Inertia props.
256
-
257
- Field values arrive as whatever type the module declared — pydantic has
258
- already coerced ``media_root: Path`` to a ``PosixPath``, ``timeout:
259
- timedelta`` to a ``timedelta`` — and this screen reflects every installed
260
- module's settings, so the set of types is open-ended by design. They are
261
- encoded here rather than handed on as-is: this is the boundary where a
262
- settings object stops being Python and becomes a prop.
263
- """
264
- return [
265
- {
266
- "module_name": v.module_name,
267
- "package": v.package,
268
- "env_prefix": v.env_prefix,
269
- "class_name": v.class_name,
270
- "fields": [
271
- {
272
- "name": f.name,
273
- "env_var": f.env_var,
274
- "value": jsonable_encoder(f.value),
275
- "default": jsonable_encoder(f.default),
276
- "description": f.description,
277
- "is_secret": f.is_secret,
278
- "type": f.type,
279
- "requires_restart": f.requires_restart,
280
- "group": f.group,
281
- "env_set": f.env_set,
282
- "db_override": f.db_override,
283
- "source": f.source,
284
- }
285
- for f in v.fields
286
- ],
287
- }
288
- for v in views
289
- ]
@@ -0,0 +1,56 @@
1
+ """Serialize module-settings views into Inertia props.
2
+
3
+ Split from ``_module_settings`` (collection) so each file keeps one
4
+ responsibility: that one discovers and shapes the views, this one is the
5
+ boundary where a settings object stops being Python and becomes a prop.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from fastapi.encoders import jsonable_encoder
13
+
14
+ from settings._module_settings import ModuleSettingsView
15
+
16
+
17
+ def serialize(views: list[ModuleSettingsView]) -> list[dict[str, Any]]:
18
+ """Convert dataclass views to plain dicts for Inertia props.
19
+
20
+ Field values arrive as whatever type the module declared — pydantic has
21
+ already coerced ``media_root: Path`` to a ``PosixPath``, ``timeout:
22
+ timedelta`` to a ``timedelta`` — and this screen reflects every installed
23
+ module's settings, so the set of types is open-ended by design. They are
24
+ encoded here rather than handed on as-is.
25
+ """
26
+ return [
27
+ {
28
+ "module_name": v.module_name,
29
+ "package": v.package,
30
+ "env_prefix": v.env_prefix,
31
+ "class_name": v.class_name,
32
+ "manage_url": v.manage_url,
33
+ "fields": [
34
+ {
35
+ "name": f.name,
36
+ "env_var": f.env_var,
37
+ "value": jsonable_encoder(f.value),
38
+ "default": jsonable_encoder(f.default),
39
+ "description": f.description,
40
+ "is_secret": f.is_secret,
41
+ "type": f.type,
42
+ "requires_restart": f.requires_restart,
43
+ "group": f.group,
44
+ "env_set": f.env_set,
45
+ "env_readable": f.env_readable,
46
+ "db_override": f.db_override,
47
+ "source": f.source,
48
+ # Turns a pattern-constrained string into a select rather
49
+ # than a text box whose only feedback on a typo is a 422.
50
+ "choices": f.choices,
51
+ }
52
+ for f in v.fields
53
+ ],
54
+ }
55
+ for v in views
56
+ ]
@@ -0,0 +1,60 @@
1
+ """What counts as a secret on a settings field, and how one is hidden.
2
+
3
+ Split out of ``_module_settings`` so the "should this value be shown?"
4
+ question — a name rule, a value rule and one mask — lives in one file and can
5
+ be reasoned about without a FastAPI app in scope. Re-exported from
6
+ ``_module_settings``, which is where the rest of the codebase imports it from.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ # We intentionally DON'T match the bare word "token" (would mask
16
+ # `verification_token_lifetime_seconds` — just an int) or "key" alone (would
17
+ # mask `s3_bucket_key_prefix`). Only fragments that actually indicate material.
18
+ _SECRET_PATTERNS = re.compile(
19
+ r"(password|secret|api[_-]?key|private[_-]?key|token[_-]?secret)", re.I
20
+ )
21
+ SECRET_MASK = "••••••••"
22
+
23
+ # Value types that cannot carry credential material, so a secret-ish *name*
24
+ # on one of them is a false positive rather than something to hide.
25
+ _NEVER_SECRET_TYPES = frozenset({"int", "float", "bool"})
26
+
27
+
28
+ def is_secret_field(name: str) -> bool:
29
+ """True if a field name suggests it holds credential material."""
30
+ return bool(_SECRET_PATTERNS.search(name))
31
+
32
+
33
+ def embeds_credential(value: Any) -> bool:
34
+ """True if ``value`` is a URL carrying a password in its authority.
35
+
36
+ The name rule alone cannot see these: ``broker_url``, ``result_backend``,
37
+ ``redis_url`` and ``database_url`` match nothing in
38
+ :data:`_SECRET_PATTERNS`, yet a production DSN is precisely where the
39
+ Redis and Postgres passwords live — and those fields are ``env_readable``,
40
+ so the value reached both the module editor and the ``known_keys``
41
+ suggestion list in clear text.
42
+
43
+ Judged on the value rather than the name because the name is the thing
44
+ that was wrong. A DSN without a password stays visible: hiding
45
+ ``redis://localhost:6379/0`` helps nobody debug why the queue is idle.
46
+ """
47
+ if not isinstance(value, str) or "://" not in value:
48
+ return False
49
+ try:
50
+ return bool(urlsplit(value).password)
51
+ except ValueError:
52
+ # A malformed authority (an unclosed IPv6 literal, say) is not a
53
+ # credential, but it must not take the settings screen down either.
54
+ return False
55
+
56
+
57
+ def mask(value: Any) -> Any:
58
+ if value in (None, "", [], {}):
59
+ return value
60
+ return SECRET_MASK
@@ -0,0 +1,49 @@
1
+ """Turn the store screen's query string into filters the service can run.
2
+
3
+ Every parameter here arrives from a url an admin may have typed, bookmarked or
4
+ kept open while the data moved underneath it. None of those are client errors
5
+ worth a 422 page — a broken link should still render the table — so each one
6
+ falls back to a sane value rather than rejecting the request. Clamping lives
7
+ here rather than in the view so the ``filters``/``pagination`` props can echo
8
+ exactly what the query ran with.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass
14
+
15
+ from settings.constants import ALL_SCOPES, DEFAULT_PER_PAGE, MAX_PER_PAGE, SCOPE_ALL
16
+ from settings.contracts.schemas import SettingScope
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class BrowseQuery:
21
+ """Validated, clamped filters for one render of the raw store."""
22
+
23
+ scope: str
24
+ """A tab value: ``all`` or one of the real scopes."""
25
+ q: str
26
+ page: int
27
+ per_page: int
28
+
29
+ @property
30
+ def scope_filter(self) -> SettingScope | None:
31
+ """The scope to filter on, or ``None`` for the ``all`` tab."""
32
+ return None if self.scope == SCOPE_ALL else SettingScope(self.scope)
33
+
34
+
35
+ def _int_or(raw: str, fallback: int) -> int:
36
+ try:
37
+ return int(raw)
38
+ except (TypeError, ValueError):
39
+ return fallback
40
+
41
+
42
+ def parse(scope: str, q: str, page: str, per_page: str) -> BrowseQuery:
43
+ """Read the four query params, substituting defaults for anything unusable."""
44
+ return BrowseQuery(
45
+ scope=scope if scope in ALL_SCOPES else SCOPE_ALL,
46
+ q=q,
47
+ page=max(_int_or(page, 1), 1),
48
+ per_page=max(1, min(_int_or(per_page, DEFAULT_PER_PAGE), MAX_PER_PAGE)),
49
+ )
@@ -42,9 +42,19 @@ ALL_VALUE_TYPES: Final = (
42
42
  VALUE_TYPE_JSON,
43
43
  )
44
44
 
45
+ # ── Secrets ──────────────────────────────────────────────────────────
46
+ # Keys whose stored value must never leave the settings API or the raw
47
+ # key/value browse screen. ``host.secret_key`` is the session-signing key: the
48
+ # hosting layer generates and persists it here when SM_SECRET_KEY is unset, and
49
+ # anyone who can read it can forge a session cookie for any account. Masked on
50
+ # the way out only — the boot-time reader goes straight to SQL, so hiding it
51
+ # here costs nothing.
52
+ SENSITIVE_KEYS: Final = frozenset({"host.secret_key"})
53
+ SENSITIVE_PLACEHOLDER: Final = "********"
54
+
45
55
  # ── Routing ──────────────────────────────────────────────────────────
46
56
  API_PREFIX: Final = "/api/settings"
47
- VIEW_PREFIX: Final = "/settings"
57
+ VIEW_PREFIX: Final = "/admin/settings"
48
58
  VIEW_CREATE_PATH: Final = "/create"
49
59
  VIEW_EDIT_PATH: Final = "/{setting_id}/edit"
50
60
  VIEW_MODULES_PATH: Final = "/modules"
@@ -101,6 +111,21 @@ PROP_SETTINGS: Final = "settings"
101
111
  PROP_SETTING: Final = "setting"
102
112
  PROP_MODULES: Final = "modules"
103
113
  PROP_ERROR: Final = "error"
114
+ PROP_PAGINATION: Final = "pagination"
115
+ PROP_COUNTS: Final = "counts"
116
+ PROP_FILTERS: Final = "filters"
117
+ PROP_KNOWN_KEYS: Final = "known_keys"
118
+ PROP_TESTABLE: Final = "testable"
119
+
120
+ # ── Store listing (scope tabs + search + paging) ─────────────────────
121
+ # The table is server-filtered: the scope tabs report counts the client can no
122
+ # longer compute once it holds a single page, and an install with a few
123
+ # thousand overrides must not ship all of them to render twenty.
124
+ SCOPE_ALL: Final = "all"
125
+ """Sentinel for "no scope filter" — a tab value, never a stored scope."""
126
+
127
+ DEFAULT_PER_PAGE: Final = 20
128
+ MAX_PER_PAGE: Final = 200
104
129
 
105
130
  # ── User-facing error messages ───────────────────────────────────────
106
131
  ERR_SETTING_NOT_FOUND: Final = "Setting not found"
@@ -121,3 +146,6 @@ QP_USER_ID: Final = "user_id"
121
146
  QP_TENANT_ID: Final = "tenant_id"
122
147
  QP_SCOPE: Final = "scope"
123
148
  QP_SCOPE_ID: Final = "scope_id"
149
+ QP_Q: Final = "q"
150
+ QP_PAGE: Final = "page"
151
+ QP_PER_PAGE: Final = "per_page"