simple-module-settings 0.0.33__tar.gz → 0.0.34__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 (86) hide show
  1. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/PKG-INFO +4 -4
  2. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/pyproject.toml +4 -4
  3. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/_module_settings.py +4 -12
  4. simple_module_settings-0.0.34/settings/_row_masking.py +68 -0
  5. simple_module_settings-0.0.34/settings/_secrets.py +97 -0
  6. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/browse_query.py +26 -8
  7. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/endpoints/module_api.py +29 -10
  8. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/endpoints/views.py +8 -5
  9. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/known_keys.py +17 -14
  10. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/Browse.tsx +7 -4
  11. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/KeyField.tsx +0 -2
  12. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/service.py +59 -58
  13. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/store.py +10 -3
  14. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_known_keys_meta.py +0 -3
  15. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_secret_urls.py +45 -2
  16. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_store_filters.py +26 -0
  17. simple_module_settings-0.0.34/tests/test_store_secret_masking.py +231 -0
  18. simple_module_settings-0.0.33/settings/_secrets.py +0 -60
  19. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/.gitignore +0 -0
  20. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/LICENSE +0 -0
  21. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/README.md +0 -0
  22. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/package.json +0 -0
  23. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/__init__.py +0 -0
  24. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/_field_meta.py +0 -0
  25. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/_module_settings_props.py +0 -0
  26. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/cli.py +0 -0
  27. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/constants.py +0 -0
  28. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/contracts/__init__.py +0 -0
  29. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/contracts/accessor.py +0 -0
  30. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/contracts/events.py +0 -0
  31. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/contracts/registry.py +0 -0
  32. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/contracts/schemas.py +0 -0
  33. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/deps.py +0 -0
  34. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/endpoints/__init__.py +0 -0
  35. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/endpoints/api.py +0 -0
  36. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/env_vars.py +0 -0
  37. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/hydrate.py +0 -0
  38. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/locales/en.json +0 -0
  39. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/models.py +0 -0
  40. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/module.py +0 -0
  41. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/module_registry.py +0 -0
  42. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/Create.tsx +0 -0
  43. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/Edit.tsx +0 -0
  44. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/ModulesEdit.tsx +0 -0
  45. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/FieldInput.tsx +0 -0
  46. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/FieldSource.tsx +0 -0
  47. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/ModuleFieldRow.tsx +0 -0
  48. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/ModuleForm.tsx +0 -0
  49. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/ResolvedValue.tsx +0 -0
  50. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/ScopeTabs.tsx +0 -0
  51. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/StoreCards.tsx +0 -0
  52. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/StoreTable.tsx +0 -0
  53. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/TestConnectionButton.tsx +0 -0
  54. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/ValueInput.tsx +0 -0
  55. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/components/last-test.ts +0 -0
  56. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/routes.ts +0 -0
  57. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/pages/types.ts +0 -0
  58. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/py.typed +0 -0
  59. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/registration.py +0 -0
  60. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/reload.py +0 -0
  61. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/services.py +0 -0
  62. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/settings/settings.py +0 -0
  63. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_cli_import.py +0 -0
  64. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_concurrent_overrides.py +0 -0
  65. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_hydrate.py +0 -0
  66. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_module_api.py +0 -0
  67. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_module_registry.py +0 -0
  68. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_module_settings.py +0 -0
  69. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_module_settings_render.py +0 -0
  70. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_module_settings_serialize.py +0 -0
  71. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_registration.py +0 -0
  72. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_reload.py +0 -0
  73. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_accessor.py +0 -0
  74. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_api.py +0 -0
  75. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_events.py +0 -0
  76. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_field_sources.py +0 -0
  77. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_module.py +0 -0
  78. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_schemas.py +0 -0
  79. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_secret_masking.py +0 -0
  80. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_service.py +0 -0
  81. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_settings_view_authz.py +0 -0
  82. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_store.py +0 -0
  83. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests/test_testable_checks.py +0 -0
  84. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests-js/ModuleForm.test.tsx +0 -0
  85. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tests-js/ScopeTabs.test.tsx +0 -0
  86. {simple_module_settings-0.0.33 → simple_module_settings-0.0.34}/tsconfig.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simple_module_settings
