Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
fea7348
feat(api): add ZLA models, PASession cache, and preauth config
MaciejTe Apr 8, 2026
a46a977
feat(api): implement ZLA PASession validation and registration flow
MaciejTe Apr 8, 2026
caff7ae
feat(api): add PASession endpoints, remove /subscription/add
MaciejTe Apr 8, 2026
5111c5f
feat(app): support ZLA signup flow
MaciejTe Apr 8, 2026
bf38085
chore: Improve Makefile
MaciejTe Apr 8, 2026
a1ecf99
chore(test): Update Python modDNS API client
MaciejTe Apr 8, 2026
d838d65
chore(api): Update subscription unit test
MaciejTe Apr 8, 2026
193f4d7
chore(app): Deprecate signup/:subid route
MaciejTe Apr 8, 2026
54c7847
test(e2e): Update E2E tests with updated sign up flow
MaciejTe Apr 8, 2026
1485178
chore(api): Format files
MaciejTe Apr 8, 2026
361afa4
feat(api): add subscription sync endpoint, expiry cron, status lifecycle
MaciejTe Apr 9, 2026
e7847cd
docs(api): Update swagger docs
MaciejTe Apr 9, 2026
c968e1c
feat(app): subscription status display with resync and lifecycle alerts
MaciejTe Apr 9, 2026
29a1e38
chore(api): Change service catalog log level
MaciejTe Apr 10, 2026
f582843
chore(api): Setup API endpoints as expected
MaciejTe Apr 10, 2026
2708221
fix(api): Fix incorrect webhook log logic
MaciejTe Apr 10, 2026
3a2ba19
fix(app): Avoid multiple sign up requests
MaciejTe Apr 10, 2026
c2262e0
feat(libs): register UUID BSON codec for subtype 0x04
MaciejTe Apr 27, 2026
f81bbdd
refactor(api): remove dead GetSubscriptionById
MaciejTe Apr 27, 2026
994e8e1
feat(api): run subscription UUID subtype migration on startup
MaciejTe Apr 27, 2026
d6a541b
chore(blocklists): Update go.mod and go.sum after libs/ package changes
MaciejTe Apr 27, 2026
f9da419
chore(app): Add VITE_RESYNC_URL values
MaciejTe Apr 29, 2026
9c11ced
chore(api): Simplify Update subscription endpoint
MaciejTe Apr 29, 2026
3d1c2a7
chore(app): Adjust AccountSubscription component to API changes
MaciejTe Apr 29, 2026
340a1a5
docs(tests): Update Python client
MaciejTe Apr 29, 2026
ad9a96b
chore(app): Add success message after sync
MaciejTe Apr 29, 2026
b0fa7d7
fix(api): Send proper subid value in passkey registration finish
MaciejTe Apr 29, 2026
e72a760
feat(api): Subscription Guard mechanism
MaciejTe Apr 30, 2026
ad94309
feat(api): pending-delete subscription lifecycle
MaciejTe May 4, 2026
2e45308
chore(api): allow GET /webauthn/passkeys in any subscription state
MaciejTe May 4, 2026
3886951
chore(api): regenerate mocks
MaciejTe May 4, 2026
c16c94b
feat(app): subscription guard infrastructure
MaciejTe May 4, 2026
1f2e114
feat(app): gate mutations across pages in Limited Access states
MaciejTe May 4, 2026
4a09d92
fix(app): Bring back proper vertical spacing on custom rules page
MaciejTe May 5, 2026
4acc094
chore(app): Remove banner from login page
MaciejTe May 5, 2026
d20f86b
chore(app): Remove Last synced field from Subscription card
MaciejTe May 5, 2026
ee45008
chore(ci): Temporarily disable goconst linter
MaciejTe May 5, 2026
78a1bea
feat(api): Update subscription expiration logic
MaciejTe May 5, 2026
db4b13d
chore(app): Hide Active until field in Limited Access and Pending
MaciejTe May 5, 2026
6bcfada
feat(api): Send LA and PD emails after IVPN account is deleted
MaciejTe May 5, 2026
03fb47a
fix(api): Add fallback mechanism in /api/v1/pasession/rotate endpoint
MaciejTe May 6, 2026
55a3732
fix(api): Improve signup UX - make welcome email dispatch failure
MaciejTe May 6, 2026
8554670
feat(api): Redis-based gocron/v2 locker
MaciejTe May 6, 2026
0b6fa24
fix(app): Make preferences dialog unaccessible in LA state
MaciejTe May 6, 2026
70dc604
fix(app): Display account data as expected in PD state
MaciejTe May 6, 2026
9c173ce
chore(api): Add necessary subsription fields in migration
MaciejTe May 6, 2026
68f87ca
Merge pull request #105 from ivpn/feat/zla-auth-integration
MaciejTe May 7, 2026
93700a3
chore(app): self-host VT323 + IBM Plex Mono fonts
MaciejTe May 4, 2026
cae2177
feat(app): add public marketing landing page at /
MaciejTe May 4, 2026
f69eebf
test(app): align root-redirect tests with landing page mount
MaciejTe May 4, 2026
6143fba
test(app): Adjust tests to landing page
MaciejTe May 5, 2026
712ad1d
feat(app): Display dashboard button instead of login when user is
MaciejTe May 5, 2026
83a9296
chore(app): Change login to dashboard button in footer
MaciejTe May 5, 2026
3b47fb2
feat(app): Mobile view support
MaciejTe May 6, 2026
82d8d2c
chore(app): Improve text in input field
MaciejTe May 6, 2026
a64107e
Merge pull request #114 from ivpn/feat/landing-page
MaciejTe May 7, 2026
2f32af5
Merge tag 'v0.1.7' into develop
MaciejTe May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .github/golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ linters:
default: none
enable:
- errcheck
- goconst
# - goconst # temporarily disabled — golangci-lint v1.12.1 surfaced ~50 new findings; tracked for cleanup in a separate PR.
- gocritic
- gocyclo
- gosec
Expand Down
10 changes: 4 additions & 6 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,12 @@ dev_check: ## Starts the development dnscheck service.
docker exec -it dnscheck make gow

