Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 20 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.2.0]

### Added

- Producer-side `New…` constructors for every non-deprecated event type
(`NewAccountDisabled`, `NewCredentialCompromise`, …). The required
subject is a positional argument, so an event can be built in a single
expression and "forgot the subject" is a compile error rather than a
runtime `AddTo` failure. `NewCredentialCompromise` also takes its
required `credential_type` positionally. The deprecated
`sessions-revoked` event has no constructor by design.

### Changed

- `ExampleAddTo` and the README now build events with the constructors
instead of declaring a value and assigning `Subject` separately. No
wire-shape or decode-path change.

## [0.1.0]

Initial release: the OpenID RISC Profile 1.0 event vocabulary.
Expand All @@ -29,5 +47,6 @@ Initial release: the OpenID RISC Profile 1.0 event vocabulary.
- Byte-stable preservation of unrecognized event-payload members via
each event's `Extra` map.

[Unreleased]: https://github.com/hstern/go-risc/compare/v0.1.0...HEAD
[Unreleased]: https://github.com/hstern/go-risc/compare/v0.2.0...HEAD
[0.2.0]: https://github.com/hstern/go-risc/compare/v0.1.0...v0.2.0
[0.1.0]: https://github.com/hstern/go-risc/releases/tag/v0.1.0
16 changes: 12 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,21 +76,29 @@ An event-type URI this build does not import stays raw in

## Build a RISC event into a SET

The `subject` is carried on every RISC event; set it by assignment,
then `AddTo` validates and stores the event under its URI.
Each event type has a `New…` constructor that takes the required
`subject` (and any other required field) positionally; optional members
are set on the returned value. `AddTo` then validates and stores the
event under its URI.

```go
events := secevent.Events{}

e := risc.AccountDisabled{Reason: "hijacking"}
e.Subject = subjectid.IssSubID{Iss: "https://idp.example.com/", Sub: "user-7f3e2a"}
e := risc.NewAccountDisabled(subjectid.IssSubID{Iss: "https://idp.example.com/", Sub: "user-7f3e2a"})
e.Reason = "hijacking"

if err := risc.AddTo(events, e); err != nil {
return err // e.g. a credential-compromise missing its credential_type
}
// events now carries the account-disabled member, ready for a *secevent.SET.
```

`credential-compromise` carries a second required field, so its
constructor takes it positionally:
`risc.NewCredentialCompromise(subject, "password")`. The deprecated
`sessions-revoked` event has no constructor by design; prefer the CAEP
`session-revoked` event.

`AddTo` applies the library's strict-marshal contract: it calls
`Validate` first and refuses to emit a half-built event.

Expand Down
98 changes: 98 additions & 0 deletions construct.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
// Copyright 2026 The go-risc Authors
// SPDX-License-Identifier: Apache-2.0

package risc

import "github.com/hstern/go-subjectid"

// This file provides the producer-side constructors for the RISC event types.
// Each event embeds the unexported subjectEvent, so a consumer cannot set the
// required subject in a composite literal; the New… constructors take the
// subject (and any other required field) positionally, making "forgot the
// subject" a compile error rather than a runtime AddTo failure.
//
// Optional members (AccountDisabled.Reason, IdentifierChanged.NewValue,
// CredentialCompromise.ReasonAdmin, …) are exported fields set on the returned
// value. Construction is lenient: the constructors do not validate the subject
// format (identifier-changed/-recycled require email or phone_number); that
// check stays at the marshal boundary in AddTo/Validate (Postel's law).
//
// The deprecated sessions-revoked event has no constructor by design, so the
// API does not encourage emitting it; prefer the CAEP session-revoked event.

// NewAccountCredentialChangeRequired builds an account-credential-change-required
// event for subject.
func NewAccountCredentialChangeRequired(subject subjectid.SubjectIdentifier) AccountCredentialChangeRequired {
return AccountCredentialChangeRequired{subjectEvent{Subject: subject}}
}

// NewAccountPurged builds an account-purged event for subject.
func NewAccountPurged(subject subjectid.SubjectIdentifier) AccountPurged {
return AccountPurged{subjectEvent{Subject: subject}}
}

// NewAccountDisabled builds an account-disabled event for subject. Set the
// optional Reason on the returned value.
func NewAccountDisabled(subject subjectid.SubjectIdentifier) AccountDisabled {
return AccountDisabled{subjectEvent: subjectEvent{Subject: subject}}
}

