Help us shape the future of Go! Take the 2026 Go Developer Survey

saas

package
v0.14.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 21, 2026 License: AGPL-3.0 Imports: 25 Imported by: 0

README

SaaS subscriptions

This module stores migrated application subscriptions separately from commerce server provisioning. It does not call external APIs, send mail, initiate payments or replay historical tasks.

Register saas.New() in the kernel module list and add saas after commerce in kernel.BootOrder. It imports auth/api and shares only kernel infrastructure. The service is available as kernel.Get[*saas.Service](d, "saas").

Authenticated routes:

  • /saas: current account applications, subscriptions and usage.
  • /admin/saas: management overview; saas.manage required.
  • GET /api/v1/saas: owner-scoped JSON overview.
  • GET /api/v1/saas/manage: management overview.
  • POST /api/v1/saas/entitlements/{id}/consume: owner-scoped atomic quota consumption. Body: {"metric":"tasks","quantity":1,"idempotency_key":"unique-request-at-least-16-characters"}.
  • PATCH /api/v1/saas/entitlements/{id}: management status compare-and-swap. Body: {"expected_status":"active","status":"suspended"}.

Import uses saas_ table names; exact field definitions are in the migration. All native IDs are independent strings, source IDs unique and nullable. Source app references must resolve to native IDs. owner_id resolves the original user to a native account and remains null when unknown; never default unresolved subscriptions to a random or bootstrap user. external_id and usage subject_id retain the original subject identifier, so imported quota totals match subscriptions. Public IDs retain original values. Manifest/features/limits preserve JSON objects; source byte/schema records remain separately encrypted as compatibility input.

Native management APIs:

  • POST /api/v1/saas/apps: owner_id, name, slug, optional base_url, status(active/disabled), mode(subscription/external), idempotency_key (16–200 chars).
  • PATCH /api/v1/saas/apps/{id}: same configuration plus expected_version from overview.
  • POST /api/v1/saas/entitlements: app_id, owner_id, plan_id, status, features(boolean map), limits(integer metric map), optional valid_until(RFC3339), idempotency_key.
  • PUT /api/v1/saas/entitlements/{id}: same subscription configuration plus expected_version; app and owner remain fixed and usage persists.
  • DELETE /api/v1/saas/apps/{id} or /entitlements/{id}: unused native objects only; imported or referenced history must be disabled/revoked instead.
  • /admin/saas/new: CSRF-protected app and subscription creation forms. Management overview includes app editing and subscription state changes.

The module refreshes role permissions after seeding customer read/use scopes. Local subscription mode requires no external provisioning connection; external mode still reports missing fulfillment configuration.

Key version ciphertext, IV, tag and KDF context are retained privately, never included in API/UI structures. Legacy transferred keys are retained as archived migration data; use existing GoRun API keys with saas.read, saas.use and saas.manage for native functionality. Imported key protocols default to legacy-unconfigured; nonce validation refuses these keys. The original plugin's signature/KDF source implementation was not present in the transferred executable plugin manifests, so accepting those external client signatures requires a separately verified adapter. ConsumeNonce guards replay for a configured protocol; it does not verify a signature itself. AcceptEvent is an internal owner-scoped ledger helper and is not exposed as an unauthenticated webhook.

Quotas are numeric metric-to-limit JSON objects. Missing, negative or invalid quotas fail closed. Active subscription and unexpired validity are required. Database IMMEDIATE transactions serialize quota writes and ledger operations; identical idempotency keys replay the stored result and changed payloads return conflict. Historical event/nonce rows remain preserved.

Tests: go test -race ./internal/saas verifies ownership, disabled/anonymous accounts, public UI content, quota atomicity/concurrency, idempotent replay/conflicts, expiry, inactive status, replay ledgers, unconfigured/revoked keys and management CAS. Production migration and live UI/API acceptance are separate operator steps.

Recorded owner services

PATCH /api/v1/saas/entitlements/{id}/services requires saas.manage and expected_version from the latest overview (including a legacy empty version). Example: {"expected_version":"current-version","renew_months":1,"central_outbound_mail":true,"existing_domain_linked":true}. Optional status accepts the existing subscription states. Renewal accepts 1–36 calendar months; omitted/zero leaves expiry unchanged. It extends the later of now and current expiry, clamps the day at month-end, and does not activate a suspended subscription unless status is explicitly supplied. At least one change is required. A stale version returns 409; refresh before another change. Ownership, plan, quotas, usage, unrelated feature fields and payment markers are preserved. Changes are audited. These flags record service entitlements only: this endpoint does not provision mailboxes, register domains, change DNS or charge/record payment.