3
- Version: 0.0.33
3
+ Version: 0.0.34
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.33
25
- Requires-Dist: simple-module-db==0.0.33
26
- Requires-Dist: simple-module-hosting==0.0.33
24
+ Requires-Dist: simple-module-core==0.0.34
25
+ Requires-Dist: simple-module-db==0.0.34
26
+ Requires-Dist: simple-module-hosting==0.0.34
27
27
  Requires-Dist: typer>=0.12
28
28
  Description-Content-Type: text/markdown
29
29
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "simple_module_settings"
3
- version = "0.0.33"
3
+ version = "0.0.34"
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.33",
25
- "simple_module_db==0.0.33",
26
- "simple_module_hosting==0.0.33",
24
+ "simple_module_core==0.0.34",
25
+ "simple_module_db==0.0.34",
26
+ "simple_module_hosting==0.0.34",
27
27
  "typer>=0.12",
28
28
  ]
29
29
 
@@ -19,7 +19,9 @@ from settings._field_meta import choices_for, env_readable_var, resolve_default
19
19
  from settings._secrets import ( # noqa: F401
20
20
  _NEVER_SECRET_TYPES,
21
21
  SECRET_MASK,
22
+ conceals_secret,
22
23
  embeds_credential,
24
+ is_named_secret,
23
25
  is_secret_field,
24
26
  mask,
25
27
  )
@@ -129,14 +131,7 @@ def _field_view(
129
131
  info = cls.model_fields[name]
130
132
  raw_value = getattr(settings, name)
131
133
  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)
134
+ named_secret = is_named_secret(name, value_type)
140
135
  extra = info.json_schema_extra if isinstance(info.json_schema_extra, dict) else {}
141
136
  default = resolve_default(info)
142
137
  env_var = f"{prefix}{name.upper()}"
@@ -147,10 +142,7 @@ def _field_view(
147
142
  raw_env = os.environ[live_env_var] if env_set and live_env_var is not None else None
148
143
 
149
144
  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)
145
+ return conceals_secret(name, value, value_type)
154
146
 
155
147
  # ``is_secret`` drives the editor's input type and the "reveal" affordance,
156
148
  # so it has to be true when *any* of the three readings is being hidden —
