frutebox-node 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.
frutebox/envelope.py ADDED
@@ -0,0 +1,77 @@
1
+ """What remains of the Python envelope: two SHAPE checks, and no cryptography.
2
+
3
+ THE CRYPTOGRAPHY LIVED HERE AND WAS NEVER USED. `master_key`, `field_key`, `encrypt_field` and
4
+ `decrypt_field` were deleted on 2026-09-22 (owner ruling) because nothing called them and nothing
5
+ could. Their only Python consumers were `providers.encrypt_credentials`/`decrypt_credentials`,
6
+ themselves uncalled, and a caller could not have been created without breaking the constraint the
7
+ whole design rests on: **`frutebox-directory` holds neither `DATABASE_URL` nor `STEVAI_DATA_KEY`
8
+ by design**, so it never sees a `valueEnc` row and `master_key()` raised on it. That is exactly the
9
+ 28 §O1 failure that had the live doctor reporting `EnvelopeError` for every provider on
10
+ 2026-09-18.
11
+
12
+ DEAD CRYPTOGRAPHY IS WORSE THAN NO CRYPTOGRAPHY. It reads as protection while protecting nothing,
13
+ and it is the first thing someone wires up in a hurry — which here would mean handing the
14
+ provisioning service the key that decrypts every encrypted field in the product, to solve a
15
+ problem that is only about provider API keys.
16
+
17
+ THE REAL ENVELOPE IS `web/src/lib/crypto/identity-core.ts`. It runs in
18
+ `web/src/app/api/internal/provider-credentials/route.ts`: `encryptField` on the PUT,
19
+ `decryptField` on the GET, both under the master key the web app legitimately holds. That is where
20
+ a credential is encrypted at rest (readme rule 10, no cloud KMS), and where it should be changed.
21
+
22
+ WHAT IS LEFT, AND WHY IT IS NOT CRYPTOGRAPHY
23
+ - `mask()` — the doctor prints PRESENCE, never a value, a length or a tail.
24
+ - `looks_like_field_token()` — a SHAPE check so `resolve_credentials` can REFUSE ciphertext
25
+ rather than decrypt it. It reads the version byte and a length; it holds no key and decides
26
+ nothing safe.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ import base64
31
+ from typing import Optional
32
+
33
+ #: Matches `VERSION` in identity-core.ts. A version byte on every token so the format can change
34
+ #: without a migration that must guess what it is reading.
35
+ VERSION = 1
36
+
37
+
38
+ def mask(secret: Optional[str]) -> str:
39
+ """What `providers doctor` prints: PRESENCE, and nothing else.
40
+
41
+ It used to print `set (50 chars, ends 8l7y)`. The intent was "enough to recognise a key,
42
+ never enough to use one", and that reasoning is wrong twice over. The length narrows the
43
+ search space and identifies which credential format a provider issued; the last four
44
+ characters are secret material outright. The standing rule is that a secret is never printed
45
+ — not its value, not its length, not any part of it — and a doctor endpoint exists to be
46
+ pasted into a report, which is precisely where those details would travel.
47
+
48
+ "set" answers the only question the doctor is for: is a credential installed. Anything more
49
+ is the caller's curiosity, not the operator's need.
50
+ """
51
+ return "set" if (secret or "").strip() else "(unset)"
52
+
53
+
54
+ def looks_like_field_token(value: str) -> bool:
55
+ """True when `value` has the shape of an `encrypt_field` token rather than a plaintext secret.
56
+
57
+ WHY THIS EXISTS (file 28 §O1). The provider-credential path decrypted twice: the web endpoint
58
+ returns plaintext, and `resolve_credentials` then decrypted it again. The two halves were each
59
+ tested in isolation and never against each other, so the break only ever showed as
60
+ `EnvelopeError` in a live doctor run.
61
+
62
+ Now that `resolve_credentials` treats what it receives as plaintext, the same class of mistake
63
+ would be SILENT and worse: ciphertext handed to a provider as an API key. This makes it loud.
64
+ It is a shape check, not a security boundary — the caller uses it to refuse, never to decide
65
+ that something is safe.
66
+
67
+ A token is base64url of `VERSION || iv(12) || tag(16) || ct`, so a plaintext key would have to
68
+ be valid base64url, decode to at least 29 bytes, AND begin with exactly our version byte.
69
+ """
70
+ s = (value or "").strip()
71
+ if not s:
72
+ return False
73
+ try:
74
+ packed = base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))
75
+ except Exception:
76
+ return False
77
+ return len(packed) >= 29 and packed[0] == VERSION
frutebox/envvars.py ADDED
@@ -0,0 +1,167 @@
1
+ """Dual-read environment lookup: FRUTEBOX_* first, STEVAI_* second.
2
+
3
+ WHY THIS EXISTS
4
+ The brand rename touches ~320 distinct STEVAI_* variable names that are set on
5
+ LIVE, RUNNING services. Renaming a variable in code while the deployed service still sets the old
6
+ name breaks that service the instant the new code ships — silently, because a missing env var
7
+ usually reads as "unset" rather than as an error.
8
+
9
+ This helper decouples the two halves. Code can move to FRUTEBOX_* today; deployments can keep
10
+ setting STEVAI_* until someone gets round to them, in any order, with no flag day. When the last
11
+ deployment has moved, the STEVAI_* arm becomes dead and can be deleted — and until then it is the
12
+ only thing keeping production alive.
13
+
14
+ env("ADMIN_TOKEN") -> $FRUTEBOX_ADMIN_TOKEN, else $STEVAI_ADMIN_TOKEN, else None
15
+ env("ADMIN_TOKEN", "dev") -> ... else "dev"
16
+ env_bool("REQUIRE_ADMISSION") -> True for 1/true/yes/on (case-insensitive)
17
+
18
+ Pass the BARE suffix, not the prefixed name: `env("ADMIN_TOKEN")`, never `env("STEVAI_ADMIN_TOKEN")`.
19
+ Passing a prefixed name is a mistake that would otherwise look up FRUTEBOX_STEVAI_ADMIN_TOKEN and
20
+ quietly find nothing, so it raises instead.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ from typing import Optional
26
+
27
+ NEW_PREFIX = "FRUTEBOX_"
28
+ OLD_PREFIX = "STEVAI_"
29
+
30
+ _TRUE = {"1", "true", "yes", "on"}
31
+ _FALSE = {"0", "false", "no", "off"}
32
+
33
+
34
+ def _names(suffix: str) -> tuple[str, str]:
35
+ s = (suffix or "").strip()
36
+ if not s:
37
+ raise ValueError("env() needs a variable name")
38
+ if s.startswith(NEW_PREFIX) or s.startswith(OLD_PREFIX):
39
+ raise ValueError(
40
+ f"pass the bare suffix, not {s!r} — e.g. env('ADMIN_TOKEN'), not env('{s}')"
41
+ )
42
+ return NEW_PREFIX + s, OLD_PREFIX + s
43
+
44
+
45
+ def env(suffix: str, default: Optional[str] = None) -> Optional[str]:
46
+ """FRUTEBOX_<suffix>, else STEVAI_<suffix>, else `default`.
47
+
48
+ An EMPTY value counts as set and wins, exactly as `os.environ.get` would treat it: a deployment
49
+ that deliberately blanks a variable must not silently fall through to the old one.
50
+ """
51
+ new, old = _names(suffix)
52
+ if new in os.environ:
53
+ return os.environ[new]
54
+ if old in os.environ:
55
+ return os.environ[old]
56
+ return default
57
+
58
+
59
+ class MissingConfig(RuntimeError):
60
+ """A required variable is unset. Raised at import, so the process refuses to start."""
61
+
62
+
63
+ def env_required(suffix: str, *, why: str = "") -> str:
64
+ """FRUTEBOX_<suffix>, else STEVAI_<suffix>. **No default. Unset is FATAL.**
65
+
66
+ WHY THERE IS NO DEFAULT HERE, AND WHY IT MATTERS MORE THAN IT LOOKS
67
+ These variables used to default to `https://stevai.com`. That domain is being removed, so
68
+ every one of those defaults was a time-bomb whose fuse is a deletion the owner is entitled
69
+ to perform without consulting the code.
70
+
71
+ `revocations.py` shows the shape of the damage. Admission FAILS CLOSED: an unreachable
72
+ revocation list refuses every node, which is correct. Point it at a dead domain and the
73
+ network stops admitting anyone at all — safely, and with logs blaming a revocation service
74
+ rather than a decommissioned domain. Safe and completely unreadable is the worst failure
75
+ mode this codebase produces.
76
+
77
+ An unset variable is a QUESTION. The honest answer is to stop and name it, not to guess the
78
+ old answer. Same rule `deploy-gateway.sh` already follows.
79
+
80
+ NOTE the deliberate asymmetry with code we SHIP: an SDK, desktop app or extension keeps a
81
+ default and points it at the new host, because a user's upgrade is not supervised and making
82
+ their client throw would be a worse failure than the one being removed.
83
+ """
84
+ new, old = _names(suffix)
85
+ val = os.environ.get(new, os.environ.get(old))
86
+ if val is None or not val.strip():
87
+ raise MissingConfig(
88
+ f"{new} is not set (nor {old}). {why or 'It has no default: the old default was '
89
+ 'https://stevai.com, a domain being removed.'} Set it, e.g.:\n"
90
+ f" export {new}=https://frutebox-web-qmatfu5ovq-ew.a.run.app")
91
+ return val
92
+
93
+
94
+ def env_bool(suffix: str, default: bool = False) -> bool:
95
+ """The same lookup, parsed as a flag. Unrecognised values fall back to `default`."""
96
+ raw = env(suffix)
97
+ if raw is None:
98
+ return default
99
+ v = raw.strip().lower()
100
+ if v in _TRUE:
101
+ return True
102
+ if v in _FALSE:
103
+ return False
104
+ return default
105
+
106
+
107
+ def env_int(suffix: str, default: int = 0) -> int:
108
+ """The same lookup, parsed as an int. Unparseable values fall back to `default`."""
109
+ raw = env(suffix)
110
+ if raw is None:
111
+ return default
112
+ try:
113
+ return int(raw.strip())
114
+ except (TypeError, ValueError):
115
+ return default
116
+
117
+
118
+ def source_of(suffix: str) -> str:
119
+ """Which arm answered: "frutebox", "stevai" or "default". For migration reporting."""
120
+ new, old = _names(suffix)
121
+ if new in os.environ:
122
+ return "frutebox"
123
+ if old in os.environ:
124
+ return "stevai"
125
+ return "default"
126
+
127
+
128
+ # --- the on-disk config directory -----------------------------------------------------------
129
+ # Same problem as the variable names, one level up: every existing install has its state in
130
+ # ~/.stevai. Renaming the path blind would not migrate that state, it would ORPHAN it — the
131
+ # software would start up looking healthy and empty, which is worse than failing.
132
+
133
+ import shutil
134
+ from pathlib import Path
135
+
136
+ NEW_DIR = ".frutebox"
137
+ OLD_DIR = ".stevai"
138
+
139
+
140
+ def config_dir(home: Optional[str] = None, *, migrate: bool = False) -> Path:
141
+ """Where this machine keeps its config: ~/.frutebox, else ~/.stevai, else ~/.frutebox.
142
+
143
+ Resolution mirrors `env()`: the new name wins if it exists, the old one is honoured while
144
+ it is all that exists, and a fresh install gets the new name. `migrate=True` performs a
145
+ one-time MOVE of the old directory to the new one and is what a CLI should pass on startup;
146
+ it is off by default so that merely *reading* the path can never mutate the disk.
147
+ """
148
+ root = Path(home) if home else Path.home()
149
+ new, old = root / NEW_DIR, root / OLD_DIR
150
+ if new.exists():
151
+ return new
152
+ if old.exists():
153
+ if migrate:
154
+ shutil.move(str(old), str(new)) # atomic on the same filesystem
155
+ return new
156
+ return old
157
+ return new
158
+
159
+
160
+ def config_dir_source(home: Optional[str] = None) -> str:
161
+ """Which directory answered: "frutebox", "stevai" or "new". For migration reporting."""
162
+ root = Path(home) if home else Path.home()
163
+ if (root / NEW_DIR).exists():
164
+ return "frutebox"
165
+ if (root / OLD_DIR).exists():
166
+ return "stevai"
167
+ return "new"