everydeck 0.3.0__tar.gz → 0.3.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {everydeck-0.3.0 → everydeck-0.3.2}/PKG-INFO +2 -2
- {everydeck-0.3.0 → everydeck-0.3.2}/README.md +1 -1
- {everydeck-0.3.0 → everydeck-0.3.2}/everydeck/__init__.py +93 -93
- {everydeck-0.3.0 → everydeck-0.3.2}/everydeck/read_bridge.py +37 -37
- {everydeck-0.3.0 → everydeck-0.3.2}/everydeck/read_route.py +17 -17
- {everydeck-0.3.0 → everydeck-0.3.2}/everydeck/verify.py +10 -10
- {everydeck-0.3.0 → everydeck-0.3.2}/everydeck.egg-info/PKG-INFO +2 -2
- {everydeck-0.3.0 → everydeck-0.3.2}/pyproject.toml +1 -1
- {everydeck-0.3.0 → everydeck-0.3.2}/LICENSE +0 -0
- {everydeck-0.3.0 → everydeck-0.3.2}/everydeck.egg-info/SOURCES.txt +0 -0
- {everydeck-0.3.0 → everydeck-0.3.2}/everydeck.egg-info/dependency_links.txt +0 -0
- {everydeck-0.3.0 → everydeck-0.3.2}/everydeck.egg-info/requires.txt +0 -0
- {everydeck-0.3.0 → everydeck-0.3.2}/everydeck.egg-info/top_level.txt +0 -0
- {everydeck-0.3.0 → everydeck-0.3.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: everydeck
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: everydeck Python SDK: bounded buffer, background sender, flush on shutdown, reported data gaps
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://everydeck.ai
|
|
@@ -40,7 +40,7 @@ everydeck.signal("job.created", operation_id)
|
|
|
40
40
|
- `signal()`, `error()` and `log()` never block and never raise: they enqueue into a bounded in-memory buffer; a background thread sends batches (at most 50 events, 64 KiB, gzip-compressed).
|
|
41
41
|
- Retries only on 429, 5xx, timeout and network errors; `Retry-After` is honoured; final rejections are dropped and counted, never reported as sent.
|
|
42
42
|
- A buffer overflow is reported as a `gap` event, so everydeck shows a known data gap instead of silence.
|
|
43
|
-
- `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"
|
|
43
|
+
- `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"worker"`).
|
|
44
44
|
- `release_key="auto"` derives the version from the deployment commit (`EVERYDECK_RELEASE_KEY`, `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`, …) or local git.
|
|
45
45
|
- `everydeck.verify.verify_everydeck_request` checks everydeck's signature on bridge calls.
|
|
46
46
|
- Read bridge (0.3.0): `everydeck.read_route.EverydeckReadBridge(endpoint, key, db)` is the ready-made route. It fetches its read scope from everydeck (`GET <endpoint>/v1/read-scope` with the bridge key) and uses it only with everydeck's signature; no read-scope file, no read role, no views. `db` is the connection the app already has: `PsycopgReadOnly(conninfo)` runs every query in `BEGIN READ ONLY`, `SupabaseReadOnly(client)` reads through the app's Supabase client with `select()` only (GET requests, which PostgREST runs read-only; only tables in schemas the Supabase API exposes).
|
|
@@ -24,7 +24,7 @@ everydeck.signal("job.created", operation_id)
|
|
|
24
24
|
- `signal()`, `error()` and `log()` never block and never raise: they enqueue into a bounded in-memory buffer; a background thread sends batches (at most 50 events, 64 KiB, gzip-compressed).
|
|
25
25
|
- Retries only on 429, 5xx, timeout and network errors; `Retry-After` is honoured; final rejections are dropped and counted, never reported as sent.
|
|
26
26
|
- A buffer overflow is reported as a `gap` event, so everydeck shows a known data gap instead of silence.
|
|
27
|
-
- `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"
|
|
27
|
+
- `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"worker"`).
|
|
28
28
|
- `release_key="auto"` derives the version from the deployment commit (`EVERYDECK_RELEASE_KEY`, `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`, …) or local git.
|
|
29
29
|
- `everydeck.verify.verify_everydeck_request` checks everydeck's signature on bridge calls.
|
|
30
30
|
- Read bridge (0.3.0): `everydeck.read_route.EverydeckReadBridge(endpoint, key, db)` is the ready-made route. It fetches its read scope from everydeck (`GET <endpoint>/v1/read-scope` with the bridge key) and uses it only with everydeck's signature; no read-scope file, no read role, no views. `db` is the connection the app already has: `PsycopgReadOnly(conninfo)` runs every query in `BEGIN READ ONLY`, `SupabaseReadOnly(client)` reads through the app's Supabase client with `select()` only (GET requests, which PostgREST runs read-only; only tables in schemas the Supabase API exposes).
|
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
"""everydeck Python SDK (
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
- `signal()/error()/log()`
|
|
5
|
-
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
restart_recovered);
|
|
23
|
-
|
|
24
|
-
(`spool_queue_dropped`, `spool_ack_dropped`);
|
|
25
|
-
|
|
26
|
-
-
|
|
27
|
-
(operation_id `everydeck.sdk:<instance_id>`,
|
|
28
|
-
|
|
29
|
-
-
|
|
1
|
+
"""everydeck Python SDK (transport contract 1.0-r0).
|
|
2
|
+
|
|
3
|
+
Guarantees:
|
|
4
|
+
- `signal()/error()/log()` never block and never raise: O(1) enqueue into a bounded in-memory buffer. No file or network IO in the caller.
|
|
5
|
+
- A sender thread transmits batches (max. 50 events, 64 KiB). Three separate outcomes per batch:
|
|
6
|
+
accepted (202) · retry (429, 5xx, timeout, network) · finally rejected (400/401/403/409/413/422).
|
|
7
|
+
Rejected batches never count as sent; 401/403 pause the sender (auth_pause_seconds) and report a warning.
|
|
8
|
+
- Batches are sent gzip-compressed (`compress=False` turns it off), `Retry-After` on 429/503 is honored,
|
|
9
|
+
retries use full jitter (`full_jitter`); `stats`: batches_compressed, bytes_raw, bytes_sent, retry_after_waits, as in the TypeScript SDK.
|
|
10
|
+
- `everydeck.verify.verify_everydeck_request` checks everydeck's signature at bridges (5-minute window, request_id used once); `ReadBridge` checks it itself (`signatures="required"`).
|
|
11
|
+
- `error(..., exc=e)` sends the code lines for up to 5 frames in the app's code (the error line and 5 before and after, at most 200 characters each, scrubbed like error messages); read in the sender thread, never in the call; `Everydeck(..., source_context=False)` turns it off. `error.context_status` says whether lines are included or why not.
|
|
12
|
+
- `error(..., response={code, message})` as in the TS SDK (code only as an identifier, message scrubbed, 300 characters); `answer_of(exc)` reads both from attributes of the exception (e.g. postgrest `APIError.code`/`.message`), never from its text.
|
|
13
|
+
- `signal`, `error` and `log` take `object_type`/`object_ref` like the TS SDK (`objectType`/`objectRef`).
|
|
14
|
+
- `Everydeck.error(..., exc=e)` sends the exception's stack frames (`error.frames`, innermost first), or ready-made `frames=[…]`; `ReadBridge(match=psycopg_match(conn))` delivers the messages of one conversation on demand (`GET /everydeck/read/messages/{type}/{ref}`).
|
|
15
|
+
- The sender thread never ends silently: every exception is counted, reported, and the loop continues.
|
|
16
|
+
- Buffer overflow is reported as a `gap` event (buffer_overflow): a *detectable* data gap.
|
|
17
|
+
- An optional spool (JSONL, bounded size) runs in its own thread, which is the only one touching the file: writes and
|
|
18
|
+
acknowledgements (accepted/rejected events) arrive through two bounded in-memory queues (`max_spool_queue`) and are evaluated
|
|
19
|
+
together in the spool thread; an acknowledgement that arrives before the write prevents the write instead of leaving an orphan
|
|
20
|
+
behind. Neither caller nor sender holds a lock during file IO. Errors (full, not writable) are counted and
|
|
21
|
+
reported but affect neither customer requests nor the sender. On start, intact spool lines are replayed (`gap`
|
|
22
|
+
restart_recovered); corrupt lines (interrupted write) are counted and discarded. Without a spool a SIGKILL stays invisible.
|
|
23
|
+
If the queues overflow (spool thread stuck on a slow disk), the oldest entries are discarded and counted
|
|
24
|
+
(`spool_queue_dropped`, `spool_ack_dropped`); a discarded acknowledgement can at most cause already accepted events to be
|
|
25
|
+
delivered again after a restart (everydeck deduplicates by event_id), never a loss.
|
|
26
|
+
- Heartbeats: with `heartbeat_interval` the sender thread sends a signal `everydeck.sdk.heartbeat` every n seconds
|
|
27
|
+
(operation_id `everydeck.sdk:<instance_id>`, fields instance_id/interval_seconds/pid). Missing heartbeats are a
|
|
28
|
+
monitoring gap for everydeck, not a proven outage of the app.
|
|
29
|
+
- The SDK knows no everydeck database and makes no engine verdicts.
|
|
30
30
|
"""
|
|
31
31
|
from __future__ import annotations
|
|
32
32
|
|
|
@@ -51,7 +51,7 @@ from typing import Any, Callable, Literal
|
|
|
51
51
|
__all__ = ["Everydeck", "AfterCommit", "SCHEMA_VERSION", "BATCH_MAX", "error_class_of", "scrub_text", "answer_of"]
|
|
52
52
|
|
|
53
53
|
SCHEMA_VERSION = "1.3-r0"
|
|
54
|
-
#
|
|
54
|
+
# origin of every event: a server key always reports the platform `server`
|
|
55
55
|
CLIENT = {"platform": "server", "runtime": "python"}
|
|
56
56
|
BATCH_MAX = 50
|
|
57
57
|
BATCH_MAX_BYTES = 64 * 1024
|
|
@@ -70,9 +70,9 @@ _COMMIT_ENV = ("GITHUB_SHA", "VERCEL_GIT_COMMIT_SHA", "RENDER_GIT_COMMIT", "CF_P
|
|
|
70
70
|
|
|
71
71
|
|
|
72
72
|
def resolve_release_key(env: dict[str, str] | None = None, cwd: str | None = None) -> str:
|
|
73
|
-
"""
|
|
73
|
+
"""Automatic release key: same rule as @everydeck/sdk resolveReleaseKey and everydeck-agent version.
|
|
74
74
|
|
|
75
|
-
EVERYDECK_RELEASE_KEY (
|
|
75
|
+
EVERYDECK_RELEASE_KEY (except "auto") > commit from CI/hosting (git:<sha12>) > local Git (git:<sha12>[+dirty.<hash8>]) > "unknown".
|
|
76
76
|
"""
|
|
77
77
|
import hashlib
|
|
78
78
|
import re
|
|
@@ -113,13 +113,13 @@ def _dumps(v: Any) -> str:
|
|
|
113
113
|
|
|
114
114
|
|
|
115
115
|
def full_jitter(failures: int, cap: float, rnd: Callable[[], float] = random.random) -> float:
|
|
116
|
-
"""
|
|
116
|
+
"""Backoff with full jitter: uniform in [0, min(cap, 0.5 s · 2^n)], as in the TypeScript SDK."""
|
|
117
117
|
ceiling = min(cap, 0.5 * 2 ** max(0, min(6, failures)))
|
|
118
118
|
return rnd() * ceiling
|
|
119
119
|
|
|
120
120
|
|
|
121
121
|
def parse_retry_after(value: str | None, now: float | None = None) -> float | None:
|
|
122
|
-
"""`Retry-After`
|
|
122
|
+
"""`Retry-After` as seconds or HTTP date → seconds, capped at one hour; None when missing or unreadable."""
|
|
123
123
|
if not value:
|
|
124
124
|
return None
|
|
125
125
|
v = value.strip()
|
|
@@ -164,7 +164,7 @@ class Everydeck:
|
|
|
164
164
|
self.credential = credential or ""
|
|
165
165
|
self.release_key = resolve_release_key() if not release_key or release_key == "auto" else release_key
|
|
166
166
|
self.manifest_digest = manifest_digest or ""
|
|
167
|
-
#
|
|
167
|
+
# setup check run (verification scripts only)
|
|
168
168
|
self.probe_id = probe_id
|
|
169
169
|
self.max_buffer = max_buffer
|
|
170
170
|
self.flush_interval = flush_interval
|
|
@@ -175,18 +175,18 @@ class Everydeck:
|
|
|
175
175
|
self.max_spool_queue = max(1, int(max_spool_queue))
|
|
176
176
|
self.auth_pause_seconds = auth_pause_seconds
|
|
177
177
|
self.max_send_backoff = max_send_backoff
|
|
178
|
-
#
|
|
178
|
+
# send batches gzip-compressed (on by default); everydeck accepts Content-Encoding: gzip
|
|
179
179
|
self.compress = compress
|
|
180
|
-
#
|
|
180
|
+
# code lines for frames in the app's code (on by default); the sender thread reads the files, cached per path
|
|
181
181
|
self.source_context = source_context
|
|
182
182
|
self._source_cache: dict[str, list[str] | None] = {}
|
|
183
183
|
self._warn_cb = on_warning or (lambda m: _log.warning(m))
|
|
184
184
|
self.enabled = enabled
|
|
185
|
-
#
|
|
185
|
+
# if address or key is missing (e.g. after an update with new variable names), the SDK turns itself off, loudly, never silently
|
|
186
186
|
if self.enabled and (not self.endpoint or not self.credential or not self.manifest_digest):
|
|
187
187
|
self.enabled = False
|
|
188
188
|
missing = "no endpoint" if not self.endpoint else "no key" if not self.credential else "no manifest digest"
|
|
189
|
-
self._warn_cb(f"everydeck: disabled: {missing}
|
|
189
|
+
self._warn_cb(f"everydeck: disabled: {missing}; nothing is sent to everydeck until the app passes endpoint, key and manifest digest")
|
|
190
190
|
self.heartbeat_interval = heartbeat_interval
|
|
191
191
|
self.instance_id = instance_id or f"{socket.gethostname()[:40]}-{os.getpid()}-{uuid.uuid4().hex[:6]}"
|
|
192
192
|
self._next_heartbeat = 0.0
|
|
@@ -198,10 +198,10 @@ class Everydeck:
|
|
|
198
198
|
self._unreported_loss = 0
|
|
199
199
|
self._paused_until = 0.0
|
|
200
200
|
self._consecutive_failures = 0
|
|
201
|
-
self._spool_q: deque[dict[str, Any]] = deque() #
|
|
202
|
-
self._spool_acks: dict[str, None] = {} #
|
|
203
|
-
self._spool_q_lock = threading.Lock() #
|
|
204
|
-
self._spool_lock = threading.Lock() #
|
|
201
|
+
self._spool_q: deque[dict[str, Any]] = deque() # pending writes (bounded)
|
|
202
|
+
self._spool_acks: dict[str, None] = {} # pending acknowledgements, ordered (bounded)
|
|
203
|
+
self._spool_q_lock = threading.Lock() # queues only (caller, sender, spool thread), never held during file IO
|
|
204
|
+
self._spool_lock = threading.Lock() # file; spool thread only (and recovery in the constructor)
|
|
205
205
|
self._spool_ack_overflow_warned = False
|
|
206
206
|
self._spool_wake = threading.Event()
|
|
207
207
|
self._spool_disabled = False
|
|
@@ -212,7 +212,7 @@ class Everydeck:
|
|
|
212
212
|
"auth_failures": 0, "last_rejection": None, "sender_errors": 0,
|
|
213
213
|
"spool_errors": 0, "spool_corrupt_lines": 0, "spool_dropped": 0, "spool_queue_dropped": 0, "spool_ack_dropped": 0,
|
|
214
214
|
"replayed_from_spool": 0, "spool_disabled": False, "heartbeats": 0,
|
|
215
|
-
"batches_compressed": 0, "bytes_raw": 0, "bytes_sent": 0, "retry_after_waits": 0,
|
|
215
|
+
"batches_compressed": 0, "bytes_raw": 0, "bytes_sent": 0, "retry_after_waits": 0,
|
|
216
216
|
}
|
|
217
217
|
if not enabled:
|
|
218
218
|
return
|
|
@@ -236,18 +236,18 @@ class Everydeck:
|
|
|
236
236
|
|
|
237
237
|
_signal.signal(sig, handler)
|
|
238
238
|
|
|
239
|
-
# ---------------- API (
|
|
239
|
+
# ---------------- API (never blocking, never raising) ----------------
|
|
240
240
|
def signal(self, name: str, operation_id: str, *, attempt_id: str | None = None, subject_ref: str | None = None,
|
|
241
241
|
account_ref: str | None = None, fields: dict[str, Any] | None = None, request_id: str | None = None,
|
|
242
242
|
business_key: str | None = None, object_type: str | None = None, object_ref: str | None = None) -> str | None:
|
|
243
|
-
"""
|
|
243
|
+
"""`object_type`/`object_ref` name the object of the outcome (like `objectType`/`objectRef` in the TS SDK)."""
|
|
244
244
|
return self._enq({"kind": "signal", "name": name, "operation_id": str(operation_id),
|
|
245
245
|
**_opt(attempt_id=attempt_id, subject_ref=subject_ref, account_ref=account_ref, fields=fields,
|
|
246
246
|
request_id=request_id, business_key=business_key, object_type=object_type, object_ref=object_ref)})
|
|
247
247
|
|
|
248
248
|
def heartbeat(self, interval_seconds: float, *, job: str | None = None) -> str | None:
|
|
249
|
-
"""
|
|
250
|
-
|
|
249
|
+
"""Heartbeat on demand. With `job` (1.9-r0, `jobs[].id` in the manifest): this job has run; send it at the end of every successful run;
|
|
250
|
+
everydeck reports a job that stays silent for two intervals. Without `job`: this process instance is running (like `heartbeat_interval`)."""
|
|
251
251
|
import re as _re
|
|
252
252
|
if job is not None and not _re.fullmatch(r"[a-z][a-z0-9_.-]{0,63}", job):
|
|
253
253
|
_log.warning("everydeck: heartbeat job id rejected: %r", job)
|
|
@@ -262,12 +262,12 @@ class Everydeck:
|
|
|
262
262
|
fields: dict[str, Any] | None = None, http: dict[str, Any] | None = None, request_id: str | None = None,
|
|
263
263
|
frames: list[dict[str, Any]] | None = None, exc: BaseException | None = None, object_type: str | None = None,
|
|
264
264
|
object_ref: str | None = None, response: dict[str, Any] | None = None) -> str | None:
|
|
265
|
-
"""
|
|
266
|
-
|
|
265
|
+
"""With `exc` (the caught exception) the SDK builds the stack frames itself: innermost first, at most 20,
|
|
266
|
+
file as the last two path parts as in the browser SDK; `frames` passes them ready-made ({function, file?, line?})."""
|
|
267
267
|
err: dict[str, Any] = {"class": error_class}
|
|
268
268
|
if message:
|
|
269
269
|
err["message"] = str(message)[:1024]
|
|
270
|
-
# 1.4-r0
|
|
270
|
+
# 1.4-r0: error code and message from the response the app received, as in the TS SDK (code only as an identifier, message scrubbed, 300 characters)
|
|
271
271
|
if response:
|
|
272
272
|
ans: dict[str, Any] = {}
|
|
273
273
|
code = response.get("code"); msg = response.get("message")
|
|
@@ -279,7 +279,7 @@ class Everydeck:
|
|
|
279
279
|
err["response"] = ans
|
|
280
280
|
fr = frames if frames is not None else (_frames_of(exc) if exc is not None else None)
|
|
281
281
|
if fr:
|
|
282
|
-
keep_abs = frames is None and self.source_context #
|
|
282
|
+
keep_abs = frames is None and self.source_context # the full path only lives until the sender thread (code lines); it is never sent
|
|
283
283
|
err["frames"] = [{"function": str(f.get("function") or "anonymous")[:128], **({"file": str(f["file"])[:240]} if f.get("file") else {}), **({"line": int(f["line"])} if f.get("line") else {}),
|
|
284
284
|
**({"in_app": bool(f["in_app"])} if isinstance(f.get("in_app"), bool) else {}), **({"_abs": f["_abs"]} if keep_abs and f.get("_abs") else {})} for f in fr[:20]]
|
|
285
285
|
if not self.source_context:
|
|
@@ -296,8 +296,8 @@ class Everydeck:
|
|
|
296
296
|
def identify(self, subject_ref: str, *, account_ref: str | None = None, display_name: str | None = None,
|
|
297
297
|
email: str | None = None, login_method: str | None = None, provider_user_id: str | None = None,
|
|
298
298
|
org_name: str | None = None) -> str | None:
|
|
299
|
-
"""
|
|
300
|
-
|
|
299
|
+
"""Identity source (manifest 1.1 `people`): approved master data of a person. everydeck stores only the fields named in the approved
|
|
300
|
+
manifest; all others are discarded and counted. No operation, no rule. Only meaningful on the server side."""
|
|
301
301
|
fields: dict[str, Any] = {k: str(v)[:256] for k, v in {"display_name": display_name, "email": email, "login_method": login_method,
|
|
302
302
|
"provider_user_id": provider_user_id, "org_name": org_name}.items() if v is not None}
|
|
303
303
|
self.stats["identities"] = self.stats.get("identities", 0) + 1
|
|
@@ -310,7 +310,7 @@ class Everydeck:
|
|
|
310
310
|
**_opt(fields=fields, operation_id=operation_id, request_id=request_id, object_type=object_type, object_ref=object_ref)})
|
|
311
311
|
|
|
312
312
|
def flush(self, timeout: float | None = None) -> bool:
|
|
313
|
-
"""
|
|
313
|
+
"""Waits until the buffer is empty. False when the budget runs out (e.g. everydeck not reachable)."""
|
|
314
314
|
if not self.enabled:
|
|
315
315
|
return True
|
|
316
316
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
@@ -321,7 +321,7 @@ class Everydeck:
|
|
|
321
321
|
return True
|
|
322
322
|
if deadline is not None and time.monotonic() >= deadline:
|
|
323
323
|
return False
|
|
324
|
-
self._paused_until = 0.0 #
|
|
324
|
+
self._paused_until = 0.0 # an explicit flush lifts the auth pause (one attempt)
|
|
325
325
|
self._wake.set()
|
|
326
326
|
time.sleep(0.02)
|
|
327
327
|
|
|
@@ -336,7 +336,7 @@ class Everydeck:
|
|
|
336
336
|
self._spool_wake.set()
|
|
337
337
|
st = getattr(self, "_st", None)
|
|
338
338
|
if st is not None and st is not threading.current_thread():
|
|
339
|
-
st.join(max(0.0, deadline - time.monotonic())) #
|
|
339
|
+
st.join(max(0.0, deadline - time.monotonic())) # still apply pending spool acknowledgements (bounded)
|
|
340
340
|
return ok
|
|
341
341
|
|
|
342
342
|
# ---------------- Enqueue ----------------
|
|
@@ -354,8 +354,8 @@ class Everydeck:
|
|
|
354
354
|
spool = bool(self.spool_path) and not self._spool_disabled
|
|
355
355
|
spool_dropped = 0
|
|
356
356
|
if spool:
|
|
357
|
-
#
|
|
358
|
-
#
|
|
357
|
+
# The order is intentional: write job first, then send buffer. The sender can only see an event after this
|
|
358
|
+
# step; the spool thread therefore never sees an acknowledgement without its write job.
|
|
359
359
|
with self._spool_q_lock:
|
|
360
360
|
if len(self._spool_q) >= self.max_spool_queue:
|
|
361
361
|
self._spool_q.popleft()
|
|
@@ -375,11 +375,11 @@ class Everydeck:
|
|
|
375
375
|
if wake:
|
|
376
376
|
self._wake.set()
|
|
377
377
|
return ev["event_id"]
|
|
378
|
-
except Exception as e: #
|
|
378
|
+
except Exception as e: # a customer request must never fail
|
|
379
379
|
self._warn(f"everydeck: enqueue failed: {e!r}")
|
|
380
380
|
return None
|
|
381
381
|
|
|
382
|
-
# ---------------- Sender
|
|
382
|
+
# ---------------- Sender thread ----------------
|
|
383
383
|
def _run(self) -> None:
|
|
384
384
|
while True:
|
|
385
385
|
try:
|
|
@@ -390,13 +390,13 @@ class Everydeck:
|
|
|
390
390
|
self._send_pending()
|
|
391
391
|
if self._stop.is_set() and not self._q:
|
|
392
392
|
break
|
|
393
|
-
except Exception as e: #
|
|
393
|
+
except Exception as e: # the thread never ends silently
|
|
394
394
|
self.stats["sender_errors"] += 1
|
|
395
395
|
self._warn(f"everydeck: sender error (continuing): {e!r}")
|
|
396
396
|
time.sleep(full_jitter(self._consecutive_failures, self.max_send_backoff))
|
|
397
397
|
|
|
398
398
|
def _maybe_heartbeat(self) -> None:
|
|
399
|
-
"""
|
|
399
|
+
"""Heartbeat in the sender thread; goes through the same buffer as all events (no special path, no guarantee)."""
|
|
400
400
|
if not self.heartbeat_interval or self._stop.is_set():
|
|
401
401
|
return
|
|
402
402
|
now = time.monotonic()
|
|
@@ -438,8 +438,8 @@ class Everydeck:
|
|
|
438
438
|
return self._source_cache.get(path)
|
|
439
439
|
|
|
440
440
|
def _with_source_context(self, ev: dict[str, Any]) -> dict[str, Any]:
|
|
441
|
-
"""
|
|
442
|
-
|
|
441
|
+
"""Code lines for at most 5 frames in the app's code; the event stays below EVENT_MAX_BYTES (what does not fit: partial).
|
|
442
|
+
The full path (`_abs`) never leaves the SDK. Works on a copy; the original in the buffer and in the spool stays unchanged."""
|
|
443
443
|
err = ev.get("error") if isinstance(ev.get("error"), dict) else None
|
|
444
444
|
if not err or not err.get("frames"):
|
|
445
445
|
return ev
|
|
@@ -484,7 +484,7 @@ class Everydeck:
|
|
|
484
484
|
return
|
|
485
485
|
items = self._take_batch()
|
|
486
486
|
self._inflight = True
|
|
487
|
-
items = [self._with_source_context(it) for it in items] #
|
|
487
|
+
items = [self._with_source_context(it) for it in items] # file IO outside the lock, on copies (the spool thread reads the originals)
|
|
488
488
|
try:
|
|
489
489
|
outcome, detail = self._post(items)
|
|
490
490
|
except Exception as e:
|
|
@@ -494,7 +494,7 @@ class Everydeck:
|
|
|
494
494
|
ack: set[str] | None = None
|
|
495
495
|
warn: str | None = None
|
|
496
496
|
stop_after = False
|
|
497
|
-
with self._lock: #
|
|
497
|
+
with self._lock: # counters and state only; file IO and callbacks run outside this lock
|
|
498
498
|
if outcome == "accepted":
|
|
499
499
|
self.stats["accepted"] += len(items)
|
|
500
500
|
self.stats["batches_accepted"] += 1
|
|
@@ -504,7 +504,7 @@ class Everydeck:
|
|
|
504
504
|
self.stats["batches_rejected"] += 1
|
|
505
505
|
self.stats["dropped_rejected"] += len(items)
|
|
506
506
|
self.stats["last_rejection"] = detail
|
|
507
|
-
ack = {i["event_id"] for i in items} #
|
|
507
|
+
ack = {i["event_id"] for i in items} # no retry, not even after a restart
|
|
508
508
|
code = int(detail.get("status", 0)) if isinstance(detail, dict) else 0
|
|
509
509
|
if code in _AUTH:
|
|
510
510
|
self.stats["auth_failures"] += 1
|
|
@@ -518,7 +518,7 @@ class Everydeck:
|
|
|
518
518
|
self.stats["retries"] += 1
|
|
519
519
|
self._consecutive_failures += 1
|
|
520
520
|
self._requeue(items)
|
|
521
|
-
#
|
|
521
|
+
# Retry-After from everydeck (429 budget/quota, 503 protection) is honored, otherwise backoff with full jitter
|
|
522
522
|
retry_after = detail.get("retry_after") if isinstance(detail, dict) else None
|
|
523
523
|
if retry_after is not None:
|
|
524
524
|
self.stats["retry_after_waits"] += 1
|
|
@@ -527,7 +527,7 @@ class Everydeck:
|
|
|
527
527
|
self._paused_until = time.monotonic() + full_jitter(self._consecutive_failures, self.max_send_backoff)
|
|
528
528
|
stop_after = True
|
|
529
529
|
if ack:
|
|
530
|
-
self._spool_enqueue_ack(ack) #
|
|
530
|
+
self._spool_enqueue_ack(ack) # before inflight ends: flush() only sees "empty" afterwards, close() waits for the spool
|
|
531
531
|
with self._lock:
|
|
532
532
|
self._inflight = False
|
|
533
533
|
if warn:
|
|
@@ -536,12 +536,12 @@ class Everydeck:
|
|
|
536
536
|
return
|
|
537
537
|
|
|
538
538
|
def _post(self, items: list[dict[str, Any]]) -> tuple[Outcome, Any]:
|
|
539
|
-
"""
|
|
539
|
+
"""Exactly one delivery attempt. The sender controls retries (backoff up to max_send_backoff)."""
|
|
540
540
|
body = _dumps({"schema_version": SCHEMA_VERSION, "batch_id": str(uuid.uuid4()), "release_key": self.release_key,
|
|
541
541
|
"manifest_digest": self.manifest_digest, **({"probe_id": self.probe_id} if self.probe_id else {}), "events": items}).encode()
|
|
542
542
|
headers = {"Content-Type": "application/json", "Authorization": "Bearer " + self.credential}
|
|
543
543
|
self.stats["bytes_raw"] += len(body)
|
|
544
|
-
if self.compress:
|
|
544
|
+
if self.compress:
|
|
545
545
|
body = gzip.compress(body, compresslevel=6, mtime=0)
|
|
546
546
|
headers["Content-Encoding"] = "gzip"
|
|
547
547
|
self.stats["batches_compressed"] += 1
|
|
@@ -563,9 +563,9 @@ class Everydeck:
|
|
|
563
563
|
except Exception as e:
|
|
564
564
|
return "retry", {"error": repr(e)[:200]}
|
|
565
565
|
|
|
566
|
-
# ---------------- Spool
|
|
566
|
+
# ---------------- Spool thread (durable record) ----------------
|
|
567
567
|
def _spool_open(self, mode: str):
|
|
568
|
-
return open(self.spool_path, mode, encoding="utf-8") #
|
|
568
|
+
return open(self.spool_path, mode, encoding="utf-8") # separate so tests can inject errors
|
|
569
569
|
|
|
570
570
|
def _spool_run(self) -> None:
|
|
571
571
|
while True:
|
|
@@ -583,7 +583,7 @@ class Everydeck:
|
|
|
583
583
|
self._spool_fail(e)
|
|
584
584
|
|
|
585
585
|
def _spool_enqueue_ack(self, ids: set[str]) -> None:
|
|
586
|
-
"""
|
|
586
|
+
"""From the sender: events are finally done (accepted or rejected). Queue only, no file IO."""
|
|
587
587
|
if not self.spool_path or self._spool_disabled:
|
|
588
588
|
return
|
|
589
589
|
over = 0
|
|
@@ -592,7 +592,7 @@ class Everydeck:
|
|
|
592
592
|
self._spool_acks[i] = None
|
|
593
593
|
over = len(self._spool_acks) - self.max_spool_queue
|
|
594
594
|
if over > 0:
|
|
595
|
-
for k in list(self._spool_acks)[:over]: #
|
|
595
|
+
for k in list(self._spool_acks)[:over]: # oldest first; their write jobs are the oldest as well
|
|
596
596
|
del self._spool_acks[k]
|
|
597
597
|
self.stats["spool_ack_dropped"] += over
|
|
598
598
|
if over > 0 and not self._spool_ack_overflow_warned:
|
|
@@ -601,7 +601,7 @@ class Everydeck:
|
|
|
601
601
|
self._spool_wake.set()
|
|
602
602
|
|
|
603
603
|
def _spool_cycle(self) -> None:
|
|
604
|
-
"""
|
|
604
|
+
"""One pass: look at write jobs and acknowledgements together. Acknowledged and not yet written → do not write at all."""
|
|
605
605
|
with self._spool_q_lock:
|
|
606
606
|
writes = list(self._spool_q)
|
|
607
607
|
self._spool_q.clear()
|
|
@@ -617,12 +617,12 @@ class Everydeck:
|
|
|
617
617
|
if writes:
|
|
618
618
|
self._spool_write(writes)
|
|
619
619
|
if acks:
|
|
620
|
-
self._spool_ack(acks) #
|
|
620
|
+
self._spool_ack(acks) # acknowledgements without a line (replay/gap events, discarded write jobs) expire here
|
|
621
621
|
|
|
622
622
|
def _spool_write(self, batch: list[dict[str, Any]]) -> None:
|
|
623
623
|
with self._spool_lock:
|
|
624
|
-
#
|
|
625
|
-
#
|
|
624
|
+
# Fill up to the limit (whole lines), discard the rest and close the spool, regardless of how many
|
|
625
|
+
# events a cycle happens to collect.
|
|
626
626
|
lines: list[str] = []
|
|
627
627
|
size = 0
|
|
628
628
|
dropped = 0
|
|
@@ -659,7 +659,7 @@ class Everydeck:
|
|
|
659
659
|
self._warn(f"everydeck: spool unavailable ({e!r}); spool disabled, delivery continues in memory only")
|
|
660
660
|
|
|
661
661
|
def _spool_ack(self, ids: set[str]) -> None:
|
|
662
|
-
"""
|
|
662
|
+
"""Spool thread only: remove acknowledged lines (atomic rewrite)."""
|
|
663
663
|
if not self.spool_path or self._spool_disabled:
|
|
664
664
|
return
|
|
665
665
|
try:
|
|
@@ -674,7 +674,7 @@ class Everydeck:
|
|
|
674
674
|
f.writelines(keep)
|
|
675
675
|
f.flush()
|
|
676
676
|
os.fsync(f.fileno())
|
|
677
|
-
os.replace(tmp, self.spool_path) #
|
|
677
|
+
os.replace(tmp, self.spool_path) # atomic: no half-written spool
|
|
678
678
|
self._spool_bytes = sum(len(l.encode("utf-8")) for l in keep)
|
|
679
679
|
except Exception as e:
|
|
680
680
|
self._spool_fail(e)
|
|
@@ -705,7 +705,7 @@ class Everydeck:
|
|
|
705
705
|
self.stats["replayed_from_spool"] += 1
|
|
706
706
|
self._q.append({"event_id": str(uuid.uuid4()), "occurred_at": _now(), "kind": "gap", "name": "everydeck.sdk.recovered",
|
|
707
707
|
"gap": {"cause": "restart_recovered", "replayed": len(replay), "dropped": dropped}})
|
|
708
|
-
with self._spool_lock: #
|
|
708
|
+
with self._spool_lock: # rewrite the spool to the replayed, valid lines
|
|
709
709
|
tmp = self.spool_path + ".tmp"
|
|
710
710
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
711
711
|
f.writelines(_dumps(ev) + "\n" for ev in replay)
|
|
@@ -731,7 +731,7 @@ def _safe_id(line: str) -> str | None:
|
|
|
731
731
|
|
|
732
732
|
|
|
733
733
|
class AfterCommit:
|
|
734
|
-
"""
|
|
734
|
+
"""Outcome evidence = commit: send signals only after a successful commit."""
|
|
735
735
|
|
|
736
736
|
def __init__(self) -> None:
|
|
737
737
|
self._fns: list[Callable[[], Any]] = []
|
|
@@ -755,8 +755,8 @@ _ANSWER_CODE = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$")
|
|
|
755
755
|
|
|
756
756
|
|
|
757
757
|
def answer_of(exc: BaseException | None) -> dict[str, Any] | None:
|
|
758
|
-
"""
|
|
759
|
-
`error(..., response=answer_of(exc))`.
|
|
758
|
+
"""Error code and message an exception carries as its own attributes (e.g. postgrest APIError: `code`, `message`), for
|
|
759
|
+
`error(..., response=answer_of(exc))`. Attributes only, no parsing of the exception text; None without both attributes."""
|
|
760
760
|
if exc is None:
|
|
761
761
|
return None
|
|
762
762
|
code = getattr(exc, "code", None); msg = getattr(exc, "message", None)
|
|
@@ -765,7 +765,7 @@ def answer_of(exc: BaseException | None) -> dict[str, Any] | None:
|
|
|
765
765
|
|
|
766
766
|
|
|
767
767
|
def error_class_of(exc: BaseException | type | str) -> str:
|
|
768
|
-
"""
|
|
768
|
+
"""Stable error class from an exception name; acronyms stay together: APIError → api_error, HTTPException → http_exception, TypeError → type_error."""
|
|
769
769
|
import re as _re
|
|
770
770
|
name = exc if isinstance(exc, str) else (exc.__name__ if isinstance(exc, type) else type(exc).__name__)
|
|
771
771
|
s = _re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name); s = _re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
|
@@ -775,7 +775,7 @@ def error_class_of(exc: BaseException | type | str) -> str:
|
|
|
775
775
|
|
|
776
776
|
|
|
777
777
|
def _library_roots() -> tuple[str, ...]:
|
|
778
|
-
"""
|
|
778
|
+
"""Standard library and installed packages: not the app's code"""
|
|
779
779
|
import sysconfig
|
|
780
780
|
roots = {sysconfig.get_paths().get(k) for k in ("stdlib", "platstdlib", "purelib", "platlib")}
|
|
781
781
|
return tuple(os.path.realpath(r) + os.sep for r in roots if r)
|
|
@@ -796,8 +796,8 @@ def _in_app(path: str) -> bool:
|
|
|
796
796
|
|
|
797
797
|
|
|
798
798
|
def _frames_of(exc: BaseException) -> list[dict[str, Any]]:
|
|
799
|
-
"""Stack
|
|
800
|
-
(`_abs`,
|
|
799
|
+
"""Stack frames of an exception, innermost first (like a JS stack), file = the last two path parts; `in_app` and the full path
|
|
800
|
+
(`_abs`, internal only, for the code lines)"""
|
|
801
801
|
import traceback
|
|
802
802
|
out: list[dict[str, Any]] = []
|
|
803
803
|
for fs in reversed(traceback.extract_tb(exc.__traceback__)):
|
|
@@ -808,7 +808,7 @@ def _frames_of(exc: BaseException) -> list[dict[str, Any]]:
|
|
|
808
808
|
return out
|
|
809
809
|
|
|
810
810
|
|
|
811
|
-
# ──
|
|
811
|
+
# ── Code lines and scrubbing: the same rule as @everydeck/sdk (TS) and everydeck's scrubText; a parity check compares them ──
|
|
812
812
|
CONTEXT_FRAMES = 5
|
|
813
813
|
CONTEXT_LINES = 5
|
|
814
814
|
CONTEXT_LINE_MAX = 200
|
|
@@ -827,7 +827,7 @@ _EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
|
|
827
827
|
|
|
828
828
|
|
|
829
829
|
def scrub_text(text: str) -> str:
|
|
830
|
-
"""
|
|
830
|
+
"""Remove secrets, bearer tokens and e-mail addresses (error message or code line)"""
|
|
831
831
|
out = _BEARER.sub("Bearer [redacted]", str(text))
|
|
832
832
|
for rx in _SECRET_PATTERNS:
|
|
833
833
|
out = rx.sub("[redacted]", out)
|
|
@@ -836,7 +836,7 @@ def scrub_text(text: str) -> str:
|
|
|
836
836
|
|
|
837
837
|
|
|
838
838
|
def _context_of(lines: list[str], line: int) -> dict[str, Any] | None:
|
|
839
|
-
"""
|
|
839
|
+
"""The error line and CONTEXT_LINES before and after; None when the line is not in the file (no guessing)"""
|
|
840
840
|
if line < 1 or line > len(lines):
|
|
841
841
|
return None
|
|
842
842
|
cut = lambda x: scrub_text(x[:CONTEXT_LINE_MAX])[:CONTEXT_LINE_MAX]
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The general read bridge: Python version of the TypeScript SDK (read-bridge.ts), same behavior.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
(`everydeck/everydeck-read-scope.json`,
|
|
5
|
-
|
|
3
|
+
One endpoint per declared table, only declared columns, with a cursor. The agent writes no bridge per app: it takes the read scope
|
|
4
|
+
(`everydeck/everydeck-read-scope.json`, derived from the app map) and passes a function `rows(table, columns, keys, after, limit)` that reads rows from
|
|
5
|
+
the app's database through a dedicated reader role (SELECT only). `psycopg_rows(conn)` is the ready-made version for psycopg (2 or 3).
|
|
6
6
|
|
|
7
7
|
bridge = ReadBridge(scope=json.load(open("everydeck/everydeck-read-scope.json")), token=os.environ["EVERYDECK_READ_BRIDGE_TOKEN"], rows=psycopg_rows(conn))
|
|
8
8
|
# FastAPI: @app.api_route("/everydeck/read/{rest:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
|
|
@@ -10,9 +10,9 @@ der Datenbank der App liest – über eine eigene Leserolle (nur SELECT). `psyco
|
|
|
10
10
|
# status, body = bridge.handle(request.method, request.url.path, dict(request.query_params), request.headers.get("authorization"))
|
|
11
11
|
# return JSONResponse(body, status_code=status)
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
Contract: GET /everydeck/read/capabilities · GET /everydeck/read/objects/{type}?cursor&limit · GET /everydeck/read/people?cursor&limit; own Bearer key;
|
|
14
|
+
every write verb is rejected; pages ≤ 200; identifiers as text (several key columns joined with "|"); timestamps ISO-8601; object values
|
|
15
|
+
as JSON text; no value longer than 256 characters. Relations are delivered like object types (type = relations[].id).
|
|
16
16
|
"""
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
@@ -28,7 +28,7 @@ from typing import Any, Callable, Iterable, Mapping, Sequence
|
|
|
28
28
|
|
|
29
29
|
PAGE_MAX = 200
|
|
30
30
|
VALUE_MAX = 256
|
|
31
|
-
# rows(table, columns, keys, after, limit[, changed])
|
|
31
|
+
# rows(table, columns, keys, after, limit[, changed]); changed = {"column", "since"}: only changed rows, stable by (change column, keys); cursor is then [change time, …keys]
|
|
32
32
|
Rows = Callable[..., Iterable[Mapping[str, Any]]]
|
|
33
33
|
|
|
34
34
|
|
|
@@ -84,7 +84,7 @@ GRANT_MAX_SECONDS = 300
|
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
def sign_everydeck_grant(keys: Iterable[str], grant: Mapping[str, Any]) -> dict[str, str]:
|
|
87
|
-
"""
|
|
87
|
+
"""Counterpart (everydeck and tests): query parameters of a grant; grant = base64url(JSON), grant_sig = v1=<hex> per key."""
|
|
88
88
|
g = base64.urlsafe_b64encode(json.dumps(grant, separators=(",", ":")).encode()).decode().rstrip("=")
|
|
89
89
|
sigs = []
|
|
90
90
|
for k in keys:
|
|
@@ -100,8 +100,8 @@ def _is_name(x: Any) -> bool:
|
|
|
100
100
|
|
|
101
101
|
|
|
102
102
|
def grant_of(keys: Iterable[str], query: Mapping[str, str] | None, typ: str, ref: str) -> dict[str, Any] | str:
|
|
103
|
-
"""
|
|
104
|
-
|
|
103
|
+
"""Check a grant: signature with a valid key, exactly this conversation, a reason, not expired, message table complete.
|
|
104
|
+
Returns the conversation ({messages, attachments}) or the reason for rejection."""
|
|
105
105
|
q = query or {}; g = q.get("grant"); sig = q.get("grant_sig")
|
|
106
106
|
if not g or not sig:
|
|
107
107
|
return "grant missing"
|
|
@@ -131,12 +131,12 @@ class ReadBridge:
|
|
|
131
131
|
deleted: Callable[[str, Sequence[str], str, int], Sequence[Mapping[str, Any]]] | None = None,
|
|
132
132
|
signatures: str = "required", keys: Sequence[str] | None = None, seen: Callable[[str, float], bool] | None = None,
|
|
133
133
|
match: Callable[..., Iterable[Mapping[str, Any]]] | None = None) -> None:
|
|
134
|
-
#
|
|
134
|
+
# match(table, columns, where_column, values, order, after, limit) → rows of one conversation (psycopg_match); without match no conversations
|
|
135
135
|
self.match = match
|
|
136
|
-
#
|
|
136
|
+
# one(table, columns, keys, ref) → one row; deleted(table, keys, since, limit) → [{ref, deleted_at}] (deletions the app knows about)
|
|
137
137
|
self.one = one; self.deleted = deleted
|
|
138
|
-
#
|
|
139
|
-
# keys =
|
|
138
|
+
# everydeck signs every call: "required" (default) rejects unsigned calls, "optional" accepts the Bearer without a signature (everydeck instances that do not sign yet), "off" checks only the Bearer;
|
|
139
|
+
# keys = valid keys (default: token; during a rotation the new and the old one), seen = replay protection across several instances
|
|
140
140
|
if signatures not in ("required", "optional", "off"): raise ValueError("signatures must be required, optional or off")
|
|
141
141
|
self.signatures = signatures; self.keys = list(keys) if keys else [token]; self.seen = seen
|
|
142
142
|
self.scope = scope
|
|
@@ -144,7 +144,7 @@ class ReadBridge:
|
|
|
144
144
|
self.rows = rows
|
|
145
145
|
self.app = app
|
|
146
146
|
self.page_limit = min(PAGE_MAX, max(1, page_limit))
|
|
147
|
-
self._rights = rights #
|
|
147
|
+
self._rights = rights # fixed, or a function that checks on the first call (psycopg_rights); never just claimed
|
|
148
148
|
self._rights_cache: dict[str, Any] | None = None
|
|
149
149
|
self.types: dict[str, dict[str, Any]] = {}
|
|
150
150
|
for o in scope.get("objects", []):
|
|
@@ -161,7 +161,7 @@ class ReadBridge:
|
|
|
161
161
|
return self._rights_cache
|
|
162
162
|
|
|
163
163
|
def capabilities(self) -> dict[str, Any]:
|
|
164
|
-
#
|
|
164
|
+
# per object type whether changed_since works (change column in the scope), and whether fetching one object works
|
|
165
165
|
objects = [{"type": o["type"], "fields": [f["name"] for f in o["fields"]], **({"statuses": list(o["statuses"])} if o.get("statuses") else {}), "changed_since": bool(o.get("changed"))} for o in self.scope.get("objects", [])]
|
|
166
166
|
objects += [{"type": r["id"], "fields": [f["name"] for f in r["fields"]], "changed_since": False} for r in self.scope.get("relations", [])]
|
|
167
167
|
people = self.scope.get("people")
|
|
@@ -179,7 +179,7 @@ class ReadBridge:
|
|
|
179
179
|
|
|
180
180
|
def handle(self, method: str, path: str, query: Mapping[str, str] | None = None, authorization: str | None = None,
|
|
181
181
|
headers: Mapping[str, str] | None = None, body: bytes | str | None = None, url: str | None = None) -> tuple[int, Any]:
|
|
182
|
-
# headers/url/body
|
|
182
|
+
# headers/url/body: headers and path with query as the app sees them, for the signature check; FastAPI: request.headers, str(request.url), await request.body()
|
|
183
183
|
full = url if url is not None else (path + ("?" + "&".join(f"{k}={v}" for k, v in (query or {}).items()) if query else ""))
|
|
184
184
|
path = path.split("?", 1)[0]
|
|
185
185
|
if not path.startswith("/everydeck/read/"):
|
|
@@ -192,8 +192,8 @@ class ReadBridge:
|
|
|
192
192
|
if not v.ok:
|
|
193
193
|
return 401, {"error": f"signature rejected: {v.reason}"}
|
|
194
194
|
elif self.signatures == "required":
|
|
195
|
-
return 401, {"error": "signature required (X-Everydeck-Signature missing)
|
|
196
|
-
elif not hmac.compare_digest((authorization or "").encode(), f"Bearer {self.token}".encode()): #
|
|
195
|
+
return 401, {"error": "signature required (X-Everydeck-Signature missing): everydeck signs every call; use signatures=\"optional\" only for everydeck instances that do not sign calls yet"}
|
|
196
|
+
elif not hmac.compare_digest((authorization or "").encode(), f"Bearer {self.token}".encode()): # constant time
|
|
197
197
|
return 401, {"error": "read key invalid"}
|
|
198
198
|
if method.upper() != "GET":
|
|
199
199
|
return 405, {"error": "read only"}
|
|
@@ -212,7 +212,7 @@ class ReadBridge:
|
|
|
212
212
|
rows = list(self.rows(p["table"], cols, [p["ref"]], after, limit + 1))
|
|
213
213
|
kept = rows[:limit]
|
|
214
214
|
|
|
215
|
-
def name_of(r): #
|
|
215
|
+
def name_of(r): # display name by the app's rule: first step with content, parts joined with the app's separator (as in read-bridge.ts)
|
|
216
216
|
for st in steps:
|
|
217
217
|
parts = [x for x in ("" if r.get(c) is None else _text(r[c]).strip() for c in st["columns"]) if x]
|
|
218
218
|
if parts:
|
|
@@ -220,11 +220,11 @@ class ReadBridge:
|
|
|
220
220
|
return None
|
|
221
221
|
items = [{"subject_ref": _text(r[p["ref"]])[:128], **{f: (None if r.get(c) is None else _text(r[c])[:VALUE_MAX]) for f, c in p["fields"].items()}, **({"display_name": name_of(r)} if steps and "display_name" in p["fields"] else {})} for r in kept]
|
|
222
222
|
return 200, {"as_of": as_of, "items": items, "next_cursor": _b64([_text(kept[-1][p["ref"]])]) if len(rows) > limit else None}
|
|
223
|
-
if path.startswith("/everydeck/read/messages/") and path[len("/everydeck/read/messages/"):].count("/") == 1: #
|
|
223
|
+
if path.startswith("/everydeck/read/messages/") and path[len("/everydeck/read/messages/"):].count("/") == 1: # messages of one conversation, on demand only
|
|
224
224
|
typ, ref = path[len("/everydeck/read/messages/"):].split("/", 1)
|
|
225
225
|
if self.match is None:
|
|
226
226
|
return 404, {"error": "this bridge reads no conversations"}
|
|
227
|
-
#
|
|
227
|
+
# only with everydeck's grant for exactly this conversation (opened with a reason); table and columns are only in the grant
|
|
228
228
|
conv = grant_of(self.keys, query, typ, ref)
|
|
229
229
|
if isinstance(conv, str):
|
|
230
230
|
return 403, {"error": f"a conversation is read only when a moderator opens it with a reason in everydeck ({conv})"}
|
|
@@ -244,7 +244,7 @@ class ReadBridge:
|
|
|
244
244
|
"text": "\n".join(_text(r[c]) for c in m["text"] if r.get(c) is not None)[:4000], **({"attachments": att[ids[i]]} if att.get(ids[i]) else {})} for i, r in enumerate(kept)]
|
|
245
245
|
return 200, {"type": typ, "ref": ref[:128], "as_of": as_of, "items": items, "next_cursor": _b64([_iso(kept[-1][m["time"]]) or "", *(_text(kept[-1][k]) for k in m["ref"])]) if len(rows) > limit else None}
|
|
246
246
|
rest = path[len("/everydeck/read/objects/"):] if path.startswith("/everydeck/read/objects/") else None
|
|
247
|
-
if rest is not None and rest.count("/") == 1: #
|
|
247
|
+
if rest is not None and rest.count("/") == 1: # fetch one object: GET /everydeck/read/objects/{type}/{ref}
|
|
248
248
|
typ, ref_raw = rest.split("/", 1)
|
|
249
249
|
x = self.types.get(typ)
|
|
250
250
|
if not x:
|
|
@@ -267,7 +267,7 @@ class ReadBridge:
|
|
|
267
267
|
if bad:
|
|
268
268
|
return 400, {"error": "bad cursor"}
|
|
269
269
|
since_raw = (query or {}).get("changed_since")
|
|
270
|
-
if since_raw is not None: #
|
|
270
|
+
if since_raw is not None: # only rows changed since `since`, stable by (change column, keys); plus deletions where the app knows them
|
|
271
271
|
if not x["changed"]:
|
|
272
272
|
return 400, {"error": f"changed_since is not supported for {typ}: no column the database sets on every change"}
|
|
273
273
|
since = _iso(since_raw)
|
|
@@ -306,7 +306,7 @@ class ReadBridge:
|
|
|
306
306
|
|
|
307
307
|
|
|
308
308
|
def _iso(v: Any) -> str | None:
|
|
309
|
-
"""
|
|
309
|
+
"""Timestamp as ISO-8601 (UTC, Z) or None"""
|
|
310
310
|
if v is None:
|
|
311
311
|
return None
|
|
312
312
|
if isinstance(v, _dt.datetime):
|
|
@@ -325,18 +325,18 @@ def _ident(name: str) -> str:
|
|
|
325
325
|
|
|
326
326
|
|
|
327
327
|
def read_view_name(table: str) -> str:
|
|
328
|
-
"""Name
|
|
328
|
+
"""Name of the view in the reader role's schema (everydeck's SQL creates it): the table name, outside public `<schema>__<table>`."""
|
|
329
329
|
return table.replace(".", "__", 1) if "." in table else table
|
|
330
330
|
|
|
331
331
|
|
|
332
332
|
def psycopg_rows(conn: Any, schema: str | None = None) -> Rows:
|
|
333
|
-
"""`rows`
|
|
334
|
-
`schema`:
|
|
333
|
+
"""`rows` for psycopg 2/3: keyset cursor over the key columns, only the given columns; the connection belongs to the reader role.
|
|
334
|
+
`schema`: the reader role's views (e.g. everydeck_read) instead of the tables, so the app's row-level policies do not apply to the reader."""
|
|
335
335
|
def rows(table: str, columns: Sequence[str], keys: Sequence[str], after: Sequence[str] | None, limit: int, changed: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
|
|
336
336
|
src = f"{_ident(schema)}.{_ident(read_view_name(table))}" if schema else _ident(table)
|
|
337
337
|
lim = f"LIMIT {max(1, min(PAGE_MAX + 1, int(limit)))}"
|
|
338
338
|
cur = conn.cursor()
|
|
339
|
-
if changed: #
|
|
339
|
+
if changed: # only rows changed since `since`, stable by (change column, keys); cursor = [change time, …keys]
|
|
340
340
|
order = [changed["column"], *keys]; params: list[Any] = [changed["since"]]
|
|
341
341
|
where = f" WHERE {_ident(changed['column'])} >= %s"
|
|
342
342
|
if after and len(after) == len(order):
|
|
@@ -352,7 +352,7 @@ def psycopg_rows(conn: Any, schema: str | None = None) -> Rows:
|
|
|
352
352
|
|
|
353
353
|
|
|
354
354
|
def psycopg_one(conn: Any, schema: str | None = None) -> Callable[[str, Sequence[str], Sequence[str], Sequence[str]], Mapping[str, Any] | None]:
|
|
355
|
-
"""
|
|
355
|
+
"""`one` for psycopg: exactly one row by its key values."""
|
|
356
356
|
def one(table: str, columns: Sequence[str], keys: Sequence[str], ref: Sequence[str]) -> Mapping[str, Any] | None:
|
|
357
357
|
src = f"{_ident(schema)}.{_ident(read_view_name(table))}" if schema else _ident(table)
|
|
358
358
|
cur = conn.cursor()
|
|
@@ -365,7 +365,7 @@ def psycopg_one(conn: Any, schema: str | None = None) -> Callable[[str, Sequence
|
|
|
365
365
|
|
|
366
366
|
|
|
367
367
|
def psycopg_match(conn: Any, schema: str | None = None) -> Callable[..., list[dict[str, Any]]]:
|
|
368
|
-
"""
|
|
368
|
+
"""`match` for psycopg: rows with where_column = ANY(values), keyset cursor over order (messages of one conversation)."""
|
|
369
369
|
def match(table: str, columns: Sequence[str], where_column: str, values: Sequence[str], order: Sequence[str], after: Sequence[str] | None, limit: int) -> list[dict[str, Any]]:
|
|
370
370
|
src = f"{_ident(schema)}.{_ident(read_view_name(table))}" if schema else _ident(table)
|
|
371
371
|
params: list[Any] = [list(values)]; cond = f"{_ident(where_column)}::text = ANY(%s)"
|
|
@@ -380,7 +380,7 @@ def psycopg_match(conn: Any, schema: str | None = None) -> Callable[..., list[di
|
|
|
380
380
|
|
|
381
381
|
|
|
382
382
|
def scope_tables(scope: Mapping[str, Any]) -> list[str]:
|
|
383
|
-
"""
|
|
383
|
+
"""All tables of the scope (objects, relations, conversations, people)."""
|
|
384
384
|
out: list[str] = []
|
|
385
385
|
convs = [t for c in (scope.get("conversations") or []) for t in [c["table"], c["messages"]["table"], *([c["attachments"]["table"]] if c.get("attachments") else [])]]
|
|
386
386
|
for t in [o["table"] for o in scope.get("objects", [])] + [r["table"] for r in scope.get("relations", [])] + convs + ([scope["people"]["table"]] if scope.get("people") else []):
|
|
@@ -397,22 +397,22 @@ EXPOSURE_SQL = """SELECT
|
|
|
397
397
|
|
|
398
398
|
|
|
399
399
|
def psycopg_rights(conn: Any, scope: Mapping[str, Any], view_schema: str | None = None) -> Callable[[], dict[str, Any]]:
|
|
400
|
-
"""
|
|
401
|
-
|
|
400
|
+
"""Check the role's rights instead of claiming them: superuser (pg_roles.rolsuper) and INSERT/UPDATE/DELETE per scope table (has_table_privilege);
|
|
401
|
+
with `view_schema` (the reader role's views, e.g. everydeck_read) also whether the schema is reachable through the Supabase API (pgrst.db_schemas) or for anon/authenticated/PUBLIC."""
|
|
402
402
|
def check() -> dict[str, Any]:
|
|
403
403
|
cur = conn.cursor()
|
|
404
404
|
cur.execute("SELECT current_user::text, (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)")
|
|
405
405
|
role, superuser = cur.fetchone()
|
|
406
406
|
writable = []
|
|
407
407
|
for t in scope_tables(scope):
|
|
408
|
-
#
|
|
408
|
+
# via the catalog (pg_class), not by name: has_table_privilege(name) requires USAGE on the schema, which a reader role does not have
|
|
409
409
|
schema, name = t.split(".", 1) if "." in t else ("public", t)
|
|
410
410
|
cur.execute("SELECT (has_table_privilege(current_user, c.oid, 'INSERT') OR has_table_privilege(current_user, c.oid, 'UPDATE') OR has_table_privilege(current_user, c.oid, 'DELETE')) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = %s AND c.relname = %s", [schema, name])
|
|
411
411
|
row = cur.fetchone()
|
|
412
412
|
if row and row[0]:
|
|
413
413
|
writable.append(t)
|
|
414
414
|
out = {"role": role, "superuser": bool(superuser), "writable": writable, "select_only": not superuser and not writable, "checked_at": _dt.datetime.now(_dt.timezone.utc).isoformat().replace("+00:00", "Z")}
|
|
415
|
-
if view_schema: #
|
|
415
|
+
if view_schema: # the views run as their owner (no app row-level policies): nobody but the reader role may access the schema
|
|
416
416
|
cur.execute(EXPOSURE_SQL, {"s": view_schema})
|
|
417
417
|
api, anon, authenticated, pub = cur.fetchone()
|
|
418
418
|
out["exposed"] = [n for n, v in (("supabase api", api), ("anon", anon), ("authenticated", authenticated), ("public", pub)) if v]
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""The read bridge as a ready-made route: Python version of @everydeck/sdk/read-bridge, same behavior.
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The app mounts it with the connection it already has; no reader role, no views, no read scope file in the repo:
|
|
4
4
|
|
|
5
|
-
from everydeck.read_route import EverydeckReadBridge, SupabaseReadOnly #
|
|
5
|
+
from everydeck.read_route import EverydeckReadBridge, SupabaseReadOnly # or PsycopgReadOnly(os.environ["DATABASE_URL"])
|
|
6
6
|
bridge = EverydeckReadBridge(os.environ["EVERYDECK_URL"], os.environ["EVERYDECK_READ_BRIDGE_TOKEN"], SupabaseReadOnly(supabase_admin))
|
|
7
7
|
@app.api_route("/everydeck/read/{rest:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
|
|
8
8
|
async def everydeck_read(rest: str, request: Request):
|
|
@@ -10,13 +10,13 @@ Die App bindet sie mit der Verbindung ein, die sie schon hat; keine Leserolle, k
|
|
|
10
10
|
request.headers.get("authorization"), request.headers, await request.body(), str(request.url))
|
|
11
11
|
return JSONResponse(body, status_code=status)
|
|
12
12
|
|
|
13
|
-
-
|
|
14
|
-
|
|
15
|
-
- PsycopgReadOnly:
|
|
16
|
-
|
|
17
|
-
- SupabaseReadOnly:
|
|
18
|
-
in
|
|
19
|
-
|
|
13
|
+
- The route fetches the read scope from everydeck (GET <endpoint>/v1/read-scope with the bridge key as Bearer) and uses it only with a valid
|
|
14
|
+
signature (HMAC-SHA256 with the same key over "EVERYDECK-SCOPE-V1\\n" + payload); otherwise it reads nothing (503).
|
|
15
|
+
- PsycopgReadOnly: every query runs in its own BEGIN READ ONLY transaction on the app's connection; the database rejects any write inside it.
|
|
16
|
+
The rights are checked: role, superuser, rolbypassrls, current_setting('transaction_read_only').
|
|
17
|
+
- SupabaseReadOnly: the app's Supabase client (PostgREST). The route only calls select(), so only GET requests; PostgREST runs GET requests
|
|
18
|
+
in a read-only transaction. Only tables in schemas exposed by the Supabase API are reachable.
|
|
19
|
+
No dependencies beyond the standard library; the app brings psycopg or supabase itself.
|
|
20
20
|
"""
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
@@ -63,7 +63,7 @@ def verify_scope_signature(keys: Iterable[str], payload: str, signature: str | N
|
|
|
63
63
|
|
|
64
64
|
|
|
65
65
|
class PsycopgReadOnly:
|
|
66
|
-
"""
|
|
66
|
+
"""The app's connection via psycopg 3: `PsycopgReadOnly(conninfo)` or `PsycopgReadOnly(connect=lambda: …)`; every query in BEGIN READ ONLY … COMMIT."""
|
|
67
67
|
|
|
68
68
|
def __init__(self, conninfo: str | None = None, *, connect: Callable[[], Any] | None = None) -> None:
|
|
69
69
|
if conninfo is None and connect is None:
|
|
@@ -87,7 +87,7 @@ class PsycopgReadOnly:
|
|
|
87
87
|
try:
|
|
88
88
|
conn.cursor().execute("ROLLBACK")
|
|
89
89
|
except Exception:
|
|
90
|
-
self._conn = None #
|
|
90
|
+
self._conn = None # connection broken: reconnect on the next call
|
|
91
91
|
raise
|
|
92
92
|
|
|
93
93
|
def rows(self, *a: Any, **k: Any) -> list[dict[str, Any]]:
|
|
@@ -110,12 +110,12 @@ class PsycopgReadOnly:
|
|
|
110
110
|
|
|
111
111
|
|
|
112
112
|
def _quote(v: str) -> str:
|
|
113
|
-
"""
|
|
113
|
+
"""Value in a PostgREST filter (or=…): in double quotes, with \\ and \" escaped"""
|
|
114
114
|
return '"' + str(v).replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
class SupabaseReadOnly:
|
|
118
|
-
"""
|
|
118
|
+
"""The app's Supabase client (supabase-py, PostgREST): only select(), so only GET requests; PostgREST runs them in a read-only transaction."""
|
|
119
119
|
|
|
120
120
|
def __init__(self, client: Any) -> None:
|
|
121
121
|
self.client = client
|
|
@@ -163,21 +163,21 @@ class SupabaseReadOnly:
|
|
|
163
163
|
return list(q.limit(max(1, min(PAGE_MAX + 1, int(limit)))).execute().data or [])
|
|
164
164
|
|
|
165
165
|
def rights(self, scope: Mapping[str, Any]) -> dict[str, Any]:
|
|
166
|
-
#
|
|
166
|
+
# The role cannot be checked by SQL through PostgREST; the path is fixed: only GET (select), run read-only by PostgREST.
|
|
167
167
|
return {"role": "supabase api (postgrest)", "select_only": False, "read_only_transaction": True, "checked_at": _dt.datetime.now(_dt.timezone.utc).isoformat().replace("+00:00", "Z")}
|
|
168
168
|
|
|
169
169
|
|
|
170
170
|
def _default_fetch(url: str, headers: Mapping[str, str]) -> tuple[int, str]:
|
|
171
171
|
req = urllib.request.Request(url, headers=dict(headers), method="GET")
|
|
172
172
|
try:
|
|
173
|
-
with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310
|
|
173
|
+
with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310 (everydeck address from the app's environment)
|
|
174
174
|
return r.status, r.read().decode()
|
|
175
175
|
except urllib.error.HTTPError as e:
|
|
176
176
|
return e.code, ""
|
|
177
177
|
|
|
178
178
|
|
|
179
179
|
class EverydeckReadBridge:
|
|
180
|
-
"""
|
|
180
|
+
"""The ready-made read bridge: read scope signed by everydeck, reads through `db` (PsycopgReadOnly or SupabaseReadOnly)."""
|
|
181
181
|
|
|
182
182
|
def __init__(self, endpoint: str | None, key: str | None, db: Any, *, previous_key: str | None = None, app: str | None = None, scope_ttl: float = 60.0,
|
|
183
183
|
seen: Callable[[str, float], bool] | None = None, fetch: Callable[[str, Mapping[str, str]], tuple[int, str]] | None = None) -> None:
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""Verification helper for bridges: everydeck signs every call to a read or app bridge (like Stripe).
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
Headers: X-Everydeck-Timestamp (Unix seconds), X-Everydeck-Request-Id (UUID), X-Everydeck-Signature (v1=<hex>[,v1=<hex>], one per valid key).
|
|
4
|
+
Signature = HMAC-SHA256(key, "EVERYDECK-V1\\n" + METHOD + "\\n" + path from /everydeck/ with query + "\\n" + timestamp + "\\n" + request_id + "\\n" + SHA-256(body) hex).
|
|
5
|
+
The app rejects the call when the timestamp is off by more than 5 minutes or the request_id was already seen within that window.
|
|
6
|
+
The signature's request_id (X-Everydeck-Request-Id) is new for every HTTP call, even when everydeck retries the same job; replay
|
|
7
|
+
protection only catches a literally repeated request. Idempotency of an action is carried by `request_id` in the body and `Idempotency-Key`;
|
|
8
|
+
the bridge route answers a retry with the recorded result, never with 401.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
One line in the app's bridge route (FastAPI):
|
|
11
11
|
v = verify_everydeck_request(request.method, str(request.url), request.headers, await request.body(), keys=[os.environ["EVERYDECK_BRIDGE_KEY"]])
|
|
12
12
|
if not v.ok: raise HTTPException(401)
|
|
13
13
|
"""
|
|
@@ -42,7 +42,7 @@ def signature_for(key: str, canonical: str) -> str:
|
|
|
42
42
|
|
|
43
43
|
|
|
44
44
|
class MemorySeen:
|
|
45
|
-
"""
|
|
45
|
+
"""Per-process replay store: request_id → timestamp, pruned after the window (several instances: use your own check, e.g. Redis/Postgres)."""
|
|
46
46
|
|
|
47
47
|
def __init__(self, window_seconds: int = SIGNATURE_WINDOW_SECONDS, now: Callable[[], float] = time.time) -> None:
|
|
48
48
|
self._m: dict[str, float] = {}; self._lock = threading.Lock(); self.window = window_seconds; self.now = now
|
|
@@ -109,7 +109,7 @@ def verify_everydeck_request(method: str, url_or_path: str, headers: Mapping[str
|
|
|
109
109
|
|
|
110
110
|
|
|
111
111
|
def sign_everydeck_request(keys: Iterable[str], method: str, url_or_path: str, body: bytes | str | None = None, request_id: str | None = None, now: float | None = None) -> dict[str, str]:
|
|
112
|
-
"""
|
|
112
|
+
"""Counterpart for the app's tests and check scripts: headers as everydeck sends them (first key current, further keys previous). In production only everydeck signs."""
|
|
113
113
|
import uuid
|
|
114
114
|
timestamp = int((time.time() if now is None else now))
|
|
115
115
|
rid = request_id or str(uuid.uuid4())
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: everydeck
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.2
|
|
4
4
|
Summary: everydeck Python SDK: bounded buffer, background sender, flush on shutdown, reported data gaps
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
Project-URL: Homepage, https://everydeck.ai
|
|
@@ -40,7 +40,7 @@ everydeck.signal("job.created", operation_id)
|
|
|
40
40
|
- `signal()`, `error()` and `log()` never block and never raise: they enqueue into a bounded in-memory buffer; a background thread sends batches (at most 50 events, 64 KiB, gzip-compressed).
|
|
41
41
|
- Retries only on 429, 5xx, timeout and network errors; `Retry-After` is honoured; final rejections are dropped and counted, never reported as sent.
|
|
42
42
|
- A buffer overflow is reported as a `gap` event, so everydeck shows a known data gap instead of silence.
|
|
43
|
-
- `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"
|
|
43
|
+
- `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"worker"`).
|
|
44
44
|
- `release_key="auto"` derives the version from the deployment commit (`EVERYDECK_RELEASE_KEY`, `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`, …) or local git.
|
|
45
45
|
- `everydeck.verify.verify_everydeck_request` checks everydeck's signature on bridge calls.
|
|
46
46
|
- Read bridge (0.3.0): `everydeck.read_route.EverydeckReadBridge(endpoint, key, db)` is the ready-made route. It fetches its read scope from everydeck (`GET <endpoint>/v1/read-scope` with the bridge key) and uses it only with everydeck's signature; no read-scope file, no read role, no views. `db` is the connection the app already has: `PsycopgReadOnly(conninfo)` runs every query in `BEGIN READ ONLY`, `SupabaseReadOnly(client)` reads through the app's Supabase client with `select()` only (GET requests, which PostgREST runs read-only; only tables in schemas the Supabase API exposes).
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|