Renewal visibility

Both overview APIs include entitlements[].renewal: state, nullable remaining_seconds and remaining_days, reminder_days (7), and observed_at (UTC RFC3339). valid_until remains the original expiry field. Empty expiry is unscheduled; malformed expiry is invalid; both return null remaining values. Expiry at or before the observation time is expired with zero remaining. Positive remaining seconds and days are rounded up; at most seven days until expiry is due_soon, otherwise current. One observation time is shared by all subscriptions in a response. This read-only metadata does not send messages, renew a subscription, or activate suspended/revoked subscriptions: always show status separately. OpenAPI schemas are generated from these response types.

Both overview endpoints accept optional renewal_state: needs_renewal (due_soon or expired), due_soon, expired, current, unscheduled, or invalid. Example: GET /api/v1/saas/manage?renewal_state=needs_renewal. Unknown or repeated values return 422; omitted/empty preserves the existing response. Filtering narrows the authorized overview snapshot (the existing limit is 500 subscriptions) to matching entitlements and their related apps and usage subjects. It does not change subscription status, send reminders, charge renewal, or expose other owners' records. Expiry filtering includes suspended/revoked entitlements when their dates match; clients must also display the independent subscription status. No-match results use empty arrays.

Documentation

Overview

Package saas preserves app ownership, subscriptions and atomic usage independently of commerce provisioning.

Index

Constants

View Source
const PermManage = "saas.manage"

Variables

View Source
var (
	ErrForbidden = errors.New("saas: forbidden")
	ErrInvalid   = errors.New("saas: invalid input")
	ErrConflict  = errors.New("saas: idempotency conflict")
	ErrInactive  = errors.New("saas: entitlement inactive or expired")
	ErrQuota     = errors.New("saas: quota exceeded or undefined")
)

Functions

This section is empty.

Types

type App

type App struct {
	OwnerID            string `json:"owner_id"`
	Version            string `json:"version"`
	Mode               string `json:"mode"`
	ID                 string `json:"id"`
	PublicID           string `json:"public_id"`
	Name               string `json:"name"`
	Slug               string `json:"slug"`
	Status             string `json:"status"`
	BaseURL            string `json:"base_url"`
	Runtime            string `json:"runtime"`
	Framework          string `json:"framework"`
	LastSeenAt         string `json:"last_seen_at"`
	FulfillmentEnabled bool   `json:"fulfillment_enabled"`
}

type AppInput

type AppInput struct {
	OwnerID         string `json:"owner_id"`
	Name            string `json:"name"`
	Slug            string `json:"slug"`
	BaseURL         string `json:"base_url"`
	Status          string `json:"status"`
	Mode            string `json:"mode"`
	ExpectedVersion string `json:"expected_version"`
	IdempotencyKey  string `json:"idempotency_key"`
}

type ConsumeRequest

type ConsumeRequest struct {
	Metric         string `json:"metric"`
	Quantity       int64  `json:"quantity"`
	IdempotencyKey string `json:"idempotency_key"`
}

type Consumption

type Consumption struct {
	Quantity int64 `json:"quantity"`
	Replayed bool  `json:"replayed"`
}

type Entitlement

type Entitlement struct {
	Version     string          `json:"version"`
	ID          string          `json:"id"`
	PublicID    string          `json:"public_id"`
	AppID       string          `json:"app_id"`
	AppName     string          `json:"app_name"`
	SubjectType string          `json:"subject_type"`
	ExternalID  string          `json:"external_id"`
	PlanID      string          `json:"plan_id"`
	Status      string          `json:"status"`
	Features    json.RawMessage `json:"features"`
	Limits      json.RawMessage `json:"limits"`
	ValidUntil  string          `json:"valid_until"`
	Renewal     Renewal         `json:"renewal"`
}

type Module

type Module struct{ Service *Service }

Module adds owner-scoped SaaS state. Import and external protocol adapters are separate concerns.

func New

func New() *Module

func (*Module) Catalogs

func (*Module) Catalogs() fs.FS

func (*Module) Migrations

func (*Module) Migrations() fs.FS

func (*Module) Name

func (*Module) Name() string

func (*Module) Register

func (m *Module) Register(d *kernel.Deps) error

type Overview

type Overview struct {
	Apps         []App         `json:"apps"`
	Entitlements []Entitlement `json:"entitlements"`
	Usage        []Usage       `json:"usage"`
}

type Renewal

