Merge branch 'invitation-signs-in' into main
24 files changed,
+253
−65
.gitignore+6 −0.superpowers/sdd/2026-08-23-idear/progress.md+2 −2SKILL.md+6 −6admit.go+1 −1admit_test.go+2 −2docs/superpowers/plans/2026-08-23-idear.md+2 −2errors.go+14 −1example/app.go+5 −5example/app_test.go+3 −3example/handlers.go+1 −1example/main.go+2 −2example/models.go+3 −3example/render.go+3 −3go.mod+1 −1go.sum+22 −10handlers.go+57 −8handlers_test.go+96 −2internal/ideartest/app.go+2 −2internal/ideartest/harness.go+3 −3middleware_test.go+1 −1roster.go+11 −1roster_test.go+6 −2schema.go+1 −1schema_test.go+3 −3
CI passed — run details
CI log
=== vet === go vet ./... === fmt === === test === go test ./... -count=1 ok amadan.net/rastrillo/idear 1.123s ok amadan.net/rastrillo/idear/example 2.556s ? amadan.net/rastrillo/idear/internal/ideartest [no test files] === race === CGO_ENABLED=1 go test ./... -race -count=1 ok amadan.net/rastrillo/idear 10.763s ok amadan.net/rastrillo/idear/example 31.799s ? amadan.net/rastrillo/idear/internal/ideartest [no test files]
diff --git a/.gitignore b/.gitignore| index f417343..615f0a6 100644 |
| --- a/.gitignore |
| +++ b/.gitignore |
| @@ -4,3 +4,9 @@ |
| /board.db |
| /board.db-shm |
| /board.db-wal |
| + |
| +# Go workspace files: local-only, and never the module's business. A |
| +# committed go.work names absolute paths on one developer's machine, |
| +# which breaks every other checkout (and CI) the moment it is read. |
| +/go.work |
| +/go.work.sum |
diff --git a/.superpowers/sdd/2026-08-23-idear/progress.md b/.superpowers/sdd/2026-08-23-idear/progress.md| index a47903e..78b762d 100644 |
| --- a/.superpowers/sdd/2026-08-23-idear/progress.md |
| +++ b/.superpowers/sdd/2026-08-23-idear/progress.md |
| @@ -10,9 +10,9 @@ Final tree gets installed to ~/github.com/rastrillo/idear at the end. |
| TASK ZERO PASSED — the vanity path and the whole dependency chain are proven: |
| - https://amadan.net/rastrillo/idear?go-get=1 serves |
| <meta name="go-import" content="amadan.net/rastrillo/idear git https://amadan.net/rastrillo/idear"> |
| -- github.com/carlosframework/rastrillo is a live mirror of rastrilloorg/rastrillo, |
| +- amadan.net/rastrillo/rastrillo is a live mirror of rastrilloorg/rastrillo, |
| already at 7439afc (the PR #91 merge). |
| -- `go get github.com/carlosframework/rastrillo@7439afc` resolves with NO replace |
| +- `go get amadan.net/rastrillo/rastrillo@7439afc` resolves with NO replace |
| directive, and a smoke test confirmed password.Refuse, password.ErrRefused, |
| sessions.Schema and migrate.Set are all reachable. |
diff --git a/SKILL.md b/SKILL.md| index 71f703b..551caa0 100644 |
| --- a/SKILL.md |
| +++ b/SKILL.md |
| @@ -38,12 +38,12 @@ Everything below comes from Rastrillo's own packages plus this one: |
| import ( |
| "amadan.net/rastrillo/idear" |
| - "github.com/carlosframework/rastrillo/csrf" |
| - "github.com/carlosframework/rastrillo/db" |
| - "github.com/carlosframework/rastrillo/flash" |
| - "github.com/carlosframework/rastrillo/migrate" |
| - "github.com/carlosframework/rastrillo/password" // or .../auth for keymail |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/flash" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/password" // or .../auth for keymail |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "github.com/go-chi/chi/v5" |
| ) |
| ``` |
diff --git a/admit.go b/admit.go| index 6dc1fc4..7fb0268 100644 |
| --- a/admit.go |
| +++ b/admit.go |
| @@ -8,7 +8,7 @@ import ( |
| "strconv" |
| "time" |
| - "github.com/carlosframework/rastrillo/password" |
| + "amadan.net/rastrillo/rastrillo/password" |
| ) |
| // refusedCopy is THE message every refusal renders, for every refused |
diff --git a/admit_test.go b/admit_test.go| index 98d769d..80290ba 100644 |
| --- a/admit_test.go |
| +++ b/admit_test.go |
| @@ -13,8 +13,8 @@ import ( |
| "sync" |
| "testing" |
| - "github.com/carlosframework/rastrillo/password" |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/password" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "gorm.io/gorm" |
| "amadan.net/rastrillo/idear" |
diff --git a/docs/superpowers/plans/2026-08-23-idear.md b/docs/superpowers/plans/2026-08-23-idear.md| index 3102acf..cf7f04a 100644 |
| --- a/docs/superpowers/plans/2026-08-23-idear.md |
| +++ b/docs/superpowers/plans/2026-08-23-idear.md |
| @@ -6,13 +6,13 @@ |
| **Architecture:** A Go module depending on Rastrillo (never the reverse). Pure policy functions, a GORM-backed store whose every mutation is one transaction, two middlewares, two identity adapters, and mountable handlers that render through app-supplied callbacks. |
| -**Tech Stack:** Go 1.25, GORM, chi, `github.com/carlosframework/rastrillo` (`sessions`, `migrate`, `password`, `form`, `flash`), SQLite via `rastrillo/db`. |
| +**Tech Stack:** Go 1.25, GORM, chi, `amadan.net/rastrillo/rastrillo` (`sessions`, `migrate`, `password`, `form`, `flash`), SQLite via `rastrillo/db`. |
| **Spec:** `docs/superpowers/specs/2026-08-23-idear-design.md` — read it. It is the binding authority; where this plan and the spec disagree, the spec wins and you should say so. |
| ## Global Constraints |
| -- Module path is `amadan.net/rastrillo/idear`. Never add a `replace` directive — the published `github.com/carlosframework/rastrillo` resolves, and a replace would hide a broken dependency. |
| +- Module path is `amadan.net/rastrillo/idear`. Never add a `replace` directive — the published `amadan.net/rastrillo/rastrillo` resolves, and a replace would hide a broken dependency. |
| - **Build environment (use this verbatim, every command):** |
| ``` |
| export GOFLAGS=-mod=mod CGO_ENABLED=0 \ |
diff --git a/errors.go b/errors.go| index eb1c3c4..62cc413 100644 |
| --- a/errors.go |
| +++ b/errors.go |
| @@ -1,6 +1,9 @@ |
| package idear |
| -import "errors" |
| +import ( |
| + "errors" |
| + "fmt" |
| +) |
| // The store's sentinels, and the two CLASSES a handler switches on. |
| // |
| @@ -51,6 +54,16 @@ var ( |
| // name the field that was wrong. |
| ErrInvalid = errors.New("idear: invalid argument") |
| + // ErrRemovedInvitee refuses an invitation to an address that |
| + // belongs to a DEACTIVATED member. Admission refuses such a |
| + // member whatever invitation they hold — readmission is Restore, |
| + // by an admin, so that being removed cannot be undone by the |
| + // removed person from their inbox — and an invitation that can |
| + // never be redeemed is a dead end for both people. It is a |
| + // member of the ErrInvalid class: the address was the wrong |
| + // argument, and the right one is the Restore button. |
| + ErrRemovedInvitee = fmt.Errorf("%w: that address belongs to a member who was removed; restore them instead", ErrInvalid) |
| + |
| // ErrLastOwner refuses any change that would leave the instance |
| // without exactly one active Owner: deactivating the Owner, or |
| // changing the Owner's role by any route other than Transfer. |
diff --git a/example/app.go b/example/app.go| index 19cc528..8d298d5 100644 |
| --- a/example/app.go |
| +++ b/example/app.go |
| @@ -5,11 +5,11 @@ import ( |
| "log/slog" |
| "net/http" |
| - "github.com/carlosframework/rastrillo/csrf" |
| - "github.com/carlosframework/rastrillo/db" |
| - "github.com/carlosframework/rastrillo/migrate" |
| - "github.com/carlosframework/rastrillo/password" |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/password" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "github.com/go-chi/chi/v5" |
| "gorm.io/gorm" |
diff --git a/example/app_test.go b/example/app_test.go| index e9cf729..602522d 100644 |
| --- a/example/app_test.go |
| +++ b/example/app_test.go |
| @@ -17,9 +17,9 @@ import ( |
| "strings" |
| "testing" |
| - "github.com/carlosframework/rastrillo/db" |
| - "github.com/carlosframework/rastrillo/migrate" |
| - "github.com/carlosframework/rastrillo/password" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/password" |
| "amadan.net/rastrillo/idear" |
| ) |
diff --git a/example/handlers.go b/example/handlers.go| index 1163c27..01c66d1 100644 |
| --- a/example/handlers.go |
| +++ b/example/handlers.go |
| @@ -5,7 +5,7 @@ import ( |
| "strconv" |
| "strings" |
| - "github.com/carlosframework/rastrillo/flash" |
| + "amadan.net/rastrillo/rastrillo/flash" |
| "amadan.net/rastrillo/idear" |
| ) |
diff --git a/example/main.go b/example/main.go| index 48b8347..8872a7c 100644 |
| --- a/example/main.go |
| +++ b/example/main.go |
| @@ -21,8 +21,8 @@ import ( |
| "net/url" |
| "os" |
| - "github.com/carlosframework/rastrillo" |
| - "github.com/carlosframework/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo" |
| + "amadan.net/rastrillo/rastrillo/db" |
| ) |
| func main() { |
diff --git a/example/models.go b/example/models.go| index 5cd2384..dc76f0b 100644 |
| --- a/example/models.go |
| +++ b/example/models.go |
| @@ -8,9 +8,9 @@ import ( |
| "strconv" |
| "time" |
| - "github.com/carlosframework/rastrillo/migrate" |
| - "github.com/carlosframework/rastrillo/password" |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/password" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "gorm.io/gorm" |
| "amadan.net/rastrillo/idear" |
diff --git a/example/render.go b/example/render.go| index 2c3218d..50c996e 100644 |
| --- a/example/render.go |
| +++ b/example/render.go |
| @@ -6,9 +6,9 @@ import ( |
| "html/template" |
| "net/http" |
| - "github.com/carlosframework/rastrillo/flash" |
| - "github.com/carlosframework/rastrillo/password" |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/flash" |
| + "amadan.net/rastrillo/rastrillo/password" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "amadan.net/rastrillo/idear" |
| ) |
diff --git a/go.mod b/go.mod| index ac1cc1b..da68db5 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,7 +3,7 @@ module amadan.net/rastrillo/idear |
| go 1.25.0 |
| require ( |
| - github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687 |
| + amadan.net/rastrillo/rastrillo v0.26.0 |
| github.com/go-chi/chi/v5 v5.3.2 |
| gorm.io/gorm v1.31.2 |
| ) |
diff --git a/go.sum b/go.sum| index aab4073..8ce353e 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,53 +1,61 @@ |
| -github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| -github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687 h1:sfD7narHWcha0swHvv3vblvzxhbwgHRI8kI5F4P/YgQ= |
| -github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| -github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= |
| -github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= |
| -github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= |
| +amadan.net/rastrillo/rastrillo v0.26.0 h1:I7UkiDbT304q9wXnmgabe84P6RDFTjMbKU174i1QZDo= |
| +amadan.net/rastrillo/rastrillo v0.26.0/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE= |
| github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= |
| github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= |
| github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= |
| -github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= |
| +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= |
| github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= |
| -github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= |
| -github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= |
| -github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= |
| +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= |
| github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= |
| github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= |
| github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= |
| github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= |
| github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= |
| +github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q= |
| github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls= |
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= |
| github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= |
| github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= |
| golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= |
| +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= |
| golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= |
| golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= |
| +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
| gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= |
| gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= |
| gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= |
| gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= |
| gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= |
| +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
| modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= |
| modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= |
| +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= |
| modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= |
| +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= |
| modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= |
| +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= |
| modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= |
| +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= |
| modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= |
| modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ= |
| modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os= |
| @@ -55,9 +63,13 @@ modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= |
| modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= |
| modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= |
| modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= |
| +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= |
| modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= |
| +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= |
| modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= |
| modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM= |
| modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw= |
| +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= |
| modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= |
| +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= |
| modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= |
diff --git a/handlers.go b/handlers.go| index 3d0ac5a..f4e990b 100644 |
| --- a/handlers.go |
| +++ b/handlers.go |
| @@ -8,7 +8,7 @@ import ( |
| "strconv" |
| "strings" |
| - "github.com/carlosframework/rastrillo/flash" |
| + "amadan.net/rastrillo/rastrillo/flash" |
| ) |
| // The copy every refusal renders. Fixed strings, never the error's own |
| @@ -16,13 +16,14 @@ import ( |
| // message, and a page that echoed it would publish them to whoever |
| // provoked it. The log line gets the detail; the page gets the class. |
| const ( |
| - invalidCopy = "That request was not valid." |
| - forbiddenCopy = "You may not do that." |
| - lastOwnerCopy = "The owner cannot be removed. Transfer ownership first." |
| - noInvitationCopy = "That invitation is no longer available." |
| - failedCopy = "Something went wrong. Please try again." |
| - signInFirstCopy = "Sign in first, then open this invitation link again." |
| - rateLimitedCopy = "Too many requests. Please wait a moment and try again." |
| + invalidCopy = "That request was not valid." |
| + forbiddenCopy = "You may not do that." |
| + lastOwnerCopy = "The owner cannot be removed. Transfer ownership first." |
| + noInvitationCopy = "That invitation is no longer available." |
| + failedCopy = "Something went wrong. Please try again." |
| + signInFirstCopy = "Sign in first, then open this invitation link again." |
| + removedInviteeCopy = "That address belongs to somebody who was removed from the team. Restore them instead of inviting them again." |
| + rateLimitedCopy = "Too many requests. Please wait a moment and try again." |
| ) |
| // The notices a successful mutation flashes. They are one-shot display |
| @@ -85,6 +86,14 @@ type InvitationPage struct { |
| Error string |
| SignedIn bool |
| Reconcile bool |
| + // Join says the app can start the invitee's sign-in from here |
| + // (HandlerConfig.Begin is set and the viewer holds no session): the |
| + // page shows one button, and pressing it sends the sign-in link to |
| + // the invited address. The address itself is deliberately NOT here |
| + // — a token in the wrong hands must not name its owner |
| + // (TestInvitationPageDoesNotDiscloseTheAddress); the person pressing |
| + // the button knows which inbox they opened the link in. |
| + Join bool |
| } |
| // HandlerConfig wires the flows to the app's own shell. Roster and |
| @@ -145,6 +154,27 @@ type HandlerConfig struct { |
| // entirely; NewHandlers logs a warning when it is nil. |
| Deliver func(r *http.Request, inv *Invitation, link string) error |
| + // Begin, when set, lets the invitee start their first sign-in from |
| + // the invitation page instead of being told to go and find one. |
| + // |
| + // An invitation link is a week-long secret that sits in an inbox, |
| + // gets forwarded and lands in logs, so following it must prove |
| + // nothing by itself. What it CAN do is start the app's own sign-in |
| + // for the invited address: POST /invitations/{token} from a visitor |
| + // with no session looks up the pending invitation and hands its |
| + // address to Begin, which sends the app's sign-in link (or keymail |
| + // ceremony) to that address and answers the request. Whoever holds |
| + // the inbox follows the link; admission on the way, through |
| + // Roster.Authorize, redeems the invitation. Whoever merely holds |
| + // the invitation link gets a page saying an email was sent — to an |
| + // inbox they cannot read — and the token stays pending, untouched. |
| + // |
| + // The hook owns the response: redirect, or render. Return an error |
| + // only when nothing was written. The app's own budget on sign-in |
| + // links bounds a visitor pressing the button over and over, on |
| + // top of RateLimit here. |
| + Begin func(w http.ResponseWriter, r *http.Request, address string) error |
| + |
| // RateLimit bounds the two public routes. See RateLimit — the |
| // zero value is the documented default, and there is no way to |
| // turn the limiter off. |
| @@ -455,6 +485,7 @@ func (h *Handlers) Invitation(w http.ResponseWriter, r *http.Request) { |
| Token: token, |
| SignedIn: signedIn, |
| Reconcile: reconcile, |
| + Join: h.cfg.Begin != nil && !signedIn, |
| }) |
| } |
| @@ -517,6 +548,22 @@ func (h *Handlers) Accept(w http.ResponseWriter, r *http.Request) { |
| subject, ok := rs.cfg.Subject(r) |
| subject = strings.TrimSpace(subject) |
| if !ok || subject == "" { |
| + if h.cfg.Begin != nil { |
| + // Not signed in, and the app can start that from here: the |
| + // hook sends its sign-in link to the invited address and |
| + // answers. The token is not touched — admission redeems it |
| + // when the link is followed, by whoever holds the inbox. |
| + inv, err := rs.pendingInvitation(ctx, token) |
| + if err != nil { |
| + h.deadInvitation(w, r, err, false) |
| + return |
| + } |
| + if err := h.cfg.Begin(w, r, inv.Email); err != nil { |
| + h.log().Error("idear: the app could not begin the invitee's sign-in", "err", err) |
| + h.failInvitation(w, r, token) |
| + } |
| + return |
| + } |
| // Not signed in. Reconciliation writes a member for the |
| // session in hand; there is no session in hand. The page says |
| // so rather than pretending the token is bad — and it says it |
| @@ -708,6 +755,8 @@ func (h *Handlers) refuse(w http.ResponseWriter, r *http.Request, viewer *Member |
| // the reason is true for every actor at every rank and the |
| // generic copy would imply somebody senior could do it. |
| h.page(w, r, viewer, http.StatusForbidden, lastOwnerCopy) |
| + case errors.Is(err, ErrRemovedInvitee): |
| + h.page(w, r, viewer, http.StatusBadRequest, removedInviteeCopy) |
| case errors.Is(err, ErrInvalid): |
| h.page(w, r, viewer, http.StatusBadRequest, invalidCopy) |
| case errors.Is(err, ErrForbidden): |
diff --git a/handlers_test.go b/handlers_test.go| index 96cae25..22fac2b 100644 |
| --- a/handlers_test.go |
| +++ b/handlers_test.go |
| @@ -952,9 +952,15 @@ func TestReconciliationRefusesASignedOutVisitor(t *testing.T) { |
| func TestReconciliationRefusesADeactivatedMember(t *testing.T) { |
| app, col := newApp(t) |
| - oc := app.As(app.H.Owner()) |
| - gone := app.H.Deactivated(idear.RoleAdmin) |
| + owner := app.H.Owner() |
| + oc := app.As(owner) |
| + // Invited while still a member (Invite refuses a removed one), |
| + // then removed: the invitation outlives the membership. |
| + gone := app.H.Member(idear.RoleAdmin) |
| token := invite(t, app, col, oc, gone.Email, idear.RoleMember) |
| + if err := app.H.Roster.Deactivate(app.H.Ctx(), owner, gone); err != nil { |
| + t.Fatalf("Deactivate: %v", err) |
| + } |
| c := app.As(gone) |
| if res := c.Post("/invitations/"+token, url.Values{}); res.Status != http.StatusNotFound { |
| @@ -1755,3 +1761,91 @@ func TestEveryHandlerIsMounted(t *testing.T) { |
| t.Fatalf("*Handlers has %d handler methods (%v) but Routes() mounts %d; an unmounted handler is invisible to every derived test in this file", handlers, names, got) |
| } |
| } |
| + |
| +// --------------------------------------------------------------- |
| +// The invitation page starts the invitee's sign-in. |
| +// --------------------------------------------------------------- |
| + |
| +// TestInvitationBeginsTheInviteesSignIn: with HandlerConfig.Begin set, |
| +// a visitor with no session who presses the button on a live |
| +// invitation has the app's sign-in link sent to the INVITED address — |
| +// not signed in, and the token untouched, so holding the invitation |
| +// link alone still opens nothing. |
| +func TestInvitationBeginsTheInviteesSignIn(t *testing.T) { |
| + col := &collector{} |
| + var began []string |
| + app := ideartest.NewAppWith(t, idear.Config{}, idear.HandlerConfig{ |
| + Deliver: col.deliver, |
| + Begin: func(w http.ResponseWriter, r *http.Request, address string) error { |
| + began = append(began, address) |
| + http.Redirect(w, r, "/invitations/sent", http.StatusSeeOther) |
| + return nil |
| + }, |
| + }) |
| + owner := app.H.Owner() |
| + const invitee = "new@example.test" |
| + token := invite(t, app, col, app.As(owner), invitee, idear.RoleMember) |
| + |
| + visitor := app.Visitor() |
| + page := visitor.Get("/invitations/" + token) |
| + if page.Status != http.StatusOK || !strings.Contains(page.Body, "join true") { |
| + t.Fatalf("the invitation page does not offer to begin: %d %q", page.Status, page.Body) |
| + } |
| + if strings.Contains(page.Body, invitee) { |
| + t.Fatalf("the invitation page names the invitee to whoever holds the token: %q", page.Body) |
| + } |
| + |
| + res := visitor.Post("/invitations/"+token, url.Values{}) |
| + if res.Status != http.StatusSeeOther || res.Location != "/invitations/sent" { |
| + t.Fatalf("Accept: %d -> %q, want the app's answer", res.Status, res.Location) |
| + } |
| + if len(began) != 1 || began[0] != invitee { |
| + t.Fatalf("the app was asked to begin for %v, want [%s]", began, invitee) |
| + } |
| + if memberBySubject(t, app, invitee) != nil { |
| + t.Fatal("pressing the button admitted the invitee; only the link they follow may") |
| + } |
| + // The token is untouched: the link can be asked for again, and the |
| + // invitation is redeemed when the invitee signs in, as always. |
| + if again := app.Visitor().Post("/invitations/"+token, url.Values{}); again.Status != http.StatusSeeOther { |
| + t.Fatalf("a second press answered %d", again.Status) |
| + } |
| + if !app.H.Roster.Authorize(invitee) { |
| + t.Fatal("the invitation could not be redeemed by the invitee's sign-in") |
| + } |
| + if memberBySubject(t, app, invitee) == nil { |
| + t.Fatal("admission did not write the member") |
| + } |
| +} |
| + |
| +// TestInvitationWithoutBeginHookStillAsksToSignInFirst pins the older |
| +// contract for apps that have not set the hook. |
| +func TestInvitationWithoutBeginHookStillAsksToSignInFirst(t *testing.T) { |
| + app, col := newApp(t) |
| + token := invite(t, app, col, app.As(app.H.Owner()), "new@example.test", idear.RoleMember) |
| + page := app.Visitor().Get("/invitations/" + token) |
| + if !strings.Contains(page.Body, "join false") { |
| + t.Fatalf("the page offers the button with no hook: %q", page.Body) |
| + } |
| + if res := app.Visitor().Post("/invitations/"+token, url.Values{}); res.Status != http.StatusForbidden { |
| + t.Fatalf("Accept with no hook: %d, want 403", res.Status) |
| + } |
| +} |
| + |
| +// TestInvitingARemovedMemberIsRefused: admission refuses a deactivated |
| +// member whatever invitation they hold, so an invitation to one is a |
| +// dead end; Invite refuses it up front and points at Restore. |
| +func TestInvitingARemovedMemberIsRefused(t *testing.T) { |
| + app, _ := newApp(t) |
| + gone := app.H.Deactivated(idear.RoleMember) |
| + oc := app.As(app.H.Owner()) |
| + before, _ := app.H.Roster.PendingInvitations(app.H.Ctx()) |
| + res := oc.Post("/members/invitations", url.Values{"email": {gone.Email}, "role": {string(idear.RoleMember)}}) |
| + if res.Status != http.StatusBadRequest || !strings.Contains(res.Body, "removed from the team") { |
| + t.Fatalf("inviting a removed member: %d %q", res.Status, res.Body) |
| + } |
| + after, _ := app.H.Roster.PendingInvitations(app.H.Ctx()) |
| + if len(after) != len(before) { |
| + t.Fatal("an invitation was created for a removed member") |
| + } |
| +} |
diff --git a/internal/ideartest/app.go b/internal/ideartest/app.go| index d9c20cf..cf81e79 100644 |
| --- a/internal/ideartest/app.go |
| +++ b/internal/ideartest/app.go |
| @@ -14,8 +14,8 @@ import ( |
| "testing" |
| "time" |
| - "github.com/carlosframework/rastrillo/csrf" |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/csrf" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "github.com/go-chi/chi/v5" |
| "amadan.net/rastrillo/idear" |
diff --git a/internal/ideartest/harness.go b/internal/ideartest/harness.go| index 0021a00..0101414 100644 |
| --- a/internal/ideartest/harness.go |
| +++ b/internal/ideartest/harness.go |
| @@ -17,9 +17,9 @@ import ( |
| "testing" |
| "time" |
| - "github.com/carlosframework/rastrillo/db" |
| - "github.com/carlosframework/rastrillo/migrate" |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "amadan.net/rastrillo/idear" |
| ) |
diff --git a/middleware_test.go b/middleware_test.go| index 9403fbc..e477116 100644 |
| --- a/middleware_test.go |
| +++ b/middleware_test.go |
| @@ -6,7 +6,7 @@ import ( |
| "net/http/httptest" |
| "testing" |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "amadan.net/rastrillo/idear" |
| "amadan.net/rastrillo/idear/internal/ideartest" |
diff --git a/roster.go b/roster.go| index 79efe21..a8b1671 100644 |
| --- a/roster.go |
| +++ b/roster.go |
| @@ -9,7 +9,7 @@ import ( |
| "strings" |
| "time" |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "gorm.io/gorm" |
| ) |
| @@ -434,6 +434,16 @@ func (rs *Roster) Invite(ctx context.Context, actor *Member, email string, role |
| if err := checkInviteRole(cur, role); err != nil { |
| return err |
| } |
| + // A removed member is restored, not re-invited: admission |
| + // would refuse the invitation anyway, and the invitee would |
| + // learn that only at the end of the link. |
| + var gone Member |
| + switch err := tx.Where("email = ?", email).First(&gone).Error; { |
| + case err == nil && !gone.Active(): |
| + return ErrRemovedInvitee |
| + case err != nil && !errors.Is(err, gorm.ErrRecordNotFound): |
| + return err |
| + } |
| // Supersede, in the SAME transaction as the create: a reader |
| // must never see two live invitations for one address, and a |
| // revocation that committed without its replacement would |
diff --git a/roster_test.go b/roster_test.go| index 81acd65..db5274e 100644 |
| --- a/roster_test.go |
| +++ b/roster_test.go |
| @@ -605,12 +605,16 @@ func TestAccept_ReturningMemberCollidesAndRollsBack(t *testing.T) { |
| h := ideartest.New(t) |
| ctx := h.Ctx() |
| owner := h.Owner() |
| - gone := h.Deactivated(idear.RoleMember) |
| - |
| + // Invited while still a member (Invite refuses a removed one), |
| + // then removed: the invitation outlives the membership. |
| + gone := h.Member(idear.RoleMember) |
| inv, token, err := h.Roster.Invite(ctx, owner, gone.Email, idear.RoleMember) |
| if err != nil { |
| t.Fatalf("Invite: %v", err) |
| } |
| + if err := h.Roster.Deactivate(ctx, owner, gone); err != nil { |
| + t.Fatalf("Deactivate: %v", err) |
| + } |
| if _, err := h.Roster.Accept(ctx, token, gone.Subject, gone.Name); err == nil { |
| t.Fatal("Accept re-admitted a deactivated subject; the unique index should have refused it") |
| } |
diff --git a/schema.go b/schema.go| index 7d0f99e..6e1f9e8 100644 |
| --- a/schema.go |
| +++ b/schema.go |
| @@ -3,7 +3,7 @@ package idear |
| import ( |
| "embed" |
| - "github.com/carlosframework/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| ) |
| //go:embed migrations |
diff --git a/schema_test.go b/schema_test.go| index a8705a7..90c86d9 100644 |
| --- a/schema_test.go |
| +++ b/schema_test.go |
| @@ -7,9 +7,9 @@ import ( |
| "testing" |
| "time" |
| - "github.com/carlosframework/rastrillo/db" |
| - "github.com/carlosframework/rastrillo/migrate" |
| - "github.com/carlosframework/rastrillo/sessions" |
| + "amadan.net/rastrillo/rastrillo/db" |
| + "amadan.net/rastrillo/rastrillo/migrate" |
| + "amadan.net/rastrillo/rastrillo/sessions" |
| "amadan.net/rastrillo/idear" |
| ) |