// NewAccountEnabled builds an account-enabled event for subject.
func NewAccountEnabled(subject subjectid.SubjectIdentifier) AccountEnabled {
return AccountEnabled{subjectEvent{Subject: subject}}
}

// NewIdentifierChanged builds an identifier-changed event for subject, which
// names the old identifier and must be in email or phone_number format. Set
// the optional NewValue on the returned value.
func NewIdentifierChanged(subject subjectid.SubjectIdentifier) IdentifierChanged {
return IdentifierChanged{subjectEvent: subjectEvent{Subject: subject}}
}

// NewIdentifierRecycled builds an identifier-recycled event for subject, which
// must be in email or phone_number format.
func NewIdentifierRecycled(subject subjectid.SubjectIdentifier) IdentifierRecycled {
return IdentifierRecycled{subjectEvent{Subject: subject}}
}

// NewCredentialCompromise builds a credential-compromise event for subject.
// credentialType is required (the CAEP credential-type vocabulary, e.g.
// "password", "pin", "x509"). Set the optional EventTimestamp, ReasonAdmin,
// and ReasonUser on the returned value.
func NewCredentialCompromise(subject subjectid.SubjectIdentifier, credentialType string) CredentialCompromise {
return CredentialCompromise{
subjectEvent: subjectEvent{Subject: subject},
CredentialType: credentialType,
}
}

// NewOptIn builds an opt-in event for subject.
func NewOptIn(subject subjectid.SubjectIdentifier) OptIn {
return OptIn{subjectEvent{Subject: subject}}
}

// NewOptOutInitiated builds an opt-out-initiated event for subject.
func NewOptOutInitiated(subject subjectid.SubjectIdentifier) OptOutInitiated {
return OptOutInitiated{subjectEvent{Subject: subject}}
}

// NewOptOutCancelled builds an opt-out-cancelled event for subject.
func NewOptOutCancelled(subject subjectid.SubjectIdentifier) OptOutCancelled {
return OptOutCancelled{subjectEvent{Subject: subject}}
}

// NewOptOutEffective builds an opt-out-effective event for subject.
func NewOptOutEffective(subject subjectid.SubjectIdentifier) OptOutEffective {
return OptOutEffective{subjectEvent{Subject: subject}}
}

// NewRecoveryActivated builds a recovery-activated event for subject.
func NewRecoveryActivated(subject subjectid.SubjectIdentifier) RecoveryActivated {
return RecoveryActivated{subjectEvent{Subject: subject}}
}

// NewRecoveryInformationChanged builds a recovery-information-changed event for
// subject.
func NewRecoveryInformationChanged(subject subjectid.SubjectIdentifier) RecoveryInformationChanged {
return RecoveryInformationChanged{subjectEvent{Subject: subject}}
}
122 changes: 122 additions & 0 deletions construct_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
// Copyright 2026 The go-risc Authors
// SPDX-License-Identifier: Apache-2.0

package risc_test

import (
"encoding/json"
"testing"

risc "github.com/hstern/go-risc"
secevent "github.com/hstern/go-secevent"
subjectid "github.com/hstern/go-subjectid"
)

// Concrete subject fixtures for the producer-side constructors. The identifier
// events require an email or phone_number subject (see validate.go).
var (
ctorIssSub = subjectid.IssSubID{Iss: "https://idp.example.com/", Sub: "user-7f3e2a"}
ctorEmail = subjectid.EmailID{Email: "user@example.com"}
ctorPhone = subjectid.PhoneNumberID{PhoneNumber: "+12065550100"}
)

// constructorCase pairs an event built through its New… constructor with the
// URI it must carry. The deprecated sessions-revoked event has no constructor
// and so does not appear here.
type constructorCase struct {
name string
ev secevent.Event
}

func constructorCases() []constructorCase {
return []constructorCase{
{"account-credential-change-required", risc.NewAccountCredentialChangeRequired(ctorIssSub)},
{"account-purged", risc.NewAccountPurged(ctorIssSub)},
{"account-disabled", risc.NewAccountDisabled(ctorIssSub)},
{"account-enabled", risc.NewAccountEnabled(ctorIssSub)},
{"identifier-changed", risc.NewIdentifierChanged(ctorEmail)},
{"identifier-recycled", risc.NewIdentifierRecycled(ctorPhone)},
{"credential-compromise", risc.NewCredentialCompromise(ctorIssSub, "password")},
{"opt-in", risc.NewOptIn(ctorIssSub)},
{"opt-out-initiated", risc.NewOptOutInitiated(ctorIssSub)},
{"opt-out-cancelled", risc.NewOptOutCancelled(ctorIssSub)},
{"opt-out-effective", risc.NewOptOutEffective(ctorIssSub)},
{"recovery-activated", risc.NewRecoveryActivated(ctorIssSub)},
{"recovery-information-changed", risc.NewRecoveryInformationChanged(ctorIssSub)},
}
}