@@ -0,0 +1,68 @@
1
+ """Whether a stored row's value may be shown, and how the mask is honoured.
2
+
3
+ Split out of ``service`` so the "is this a secret, and is this write the mask
4
+ being echoed back?" question lives in one file, next to the field-level rules in
5
+ ``_secrets`` that it delegates to. ``service`` imports these; nothing else
6
+ should need to, because the point of funnelling every read through ``out`` is
7
+ that there is no second way to serialize a row.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from settings._secrets import conceals_secret
13
+ from settings.constants import SENSITIVE_KEYS, SENSITIVE_PLACEHOLDER
14
+ from settings.contracts.schemas import SettingOut
15
+ from settings.models import Setting
16
+
17
+
18
+ def is_masked(entity: Setting) -> bool:
19
+ """Whether this row's stored value must not leave the service.
20
+
21
+ :data:`SENSITIVE_KEYS` names the one key the hosting layer owns
22
+ (``host.secret_key``). Everything else is judged by the same name/value
23
+ rule the module editor uses, because the store is the *same data* seen
24
+ through a different screen: an override named ``users.smtp_password``, or
25
+ any override holding a ``postgresql://user:pw@host/db``, was rendered in
26
+ clear text in the browse table and pre-filled into the edit form purely
27
+ because the allowlist had a single entry in it.
28
+ """
29
+ return entity.key in SENSITIVE_KEYS or conceals_secret(
30
+ entity.key, entity.value, entity.value_type
31
+ )
32
+
33
+
34
+ def out(entity: Setting) -> SettingOut:
35
+ """Serialize a row, masking values that must not leave the service.
36
+
37
+ Every read path funnels through here so a secret cannot be read back by
38
+ listing it, resolving it, or fetching it by id.
39
+ """
40
+ out = SettingOut.model_validate(entity)
41
+ if is_masked(entity):
42
+ return out.model_copy(update={"value": SENSITIVE_PLACEHOLDER})
43
+ return out
44
+
45
+
46
+ def is_placeholder_write(entity: Setting, value: object) -> bool:
47
+ """Whether this write is the mask being echoed back, not a real new value.
48
+
49
+ The admin edit form GETs the row, pre-fills its input from the response,
50
+ and PUTs it back. For a masked row that response carries ``"********"``, so
51
+ an admin who opens the row and clicks Save — without touching the field —
52
+ would otherwise overwrite a real credential with a fixed, publicly-known
53
+ string. On ``host.secret_key`` that silently invalidates every session and
54
+ makes every future cookie forgeable.
55
+
56
+ Gated on the stored row rather than on the sentinel alone, so a non-secret
57
+ row whose real value happens to be eight asterisks still saves.
58
+
59
+ Treated as "leave it alone" rather than rejected, so the rest of the form
60
+ still saves and an admin who genuinely types a new value can still set one.
61
+ """
62
+ return is_masked(entity) and value == SENSITIVE_PLACEHOLDER
63
+
64
+
65
+ def drop_placeholder_write(entity: Setting, changes: dict) -> None:
66
+ """Strip a masked-value echo out of an update payload, in place."""
67
+ if "value" in changes and is_placeholder_write(entity, changes["value"]):
68
+ del changes["value"]
@@ -0,0 +1,97 @@
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 is_named_secret(name: str, value_type: str | None = None) -> bool:
34
+ """True if the *name* marks this field as credential material.
35
+
36
+ ``value_type`` exempts the types that cannot hold one, so a numeric field
37
+ whose name merely contains a secret-ish word is not masked and made
38
+ uneditable — ``reset_password_token_lifetime_seconds`` is an int, but it
39
+ matches on "password" exactly as the real secrets do. Phrased as "exempt
40
+ the types that cannot hold a credential" rather than "mask only strings" so
41
+ the failure direction is safe: an unexpected type (``str | None`` resolves
42
+ to "json") stays masked instead of silently exposing a secret.
43
+ """
44
+ if value_type in _NEVER_SECRET_TYPES:
45
+ return False
46
+ return is_secret_field(name)
47
+
48
+
49
+ def conceals_secret(name: str, value: Any, value_type: str | None = None) -> bool:
50
+ """True if this name/value pair must be masked on the way out.
51
+
52
+ The one rule every read path shares: mask per *value* as well as per name,
53
+ so a DSN carrying a password is hidden even on a field called
54
+ ``broker_url``, while that field's password-free default is still shown.
55
+ """
56
+ return is_named_secret(name, value_type) or embeds_credential(value)
57
+
58
+
59
+ def embeds_credential(value: Any) -> bool:
60
+ """True if ``value`` carries a password in a URL authority.
61
+
62
+ The name rule alone cannot see these: ``broker_url``, ``result_backend``,
63
+ ``redis_url`` and ``database_url`` match nothing in
64
+ :data:`_SECRET_PATTERNS`, yet a production DSN is precisely where the
65
+ Redis and Postgres passwords live — and those fields are ``env_readable``,
66
+ so the value reached both the module editor and the ``known_keys``
67
+ suggestion list in clear text.
68
+
69
+ Judged on the value rather than the name because the name is the thing
70
+ that was wrong. A DSN without a password stays visible: hiding
71
+ ``redis://localhost:6379/0`` helps nobody debug why the queue is idle.
72
+
73
+ Containers are walked rather than dismissed: a ``list[str]`` of broker
74
+ URLs or a ``dict`` of per-tenant DSNs holds exactly the same material as
75
+ the bare string, and returning False for anything non-``str`` meant one
76
+ such field would have been shown in full.
77
+ """
78
+ if isinstance(value, str):
79
+ if "://" not in value:
80
+ return False
81
+ try:
82
+ return bool(urlsplit(value).password)
83
+ except ValueError:
84
+ # A malformed authority (an unclosed IPv6 literal, say) is not a
85
+ # credential, but it must not take the settings screen down either.
86
+ return False
87
+ if isinstance(value, (list, tuple, set, frozenset)):
88
+ return any(embeds_credential(item) for item in value)
89
+ if isinstance(value, dict):
90
+ return any(embeds_credential(item) for item in value.values())
91
+ return False
92
+
93
+
94
+ def mask(value: Any) -> Any:
95
+ if value in (None, "", [], {}):
96
+ return value
97
+ return SECRET_MASK
@@ -11,6 +11,9 @@ exactly what the query ran with.
11
11
  from __future__ import annotations
12
12
 
13
13
  from dataclasses import dataclass
14
+ from typing import Annotated, Any
15
+
16
+ from pydantic import BeforeValidator
14
17
 
15
18
  from settings.constants import ALL_SCOPES, DEFAULT_PER_PAGE, MAX_PER_PAGE, SCOPE_ALL
16
19
  from settings.contracts.schemas import SettingScope
@@ -32,18 +35,33 @@ class BrowseQuery:
32
35
  return None if self.scope == SCOPE_ALL else SettingScope(self.scope)
33
36
 
34
37
 
35
- def _int_or(raw: str, fallback: int) -> int:
36
- try:
37
- return int(raw)
38
- except (TypeError, ValueError):
39
- return fallback
38
+ def _lenient_int(fallback: int) -> BeforeValidator:
39
+ """Coerce a query param to ``int``, substituting ``fallback`` for garbage.
40
+
41
+ Declared as a validator on an ``int``-annotated param rather than by typing
42
+ the param ``str``: both accept ``?page=banana`` without a 422, but only this
43
+ one leaves the OpenAPI schema advertising an integer. A generated client
44
+ that reads the schema was otherwise told to send page numbers as strings.
45
+ """
46
+
47
+ def coerce(raw: Any) -> int:
48
+ try:
49
+ return int(raw)
50
+ except (TypeError, ValueError):
51
+ return fallback
52
+
53
+ return BeforeValidator(coerce)
54
+
55
+
56
+ PageParam = Annotated[int, _lenient_int(1)]
57
+ PerPageParam = Annotated[int, _lenient_int(DEFAULT_PER_PAGE)]
40
58
 
41
59
 
42
- def parse(scope: str, q: str, page: str, per_page: str) -> BrowseQuery:
60
+ def parse(scope: str, q: str, page: int, per_page: int) -> BrowseQuery:
43
61
  """Read the four query params, substituting defaults for anything unusable."""
44
62
  return BrowseQuery(
45
63
  scope=scope if scope in ALL_SCOPES else SCOPE_ALL,
46
64
  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)),
65
+ page=max(page, 1),
66
+ per_page=max(1, min(per_page, MAX_PER_PAGE)),
49
67
  )
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
  import json
10
10
  from typing import Any
11
11
 
12
- from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
12
+ from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request, Response, status
13
13
  from pydantic import ValidationError
14
14
  from simple_module_hosting.permissions import RequiresPermission
15
15
 
@@ -37,19 +37,38 @@ _EDIT = [Depends(RequiresPermission(PERM_EDIT))]
37
37
  _DELETE = [Depends(RequiresPermission(PERM_DELETE))]
38
38
 
39
39
 
40
- def _strip_mask_sentinels(changes: dict[str, Any]) -> dict[str, Any]:
41
- """Drop any field whose submitted value is the UI mask sentinel.
40
+ def _masked_fields(app: FastAPI, package: str) -> frozenset[str]:
41
+ """The fields this package's editor rendered as dots.
42
42
 
43
- Keyed on the sentinel alone rather than on ``is_secret_field(name)``: a
44
- value can be masked because it *is* a credential (a DSN with a password in
45
- it) on a field whose name says nothing of the sort, and storing the row of
46
- dots that the editor rendered would overwrite the real connection string.
47
- Nothing legitimately equals :data:`SECRET_MASK`.
43
+ ``ModuleSettingField.is_secret`` is the same flag that drove the input type
44
+ and the reveal affordance, so it answers exactly the question here: which
45
+ submitted values could be an echo of the mask rather than a real edit.
46
+ """
47
+ return frozenset(
48
+ field.name
49
+ for view in collect_module_settings(app)
50
+ if view.package == package
51
+ for field in view.fields
52
+ if field.is_secret
53
+ )
54
+
55
+
56
+ def _strip_mask_sentinels(masked: frozenset[str], changes: dict[str, Any]) -> dict[str, Any]:
57
+ """Drop the mask sentinel on the fields that were rendered masked.
58
+
59
+ Not on ``is_secret_field(name)``: a value can be masked because it *is* a
60
+ credential (a DSN with a password in it) on a field whose name says nothing
61
+ of the sort, and storing the row of dots the editor rendered would overwrite
62
+ the real connection string.
63
+
64
+ Not on the sentinel alone either, which is where this started — that silently
65
+ dropped the write on any field whose real value happened to equal
66
+ :data:`SECRET_MASK`, with no error and no saved row to show for it.
48
67
  """
