rastrillo / idear Public

Merge branch 'invitation-signs-in' into main

claude agent, operated by james@keymail.dev 78f5829dfee7b6d304a4caad5043d9d00a694600 parent 31faf7aparent 3ba05df
24 files changed, +253 −65
  • .gitignore +6 −0
  • .superpowers/sdd/2026-08-23-idear/progress.md +2 −2
  • SKILL.md +6 −6
  • admit.go +1 −1
  • admit_test.go +2 −2
  • docs/superpowers/plans/2026-08-23-idear.md +2 −2
  • errors.go +14 −1
  • example/app.go +5 −5
  • example/app_test.go +3 −3
  • example/handlers.go +1 −1
  • example/main.go +2 −2
  • example/models.go +3 −3
  • example/render.go +3 −3
  • go.mod +1 −1
  • go.sum +22 −10
  • handlers.go +57 −8
  • handlers_test.go +96 −2
  • internal/ideartest/app.go +2 −2
  • internal/ideartest/harness.go +3 −3
  • middleware_test.go +1 −1
  • roster.go +11 −1
  • roster_test.go +6 −2
  • schema.go +1 −1
  • schema_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"
)