ssc-app 0.0.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ssc_app/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Tiny helper library apps may install to read the identity note (``ssc_app.identity``)."""
2
+
3
+ from ssc_app.identity import IdentityRefused, IdentityVerifier, verify
4
+
5
+ __all__ = ["IdentityRefused", "IdentityVerifier", "verify"]
ssc_app/data.py ADDED
@@ -0,0 +1,230 @@
1
+ """Read a database the org connected, through the cell's data gateway (SSC-052). Name the connection
2
+ in ``[connections] names`` in ``ssc.toml``; an admin grants the environment the connection. For
3
+ a ``postgres`` connection:
4
+
5
+ from ssc_app import data
6
+
7
+ result = data.query("finance", "select id, total from invoices where year = $1", [2026])
8
+ for row in result.rows: # each row is a list in column order
9
+ ...
10
+
11
+ The call carries the app's own workload token (``ssc_app.workload``), so the app holds no database
12
+ credentials. The statement is one read, and nothing can write. Its form follows the connection's
13
+ kind: one ``SELECT`` for ``postgres`` (``$1``, ``$2``, ... placeholders) and for ``mysql``,
14
+ ``sqlserver``, ``bigquery`` and ``snowflake`` (``?`` placeholders); and, with no parameters, an A1
15
+ range for ``gsheets``, ``list <prefix>`` or ``get <key>`` for ``s3`` and ``gcs``,
16
+ ``<table>[ view <view>][ where <formula>]`` for ``airtable`` and a GET path for ``rest``.
17
+ ``max_rows``, ``max_bytes`` and ``timeout_ms`` only narrow what the platform, the connection and the
18
+ grant allow, and ``truncated`` says the gateway stopped reading before the result ended. To act for
19
+ the signed-in user, pass the ``X-SSC-Identity`` value of the request being answered as
20
+ ``identity``; without it the app acts for itself (``docs/contracts/data-gateway.md``).
21
+
22
+ ``data.describe("finance")`` names the tables of a granted connection and their typed columns
23
+ (GA-5.8): a ``Schema`` of ``Table`` s, each ``{name, type, db_type}`` column as a query types it,
24
+ none for a file not yet read. The data gateway keeps a description five minutes.
25
+
26
+ The data gateway's address comes from the metadata server, as for ``ssc_app.files``, and
27
+ ``SSC_DATAGW_URL`` or ``url=`` replaces it. A query only reads, so it is tried once more when the
28
+ data gateway cannot be reached or answers 502, 503 or 504; a query that timed out is not. Same
29
+ names and behaviour as the Node helper ``@delimitus/ssc-data``, which has no ``describe`` yet.
30
+ """
31
+
32
+ import json
33
+ import os
34
+ import threading
35
+ import urllib.error
36
+ import urllib.request
37
+ from collections.abc import Mapping, Sequence
38
+ from dataclasses import dataclass
39
+ from typing import Any, Final
40
+
41
+ from ssc_app.files import RETRY_STATUSES, URL_VARIABLE, FilesError, gateway_url
42
+ from ssc_app.workload import METADATA, WorkloadToken
43
+
44
+ QUERY_TIMEOUT_SECONDS: Final = 60.0
45
+ """Longer than the data gateway's 30 s query limit plus its start from zero."""
46
+ IDENTITY_HEADER: Final = "x-ssc-identity"
47
+
48
+
49
+ class DataError(RuntimeError):
50
+ """A refusal or a failure. ``code`` is the data gateway's (``CONNECTION_NOT_GRANTED``,
51
+ ``CONNECTION_SUSPENDED``, ``QUERY_REFUSED``, ``QUERY_FAILED``, ``DAILY_BUDGET_SPENT``, ...) or
52
+ ``UNREACHABLE``; ``sqlstate`` is the database's for ``QUERY_FAILED`` when it gave one."""
53
+
54
+ def __init__(
55
+ self, code: str, message: str, status: int | None = None, sqlstate: str | None = None
56
+ ) -> None:
57
+ super().__init__(f"{code}: {message}")
58
+ self.code = code
59
+ self.status = status
60
+ self.sqlstate = sqlstate
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class QueryResult:
65
+ """What a query returned. ``columns`` are ``{name, type, db_type}``; a decimal is a string,
66
+ a timestamp is ISO 8601, bytes are base64."""
67
+
68
+ columns: list[dict[str, str]]
69
+ rows: list[list[Any]]
70
+ row_count: int
71
+ truncated: bool
72
+ truncated_reason: str | None
73
+ request_id: str
74
+
75
+
76
+ @dataclass(frozen=True, slots=True)
77
+ class Table:
78
+ """One table of a connection as its reads name it. ``columns`` are ``{name, type,
79
+ db_type}``, typed as a query types them; none when only a read can tell (a file)."""
80
+
81
+ name: str
82
+ columns: list[dict[str, str]]
83
+
84
+
85
+ @dataclass(frozen=True, slots=True)
86
+ class Schema:
87
+ """What ``describe`` returned: the connection's name, its kind and its tables."""
88
+
89
+ connection: str
90
+ kind: str
91
+ tables: list[Table]
92
+
93
+
94
+ def _send(url: str, headers: Mapping[str, str], body: bytes | None) -> tuple[int, bytes]:
95
+ """POST ``body``, or GET when it is None."""
96
+ method = "GET" if body is None else "POST"
97
+ request = urllib.request.Request(url, data=body, method=method, headers=dict(headers)) # noqa: S310
98
+ try:
99
+ with urllib.request.urlopen(request, timeout=QUERY_TIMEOUT_SECONDS) as response: # noqa: S310
100
+ return response.status, response.read()
101
+ except urllib.error.HTTPError as exc:
102
+ return exc.code, exc.read()
103
+
104
+
105
+ def _refusal(status: int, raw: bytes) -> DataError:
106
+ try:
107
+ error = json.loads(raw)["error"]
108
+ return DataError(str(error["code"]), str(error["message"]), status, error.get("sqlstate"))
109
+ except (ValueError, KeyError, TypeError):
110
+ return DataError("UNAVAILABLE", f"the data gateway answered HTTP {status}", status)
111
+
112
+
113
+ class Data:
114
+ """The data gateway of the cell this app runs in. ``url`` and ``metadata`` replace the data
115
+ gateway's address and the metadata server's (tests). Safe to share between threads."""
116
+
117
+ def __init__(self, *, url: str | None = None, metadata: str = METADATA) -> None:
118
+ self._url = (url or os.environ.get(URL_VARIABLE) or "").rstrip("/") or None
119
+ self._metadata = metadata
120
+ self._tokens: WorkloadToken | None = None
121
+ self._lock = threading.Lock()
122
+
123
+ def query( # noqa: PLR0913 (keyword-only)
124
+ self,
125
+ name: str,
126
+ sql: str,
127
+ params: Sequence[Any] = (),
128
+ *,
129
+ max_rows: int | None = None,
130
+ max_bytes: int | None = None,
131
+ timeout_ms: int | None = None,
132
+ identity: str | None = None,
133
+ ) -> QueryResult:
134
+ """Run one read-only statement on connection ``name``. ``DataError`` for a refusal."""
135
+ asks = {"max_rows": max_rows, "max_bytes": max_bytes, "timeout_ms": timeout_ms}
136
+ body: dict[str, Any] = {"sql": sql, "params": list(params)}
137
+ body.update({k: v for k, v in asks.items() if v is not None})
138
+ answer = self._ask(f"{name}/query", json.dumps(body).encode(), identity)
139
+ return QueryResult(
140
+ columns=answer["columns"],
141
+ rows=answer["rows"],
142
+ row_count=answer["row_count"],
143
+ truncated=answer["truncated"],
144
+ truncated_reason=answer["truncated_reason"],
145
+ request_id=answer["request_id"],
146
+ )
147
+
148
+ def describe(self, name: str) -> Schema:
149
+ """The tables of connection ``name`` and their columns. ``DataError`` for a refusal."""
150
+ answer = self._ask(f"{name}/schema", None, None)
151
+ return Schema(
152
+ connection=answer["connection"],
153
+ kind=answer["kind"],
154
+ tables=[Table(name=t["name"], columns=t["columns"]) for t in answer["tables"]],
155
+ )
156
+
157
+ def _ask(self, path: str, body: bytes | None, identity: str | None) -> dict[str, Any]:
158
+ """The data gateway's answer to ``/v1/connections/<path>``, asked once more while it
159
+ cannot be reached or starts."""
160
+ url, tokens = self._target()
161
+ for attempt in (1, 2):
162
+ headers = {"authorization": f"Bearer {tokens.get()}"}
163
+ if body is not None:
164
+ headers["content-type"] = "application/json"
165
+ if identity is not None:
166
+ headers[IDENTITY_HEADER] = identity
167
+ try:
168
+ status, raw = _send(f"{url}/v1/connections/{path}", headers, body)
169
+ except TimeoutError:
170
+ raise DataError("UNREACHABLE", "data gateway: TimeoutError") from None
171
+ except (urllib.error.URLError, OSError) as exc:
172
+ if attempt == 1:
173
+ continue
174
+ raise DataError("UNREACHABLE", f"data gateway: {type(exc).__name__}") from None
175
+ if status in RETRY_STATUSES and attempt == 1:
176
+ continue
177
+ if status != 200: # noqa: PLR2004
178
+ raise _refusal(status, raw)
179
+ return json.loads(raw)
180
+ raise AssertionError
181
+
182
+ def _target(self) -> tuple[str, WorkloadToken]:
183
+ with self._lock:
184
+ if self._url is None:
185
+ try:
186
+ self._url = gateway_url(self._metadata)
187
+ except FilesError as exc:
188
+ raise DataError(exc.code, str(exc).partition(": ")[2]) from None
189
+ if self._tokens is None:
190
+ self._tokens = WorkloadToken(audience=self._url, metadata=self._metadata)
191
+ return self._url, self._tokens
192
+
193
+
194
+ _default: Data | None = None
195
+ _default_lock = threading.Lock()
196
+
197
+
198
+ def query( # noqa: PLR0913 (keyword-only)
199
+ name: str,
200
+ sql: str,
201
+ params: Sequence[Any] = (),
202
+ *,
203
+ max_rows: int | None = None,
204
+ max_bytes: int | None = None,
205
+ timeout_ms: int | None = None,
206
+ identity: str | None = None,
207
+ ) -> QueryResult:
208
+ """``Data.query`` on this app's cell."""
209
+ return _data().query(
210
+ name,
211
+ sql,
212
+ params,
213
+ max_rows=max_rows,
214
+ max_bytes=max_bytes,
215
+ timeout_ms=timeout_ms,
216
+ identity=identity,
217
+ )
218
+
219
+
220
+ def describe(name: str) -> Schema:
221
+ """``Data.describe`` on this app's cell."""
222
+ return _data().describe(name)
223
+
224
+
225
+ def _data() -> Data:
226
+ global _default # noqa: PLW0603
227
+ with _default_lock:
228
+ if _default is None:
229
+ _default = Data()
230
+ return _default
ssc_app/files.py ADDED
@@ -0,0 +1,194 @@
1
+ """Keep files through the cell's file broker (SSC-046). Ask for it with ``[files]`` in
2
+ ``ssc.toml``.
3
+
4
+ from ssc_app import files
5
+
6
+ files.put("photos/cat.png", data, content_type="image/png")
7
+ data = files.get("photos/cat.png")
8
+ url = files.link("get", "photos/cat.png")["url"] # send a browser there to download
9
+ files.delete("photos/cat.png")
10
+
11
+ Each call asks the data gateway for a signed link with the app's own workload token
12
+ (``ssc_app.workload``), then sends the bytes to Cloud Storage with it; the app holds no storage
13
+ credentials. A file is at most 25 MB, a name is ``/``-separated segments of letters, digits,
14
+ ``.``, ``_`` and ``-``, and a download always arrives as an attachment, never rendered as a page
15
+ (``docs/contracts/data-gateway.md#files``).
16
+
17
+ The data gateway's address comes from the metadata server: Cloud Run's ``instance/region``
18
+ names the project number and the region, and the gateway is ``ssc-datagw`` there.
19
+ ``SSC_DATAGW_URL`` or ``url=`` replaces it. The data gateway runs from zero, so a call to it is
20
+ tried once more when it cannot be reached, times out, or answers 502, 503 or 504. Same names and
21
+ behaviour as the Node helper ``@delimitus/ssc-files``.
22
+ """
23
+
24
+ import json
25
+ import os
26
+ import threading
27
+ import urllib.error
28
+ import urllib.request
29
+ from collections.abc import Mapping
30
+ from typing import Any, Final, Literal
31
+
32
+ from ssc_app.workload import METADATA, TIMEOUT_SECONDS, WorkloadToken
33
+
34
+ SERVICE: Final = "ssc-datagw"
35
+ REGION_PATH: Final = "/computeMetadata/v1/instance/region"
36
+ URL_VARIABLE: Final = "SSC_DATAGW_URL"
37
+ GATEWAY_TIMEOUT_SECONDS: Final = 30.0
38
+ """Long enough for the data gateway to start from zero."""
39
+ TRANSFER_TIMEOUT_SECONDS: Final = 120.0
40
+ RETRY_STATUSES: Final = frozenset({502, 503, 504})
41
+ DEFAULT_CONTENT_TYPE: Final = "application/octet-stream"
42
+
43
+ Op = Literal["put", "get", "delete"]
44
+
45
+
46
+ class FilesError(RuntimeError):
47
+ """A refusal or a failure. ``code`` is the data gateway's (``FILE_NOT_FOUND``,
48
+ ``FILES_QUOTA_EXCEEDED``, ``APP_NOT_ACTIVE``, ...), ``STORAGE_<status>`` when Cloud Storage
49
+ refused the transfer, or ``UNREACHABLE``."""
50
+
51
+ def __init__(self, code: str, message: str, status: int | None = None) -> None:
52
+ super().__init__(f"{code}: {message}")
53
+ self.code = code
54
+ self.status = status
55
+
56
+
57
+ def gateway_url(metadata: str = METADATA) -> str:
58
+ """``https://ssc-datagw-<project number>.<region>.run.app`` of the cell this app runs in."""
59
+ request = urllib.request.Request( # noqa: S310
60
+ f"{metadata}{REGION_PATH}", headers={"Metadata-Flavor": "Google"}
61
+ )
62
+ try:
63
+ with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: # noqa: S310
64
+ answer = response.read().decode().strip()
65
+ except (urllib.error.URLError, OSError) as exc:
66
+ raise FilesError("UNREACHABLE", f"metadata server: {type(exc).__name__}") from None
67
+ parts = answer.split("/")
68
+ if len(parts) != 4 or parts[0] != "projects" or parts[2] != "regions": # noqa: PLR2004
69
+ raise FilesError("UNREACHABLE", "the metadata server did not name a region")
70
+ return f"https://{SERVICE}-{parts[1]}.{parts[3]}.run.app"
71
+
72
+
73
+ def _send(
74
+ method: str, url: str, headers: Mapping[str, str], body: bytes | None, timeout: float
75
+ ) -> tuple[int, bytes]:
76
+ request = urllib.request.Request(url, data=body, method=method, headers=dict(headers)) # noqa: S310
77
+ try:
78
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
79
+ return response.status, response.read()
80
+ except urllib.error.HTTPError as exc:
81
+ return exc.code, exc.read()
82
+
83
+
84
+ def _refusal(status: int, raw: bytes) -> FilesError:
85
+ try:
86
+ error = json.loads(raw)["error"]
87
+ return FilesError(str(error["code"]), str(error["message"]), status)
88
+ except (ValueError, KeyError, TypeError):
89
+ return FilesError("UNAVAILABLE", f"the data gateway answered HTTP {status}", status)
90
+
91
+
92
+ class Files:
93
+ """The file broker of the cell this app runs in. ``url`` and ``metadata`` replace the data
94
+ gateway's address and the metadata server's (tests). Safe to share between threads."""
95
+
96
+ def __init__(self, *, url: str | None = None, metadata: str = METADATA) -> None:
97
+ self._url = (url or os.environ.get(URL_VARIABLE) or "").rstrip("/") or None
98
+ self._metadata = metadata
99
+ self._tokens: WorkloadToken | None = None
100
+ self._lock = threading.Lock()
101
+
102
+ def put(self, name: str, data: bytes, *, content_type: str = DEFAULT_CONTENT_TYPE) -> None:
103
+ """Store ``data`` as ``name``, replacing a file of that name."""
104
+ link = self.link("put", name, content_type=content_type)
105
+ self._transfer(link, data)
106
+
107
+ def get(self, name: str) -> bytes:
108
+ """The bytes of ``name``; ``FilesError`` ``FILE_NOT_FOUND`` when there is none."""
109
+ return self._transfer(self.link("get", name), None)
110
+
111
+ def delete(self, name: str) -> None:
112
+ """Remove ``name``; ``FilesError`` ``FILE_NOT_FOUND`` when there is none."""
113
+ self._ask("delete", {"name": name})
114
+
115
+ def link(self, op: Literal["put", "get"], name: str, **body: str) -> dict[str, Any]:
116
+ """The signed link itself: ``url``, ``method``, ``headers`` to send, ``expires_at`` (10
117
+ minutes), and ``max_bytes`` for ``put``. A ``get`` link suits a browser redirect."""
118
+ return self._ask(op, {"name": name, **body})
119
+
120
+ def _ask(self, op: Op, body: Mapping[str, str]) -> dict[str, Any]:
121
+ url, tokens = self._target()
122
+ raw_body = json.dumps(body).encode()
123
+ for attempt in (1, 2):
124
+ headers = {
125
+ "authorization": f"Bearer {tokens.get()}",
126
+ "content-type": "application/json",
127
+ }
128
+ try:
129
+ status, raw = _send(
130
+ "POST", f"{url}/v1/files/{op}", headers, raw_body, GATEWAY_TIMEOUT_SECONDS
131
+ )
132
+ except (urllib.error.URLError, OSError) as exc:
133
+ if attempt == 1:
134
+ continue
135
+ raise FilesError("UNREACHABLE", f"data gateway: {type(exc).__name__}") from None
136
+ if status in RETRY_STATUSES and attempt == 1:
137
+ continue
138
+ if status != 200: # noqa: PLR2004
139
+ raise _refusal(status, raw)
140
+ return json.loads(raw)
141
+ raise AssertionError
142
+
143
+ def _transfer(self, link: Mapping[str, Any], data: bytes | None) -> bytes:
144
+ try:
145
+ status, raw = _send(
146
+ link["method"], link["url"], link["headers"], data, TRANSFER_TIMEOUT_SECONDS
147
+ )
148
+ except (urllib.error.URLError, OSError) as exc:
149
+ raise FilesError("UNREACHABLE", f"Cloud Storage: {type(exc).__name__}") from None
150
+ if status == 404 and link["method"] == "GET": # noqa: PLR2004
151
+ raise FilesError("FILE_NOT_FOUND", "the file is gone", status)
152
+ if not 200 <= status < 300: # noqa: PLR2004
153
+ raise FilesError(f"STORAGE_{status}", "Cloud Storage refused the transfer", status)
154
+ return raw
155
+
156
+ def _target(self) -> tuple[str, WorkloadToken]:
157
+ with self._lock:
158
+ if self._url is None:
159
+ self._url = gateway_url(self._metadata)
160
+ if self._tokens is None:
161
+ self._tokens = WorkloadToken(audience=self._url, metadata=self._metadata)
162
+ return self._url, self._tokens
163
+
164
+
165
+ _default: Files | None = None
166
+ _default_lock = threading.Lock()
167
+
168
+
169
+ def _files() -> Files:
170
+ global _default # noqa: PLW0603
171
+ with _default_lock:
172
+ if _default is None:
173
+ _default = Files()
174
+ return _default
175
+
176
+
177
+ def put(name: str, data: bytes, *, content_type: str = DEFAULT_CONTENT_TYPE) -> None:
178
+ """``Files.put`` on this app's cell."""
179
+ _files().put(name, data, content_type=content_type)
180
+
181
+
182
+ def get(name: str) -> bytes:
183
+ """``Files.get`` on this app's cell."""
184
+ return _files().get(name)
185
+
186
+
187
+ def delete(name: str) -> None:
188
+ """``Files.delete`` on this app's cell."""
189
+ _files().delete(name)
190
+
191
+
192
+ def link(op: Literal["put", "get"], name: str, **body: str) -> dict[str, Any]:
193
+ """``Files.link`` on this app's cell."""
194
+ return _files().link(op, name, **body)
ssc_app/identity.py ADDED
@@ -0,0 +1,250 @@
1
+ """Read the identity note the SSC gateway attaches to every request as ``X-SSC-Identity``.
2
+
3
+ from ssc_app.identity import IdentityVerifier
4
+
5
+ verifier = IdentityVerifier(audience="https://quiet-river-7f3k.delimitusapps.com",
6
+ keys="https://keys.delimitus.com/cell-01/jwks.json")
7
+ note = verifier.from_headers(request.headers)
8
+ who = note.sub # key on this
9
+ label = note.name # display only
10
+
11
+ Two rules:
12
+
13
+ * **Key on ``sub``, never on ``email``.** ``sub`` is a stable ``usr_`` or ``sch_`` id. ``email`` and
14
+ ``name`` are display strings the company directory can change, and a schedule note has neither.
15
+ * **Verify once, when the request arrives.** Do not re-verify inside a long WebSocket or event
16
+ stream; the note expires after five minutes and ending open streams is the platform kill
17
+ switch's job. Re-verifying mid-stream only breaks the stream.
18
+
19
+ Every refusal is an :class:`IdentityRefused` carrying one code from :data:`REFUSAL_CODES`. Treat
20
+ all of them the same way: the request is not from a signed-in user of this app.
21
+ """
22
+
23
+ import base64
24
+ import json
25
+ import time
26
+ from collections.abc import Mapping
27
+ from typing import Any, Final, Literal, cast
28
+ from urllib.parse import unquote_to_bytes
29
+
30
+ import jwt
31
+ from pydantic import ValidationError
32
+
33
+ from ssc_app.note import (
34
+ IDENTITY_ALG,
35
+ IDENTITY_HEADER,
36
+ IDENTITY_TYP,
37
+ MAX_TTL_SECONDS,
38
+ IdentityNote,
39
+ )
40
+
41
+ RefusalCode = Literal[
42
+ "missing",
43
+ "malformed",
44
+ "wrong_type",
45
+ "wrong_algorithm",
46
+ "unknown_key",
47
+ "bad_signature",
48
+ "wrong_audience",
49
+ "wrong_issuer",
50
+ "expired",
51
+ "not_yet_valid",
52
+ "ttl_too_long",
53
+ "bad_claims",
54
+ ]
55
+ REFUSAL_CODES: Final[frozenset[str]] = frozenset(
56
+ (
57
+ "missing",
58
+ "malformed",
59
+ "wrong_type",
60
+ "wrong_algorithm",
61
+ "unknown_key",
62
+ "bad_signature",
63
+ "wrong_audience",
64
+ "wrong_issuer",
65
+ "expired",
66
+ "not_yet_valid",
67
+ "ttl_too_long",
68
+ "bad_claims",
69
+ )
70
+ )
71
+ DEFAULT_LEEWAY_SECONDS: Final = 30
72
+
73
+ KeySource = Mapping[str, Any] | jwt.PyJWKSet | jwt.PyJWKClient
74
+
75
+
76
+ class IdentityRefused(Exception): # noqa: N818 (public name; refusal, not a bug)
77
+ """The note was absent or did not verify. ``code`` says why; the message is for logs."""
78
+
79
+ code: RefusalCode
80
+
81
+ def __init__(self, code: RefusalCode, message: str) -> None:
82
+ super().__init__(f"{code}: {message}")
83
+ self.code = code
84
+
85
+
86
+ def _signing_key(keys: KeySource, kid: str) -> jwt.PyJWK:
87
+ try:
88
+ if isinstance(keys, jwt.PyJWKClient):
89
+ return keys.get_signing_key(kid)
90
+ key_set = keys if isinstance(keys, jwt.PyJWKSet) else jwt.PyJWKSet.from_dict(dict(keys))
91
+ return key_set[kid]
92
+ except (KeyError, jwt.PyJWKClientError, jwt.PyJWKSetError, jwt.PyJWKError) as e:
93
+ raise IdentityRefused("unknown_key", f"no key {kid!r} in the JWKS") from e
94
+
95
+
96
+ def _int_claim(claims: Mapping[str, Any], name: str) -> int:
97
+ value = claims.get(name)
98
+ if isinstance(value, bool) or not isinstance(value, int):
99
+ raise IdentityRefused("bad_claims", f"{name} is not an integer")
100
+ return value
101
+
102
+
103
+ def verify( # noqa: PLR0913 (keyword-only)
104
+ token: str | None,
105
+ *,
106
+ audience: str,
107
+ keys: KeySource,
108
+ issuer: str | None = None,
109
+ now: int | None = None,
110
+ leeway: int = DEFAULT_LEEWAY_SECONDS,
111
+ ) -> IdentityNote:
112
+ """Verify one note and return its claims, or raise :class:`IdentityRefused`.
113
+
114
+ ``audience`` is this app's exact origin, ``https://host`` with no path. A note minted for
115
+ another app is refused with ``wrong_audience`` even though the same cell signed it.
116
+ ``keys`` is the cell JWKS as a dict, a :class:`jwt.PyJWKSet`, or a :class:`jwt.PyJWKClient`
117
+ pointed at ``<issuer>/jwks.json``. ``now`` (Unix seconds) is for tests; default is the clock.
118
+ """
119
+ if token is None or token == "":
120
+ raise IdentityRefused("missing", f"no {IDENTITY_HEADER} header")
121
+ if not isinstance(token, str): # pyright: ignore[reportUnnecessaryIsInstance]
122
+ raise IdentityRefused("malformed", "token is not a string")
123
+ try:
124
+ header = jwt.get_unverified_header(token)
125
+ except jwt.DecodeError as e:
126
+ raise IdentityRefused("malformed", str(e)) from e
127
+ if header.get("typ") != IDENTITY_TYP:
128
+ raise IdentityRefused("wrong_type", f"typ is {header.get('typ')!r}, not {IDENTITY_TYP!r}")
129
+ if header.get("alg") != IDENTITY_ALG:
130
+ raise IdentityRefused("wrong_algorithm", f"alg is {header.get('alg')!r}")
131
+ kid = header.get("kid")
132
+ if not isinstance(kid, str) or not kid:
133
+ raise IdentityRefused("unknown_key", "no kid in the header")
134
+ key = _signing_key(keys, kid)
135
+ try:
136
+ claims = jwt.decode(
137
+ token,
138
+ key,
139
+ algorithms=[IDENTITY_ALG],
140
+ options={
141
+ "verify_signature": True,
142
+ "verify_exp": False,
143
+ "verify_iat": False,
144
+ "verify_nbf": False,
145
+ "verify_aud": False,
146
+ "verify_iss": False,
147
+ "verify_sub": False,
148
+ },
149
+ )
150
+ except jwt.InvalidSignatureError as e:
151
+ raise IdentityRefused("bad_signature", "signature does not verify") from e
152
+ except jwt.InvalidAlgorithmError as e:
153
+ raise IdentityRefused("wrong_algorithm", str(e)) from e
154
+ except jwt.PyJWTError as e:
155
+ raise IdentityRefused("malformed", str(e)) from e
156
+ return check_claims(claims, audience=audience, issuer=issuer, now=now, leeway=leeway)
157
+
158
+
159
+ def check_claims(
160
+ claims: Mapping[str, Any],
161
+ *,
162
+ audience: str,
163
+ issuer: str | None,
164
+ now: int | None,
165
+ leeway: int,
166
+ ) -> IdentityNote:
167
+ """The claim checks, after the signature. Split out so the order is readable and testable."""
168
+ if claims.get("aud") != audience:
169
+ raise IdentityRefused("wrong_audience", f"note is for {claims.get('aud')!r}")
170
+ if issuer is not None and claims.get("iss") != issuer:
171
+ raise IdentityRefused("wrong_issuer", f"note is from {claims.get('iss')!r}")
172
+ iat = _int_claim(claims, "iat")
173
+ exp = _int_claim(claims, "exp")
174
+ if exp - iat > MAX_TTL_SECONDS:
175
+ raise IdentityRefused("ttl_too_long", f"exp - iat is {exp - iat}s")
176
+ at = _now() if now is None else now
177
+ if at >= exp + leeway:
178
+ raise IdentityRefused("expired", f"expired {at - exp}s ago")
179
+ if iat > at + leeway:
180
+ raise IdentityRefused("not_yet_valid", f"issued {iat - at}s in the future")
181
+ try:
182
+ return IdentityNote.model_validate(dict(claims))
183
+ except ValidationError as e:
184
+ raise IdentityRefused("bad_claims", "; ".join(err["msg"] for err in e.errors())) from e
185
+
186
+
187
+ def _now() -> int:
188
+ return int(time.time())
189
+
190
+
191
+ def token_from_headers(headers: Mapping[str, str]) -> str | None:
192
+ """Pull the note from a request's headers, whatever the case of the header name."""
193
+ wanted = IDENTITY_HEADER.lower()
194
+ for name, value in headers.items():
195
+ if name.lower() == wanted:
196
+ return value
197
+ return None
198
+
199
+
200
+ def _inline_jwks(url: str) -> dict[str, Any]:
201
+ header, _, payload = url.removeprefix("data:").partition(",")
202
+ raw = base64.b64decode(payload) if header.endswith(";base64") else unquote_to_bytes(payload)
203
+ return cast(dict[str, Any], json.loads(raw))
204
+
205
+
206
+ class IdentityVerifier:
207
+ """One verifier per app process. Holds the audience and the key source.
208
+
209
+ ``keys`` is the JWKS URL (``<issuer>/jwks.json``; fetched and cached, refreshed when an
210
+ unknown ``kid`` shows up during a key rotation), the JWKS inline as a ``data:`` URL (how the
211
+ platform hands it to an app, which has no internet), or an already loaded JWKS dict.
212
+ """
213
+
214
+ def __init__(
215
+ self,
216
+ *,
217
+ audience: str,
218
+ keys: str | Mapping[str, Any],
219
+ issuer: str | None = None,
220
+ leeway: int = DEFAULT_LEEWAY_SECONDS,
221
+ ) -> None:
222
+ self.audience = audience
223
+ self.issuer = issuer
224
+ self.leeway = leeway
225
+ if isinstance(keys, str) and keys.startswith("data:"):
226
+ keys = _inline_jwks(keys)
227
+ self._keys: KeySource = (
228
+ jwt.PyJWKClient(keys, cache_keys=True, lifespan=300)
229
+ if isinstance(keys, str)
230
+ else jwt.PyJWKSet.from_dict(dict(keys))
231
+ )
232
+
233
+ def verify(self, token: str | None, *, now: int | None = None) -> IdentityNote:
234
+ return verify(
235
+ token,
236
+ audience=self.audience,
237
+ keys=self._keys,
238
+ issuer=self.issuer,
239
+ now=now,
240
+ leeway=self.leeway,
241
+ )
242
+
243
+ def from_headers(self, headers: Mapping[str, str], *, now: int | None = None) -> IdentityNote:
244
+ """Verify the note in ``headers``; ``missing`` when the gateway did not attach one."""
245
+ return self.verify(token_from_headers(headers), now=now)
246
+
247
+
248
+ def refusal_code(error: BaseException) -> str | None:
249
+ """The code of an :class:`IdentityRefused`, or None for anything else."""
250
+ return cast(str, error.code) if isinstance(error, IdentityRefused) else None
ssc_app/note.py ADDED
@@ -0,0 +1,73 @@
1
+ """Identity note v1: the claims the gateway signs into ``X-SSC-Identity`` on every request.
2
+
3
+ This is a public, versioned contract (``docs/contracts/identity-note.md``). It changes by adding
4
+ a new version, never by editing v1. Two rules every consumer must keep:
5
+
6
+ * Key on ``sub``. It is a ``usr_`` or ``sch_`` id that never changes. ``email`` and ``name`` are
7
+ display strings that a directory can change at any time, and a note for a schedule carries
8
+ neither.
9
+ * Verify once per request, when it arrives. Do not re-verify in the middle of a long-lived
10
+ WebSocket or event stream: the note expires five minutes after issue, and ending open streams
11
+ is the kill switch's job, not the app's.
12
+
13
+ Pure data. Signing lives in ``ssc_edge.identity_note`` and verification in ``ssc_app.identity``.
14
+ """
15
+
16
+ from typing import Final, Literal, Self
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
19
+
20
+ IDENTITY_HEADER: Final = "X-SSC-Identity"
21
+ IDENTITY_TYP: Final = "ssc-id+jwt"
22
+ IDENTITY_ALG: Final = "ES256"
23
+ MAX_TTL_SECONDS: Final = 300
24
+ MAX_GROUPS: Final = 50
25
+
26
+ Role = Literal["builder", "user", "schedule"]
27
+ EnvironmentName = Literal["prod", "preview"]
28
+
29
+ _ORIGIN = r"^https://[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+(:[0-9]{1,5})?$"
30
+ _EMAIL = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
31
+
32
+
33
+ class IdentityNote(BaseModel):
34
+ """The claim set of one note. Frozen; unknown claims are refused."""
35
+
36
+ model_config = ConfigDict(frozen=True, extra="forbid")
37
+
38
+ iss: str = Field(pattern=r"^https://[a-z0-9.-]+/[a-z0-9-]+$")
39
+ aud: str = Field(pattern=_ORIGIN)
40
+ sub: str = Field(pattern=r"^(usr|sch)_[a-z0-9]{20}$")
41
+ iat: int = Field(ge=0)
42
+ exp: int = Field(ge=0)
43
+ org: str = Field(pattern=r"^org_[a-z0-9]{20}$")
44
+ app: str = Field(pattern=r"^app_[a-z0-9]{20}$")
45
+ env: EnvironmentName
46
+ role: Role
47
+ groups: tuple[str, ...] = Field(default=(), max_length=MAX_GROUPS)
48
+ name: str | None = Field(default=None, min_length=1, max_length=256)
49
+ email: str | None = Field(default=None, pattern=_EMAIL, max_length=320)
50
+
51
+ @property
52
+ def is_schedule(self) -> bool:
53
+ return self.sub.startswith("sch_")
54
+
55
+ @model_validator(mode="after")
56
+ def _consistent(self) -> Self:
57
+ if self.exp <= self.iat:
58
+ raise ValueError("exp must be after iat")
59
+ if self.exp - self.iat > MAX_TTL_SECONDS:
60
+ raise ValueError(f"a note lives at most {MAX_TTL_SECONDS} seconds")
61
+ if self.is_schedule:
62
+ if self.role != "schedule":
63
+ raise ValueError("a schedule subject carries role 'schedule'")
64
+ if self.name is not None or self.email is not None:
65
+ raise ValueError("a schedule note carries no name or email")
66
+ elif self.role == "schedule":
67
+ raise ValueError("only a schedule subject carries role 'schedule'")
68
+ for group in self.groups:
69
+ if not group.startswith("grp_") or len(group) != 24:
70
+ raise ValueError(f"not a group id: {group!r}")
71
+ if len(set(self.groups)) != len(self.groups):
72
+ raise ValueError("groups repeat")
73
+ return self
ssc_app/py.typed ADDED
File without changes
ssc_app/reconnect.js ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * A WebSocket that comes back by itself (SSC-090). No dependencies; serve or inline it in a page
3
+ * as a plain script, then:
4
+ *
5
+ * let last = -1;
6
+ * const socket = sscSocket(() => `wss://${location.host}/ws?after=${last}`, {
7
+ * onopen() { ... }, // runs again after every reconnect
8
+ * onmessage(event) { last = ...; },
9
+ * });
10
+ * socket.send('hello'); // false while it is reconnecting
11
+ *
12
+ * The app closes its end with code 1012 a little before the limit the gateway reports
13
+ * (`close_before_deadline` in Python, `closeBeforeDeadline` in Node); this reconnects at once on
14
+ * that close, and after a growing pause on an unclean drop. WebKit reports every close the server
15
+ * starts as 1005 (no status), whatever its code, so 1005 counts as 1012. It gives up, calling
16
+ * `onclose`, after `attempts` reconnects in a row that did not stay open, or on a clean close with
17
+ * any other code. `url` may be a function, so a reconnect can say where to resume.
18
+ */
19
+ (function (root) {
20
+ const RESTART_CODE = 1012;
21
+ const NO_STATUS = 1005;
22
+
23
+ /**
24
+ * @param {string | (() => string)} url
25
+ * @param {{protocols?: string | string[], onopen?: (event: Event) => void,
26
+ * onmessage?: (event: MessageEvent) => void, onclose?: (event: CloseEvent) => void,
27
+ * delayMs?: number, maxDelayMs?: number, attempts?: number}} [options]
28
+ */
29
+ function sscSocket(url, options = {}) {
30
+ const { protocols, onopen, onmessage, onclose, delayMs = 250, maxDelayMs = 10000, attempts = 10 } = options;
31
+ let ws = null;
32
+ let timer = null;
33
+ let failures = 0;
34
+ let openedAt = 0;
35
+ let stopped = false;
36
+
37
+ function stop(event) {
38
+ stopped = true;
39
+ if (onclose) onclose(event);
40
+ }
41
+
42
+ function open() {
43
+ timer = null;
44
+ openedAt = 0;
45
+ ws = new WebSocket(typeof url === 'function' ? url() : url, protocols);
46
+ ws.onopen = (event) => {
47
+ openedAt = Date.now();
48
+ if (onopen) onopen(event);
49
+ };
50
+ ws.onmessage = (event) => {
51
+ if (onmessage) onmessage(event);
52
+ };
53
+ ws.onclose = (event) => {
54
+ if (stopped) return;
55
+ const restart = event.code === RESTART_CODE || event.code === NO_STATUS;
56
+ if (event.wasClean && !restart) return stop(event);
57
+ if (openedAt !== 0 && Date.now() - openedAt >= maxDelayMs) failures = 0;
58
+ const wait = restart && failures === 0 ? 0 : Math.min(maxDelayMs, delayMs * 2 ** failures);
59
+ failures += 1;
60
+ if (failures > attempts) return stop(event);
61
+ timer = setTimeout(open, wait);
62
+ };
63
+ }
64
+
65
+ open();
66
+ return {
67
+ get socket() {
68
+ return ws;
69
+ },
70
+ send(data) {
71
+ if (!ws || ws.readyState !== 1) return false;
72
+ ws.send(data);
73
+ return true;
74
+ },
75
+ close(code = 1000, reason = '') {
76
+ stopped = true;
77
+ clearTimeout(timer);
78
+ if (ws) ws.close(code, reason);
79
+ },
80
+ };
81
+ }
82
+
83
+ root.sscSocket = sscSocket;
84
+ })(globalThis);
ssc_app/reconnect.py ADDED
@@ -0,0 +1,112 @@
1
+ """Keep a WebSocket or an event stream going past the gateway's limit (SSC-090).
2
+
3
+ Cloud Run ends every request at a fixed time, and an open WebSocket or event stream is one
4
+ request: after 5 minutes for most apps, 60 for a session app. The gateway tells the app that time
5
+ in ``X-SSC-Request-Deadline`` (Unix seconds). The
6
+ server cannot reconnect a browser, so these helpers end the stream cleanly a little before that
7
+ time and the browser opens a new one, which gets a new deadline:
8
+
9
+ * An event stream: wrap the events in :func:`end_before_deadline`. It ends the stream with a
10
+ ``retry:`` hint and the browser's ``EventSource`` reconnects by itself, sending
11
+ ``Last-Event-ID`` when the events carry an ``id:``.
12
+ * A WebSocket: run :func:`close_before_deadline` beside the socket. It closes the socket with
13
+ :data:`RESTART_CODE`; the browser client (:func:`browser_client`) reconnects on that close and
14
+ on an unclean drop.
15
+
16
+ return StreamingResponse(end_before_deadline(events(), request.headers),
17
+ media_type="text/event-stream")
18
+
19
+ await socket.accept()
20
+ restart = asyncio.create_task(close_before_deadline(socket.close, socket.headers))
21
+ try:
22
+ ... # the socket's own loop
23
+ finally:
24
+ restart.cancel()
25
+
26
+ Without the header, as on a laptop, nothing is cut. A stream that opens with less than the margin
27
+ left is ended after half the time left (:func:`seconds_to_wait`), never at once. Whatever must
28
+ outlive one connection belongs in Postgres or in the browser, never in the connection. Same names,
29
+ defaults and vectors as the Node helper ``@delimitus/ssc-reconnect``.
30
+ """
31
+
32
+ import asyncio
33
+ import time
34
+ from collections.abc import AsyncIterable, AsyncIterator, Awaitable, Callable, Mapping
35
+ from importlib.resources import files
36
+ from typing import Final
37
+
38
+ DEADLINE_HEADER: Final = "X-SSC-Request-Deadline"
39
+ RESTART_CODE: Final = 1012
40
+ DEFAULT_MARGIN_SECONDS: Final = 30.0
41
+ DEFAULT_RETRY_MS: Final = 1000
42
+
43
+
44
+ def seconds_left(headers: Mapping[str, str], *, now: float | None = None) -> float | None:
45
+ """Seconds until the gateway's limit ends this request, never below 0; None when the request
46
+ carries no readable ``X-SSC-Request-Deadline``. ``now`` (Unix seconds) is for tests."""
47
+ wanted = DEADLINE_HEADER.lower()
48
+ for name, value in headers.items():
49
+ if name.lower() == wanted:
50
+ raw = value.strip()
51
+ if not (raw.isascii() and raw.isdigit()):
52
+ return None
53
+ return max(0.0, int(raw) - (time.time() if now is None else now))
54
+ return None
55
+
56
+
57
+ def seconds_to_wait(left: float | None, margin: float = DEFAULT_MARGIN_SECONDS) -> float | None:
58
+ """How long to keep a stream with ``left`` seconds to its deadline: until ``margin`` seconds
59
+ before it, or half of ``left`` when that is later, so a stream that opens inside the margin is
60
+ not ended at once and reopened in a loop. None, cut nothing, without a deadline or once it has
61
+ passed (this clock is ahead of the gateway's); the platform's limit ends that stream."""
62
+ if left is None or left <= 0:
63
+ return None
64
+ return max(left - margin, left / 2)
65
+
66
+
67
+ async def end_before_deadline(
68
+ events: AsyncIterable[str | bytes],
69
+ headers: Mapping[str, str],
70
+ *,
71
+ margin: float = DEFAULT_MARGIN_SECONDS,
72
+ retry_ms: int = DEFAULT_RETRY_MS,
73
+ ) -> AsyncIterator[bytes]:
74
+ """``events``, each a whole event ending in a blank line, ended :func:`seconds_to_wait` from
75
+ now with ``retry: <retry_ms>`` so ``EventSource`` reconnects that many milliseconds later.
76
+ Without a deadline, ``events`` unchanged."""
77
+ wait = seconds_to_wait(seconds_left(headers), margin)
78
+ loop = asyncio.get_running_loop()
79
+ stop = None if wait is None else loop.time() + wait
80
+ source = aiter(events)
81
+ while True:
82
+ try:
83
+ wait = None if stop is None else max(0.0, stop - loop.time())
84
+ event = await asyncio.wait_for(anext(source), wait)
85
+ except StopAsyncIteration:
86
+ return
87
+ except TimeoutError:
88
+ yield f"retry: {retry_ms}\n\n".encode()
89
+ return
90
+ yield event.encode() if isinstance(event, str) else event
91
+
92
+
93
+ async def close_before_deadline(
94
+ close: Callable[[int, str], Awaitable[object]],
95
+ headers: Mapping[str, str],
96
+ *,
97
+ margin: float = DEFAULT_MARGIN_SECONDS,
98
+ ) -> None:
99
+ """Wait :func:`seconds_to_wait`, then ``close(RESTART_CODE, "restart")``. Run it as a task
100
+ beside the socket and cancel it when the socket ends first. Without a deadline it returns at
101
+ once and closes nothing."""
102
+ wait = seconds_to_wait(seconds_left(headers), margin)
103
+ if wait is None:
104
+ return
105
+ await asyncio.sleep(wait)
106
+ await close(RESTART_CODE, "restart")
107
+
108
+
109
+ def browser_client() -> str:
110
+ """The browser side of :func:`close_before_deadline`, dependency-free JavaScript to serve or
111
+ inline in a page. It defines ``sscSocket(url, options)``, a WebSocket that reconnects."""
112
+ return files("ssc_app").joinpath("reconnect.js").read_text(encoding="utf-8")
ssc_app/workload.py ADDED
@@ -0,0 +1,82 @@
1
+ """The app's own Google ID token, for calling the cell's data gateway (SSC-051).
2
+
3
+ from ssc_app.workload import WorkloadToken
4
+
5
+ tokens = WorkloadToken(audience=data_gateway_url)
6
+ headers = {"Authorization": f"Bearer {tokens.get()}"}
7
+
8
+ The token comes from Cloud Run's metadata server and must be asked for with ``format=full``:
9
+ without it Google leaves out ``email`` and ``email_verified``, and the data gateway, which knows
10
+ the app environment only by its service account's email, refuses every query. A token is reused
11
+ until five minutes before it expires. ``ssc_app.data.query`` and ``ssc_app.files`` find the
12
+ gateway and send it; this is the part that must ask for the token correctly.
13
+ """
14
+
15
+ import base64
16
+ import json
17
+ import threading
18
+ import time
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+ from collections.abc import Callable
23
+ from typing import Final
24
+
25
+ METADATA: Final = "http://metadata.google.internal"
26
+ IDENTITY_PATH: Final = "/computeMetadata/v1/instance/service-accounts/default/identity"
27
+ REFRESH_BEFORE: Final = 300
28
+ TIMEOUT_SECONDS: Final = 5.0
29
+
30
+
31
+ class WorkloadTokenError(RuntimeError):
32
+ """The metadata server did not give a usable token."""
33
+
34
+
35
+ def identity_url(audience: str, metadata: str = METADATA) -> str:
36
+ """The metadata server's ID-token URL for ``audience``, always with ``format=full``."""
37
+ query = urllib.parse.urlencode({"audience": audience, "format": "full"})
38
+ return f"{metadata}{IDENTITY_PATH}?{query}"
39
+
40
+
41
+ def _expiry(token: str) -> float:
42
+ try:
43
+ payload = token.split(".")[1]
44
+ claims = json.loads(base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4)))
45
+ return float(claims["exp"])
46
+ except (IndexError, ValueError, KeyError, TypeError) as exc:
47
+ raise WorkloadTokenError("the metadata server returned an unreadable ID token") from exc
48
+
49
+
50
+ class WorkloadToken:
51
+ """ID tokens for one ``audience``, cached and safe to share between threads. ``metadata``
52
+ replaces the metadata server's address (tests)."""
53
+
54
+ def __init__(
55
+ self,
56
+ *,
57
+ audience: str,
58
+ metadata: str = METADATA,
59
+ clock: Callable[[], float] = time.time,
60
+ ) -> None:
61
+ self._url = identity_url(audience, metadata)
62
+ self._clock = clock
63
+ self._lock = threading.Lock()
64
+ self._token: tuple[str, float] | None = None
65
+
66
+ def get(self) -> str:
67
+ """A token valid for at least five more minutes."""
68
+ with self._lock:
69
+ if self._token is None or self._token[1] - REFRESH_BEFORE <= self._clock():
70
+ token = self._fetch()
71
+ self._token = (token, _expiry(token))
72
+ return self._token[0]
73
+
74
+ def _fetch(self) -> str:
75
+ request = urllib.request.Request(self._url, headers={"Metadata-Flavor": "Google"}) # noqa: S310
76
+ try:
77
+ with urllib.request.urlopen(request, timeout=TIMEOUT_SECONDS) as response: # noqa: S310
78
+ return response.read().decode().strip()
79
+ except urllib.error.HTTPError as exc:
80
+ raise WorkloadTokenError(f"metadata server answered HTTP {exc.code}") from None
81
+ except (urllib.error.URLError, OSError) as exc:
82
+ raise WorkloadTokenError(f"metadata server unreachable: {type(exc).__name__}") from None
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: ssc-app
3
+ Version: 0.0.3
4
+ Summary: The helper an app on Small Software Cloud installs: who is signed in, streams that reconnect, files and company data.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Dist: pydantic>=2.5,<3
8
+ Requires-Dist: pyjwt[crypto]>=2.15.0,<3
9
+ Requires-Python: >=3.11
10
+ Description-Content-Type: text/markdown
11
+
12
+ # ssc-app
13
+
14
+ The tiny helper an app installs to read the SSC identity note (`X-SSC-Identity`), to reconnect streams before the gateway's limit, to keep files and to read company data.
15
+
16
+ ```
17
+ pip install ssc-app
18
+ ```
19
+
20
+ Python 3.11 or later. Depends on `pydantic` (2.5 or later) and `pyjwt[crypto]` (2.15 or later) only, both as ranges, so the app keeps its own pins.
21
+
22
+ ```python
23
+ import os
24
+
25
+ from ssc_app.identity import IdentityRefused, IdentityVerifier
26
+
27
+ verifier = IdentityVerifier(
28
+ audience="https://quiet-river-7f3k.delimitusapps.com", # this app's exact origin
29
+ keys=os.environ["SSC_IDENTITY_KEYS_URL"], # a data: URL in a cell; or a JWKS URL or dict
30
+ )
31
+ note = verifier.from_headers(request.headers)
32
+ note.sub # "usr_…" or "sch_…": key on this
33
+ note.name # display only; None for a schedule run
34
+ note.email # display only; None for a schedule run
35
+ ```
36
+
37
+ Key on `sub`, never on `email`. Verify once per request; never re-verify inside a long WebSocket or event stream. Every refusal is `IdentityRefused` with a `code`; treat them all as "not a signed-in user of this app". Contract: `docs/contracts/identity-note.md`.
38
+
39
+ ## Reconnect before the limit
40
+
41
+ Each WebSocket or event stream ends at the limit the gateway reports (5 minutes for most apps, 60 for session apps). `ssc_app.reconnect` reads the gateway's `X-SSC-Request-Deadline` header and ends the stream a little before then; the browser reconnects without a reload. No extra dependency.
42
+
43
+ ```python
44
+ from ssc_app.reconnect import browser_client, close_before_deadline, end_before_deadline
45
+
46
+ # Event stream: EventSource reconnects by itself, with Last-Event-ID if your events carry an id.
47
+ return StreamingResponse(
48
+ end_before_deadline(events(), request.headers), media_type="text/event-stream"
49
+ )
50
+
51
+ # WebSocket: closed with code 1012, 30 seconds before the limit.
52
+ await ws.accept()
53
+ restart = asyncio.create_task(close_before_deadline(ws.close, ws.headers))
54
+ try:
55
+ ...
56
+ finally:
57
+ restart.cancel()
58
+
59
+ # The browser side: serve browser_client(), then sscSocket(url, {onopen, onmessage}) instead of new WebSocket(url).
60
+ ```
61
+
62
+ `seconds_left(request.headers)` gives the seconds until the limit, or None without the gateway. Options: `margin` (30 s) on both, `retry_ms` (1000) on `end_before_deadline`. A stream that opens with less than the margin left is ended after half the time left (`seconds_to_wait`), never at once. A reconnect is a new connection: keep what must outlive it in Postgres or in the browser. The Node helper `@delimitus/ssc-reconnect` has the same names and runs the same vectors (`conformance/reconnect/vectors.json`).
63
+
64
+ ## Files
65
+
66
+ Ask for file storage with `[files]` in `ssc.toml`; the first deploy that asks sets it up once, in a few minutes. `ssc_app.files` asks the cell's data gateway for a signed link with the app's own ID token and sends the bytes to Cloud Storage; the app holds no storage credentials. No extra dependency.
67
+
68
+ ```python
69
+ from ssc_app import files
70
+
71
+ files.put("photos/cat.png", data, content_type="image/png")
72
+ data = files.get("photos/cat.png")
73
+ url = files.link("get", "photos/cat.png")["url"] # redirect a browser here to download
74
+ files.delete("photos/cat.png")
75
+ ```
76
+
77
+ Each environment has its own files: at most 25 MB each and 1 GB together. A name is `/`-separated segments of `A-Z a-z 0-9 . _ -`, each starting with a letter or digit. A link lasts 10 minutes, and a download always arrives as an attachment, never shown as a page. Upload from the server: the bucket allows no browser-direct upload. Files are not scanned for viruses. Errors are `FilesError` with the data gateway's `code` (`FILE_NOT_FOUND`, `FILES_QUOTA_EXCEEDED`, `APP_NOT_ACTIVE`, ...), `STORAGE_<status>` or `UNREACHABLE`; the data gateway starts from zero, so a call to it is tried once more on a timeout, a lost connection or a 502, 503 or 504. The gateway's address comes from the metadata server (`SSC_DATAGW_URL` replaces it). The Node helper `@delimitus/ssc-files` has the same names, with `remove` for `delete`. Contract: `docs/contracts/data-gateway.md#files`.
78
+
79
+ ## Company data
80
+
81
+ Name a connection in `[connections] names` in `ssc.toml`; an org admin grants the environment that connection. `ssc_app.data.query` sends one read-only statement to the cell's data gateway with the app's own ID token; the app holds no database credentials. No extra dependency. For a `postgres` connection:
82
+
83
+ ```python
84
+ from ssc_app import data
85
+
86
+ result = data.query("finance", "select id, total from invoices where year = $1", [2026])
87
+ for row in result.rows: # a list in column order
88
+ ...
89
+ ```
90
+
91
+ The statement follows the connection's kind: one `SELECT` for `postgres` (`$1`, `$2`, ... placeholders) and for `mysql`, `sqlserver`, `bigquery` and `snowflake` (`?` placeholders); and, with no parameters, an A1 range for `gsheets`, `list <prefix>` or `get <key>` for `s3` and `gcs`, `<table>[ view <view>][ where <formula>]` for `airtable` and a GET path for `rest`. `max_rows`, `max_bytes` and `timeout_ms` only narrow what the platform, the connection and the grant allow; `result.truncated` and `result.truncated_reason` say the gateway stopped reading early. To act for the signed-in user pass the request's `X-SSC-Identity` value as `identity=`; without it the app acts for itself. A decimal is a string and a timestamp is ISO 8601. Errors are `DataError` with the data gateway's `code` (`CONNECTION_NOT_GRANTED`, `CONNECTION_SUSPENDED`, `QUERY_REFUSED`, `QUERY_FAILED` with a `sqlstate`, `DAILY_BUDGET_SPENT`, ...) or `UNREACHABLE`; a query is tried once more on a lost connection or a 502, 503 or 504, and one that timed out is not. The Node helper `@delimitus/ssc-data` has the same names with `maxRows`, `maxBytes` and `timeoutMs`. Contract: `docs/contracts/data-gateway.md`.
@@ -0,0 +1,13 @@
1
+ ssc_app/__init__.py,sha256=0Z0mQkb-1xxDCFpswASGTCle4Ne-G9Mu6rTEiLB9amg,226
2
+ ssc_app/data.py,sha256=Q5gqDB9Hcy2nOJXOYg5s8rlxcEsZSAfVI9Gg1nmILPU,9264
3
+ ssc_app/files.py,sha256=ZWghYBR9Jc6n23b9O3QBX-QR0NZf-vdRkZNtozOvaM8,8178
4
+ ssc_app/identity.py,sha256=tYUBXU8AG8htHyzWdwqPtjAMppUZgZX-zbJF7Q6aY9M,9063
5
+ ssc_app/note.py,sha256=2Kvovk2DBF7B574vqc_G2auQAIEbM4ndowhhHTcpoEU,3115
6
+ ssc_app/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ ssc_app/reconnect.js,sha256=U3T-wcGicc1cOfog7ZDbT4-ue4odM2ae4Vv1umIzFv8,2994
8
+ ssc_app/reconnect.py,sha256=qN8ice02P6jxX19YVDNRCnZnaOvf48nG6wUgXChubik,5037
9
+ ssc_app/workload.py,sha256=Wz3MMMveV7p4TSTaEnXH0VBgYQw9HYAmKu9GFkxgbVo,3272
10
+ ssc_app-0.0.3.dist-info/licenses/LICENSE,sha256=d4h3gSdkq-2PMISQonnnlFr1dlZlDeAUrRAc8bUsa4k,1066
11
+ ssc_app-0.0.3.dist-info/WHEEL,sha256=e4_1dyBeezi8ZjfxrZ3bnVOxFDa3ksqVqH0jTHkUZ3k,81
12
+ ssc_app-0.0.3.dist-info/METADATA,sha256=-bM8UJ3svQr3i4BH_mJ_Hcd7bsmIV0WvReY42SAlP4s,6096
13
+ ssc_app-0.0.3.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.12.19
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Delimitus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.