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 +5 -0
- ssc_app/data.py +230 -0
- ssc_app/files.py +194 -0
- ssc_app/identity.py +250 -0
- ssc_app/note.py +73 -0
- ssc_app/py.typed +0 -0
- ssc_app/reconnect.js +84 -0
- ssc_app/reconnect.py +112 -0
- ssc_app/workload.py +82 -0
- ssc_app-0.0.3.dist-info/METADATA +91 -0
- ssc_app-0.0.3.dist-info/RECORD +13 -0
- ssc_app-0.0.3.dist-info/WHEEL +4 -0
- ssc_app-0.0.3.dist-info/licenses/LICENSE +21 -0
ssc_app/__init__.py
ADDED
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,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.
|