oficina / home Public

Add empty operator consumer pointers and verification history schema

Paul Campbell pushed by paul@keymail.dev 89aadfc8337f09a89c5ad6d3d6b02cc19d5c838c parent cd598f6
3 files changed, +57 −2
  • internal/home/directorydata/operator_migrations/migrations/0001_operator_bindings.sql +34 −0
  • internal/home/directorydata/operator_schema.go +21 −0
  • internal/home/directorydata/roster.go +2 −2

CI passed — run details

diff --git a/internal/home/directorydata/operator_migrations/migrations/0001_operator_bindings.sql b/internal/home/directorydata/operator_migrations/migrations/0001_operator_bindings.sql
new file mode 100644
index 0000000..5cec2d7
--- /dev/null
+++ b/internal/home/directorydata/operator_migrations/migrations/0001_operator_bindings.sql
@@ -0,0 +1,34 @@
+CREATE TABLE directory_operator_consumer_records (
+ consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),
+ operation_id TEXT NOT NULL UNIQUE REFERENCES roster_operator_receipts(operation_id),
+ sequence INTEGER NOT NULL CHECK(typeof(sequence)='integer' AND sequence>0),
+ generation TEXT NOT NULL CHECK(length(generation)=43),
+ record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32)
+);
+CREATE TABLE verification_attempts (
+ operation_id TEXT PRIMARY KEY CHECK(length(operation_id)=43),
+ consumer_id TEXT NOT NULL REFERENCES roster_consumers(consumer_id),
+ team_id INTEGER NOT NULL REFERENCES teams(id),
+ app TEXT NOT NULL,
+ origin TEXT NOT NULL,
+ generation TEXT NOT NULL CHECK(length(generation)=43),
+ record_sequence INTEGER NOT NULL CHECK(typeof(record_sequence)='integer' AND record_sequence>0),
+ record_digest BLOB NOT NULL CHECK(typeof(record_digest)='blob' AND length(record_digest)=32),
+ expected_epoch INTEGER NOT NULL CHECK(typeof(expected_epoch)='integer' AND expected_epoch>0),
+ owner_ref TEXT NOT NULL CHECK(length(owner_ref)=29),
+ issued_at INTEGER NOT NULL CHECK(typeof(issued_at)='integer' AND issued_at>0),
+ expires_at INTEGER NOT NULL CHECK(typeof(expires_at)='integer' AND expires_at>issued_at AND expires_at-issued_at<=600),
+ authorization_digest BLOB NOT NULL CHECK(typeof(authorization_digest)='blob' AND length(authorization_digest)=32)
+);
+CREATE TABLE verification_active (
+ consumer_id TEXT PRIMARY KEY REFERENCES roster_consumers(consumer_id),
+ operation_id TEXT NOT NULL UNIQUE REFERENCES verification_attempts(operation_id)
+);
+CREATE TABLE verification_outcomes (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ attempt_id TEXT NOT NULL REFERENCES verification_attempts(operation_id),
+ operator_operation_id TEXT NOT NULL CHECK(length(operator_operation_id)=43),
+ kind TEXT NOT NULL CHECK(kind IN ('opened','expired','activated')),
+ completed_at INTEGER NOT NULL CHECK(typeof(completed_at)='integer' AND completed_at>0),
+ UNIQUE(attempt_id,operator_operation_id,kind)
+);
diff --git a/internal/home/directorydata/operator_schema.go b/internal/home/directorydata/operator_schema.go
new file mode 100644
index 0000000..357ecae
--- /dev/null
+++ b/internal/home/directorydata/operator_schema.go
@@ -0,0 +1,21 @@
+package directorydata
+
+import (
+ "embed"
+ "io/fs"
+
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+//go:embed operator_migrations
+var operatorMigrations embed.FS
+
+// OperatorSchema adds empty provisioning pointers and verification history.
+// No migration creates authority or opens a pending consumer window.
+var OperatorSchema = func() *migrate.Set {
+ f, e := fs.Sub(operatorMigrations, "operator_migrations")
+ if e != nil {
+ panic(e)
+ }
+ return migrate.MustFromFS(f, "oficina_directory_operator_bindings")
+}()
diff --git a/internal/home/directorydata/roster.go b/internal/home/directorydata/roster.go
index 317cd2c..2023d7f 100644
--- a/internal/home/directorydata/roster.go
+++ b/internal/home/directorydata/roster.go
@@ -42,7 +42,7 @@ type RosterSource struct {
// NewRosterSource creates a read-only source. The caller authenticates operator
// provisioning separately; this constructor never initializes authority.
func NewRosterSource(db *sql.DB, sealer Sealer, cfg Config, directory string) (*RosterSource, error) {
- if !validOrigin(directory) {
+ if !ValidOrigin(directory) {
return nil, ErrInvalid
}
s, err := New(db, sealer, cfg)
@@ -107,7 +107,7 @@ func (s *RosterSource) Read(ctx context.Context, req RosterRequest, now time.Tim
if req.Team != route.TeamSQID {
return fail(404, ErrRosterNotFound)
}
- if state != "live" || !s.store.apps[route.App] || !validOrigin(route.Origin) || !validConsumer(route.ConsumerID) || !validLabel(route.Label) || !s.store.validateRoute(route) {
+ if state != "live" || !s.store.apps[route.App] || !ValidOrigin(route.Origin) || !validConsumer(route.ConsumerID) || !validLabel(route.Label) || !s.store.validateRoute(route) {
return fail(503, ErrUnavailable)
}
p := roster.Page{Version: 1, Type: "page", Directory: s.directory, Consumer: route.ConsumerID, Team: route.TeamSQID, Audience: route.Origin, Fetch: req.Fetch, RequestCursorHash: roster.CursorHash(req.Cursor)}