// TestConstructorCoverage guards that every non-deprecated event type has a
// constructor case, and that the deprecated sessions-revoked event does not —
// so adding an event type without a constructor (or adding a constructor for
// sessions-revoked) fails here.
func TestConstructorCoverage(t *testing.T) {
covered := map[string]bool{}
for _, c := range constructorCases() {
covered[c.ev.EventTypeURI()] = true
}
for _, uri := range allEventURIs {
if uri == risc.SessionsRevokedURI {
if covered[uri] {
t.Errorf("deprecated %s should have no constructor", uri)
}
continue
}
if !covered[uri] {
t.Errorf("event type %s has no constructor", uri)
}
}
}

// TestConstructorRoundTrip is the consumer-side acceptance check: build each
// event through its constructor, place it in a SET with AddTo, parse the SET
// back, and assert the decoded event is equal to the one constructed.
//
// Equality is checked on the marshaled wire form (jsonEqual), matching the
// repo's TestRoundTrip: the decode path canonicalizes a subject to a pointer
// (*subjectid.IssSubID) where a constructor may hold a value, so the Go values
// differ representationally while the events are identical on the wire — which
// is the round-trip contract this library makes.
func TestConstructorRoundTrip(t *testing.T) {
for _, c := range constructorCases() {
t.Run(c.name, func(t *testing.T) {
events := secevent.Events{}
if err := risc.AddTo(events, c.ev); err != nil {
t.Fatalf("AddTo: %v", err)
}
raw, ok := events[c.ev.EventTypeURI()]
if !ok {
t.Fatalf("AddTo did not store event under %s", c.ev.EventTypeURI())
}
back := typedEvent(t, c.ev.EventTypeURI(), string(raw))
out, err := json.Marshal(back)
if err != nil {
t.Fatalf("Marshal decoded: %v", err)
}
if !jsonEqual(t, out, raw) {
t.Errorf("round-trip mismatch\n got: %s\nwant: %s", out, raw)
}
})
}
}

// TestConstructorSetsSubject confirms the required subject is carried on the
// returned value — the footgun the constructors exist to remove.
func TestConstructorSetsSubject(t *testing.T) {
e := risc.NewAccountDisabled(ctorIssSub)
if e.Subject != ctorIssSub {
t.Errorf("Subject = %#v, want %#v", e.Subject, ctorIssSub)
}
}

// TestNewCredentialCompromiseSetsRequiredFields confirms credential-compromise
// takes its second required field (credential_type) positionally.
func TestNewCredentialCompromiseSetsRequiredFields(t *testing.T) {
e := risc.NewCredentialCompromise(ctorIssSub, "password")
if e.Subject != ctorIssSub {
t.Errorf("Subject = %#v, want %#v", e.Subject, ctorIssSub)
}
if e.CredentialType != "password" {
t.Errorf("CredentialType = %q, want password", e.CredentialType)
}
}
12 changes: 7 additions & 5 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import (
)

// Example decodes a SET carrying a RISC account-disabled event into its typed
// Go value. The blank import of go-risc (see the package documentation) wires
// every RISC decoder into go-secevent's registry.
// Go value. Importing go-risc (here by name, for its URI constants; a blank
// import works when only the side effect is wanted) registers every RISC
// decoder into go-secevent's registry via the package init.
func Example() {
payload := []byte(`{
"iss": "https://idp.example.com/",
Expand Down Expand Up @@ -43,12 +44,13 @@ func Example() {
}

// ExampleAddTo builds a RISC event and places it in a SET's events claim. The
// subject is set by assignment because it is carried on every RISC event.
// constructor takes the required subject positionally; optional members such
// as Reason are set on the returned value.
func ExampleAddTo() {
events := secevent.Events{}

e := risc.AccountDisabled{Reason: "bulk-account"}
e.Subject = subjectid.IssSubID{Iss: "https://idp.example.com/", Sub: "user-7f3e2a"}
e := risc.NewAccountDisabled(subjectid.IssSubID{Iss: "https://idp.example.com/", Sub: "user-7f3e2a"})
e.Reason = "bulk-account"

if err := risc.AddTo(events, e); err != nil {
panic(err)
Expand Down
Loading