@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
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py
CHANGED
|
@@ -30,8 +30,39 @@ from pathlib import Path
|
|
|
30
30
|
from typing import Any, Dict, List, Optional, Tuple
|
|
31
31
|
|
|
32
32
|
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
33
|
-
import
|
|
33
|
+
from fabric_crypto import ed25519_verify, is_small_order_key # noqa: E402 - beside this file (FAA-65)
|
|
34
|
+
from fabric_jsvalue import jcs # noqa: E402 - beside this file (FAA-65)
|
|
34
35
|
import fabric_secrets as _secrets # noqa: E402 - beside this file: the contract's credential detector
|
|
36
|
+
from fabric_crypto import ( # noqa: E402 - the core (FAA-65)
|
|
37
|
+
_P256_B as _B,
|
|
38
|
+
_P256_N as _N,
|
|
39
|
+
_P256_P as _P,
|
|
40
|
+
_der_signature,
|
|
41
|
+
b64url as _b64url,
|
|
42
|
+
b64url_bytes as _b64url_bytes,
|
|
43
|
+
es256_verify,
|
|
44
|
+
is_valid_p256_key,
|
|
45
|
+
node_base64 as _node_base64,
|
|
46
|
+
sha256 as _sha256,
|
|
47
|
+
)
|
|
48
|
+
from fabric_jsvalue import ( # noqa: E402 - the core (FAA-65)
|
|
49
|
+
MISSING as _MISSING,
|
|
50
|
+
contains as _contains,
|
|
51
|
+
get as _get,
|
|
52
|
+
get_string as _s,
|
|
53
|
+
indexed_objects as _indexed,
|
|
54
|
+
js_string as _js_string,
|
|
55
|
+
js_to_number as _js_to_number,
|
|
56
|
+
key_of as _key_of,
|
|
57
|
+
objects as _list,
|
|
58
|
+
safe_integer as _safe_integer,
|
|
59
|
+
same as _same,
|
|
60
|
+
show as _show,
|
|
61
|
+
)
|
|
62
|
+
from fabric_time import ( # noqa: E402 - the core (FAA-65)
|
|
63
|
+
clock_ms as _clock,
|
|
64
|
+
parse_rfc3339_ms as estate_time,
|
|
65
|
+
)
|
|
35
66
|
|
|
36
67
|
ESTATE_RECORD_SCHEMA = "estate-record/1"
|
|
37
68
|
ESTATE_CHALLENGE_TYPE = "estate-revision/1"
|
|
@@ -41,202 +72,12 @@ MAX_VALIDITY_SECONDS = 30 * 24 * 60 * 60
|
|
|
41
72
|
CLOCK_SKEW_SECONDS = 60
|
|
42
73
|
ASSERTION_ALGORITHMS = ("ES256", "EdDSA")
|
|
43
74
|
FLAGS = {"UP": 0x01, "UV": 0x04, "BE": 0x08, "BS": 0x10, "AT": 0x40, "ED": 0x80}
|
|
44
|
-
_MISSING = object()
|
|
45
75
|
_B64URL = re.compile(r"[A-Za-z0-9_-]+")
|
|
46
76
|
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
47
77
|
|
|
48
78
|
|
|
49
79
|
# --- JavaScript's view of JSON values, which the contract's answers are written in ---------------------------------
|
|
50
80
|
|
|
51
|
-
def _js_string(value: Any) -> str:
|
|
52
|
-
"""String(value) for a JSON value."""
|
|
53
|
-
if value is _MISSING:
|
|
54
|
-
return "undefined"
|
|
55
|
-
if value is None:
|
|
56
|
-
return "null"
|
|
57
|
-
if value is True:
|
|
58
|
-
return "true"
|
|
59
|
-
if value is False:
|
|
60
|
-
return "false"
|
|
61
|
-
if isinstance(value, float):
|
|
62
|
-
if math.isnan(value):
|
|
63
|
-
return "NaN"
|
|
64
|
-
if math.isinf(value):
|
|
65
|
-
return "Infinity" if value > 0 else "-Infinity"
|
|
66
|
-
return _delegation._number(value)
|
|
67
|
-
if isinstance(value, int):
|
|
68
|
-
if abs(value) < 2 ** 53:
|
|
69
|
-
return str(value)
|
|
70
|
-
try:
|
|
71
|
-
return _delegation._number(float(value))
|
|
72
|
-
except OverflowError: # a JSON number beyond a double is Infinity in JavaScript
|
|
73
|
-
return "Infinity" if value > 0 else "-Infinity"
|
|
74
|
-
if isinstance(value, dict):
|
|
75
|
-
return "[object Object]"
|
|
76
|
-
if isinstance(value, list):
|
|
77
|
-
return ",".join("" if item is None else _js_string(item) for item in value)
|
|
78
|
-
return str(value)
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _get(value: Any, key: str) -> Any:
|
|
82
|
-
return value.get(key, _MISSING) if isinstance(value, dict) else _MISSING
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
def _s(value: Any, key: str) -> str:
|
|
86
|
-
return _js_string(_get(value, key))
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _number(value: Any) -> bool:
|
|
90
|
-
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
def _double(value: Any) -> float:
|
|
94
|
-
"""A JSON number as JavaScript holds it: the nearest double, and an integer beyond the double range ±Infinity."""
|
|
95
|
-
try:
|
|
96
|
-
return float(value)
|
|
97
|
-
except OverflowError:
|
|
98
|
-
return math.inf if value > 0 else -math.inf
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
def _same(a: Any, b: Any) -> bool:
|
|
102
|
-
"""JavaScript's === (and a Set's SameValueZero) on parsed JSON: an object equals only itself."""
|
|
103
|
-
if a is _MISSING or b is _MISSING:
|
|
104
|
-
return a is b
|
|
105
|
-
if isinstance(a, (dict, list)) or isinstance(b, (dict, list)):
|
|
106
|
-
return a is b
|
|
107
|
-
if isinstance(a, bool) or isinstance(b, bool):
|
|
108
|
-
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
109
|
-
if _number(a) and _number(b):
|
|
110
|
-
return _double(a) == _double(b) # 2**53 + 1 is 2**53 in JavaScript
|
|
111
|
-
return type(a) is type(b) and a == b
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def _contains(items: List[Any], value: Any) -> bool:
|
|
115
|
-
return any(_same(item, value) for item in items)
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
#: The characters JavaScript's StringToNumber trims: WhiteSpace and LineTerminator (ECMA-262 §12.2, §12.3).
|
|
119
|
-
_JS_SPACE = "\t\n\v\f\r
"
|
|
120
|
-
_JS_DECIMAL = re.compile(r"[+-]?(?:(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][+-]?[0-9]+)?|Infinity)", re.ASCII)
|
|
121
|
-
_JS_RADIX = re.compile(r"0(?:[xX](?P<x>[0-9a-fA-F]+)|[oO](?P<o>[0-7]+)|[bB](?P<b>[01]+))", re.ASCII)
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _js_to_number(value: Any) -> float:
|
|
125
|
-
"""Number(value) for a JSON value, as JavaScript reads it; NaN for what it cannot read as a number."""
|
|
126
|
-
if value is _MISSING:
|
|
127
|
-
return math.nan
|
|
128
|
-
if value is None:
|
|
129
|
-
return 0.0
|
|
130
|
-
if isinstance(value, bool):
|
|
131
|
-
return 1.0 if value else 0.0
|
|
132
|
-
if _number(value):
|
|
133
|
-
try:
|
|
134
|
-
return float(value)
|
|
135
|
-
except OverflowError:
|
|
136
|
-
return math.inf if value > 0 else -math.inf
|
|
137
|
-
if isinstance(value, list):
|
|
138
|
-
return _js_to_number(_js_string(value)) # Number([2]) is Number("2"); Number([]) is 0
|
|
139
|
-
if isinstance(value, str):
|
|
140
|
-
text = value.strip(_JS_SPACE)
|
|
141
|
-
if text == "":
|
|
142
|
-
return 0.0
|
|
143
|
-
if _JS_DECIMAL.fullmatch(text):
|
|
144
|
-
return float(text.replace("Infinity", "inf"))
|
|
145
|
-
radix = _JS_RADIX.fullmatch(text)
|
|
146
|
-
if radix:
|
|
147
|
-
digits, base = next((radix.group(k), b) for k, b in (("x", 16), ("o", 8), ("b", 2)) if radix.group(k))
|
|
148
|
-
return _double(int(digits, base))
|
|
149
|
-
return math.nan
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _safe_integer(value: Any) -> bool:
|
|
153
|
-
if isinstance(value, int) and not isinstance(value, bool):
|
|
154
|
-
return abs(value) <= 2 ** 53 - 1 # checked before any float: a 400-digit integer would overflow
|
|
155
|
-
return isinstance(value, float) and math.isfinite(value) and value.is_integer() and abs(value) <= 2 ** 53 - 1
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
def _list(value: Any) -> List[Dict[str, Any]]:
|
|
159
|
-
return [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _indexed(value: Any) -> List[Tuple[Dict[str, Any], int]]:
|
|
163
|
-
"""The object items of an array with their index in the array as written, so an instancePath names the item it
|
|
164
|
-
flags even when a non-object precedes it (the contract's ``indexed``)."""
|
|
165
|
-
return [(item, i) for i, item in enumerate(value) if isinstance(item, dict)] if isinstance(value, list) else []
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
class _Unshowable(Exception):
|
|
169
|
-
pass
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
def _strict_string(value: Any) -> str:
|
|
173
|
-
"""String(value), raising where JavaScript's String() throws: an object whose own ``toString`` is not a function
|
|
174
|
-
(every own member of parsed JSON is data), directly or inside an array it joins."""
|
|
175
|
-
if isinstance(value, dict):
|
|
176
|
-
if "toString" in value:
|
|
177
|
-
raise _Unshowable()
|
|
178
|
-
return "[object Object]"
|
|
179
|
-
if isinstance(value, list):
|
|
180
|
-
return ",".join("" if item is None else _strict_string(item) for item in value)
|
|
181
|
-
return _js_string(value)
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
_INDEX_KEY = re.compile(r"0|[1-9][0-9]*", re.ASCII)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def _js_keys(value: Dict[str, Any]) -> List[str]:
|
|
188
|
-
"""An object's own keys in the order JavaScript lists them: array-index keys ("0" … "4294967294", no leading zero)
|
|
189
|
-
ascending, then the others in the order written."""
|
|
190
|
-
index = sorted((k for k in value if _INDEX_KEY.fullmatch(k) and int(k) <= 2 ** 32 - 2), key=int)
|
|
191
|
-
taken = set(index)
|
|
192
|
-
return index + [k for k in value if k not in taken]
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _js_json_string(text: str) -> str:
|
|
196
|
-
"""JSON.stringify of a string (well-formed, ES2019): a lone surrogate is escaped as \\uXXXX, never refused."""
|
|
197
|
-
out = ['"']
|
|
198
|
-
for ch in text:
|
|
199
|
-
code = ord(ch)
|
|
200
|
-
if ch in _delegation._ESCAPES:
|
|
201
|
-
out.append(_delegation._ESCAPES[ch])
|
|
202
|
-
elif code < 0x20 or 0xD800 <= code <= 0xDFFF:
|
|
203
|
-
out.append("\\u%04x" % code)
|
|
204
|
-
else:
|
|
205
|
-
out.append(ch)
|
|
206
|
-
out.append('"')
|
|
207
|
-
return "".join(out)
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
def _json_stringify(value: Any) -> str:
|
|
211
|
-
"""JSON.stringify for a parsed JSON value: members in JavaScript's key order, numbers as JavaScript writes them, a
|
|
212
|
-
lone surrogate escaped."""
|
|
213
|
-
if isinstance(value, dict):
|
|
214
|
-
return "{" + ",".join(_js_json_string(k) + ":" + _json_stringify(value[k]) for k in _js_keys(value)) + "}"
|
|
215
|
-
if isinstance(value, list):
|
|
216
|
-
return "[" + ",".join(_json_stringify(v) for v in value) + "]"
|
|
217
|
-
if value is None:
|
|
218
|
-
return "null"
|
|
219
|
-
if isinstance(value, bool):
|
|
220
|
-
return "true" if value else "false"
|
|
221
|
-
if _number(value):
|
|
222
|
-
text = _js_string(value)
|
|
223
|
-
return "null" if text in ("NaN", "Infinity", "-Infinity") else text
|
|
224
|
-
return _js_json_string(value)
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
def _show(value: Any) -> str:
|
|
228
|
-
"""The contract's ``show``: String(value), or the value's JSON where String() would throw."""
|
|
229
|
-
try:
|
|
230
|
-
return _strict_string(value)
|
|
231
|
-
except _Unshowable:
|
|
232
|
-
return _json_stringify(value)
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
def _key_of(*parts: Any) -> str:
|
|
236
|
-
"""The contract's ``keyOf``: a collision-free key over JSON values, JSON.stringify of the parts (an absent value
|
|
237
|
-
is null inside an array, as JavaScript writes it)."""
|
|
238
|
-
return _json_stringify([None if part is _MISSING else part for part in parts])
|
|
239
|
-
|
|
240
81
|
|
|
241
82
|
#: DEC-0082: the personal context, and an organization's context ``org:<orgId>`` (the contract's ``context-route.ts``).
|
|
242
83
|
PERSONAL_CONTEXT = "personal"
|
|
@@ -264,161 +105,8 @@ def _context_of(entry: Any) -> Any:
|
|
|
264
105
|
return PERSONAL_CONTEXT if context is _MISSING else context
|
|
265
106
|
|
|
266
107
|
|
|
267
|
-
def _b64url_bytes(text: str) -> bytes:
|
|
268
|
-
"""Buffer.from(text, "base64url") for text of the base64url alphabet: a dangling sixth bit group is ignored."""
|
|
269
|
-
return _delegation._b64decode(text)
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
def _b64url(data: bytes) -> str:
|
|
273
|
-
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
def _node_base64(text: str) -> bytes:
|
|
277
|
-
"""Buffer.from(text, "base64"): either alphabet, other characters skipped, stops at the first "=", a dangling
|
|
278
|
-
sixth bit group ignored."""
|
|
279
|
-
kept = []
|
|
280
|
-
for ch in text:
|
|
281
|
-
if ch == "=":
|
|
282
|
-
break
|
|
283
|
-
if ch.isascii() and (ch.isalnum() or ch in "+/-_"):
|
|
284
|
-
kept.append({"-": "+", "_": "/"}.get(ch, ch))
|
|
285
|
-
body = "".join(kept)
|
|
286
|
-
if len(body) % 4 == 1:
|
|
287
|
-
body = body[:-1]
|
|
288
|
-
return base64.b64decode(body + "=" * (-len(body) % 4))
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
def _sha256(data: bytes) -> bytes:
|
|
292
|
-
return hashlib.sha256(data).digest()
|
|
293
|
-
|
|
294
|
-
|
|
295
108
|
# --- P-256 (DEC-0053; SEC 1 v2, FIPS 186-5) ------------------------------------------------------------------------
|
|
296
109
|
|
|
297
|
-
_P = 0xFFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF
|
|
298
|
-
_A = _P - 3
|
|
299
|
-
_B = 0x5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B
|
|
300
|
-
_N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551
|
|
301
|
-
_GX = 0x6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296
|
|
302
|
-
_GY = 0x4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
def is_valid_p256_key(raw: bytes) -> bool:
|
|
306
|
-
"""SEC 1 §3.2.2 for an uncompressed point: 0x04 ‖ X ‖ Y, X and Y below p, on y² = x³ − 3x + b. The cofactor is 1."""
|
|
307
|
-
if len(raw) != 65 or raw[0] != 0x04:
|
|
308
|
-
return False
|
|
309
|
-
x = int.from_bytes(raw[1:33], "big")
|
|
310
|
-
y = int.from_bytes(raw[33:], "big")
|
|
311
|
-
if x >= _P or y >= _P:
|
|
312
|
-
return False
|
|
313
|
-
return (y * y - (x * x * x + _A * x + _B)) % _P == 0
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
def _jacobian_double(p: Tuple[int, int, int]) -> Tuple[int, int, int]:
|
|
317
|
-
x, y, z = p
|
|
318
|
-
if y == 0 or z == 0:
|
|
319
|
-
return (0, 1, 0)
|
|
320
|
-
ysq = y * y % _P
|
|
321
|
-
s = 4 * x * ysq % _P
|
|
322
|
-
zsq = z * z % _P
|
|
323
|
-
m = 3 * (x - zsq) * (x + zsq) % _P # a = −3
|
|
324
|
-
nx = (m * m - 2 * s) % _P
|
|
325
|
-
ny = (m * (s - nx) - 8 * ysq * ysq) % _P
|
|
326
|
-
nz = 2 * y * z % _P
|
|
327
|
-
return (nx, ny, nz)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
def _jacobian_add(p: Tuple[int, int, int], q: Tuple[int, int, int]) -> Tuple[int, int, int]:
|
|
331
|
-
if p[2] == 0:
|
|
332
|
-
return q
|
|
333
|
-
if q[2] == 0:
|
|
334
|
-
return p
|
|
335
|
-
z1z1 = p[2] * p[2] % _P
|
|
336
|
-
z2z2 = q[2] * q[2] % _P
|
|
337
|
-
u1 = p[0] * z2z2 % _P
|
|
338
|
-
u2 = q[0] * z1z1 % _P
|
|
339
|
-
s1 = p[1] * q[2] * z2z2 % _P
|
|
340
|
-
s2 = q[1] * p[2] * z1z1 % _P
|
|
341
|
-
if u1 == u2:
|
|
342
|
-
return _jacobian_double(p) if s1 == s2 else (0, 1, 0)
|
|
343
|
-
h = (u2 - u1) % _P
|
|
344
|
-
r = (s2 - s1) % _P
|
|
345
|
-
hh = h * h % _P
|
|
346
|
-
hhh = h * hh % _P
|
|
347
|
-
v = u1 * hh % _P
|
|
348
|
-
nx = (r * r - hhh - 2 * v) % _P
|
|
349
|
-
ny = (r * (v - nx) - s1 * hhh) % _P
|
|
350
|
-
nz = h * p[2] * q[2] % _P
|
|
351
|
-
return (nx, ny, nz)
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def _shamir(u1: int, u2: int, q: Tuple[int, int]) -> Optional[Tuple[int, int]]:
|
|
355
|
-
"""u1·G + u2·Q in affine form, or None at infinity."""
|
|
356
|
-
g = (_GX, _GY, 1)
|
|
357
|
-
qj = (q[0], q[1], 1)
|
|
358
|
-
both = _jacobian_add(g, qj)
|
|
359
|
-
out = (0, 1, 0)
|
|
360
|
-
for bit in range(max(u1.bit_length(), u2.bit_length()) - 1, -1, -1):
|
|
361
|
-
out = _jacobian_double(out)
|
|
362
|
-
a, b = (u1 >> bit) & 1, (u2 >> bit) & 1
|
|
363
|
-
if a and b:
|
|
364
|
-
out = _jacobian_add(out, both)
|
|
365
|
-
elif a:
|
|
366
|
-
out = _jacobian_add(out, g)
|
|
367
|
-
elif b:
|
|
368
|
-
out = _jacobian_add(out, qj)
|
|
369
|
-
if out[2] == 0:
|
|
370
|
-
return None
|
|
371
|
-
zinv = pow(out[2], -1, _P)
|
|
372
|
-
return (out[0] * zinv * zinv % _P, out[1] * zinv * zinv * zinv % _P)
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def _der_integer(data: bytes, at: int) -> Tuple[Optional[int], int]:
|
|
376
|
-
if at + 2 > len(data) or data[at] != 0x02:
|
|
377
|
-
return None, at
|
|
378
|
-
length = data[at + 1]
|
|
379
|
-
start = at + 2
|
|
380
|
-
if length == 0 or length > 33 or start + length > len(data):
|
|
381
|
-
return None, at
|
|
382
|
-
body = data[start:start + length]
|
|
383
|
-
if body[0] & 0x80:
|
|
384
|
-
return None, at # negative
|
|
385
|
-
if length > 1 and body[0] == 0 and not body[1] & 0x80:
|
|
386
|
-
return None, at # not minimal
|
|
387
|
-
return int.from_bytes(body, "big"), start + length
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
def _der_signature(signature: bytes) -> Optional[Tuple[int, int]]:
|
|
391
|
-
"""An ASN.1 DER Ecdsa-Sig-Value (RFC 3279 §2.2.3), strictly: one SEQUENCE of two minimal positive INTEGERs."""
|
|
392
|
-
if len(signature) < 8 or signature[0] != 0x30:
|
|
393
|
-
return None
|
|
394
|
-
if signature[1] & 0x80:
|
|
395
|
-
return None # a P-256 signature fits a short-form length
|
|
396
|
-
if signature[1] != len(signature) - 2:
|
|
397
|
-
return None
|
|
398
|
-
r, at = _der_integer(signature, 2)
|
|
399
|
-
if r is None:
|
|
400
|
-
return None
|
|
401
|
-
s, at = _der_integer(signature, at)
|
|
402
|
-
if s is None or at != len(signature):
|
|
403
|
-
return None
|
|
404
|
-
return r, s
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
def es256_verify(x: bytes, y: bytes, message: bytes, signature: bytes) -> bool:
|
|
408
|
-
"""ECDSA P-256 with SHA-256 over `message`, the signature in DER (FIPS 186-5 §6.4.2). False, never an exception."""
|
|
409
|
-
if not is_valid_p256_key(b"\x04" + x + y):
|
|
410
|
-
return False
|
|
411
|
-
parsed = _der_signature(signature)
|
|
412
|
-
if parsed is None:
|
|
413
|
-
return False
|
|
414
|
-
r, s = parsed
|
|
415
|
-
if not (1 <= r < _N and 1 <= s < _N):
|
|
416
|
-
return False
|
|
417
|
-
e = int.from_bytes(_sha256(message), "big")
|
|
418
|
-
w = pow(s, -1, _N)
|
|
419
|
-
point = _shamir(e * w % _N, r * w % _N, (int.from_bytes(x, "big"), int.from_bytes(y, "big")))
|
|
420
|
-
return point is not None and point[0] % _N == r
|
|
421
|
-
|
|
422
110
|
|
|
423
111
|
# --- WebAuthn Level 3 §7.2, offline (src/webauthn.ts) ----------------------------------------------------------------
|
|
424
112
|
|
|
@@ -451,7 +139,7 @@ def credential_key(public_key: Any, alg: str) -> Dict[str, Any]:
|
|
|
451
139
|
raw = _b64url_bytes(public_key["x"])
|
|
452
140
|
if len(raw) != 32:
|
|
453
141
|
return {"problem": "unusable-key"}
|
|
454
|
-
if
|
|
142
|
+
if is_small_order_key(raw):
|
|
455
143
|
return {"problem": "small-order-key"} # DEC-0045 (CT-29): refused before any use
|
|
456
144
|
return {"key": ("EdDSA", raw)}
|
|
457
145
|
|
|
@@ -522,7 +210,7 @@ def verify_assertion(assertion: Any, credential: Dict[str, Any], challenge: byte
|
|
|
522
210
|
signature = _b64url_bytes(sig)
|
|
523
211
|
key = found["key"]
|
|
524
212
|
good = (es256_verify(key[1], key[2], signed, signature) if key[0] == "ES256"
|
|
525
|
-
else len(signature) == 64 and
|
|
213
|
+
else len(signature) == 64 and ed25519_verify(key[1], signed, signature))
|
|
526
214
|
if not good:
|
|
527
215
|
return fail("bad-signature")
|
|
528
216
|
return {"ok": True, "flags": flags, "signCount": int.from_bytes(auth_data[33:37], "big")}
|
|
@@ -534,11 +222,11 @@ def estate_record_digest(record: Dict[str, Any]) -> str:
|
|
|
534
222
|
"""`sha256:` and the hex SHA-256 of the UTF-8 RFC 8785 form of the record without `digest` and `signature`.
|
|
535
223
|
Raises TypeError for a value RFC 8785 has no form for."""
|
|
536
224
|
covered = {k: v for k, v in record.items() if k not in ("digest", "signature")}
|
|
537
|
-
return "sha256:" + hashlib.sha256(
|
|
225
|
+
return "sha256:" + hashlib.sha256(jcs(covered).encode("utf-8")).hexdigest()
|
|
538
226
|
|
|
539
227
|
|
|
540
228
|
def estate_challenge_input(digest: str) -> bytes:
|
|
541
|
-
return
|
|
229
|
+
return jcs({"type": ESTATE_CHALLENGE_TYPE, "digest": digest}).encode("utf-8")
|
|
542
230
|
|
|
543
231
|
|
|
544
232
|
def estate_challenge(digest: str) -> bytes:
|
|
@@ -546,55 +234,6 @@ def estate_challenge(digest: str) -> bytes:
|
|
|
546
234
|
return _sha256(estate_challenge_input(digest))
|
|
547
235
|
|
|
548
236
|
|
|
549
|
-
# ASCII digits only: Python's \d also matches other scripts' digits, which Date.parse never reads.
|
|
550
|
-
_RFC3339 = re.compile(r"([0-9]{4})-([0-9]{2})-([0-9]{2})[Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\.[0-9]+)?([Zz]|([+-])([0-9]{2}):([0-9]{2}))", re.ASCII)
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
def _days_from_civil(year: int, month: int, day: int) -> int:
|
|
554
|
-
year -= month <= 2
|
|
555
|
-
era = (year if year >= 0 else year - 399) // 400
|
|
556
|
-
yoe = year - era * 400
|
|
557
|
-
doy = (153 * (month + (-3 if month > 2 else 9)) + 2) // 5 + day - 1
|
|
558
|
-
doe = yoe * 365 + yoe // 4 - yoe // 100 + doy
|
|
559
|
-
return era * 146097 + doe - 719468
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
def estate_time(value: Any) -> float:
|
|
563
|
-
"""An RFC 3339 date-time as milliseconds since the epoch, or NaN: `T`, an offset and a day that exists are required;
|
|
564
|
-
a space and a leap second `:60` are refused (ER-10). Fractions count to the millisecond, as Date.parse reads them."""
|
|
565
|
-
match = _RFC3339.fullmatch(value) if isinstance(value, str) else None
|
|
566
|
-
if not match:
|
|
567
|
-
return math.nan
|
|
568
|
-
year, month, day, hour, minute, second = (int(match.group(i)) for i in range(1, 7))
|
|
569
|
-
leap = year % 4 == 0 and (year % 100 != 0 or year % 400 == 0)
|
|
570
|
-
days_in = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
571
|
-
if not (1 <= month <= 12 and 1 <= day <= days_in[month - 1]) or hour > 23 or minute > 59 or second > 59:
|
|
572
|
-
return math.nan
|
|
573
|
-
if match.group(9):
|
|
574
|
-
oh, om = int(match.group(10)), int(match.group(11))
|
|
575
|
-
if oh > 23 or om > 59:
|
|
576
|
-
return math.nan
|
|
577
|
-
offset = (oh * 60 + om) * (1 if match.group(9) == "+" else -1)
|
|
578
|
-
else:
|
|
579
|
-
offset = 0
|
|
580
|
-
millis = int((match.group(7) or ".")[1:4].ljust(3, "0"))
|
|
581
|
-
seconds = _days_from_civil(year, month, day) * 86400 + hour * 3600 + minute * 60 + second - offset * 60
|
|
582
|
-
return float(seconds * 1000 + millis)
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
def _clock(at: Any) -> float:
|
|
586
|
-
"""The verifier's clock as milliseconds: a timezone-aware datetime or epoch seconds; NaN for anything else."""
|
|
587
|
-
if isinstance(at, _dt.datetime) and at.tzinfo is not None:
|
|
588
|
-
return at.timestamp() * 1000.0
|
|
589
|
-
if _number(at):
|
|
590
|
-
try:
|
|
591
|
-
seconds = float(at)
|
|
592
|
-
except OverflowError:
|
|
593
|
-
return math.nan
|
|
594
|
-
return seconds * 1000.0 if math.isfinite(seconds) else math.nan
|
|
595
|
-
return math.nan
|
|
596
|
-
|
|
597
|
-
|
|
598
237
|
_LABEL = "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
|
599
238
|
#: `sign.` and at least two more labels, lower case: a signing RP ID is never a bare `sign.<tld>` (DEC-0063).
|
|
600
239
|
_SIGNING_RP = re.compile(r"sign(?:\.%s){2,}" % _LABEL, re.ASCII)
|
|
@@ -892,7 +531,7 @@ def _valid_device_key(key: Dict[str, Any]) -> bool:
|
|
|
892
531
|
public = _get(key, "publicKey")
|
|
893
532
|
raw = _node_base64("" if public is _MISSING or public is None else _show(public))
|
|
894
533
|
if _get(key, "alg") == "ed25519":
|
|
895
|
-
return len(raw) == 32 and not
|
|
534
|
+
return len(raw) == 32 and not is_small_order_key(raw)
|
|
896
535
|
return is_valid_p256_key(raw)
|
|
897
536
|
|
|
898
537
|
|
|
@@ -932,7 +571,7 @@ def _keys(value: Dict[str, Any], flag) -> None:
|
|
|
932
571
|
hub_keys.append(_get(hub, "x"))
|
|
933
572
|
x = _get(hub, "x")
|
|
934
573
|
raw = _node_base64("" if x is _MISSING or x is None else _js_string(x))
|
|
935
|
-
if
|
|
574
|
+
if is_small_order_key(raw):
|
|
936
575
|
flag("%s/hubKey/x" % at, "a hub key of small order verifies forged tokens and is never certified (DEC-0045)")
|
|
937
576
|
credentials: List[Any] = []
|
|
938
577
|
for authority, index in _indexed(_get(value, "authorities")):
|
|
@@ -1151,7 +790,7 @@ def _record_findings(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
|
1151
790
|
|
|
1152
791
|
def _package_digest(pkg: Dict[str, Any]) -> str:
|
|
1153
792
|
covered = {k: v for k, v in pkg.items() if k not in ("digest", "signatures")}
|
|
1154
|
-
return "sha256:" + hashlib.sha256(
|
|
793
|
+
return "sha256:" + hashlib.sha256(jcs(covered).encode("utf-8")).hexdigest()
|
|
1155
794
|
|
|
1156
795
|
|
|
1157
796
|
def _installation(value: Dict[str, Any]) -> List[Dict[str, str]]:
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""A service's events (DEC-0029): making them, paging them and the JSONL event log. Part of the service kit
|
|
2
|
+
(FAA-65, layer L3)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Dict, Iterable, List, Optional
|
|
9
|
+
|
|
10
|
+
import sys as _sys # noqa: E402
|
|
11
|
+
from pathlib import Path as _Path # noqa: E402
|
|
12
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
13
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
14
|
+
from fabric_base import (
|
|
15
|
+
ServiceError,
|
|
16
|
+
now_iso,
|
|
17
|
+
)
|
|
18
|
+
from fabric_paths import (
|
|
19
|
+
atomic_write,
|
|
20
|
+
)
|
|
21
|
+
from fabric_protocols import (
|
|
22
|
+
feed_data_problems,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
EVENTS_DEFAULT_LIMIT = 50
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
EVENTS_MAX_LIMIT = 200
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
LEVELS = ("info", "notice", "warning", "error")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
_KIND = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){0,5}$")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
_TRACE_ID = re.compile(r"^(?!0{32}$)[0-9a-f]{32}$")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_SPAN_ID = re.compile(r"^(?!0{16}$)[0-9a-f]{16}$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
|
|
45
|
+
link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
|
|
46
|
+
span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
47
|
+
"""One activity event: one sentence a person reads, never a machine id. `data` (DEC-0070) adds machine-readable
|
|
48
|
+
fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
|
|
49
|
+
|
|
50
|
+
An event about traced work carries its trace as a pair, `trace_id` and `span_id`
|
|
51
|
+
(fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
|
|
52
|
+
if level not in LEVELS:
|
|
53
|
+
raise ServiceError("level must be one of %s." % ", ".join(LEVELS))
|
|
54
|
+
if not _KIND.match(kind):
|
|
55
|
+
raise ServiceError("kind %r must be dotted lowercase." % kind)
|
|
56
|
+
text = " ".join(str(text).split())
|
|
57
|
+
if not text:
|
|
58
|
+
raise ServiceError("An event needs a sentence.")
|
|
59
|
+
if link is not None and (not link.startswith("/") or link.startswith("//")):
|
|
60
|
+
raise ServiceError("link must be a path on this service.")
|
|
61
|
+
if (trace_id is None) != (span_id is None):
|
|
62
|
+
raise ServiceError("An event carries traceId and spanId together, or neither.")
|
|
63
|
+
if trace_id is not None and not (_TRACE_ID.match(trace_id) and _SPAN_ID.match(str(span_id))):
|
|
64
|
+
raise ServiceError("traceId is 32 and spanId 16 lowercase hex characters, not all zeros.")
|
|
65
|
+
event: Dict[str, Any] = {"id": str(event_id), "at": at, "kind": kind, "level": level, "text": text[:500]}
|
|
66
|
+
if subject:
|
|
67
|
+
event["subject"] = subject
|
|
68
|
+
if link:
|
|
69
|
+
event["link"] = link
|
|
70
|
+
if notify:
|
|
71
|
+
event["notify"] = True
|
|
72
|
+
if trace_id is not None:
|
|
73
|
+
event["traceId"], event["spanId"] = trace_id, span_id
|
|
74
|
+
if data is not None:
|
|
75
|
+
problems = feed_data_problems(data)
|
|
76
|
+
if problems:
|
|
77
|
+
raise ServiceError("Event data: %s." % "; ".join(problems))
|
|
78
|
+
event["data"] = {"schema": data["schema"], "value": data["value"]}
|
|
79
|
+
return event
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_limit(raw: Optional[str]) -> int:
|
|
83
|
+
try:
|
|
84
|
+
value = int(raw) if raw not in (None, "") else EVENTS_DEFAULT_LIMIT
|
|
85
|
+
except ValueError:
|
|
86
|
+
raise ServiceError("limit must be an integer.") from None
|
|
87
|
+
return max(1, min(EVENTS_MAX_LIMIT, value))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def events_page(fetch: Callable[[Optional[str], int], Iterable[Dict[str, Any]]],
|
|
91
|
+
after: Optional[str], limit: int) -> Dict[str, Any]:
|
|
92
|
+
"""A cursor page over the log the service already keeps.
|
|
93
|
+
|
|
94
|
+
`fetch(after, limit)` returns events with ids greater than `after` in
|
|
95
|
+
ascending order (or the newest `limit`, ascending, when `after` is None).
|
|
96
|
+
"""
|
|
97
|
+
events = list(fetch(after, limit))[:limit]
|
|
98
|
+
cursor = events[-1]["id"] if events else (after or None)
|
|
99
|
+
return {"events": events, "cursor": cursor}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class JsonlEventLog:
|
|
103
|
+
"""A minimal append-only log for services that keep none yet. Ids are integers as strings."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, path: Path, keep: int = 5000):
|
|
106
|
+
self.path = Path(path)
|
|
107
|
+
self.keep = keep
|
|
108
|
+
|
|
109
|
+
def _all(self) -> List[Dict[str, Any]]:
|
|
110
|
+
if not self.path.exists():
|
|
111
|
+
return []
|
|
112
|
+
out = []
|
|
113
|
+
for line in self.path.read_text(encoding="utf-8").splitlines():
|
|
114
|
+
try:
|
|
115
|
+
out.append(json.loads(line))
|
|
116
|
+
except ValueError:
|
|
117
|
+
continue
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
def append(self, kind: str, level: str, text: str, **extra: Any) -> Dict[str, Any]:
|
|
121
|
+
rows = self._all()
|
|
122
|
+
next_id = int(rows[-1]["id"]) + 1 if rows else 1
|
|
123
|
+
event = make_event(next_id, now_iso(), kind, level, text, **extra)
|
|
124
|
+
rows.append(event)
|
|
125
|
+
rows = rows[-self.keep:]
|
|
126
|
+
atomic_write(self.path, ("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n").encode(), 0o600)
|
|
127
|
+
return event
|
|
128
|
+
|
|
129
|
+
def fetch(self, after: Optional[str], limit: int) -> List[Dict[str, Any]]:
|
|
130
|
+
rows = self._all()
|
|
131
|
+
if after is None:
|
|
132
|
+
return rows[-limit:]
|
|
133
|
+
try:
|
|
134
|
+
floor = int(after)
|
|
135
|
+
except ValueError:
|
|
136
|
+
raise ServiceError("cursor %r is not from this service." % after) from None
|
|
137
|
+
return [r for r in rows if int(r["id"]) > floor][:limit]
|