49
68
  return {
50
69
  name: value
51
70
  for name, value in changes.items()
52
- if not (isinstance(value, str) and value == SECRET_MASK)
71
+ if not (name in masked and isinstance(value, str) and value == SECRET_MASK)
53
72
  }
54
73
 
55
74
 
@@ -86,7 +105,7 @@ async def update_module(
86
105
  detail="This module has a dedicated settings page; edit it there instead.",
87
106
  )
88
107
 
89
- cleaned = _strip_mask_sentinels(changes)
108
+ cleaned = _strip_mask_sentinels(_masked_fields(request.app, package), changes)
90
109
  if not cleaned:
91
110
  return {"ok": True, "changed": []}
92
111
 
@@ -80,8 +80,8 @@ async def browse(
80
80
  service: SettingService = Depends(get_setting_service),
81
81
  scope: str = SCOPE_ALL,
82
82
  q: str = "",
83
- page: str = "1",
84
- per_page: str = str(DEFAULT_PER_PAGE),
83
+ page: browse_query.PageParam = 1,
84
+ per_page: browse_query.PerPageParam = DEFAULT_PER_PAGE,
85
85
  ) -> InertiaResponse:
86
86
  """The raw key/value store, filtered/searched/paged on the server.
87
87
 
@@ -89,9 +89,12 @@ async def browse(
89
89
  "Settings" is nearly always after a module's form, not a table of rows
90
90
  keyed by dotted strings.
91
91
 
92
- The filters are strings because they reach us from urls people edit and
93
- bookmark; ``browse_query.parse`` substitutes defaults for anything
94
- unusable rather than 422ing a link that used to work.
92
+ The filters reach us from urls people edit and bookmark, so nothing here
93
+ 422s a link that used to work: an unusable ``scope`` falls back to the
94
+ ``all`` tab, and the two page params carry a validator that substitutes
95
+ their default instead of rejecting the request. ``browse_query.parse``
96
+ then clamps, so the ``filters``/``pagination`` props can echo exactly what
97
+ the query ran with.
95
98
  """
