small-software 0.7.0__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {small_software-0.7.0/small_software.egg-info → small_software-0.9.0}/PKG-INFO +110 -25
- {small_software-0.7.0 → small_software-0.9.0}/README.md +109 -24
- {small_software-0.7.0 → small_software-0.9.0}/pyproject.toml +1 -1
- small_software-0.9.0/small_cli/__init__.py +1 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/access.py +92 -5
- small_software-0.9.0/small_cli/commands/__init__.py +10 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/access.py +153 -20
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/deploy.py +85 -10
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/destroy.py +2 -1
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/domain.py +3 -1
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/env.py +23 -10
- small_software-0.9.0/small_cli/commands/gateway.py +251 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/logs.py +7 -1
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/sleep.py +6 -3
- small_software-0.9.0/small_cli/commands/sso.py +268 -0
- small_software-0.9.0/small_cli/commands/volume.py +105 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/dashboard.py +9 -5
- small_software-0.9.0/small_cli/gateway.py +100 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/mcp.py +41 -13
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/railway.py +44 -0
- small_software-0.9.0/small_cli/templates/fastapi/magic_link.py +1503 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/fastapi/main.py +3 -3
- small_software-0.9.0/small_cli/templates/gateway/Caddyfile +41 -0
- small_software-0.9.0/small_cli/templates/gateway/Dockerfile +7 -0
- small_software-0.9.0/small_cli/templates/gateway/gateway.py +94 -0
- small_software-0.9.0/small_cli/templates/gateway/magic_link.py +1503 -0
- small_software-0.9.0/small_cli/templates/node-http/magic-link.js +1045 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/python-http/app.py +3 -2
- small_software-0.9.0/small_cli/templates/python-http/magic_link.py +1503 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/streamlit/app.py +93 -9
- small_software-0.9.0/small_cli/templates/streamlit/magic_link.py +1503 -0
- {small_software-0.7.0 → small_software-0.9.0/small_software.egg-info}/PKG-INFO +110 -25
- {small_software-0.7.0 → small_software-0.9.0}/small_software.egg-info/SOURCES.txt +10 -0
- small_software-0.9.0/tests/test_gateway.py +514 -0
- {small_software-0.7.0 → small_software-0.9.0}/tests/test_mcp.py +1 -1
- {small_software-0.7.0 → small_software-0.9.0}/tests/test_small.py +23 -7
- small_software-0.9.0/tests/test_sso_volume.py +392 -0
- {small_software-0.7.0 → small_software-0.9.0}/tests/test_streamlit_email.py +151 -5
- small_software-0.7.0/small_cli/__init__.py +0 -1
- small_software-0.7.0/small_cli/commands/__init__.py +0 -8
- small_software-0.7.0/small_cli/templates/fastapi/magic_link.py +0 -808
- small_software-0.7.0/small_cli/templates/node-http/magic-link.js +0 -513
- small_software-0.7.0/small_cli/templates/python-http/magic_link.py +0 -808
- small_software-0.7.0/small_cli/templates/streamlit/magic_link.py +0 -808
- {small_software-0.7.0 → small_software-0.9.0}/LICENSE +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/setup.cfg +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/__main__.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/archive.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/cli.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/dashboard.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/db.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/init.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/link.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/mail.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/mcp.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/notify.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/common.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/config.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/i18n.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/scaffold.py +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/fastapi/requirements.txt +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/node-http/package.json +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/node-http/server.js +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/streamlit/requirements.txt +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_software.egg-info/dependency_links.txt +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_software.egg-info/entry_points.txt +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/small_software.egg-info/top_level.txt +0 -0
- {small_software-0.7.0 → small_software-0.9.0}/tests/test_dashboard_share.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: small-software
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: A cloud for small software: deploy team apps to Railway in one command, private by default, shared by work email
|
|
5
5
|
Author: RuslanKazyradzi13
|
|
6
6
|
License-Expression: MIT
|
|
@@ -30,20 +30,23 @@ Dynamic: license-file
|
|
|
30
30
|
|
|
31
31
|
[Русская версия](https://github.com/RuslanKazyradzi13/small-software/blob/main/small-cli/README.ru.md)
|
|
32
32
|
|
|
33
|
-
`small` deploys small team apps to Railway in one command and protects them with sign-in by work
|
|
34
|
-
personal invite links.
|
|
33
|
+
`small` deploys small team apps to Railway in one command and protects them with sign-in by work account (Google,
|
|
34
|
+
Microsoft), work email or personal invite links.
|
|
35
35
|
|
|
36
36
|
```
|
|
37
37
|
small init my-service # skeleton: invite-link protected server, small.json, .gitignore
|
|
38
38
|
cd my-service
|
|
39
39
|
small deploy # → https://my-service-3f9a.up.railway.app
|
|
40
40
|
small open # open with the invite link
|
|
41
|
-
small access add ivan@company.com #
|
|
42
|
-
small
|
|
41
|
+
small access add ivan@company.com # who may sign in, like sharing a Google Doc
|
|
42
|
+
small sso google # "Sign in with Google" (or small sso microsoft)
|
|
43
|
+
small mail resend # or sign-in links by email
|
|
44
|
+
small access log # who signed in, when and how
|
|
43
45
|
small access add bob # or a personal link for bob (small access revoke bob to revoke it)
|
|
44
46
|
small notify telegram # access requests to Telegram
|
|
45
47
|
small env set DEBUG=1 # environment variable in Railway and small.json
|
|
46
48
|
small db add postgres # database, DATABASE_URL in the app
|
|
49
|
+
small volume add # persistent disk: files and sign-in state survive deploys
|
|
47
50
|
small domain add app.example.com # custom domain
|
|
48
51
|
small sleep on # sleep when idle (saves money)
|
|
49
52
|
small logs -f # app logs
|
|
@@ -316,7 +319,7 @@ share apps itself: *"Deploy this folder and give ivan@company.com access."*
|
|
|
316
319
|
## `small deploy`
|
|
317
320
|
|
|
318
321
|
```
|
|
319
|
-
small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--new] [--no-wait] [--timeout SEC]
|
|
322
|
+
small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--public] [--new] [--no-wait] [--timeout SEC]
|
|
320
323
|
```
|
|
321
324
|
|
|
322
325
|
| Flag | What it does |
|
|
@@ -325,6 +328,7 @@ small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--
|
|
|
325
328
|
| `--workspace` | workspace ID if the account has several (or `RAILWAY_WORKSPACE_ID`) |
|
|
326
329
|
| `--port` | port for the public domain if the app does not listen on `$PORT` |
|
|
327
330
|
| `-m`, `--message` | deploy message shown in Railway |
|
|
331
|
+
| `--public` | an app without sign-in of its own: deploy it open to anyone, without the gateway (see `small gateway`) |
|
|
328
332
|
| `--new` | create a new project even if the folder has been deployed before |
|
|
329
333
|
| `--no-wait` | print the URL right away without waiting for the build |
|
|
330
334
|
| `--timeout` | how many seconds to wait for the build (default 900) |
|
|
@@ -332,6 +336,42 @@ small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--
|
|
|
332
336
|
Progress goes to stderr and the final URL to stdout: `URL=$(small deploy)`.
|
|
333
337
|
Exit codes: `0` success, `1` error or failed build (the last log lines are printed), `130` interrupted.
|
|
334
338
|
|
|
339
|
+
## `small gateway`: sign-in in front of any app
|
|
340
|
+
|
|
341
|
+
```
|
|
342
|
+
small gateway [on [--port N] | off [--yes]] # no subcommand: the status
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
The gateway protects an app that has no sign-in of its own — any language, any framework, a Dockerfile — without
|
|
346
|
+
changing its code. It is a second service in the app's Railway project, `<app>-gateway`: [Caddy](https://caddyserver.com)
|
|
347
|
+
takes every request, and magic-link next to it decides who gets in (Google and Microsoft sign-on, email links, invite
|
|
348
|
+
links, all `small access` / `small sso` / `small mail` settings). Requests it lets in go on to the app over Railway's
|
|
349
|
+
private network; the app itself has no public address.
|
|
350
|
+
|
|
351
|
+
- **Private by default.** The first `small deploy` of a folder without `magic_link.py` / `magic-link.js` sets the
|
|
352
|
+
gateway up before the app ever gets an address, and puts your own invite link in `small.json` (`small open`).
|
|
353
|
+
`small deploy --public` deploys such an app open to anyone instead; redeploying an app that is open prints a warning.
|
|
354
|
+
- **`on`** for an app already deployed: creates the gateway, moves the sign-in variables to it, moves the address
|
|
355
|
+
(keeping its name), and restarts the app without a public address. The app must listen on `$PORT` (small sets it to
|
|
356
|
+
`8080` if it isn't set) or pass `--port`. A custom domain has to be removed first and added back after; it then
|
|
357
|
+
points to the gateway.
|
|
358
|
+
- **Who is signed in.** The gateway adds `X-Small-User` (the address, or the invite-link name), `X-Small-Method`
|
|
359
|
+
(`email`, `google`, `microsoft`, `link`) and `X-Small-Auth`, an HMAC signature of both with `SMALL_GATEWAY_KEY`, which
|
|
360
|
+
the app gets as a Railway reference. Any `X-Small-*` header a visitor sends is dropped. magic-link in the app (0.9+)
|
|
361
|
+
trusts that signature and nothing else, so an app that keeps its own sign-in code works behind the gateway too.
|
|
362
|
+
- **Websockets** go through the same check. For Streamlit this means an `HttpOnly` session cookie set by the gateway.
|
|
363
|
+
- **Where things live.** Sign-in variables (`ACCESS_*`, `SESSION_SECRET`, `GOOGLE_*`, `MICROSOFT_*`,
|
|
364
|
+
`SESSIONS_REVOKED`…) belong to the gateway; mail and Telegram variables go to both; everything else stays the
|
|
365
|
+
app's. `small env`, `small access`, `small sso`, `small mail` and `small deploy` route them for you, and restart only
|
|
366
|
+
the service that changed.
|
|
367
|
+
- **The rest follows:** `small access log` reads the gateway's log, `small logs --gateway` shows it, `small sleep`
|
|
368
|
+
puts both to sleep (the first request wakes both, a few seconds), `small domain` attaches to the gateway,
|
|
369
|
+
`small volume add --gateway` keeps sign-in state across the gateway's restarts, `small destroy` removes both.
|
|
370
|
+
- **`small deploy`** also rebuilds the gateway when it was built by an older small.
|
|
371
|
+
- **`off`** gives the address and the variables back to the app and deletes the gateway. If the app has no sign-in of
|
|
372
|
+
its own, small asks first (or `--yes`): without the gateway anyone with the address gets in.
|
|
373
|
+
- **Cost:** one more small service. Caddy and the sign-in server idle at a few tens of MB; with sleep on, both sleep.
|
|
374
|
+
|
|
335
375
|
## After deploy
|
|
336
376
|
|
|
337
377
|
These commands work in the folder of a deployed app, that is, where `.small.json` is. `link`, `open`, `logs` and
|
|
@@ -341,18 +381,23 @@ These commands work in the folder of a deployed app, that is, where `.small.json
|
|
|
341
381
|
small link [folder] [--as NAME] # print the invite link
|
|
342
382
|
small open [folder] [--as NAME] # the same + open it in the browser
|
|
343
383
|
small access [list | add EMAIL|@DOMAIN|NAME | revoke EMAIL|NAME] # who can sign in
|
|
384
|
+
small access log [-n N] [--json] # who signed in, when and how
|
|
385
|
+
small access signout EMAIL|@DOMAIN|--all # end their sessions on all devices
|
|
386
|
+
small sso [google [--client-id ID] | microsoft [--client-id ID] [--tenant T] | off [PROVIDER]]
|
|
344
387
|
small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sign-in by email
|
|
345
388
|
small notify [telegram [--chat-id ID] | off] # access requests to Telegram
|
|
346
389
|
small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
|
|
347
390
|
small db [list | add postgres | remove postgres [--yes]]
|
|
391
|
+
small volume [add [--mount /data]] [--gateway] # persistent disk; no argument shows the status
|
|
392
|
+
small gateway [on [--port N] | off [--yes]] # sign-in in front of any app (see above)
|
|
348
393
|
small domain [list | add DOMAIN [--port N] | remove DOMAIN]
|
|
349
394
|
small sleep [on | off] # sleep mode; no argument shows the status
|
|
350
|
-
small logs [folder] [--build] [-n N] [-f]
|
|
395
|
+
small logs [folder] [--build | --gateway] [-n N] [-f] # log of the latest deploy
|
|
351
396
|
small destroy [folder] [--yes] # remove from Railway
|
|
352
397
|
```
|
|
353
398
|
|
|
354
|
-
Commands that change the app's variables (`access add` / `revoke
|
|
355
|
-
`db add` / `remove`) and `sleep on` / `off` restart the app once. Add `--no-deploy` to skip the restart; the change
|
|
399
|
+
Commands that change the app's variables (`access add` / `revoke` / `signout`, `sso`, `mail`, `notify`,
|
|
400
|
+
`env set` / `unset`, `db add` / `remove`), `volume add` and `sleep on` / `off` restart the app once. Add `--no-deploy` to skip the restart; the change
|
|
356
401
|
then takes effect on the next deploy.
|
|
357
402
|
|
|
358
403
|
**`small link` / `small open`** build `https://<domain>/?token=<token>`.
|
|
@@ -365,15 +410,22 @@ then takes effect on the next deploy.
|
|
|
365
410
|
|
|
366
411
|
**`small access`** controls who can open the app. There are two ways, and you can combine them.
|
|
367
412
|
|
|
368
|
-
*By
|
|
369
|
-
- **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
413
|
+
*By address*, like sharing a Google Doc. This is the recommended way:
|
|
414
|
+
- **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app and proves the address is his:
|
|
415
|
+
with **Sign in with Google / Microsoft** (`small sso`), or by entering it and getting a sign-in link by email
|
|
416
|
+
(`small mail`) that works for 15 minutes and only once. The browser remembers the sign-in for 30 days. Passing a
|
|
417
|
+
link on gives nobody anything: only the owner of the mailbox or account gets in.
|
|
373
418
|
- **`add @company.com`** lets in everyone with an address in that domain.
|
|
374
|
-
- **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open.
|
|
375
|
-
|
|
376
|
-
|
|
419
|
+
- **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open. The
|
|
420
|
+
time also goes to `SESSIONS_REVOKED`, so if you add the address back later, the old sessions stay invalid.
|
|
421
|
+
- **`signout ivan@company.com`** (or `@company.com`, or `--all`) ends the sessions on every device without taking
|
|
422
|
+
access away: for a lost laptop. People can do it themselves with **Sign out on all devices** if the app has a
|
|
423
|
+
volume (`small volume add`).
|
|
424
|
+
- **`log`** prints who signed in, when, how (Google, Microsoft, email link, invite link) and from which IP, plus
|
|
425
|
+
refused sign-ins and access requests, across deploys: `magic_link` writes them as JSON log lines, and Railway
|
|
426
|
+
keeps them as long as your plan keeps logs. `--json` gives one object per line.
|
|
427
|
+
- The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. One way to sign in must be set up:
|
|
428
|
+
`small sso` or `small mail`.
|
|
377
429
|
- `small` passes the address for links in emails to the app itself, in `PUBLIC_URL` (in Railway only): after the
|
|
378
430
|
domain is renamed to `<name>.up.railway.app`, Railway keeps the old address in `RAILWAY_PUBLIC_DOMAIN`, and the
|
|
379
431
|
links would lead to a 404. If you want links on your own domain, set `PUBLIC_URL` in `small.json`; `small` does
|
|
@@ -390,10 +442,26 @@ then takes effect on the next deploy.
|
|
|
390
442
|
their access.
|
|
391
443
|
|
|
392
444
|
- **`list`** shows who has access: addresses and names (tokens are hidden).
|
|
393
|
-
- **Sign-in log.**
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
445
|
+
- **Sign-in log.** `small access log` (above). In the raw log (`small logs`) each sign-in is one JSON line, for
|
|
446
|
+
example `{"message": "[magic-link] signed in with Google: ivan@company.com, ip 203.0.113.7", "magic_link":
|
|
447
|
+
"sign_in", "method": "google", ...}`; Railway's log explorer filters them with `@magic_link:sign_in`.
|
|
448
|
+
|
|
449
|
+
**`small sso`** sets up sign-in with a work account. Who may sign in is still the access list.
|
|
450
|
+
- **`google`**: "Sign in with Google" for Google Workspace accounts and Gmail addresses. `small` prints what to do
|
|
451
|
+
in Google Cloud (about 5 minutes: an OAuth client of type Web application) and the exact redirect URI to register,
|
|
452
|
+
then asks for the client ID and secret (the secret from `GOOGLE_CLIENT_SECRET` or with hidden input).
|
|
453
|
+
- **`microsoft`**: "Sign in with Microsoft" for the members of one Microsoft Entra organization. `small` prints the
|
|
454
|
+
steps (an app registration, single tenant, a client secret) and the redirect URI; `--tenant` takes the Directory
|
|
455
|
+
(tenant) ID or your domain (`company.com`), which `small` resolves to the ID.
|
|
456
|
+
- **Checked before saving.** `small` shows the provider the client ID and secret; a wrong pair is refused and
|
|
457
|
+
nothing is saved.
|
|
458
|
+
- **Which accounts count.** Google: verified Gmail addresses, or accounts of a Google Workspace organization; a
|
|
459
|
+
personal Google account registered with a company address is refused. Microsoft: members of your tenant only, not
|
|
460
|
+
guests and not other organizations. Details: [SECURITY.md](https://github.com/RuslanKazyradzi13/small-software/blob/main/SECURITY.md#threat-model).
|
|
461
|
+
- **Redirect URI**: `https://<app>/_access/sso/callback`, or `https://<app>/` for `streamlit`. With your own domain,
|
|
462
|
+
set `PUBLIC_URL` in `small.json` first, and register that address.
|
|
463
|
+
- If email sign-in isn't set up, the sign-in page shows only the provider buttons; with both, it shows both.
|
|
464
|
+
- **`off [google|microsoft]`** turns one or both off; with no subcommand you get the status and the redirect URI.
|
|
397
465
|
|
|
398
466
|
**`small mail`** sets how the app sends sign-in emails.
|
|
399
467
|
- **`resend`** sends through [Resend](https://resend.com) over HTTPS. `small` takes the key from `RESEND_API_KEY`
|
|
@@ -444,6 +512,16 @@ then takes effect on the next deploy.
|
|
|
444
512
|
status.
|
|
445
513
|
- **`remove`** removes the domain.
|
|
446
514
|
|
|
515
|
+
**`small volume`** gives the app a persistent disk (a Railway volume).
|
|
516
|
+
- **`add`** creates one at `/data` (`--mount` for another path, outside `/app`) and restarts the app. The app can
|
|
517
|
+
keep files there; the path is in `RAILWAY_VOLUME_MOUNT_PATH`. With no subcommand you get the path and how much is
|
|
518
|
+
used.
|
|
519
|
+
- **Sign-in state.** `magic_link` keeps it in `.magic-link.db` (SQLite) on the volume: used sign-in links stay used
|
|
520
|
+
after a restart, rate limits are shared by worker processes, and **Sign out on all devices** appears. Without a
|
|
521
|
+
volume this state lives in memory and a restart forgets it.
|
|
522
|
+
- **Trade-off.** With a volume each deploy has a few seconds of downtime: two copies of the app can't share the disk.
|
|
523
|
+
A service has one volume.
|
|
524
|
+
|
|
447
525
|
**`small sleep`** controls Railway's sleep mode (serverless).
|
|
448
526
|
- **`on`**: with no incoming requests the app goes to sleep and uses no CPU or memory; the first request wakes it
|
|
449
527
|
up in a few seconds. Handy for apps that are opened a couple of times a day.
|
|
@@ -461,7 +539,7 @@ then takes effect on the next deploy.
|
|
|
461
539
|
that it disappears from Railway too.
|
|
462
540
|
|
|
463
541
|
**`small logs`** shows the log of the latest deploy.
|
|
464
|
-
- **Which log.** The app log by default, the build log with `--build`. `-n` sets the number of lines (default 100).
|
|
542
|
+
- **Which log.** The app log by default, the build log with `--build`, the gateway's with `--gateway`. `-n` sets the number of lines (default 100).
|
|
465
543
|
- **Follow mode.** `-f` appends new lines every 2 seconds; `Ctrl+C` exits.
|
|
466
544
|
- **Format.** Every line is printed with its local time. Requests from the **Request access** button show up as
|
|
467
545
|
`[access-request] ...`, and they are easy to filter: `small logs | findstr access-request`.
|
|
@@ -566,8 +644,9 @@ The tests start a local mock of the Railway API (GraphQL + the upload endpoint)
|
|
|
566
644
|
deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
|
|
567
645
|
|
|
568
646
|
Template tests on real frameworks are skipped if the framework is not installed; the rest pass.
|
|
569
|
-
- `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens
|
|
570
|
-
form, one-time link, session cookie, revocation, sign out
|
|
647
|
+
- `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens, sign-in by email:
|
|
648
|
+
form, one-time link, session cookie, revocation, sign out; sign-in with Google against a local provider, refusals,
|
|
649
|
+
sign out on all devices) and the server through `python app.py`.
|
|
571
650
|
- 2 `fastapi` tests: `TestClient` and `uvicorn main:app` the way Railway runs it, plus a check that the token is
|
|
572
651
|
hidden in the access log.
|
|
573
652
|
|
|
@@ -582,6 +661,11 @@ Dashboard buttons: the "Open via invite link" redirect and its protection agains
|
|
|
582
661
|
token, with Railway and with Railway unavailable. Sleep mode and the cost estimate in the dashboard, Railway errors
|
|
583
662
|
while computing the cost.
|
|
584
663
|
|
|
664
|
+
`small sso`, `volume`, `access log` and `access signout` are tested against mocks of Railway, Google and Microsoft:
|
|
665
|
+
the credential check, a wrong secret that saves nothing, resolving a domain to a tenant, the Streamlit and custom
|
|
666
|
+
domain redirect URIs, old `magic_link` code, the volume and its mount path, the access log table, `--json` and the MCP
|
|
667
|
+
tool, sign-outs by address, domain and `--all`, forgetting old sign-outs.
|
|
668
|
+
|
|
585
669
|
`small sleep`, `db`, `domain` and `notify` are tested against mocks of Railway and the Telegram Bot API:
|
|
586
670
|
- turning sleep on and off, turning it on again without requests, `--no-deploy`;
|
|
587
671
|
- deploying the Postgres template with default values, the `DATABASE_URL` reference, a workflow error, removing
|
|
@@ -621,4 +705,5 @@ network, timeout, wrong token, garbage status, no token in the response):
|
|
|
621
705
|
## Limitations
|
|
622
706
|
|
|
623
707
|
- Only the root `.gitignore`/`.railwayignore` are read; nested ones are ignored.
|
|
624
|
-
- One service is created, in the `production` environment. Databases: Postgres only
|
|
708
|
+
- One service is created (two with the gateway), in the `production` environment. Databases: Postgres only
|
|
709
|
+
(`small db`).
|
|
@@ -2,20 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
[Русская версия](https://github.com/RuslanKazyradzi13/small-software/blob/main/small-cli/README.ru.md)
|
|
4
4
|
|
|
5
|
-
`small` deploys small team apps to Railway in one command and protects them with sign-in by work
|
|
6
|
-
personal invite links.
|
|
5
|
+
`small` deploys small team apps to Railway in one command and protects them with sign-in by work account (Google,
|
|
6
|
+
Microsoft), work email or personal invite links.
|
|
7
7
|
|
|
8
8
|
```
|
|
9
9
|
small init my-service # skeleton: invite-link protected server, small.json, .gitignore
|
|
10
10
|
cd my-service
|
|
11
11
|
small deploy # → https://my-service-3f9a.up.railway.app
|
|
12
12
|
small open # open with the invite link
|
|
13
|
-
small access add ivan@company.com #
|
|
14
|
-
small
|
|
13
|
+
small access add ivan@company.com # who may sign in, like sharing a Google Doc
|
|
14
|
+
small sso google # "Sign in with Google" (or small sso microsoft)
|
|
15
|
+
small mail resend # or sign-in links by email
|
|
16
|
+
small access log # who signed in, when and how
|
|
15
17
|
small access add bob # or a personal link for bob (small access revoke bob to revoke it)
|
|
16
18
|
small notify telegram # access requests to Telegram
|
|
17
19
|
small env set DEBUG=1 # environment variable in Railway and small.json
|
|
18
20
|
small db add postgres # database, DATABASE_URL in the app
|
|
21
|
+
small volume add # persistent disk: files and sign-in state survive deploys
|
|
19
22
|
small domain add app.example.com # custom domain
|
|
20
23
|
small sleep on # sleep when idle (saves money)
|
|
21
24
|
small logs -f # app logs
|
|
@@ -288,7 +291,7 @@ share apps itself: *"Deploy this folder and give ivan@company.com access."*
|
|
|
288
291
|
## `small deploy`
|
|
289
292
|
|
|
290
293
|
```
|
|
291
|
-
small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--new] [--no-wait] [--timeout SEC]
|
|
294
|
+
small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--public] [--new] [--no-wait] [--timeout SEC]
|
|
292
295
|
```
|
|
293
296
|
|
|
294
297
|
| Flag | What it does |
|
|
@@ -297,6 +300,7 @@ small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--
|
|
|
297
300
|
| `--workspace` | workspace ID if the account has several (or `RAILWAY_WORKSPACE_ID`) |
|
|
298
301
|
| `--port` | port for the public domain if the app does not listen on `$PORT` |
|
|
299
302
|
| `-m`, `--message` | deploy message shown in Railway |
|
|
303
|
+
| `--public` | an app without sign-in of its own: deploy it open to anyone, without the gateway (see `small gateway`) |
|
|
300
304
|
| `--new` | create a new project even if the folder has been deployed before |
|
|
301
305
|
| `--no-wait` | print the URL right away without waiting for the build |
|
|
302
306
|
| `--timeout` | how many seconds to wait for the build (default 900) |
|
|
@@ -304,6 +308,42 @@ small deploy [folder] [--name NAME] [--workspace ID] [--port N] [-m MESSAGE] [--
|
|
|
304
308
|
Progress goes to stderr and the final URL to stdout: `URL=$(small deploy)`.
|
|
305
309
|
Exit codes: `0` success, `1` error or failed build (the last log lines are printed), `130` interrupted.
|
|
306
310
|
|
|
311
|
+
## `small gateway`: sign-in in front of any app
|
|
312
|
+
|
|
313
|
+
```
|
|
314
|
+
small gateway [on [--port N] | off [--yes]] # no subcommand: the status
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
The gateway protects an app that has no sign-in of its own — any language, any framework, a Dockerfile — without
|
|
318
|
+
changing its code. It is a second service in the app's Railway project, `<app>-gateway`: [Caddy](https://caddyserver.com)
|
|
319
|
+
takes every request, and magic-link next to it decides who gets in (Google and Microsoft sign-on, email links, invite
|
|
320
|
+
links, all `small access` / `small sso` / `small mail` settings). Requests it lets in go on to the app over Railway's
|
|
321
|
+
private network; the app itself has no public address.
|
|
322
|
+
|
|
323
|
+
- **Private by default.** The first `small deploy` of a folder without `magic_link.py` / `magic-link.js` sets the
|
|
324
|
+
gateway up before the app ever gets an address, and puts your own invite link in `small.json` (`small open`).
|
|
325
|
+
`small deploy --public` deploys such an app open to anyone instead; redeploying an app that is open prints a warning.
|
|
326
|
+
- **`on`** for an app already deployed: creates the gateway, moves the sign-in variables to it, moves the address
|
|
327
|
+
(keeping its name), and restarts the app without a public address. The app must listen on `$PORT` (small sets it to
|
|
328
|
+
`8080` if it isn't set) or pass `--port`. A custom domain has to be removed first and added back after; it then
|
|
329
|
+
points to the gateway.
|
|
330
|
+
- **Who is signed in.** The gateway adds `X-Small-User` (the address, or the invite-link name), `X-Small-Method`
|
|
331
|
+
(`email`, `google`, `microsoft`, `link`) and `X-Small-Auth`, an HMAC signature of both with `SMALL_GATEWAY_KEY`, which
|
|
332
|
+
the app gets as a Railway reference. Any `X-Small-*` header a visitor sends is dropped. magic-link in the app (0.9+)
|
|
333
|
+
trusts that signature and nothing else, so an app that keeps its own sign-in code works behind the gateway too.
|
|
334
|
+
- **Websockets** go through the same check. For Streamlit this means an `HttpOnly` session cookie set by the gateway.
|
|
335
|
+
- **Where things live.** Sign-in variables (`ACCESS_*`, `SESSION_SECRET`, `GOOGLE_*`, `MICROSOFT_*`,
|
|
336
|
+
`SESSIONS_REVOKED`…) belong to the gateway; mail and Telegram variables go to both; everything else stays the
|
|
337
|
+
app's. `small env`, `small access`, `small sso`, `small mail` and `small deploy` route them for you, and restart only
|
|
338
|
+
the service that changed.
|
|
339
|
+
- **The rest follows:** `small access log` reads the gateway's log, `small logs --gateway` shows it, `small sleep`
|
|
340
|
+
puts both to sleep (the first request wakes both, a few seconds), `small domain` attaches to the gateway,
|
|
341
|
+
`small volume add --gateway` keeps sign-in state across the gateway's restarts, `small destroy` removes both.
|
|
342
|
+
- **`small deploy`** also rebuilds the gateway when it was built by an older small.
|
|
343
|
+
- **`off`** gives the address and the variables back to the app and deletes the gateway. If the app has no sign-in of
|
|
344
|
+
its own, small asks first (or `--yes`): without the gateway anyone with the address gets in.
|
|
345
|
+
- **Cost:** one more small service. Caddy and the sign-in server idle at a few tens of MB; with sleep on, both sleep.
|
|
346
|
+
|
|
307
347
|
## After deploy
|
|
308
348
|
|
|
309
349
|
These commands work in the folder of a deployed app, that is, where `.small.json` is. `link`, `open`, `logs` and
|
|
@@ -313,18 +353,23 @@ These commands work in the folder of a deployed app, that is, where `.small.json
|
|
|
313
353
|
small link [folder] [--as NAME] # print the invite link
|
|
314
354
|
small open [folder] [--as NAME] # the same + open it in the browser
|
|
315
355
|
small access [list | add EMAIL|@DOMAIN|NAME | revoke EMAIL|NAME] # who can sign in
|
|
356
|
+
small access log [-n N] [--json] # who signed in, when and how
|
|
357
|
+
small access signout EMAIL|@DOMAIN|--all # end their sessions on all devices
|
|
358
|
+
small sso [google [--client-id ID] | microsoft [--client-id ID] [--tenant T] | off [PROVIDER]]
|
|
316
359
|
small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sign-in by email
|
|
317
360
|
small notify [telegram [--chat-id ID] | off] # access requests to Telegram
|
|
318
361
|
small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
|
|
319
362
|
small db [list | add postgres | remove postgres [--yes]]
|
|
363
|
+
small volume [add [--mount /data]] [--gateway] # persistent disk; no argument shows the status
|
|
364
|
+
small gateway [on [--port N] | off [--yes]] # sign-in in front of any app (see above)
|
|
320
365
|
small domain [list | add DOMAIN [--port N] | remove DOMAIN]
|
|
321
366
|
small sleep [on | off] # sleep mode; no argument shows the status
|
|
322
|
-
small logs [folder] [--build] [-n N] [-f]
|
|
367
|
+
small logs [folder] [--build | --gateway] [-n N] [-f] # log of the latest deploy
|
|
323
368
|
small destroy [folder] [--yes] # remove from Railway
|
|
324
369
|
```
|
|
325
370
|
|
|
326
|
-
Commands that change the app's variables (`access add` / `revoke
|
|
327
|
-
`db add` / `remove`) and `sleep on` / `off` restart the app once. Add `--no-deploy` to skip the restart; the change
|
|
371
|
+
Commands that change the app's variables (`access add` / `revoke` / `signout`, `sso`, `mail`, `notify`,
|
|
372
|
+
`env set` / `unset`, `db add` / `remove`), `volume add` and `sleep on` / `off` restart the app once. Add `--no-deploy` to skip the restart; the change
|
|
328
373
|
then takes effect on the next deploy.
|
|
329
374
|
|
|
330
375
|
**`small link` / `small open`** build `https://<domain>/?token=<token>`.
|
|
@@ -337,15 +382,22 @@ then takes effect on the next deploy.
|
|
|
337
382
|
|
|
338
383
|
**`small access`** controls who can open the app. There are two ways, and you can combine them.
|
|
339
384
|
|
|
340
|
-
*By
|
|
341
|
-
- **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
385
|
+
*By address*, like sharing a Google Doc. This is the recommended way:
|
|
386
|
+
- **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app and proves the address is his:
|
|
387
|
+
with **Sign in with Google / Microsoft** (`small sso`), or by entering it and getting a sign-in link by email
|
|
388
|
+
(`small mail`) that works for 15 minutes and only once. The browser remembers the sign-in for 30 days. Passing a
|
|
389
|
+
link on gives nobody anything: only the owner of the mailbox or account gets in.
|
|
345
390
|
- **`add @company.com`** lets in everyone with an address in that domain.
|
|
346
|
-
- **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open.
|
|
347
|
-
|
|
348
|
-
|
|
391
|
+
- **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open. The
|
|
392
|
+
time also goes to `SESSIONS_REVOKED`, so if you add the address back later, the old sessions stay invalid.
|
|
393
|
+
- **`signout ivan@company.com`** (or `@company.com`, or `--all`) ends the sessions on every device without taking
|
|
394
|
+
access away: for a lost laptop. People can do it themselves with **Sign out on all devices** if the app has a
|
|
395
|
+
volume (`small volume add`).
|
|
396
|
+
- **`log`** prints who signed in, when, how (Google, Microsoft, email link, invite link) and from which IP, plus
|
|
397
|
+
refused sign-ins and access requests, across deploys: `magic_link` writes them as JSON log lines, and Railway
|
|
398
|
+
keeps them as long as your plan keeps logs. `--json` gives one object per line.
|
|
399
|
+
- The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. One way to sign in must be set up:
|
|
400
|
+
`small sso` or `small mail`.
|
|
349
401
|
- `small` passes the address for links in emails to the app itself, in `PUBLIC_URL` (in Railway only): after the
|
|
350
402
|
domain is renamed to `<name>.up.railway.app`, Railway keeps the old address in `RAILWAY_PUBLIC_DOMAIN`, and the
|
|
351
403
|
links would lead to a 404. If you want links on your own domain, set `PUBLIC_URL` in `small.json`; `small` does
|
|
@@ -362,10 +414,26 @@ then takes effect on the next deploy.
|
|
|
362
414
|
their access.
|
|
363
415
|
|
|
364
416
|
- **`list`** shows who has access: addresses and names (tokens are hidden).
|
|
365
|
-
- **Sign-in log.**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
417
|
+
- **Sign-in log.** `small access log` (above). In the raw log (`small logs`) each sign-in is one JSON line, for
|
|
418
|
+
example `{"message": "[magic-link] signed in with Google: ivan@company.com, ip 203.0.113.7", "magic_link":
|
|
419
|
+
"sign_in", "method": "google", ...}`; Railway's log explorer filters them with `@magic_link:sign_in`.
|
|
420
|
+
|
|
421
|
+
**`small sso`** sets up sign-in with a work account. Who may sign in is still the access list.
|
|
422
|
+
- **`google`**: "Sign in with Google" for Google Workspace accounts and Gmail addresses. `small` prints what to do
|
|
423
|
+
in Google Cloud (about 5 minutes: an OAuth client of type Web application) and the exact redirect URI to register,
|
|
424
|
+
then asks for the client ID and secret (the secret from `GOOGLE_CLIENT_SECRET` or with hidden input).
|
|
425
|
+
- **`microsoft`**: "Sign in with Microsoft" for the members of one Microsoft Entra organization. `small` prints the
|
|
426
|
+
steps (an app registration, single tenant, a client secret) and the redirect URI; `--tenant` takes the Directory
|
|
427
|
+
(tenant) ID or your domain (`company.com`), which `small` resolves to the ID.
|
|
428
|
+
- **Checked before saving.** `small` shows the provider the client ID and secret; a wrong pair is refused and
|
|
429
|
+
nothing is saved.
|
|
430
|
+
- **Which accounts count.** Google: verified Gmail addresses, or accounts of a Google Workspace organization; a
|
|
431
|
+
personal Google account registered with a company address is refused. Microsoft: members of your tenant only, not
|
|
432
|
+
guests and not other organizations. Details: [SECURITY.md](https://github.com/RuslanKazyradzi13/small-software/blob/main/SECURITY.md#threat-model).
|
|
433
|
+
- **Redirect URI**: `https://<app>/_access/sso/callback`, or `https://<app>/` for `streamlit`. With your own domain,
|
|
434
|
+
set `PUBLIC_URL` in `small.json` first, and register that address.
|
|
435
|
+
- If email sign-in isn't set up, the sign-in page shows only the provider buttons; with both, it shows both.
|
|
436
|
+
- **`off [google|microsoft]`** turns one or both off; with no subcommand you get the status and the redirect URI.
|
|
369
437
|
|
|
370
438
|
**`small mail`** sets how the app sends sign-in emails.
|
|
371
439
|
- **`resend`** sends through [Resend](https://resend.com) over HTTPS. `small` takes the key from `RESEND_API_KEY`
|
|
@@ -416,6 +484,16 @@ then takes effect on the next deploy.
|
|
|
416
484
|
status.
|
|
417
485
|
- **`remove`** removes the domain.
|
|
418
486
|
|
|
487
|
+
**`small volume`** gives the app a persistent disk (a Railway volume).
|
|
488
|
+
- **`add`** creates one at `/data` (`--mount` for another path, outside `/app`) and restarts the app. The app can
|
|
489
|
+
keep files there; the path is in `RAILWAY_VOLUME_MOUNT_PATH`. With no subcommand you get the path and how much is
|
|
490
|
+
used.
|
|
491
|
+
- **Sign-in state.** `magic_link` keeps it in `.magic-link.db` (SQLite) on the volume: used sign-in links stay used
|
|
492
|
+
after a restart, rate limits are shared by worker processes, and **Sign out on all devices** appears. Without a
|
|
493
|
+
volume this state lives in memory and a restart forgets it.
|
|
494
|
+
- **Trade-off.** With a volume each deploy has a few seconds of downtime: two copies of the app can't share the disk.
|
|
495
|
+
A service has one volume.
|
|
496
|
+
|
|
419
497
|
**`small sleep`** controls Railway's sleep mode (serverless).
|
|
420
498
|
- **`on`**: with no incoming requests the app goes to sleep and uses no CPU or memory; the first request wakes it
|
|
421
499
|
up in a few seconds. Handy for apps that are opened a couple of times a day.
|
|
@@ -433,7 +511,7 @@ then takes effect on the next deploy.
|
|
|
433
511
|
that it disappears from Railway too.
|
|
434
512
|
|
|
435
513
|
**`small logs`** shows the log of the latest deploy.
|
|
436
|
-
- **Which log.** The app log by default, the build log with `--build`. `-n` sets the number of lines (default 100).
|
|
514
|
+
- **Which log.** The app log by default, the build log with `--build`, the gateway's with `--gateway`. `-n` sets the number of lines (default 100).
|
|
437
515
|
- **Follow mode.** `-f` appends new lines every 2 seconds; `Ctrl+C` exits.
|
|
438
516
|
- **Format.** Every line is printed with its local time. Requests from the **Request access** button show up as
|
|
439
517
|
`[access-request] ...`, and they are easy to filter: `small logs | findstr access-request`.
|
|
@@ -538,8 +616,9 @@ The tests start a local mock of the Railway API (GraphQL + the upload endpoint)
|
|
|
538
616
|
deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
|
|
539
617
|
|
|
540
618
|
Template tests on real frameworks are skipped if the framework is not installed; the rest pass.
|
|
541
|
-
- `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens
|
|
542
|
-
form, one-time link, session cookie, revocation, sign out
|
|
619
|
+
- `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens, sign-in by email:
|
|
620
|
+
form, one-time link, session cookie, revocation, sign out; sign-in with Google against a local provider, refusals,
|
|
621
|
+
sign out on all devices) and the server through `python app.py`.
|
|
543
622
|
- 2 `fastapi` tests: `TestClient` and `uvicorn main:app` the way Railway runs it, plus a check that the token is
|
|
544
623
|
hidden in the access log.
|
|
545
624
|
|
|
@@ -554,6 +633,11 @@ Dashboard buttons: the "Open via invite link" redirect and its protection agains
|
|
|
554
633
|
token, with Railway and with Railway unavailable. Sleep mode and the cost estimate in the dashboard, Railway errors
|
|
555
634
|
while computing the cost.
|
|
556
635
|
|
|
636
|
+
`small sso`, `volume`, `access log` and `access signout` are tested against mocks of Railway, Google and Microsoft:
|
|
637
|
+
the credential check, a wrong secret that saves nothing, resolving a domain to a tenant, the Streamlit and custom
|
|
638
|
+
domain redirect URIs, old `magic_link` code, the volume and its mount path, the access log table, `--json` and the MCP
|
|
639
|
+
tool, sign-outs by address, domain and `--all`, forgetting old sign-outs.
|
|
640
|
+
|
|
557
641
|
`small sleep`, `db`, `domain` and `notify` are tested against mocks of Railway and the Telegram Bot API:
|
|
558
642
|
- turning sleep on and off, turning it on again without requests, `--no-deploy`;
|
|
559
643
|
- deploying the Postgres template with default values, the `DATABASE_URL` reference, a workflow error, removing
|
|
@@ -593,4 +677,5 @@ network, timeout, wrong token, garbage status, no token in the response):
|
|
|
593
677
|
## Limitations
|
|
594
678
|
|
|
595
679
|
- Only the root `.gitignore`/`.railwayignore` are read; nested ones are ignored.
|
|
596
|
-
- One service is created, in the `production` environment. Databases: Postgres only
|
|
680
|
+
- One service is created (two with the gateway), in the `production` environment. Databases: Postgres only
|
|
681
|
+
(`small db`).
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "small-software"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.9.0"
|
|
8
8
|
description = "A cloud for small software: deploy team apps to Railway in one command, private by default, shared by work email"
|
|
9
9
|
license = "MIT"
|
|
10
10
|
license-files = ["LICENSE"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.0"
|
|
@@ -9,6 +9,7 @@ import re
|
|
|
9
9
|
import secrets
|
|
10
10
|
from urllib.parse import quote
|
|
11
11
|
|
|
12
|
+
from . import gateway
|
|
12
13
|
from .config import CONFIG_FILE, load_config
|
|
13
14
|
from .i18n import tr
|
|
14
15
|
from .railway import Railway, RailwayError, token_from_env
|
|
@@ -18,6 +19,12 @@ NAME_RE = re.compile(r"[A-Za-z0-9_.@-]{1,40}\Z")
|
|
|
18
19
|
EMAIL_RE = re.compile(r"[^@\s,;<>\"']+@[^@\s,;<>\"']+\.[^@\s,;<>\"']+\Z")
|
|
19
20
|
DOMAIN_RE = re.compile(r"@[^@\s,;<>\"']+\.[^@\s,;<>\"']+\Z")
|
|
20
21
|
MAIL_VARS = ("RESEND_API_KEY", "SMTP_URL", "LOGIN_LINKS_TO_LOG")
|
|
22
|
+
# Single sign-on: a provider is on when all of its variables are set (small sso google / small sso microsoft).
|
|
23
|
+
SSO_VARS = {"google": ("GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"),
|
|
24
|
+
"microsoft": ("MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID")}
|
|
25
|
+
SSO_NAMES = {"google": "Google", "microsoft": "Microsoft"}
|
|
26
|
+
SSO_CALLBACK = "/_access/sso/callback"
|
|
27
|
+
SESSION_DAYS = 30 # how long a sign-in lasts in magic_link: older sign-out times can be forgotten
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
def parse(value):
|
|
@@ -54,13 +61,19 @@ def parse_emails(value):
|
|
|
54
61
|
|
|
55
62
|
|
|
56
63
|
def app_env(root, state, warn=None):
|
|
57
|
-
"""(app variables, where each one comes from): Railway, overlaid with non-empty values from small.json.
|
|
64
|
+
"""(app variables, where each one comes from): Railway, overlaid with non-empty values from small.json. Behind the
|
|
65
|
+
gateway, the sign-in variables come from the gateway's service."""
|
|
58
66
|
env, sources = {}, {}
|
|
59
67
|
linked = all(state.get(k) for k in ("projectId", "environmentId", "serviceId"))
|
|
60
68
|
if linked and token_from_env():
|
|
61
69
|
try:
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
api = Railway(token_from_env())
|
|
71
|
+
env = dict(api.service_variables(state["projectId"], state["environmentId"], state["serviceId"]))
|
|
72
|
+
if gateway.is_on(state):
|
|
73
|
+
for name in gateway.GATEWAY_ONLY:
|
|
74
|
+
env.pop(name, None)
|
|
75
|
+
ours = api.service_variables(state["projectId"], state["environmentId"], state["gateway"]["serviceId"])
|
|
76
|
+
env.update({k: v for k, v in ours.items() if k in gateway.GATEWAY_ONLY + gateway.SHARED})
|
|
64
77
|
sources = dict.fromkeys(env, "Railway")
|
|
65
78
|
except RailwayError as e:
|
|
66
79
|
if warn:
|
|
@@ -95,11 +108,85 @@ def invite_link(domain, token):
|
|
|
95
108
|
return url + "?token=" + quote(token, safe="") if token else url
|
|
96
109
|
|
|
97
110
|
|
|
98
|
-
def
|
|
111
|
+
def sso_enabled(env):
|
|
112
|
+
"""Single sign-on providers set up in the app's variables, in button order: ["google", "microsoft"] or fewer."""
|
|
113
|
+
return [provider for provider, names in SSO_VARS.items() if all(env.get(name) for name in names)]
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def is_streamlit(root):
|
|
117
|
+
app = root / "app.py"
|
|
118
|
+
return app.is_file() and "streamlit" in app.read_text(encoding="utf-8", errors="replace")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def app_url(root, state):
|
|
122
|
+
"""The app's public address: PUBLIC_URL from small.json (your own domain), else the Railway domain, else None."""
|
|
123
|
+
custom = load_config(root / CONFIG_FILE)["env"].get("PUBLIC_URL", "").strip().rstrip("/")
|
|
124
|
+
if custom:
|
|
125
|
+
return custom
|
|
126
|
+
return "https://" + state["domain"] if state.get("domain") else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def redirect_uri(root, state):
|
|
130
|
+
"""Where Google and Microsoft send people back after sign-in: /_access/sso/callback, or the page itself for
|
|
131
|
+
Streamlit without the gateway (it can't serve other paths). None until the app has an address."""
|
|
132
|
+
base = app_url(root, state)
|
|
133
|
+
if base is None:
|
|
134
|
+
return None
|
|
135
|
+
return base + "/" if is_streamlit(root) and not gateway.is_on(state) else base + SSO_CALLBACK
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def parse_revoked(value):
|
|
139
|
+
"""SESSIONS_REVOKED "ivan@company.com=1760000000, *=1760000100" -> {"ivan@company.com": 1760000000, "*": ...}."""
|
|
140
|
+
found = {}
|
|
141
|
+
for item in (value or "").split(","):
|
|
142
|
+
who, _, moment = item.strip().lower().rpartition("=")
|
|
143
|
+
who = who.strip()
|
|
144
|
+
if (who == "*" or is_email(who)) and moment.strip().isdigit():
|
|
145
|
+
found[who] = max(found.get(who, 0), int(moment))
|
|
146
|
+
return found
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def add_revoked(value, who, now):
|
|
150
|
+
"""SESSIONS_REVOKED with `who` (an address, @domain or *) signed out at `now`. Sign-outs older than a session's
|
|
151
|
+
lifetime are dropped: every session they could end has expired anyway."""
|
|
152
|
+
entries = parse_revoked(value)
|
|
153
|
+
entries[who.lower()] = int(now)
|
|
154
|
+
keep = sorted((w, t) for w, t in entries.items() if t > now - SESSION_DAYS * 24 * 3600)
|
|
155
|
+
return ", ".join("%s=%d" % pair for pair in keep)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def feature_problem(root, state=None):
|
|
159
|
+
"""None if the app's code knows sign-in with Google and Microsoft and signing out (small 0.8), or the gateway
|
|
160
|
+
does it for the app; otherwise what is in the way and how to fix it."""
|
|
161
|
+
if gateway.is_on(state or {}):
|
|
162
|
+
return None
|
|
163
|
+
streamlit = is_streamlit(root)
|
|
164
|
+
if streamlit and "sso_finish" not in (root / "app.py").read_text(encoding="utf-8", errors="replace"):
|
|
165
|
+
return (tr("app.py is from a streamlit template older than small 0.8, without sign-in with Google and "
|
|
166
|
+
"Microsoft. Take require_access() from the new app.py, and magic_link.py next to it: %s",
|
|
167
|
+
"app.py — из шаблона streamlit старше small 0.8, без входа через Google и Microsoft. Возьмите "
|
|
168
|
+
"require_access() из нового app.py и magic_link.py рядом с ним: %s") % (TEMPLATES_DIR / "streamlit"))
|
|
169
|
+
for name, template in (("magic_link.py", "streamlit" if streamlit else "python-http"),
|
|
170
|
+
("magic-link.js", "node-http")):
|
|
171
|
+
path = root / name
|
|
172
|
+
if path.is_file():
|
|
173
|
+
if "SESSIONS_REVOKED" in path.read_text(encoding="utf-8", errors="replace"):
|
|
174
|
+
return None
|
|
175
|
+
return (tr("%s in the app is older than small 0.8: it knows neither sign-in with Google and Microsoft nor "
|
|
176
|
+
"signing out. Replace it with the new one: %s",
|
|
177
|
+
"%s в приложении старше small 0.8: не знает ни входа через Google и Microsoft, ни выхода "
|
|
178
|
+
"на всех устройствах. Замените его новым: %s") % (name, TEMPLATES_DIR / template / name))
|
|
179
|
+
return None # no magic_link next to the code: the app may keep it elsewhere, as for email sign-in
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def email_login_problem(root, state=None):
|
|
99
183
|
"""None if the app code supports sign-in by email; otherwise, what is in the way and how to fix it.
|
|
100
184
|
|
|
101
185
|
Streamlit cannot be wrapped in middleware: there sign-in by email is done by require_access() in app.py via
|
|
102
|
-
MagicLinkGate from magic_link.py, so new versions of both files are needed.
|
|
186
|
+
MagicLinkGate from magic_link.py, so new versions of both files are needed. Behind the gateway the app's code
|
|
187
|
+
doesn't matter: the gateway signs people in."""
|
|
188
|
+
if gateway.is_on(state or {}):
|
|
189
|
+
return None
|
|
103
190
|
app = root / "app.py"
|
|
104
191
|
source = app.read_text(encoding="utf-8", errors="replace") if app.is_file() else ""
|
|
105
192
|
streamlit = "streamlit" in source
|