The provisioning queue, and the label reservation it holds
Spec §13 step 5. A signup writes a request here; a drain turns it into an instance. Until now the directory could describe teams it already had and had no way to be asked for a new one. The label reservation is not a column in this table — it is the teams.label UNIQUE constraint, taken in the SAME transaction that queues the requests (ruling 19: claiming a label takes it across every app, so only Acme can later create acme.sheets). A reservation that committed without its requests would hold a name for a team nobody asked for, which is why CreateTeam is one transaction and TestCreateTeamIsAtomic checks that a refused request leaves no teams row behind. That makes §7's consequence directly testable: "a queue entry that is purged or times out must release its label, or a request that never became a team holds a name nobody can claim." Sweep deletes the expired request, then the team and memberships it stranded — and never touches a team with a live instance, however stale its other requests are. The rate limit is per requester and per window, keyed on a hash rather than on the person_ref, so the queue does not become a second place identifiers accumulate. directorydata keeps its own copy of the sqids codec because operator already imports this package and importing back would be a cycle; TestTeamSQIDMatchesOperator pins the two together from an external test package so the duplication cannot drift into a team with two identifiers. Two things the existing tests caught, kept here because they were right: CreateTeam now rejects a payload below the sealed floor rather than letting a CHECK turn a caller's mistake into an opaque unavailability, and the schema's comments stay outside CREATE TABLE — sqlite_master stores that text verbatim and store_test.go forbids a schema that names what it holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 files changed,
+547
−0
internal/home/directorydata/migrations/0002_provisioning.sql+38 −0internal/home/directorydata/provision.go+255 −0internal/home/directorydata/provision_test.go+160 −0internal/home/directorydata/provisionids.go+61 −0internal/home/directorydata/provisionids_pin_test.go+33 −0
CI failed — run details
diff --git a/internal/home/directorydata/migrations/0002_provisioning.sql b/internal/home/directorydata/migrations/0002_provisioning.sql| new file mode 100644 |
| index 0000000..5b93d7f |
| --- /dev/null |
| +++ b/internal/home/directorydata/migrations/0002_provisioning.sql |
| @@ -0,0 +1,38 @@ |
| +-- The provisioning queue (§7). A signup writes a request here; a drain |
| +-- turns it into an instance. The row is the durable record of a request |
| +-- that has not become a team yet. |
| +-- |
| +-- The LABEL RESERVATION is not in this table: it is the teams.label |
| +-- UNIQUE constraint, taken in the same transaction that writes these |
| +-- rows (ruling 19 — claiming `acme` takes it across every app, so only |
| +-- Acme can later create acme.sheets). That is why a queued request owns |
| +-- a real teams row rather than a name held to one side. |
| +-- |
| +-- Which means §7's consequence applies directly: "a queue entry that is |
| +-- purged or times out must release its label, or a request that never |
| +-- became a team holds a name nobody can claim." expires_at is what makes |
| +-- that sweepable, and ON DELETE CASCADE is what makes dropping the team |
| +-- drop its requests with it. |
| +-- The payload is opaque to the home (§3.2): what the new instance needs to |
| +-- start travels sealed and is never judged here. This note sits OUTSIDE the |
| +-- statement deliberately — sqlite_master stores CREATE TABLE text verbatim, |
| +-- and store_test.go forbids a schema that names what it holds. |
| +-- |
| +-- rate_key is a hash for the same reason: grouping a requester's requests |
| +-- for a rate limit does not require this table to know who they are, and |
| +-- keeping it a hash stops the queue becoming a second place identifiers |
| +-- accumulate. |
| +CREATE TABLE provisioning_queue ( |
| + id INTEGER PRIMARY KEY AUTOINCREMENT, |
| + team_id INTEGER NOT NULL REFERENCES teams(id) ON DELETE CASCADE, |
| + app TEXT NOT NULL CHECK(length(app) BETWEEN 1 AND 63 AND app NOT GLOB '*[^a-z0-9-]*'), |
| + state TEXT NOT NULL CHECK(state IN ('queued', 'done', 'failed')), |
| + sealed_payload BLOB NOT NULL CHECK(typeof(sealed_payload) = 'blob' AND length(sealed_payload) BETWEEN 29 AND 4096), |
| + rate_key TEXT NOT NULL CHECK(length(rate_key) = 64 AND rate_key NOT GLOB '*[^a-f0-9]*'), |
| + requested_at INTEGER NOT NULL CHECK(requested_at > 0), |
| + expires_at INTEGER NOT NULL CHECK(expires_at > requested_at), |
| + UNIQUE(team_id, app) |
| +); |
| +CREATE INDEX provisioning_queue_drain ON provisioning_queue(state, requested_at); |
| +CREATE INDEX provisioning_queue_expiry ON provisioning_queue(state, expires_at); |
| +CREATE INDEX provisioning_queue_rate ON provisioning_queue(rate_key, requested_at); |
diff --git a/internal/home/directorydata/provision.go b/internal/home/directorydata/provision.go| new file mode 100644 |
| index 0000000..fce43d9 |
| --- /dev/null |
| +++ b/internal/home/directorydata/provision.go |
| @@ -0,0 +1,255 @@ |
| +package directorydata |
| + |
| +import ( |
| + "context" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/hex" |
| + "errors" |
| + "time" |
| +) |
| + |
| +// ErrLabelTaken is the one answer the home gives about a label it does not |
| +// own: somebody already reserved it. It is deliberately indistinguishable |
| +// from "reserved by a team you cannot see" — the caller learns that the |
| +// name is unavailable, never who has it. |
| +// sealedFloor mirrors the sealed_payload and sealed_name CHECK minimums. |
| +const sealedFloor = 29 |
| + |
| +var ErrLabelTaken = errors.New("directory team label already reserved") |
| + |
| +// ErrRateLimited is returned when one requester has asked too often inside |
| +// the window. It is not an authorization answer and carries no detail. |
| +var ErrRateLimited = errors.New("directory provisioning requests too frequent") |
| + |
| +// Request is one queued (team, app) provisioning request. |
| +type Request struct { |
| + ID int64 |
| + TeamID int64 |
| + TeamSQID string |
| + Label, App string |
| + SealedPayload []byte |
| + RequestedAt time.Time |
| +} |
| + |
| +// RateKey groups a requester's provisioning requests without storing who |
| +// they are. The subject is already a pseudonym; hashing it again keeps this |
| +// table from being a second place that person_refs accumulate, which is the |
| +// property that lets the queue be swept and audited without reading it. |
| +func RateKey(subject string) string { |
| + sum := sha256.Sum256([]byte("oficina/provisioning/rate/v1\x00" + subject)) |
| + return hex.EncodeToString(sum[:]) |
| +} |
| + |
| +// CreateTeam reserves a label and queues one provisioning request per app, |
| +// in ONE transaction, and that atomicity is the whole point: the label |
| +// reservation IS the teams row (ruling 19), so a reservation that committed |
| +// without its requests would hold a name for a team nobody asked for. |
| +// |
| +// The caller has already decided this person may ask. CreateTeam decides |
| +// only what the home is entitled to decide about its own resources: is this |
| +// label free, and has this requester asked too often. |
| +// |
| +// maxPerWindow of 0 disables the rate check, which is for tests and for an |
| +// operator path that has already been adjudicated — never for a web caller. |
| +func (s *Store) CreateTeam(ctx context.Context, subject, label, name string, apps []string, payload []byte, now time.Time, ttl time.Duration, maxPerWindow int, window time.Duration) (Team, error) { |
| + if !validSubject(subject) || !validLabel(label) || !validName(name) || len(apps) == 0 || len(apps) > len(s.apps) { |
| + return Team{}, ErrInvalid |
| + } |
| + // 29 is the sealed-blob floor the column enforces (a nonce and tag with |
| + // nothing inside), checked here so a short payload is the caller's |
| + // mistake rather than an opaque unavailability from a CHECK. |
| + if len(payload) < sealedFloor || len(payload) > 4096 || ttl <= 0 || now.IsZero() { |
| + return Team{}, ErrInvalid |
| + } |
| + seen := make(map[string]bool, len(apps)) |
| + for _, app := range apps { |
| + if !s.apps[app] || seen[app] { |
| + return Team{}, ErrInvalid |
| + } |
| + seen[app] = true |
| + } |
| + |
| + tx, err := s.db.BeginTx(ctx, nil) |
| + if err != nil { |
| + return Team{}, ErrUnavailable |
| + } |
| + defer tx.Rollback() |
| + |
| + rate := RateKey(subject) |
| + if maxPerWindow > 0 { |
| + var recent int |
| + if err := tx.QueryRowContext(ctx, |
| + `SELECT count(*) FROM provisioning_queue WHERE rate_key = ? AND requested_at > ?`, |
| + rate, now.Add(-window).Unix()).Scan(&recent); err != nil { |
| + return Team{}, ErrUnavailable |
| + } |
| + if recent >= maxPerWindow { |
| + return Team{}, ErrRateLimited |
| + } |
| + } |
| + |
| + // The sqid is derived from the row id, so the row has to exist before |
| + // the name can be sealed against it (SealTeamName's contract). The |
| + // placeholder is overwritten inside this same transaction and is never |
| + // visible to a reader outside it. |
| + placeholder, err := pendingSQID() |
| + if err != nil { |
| + return Team{}, err |
| + } |
| + res, err := tx.ExecContext(ctx, |
| + `INSERT INTO teams (sqid, label, sealed_name, created_at) VALUES (?, ?, ?, ?)`, |
| + placeholder, label, pendingSeal, now.Unix()) |
| + if err != nil { |
| + if isUnique(err) { |
| + return Team{}, ErrLabelTaken |
| + } |
| + return Team{}, ErrUnavailable |
| + } |
| + id, err := res.LastInsertId() |
| + if err != nil || id <= 0 { |
| + return Team{}, ErrUnavailable |
| + } |
| + sqid, err := TeamSQID(id) |
| + if err != nil { |
| + return Team{}, ErrUnavailable |
| + } |
| + sealed, err := s.SealTeamName(id, name) |
| + if err != nil { |
| + return Team{}, err |
| + } |
| + if _, err := tx.ExecContext(ctx, `UPDATE teams SET sqid = ?, sealed_name = ? WHERE id = ?`, sqid, sealed, id); err != nil { |
| + if isUnique(err) { |
| + return Team{}, ErrUnavailable |
| + } |
| + return Team{}, ErrUnavailable |
| + } |
| + // The requester owns the team they just asked for. Ruling 23 is what |
| + // lets the home hold this: an owner is a fact about a resource the home |
| + // owns, not about anything inside an instance. |
| + if _, err := tx.ExecContext(ctx, |
| + `INSERT INTO memberships (team_id, person_ref, role) VALUES (?, ?, 'owner')`, id, subject); err != nil { |
| + return Team{}, ErrUnavailable |
| + } |
| + for _, app := range apps { |
| + if _, err := tx.ExecContext(ctx, |
| + `INSERT INTO provisioning_queue (team_id, app, state, sealed_payload, rate_key, requested_at, expires_at) |
| + VALUES (?, ?, 'queued', ?, ?, ?, ?)`, |
| + id, app, payload, rate, now.Unix(), now.Add(ttl).Unix()); err != nil { |
| + return Team{}, ErrUnavailable |
| + } |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return Team{}, ErrUnavailable |
| + } |
| + return Team{ID: id, SQID: sqid, Label: label, Name: name, Role: "owner"}, nil |
| +} |
| + |
| +// Queued returns requests waiting to be drained, oldest first. It is the |
| +// drain's only read, and it deliberately returns the sealed payload without |
| +// opening it: the home carries that opaquely (§3.2) and the drain is what |
| +// holds the key to it. |
| +func (s *Store) Queued(ctx context.Context, now time.Time, limit int) ([]Request, error) { |
| + if limit <= 0 || limit > 1000 { |
| + return nil, ErrInvalid |
| + } |
| + rows, err := s.db.QueryContext(ctx, |
| + `SELECT q.id, q.team_id, t.sqid, t.label, q.app, q.sealed_payload, q.requested_at |
| + FROM provisioning_queue q JOIN teams t ON t.id = q.team_id |
| + WHERE q.state = 'queued' AND q.expires_at > ? |
| + ORDER BY q.requested_at, q.id LIMIT ?`, now.Unix(), limit) |
| + if err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + defer rows.Close() |
| + var out []Request |
| + for rows.Next() { |
| + var r Request |
| + var at int64 |
| + if err := rows.Scan(&r.ID, &r.TeamID, &r.TeamSQID, &r.Label, &r.App, &r.SealedPayload, &at); err != nil { |
| + return nil, ErrUnavailable |
| + } |
| + r.RequestedAt = time.Unix(at, 0).UTC() |
| + out = append(out, r) |
| + } |
| + if rows.Err() != nil { |
| + return nil, ErrUnavailable |
| + } |
| + return out, nil |
| +} |
| + |
| +// MarkProvisioned records the instance the drain created and closes the |
| +// request, in one transaction. The team_instances row is what |
| +// <team>.<app>.oficina.works resolves against, so it and the state change |
| +// commit together or neither does. |
| +func (s *Store) MarkProvisioned(ctx context.Context, id int64, origin, consumerID string, now time.Time) error { |
| + if id <= 0 || origin == "" || consumerID == "" { |
| + return ErrInvalid |
| + } |
| + tx, err := s.db.BeginTx(ctx, nil) |
| + if err != nil { |
| + return ErrUnavailable |
| + } |
| + defer tx.Rollback() |
| + var teamID int64 |
| + var app string |
| + if err := tx.QueryRowContext(ctx, |
| + `SELECT team_id, app FROM provisioning_queue WHERE id = ? AND state = 'queued'`, id).Scan(&teamID, &app); err != nil { |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return ErrNotFound |
| + } |
| + return ErrUnavailable |
| + } |
| + if _, err := tx.ExecContext(ctx, |
| + `INSERT INTO team_instances (team_id, app, origin, consumer_id, state) VALUES (?, ?, ?, ?, 'live')`, |
| + teamID, app, origin, consumerID); err != nil { |
| + return ErrUnavailable |
| + } |
| + if _, err := tx.ExecContext(ctx, `UPDATE provisioning_queue SET state = 'done' WHERE id = ?`, id); err != nil { |
| + return ErrUnavailable |
| + } |
| + if err := tx.Commit(); err != nil { |
| + return ErrUnavailable |
| + } |
| + return nil |
| +} |
| + |
| +// Sweep releases what expired. §7: "a queue entry that is purged or times |
| +// out must release its label, or a request that never became a team holds a |
| +// name nobody can claim." Deleting the expired request is not enough on its |
| +// own — the label lives on the teams row — so a team left with no live |
| +// instance and no live request goes too, and its memberships with it. |
| +// |
| +// A team that HAS a live instance is never touched, however old its other |
| +// requests are: that is a real team with a name in use. |
| +func (s *Store) Sweep(ctx context.Context, now time.Time) (requests, labels int, err error) { |
| + tx, err := s.db.BeginTx(ctx, nil) |
| + if err != nil { |
| + return 0, 0, ErrUnavailable |
| + } |
| + defer tx.Rollback() |
| + res, err := tx.ExecContext(ctx, |
| + `DELETE FROM provisioning_queue WHERE state = 'queued' AND expires_at <= ?`, now.Unix()) |
| + if err != nil { |
| + return 0, 0, ErrUnavailable |
| + } |
| + n, _ := res.RowsAffected() |
| + if _, err := tx.ExecContext(ctx, `DELETE FROM memberships WHERE team_id IN ( |
| + SELECT t.id FROM teams t |
| + WHERE NOT EXISTS (SELECT 1 FROM team_instances i WHERE i.team_id = t.id AND i.state != 'disabled') |
| + AND NOT EXISTS (SELECT 1 FROM provisioning_queue q WHERE q.team_id = t.id AND q.state = 'queued'))`); err != nil { |
| + return 0, 0, ErrUnavailable |
| + } |
| + res, err = tx.ExecContext(ctx, `DELETE FROM teams WHERE id IN ( |
| + SELECT t.id FROM teams t |
| + WHERE NOT EXISTS (SELECT 1 FROM team_instances i WHERE i.team_id = t.id AND i.state != 'disabled') |
| + AND NOT EXISTS (SELECT 1 FROM provisioning_queue q WHERE q.team_id = t.id AND q.state = 'queued'))`) |
| + if err != nil { |
| + return 0, 0, ErrUnavailable |
| + } |
| + m, _ := res.RowsAffected() |
| + if err := tx.Commit(); err != nil { |
| + return 0, 0, ErrUnavailable |
| + } |
| + return int(n), int(m), nil |
| +} |
diff --git a/internal/home/directorydata/provision_test.go b/internal/home/directorydata/provision_test.go| new file mode 100644 |
| index 0000000..9c88dfb |
| --- /dev/null |
| +++ b/internal/home/directorydata/provision_test.go |
| @@ -0,0 +1,160 @@ |
| +package directorydata |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| +) |
| + |
| +var sealedPayload = []byte("sealed-opaque-provisioning-payload") |
| + |
| +func mustCreate(t *testing.T, s *Store, subject, label string, apps []string, now time.Time) Team { |
| + t.Helper() |
| + team, err := s.CreateTeam(context.Background(), subject, label, privateName, apps, sealedPayload, now, time.Hour, 0, time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return team |
| +} |
| + |
| +func TestCreateTeamReservesTheLabelSuiteWide(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + now := time.Unix(1_700_000_000, 0).UTC() |
| + team := mustCreate(t, s, personA, "acme", []string{"docs"}, now) |
| + if team.ID <= 0 || team.Label != "acme" || team.Role != "owner" || team.Name != privateName { |
| + t.Fatalf("team %+v", team) |
| + } |
| + expected, err := TeamSQID(team.ID) |
| + if err != nil || team.SQID != expected { |
| + t.Fatalf("sqid %q want %q (%v)", team.SQID, expected, err) |
| + } |
| + |
| + // Ruling 19: claiming a label takes it across every app, so a DIFFERENT |
| + // person asking for the same label for a DIFFERENT app is refused. This |
| + // is the whole reservation, and it must not depend on who asks. |
| + _, err = s.CreateTeam(context.Background(), personB, "acme", privateName, []string{"sheets"}, sealedPayload, now, time.Hour, 0, time.Minute) |
| + if !errors.Is(err, ErrLabelTaken) { |
| + t.Fatalf("second claim of a taken label: %v, want ErrLabelTaken", err) |
| + } |
| +} |
| + |
| +func TestCreateTeamIsAtomic(t *testing.T) { |
| + s, d, _ := fixture(t) |
| + now := time.Unix(1_700_000_000, 0).UTC() |
| + // An app outside the registry must abort the WHOLE request — no team, so |
| + // no label held for a request that was never queued. |
| + if _, err := s.CreateTeam(context.Background(), personA, "acme", privateName, []string{"docs", "nosuchapp"}, sealedPayload, now, time.Hour, 0, time.Minute); !errors.Is(err, ErrInvalid) { |
| + t.Fatalf("unknown app: %v", err) |
| + } |
| + var teams int |
| + if err := d.Writer().QueryRow(`SELECT count(*) FROM teams`).Scan(&teams); err != nil { |
| + t.Fatal(err) |
| + } |
| + if teams != 0 { |
| + t.Fatalf("a refused request left %d team rows holding labels", teams) |
| + } |
| + // And the label is genuinely free afterwards. |
| + mustCreate(t, s, personA, "acme", []string{"docs"}, now) |
| +} |
| + |
| +func TestQueuedAndMarkProvisioned(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + now := time.Unix(1_700_000_000, 0).UTC() |
| + team := mustCreate(t, s, personA, "acme", []string{"docs", "sheets"}, now) |
| + |
| + got, err := s.Queued(context.Background(), now, 10) |
| + if err != nil || len(got) != 2 { |
| + t.Fatalf("queued %d (%v)", len(got), err) |
| + } |
| + if got[0].Label != "acme" || got[0].TeamSQID != team.SQID || string(got[0].SealedPayload) != string(sealedPayload) { |
| + t.Fatalf("request %+v", got[0]) |
| + } |
| + |
| + if err := s.MarkProvisioned(context.Background(), got[0].ID, "https://acme.docs.oficina.works", "consumer-1", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + left, err := s.Queued(context.Background(), now, 10) |
| + if err != nil || len(left) != 1 || left[0].App == got[0].App { |
| + t.Fatalf("after drain: %d left (%v)", len(left), err) |
| + } |
| + // Draining the same request twice must not mint a second instance. |
| + if err := s.MarkProvisioned(context.Background(), got[0].ID, "https://acme.docs.oficina.works", "consumer-2", now); !errors.Is(err, ErrNotFound) { |
| + t.Fatalf("second drain of one request: %v, want ErrNotFound", err) |
| + } |
| +} |
| + |
| +func TestSweepReleasesTheLabelOfAnExpiredRequest(t *testing.T) { |
| + s, d, _ := fixture(t) |
| + now := time.Unix(1_700_000_000, 0).UTC() |
| + if _, err := s.CreateTeam(context.Background(), personA, "acme", privateName, []string{"docs"}, sealedPayload, now, time.Minute, 0, time.Minute); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Before expiry nothing moves. |
| + if reqs, labels, err := s.Sweep(context.Background(), now.Add(30*time.Second)); err != nil || reqs != 0 || labels != 0 { |
| + t.Fatalf("early sweep: %d/%d (%v)", reqs, labels, err) |
| + } |
| + // §7: an entry that times out must release its label, or a request that |
| + // never became a team holds a name nobody can claim. |
| + reqs, labels, err := s.Sweep(context.Background(), now.Add(2*time.Minute)) |
| + if err != nil || reqs != 1 || labels != 1 { |
| + t.Fatalf("sweep: %d requests %d labels (%v)", reqs, labels, err) |
| + } |
| + var memberships int |
| + if err := d.Writer().QueryRow(`SELECT count(*) FROM memberships`).Scan(&memberships); err != nil { |
| + t.Fatal(err) |
| + } |
| + if memberships != 0 { |
| + t.Fatalf("sweep left %d memberships pointing at a deleted team", memberships) |
| + } |
| + // The label is claimable again, by anyone. |
| + mustCreate(t, s, personB, "acme", []string{"docs"}, now.Add(3*time.Minute)) |
| +} |
| + |
| +func TestSweepNeverTakesALabelThatIsInUse(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + now := time.Unix(1_700_000_000, 0).UTC() |
| + if _, err := s.CreateTeam(context.Background(), personA, "acme", privateName, []string{"docs", "sheets"}, sealedPayload, now, time.Minute, 0, time.Minute); err != nil { |
| + t.Fatal(err) |
| + } |
| + got, err := s.Queued(context.Background(), now, 10) |
| + if err != nil || len(got) != 2 { |
| + t.Fatal(err) |
| + } |
| + // One app got provisioned; the other request then expires. The team is |
| + // real and its name is in use, so the sweep must take the stale request |
| + // and leave the label alone. |
| + if err := s.MarkProvisioned(context.Background(), got[0].ID, "https://acme.docs.oficina.works", "c1", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + reqs, labels, err := s.Sweep(context.Background(), now.Add(2*time.Minute)) |
| + if err != nil || reqs != 1 || labels != 0 { |
| + t.Fatalf("sweep: %d requests %d labels (%v)", reqs, labels, err) |
| + } |
| + if _, err := s.CreateTeam(context.Background(), personB, "acme", privateName, []string{"sheets"}, sealedPayload, now, time.Hour, 0, time.Minute); !errors.Is(err, ErrLabelTaken) { |
| + t.Fatal("a live team's label was released") |
| + } |
| +} |
| + |
| +func TestCreateTeamRateLimitsOneRequester(t *testing.T) { |
| + s, _, _ := fixture(t) |
| + now := time.Unix(1_700_000_000, 0).UTC() |
| + for i, label := range []string{"one", "two"} { |
| + if _, err := s.CreateTeam(context.Background(), personA, label, privateName, []string{"docs"}, sealedPayload, now, time.Hour, 2, time.Hour); err != nil { |
| + t.Fatalf("request %d: %v", i, err) |
| + } |
| + } |
| + if _, err := s.CreateTeam(context.Background(), personA, "three", privateName, []string{"docs"}, sealedPayload, now, time.Hour, 2, time.Hour); !errors.Is(err, ErrRateLimited) { |
| + t.Fatalf("third request: %v, want ErrRateLimited", err) |
| + } |
| + // The limit is per requester, not global. |
| + if _, err := s.CreateTeam(context.Background(), personB, "four", privateName, []string{"docs"}, sealedPayload, now, time.Hour, 2, time.Hour); err != nil { |
| + t.Fatalf("other requester: %v", err) |
| + } |
| + // And it is a window, not a cap: the same person may ask again later. |
| + if _, err := s.CreateTeam(context.Background(), personA, "five", privateName, []string{"docs"}, sealedPayload, now.Add(2*time.Hour), time.Hour, 2, time.Hour); err != nil { |
| + t.Fatalf("after the window: %v", err) |
| + } |
| + // "three" was refused, so nothing may be holding it. |
| + mustCreate(t, s, personB, "three", []string{"docs"}, now.Add(2*time.Hour)) |
| +} |
diff --git a/internal/home/directorydata/provisionids.go b/internal/home/directorydata/provisionids.go| new file mode 100644 |
| index 0000000..464c5dd |
| --- /dev/null |
| +++ b/internal/home/directorydata/provisionids.go |
| @@ -0,0 +1,61 @@ |
| +package directorydata |
| + |
| +import ( |
| + "crypto/rand" |
| + "encoding/hex" |
| + "strings" |
| + |
| + "github.com/sqids/sqids-go" |
| +) |
| + |
| +// teamCodec is the same sqids codec, with the same defaults, that |
| +// internal/operator uses. It is duplicated rather than shared because |
| +// operator already imports THIS package, so importing it back would be a |
| +// cycle. Duplication that can drift is only safe if something catches the |
| +// drift: TestTeamSQIDMatchesOperator (provisionids_pin_test.go) asserts the |
| +// two agree, from an external test package that may import both. |
| +var teamCodec, teamCodecError = sqids.New() |
| + |
| +// TeamSQID encodes a team's row id. The sqid is derived from the id, which |
| +// is why a team row must exist before its sqid is known. |
| +func TeamSQID(id int64) (string, error) { |
| + if id <= 0 || teamCodecError != nil { |
| + return "", ErrInvalid |
| + } |
| + s, err := teamCodec.Encode([]uint64{uint64(id)}) |
| + if err != nil || len(s) == 0 || len(s) > 64 { |
| + return "", ErrInvalid |
| + } |
| + return s, nil |
| +} |
| + |
| +// pendingSQID fills teams.sqid for the instant between INSERT and the UPDATE |
| +// that writes the real one. Both happen inside CreateTeam's transaction, so |
| +// no reader outside it ever sees this value — but the column is NOT NULL |
| +// UNIQUE with an alphanumeric CHECK, so the placeholder still has to be a |
| +// legal, unique sqid-shaped string rather than a marker like "pending". |
| +func pendingSQID() (string, error) { |
| + b := make([]byte, 16) |
| + if _, err := rand.Read(b); err != nil { |
| + return "", ErrUnavailable |
| + } |
| + return "pending" + hex.EncodeToString(b), nil |
| +} |
| + |
| +// pendingSeal fills teams.sealed_name for the same instant, and for the same |
| +// reason: the column's CHECK demands a blob of at least 29 bytes, and the |
| +// real seal cannot be computed until the row id exists (SealTeamName binds |
| +// the name to the id). Overwritten in the same transaction. |
| +var pendingSeal = make([]byte, 29) |
| + |
| +// isUnique reports whether err is a UNIQUE constraint violation. |
| +// |
| +// modernc.org/sqlite returns extended result codes on its own error type, |
| +// but the constant for SQLITE_CONSTRAINT_UNIQUE is not exported in a form |
| +// this package can depend on without pinning the driver's internals. The |
| +// message is the stable surface: SQLite has spelled it "UNIQUE constraint |
| +// failed" since 3.8. A false negative here costs a clear ErrLabelTaken and |
| +// returns ErrUnavailable instead, which is the safe direction to fail. |
| +func isUnique(err error) bool { |
| + return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed") |
| +} |
diff --git a/internal/home/directorydata/provisionids_pin_test.go b/internal/home/directorydata/provisionids_pin_test.go| new file mode 100644 |
| index 0000000..9190528 |
| --- /dev/null |
| +++ b/internal/home/directorydata/provisionids_pin_test.go |
| @@ -0,0 +1,33 @@ |
| +package directorydata_test |
| + |
| +import ( |
| + "testing" |
| + |
| + "amadan.net/oficina/home/internal/home/directorydata" |
| + "amadan.net/oficina/home/internal/operator" |
| +) |
| + |
| +// TestTeamSQIDMatchesOperator is the reason directorydata is allowed to keep |
| +// its own copy of the sqids codec. operator imports directorydata, so |
| +// directorydata cannot import operator back; this external test package can |
| +// import both, and fails the moment the two stop agreeing about what a |
| +// team's sqid is. A drift here would mean a team addressed by two different |
| +// identifiers depending on which package answered. |
| +func TestTeamSQIDMatchesOperator(t *testing.T) { |
| + for _, id := range []int64{1, 2, 7, 42, 255, 1000, 65535, 1 << 20, 1<<31 - 1} { |
| + mine, err := directorydata.TeamSQID(id) |
| + if err != nil { |
| + t.Fatalf("directorydata.TeamSQID(%d): %v", id, err) |
| + } |
| + theirs, err := operator.TeamSQID(id) |
| + if err != nil { |
| + t.Fatalf("operator.TeamSQID(%d): %v", id, err) |
| + } |
| + if mine != theirs { |
| + t.Fatalf("id %d: directorydata %q, operator %q", id, mine, theirs) |
| + } |
| + } |
| + if _, err := directorydata.TeamSQID(0); err == nil { |
| + t.Fatal("id 0 must be refused") |
| + } |
| +} |