96
99
  query = browse_query.parse(scope, q, page, per_page)
97
100
  items, total = await service.list_filtered(
@@ -19,19 +19,13 @@ from fastapi import FastAPI
19
19
  from fastapi.encoders import jsonable_encoder
20
20
 
21
21
  from settings._module_settings import collect_module_settings
22
+ from settings._secrets import conceals_secret, mask
22
23
 
23
- _DEFINITION_MODULE = "Registry"
24
- """Module label for keys declared through ``SettingsRegistry`` rather than a
25
- pydantic settings class. They have no owning package to name — the registry
26
- records intent, not a field on some module's settings object."""
27
24
 
28
-
29
- def _from_field(package: str, module_name: str, field) -> dict[str, Any]:
25
+ def _from_field(package: str, field) -> dict[str, Any]:
30
26
  return {
31
27
  "key": f"{package}.{field.name}",
32
28
  "type": field.type,
33
- "description": field.description,
34
- "module": module_name,
35
29
  "env_var": field.env_var,
36
30
  # See ``ModuleSettingField.env_readable``: a bundled module's SM_* var
37
31
  # is a label, not a fallback, and the panel must say so.
@@ -46,18 +40,27 @@ def _from_field(package: str, module_name: str, field) -> dict[str, Any]:
46
40
 
47
41
 
48
42
  def _from_definition(definition) -> dict[str, Any]:
43
+ """A registry declaration, masked on the same rule as a module field.
44
+
45
+ ``_from_field`` receives a default that ``_module_settings`` has already
46
+ masked; this builder reads the declaration directly, so it has to apply the
47
+ rule itself. Hard-coding ``is_secret: False`` and passing ``default``
48
+ straight through put the raw value into the New-override suggestion list,
49
+ which renders it verbatim.
50
+ """
51
+ value_type = str(definition.value_type)
52
+ default = jsonable_encoder(definition.default)
53
+ secret = conceals_secret(definition.key, default, value_type)
49
54
  return {
50
55
  "key": definition.key,
51
- "type": str(definition.value_type),
52
- "description": definition.description,
53
- "module": _DEFINITION_MODULE,
56
+ "type": value_type,
54
57
  "env_var": "",
55
58
  "env_readable": False,
56
59
  "env_set": False,
57
60
  "env_value": None,
58
- "default": definition.default,
61
+ "default": mask(default) if secret else default,
59
62
  "requires_restart": False,
60
- "is_secret": False,
63
+ "is_secret": secret,
61
64
  "choices": None,
62
65
  }
63
66
 
@@ -72,7 +75,7 @@ def build(app: FastAPI) -> list[dict[str, Any]]:
72
75
  suggestions: dict[str, dict[str, Any]] = {}
73
76
  for view in collect_module_settings(app):
74
77
  for field in view.fields:
75
- entry = _from_field(view.package, view.module_name, field)
78
+ entry = _from_field(view.package, field)
76
79
  suggestions[entry["key"]] = entry
77
80
 
78
81
  registry = getattr(getattr(app.state, "settings", None), "registry", None)
@@ -65,6 +65,7 @@ function Browse({ settings, pagination, counts, filters }: Props) {
65
65
  <>
66
66
  <Head title={t(keys.settings.browse.title)} />
67
67
  <PageShell
68
+ fill
68
69
  title={t(keys.settings.browse.title)}
69
70
  description={t(keys.settings.browse.description)}
70
71
  actions={
@@ -102,10 +103,12 @@ function Browse({ settings, pagination, counts, filters }: Props) {
102
103
  </div>
103
104
  </div>
104
105
 
105
- {/* A min-height rather than a fixed one: the deck's card fills the
106
- viewport with the footer pinned to the bottom, and `min-h` gets
107
- that look without clipping a full page of rows on a short window. */}
108
- <Card className="flex flex-col overflow-hidden border-border p-0 lg:min-h-[calc(100vh-var(--app-chrome-h)-15rem)]">
106
+ {/* The deck's card fills the viewport with the footer pinned to the
107
+ bottom. `fill` on the shell hands the leftover height to this
108
+ column, so the card grows into it — the `15rem` this replaced was a
109
+ hand-measured stand-in for the heading, tabs and search bar above,
110
+ and drifted whenever any of them changed. */}
111
+ <Card className="flex flex-col overflow-hidden border-border p-0 lg:flex-1">
109
112
  {settings.length === 0 ? (
110
113
  <div className="flex flex-1 flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
111
114
  <SettingsIcon className="size-8" aria-hidden="true" />
@@ -7,8 +7,6 @@ import type { ValueType } from '../types';
7
7
  export interface KnownKey {
8
8
  key: string;
9
9
  type: string;
10
- description: string;
11
- module: string;
12
10
  /** `SM_<PACKAGE>_<FIELD>` label for this field. */
13
11
  env_var: string;
14
12
  /** The declaring class reads env at all (it declares an `env_prefix`). */