forktex-cloud 0.2.3__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.
forktex_cloud/paths.py ADDED
@@ -0,0 +1,297 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Canonical filesystem layout for the forktex ecosystem (V1).
25
+
26
+ Every subsystem that reads or writes under ``.forktex/`` (project scope) or
27
+ ``~/.forktex/`` (user/OS scope) MUST go through this module. Hardcoding path
28
+ literals elsewhere is enforced against by ``tests/test_paths_contract.py``.
29
+
30
+ Cross-platform rules:
31
+ - All returned values are ``pathlib.Path`` (never ``str``).
32
+ - Project scope directory is always lowercase ``.forktex``.
33
+ - User scope directory is ``~/.forktex`` on POSIX and ``%APPDATA%/forktex`` on Windows.
34
+ - Secrets-bearing directories are created with mode ``0o700`` on POSIX; Windows
35
+ relies on per-user ACLs on ``%APPDATA%``.
36
+
37
+ See ``cloud/docs/forktex-directory-spec.md`` for the full V1 spec.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import os
43
+ import sys
44
+ from pathlib import Path
45
+
46
+ #: Bump when the on-disk layout changes incompatibly. Written to
47
+ #: ``.forktex/.version`` by :func:`ensure_project_dirs` on init.
48
+ SCHEMA_VERSION = 1
49
+
50
+ #: Directory name at project scope.
51
+ PROJECT_DIRNAME = ".forktex"
52
+
53
+ #: Directory name at user/OS scope (stripped of leading dot on Windows).
54
+ _USER_DIRNAME_POSIX = ".forktex"
55
+ _USER_DIRNAME_WINDOWS = "forktex"
56
+
57
+ #: Marker comment used to idempotently detect the canonical ``.gitignore`` block.
58
+ _GITIGNORE_MARKER = "# forktex — generated + secrets (keep .forktex/.version committed)"
59
+ _GITIGNORE_BLOCK = f"""
60
+ {_GITIGNORE_MARKER}
61
+ .forktex/**
62
+ !.forktex/.version
63
+ """
64
+
65
+
66
+ # ── Project-scope paths ──────────────────────────────────────────────────────
67
+
68
+
69
+ def project_dir(root: Path) -> Path:
70
+ return root / PROJECT_DIRNAME
71
+
72
+
73
+ def version_file(root: Path) -> Path:
74
+ return project_dir(root) / ".version"
75
+
76
+
77
+ def compose_path(root: Path, env: str) -> Path:
78
+ return project_dir(root) / f"docker-compose.{env}.yml"
79
+
80
+
81
+ def observability_dir(root: Path) -> Path:
82
+ return project_dir(root) / "observability"
83
+
84
+
85
+ def vault_dir(root: Path, env: str) -> Path:
86
+ return project_dir(root) / "vault" / env
87
+
88
+
89
+ def vault_secrets_file(root: Path, env: str) -> Path:
90
+ return vault_dir(root, env) / "secrets.enc"
91
+
92
+
93
+ def state_dir(root: Path) -> Path:
94
+ return project_dir(root) / "state"
95
+
96
+
97
+ def servers_json(root: Path) -> Path:
98
+ return state_dir(root) / "servers.json"
99
+
100
+
101
+ def server_keys_dir(root: Path) -> Path:
102
+ return state_dir(root) / "keys"
103
+
104
+
105
+ def generated_dir(root: Path) -> Path:
106
+ return project_dir(root) / "generated"
107
+
108
+
109
+ def data_dir(root: Path, service_id: str) -> Path:
110
+ return project_dir(root) / "data" / service_id
111
+
112
+
113
+ def custom_ssl_dir(root: Path) -> Path:
114
+ return project_dir(root) / "ssl" / "custom"
115
+
116
+
117
+ def fsd_evidence_dir(root: Path) -> Path:
118
+ return project_dir(root) / "fsd" / "evidence"
119
+
120
+
121
+ def architecture_dir(root: Path) -> Path:
122
+ return project_dir(root) / "architecture"
123
+
124
+
125
+ def agents_history_dir(root: Path) -> Path:
126
+ return project_dir(root) / "agents" / "history"
127
+
128
+
129
+ def agents_history_file(root: Path, agent_id: str) -> Path:
130
+ return agents_history_dir(root) / f"{agent_id}.jsonl"
131
+
132
+
133
+ def agents_types_file(root: Path) -> Path:
134
+ return project_dir(root) / "agents" / "types.json"
135
+
136
+
137
+ def scraper_truths_dir(root: Path) -> Path:
138
+ return project_dir(root) / "scraper" / "truths"
139
+
140
+
141
+ def scraper_truths_file(root: Path, domain: str) -> Path:
142
+ return scraper_truths_dir(root) / f"{domain}.json"
143
+
144
+
145
+ def scraper_output_dir(root: Path) -> Path:
146
+ return project_dir(root) / "scraper" / "output"
147
+
148
+
149
+ def project_config_file(root: Path) -> Path:
150
+ return project_dir(root) / "config.json"
151
+
152
+
153
+ def project_cloud_file(root: Path) -> Path:
154
+ return project_dir(root) / "cloud" / "config.json"
155
+
156
+
157
+ def project_intelligence_file(root: Path) -> Path:
158
+ return project_dir(root) / "intelligence.json"
159
+
160
+
161
+ def project_network_file(root: Path) -> Path:
162
+ return project_dir(root) / "network.json"
163
+
164
+
165
+ # ── User/OS-scope paths ──────────────────────────────────────────────────────
166
+
167
+
168
+ def global_dir() -> Path:
169
+ """Return the global forktex directory, cross-platform.
170
+
171
+ POSIX: ``~/.forktex/``. Windows: ``%APPDATA%/forktex/`` (roaming user profile).
172
+ """
173
+ if sys.platform == "win32":
174
+ appdata = os.environ.get("APPDATA")
175
+ base = Path(appdata) if appdata else Path.home()
176
+ return base / _USER_DIRNAME_WINDOWS
177
+ return Path.home() / _USER_DIRNAME_POSIX
178
+
179
+
180
+ def global_cloud_file() -> Path:
181
+ return global_dir() / "cloud.json"
182
+
183
+
184
+ def global_intelligence_file() -> Path:
185
+ return global_dir() / "intelligence.json"
186
+
187
+
188
+ def global_network_file() -> Path:
189
+ return global_dir() / "network.json"
190
+
191
+
192
+ def global_config_file() -> Path:
193
+ return global_dir() / "config.toml"
194
+
195
+
196
+ # ── Lifecycle helpers ────────────────────────────────────────────────────────
197
+
198
+
199
+ def ensure_project_dirs(root: Path) -> None:
200
+ """Create ``.forktex/`` under *root*, write the schema version marker, and
201
+ ensure the project ``.gitignore`` has the canonical forktex block.
202
+
203
+ Safe to call repeatedly. Does not touch existing files.
204
+ """
205
+ pdir = project_dir(root)
206
+ pdir.mkdir(parents=True, exist_ok=True)
207
+ write_schema_version(root)
208
+ _ensure_gitignore_block(root)
209
+
210
+
211
+ def ensure_global_dir() -> None:
212
+ """Create the user/OS-scope forktex dir with secure permissions on POSIX."""
213
+ gdir = global_dir()
214
+ gdir.mkdir(parents=True, exist_ok=True)
215
+ if sys.platform != "win32":
216
+ try:
217
+ gdir.chmod(0o700)
218
+ except OSError:
219
+ # Non-fatal: existing dir with stricter perms is fine.
220
+ pass
221
+
222
+
223
+ def read_schema_version(root: Path) -> int | None:
224
+ """Return the on-disk ``.forktex/.version`` as int, or ``None`` if missing."""
225
+ vf = version_file(root)
226
+ if not vf.is_file():
227
+ return None
228
+ try:
229
+ return int(vf.read_text().strip())
230
+ except (ValueError, OSError):
231
+ return None
232
+
233
+
234
+ def write_schema_version(root: Path) -> None:
235
+ """Write ``SCHEMA_VERSION`` to ``.forktex/.version`` if not already correct."""
236
+ vf = version_file(root)
237
+ if read_schema_version(root) == SCHEMA_VERSION:
238
+ return
239
+ vf.parent.mkdir(parents=True, exist_ok=True)
240
+ vf.write_text(f"{SCHEMA_VERSION}\n")
241
+
242
+
243
+ def _ensure_gitignore_block(root: Path) -> None:
244
+ gi = root / ".gitignore"
245
+ if gi.is_file():
246
+ existing = gi.read_text()
247
+ if _GITIGNORE_MARKER in existing:
248
+ return
249
+ # Append, ensuring a trailing newline separator.
250
+ if not existing.endswith("\n"):
251
+ existing += "\n"
252
+ gi.write_text(existing + _GITIGNORE_BLOCK)
253
+ else:
254
+ gi.write_text(_GITIGNORE_BLOCK.lstrip("\n"))
255
+
256
+
257
+ __all__ = [
258
+ # Constants
259
+ "SCHEMA_VERSION",
260
+ "PROJECT_DIRNAME",
261
+ # Project-scope
262
+ "project_dir",
263
+ "version_file",
264
+ "compose_path",
265
+ "observability_dir",
266
+ "vault_dir",
267
+ "vault_secrets_file",
268
+ "state_dir",
269
+ "servers_json",
270
+ "server_keys_dir",
271
+ "generated_dir",
272
+ "data_dir",
273
+ "custom_ssl_dir",
274
+ "fsd_evidence_dir",
275
+ "architecture_dir",
276
+ "agents_history_dir",
277
+ "agents_history_file",
278
+ "agents_types_file",
279
+ "scraper_truths_dir",
280
+ "scraper_truths_file",
281
+ "scraper_output_dir",
282
+ "project_config_file",
283
+ "project_cloud_file",
284
+ "project_intelligence_file",
285
+ "project_network_file",
286
+ # User/OS-scope
287
+ "global_dir",
288
+ "global_cloud_file",
289
+ "global_intelligence_file",
290
+ "global_network_file",
291
+ "global_config_file",
292
+ # Lifecycle
293
+ "ensure_project_dirs",
294
+ "ensure_global_dir",
295
+ "read_schema_version",
296
+ "write_schema_version",
297
+ ]
@@ -0,0 +1,24 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Project scaffolding templates."""
@@ -0,0 +1,143 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """forktex cloud init -- scaffold a new forktex.json manifest."""
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from pathlib import Path
30
+
31
+ TEMPLATES: dict[str, dict] = {
32
+ "ProjectDeployment": {
33
+ "cloud": {
34
+ "apiVersion": "forktex.cloud/v1beta2",
35
+ "kind": "ProjectDeployment",
36
+ "metadata": {
37
+ "name": "",
38
+ "environment": "production",
39
+ },
40
+ "deployment": {
41
+ "strategy": "blue-green",
42
+ "router": "haproxy",
43
+ "defaultComponent": "",
44
+ "retries": 45,
45
+ "drainDelaySeconds": 5,
46
+ "gracefulStopSeconds": 30,
47
+ },
48
+ "gateway": {
49
+ "domains": [],
50
+ "ssl": {
51
+ "enabled": False,
52
+ "provider": "letsencrypt",
53
+ "email": "",
54
+ },
55
+ },
56
+ "services": [],
57
+ "observability": {
58
+ "enabled": True,
59
+ "logging": {"enabled": True},
60
+ "metrics": {"enabled": True},
61
+ },
62
+ },
63
+ },
64
+ "StaticSite": {
65
+ "cloud": {
66
+ "apiVersion": "forktex.cloud/v1beta2",
67
+ "kind": "StaticSite",
68
+ "metadata": {
69
+ "name": "",
70
+ },
71
+ "build": {
72
+ "command": "npm run build",
73
+ "outputDir": "dist",
74
+ "installCommand": "npm install",
75
+ },
76
+ "serve": {
77
+ "router": "nginx",
78
+ "spa": True,
79
+ },
80
+ },
81
+ },
82
+ "SingleContainer": {
83
+ "cloud": {
84
+ "apiVersion": "forktex.cloud/v1beta2",
85
+ "kind": "SingleContainer",
86
+ "metadata": {
87
+ "name": "",
88
+ },
89
+ "container": {
90
+ "image": "",
91
+ "port": 3000,
92
+ "healthPath": "/health",
93
+ },
94
+ "deployment": {
95
+ "strategy": "blue-green",
96
+ "router": "haproxy",
97
+ },
98
+ },
99
+ },
100
+ "NativeBuild": {
101
+ "cloud": {
102
+ "apiVersion": "forktex.cloud/v1beta2",
103
+ "kind": "NativeBuild",
104
+ "metadata": {
105
+ "name": "",
106
+ },
107
+ "build": {
108
+ "command": "",
109
+ "outputBinary": "",
110
+ },
111
+ },
112
+ },
113
+ }
114
+
115
+
116
+ def scaffold_manifest(
117
+ project_root: Path,
118
+ *,
119
+ kind: str = "ProjectDeployment",
120
+ name: str | None = None,
121
+ force: bool = False,
122
+ ) -> Path:
123
+ """Create forktex.json at the project root.
124
+
125
+ Returns the path to the created manifest.
126
+ """
127
+ target = project_root / "forktex.json"
128
+
129
+ if target.exists() and not force:
130
+ raise FileExistsError(f"forktex.json already exists: {target}. Use --force to overwrite.")
131
+
132
+ template = TEMPLATES.get(kind)
133
+ if template is None:
134
+ raise ValueError(f"Unknown kind: {kind}. Available: {', '.join(TEMPLATES.keys())}")
135
+
136
+ manifest = json.loads(json.dumps(template)) # deep copy
137
+ project_name = name or project_root.name
138
+ manifest["name"] = project_name
139
+ if "cloud" in manifest and "metadata" in manifest["cloud"]:
140
+ manifest["cloud"]["metadata"]["name"] = project_name
141
+
142
+ target.write_text(json.dumps(manifest, indent=2) + "\n")
143
+ return target
@@ -0,0 +1,24 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Secrets management: Fernet vault, provider factory, and vault reference resolver."""
@@ -0,0 +1,52 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Secrets provider abstract base class."""
25
+
26
+ from __future__ import annotations
27
+
28
+ from abc import ABC, abstractmethod
29
+
30
+
31
+ class SecretsProvider(ABC):
32
+ """Abstract base for secrets providers."""
33
+
34
+ @abstractmethod
35
+ def get(self, key: str, env: str = "default") -> str:
36
+ """Retrieve a secret value by key."""
37
+ ...
38
+
39
+ @abstractmethod
40
+ def set(self, key: str, value: str, env: str = "default") -> None:
41
+ """Store a secret value."""
42
+ ...
43
+
44
+ @abstractmethod
45
+ def list_keys(self, env: str = "default") -> list[str]:
46
+ """List all secret keys for an environment."""
47
+ ...
48
+
49
+ @abstractmethod
50
+ def delete(self, key: str, env: str = "default") -> None:
51
+ """Delete a secret by key."""
52
+ ...
@@ -0,0 +1,57 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Secrets provider factory."""
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ from pathlib import Path
30
+
31
+ from forktex_cloud import paths
32
+ from forktex_cloud.secrets.base import SecretsProvider
33
+
34
+
35
+ def get_secrets_provider(
36
+ *,
37
+ project_root: Path | None = None,
38
+ provider_name: str | None = None,
39
+ master_key: str | None = None,
40
+ ) -> SecretsProvider:
41
+ """Create and return the configured SecretsProvider.
42
+
43
+ Resolution order for provider name:
44
+ 1. Explicit ``provider_name`` argument
45
+ 2. ``FORKTEX_SECRETS_PROVIDER`` environment variable
46
+ 3. Defaults to ``"fernet"``
47
+ """
48
+ name = provider_name or os.environ.get("FORKTEX_SECRETS_PROVIDER", "fernet")
49
+
50
+ if name == "fernet":
51
+ from forktex_cloud.secrets.fernet import FernetVault
52
+
53
+ root = project_root or Path.cwd()
54
+ vault_root = paths.project_dir(root) / "vault"
55
+ return FernetVault(vault_root, master_key=master_key)
56
+
57
+ raise ValueError(f"Unknown secrets provider: {name}")
@@ -0,0 +1,96 @@
1
+ # Copyright (C) 2026 FORKTEX S.R.L.
2
+ #
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later OR LicenseRef-ForkTex-Commercial
4
+ #
5
+ # This file is part of forktex-cloud.
6
+ #
7
+ # For commercial licensing -- including use in proprietary products, SaaS
8
+ # deployments, or any context where AGPL obligations cannot be met -- you
9
+ # MUST obtain a commercial license from FORKTEX S.R.L. (info@forktex.com).
10
+ #
11
+ # This program is free software: you can redistribute it and/or modify
12
+ # it under the terms of the GNU Affero General Public License as published by
13
+ # the Free Software Foundation, either version 3 of the License, or
14
+ # (at your option) any later version.
15
+ #
16
+ # This program is distributed in the hope that it will be useful,
17
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
18
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
+ # GNU Affero General Public License for more details.
20
+ #
21
+ # You should have received a copy of the GNU Affero General Public License
22
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
23
+
24
+ """Built-in Fernet-encrypted file vault for secrets."""
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import os
30
+ from pathlib import Path
31
+
32
+ from forktex_cloud.secrets.base import SecretsProvider
33
+
34
+
35
+ class FernetVault(SecretsProvider):
36
+ """File-backed secrets vault using Fernet symmetric encryption.
37
+
38
+ Storage layout: ``.forktex/vault/{env}/secrets.enc``
39
+ """
40
+
41
+ def __init__(self, vault_root: Path, master_key: str | None = None) -> None:
42
+ self._root = vault_root
43
+ self._key = master_key or os.environ.get("FORKTEX_MASTER_KEY", "")
44
+ if not self._key:
45
+ raise ValueError(
46
+ "Master key required for vault operations. "
47
+ "Set FORKTEX_MASTER_KEY or pass master_key."
48
+ )
49
+
50
+ def _fernet(self):
51
+ from cryptography.fernet import Fernet
52
+
53
+ return Fernet(self._key.encode() if isinstance(self._key, str) else self._key)
54
+
55
+ def _vault_path(self, env: str) -> Path:
56
+ return self._root / env / "secrets.enc"
57
+
58
+ def _load(self, env: str) -> dict[str, str]:
59
+ path = self._vault_path(env)
60
+ if not path.exists():
61
+ return {}
62
+ encrypted = path.read_bytes()
63
+ try:
64
+ decrypted = self._fernet().decrypt(encrypted)
65
+ except Exception:
66
+ raise ValueError(
67
+ f"Failed to decrypt vault ({path}). "
68
+ "Check that FORKTEX_MASTER_KEY matches the key used to encrypt."
69
+ )
70
+ return json.loads(decrypted)
71
+
72
+ def _save(self, env: str, data: dict[str, str]) -> None:
73
+ path = self._vault_path(env)
74
+ path.parent.mkdir(parents=True, exist_ok=True)
75
+ raw = json.dumps(data, indent=2).encode()
76
+ encrypted = self._fernet().encrypt(raw)
77
+ path.write_bytes(encrypted)
78
+
79
+ def get(self, key: str, env: str = "default") -> str:
80
+ data = self._load(env)
81
+ if key not in data:
82
+ raise KeyError(f"Secret not found: {key} (env={env})")
83
+ return data[key]
84
+
85
+ def set(self, key: str, value: str, env: str = "default") -> None:
86
+ data = self._load(env)
87
+ data[key] = value
88
+ self._save(env, data)
89
+
90
+ def list_keys(self, env: str = "default") -> list[str]:
91
+ return sorted(self._load(env).keys())
92
+
93
+ def delete(self, key: str, env: str = "default") -> None:
94
+ data = self._load(env)
95
+ data.pop(key, None)
96
+ self._save(env, data)