small-software 0.7.0__tar.gz → 0.8.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 (61) hide show
  1. {small_software-0.7.0 → small_software-0.8.0}/PKG-INFO +67 -21
  2. {small_software-0.7.0 → small_software-0.8.0}/README.md +66 -20
  3. {small_software-0.7.0 → small_software-0.8.0}/pyproject.toml +1 -1
  4. small_software-0.8.0/small_cli/__init__.py +1 -0
  5. {small_software-0.7.0 → small_software-0.8.0}/small_cli/access.py +75 -0
  6. small_software-0.8.0/small_cli/commands/__init__.py +9 -0
  7. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/access.py +149 -18
  8. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/deploy.py +5 -3
  9. small_software-0.8.0/small_cli/commands/sso.py +268 -0
  10. small_software-0.8.0/small_cli/commands/volume.py +89 -0
  11. {small_software-0.7.0 → small_software-0.8.0}/small_cli/dashboard.py +9 -5
  12. {small_software-0.7.0 → small_software-0.8.0}/small_cli/mcp.py +36 -11
  13. {small_software-0.7.0 → small_software-0.8.0}/small_cli/railway.py +41 -0
  14. small_software-0.8.0/small_cli/templates/fastapi/magic_link.py +1397 -0
  15. {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/fastapi/main.py +3 -3
  16. small_software-0.8.0/small_cli/templates/node-http/magic-link.js +988 -0
  17. {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/python-http/app.py +3 -2
  18. small_software-0.8.0/small_cli/templates/python-http/magic_link.py +1397 -0
  19. {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/streamlit/app.py +74 -9
  20. small_software-0.8.0/small_cli/templates/streamlit/magic_link.py +1397 -0
  21. {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/PKG-INFO +67 -21
  22. {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/SOURCES.txt +3 -0
  23. {small_software-0.7.0 → small_software-0.8.0}/tests/test_mcp.py +1 -1
  24. {small_software-0.7.0 → small_software-0.8.0}/tests/test_small.py +5 -3
  25. small_software-0.8.0/tests/test_sso_volume.py +392 -0
  26. {small_software-0.7.0 → small_software-0.8.0}/tests/test_streamlit_email.py +133 -5
  27. small_software-0.7.0/small_cli/__init__.py +0 -1
  28. small_software-0.7.0/small_cli/commands/__init__.py +0 -8
  29. small_software-0.7.0/small_cli/templates/fastapi/magic_link.py +0 -808
  30. small_software-0.7.0/small_cli/templates/node-http/magic-link.js +0 -513
  31. small_software-0.7.0/small_cli/templates/python-http/magic_link.py +0 -808
  32. small_software-0.7.0/small_cli/templates/streamlit/magic_link.py +0 -808
  33. {small_software-0.7.0 → small_software-0.8.0}/LICENSE +0 -0
  34. {small_software-0.7.0 → small_software-0.8.0}/setup.cfg +0 -0
  35. {small_software-0.7.0 → small_software-0.8.0}/small_cli/__main__.py +0 -0
  36. {small_software-0.7.0 → small_software-0.8.0}/small_cli/archive.py +0 -0
  37. {small_software-0.7.0 → small_software-0.8.0}/small_cli/cli.py +0 -0
  38. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/dashboard.py +0 -0
  39. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/db.py +0 -0
  40. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/destroy.py +0 -0
  41. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/domain.py +0 -0
  42. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/env.py +0 -0
  43. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/init.py +0 -0
  44. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/link.py +0 -0
  45. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/logs.py +0 -0
  46. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/mail.py +0 -0
  47. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/mcp.py +0 -0
  48. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/notify.py +0 -0
  49. {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/sleep.py +0 -0
  50. {small_software-0.7.0 → small_software-0.8.0}/small_cli/common.py +0 -0
  51. {small_software-0.7.0 → small_software-0.8.0}/small_cli/config.py +0 -0
  52. {small_software-0.7.0 → small_software-0.8.0}/small_cli/i18n.py +0 -0
  53. {small_software-0.7.0 → small_software-0.8.0}/small_cli/scaffold.py +0 -0
  54. {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/fastapi/requirements.txt +0 -0
  55. {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/node-http/package.json +0 -0
  56. {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/node-http/server.js +0 -0
  57. {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/streamlit/requirements.txt +0 -0
  58. {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/dependency_links.txt +0 -0
  59. {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/entry_points.txt +0 -0
  60. {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/top_level.txt +0 -0
  61. {small_software-0.7.0 → small_software-0.8.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.8.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
@@ -341,18 +344,22 @@ These commands work in the folder of a deployed app, that is, where `.small.json
341
344
  small link [folder] [--as NAME] # print the invite link
342
345
  small open [folder] [--as NAME] # the same + open it in the browser
343
346
  small access [list | add EMAIL|@DOMAIN|NAME | revoke EMAIL|NAME] # who can sign in
347
+ small access log [-n N] [--json] # who signed in, when and how
348
+ small access signout EMAIL|@DOMAIN|--all # end their sessions on all devices
349
+ small sso [google [--client-id ID] | microsoft [--client-id ID] [--tenant T] | off [PROVIDER]]
344
350
  small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sign-in by email
345
351
  small notify [telegram [--chat-id ID] | off] # access requests to Telegram
346
352
  small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
347
353
  small db [list | add postgres | remove postgres [--yes]]
354
+ small volume [add [--mount /data]] # persistent disk; no argument shows the status
348
355
  small domain [list | add DOMAIN [--port N] | remove DOMAIN]
349
356
  small sleep [on | off] # sleep mode; no argument shows the status
350
357
  small logs [folder] [--build] [-n N] [-f] # log of the latest deploy
351
358
  small destroy [folder] [--yes] # remove from Railway
352
359
  ```
353
360
 
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
361
+ Commands that change the app's variables (`access add` / `revoke` / `signout`, `sso`, `mail`, `notify`,
362
+ `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
363
  then takes effect on the next deploy.
357
364
 
358
365
  **`small link` / `small open`** build `https://<domain>/?token=<token>`.
@@ -365,15 +372,22 @@ then takes effect on the next deploy.
365
372
 
366
373
  **`small access`** controls who can open the app. There are two ways, and you can combine them.
367
374
 
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.
375
+ *By address*, like sharing a Google Doc. This is the recommended way:
376
+ - **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app and proves the address is his:
377
+ with **Sign in with Google / Microsoft** (`small sso`), or by entering it and getting a sign-in link by email
378
+ (`small mail`) that works for 15 minutes and only once. The browser remembers the sign-in for 30 days. Passing a
379
+ link on gives nobody anything: only the owner of the mailbox or account gets in.
373
380
  - **`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`.
381
+ - **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open. The
382
+ time also goes to `SESSIONS_REVOKED`, so if you add the address back later, the old sessions stay invalid.
383
+ - **`signout ivan@company.com`** (or `@company.com`, or `--all`) ends the sessions on every device without taking
384
+ access away: for a lost laptop. People can do it themselves with **Sign out on all devices** if the app has a
385
+ volume (`small volume add`).
386
+ - **`log`** prints who signed in, when, how (Google, Microsoft, email link, invite link) and from which IP, plus
387
+ refused sign-ins and access requests, across deploys: `magic_link` writes them as JSON log lines, and Railway
388
+ keeps them as long as your plan keeps logs. `--json` gives one object per line.
389
+ - The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. One way to sign in must be set up:
390
+ `small sso` or `small mail`.
377
391
  - `small` passes the address for links in emails to the app itself, in `PUBLIC_URL` (in Railway only): after the
378
392
  domain is renamed to `<name>.up.railway.app`, Railway keeps the old address in `RAILWAY_PUBLIC_DOMAIN`, and the
379
393
  links would lead to a 404. If you want links on your own domain, set `PUBLIC_URL` in `small.json`; `small` does
@@ -390,10 +404,26 @@ then takes effect on the next deploy.
390
404
  their access.
391
405
 
392
406
  - **`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.
407
+ - **Sign-in log.** `small access log` (above). In the raw log (`small logs`) each sign-in is one JSON line, for
408
+ example `{"message": "[magic-link] signed in with Google: ivan@company.com, ip 203.0.113.7", "magic_link":
409
+ "sign_in", "method": "google", ...}`; Railway's log explorer filters them with `@magic_link:sign_in`.
410
+
411
+ **`small sso`** sets up sign-in with a work account. Who may sign in is still the access list.
412
+ - **`google`**: "Sign in with Google" for Google Workspace accounts and Gmail addresses. `small` prints what to do
413
+ in Google Cloud (about 5 minutes: an OAuth client of type Web application) and the exact redirect URI to register,
414
+ then asks for the client ID and secret (the secret from `GOOGLE_CLIENT_SECRET` or with hidden input).
415
+ - **`microsoft`**: "Sign in with Microsoft" for the members of one Microsoft Entra organization. `small` prints the
416
+ steps (an app registration, single tenant, a client secret) and the redirect URI; `--tenant` takes the Directory
417
+ (tenant) ID or your domain (`company.com`), which `small` resolves to the ID.
418
+ - **Checked before saving.** `small` shows the provider the client ID and secret; a wrong pair is refused and
419
+ nothing is saved.
420
+ - **Which accounts count.** Google: verified Gmail addresses, or accounts of a Google Workspace organization; a
421
+ personal Google account registered with a company address is refused. Microsoft: members of your tenant only, not
422
+ guests and not other organizations. Details: [SECURITY.md](https://github.com/RuslanKazyradzi13/small-software/blob/main/SECURITY.md#threat-model).
423
+ - **Redirect URI**: `https://<app>/_access/sso/callback`, or `https://<app>/` for `streamlit`. With your own domain,
424
+ set `PUBLIC_URL` in `small.json` first, and register that address.
425
+ - If email sign-in isn't set up, the sign-in page shows only the provider buttons; with both, it shows both.
426
+ - **`off [google|microsoft]`** turns one or both off; with no subcommand you get the status and the redirect URI.
397
427
 
398
428
  **`small mail`** sets how the app sends sign-in emails.
399
429
  - **`resend`** sends through [Resend](https://resend.com) over HTTPS. `small` takes the key from `RESEND_API_KEY`
@@ -444,6 +474,16 @@ then takes effect on the next deploy.
444
474
  status.
445
475
  - **`remove`** removes the domain.
446
476
 
477
+ **`small volume`** gives the app a persistent disk (a Railway volume).
478
+ - **`add`** creates one at `/data` (`--mount` for another path, outside `/app`) and restarts the app. The app can
479
+ keep files there; the path is in `RAILWAY_VOLUME_MOUNT_PATH`. With no subcommand you get the path and how much is
480
+ used.
481
+ - **Sign-in state.** `magic_link` keeps it in `.magic-link.db` (SQLite) on the volume: used sign-in links stay used
482
+ after a restart, rate limits are shared by worker processes, and **Sign out on all devices** appears. Without a
483
+ volume this state lives in memory and a restart forgets it.
484
+ - **Trade-off.** With a volume each deploy has a few seconds of downtime: two copies of the app can't share the disk.
485
+ A service has one volume.
486
+
447
487
  **`small sleep`** controls Railway's sleep mode (serverless).
448
488
  - **`on`**: with no incoming requests the app goes to sleep and uses no CPU or memory; the first request wakes it
449
489
  up in a few seconds. Handy for apps that are opened a couple of times a day.
@@ -566,8 +606,9 @@ The tests start a local mock of the Railway API (GraphQL + the upload endpoint)
566
606
  deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
567
607
 
568
608
  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`.
609
+ - `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens, sign-in by email:
610
+ form, one-time link, session cookie, revocation, sign out; sign-in with Google against a local provider, refusals,
611
+ sign out on all devices) and the server through `python app.py`.
571
612
  - 2 `fastapi` tests: `TestClient` and `uvicorn main:app` the way Railway runs it, plus a check that the token is
572
613
  hidden in the access log.
573
614
 
@@ -582,6 +623,11 @@ Dashboard buttons: the "Open via invite link" redirect and its protection agains
582
623
  token, with Railway and with Railway unavailable. Sleep mode and the cost estimate in the dashboard, Railway errors
583
624
  while computing the cost.
584
625
 
626
+ `small sso`, `volume`, `access log` and `access signout` are tested against mocks of Railway, Google and Microsoft:
627
+ the credential check, a wrong secret that saves nothing, resolving a domain to a tenant, the Streamlit and custom
628
+ domain redirect URIs, old `magic_link` code, the volume and its mount path, the access log table, `--json` and the MCP
629
+ tool, sign-outs by address, domain and `--all`, forgetting old sign-outs.
630
+
585
631
  `small sleep`, `db`, `domain` and `notify` are tested against mocks of Railway and the Telegram Bot API:
586
632
  - turning sleep on and off, turning it on again without requests, `--no-deploy`;
587
633
  - deploying the Postgres template with default values, the `DATABASE_URL` reference, a workflow error, removing
@@ -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
@@ -313,18 +316,22 @@ These commands work in the folder of a deployed app, that is, where `.small.json
313
316
  small link [folder] [--as NAME] # print the invite link
314
317
  small open [folder] [--as NAME] # the same + open it in the browser
315
318
  small access [list | add EMAIL|@DOMAIN|NAME | revoke EMAIL|NAME] # who can sign in
319
+ small access log [-n N] [--json] # who signed in, when and how
320
+ small access signout EMAIL|@DOMAIN|--all # end their sessions on all devices
321
+ small sso [google [--client-id ID] | microsoft [--client-id ID] [--tenant T] | off [PROVIDER]]
316
322
  small mail [resend | smtp [--from FROM] [--to TO] | log | off] # emails for sign-in by email
317
323
  small notify [telegram [--chat-id ID] | off] # access requests to Telegram
318
324
  small env [list [--values] | set K=V ... | unset K ...] [--no-deploy]
319
325
  small db [list | add postgres | remove postgres [--yes]]
326
+ small volume [add [--mount /data]] # persistent disk; no argument shows the status
320
327
  small domain [list | add DOMAIN [--port N] | remove DOMAIN]
321
328
  small sleep [on | off] # sleep mode; no argument shows the status
322
329
  small logs [folder] [--build] [-n N] [-f] # log of the latest deploy
323
330
  small destroy [folder] [--yes] # remove from Railway
324
331
  ```
325
332
 
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
333
+ Commands that change the app's variables (`access add` / `revoke` / `signout`, `sso`, `mail`, `notify`,
334
+ `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
335
  then takes effect on the next deploy.
329
336
 
330
337
  **`small link` / `small open`** build `https://<domain>/?token=<token>`.
@@ -337,15 +344,22 @@ then takes effect on the next deploy.
337
344
 
338
345
  **`small access`** controls who can open the app. There are two ways, and you can combine them.
339
346
 
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.
347
+ *By address*, like sharing a Google Doc. This is the recommended way:
348
+ - **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app and proves the address is his:
349
+ with **Sign in with Google / Microsoft** (`small sso`), or by entering it and getting a sign-in link by email
350
+ (`small mail`) that works for 15 minutes and only once. The browser remembers the sign-in for 30 days. Passing a
351
+ link on gives nobody anything: only the owner of the mailbox or account gets in.
345
352
  - **`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`.
353
+ - **`revoke ivan@company.com`** closes sign-in right after the restart, even in a browser that is already open. The
354
+ time also goes to `SESSIONS_REVOKED`, so if you add the address back later, the old sessions stay invalid.
355
+ - **`signout ivan@company.com`** (or `@company.com`, or `--all`) ends the sessions on every device without taking
356
+ access away: for a lost laptop. People can do it themselves with **Sign out on all devices** if the app has a
357
+ volume (`small volume add`).
358
+ - **`log`** prints who signed in, when, how (Google, Microsoft, email link, invite link) and from which IP, plus
359
+ refused sign-ins and access requests, across deploys: `magic_link` writes them as JSON log lines, and Railway
360
+ keeps them as long as your plan keeps logs. `--json` gives one object per line.
361
+ - The first `add` creates `SESSION_SECRET`, the key that signs links and sessions. One way to sign in must be set up:
362
+ `small sso` or `small mail`.
349
363
  - `small` passes the address for links in emails to the app itself, in `PUBLIC_URL` (in Railway only): after the
350
364
  domain is renamed to `<name>.up.railway.app`, Railway keeps the old address in `RAILWAY_PUBLIC_DOMAIN`, and the
351
365
  links would lead to a 404. If you want links on your own domain, set `PUBLIC_URL` in `small.json`; `small` does
@@ -362,10 +376,26 @@ then takes effect on the next deploy.
362
376
  their access.
363
377
 
364
378
  - **`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.
379
+ - **Sign-in log.** `small access log` (above). In the raw log (`small logs`) each sign-in is one JSON line, for
380
+ example `{"message": "[magic-link] signed in with Google: ivan@company.com, ip 203.0.113.7", "magic_link":
381
+ "sign_in", "method": "google", ...}`; Railway's log explorer filters them with `@magic_link:sign_in`.
382
+
383
+ **`small sso`** sets up sign-in with a work account. Who may sign in is still the access list.
384
+ - **`google`**: "Sign in with Google" for Google Workspace accounts and Gmail addresses. `small` prints what to do
385
+ in Google Cloud (about 5 minutes: an OAuth client of type Web application) and the exact redirect URI to register,
386
+ then asks for the client ID and secret (the secret from `GOOGLE_CLIENT_SECRET` or with hidden input).
387
+ - **`microsoft`**: "Sign in with Microsoft" for the members of one Microsoft Entra organization. `small` prints the
388
+ steps (an app registration, single tenant, a client secret) and the redirect URI; `--tenant` takes the Directory
389
+ (tenant) ID or your domain (`company.com`), which `small` resolves to the ID.
390
+ - **Checked before saving.** `small` shows the provider the client ID and secret; a wrong pair is refused and
391
+ nothing is saved.
392
+ - **Which accounts count.** Google: verified Gmail addresses, or accounts of a Google Workspace organization; a
393
+ personal Google account registered with a company address is refused. Microsoft: members of your tenant only, not
394
+ guests and not other organizations. Details: [SECURITY.md](https://github.com/RuslanKazyradzi13/small-software/blob/main/SECURITY.md#threat-model).
395
+ - **Redirect URI**: `https://<app>/_access/sso/callback`, or `https://<app>/` for `streamlit`. With your own domain,
396
+ set `PUBLIC_URL` in `small.json` first, and register that address.
397
+ - If email sign-in isn't set up, the sign-in page shows only the provider buttons; with both, it shows both.
398
+ - **`off [google|microsoft]`** turns one or both off; with no subcommand you get the status and the redirect URI.
369
399
 
370
400
  **`small mail`** sets how the app sends sign-in emails.
371
401
  - **`resend`** sends through [Resend](https://resend.com) over HTTPS. `small` takes the key from `RESEND_API_KEY`
@@ -416,6 +446,16 @@ then takes effect on the next deploy.
416
446
  status.
417
447
  - **`remove`** removes the domain.
418
448
 
449
+ **`small volume`** gives the app a persistent disk (a Railway volume).
450
+ - **`add`** creates one at `/data` (`--mount` for another path, outside `/app`) and restarts the app. The app can
451
+ keep files there; the path is in `RAILWAY_VOLUME_MOUNT_PATH`. With no subcommand you get the path and how much is
452
+ used.
453
+ - **Sign-in state.** `magic_link` keeps it in `.magic-link.db` (SQLite) on the volume: used sign-in links stay used
454
+ after a restart, rate limits are shared by worker processes, and **Sign out on all devices** appears. Without a
455
+ volume this state lives in memory and a restart forgets it.
456
+ - **Trade-off.** With a volume each deploy has a few seconds of downtime: two copies of the app can't share the disk.
457
+ A service has one volume.
458
+
419
459
  **`small sleep`** controls Railway's sleep mode (serverless).
420
460
  - **`on`**: with no incoming requests the app goes to sleep and uses no CPU or memory; the first request wakes it
421
461
  up in a few seconds. Handy for apps that are opened a couple of times a day.
@@ -538,8 +578,9 @@ The tests start a local mock of the Railway API (GraphQL + the upload endpoint)
538
578
  deploy, repeat deploy, failed build, crash, taken domain, several workspaces, wrong token, ignore rules.
539
579
 
540
580
  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`.
581
+ - `streamlit` tests: the access gate through `streamlit.testing.v1.AppTest` (personal tokens, sign-in by email:
582
+ form, one-time link, session cookie, revocation, sign out; sign-in with Google against a local provider, refusals,
583
+ sign out on all devices) and the server through `python app.py`.
543
584
  - 2 `fastapi` tests: `TestClient` and `uvicorn main:app` the way Railway runs it, plus a check that the token is
544
585
  hidden in the access log.
545
586
 
@@ -554,6 +595,11 @@ Dashboard buttons: the "Open via invite link" redirect and its protection agains
554
595
  token, with Railway and with Railway unavailable. Sleep mode and the cost estimate in the dashboard, Railway errors
555
596
  while computing the cost.
556
597
 
598
+ `small sso`, `volume`, `access log` and `access signout` are tested against mocks of Railway, Google and Microsoft:
599
+ the credential check, a wrong secret that saves nothing, resolving a domain to a tenant, the Streamlit and custom
600
+ domain redirect URIs, old `magic_link` code, the volume and its mount path, the access log table, `--json` and the MCP
601
+ tool, sign-outs by address, domain and `--all`, forgetting old sign-outs.
602
+
557
603
  `small sleep`, `db`, `domain` and `notify` are tested against mocks of Railway and the Telegram Bot API:
558
604
  - turning sleep on and off, turning it on again without requests, `--no-deploy`;
559
605
  - deploying the Postgres template with default values, the `DATABASE_URL` reference, a workflow error, removing
@@ -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.8.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.8.0"
@@ -18,6 +18,12 @@ NAME_RE = re.compile(r"[A-Za-z0-9_.@-]{1,40}\Z")
18
18
  EMAIL_RE = re.compile(r"[^@\s,;<>\"']+@[^@\s,;<>\"']+\.[^@\s,;<>\"']+\Z")
19
19
  DOMAIN_RE = re.compile(r"@[^@\s,;<>\"']+\.[^@\s,;<>\"']+\Z")
20
20
  MAIL_VARS = ("RESEND_API_KEY", "SMTP_URL", "LOGIN_LINKS_TO_LOG")
21
+ # Single sign-on: a provider is on when all of its variables are set (small sso google / small sso microsoft).
22
+ SSO_VARS = {"google": ("GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"),
23
+ "microsoft": ("MICROSOFT_CLIENT_ID", "MICROSOFT_CLIENT_SECRET", "MICROSOFT_TENANT_ID")}
24
+ SSO_NAMES = {"google": "Google", "microsoft": "Microsoft"}
25
+ SSO_CALLBACK = "/_access/sso/callback"
26
+ SESSION_DAYS = 30 # how long a sign-in lasts in magic_link: older sign-out times can be forgotten
21
27
 
22
28
 
23
29
  def parse(value):
@@ -95,6 +101,75 @@ def invite_link(domain, token):
95
101
  return url + "?token=" + quote(token, safe="") if token else url
96
102
 
97
103
 
104
+ def sso_enabled(env):
105
+ """Single sign-on providers set up in the app's variables, in button order: ["google", "microsoft"] or fewer."""
106
+ return [provider for provider, names in SSO_VARS.items() if all(env.get(name) for name in names)]
107
+
108
+
109
+ def is_streamlit(root):
110
+ app = root / "app.py"
111
+ return app.is_file() and "streamlit" in app.read_text(encoding="utf-8", errors="replace")
112
+
113
+
114
+ def app_url(root, state):
115
+ """The app's public address: PUBLIC_URL from small.json (your own domain), else the Railway domain, else None."""
116
+ custom = load_config(root / CONFIG_FILE)["env"].get("PUBLIC_URL", "").strip().rstrip("/")
117
+ if custom:
118
+ return custom
119
+ return "https://" + state["domain"] if state.get("domain") else None
120
+
121
+
122
+ def redirect_uri(root, state):
123
+ """Where Google and Microsoft send people back after sign-in: /_access/sso/callback, or the page itself for
124
+ Streamlit (it can't serve other paths). None until the app has an address."""
125
+ base = app_url(root, state)
126
+ if base is None:
127
+ return None
128
+ return base + "/" if is_streamlit(root) else base + SSO_CALLBACK
129
+
130
+
131
+ def parse_revoked(value):
132
+ """SESSIONS_REVOKED "ivan@company.com=1760000000, *=1760000100" -> {"ivan@company.com": 1760000000, "*": ...}."""
133
+ found = {}
134
+ for item in (value or "").split(","):
135
+ who, _, moment = item.strip().lower().rpartition("=")
136
+ who = who.strip()
137
+ if (who == "*" or is_email(who)) and moment.strip().isdigit():
138
+ found[who] = max(found.get(who, 0), int(moment))
139
+ return found
140
+
141
+
142
+ def add_revoked(value, who, now):
143
+ """SESSIONS_REVOKED with `who` (an address, @domain or *) signed out at `now`. Sign-outs older than a session's
144
+ lifetime are dropped: every session they could end has expired anyway."""
145
+ entries = parse_revoked(value)
146
+ entries[who.lower()] = int(now)
147
+ keep = sorted((w, t) for w, t in entries.items() if t > now - SESSION_DAYS * 24 * 3600)
148
+ return ", ".join("%s=%d" % pair for pair in keep)
149
+
150
+
151
+ def feature_problem(root):
152
+ """None if the app's code knows sign-in with Google and Microsoft and signing out (small 0.8); otherwise what is
153
+ in the way and how to fix it."""
154
+ streamlit = is_streamlit(root)
155
+ if streamlit and "sso_finish" not in (root / "app.py").read_text(encoding="utf-8", errors="replace"):
156
+ return (tr("app.py is from a streamlit template older than small 0.8, without sign-in with Google and "
157
+ "Microsoft. Take require_access() from the new app.py, and magic_link.py next to it: %s",
158
+ "app.py — из шаблона streamlit старше small 0.8, без входа через Google и Microsoft. Возьмите "
159
+ "require_access() из нового app.py и magic_link.py рядом с ним: %s") % (TEMPLATES_DIR / "streamlit"))
160
+ for name, template in (("magic_link.py", "streamlit" if streamlit else "python-http"),
161
+ ("magic-link.js", "node-http")):
162
+ path = root / name
163
+ if path.is_file():
164
+ if "SESSIONS_REVOKED" in path.read_text(encoding="utf-8", errors="replace"):
165
+ return None
166
+ return (tr("%s in the app is older than small 0.8: it knows neither sign-in with Google and Microsoft nor "
167
+ "signing out. Replace it with the new one: %s",
168
+ "%s в приложении старше small 0.8: не знает ни входа через Google и Microsoft, ни выхода "
169
+ "на всех устройствах. Замените его новым: %s") % (name, TEMPLATES_DIR / template / name))
170
+ return None # no magic_link next to the code: the app may keep it elsewhere, as for email sign-in
171
+
172
+
98
173
  def email_login_problem(root):
99
174
  """None if the app code supports sign-in by email; otherwise, what is in the way and how to fix it.
100
175
 
@@ -0,0 +1,9 @@
1
+ """small commands. Each module adds its subcommands with a register(subparsers) function.
2
+
3
+ The order here is the order in `small --help`: from creating an app to deleting it.
4
+ """
5
+
6
+ from . import (access, dashboard, db, deploy, destroy, domain, env, init, link, logs, mail, mcp, notify, sleep,
7
+ sso, volume)
8
+
9
+ ALL = (init, deploy, link, access, sso, mail, notify, env, db, volume, domain, sleep, logs, dashboard, mcp, destroy)