@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.40.0
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.
- package/CHANGELOG.md +79 -0
- package/README.md +9 -3
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +15 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +64 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +264 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +128 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +10 -183
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +286 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +840 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +1 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +13 -45
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +8 -67
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -12
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +400 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +78 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +902 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +289 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +15 -180
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +39 -400
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +137 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +1 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +21 -43
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +31 -38
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +412 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1526 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +116 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +270 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +508 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +265 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +155 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +335 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +1 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Who may call a service: the descriptor token, the network guard,
|
|
2
|
+
the operator's one-time login and its session, remote placement (DEC-0019) and the feed client's token header.
|
|
3
|
+
Part of the service kit (FAA-65, layer L3)."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import datetime as _dt
|
|
7
|
+
import hashlib
|
|
8
|
+
import hmac
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import stat
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
import sys as _sys # noqa: E402
|
|
21
|
+
from pathlib import Path as _Path # noqa: E402
|
|
22
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
23
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
24
|
+
from fabric_base import (
|
|
25
|
+
PROTOCOL,
|
|
26
|
+
ServiceError,
|
|
27
|
+
WINDOWS,
|
|
28
|
+
now_iso,
|
|
29
|
+
)
|
|
30
|
+
from fabric_platform import (
|
|
31
|
+
_win_token_problem,
|
|
32
|
+
)
|
|
33
|
+
from fabric_paths import (
|
|
34
|
+
atomic_write,
|
|
35
|
+
ensure_private_dir,
|
|
36
|
+
services_dir,
|
|
37
|
+
)
|
|
38
|
+
from fabric_descriptor import (
|
|
39
|
+
validate_descriptor,
|
|
40
|
+
write_descriptor,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
LOGIN_CODE_TTL_SECONDS = 120
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# DEC-0039 (service.md "The session's lifetime"): the longest a service may keep an operator session.
|
|
48
|
+
OPERATOR_SESSION_IDLE_SECONDS = 12 * 60 * 60
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
OPERATOR_SESSION_MAX_SECONDS = 7 * 24 * 60 * 60
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_CODE = re.compile(r"^[A-Za-z0-9_-]{16,256}$")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def ensure_token(path: Path) -> str:
|
|
58
|
+
"""Create the service token once (0600, owner only); never rotate silently."""
|
|
59
|
+
path = Path(path)
|
|
60
|
+
if path.exists():
|
|
61
|
+
return read_token(path)
|
|
62
|
+
ensure_private_dir(path.parent)
|
|
63
|
+
token = secrets.token_urlsafe(32)
|
|
64
|
+
atomic_write(path, token.encode(), 0o600)
|
|
65
|
+
return token
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read_token(path: Path) -> str:
|
|
69
|
+
"""Refuse a symlink, a file owned by someone else, or one others can read.
|
|
70
|
+
|
|
71
|
+
On Windows the rule is service.md "Windows token files" (DEC-0032, DEC-0033, DEC-0057):
|
|
72
|
+
windows_token_acl_problem over the file's flags, owner and ACEs; a refusal names the SID,
|
|
73
|
+
never the contents."""
|
|
74
|
+
path = Path(path)
|
|
75
|
+
info = os.lstat(path)
|
|
76
|
+
if stat.S_ISLNK(info.st_mode):
|
|
77
|
+
raise ServiceError("Token file %s is a symlink; refusing it." % path)
|
|
78
|
+
if WINDOWS:
|
|
79
|
+
problem = _win_token_problem(path, info)
|
|
80
|
+
if problem:
|
|
81
|
+
raise ServiceError(problem)
|
|
82
|
+
else:
|
|
83
|
+
if info.st_uid != os.getuid():
|
|
84
|
+
raise ServiceError("Token file %s belongs to another user." % path)
|
|
85
|
+
if info.st_mode & 0o077:
|
|
86
|
+
raise ServiceError("Token file %s is readable by others; set mode 0600." % path)
|
|
87
|
+
token = path.read_text().strip()
|
|
88
|
+
if len(token) < 16:
|
|
89
|
+
raise ServiceError("Token file %s holds no usable token." % path)
|
|
90
|
+
return token
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def token_matches(presented: Optional[str], token: str, scheme: str = "Bearer") -> bool:
|
|
94
|
+
if not presented:
|
|
95
|
+
return False
|
|
96
|
+
value = presented
|
|
97
|
+
if scheme == "Bearer":
|
|
98
|
+
if not presented.startswith("Bearer "):
|
|
99
|
+
return False
|
|
100
|
+
value = presented[len("Bearer "):]
|
|
101
|
+
return hmac.compare_digest(value.strip().encode(), token.encode())
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def check_request(port: int, host: Optional[str], origin: Optional[str] = None,
|
|
105
|
+
sec_fetch_site: Optional[str] = None) -> Optional[str]:
|
|
106
|
+
"""Return why a request must be refused (403), or None when it may proceed."""
|
|
107
|
+
allowed_hosts = {"127.0.0.1:%d" % port, "localhost:%d" % port, "[::1]:%d" % port}
|
|
108
|
+
if host not in allowed_hosts:
|
|
109
|
+
return "Host %r is not this service." % host
|
|
110
|
+
if origin is not None and origin not in {"http://" + h for h in allowed_hosts}:
|
|
111
|
+
return "Origin %r is not this service." % origin
|
|
112
|
+
if sec_fetch_site == "cross-site":
|
|
113
|
+
return "Cross-site requests are refused."
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def token_header(auth: Any, token: str) -> Tuple[str, str]:
|
|
118
|
+
"""The one header a client sends the service token in, as the descriptor's ``auth`` declares it: ``header``
|
|
119
|
+
defaults to Authorization and ``scheme`` to Bearer; ``none`` carries the raw token. Never assume
|
|
120
|
+
``Authorization: Bearer`` (a feed client that did was refused on every poll and dropped every notify event)."""
|
|
121
|
+
declared = auth if isinstance(auth, dict) else {}
|
|
122
|
+
name = declared.get("header") if isinstance(declared.get("header"), str) else "Authorization"
|
|
123
|
+
scheme = declared.get("scheme") if isinstance(declared.get("scheme"), str) else "Bearer"
|
|
124
|
+
return name, token if scheme == "none" else "Bearer " + token
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class LoginCodes:
|
|
128
|
+
"""Single-use login codes (<=120 s), recorded as used BEFORE they are honoured, and operator
|
|
129
|
+
sessions with the DEC-0039 lifetime: a session ends 12 h after its last use, 7 days after the code
|
|
130
|
+
was redeemed, on logout, and when the descriptor token rotates. The server enforces all four: the
|
|
131
|
+
cookie carries a signed claim (session id, sign-in time, token revision) and the code store keeps a
|
|
132
|
+
record per session (last use, logout). Rotating the key with revoke_all() ends every session."""
|
|
133
|
+
|
|
134
|
+
def __init__(self, state_dir: Optional[Path], ttl: int = LOGIN_CODE_TTL_SECONDS, *,
|
|
135
|
+
store: Optional["MemoryCodeStore"] = None, key: Optional[bytes] = None,
|
|
136
|
+
token: "Optional[str | Callable[[], str]]" = None,
|
|
137
|
+
idle_seconds: int = OPERATOR_SESSION_IDLE_SECONDS,
|
|
138
|
+
max_seconds: int = OPERATOR_SESSION_MAX_SECONDS,
|
|
139
|
+
clock: Callable[[], float] = time.time):
|
|
140
|
+
# A local service keeps codes, sessions and the key in files. An online one (DEC-0019) usually has
|
|
141
|
+
# no durable disk: pass state_dir=None with store=MemoryCodeStore() and key= bytes from a platform
|
|
142
|
+
# secret. A memory store forgets codes AND sessions on a restart, so a deploy signs the operator
|
|
143
|
+
# out (the host signs in again on the page's 401); pass a durable store to keep sessions.
|
|
144
|
+
# token= is the descriptor token, or a callable returning the current one: a session begun under
|
|
145
|
+
# one token is refused once it rotates. A service MAY shorten either bound, never lengthen one.
|
|
146
|
+
self.ttl = min(ttl, LOGIN_CODE_TTL_SECONDS)
|
|
147
|
+
self.idle_seconds = _session_bound(idle_seconds, OPERATOR_SESSION_IDLE_SECONDS)
|
|
148
|
+
self.max_seconds = _session_bound(max_seconds, OPERATOR_SESSION_MAX_SECONDS)
|
|
149
|
+
# One lock around every load-modify-save, so a concurrent refresh never saves over a logout or a
|
|
150
|
+
# burned code (a threading server checks sessions on several threads; the instance lock keeps
|
|
151
|
+
# other processes out of the state directory).
|
|
152
|
+
self._lock = threading.RLock()
|
|
153
|
+
self._store = store
|
|
154
|
+
self._fixed_key = key
|
|
155
|
+
self._token = token
|
|
156
|
+
self._clock = clock
|
|
157
|
+
if state_dir is not None:
|
|
158
|
+
self.dir = ensure_private_dir(Path(state_dir))
|
|
159
|
+
self._key_path = self.dir / "session.key"
|
|
160
|
+
self._codes_path = self.dir / "login-codes.json"
|
|
161
|
+
elif store is None or key is None:
|
|
162
|
+
raise ServiceError("LoginCodes without a state directory needs store= and key=.")
|
|
163
|
+
if key is not None and len(key) < 32:
|
|
164
|
+
raise ServiceError("the session key must be at least 32 bytes.")
|
|
165
|
+
|
|
166
|
+
def _key(self) -> bytes:
|
|
167
|
+
if self._fixed_key is not None:
|
|
168
|
+
return bytes(self._fixed_key)
|
|
169
|
+
if not self._key_path.exists():
|
|
170
|
+
atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
|
|
171
|
+
return self._key_path.read_bytes()
|
|
172
|
+
|
|
173
|
+
def _load(self) -> Dict[str, Any]:
|
|
174
|
+
if self._store is not None:
|
|
175
|
+
return self._store.load()
|
|
176
|
+
try:
|
|
177
|
+
return json.loads(self._codes_path.read_text())
|
|
178
|
+
except (OSError, ValueError):
|
|
179
|
+
return {}
|
|
180
|
+
|
|
181
|
+
def _save(self, codes: Dict[str, Any]) -> None:
|
|
182
|
+
horizon = self._clock() - 3600
|
|
183
|
+
codes = {k: v for k, v in codes.items() if v.get("expires", 0) > horizon}
|
|
184
|
+
if self._store is not None:
|
|
185
|
+
self._store.save(codes)
|
|
186
|
+
return
|
|
187
|
+
atomic_write(self._codes_path, json.dumps(codes).encode(), 0o600)
|
|
188
|
+
|
|
189
|
+
def issue(self) -> Dict[str, str]:
|
|
190
|
+
with self._lock:
|
|
191
|
+
code = secrets.token_urlsafe(24)
|
|
192
|
+
expires = self._clock() + self.ttl
|
|
193
|
+
codes = self._load()
|
|
194
|
+
codes[hashlib.sha256(code.encode()).hexdigest()] = {"expires": expires, "used": False}
|
|
195
|
+
self._save(codes)
|
|
196
|
+
at = _dt.datetime.fromtimestamp(expires, _dt.timezone.utc).replace(microsecond=0)
|
|
197
|
+
return {"url": "/fabric/v1/login?code=" + code, "expiresAt": at.isoformat().replace("+00:00", "Z")}
|
|
198
|
+
|
|
199
|
+
def redeem(self, code: Optional[str]) -> Optional[str]:
|
|
200
|
+
"""Return a session cookie value, or None. The code is burned even when it has expired."""
|
|
201
|
+
with self._lock:
|
|
202
|
+
if not code or not _CODE.match(code):
|
|
203
|
+
return None
|
|
204
|
+
digest = hashlib.sha256(code.encode()).hexdigest()
|
|
205
|
+
codes = self._load()
|
|
206
|
+
entry = codes.get(digest)
|
|
207
|
+
if not entry or entry.get("used"):
|
|
208
|
+
return None
|
|
209
|
+
entry["used"] = True
|
|
210
|
+
self._save(codes)
|
|
211
|
+
now = self._clock()
|
|
212
|
+
if entry.get("expires", 0) < now:
|
|
213
|
+
return None
|
|
214
|
+
session_id = secrets.token_urlsafe(18)
|
|
215
|
+
signed_in = int(now)
|
|
216
|
+
codes[self._record_key(session_id)] = {"expires": signed_in + self.max_seconds,
|
|
217
|
+
"signedInAt": signed_in, "lastUsedAt": now}
|
|
218
|
+
self._save(codes)
|
|
219
|
+
claim = "v2.%s.%d.%s" % (session_id, signed_in, self._token_revision())
|
|
220
|
+
return claim + "." + self._mac(claim)
|
|
221
|
+
|
|
222
|
+
def session_valid(self, cookie_value: Optional[str]) -> bool:
|
|
223
|
+
"""True while the session is live; a use within the bounds refreshes the idle bound."""
|
|
224
|
+
with self._lock:
|
|
225
|
+
parsed = self._parse(cookie_value)
|
|
226
|
+
if parsed is None:
|
|
227
|
+
return False
|
|
228
|
+
session_id, signed_in = parsed
|
|
229
|
+
now = self._clock()
|
|
230
|
+
if now >= signed_in + self.max_seconds:
|
|
231
|
+
return False
|
|
232
|
+
codes = self._load()
|
|
233
|
+
record = codes.get(self._record_key(session_id))
|
|
234
|
+
if not record or record.get("signedInAt") != signed_in or record.get("loggedOutAt") is not None:
|
|
235
|
+
return False
|
|
236
|
+
last = float(record.get("lastUsedAt", 0))
|
|
237
|
+
if now >= last + self.idle_seconds:
|
|
238
|
+
return False
|
|
239
|
+
if now - last >= min(60, max(1, self.idle_seconds // 10)):
|
|
240
|
+
record["lastUsedAt"] = now
|
|
241
|
+
self._save(codes)
|
|
242
|
+
return True
|
|
243
|
+
|
|
244
|
+
def logout(self, cookie_value: Optional[str]) -> bool:
|
|
245
|
+
"""End this session on the server: every copy of the cookie is refused from now on. Clear the
|
|
246
|
+
browser's copy with expired_session_cookie_header(). False when there was no live record."""
|
|
247
|
+
with self._lock:
|
|
248
|
+
parsed = self._parse(cookie_value)
|
|
249
|
+
if parsed is None:
|
|
250
|
+
return False
|
|
251
|
+
codes = self._load()
|
|
252
|
+
record = codes.get(self._record_key(parsed[0]))
|
|
253
|
+
if not record or record.get("signedInAt") != parsed[1] or record.get("loggedOutAt") is not None:
|
|
254
|
+
return False
|
|
255
|
+
record["loggedOutAt"] = self._clock()
|
|
256
|
+
self._save(codes)
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
def _parse(self, cookie_value: Optional[str]) -> Optional[Tuple[str, int]]:
|
|
260
|
+
"""The signed claim of a cookie under the current key and token, or None."""
|
|
261
|
+
if not isinstance(cookie_value, str) or not cookie_value.isascii():
|
|
262
|
+
return None
|
|
263
|
+
parts = cookie_value.split(".")
|
|
264
|
+
if len(parts) != 5 or parts[0] != "v2" or not parts[1] or not parts[2].isdigit():
|
|
265
|
+
return None
|
|
266
|
+
claim, mac = ".".join(parts[:4]), parts[4]
|
|
267
|
+
if not hmac.compare_digest(mac, self._mac(claim)):
|
|
268
|
+
return None
|
|
269
|
+
if not hmac.compare_digest(parts[3], self._token_revision()):
|
|
270
|
+
return None
|
|
271
|
+
return parts[1], int(parts[2])
|
|
272
|
+
|
|
273
|
+
def _mac(self, claim: str) -> str:
|
|
274
|
+
return hmac.new(self._key(), claim.encode(), hashlib.sha256).hexdigest()
|
|
275
|
+
|
|
276
|
+
def _token_revision(self) -> str:
|
|
277
|
+
token = self._token() if callable(self._token) else self._token
|
|
278
|
+
if not token:
|
|
279
|
+
return "-"
|
|
280
|
+
return hmac.new(self._key(), b"token-revision\0" + token.encode(), hashlib.sha256).hexdigest()[:16]
|
|
281
|
+
|
|
282
|
+
@staticmethod
|
|
283
|
+
def _record_key(session_id: str) -> str:
|
|
284
|
+
return "s:" + hashlib.sha256(session_id.encode()).hexdigest()
|
|
285
|
+
|
|
286
|
+
def revoke_all(self) -> None:
|
|
287
|
+
if self._fixed_key is not None:
|
|
288
|
+
raise ServiceError("a platform-held session key is rotated on the platform, not here.")
|
|
289
|
+
atomic_write(self._key_path, secrets.token_bytes(32), 0o600)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _session_bound(value: Any, longest: int) -> int:
|
|
293
|
+
"""A session bound in seconds: a finite number, never longer than the contract's (DEC-0039)."""
|
|
294
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value or value in (float("inf"), float("-inf")):
|
|
295
|
+
raise ServiceError("a session bound must be a finite number of seconds.")
|
|
296
|
+
return min(int(value), longest)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class MemoryCodeStore:
|
|
300
|
+
"""DEC-0019: a login-code store held in memory — for an online service with no durable disk."""
|
|
301
|
+
|
|
302
|
+
def __init__(self) -> None:
|
|
303
|
+
self._codes: Dict[str, Any] = {}
|
|
304
|
+
|
|
305
|
+
def load(self) -> Dict[str, Any]:
|
|
306
|
+
return json.loads(json.dumps(self._codes))
|
|
307
|
+
|
|
308
|
+
def save(self, codes: Dict[str, Any]) -> None:
|
|
309
|
+
self._codes = json.loads(json.dumps(codes))
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
SESSION_COOKIE = "fabric_session"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
REMOTE_SESSION_COOKIE = "__Host-fabric_session"
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
|
|
319
|
+
"""The cookie lives no longer than the absolute bound (DEC-0039); the server ends it sooner."""
|
|
320
|
+
return "%s=%s; Path=/; HttpOnly; SameSite=Strict; Max-Age=%d" % (SESSION_COOKIE, value, max_age)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def remote_session_cookie_header(value: str, max_age: int = OPERATOR_SESSION_MAX_SECONDS) -> str:
|
|
324
|
+
"""DEC-0019: the remote cookie — __Host- name, Secure, no Domain, Path=/."""
|
|
325
|
+
return "%s=%s; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=%d" % (REMOTE_SESSION_COOKIE, value, max_age)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def expired_session_cookie_header(remote: bool = False) -> str:
|
|
329
|
+
"""The header that clears the browser's session cookie, sent with a logout."""
|
|
330
|
+
return (remote_session_cookie_header if remote else session_cookie_header)("", 0)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def check_remote_request(origin: str, host: Optional[str], request_origin: Optional[str] = None,
|
|
334
|
+
sec_fetch_site: Optional[str] = None, forwarded_proto: Optional[str] = None) -> Optional[str]:
|
|
335
|
+
"""DEC-0019: the request guard of an online service; returns the refusal sentence or None.
|
|
336
|
+
``forwarded_proto`` is the platform-set scheme where TLS ends before the process; pass None
|
|
337
|
+
when the process terminates TLS itself."""
|
|
338
|
+
own = urllib.parse.urlsplit(origin)
|
|
339
|
+
own_host = own.netloc.lower()
|
|
340
|
+
if str(host or "").lower() != own_host:
|
|
341
|
+
return "Host %s is not this service." % host
|
|
342
|
+
if forwarded_proto is not None and str(forwarded_proto).split(",")[0].strip() != "https":
|
|
343
|
+
return "This service answers over https only."
|
|
344
|
+
if request_origin is not None and request_origin != "%s://%s" % (own.scheme, own_host):
|
|
345
|
+
return "Origin %s is not this service." % request_origin
|
|
346
|
+
if sec_fetch_site == "cross-site":
|
|
347
|
+
return "Cross-site requests are refused."
|
|
348
|
+
return None
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def well_known_allowed(placement: str, authorization: Optional[str], token: str, scheme: str = "Bearer") -> bool:
|
|
352
|
+
"""DEC-0019: a local service answers anyone (the loopback guard already ran); a remote one only
|
|
353
|
+
the bearer of the service token. False is a 401 with an EMPTY body."""
|
|
354
|
+
return placement != "remote" or token_matches(authorization, token, scheme)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def remote_token_file(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
|
|
358
|
+
return (directory or services_dir()).parent / "tokens" / ("%s.%s.token" % (service_id, instance))
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def register_remote(*, service_id: str, name: str, origin: str, token: str, instance: str = "default",
|
|
362
|
+
summary: Optional[str] = None, doctor: Optional[List[str]] = None,
|
|
363
|
+
directory: Optional[Path] = None, installed_by: Any = None,
|
|
364
|
+
runner: Optional[Dict[str, str]] = None, peer: Optional[Dict[str, Any]] = None) -> Path:
|
|
365
|
+
"""DEC-0019, installer side: register an online service on THIS computer — the token file
|
|
366
|
+
(0600, never printed) and the descriptor. The same token is set on the platform as a secret.
|
|
367
|
+
|
|
368
|
+
DEC-0061: ``installed_by`` defaults to ``{"by": "person", "role": "operator", "tool": "fabric-service
|
|
369
|
+
register-remote"}`` (a person runs this); pass ``role: "administrator"`` for an organization's administrator, or a
|
|
370
|
+
string as before. ``runner`` (``{kind?, credentialKind}``) says how its model calls sign in, as a kind and never a
|
|
371
|
+
credential; ``peer`` (``{endpoint, custody, residency, maxDataClass}``) offers it to others and needs ``runner``."""
|
|
372
|
+
if not token or len(token.strip()) < 16:
|
|
373
|
+
raise ServiceError("the service token must be at least 16 characters.")
|
|
374
|
+
root = directory or services_dir()
|
|
375
|
+
token_file = remote_token_file(service_id, instance, root)
|
|
376
|
+
descriptor: Dict[str, Any] = {"protocol": PROTOCOL, "id": service_id, "instance": instance, "name": name,
|
|
377
|
+
"placement": "remote", "origin": origin, "auth": {"tokenFile": str(token_file)},
|
|
378
|
+
"lifecycle": {"manager": "none"}, "installedAt": now_iso(),
|
|
379
|
+
"installedBy": installed_by if installed_by is not None else
|
|
380
|
+
{"by": "person", "role": "operator", "tool": "fabric-service register-remote"}}
|
|
381
|
+
if summary:
|
|
382
|
+
descriptor["summary"] = summary
|
|
383
|
+
if runner is not None:
|
|
384
|
+
descriptor["runner"] = dict(runner)
|
|
385
|
+
if peer is not None:
|
|
386
|
+
descriptor["peer"] = dict(peer)
|
|
387
|
+
if doctor:
|
|
388
|
+
descriptor["commands"] = {"doctor": list(doctor)}
|
|
389
|
+
problems = validate_descriptor(descriptor)
|
|
390
|
+
if problems:
|
|
391
|
+
raise ServiceError("Descriptor is invalid: %s." % "; ".join(problems))
|
|
392
|
+
ensure_private_dir(token_file.parent)
|
|
393
|
+
atomic_write(token_file, token.strip().encode(), 0o600)
|
|
394
|
+
return write_descriptor(descriptor, root)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def cookie_value(cookie_header: Optional[str], name: str = SESSION_COOKIE) -> Optional[str]:
|
|
398
|
+
for part in (cookie_header or "").split(";"):
|
|
399
|
+
key, _, value = part.strip().partition("=")
|
|
400
|
+
if key == name:
|
|
401
|
+
return value
|
|
402
|
+
return None
|