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.
- {small_software-0.7.0 → small_software-0.8.0}/PKG-INFO +67 -21
- {small_software-0.7.0 → small_software-0.8.0}/README.md +66 -20
- {small_software-0.7.0 → small_software-0.8.0}/pyproject.toml +1 -1
- small_software-0.8.0/small_cli/__init__.py +1 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/access.py +75 -0
- small_software-0.8.0/small_cli/commands/__init__.py +9 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/access.py +149 -18
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/deploy.py +5 -3
- small_software-0.8.0/small_cli/commands/sso.py +268 -0
- small_software-0.8.0/small_cli/commands/volume.py +89 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/dashboard.py +9 -5
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/mcp.py +36 -11
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/railway.py +41 -0
- small_software-0.8.0/small_cli/templates/fastapi/magic_link.py +1397 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/fastapi/main.py +3 -3
- small_software-0.8.0/small_cli/templates/node-http/magic-link.js +988 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/python-http/app.py +3 -2
- small_software-0.8.0/small_cli/templates/python-http/magic_link.py +1397 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/streamlit/app.py +74 -9
- small_software-0.8.0/small_cli/templates/streamlit/magic_link.py +1397 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/PKG-INFO +67 -21
- {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/SOURCES.txt +3 -0
- {small_software-0.7.0 → small_software-0.8.0}/tests/test_mcp.py +1 -1
- {small_software-0.7.0 → small_software-0.8.0}/tests/test_small.py +5 -3
- small_software-0.8.0/tests/test_sso_volume.py +392 -0
- {small_software-0.7.0 → small_software-0.8.0}/tests/test_streamlit_email.py +133 -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.8.0}/LICENSE +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/setup.cfg +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/__main__.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/archive.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/cli.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/dashboard.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/db.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/destroy.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/domain.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/env.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/init.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/link.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/logs.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/mail.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/mcp.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/notify.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/commands/sleep.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/common.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/config.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/i18n.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/scaffold.py +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/fastapi/requirements.txt +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/node-http/package.json +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/node-http/server.js +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_cli/templates/streamlit/requirements.txt +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/dependency_links.txt +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/entry_points.txt +0 -0
- {small_software-0.7.0 → small_software-0.8.0}/small_software.egg-info/top_level.txt +0 -0
- {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.
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
369
|
-
- **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
|
|
376
|
-
|
|
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.**
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
|
570
|
-
form, one-time link, session cookie, revocation, sign out
|
|
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
|
|
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
|
|
@@ -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
|
|
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
|
|
341
|
-
- **`add ivan@company.com`** adds the address to `ACCESS_EMAILS`. Ivan opens the app
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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.**
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
|
542
|
-
form, one-time link, session cookie, revocation, sign out
|
|
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
|
+
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)
|