gen_python_client: ## Generates the python client from swagger spec (renamed to moddns_client, package moddns).
sudo rm -r tests/moddns_client/ || true
docker run -v ${CWD}:/app -w /app/api/docs --rm -it openapitools/openapi-generator-cli generate --package-name moddns -i swagger.yaml -g python -o /app/tests/moddns_client --skip-validate-spec
sudo chmod -R 777 tests/moddns_client/
rm -rf tests/moddns_client/ || true
docker run -v ${CWD}:/app -w /app/api/docs --user $$(id -u):$$(id -g) --rm openapitools/openapi-generator-cli generate --package-name moddns -i swagger.yaml -g python -o /app/tests/moddns_client --skip-validate-spec

gen_ts_client: ## Generates the typescript client from swagger spec.
sudo rm -r app/src/api/client/ || true
docker run -v ${CWD}:/app -w /app/api/docs --rm -it openapitools/openapi-generator-cli generate --package-name idns -i swagger.yaml -g typescript-axios -o /app/app/src/api/client --skip-validate-spec
sudo chmod -R 777 app/src/api/client/
rm -rf app/src/api/client/ || true
docker run -v ${CWD}:/app -w /app/api/docs --user $$(id -u):$$(id -g) --rm openapitools/openapi-generator-cli generate --package-name idns -i swagger.yaml -g typescript-axios -o /app/app/src/api/client --skip-validate-spec

build_tests_image: ## Builds the smoke / integration tests image.
docker build -f tests/Dockerfile -t dns_tests:latest .
Expand Down
5 changes: 5 additions & 0 deletions api/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ API_SIGNUP_WEBHOOK_URL=""
API_SIGNUP_WEBHOOK_PSK=""
PROFILE_ID_MIN_LENGTH=10

### ZLA PRE-AUTH CONFIG
PREAUTH_URL=
PREAUTH_PSK=
PREAUTH_TTL=60m

### DB CONFIG
DB_URI="mongodb://mongodb:27017"
DB_NAME="dns"
Expand Down
3 changes: 2 additions & 1 deletion api/api/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ func (s *APIServer) registerAccount() fiber.Handler {
return HandleError(c, ErrValidationFailed, "validation failed", tags...)
}