type Renewal struct {
	State            string `` /* 128-byte string literal not displayed */
	RemainingSeconds *int64 `json:"remaining_seconds" doc:"Positive remaining seconds rounded up; zero when expired, null for missing or invalid expiry."`
	RemainingDays    *int   `json:"remaining_days" doc:"Remaining 24-hour days rounded up; zero when expired, null for missing or invalid expiry."`
	ReminderDays     int    `json:"reminder_days" doc:"Reminder window in days (7). No notification is sent."`
	ObservedAt       string `json:"observed_at" doc:"UTC RFC3339 observation time shared by this overview."`
}

Renewal describes expiry only; entitlement status still controls access. Missing/invalid dates leave remaining values null, never implying unlimited access.

type Saved

type Saved struct {
	ID       string `json:"id"`
	Version  string `json:"version,omitempty"`
	Replayed bool   `json:"replayed"`
}

type Service

type Service struct {
	Audit *audit.Writer
	DB    *sql.DB
	Now   func() time.Time
	// contains filtered or unexported fields
}

func NewService

func NewService(d *sql.DB) *Service

func (*Service) AcceptEvent

func (s *Service) AcceptEvent(ctx context.Context, app, event, key, hash string) (bool, error)

AcceptEvent records replay protection only. It does not authenticate a legacy external webhook or fulfill subscriptions.

func (*Service) Consume

func (s *Service) Consume(ctx context.Context, entitlement string, req ConsumeRequest) (Consumption, error)

Consume applies one owned entitlement's usage atomically. Missing quotas deny rather than grant unlimited usage.

func (*Service) ConsumeNonce

func (s *Service) ConsumeNonce(ctx context.Context, app, key, nonce string, expires time.Time) error

ConsumeNonce is an internal replay guard, not a substitute for authenticating a request signature. A legacy key is never accepted until a protocol adapter marks it configured.

func (*Service) CreateApp

func (s *Service) CreateApp(ctx context.Context, q AppInput) (Saved, error)

CreateApp establishes explicit ownership and local subscription gating; it never provisions infrastructure.

func (*Service) CreateSubscription

func (s *Service) CreateSubscription(ctx context.Context, q SubscriptionInput) (Saved, error)

func (*Service) DeleteUnused

func (s *Service) DeleteUnused(ctx context.Context, kind, id string) error

DeleteUnused permanently removes only an unused native object. Imported or referenced history must be disabled/revoked instead.

func (*Service) Overview

func (s *Service) Overview(ctx context.Context, management bool) (Overview, error)

func (*Service) UpdateApp

func (s *Service) UpdateApp(ctx context.Context, id string, q AppInput) (Saved, error)

func (*Service) UpdateServices

func (s *Service) UpdateServices(ctx context.Context, id string, q ServicesUpdate) (Saved, error)

UpdateServices preserves ownership, plan, quota and unrelated feature data. A stale version prevents a retried renewal from extending the term twice.

func (*Service) UpdateStatus

func (s *Service) UpdateStatus(ctx context.Context, id string, q StatusUpdate) error

func (*Service) UpdateSubscription

func (s *Service) UpdateSubscription(ctx context.Context, id string, q SubscriptionInput) (Saved, error)

UpdateSubscription changes explicit native plan limits with a version guard. Existing usage is retained.

type ServicesUpdate

type ServicesUpdate struct {
	ExpectedVersion      *string `json:"expected_version"`
	Status               *string `json:"status,omitempty"`
	RenewMonths          int     `json:"renew_months,omitempty"`
	CentralOutboundMail  *bool   `json:"central_outbound_mail,omitempty"`
	ExistingDomainLinked *bool   `json:"existing_domain_linked,omitempty"`
}

ServicesUpdate edits recorded service entitlements only. It does not provision mailboxes, register domains, change DNS or record a payment.

type StatusUpdate

type StatusUpdate struct {
	ExpectedStatus string `json:"expected_status"`
	Status         string `json:"status"`
}

StatusUpdate requires the current status to avoid lost concurrent management changes.

type SubscriptionInput

type SubscriptionInput struct {
	ExpectedVersion string           `json:"expected_version"`
	AppID           string           `json:"app_id"`
	OwnerID         string           `json:"owner_id"`
	PlanID          string           `json:"plan_id"`
	Status          string           `json:"status"`
	Features        map[string]bool  `json:"features"`
	Limits          map[string]int64 `json:"limits"`
	ValidUntil      string           `json:"valid_until"`
	IdempotencyKey  string           `json:"idempotency_key"`
}

type Usage

type Usage struct {
	AppID     string `json:"app_id"`
	SubjectID string `json:"subject_id"`
	Metric    string `json:"metric"`
	Quantity  int64  `json:"quantity"`
}

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL