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.
Files changed (68) hide show
  1. {small_software-0.7.0/small_software.egg-info → small_software-0.9.0}/PKG-INFO +110 -25
  2. {small_software-0.7.0 → small_software-0.9.0}/README.md +109 -24
  3. {small_software-0.7.0 → small_software-0.9.0}/pyproject.toml +1 -1
  4. small_software-0.9.0/small_cli/__init__.py +1 -0
  5. {small_software-0.7.0 → small_software-0.9.0}/small_cli/access.py +92 -5
  6. small_software-0.9.0/small_cli/commands/__init__.py +10 -0
  7. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/access.py +153 -20
  8. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/deploy.py +85 -10
  9. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/destroy.py +2 -1
  10. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/domain.py +3 -1
  11. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/env.py +23 -10
  12. small_software-0.9.0/small_cli/commands/gateway.py +251 -0
  13. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/logs.py +7 -1
  14. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/sleep.py +6 -3
  15. small_software-0.9.0/small_cli/commands/sso.py +268 -0
  16. small_software-0.9.0/small_cli/commands/volume.py +105 -0
  17. {small_software-0.7.0 → small_software-0.9.0}/small_cli/dashboard.py +9 -5
  18. small_software-0.9.0/small_cli/gateway.py +100 -0
  19. {small_software-0.7.0 → small_software-0.9.0}/small_cli/mcp.py +41 -13
  20. {small_software-0.7.0 → small_software-0.9.0}/small_cli/railway.py +44 -0
  21. small_software-0.9.0/small_cli/templates/fastapi/magic_link.py +1503 -0
  22. {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/fastapi/main.py +3 -3
  23. small_software-0.9.0/small_cli/templates/gateway/Caddyfile +41 -0
  24. small_software-0.9.0/small_cli/templates/gateway/Dockerfile +7 -0
  25. small_software-0.9.0/small_cli/templates/gateway/gateway.py +94 -0
  26. small_software-0.9.0/small_cli/templates/gateway/magic_link.py +1503 -0
  27. small_software-0.9.0/small_cli/templates/node-http/magic-link.js +1045 -0
  28. {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/python-http/app.py +3 -2
  29. small_software-0.9.0/small_cli/templates/python-http/magic_link.py +1503 -0
  30. {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/streamlit/app.py +93 -9
  31. small_software-0.9.0/small_cli/templates/streamlit/magic_link.py +1503 -0
  32. {small_software-0.7.0 → small_software-0.9.0/small_software.egg-info}/PKG-INFO +110 -25
  33. {small_software-0.7.0 → small_software-0.9.0}/small_software.egg-info/SOURCES.txt +10 -0
  34. small_software-0.9.0/tests/test_gateway.py +514 -0
  35. {small_software-0.7.0 → small_software-0.9.0}/tests/test_mcp.py +1 -1
  36. {small_software-0.7.0 → small_software-0.9.0}/tests/test_small.py +23 -7
  37. small_software-0.9.0/tests/test_sso_volume.py +392 -0
  38. {small_software-0.7.0 → small_software-0.9.0}/tests/test_streamlit_email.py +151 -5
  39. small_software-0.7.0/small_cli/__init__.py +0 -1
  40. small_software-0.7.0/small_cli/commands/__init__.py +0 -8
  41. small_software-0.7.0/small_cli/templates/fastapi/magic_link.py +0 -808
  42. small_software-0.7.0/small_cli/templates/node-http/magic-link.js +0 -513
  43. small_software-0.7.0/small_cli/templates/python-http/magic_link.py +0 -808
  44. small_software-0.7.0/small_cli/templates/streamlit/magic_link.py +0 -808
  45. {small_software-0.7.0 → small_software-0.9.0}/LICENSE +0 -0
  46. {small_software-0.7.0 → small_software-0.9.0}/setup.cfg +0 -0
  47. {small_software-0.7.0 → small_software-0.9.0}/small_cli/__main__.py +0 -0
  48. {small_software-0.7.0 → small_software-0.9.0}/small_cli/archive.py +0 -0
  49. {small_software-0.7.0 → small_software-0.9.0}/small_cli/cli.py +0 -0
  50. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/dashboard.py +0 -0
  51. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/db.py +0 -0
  52. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/init.py +0 -0
  53. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/link.py +0 -0
  54. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/mail.py +0 -0
  55. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/mcp.py +0 -0
  56. {small_software-0.7.0 → small_software-0.9.0}/small_cli/commands/notify.py +0 -0
  57. {small_software-0.7.0 → small_software-0.9.0}/small_cli/common.py +0 -0
  58. {small_software-0.7.0 → small_software-0.9.0}/small_cli/config.py +0 -0
  59. {small_software-0.7.0 → small_software-0.9.0}/small_cli/i18n.py +0 -0
  60. {small_software-0.7.0 → small_software-0.9.0}/small_cli/scaffold.py +0 -0
  61. {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/fastapi/requirements.txt +0 -0
  62. {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/node-http/package.json +0 -0
  63. {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/node-http/server.js +0 -0
  64. {small_software-0.7.0 → small_software-0.9.0}/small_cli/templates/streamlit/requirements.txt +0 -0
  65. {small_software-0.7.0 → small_software-0.9.0}/small_software.egg-info/dependency_links.txt +0 -0
  66. {small_software-0.7.0 → small_software-0.9.0}/small_software.egg-info/entry_points.txt +0 -0
  67. {small_software-0.7.0 → small_software-0.9.0}/small_software.egg-info/top_level.txt +0 -0
  68. {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.7.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 email or
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 # sign-in by work email, like sharing a Google Doc
42
- small mail resend # how sign-in emails are sent
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] # log of the latest deploy
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`, `mail`, `notify`, `env set` / `unset`,
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 work email*, like sharing a Google Doc. This is the recommended way:
369
- - **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app, enters his email and gets a
370
- message with a sign-in link: it works for 15 minutes and only once, and the browser remembers the sign-in for
371
- 30 days. Only someone who reads that mailbox can sign in, so passing the link on to someone else gives them
372
- nothing.
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
- - The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. Email sending must be set up:
376
- `small mail`.
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.** Sign-ins show up in the log: `small logs | Select-String "signed in by"` (PowerShell; on
394
- macOS/Linux use `grep`) → `signed in by email (ivan@company.com)` or `signed in by link (bob)`. With
395
- `OWNER_LANG=ru` these lines are in Russian; search them with `Select-String`, not `findstr`, which reads the
396
- argument and the text in different encodings and does not find Cyrillic.
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 and sign-in by email:
570
- form, one-time link, session cookie, revocation, sign out) and the server through `python app.py`.
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 (`small db`).
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 email or
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 # sign-in by work email, like sharing a Google Doc
14
- small mail resend # how sign-in emails are sent
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] # log of the latest deploy
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`, `mail`, `notify`, `env set` / `unset`,
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 work email*, like sharing a Google Doc. This is the recommended way:
341
- - **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app, enters his email and gets a
342
- message with a sign-in link: it works for 15 minutes and only once, and the browser remembers the sign-in for
343
- 30 days. Only someone who reads that mailbox can sign in, so passing the link on to someone else gives them
344
- nothing.
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
- - The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. Email sending must be set up:
348
- `small mail`.
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.** Sign-ins show up in the log: `small logs | Select-String "signed in by"` (PowerShell; on
366
- macOS/Linux use `grep`) → `signed in by email (ivan@company.com)` or `signed in by link (bob)`. With
367
- `OWNER_LANG=ru` these lines are in Russian; search them with `Select-String`, not `findstr`, which reads the
368
- argument and the text in different encodings and does not find Cyrillic.
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 and sign-in by email:
542
- form, one-time link, session cookie, revocation, sign out) and the server through `python app.py`.
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 (`small db`).
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.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
- env = dict(Railway(token_from_env()).service_variables(
63
- state["projectId"], state["environmentId"], state["serviceId"]))
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 email_login_problem(root):
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