_, err := s.Service.GetUnfinishedSignupOrPostAccount(c.Context(), p.Email, p.Password, p.SubID)
sessionID := c.Cookies(PASessionCookie)
_, err := s.Service.GetUnfinishedSignupOrPostAccount(c.Context(), p.Email, p.Password, p.SubID, sessionID)
if err != nil {
// Map specific service errors to unified user-facing failure
if _, ok := err.(*account.ServiceAccountError); ok && err == account.ErrUnableToCreateAccount {
Expand Down
10 changes: 5 additions & 5 deletions api/api/accounts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_SuccessNewAccount() {

// Mock service returning newly created finished account
acc := &model.Account{ID: primitive.NewObjectID(), Email: email, Password: &password}
suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(acc, nil)
suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(acc, nil)

resp, err := suite.doRegister(email, password, subID)
suite.Require().NoError(err)
Expand All @@ -814,7 +814,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_SuccessUnfinishedReuse()

// Unfinished account simulated by nil Password in returned account (service after reuse sets password internally)
acc := &model.Account{ID: primitive.NewObjectID(), Email: email, Password: nil}
suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(acc, nil)
suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(acc, nil)

resp, err := suite.doRegister(email, password, subID)
suite.Require().NoError(err)
Expand All @@ -830,7 +830,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_ErrorCacheMissing() {
password := "StrongPass123!"
subID := "550e8400-e29b-41d4-a716-446655440002"

suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(nil, account.ErrUnableToCreateAccount)
suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(nil, account.ErrUnableToCreateAccount)

resp, err := suite.doRegister(email, password, subID)
suite.Require().NoError(err)
Expand All @@ -846,7 +846,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_ErrorSubscriptionDuplicat
password := "StrongPass123!"
subID := "550e8400-e29b-41d4-a716-446655440003"

suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(nil, account.ErrUnableToCreateAccount)
suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(nil, account.ErrUnableToCreateAccount)

resp, err := suite.doRegister(email, password, subID)
suite.Require().NoError(err)
Expand All @@ -862,7 +862,7 @@ func (suite *AccountsAPITestSuite) TestRegisterAccount_ErrorFinishedAccountReuse
password := "StrongPass123!"
subID := "550e8400-e29b-41d4-a716-446655440004"

suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID).Return(nil, account.ErrUnableToCreateAccount)
suite.mockService.On("GetUnfinishedSignupOrPostAccount", mock.Anything, email, password, subID, mock.Anything).Return(nil, account.ErrUnableToCreateAccount)

resp, err := suite.doRegister(email, password, subID)
suite.Require().NoError(err)
Expand Down
2 changes: 1 addition & 1 deletion api/api/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func (s *APIServer) login() fiber.Handler {
})
}

err = s.Service.SaveSession(c.Context(), sessionData, token, acc.ID.Hex(), "")
err = s.Service.SaveSession(c.Context(), sessionData, token, acc.ID.Hex(), "", "")
if err != nil {
return c.Status(400).JSON(fiber.Map{
"error": ErrSaveSession,
Expand Down
108 changes: 108 additions & 0 deletions api/api/pasession.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package api

import (
"strings"
"time"

"github.com/gofiber/fiber/v2"
"github.com/ivpn/dns/api/api/requests"
"github.com/ivpn/dns/api/model"
)

// PASessionCookie is the cookie name for the pre-auth session ID.
const PASessionCookie = "pa_session"

// @Summary Add pre-auth session
// @Description Add a pre-auth session to cache (called by preauth service)
// @Tags PASession
// @Accept json
// @Produce json
// @Security ApiKeyAuth
// @Param body body requests.PASessionReq true "Pre-auth session request"
// @Success 200 {object} fiber.Map
// @Failure 400 {object} ErrResponse
// @Failure 401 {object} ErrResponse
// @Router /api/v1/pasession/add [post]
func (s *APIServer) addPASession() fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(requests.PASessionReq)
if err := c.BodyParser(req); err != nil {
return HandleError(c, err, ErrInvalidRequestBody.Error())
}

errMsgs := s.Validator.ValidateRequest(c, req, ErrInvalidRequestBody.Error())
if len(errMsgs) > 0 {
return HandleError(c, ErrInvalidRequestBody, strings.Join(errMsgs, " and "))
}

session := &model.PASession{
ID: req.ID,
Token: req.Token,
PreauthID: req.PreauthID,
}

if err := s.Service.AddPASession(c.Context(), session); err != nil {
return HandleError(c, err, "failed to add pre-auth session")
}

return c.Status(200).JSON(fiber.Map{"message": "pre-auth session added"})
}
}

// @Summary Rotate pre-auth session ID
// @Description Rotate pre-auth session ID and set new ID as cookie. The endpoint
// @Description is idempotent against an already-rotated session: if the URL
// @Description sessionid is no longer in the cache but the caller already holds
// @Description a valid pa_session cookie, the call succeeds as a no-op so the
// @Description user can continue with their existing session.
// @Tags PASession
// @Accept json
// @Produce json
// @Param body body requests.RotatePASessionReq true "Rotate pre-auth session request"
// @Success 200
// @Failure 400 {object} ErrResponse
// @Router /api/v1/pasession/rotate [put]
func (s *APIServer) rotatePASession() fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(requests.RotatePASessionReq)
if err := c.BodyParser(req); err != nil {
return c.Status(400).JSON(fiber.Map{"error": "This signup link has expired."})
}

errMsgs := s.Validator.ValidateRequest(c, req, "")
if len(errMsgs) > 0 {
return c.Status(400).JSON(fiber.Map{"error": "This signup link has expired."})
}

if newID, err := s.Service.RotatePASessionID(c.Context(), req.SessionID); err == nil {
setPASessionCookie(c, newID)
return c.SendStatus(fiber.StatusOK)
}

// Fallback: the URL sessionid was already consumed (typical when the
// signup link is opened a second time in the same browser). If the
// existing pa_session cookie still points to a valid cache entry, the
// caller can continue with what they have — no rotate needed.
if existing := c.Cookies(PASessionCookie); existing != "" {
if _, err := s.Service.ValidateAndGetPreauth(c.Context(), existing); err == nil {
return c.SendStatus(fiber.StatusOK)
}
}

return c.Status(400).JSON(fiber.Map{"error": "This signup link has expired."})
}
}

// setPASessionCookie writes the pa_session cookie used by subsequent /accounts
// and /sub/update calls during the signup / resync flow.
func setPASessionCookie(c *fiber.Ctx, sessionID string) {
c.Cookie(&fiber.Cookie{
Name: PASessionCookie,
Value: sessionID,
HTTPOnly: true,
Secure: true,
SameSite: fiber.CookieSameSiteLaxMode,
MaxAge: 900,
Expires: time.Now().Add(15 * time.Minute),
})
}
Loading
Loading