diff --git a/.mokogitea/manifest.xml b/.mokogitea/manifest.xml
index 0a9062b5ea..c7e7a8fd95 100644
--- a/.mokogitea/manifest.xml
+++ b/.mokogitea/manifest.xml
@@ -4,7 +4,7 @@
MokoGitea
MokoConsulting
Moko fork of Gitea - adding project board REST API endpoints and custom enhancements
- 06.19.00
+ 06.20.00
v1.26.1+MOKO
GNU General Public License v3
diff --git a/.mokogitea/workflows/issue-branch.yml b/.mokogitea/workflows/issue-branch.yml
index c2b02a6f91..47cccacebf 100644
--- a/.mokogitea/workflows/issue-branch.yml
+++ b/.mokogitea/workflows/issue-branch.yml
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
-# VERSION: 01.00.00
+# VERSION: 06.20.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
diff --git a/.mokogitea/workflows/pr-branch-check.yml b/.mokogitea/workflows/pr-branch-check.yml
deleted file mode 100644
index debdd13b30..0000000000
--- a/.mokogitea/workflows/pr-branch-check.yml
+++ /dev/null
@@ -1,90 +0,0 @@
-# Copyright (C) 2026 Moko Consulting
-# SPDX-License-Identifier: GPL-3.0-or-later
-#
-# Enforces branch merge policy:
-# feature/* → dev only
-# fix/* → dev only
-# hotfix/* → dev or main (emergency)
-# dev → main only
-# alpha/* → dev only
-# beta/* → dev only
-# rc/* → main only
-
-name: Branch Policy Check
-
-on:
- pull_request:
- types: [opened, synchronize, reopened, edited]
-
-jobs:
- check-target:
- name: Verify merge target
- runs-on: ubuntu-latest
- steps:
- - name: Check branch policy
- run: |
- HEAD="${{ github.head_ref }}"
- BASE="${{ github.base_ref }}"
-
- echo "PR: ${HEAD} → ${BASE}"
-
- ALLOWED=true
- REASON=""
-
- case "$HEAD" in
- feature/*|feat/*)
- if [ "$BASE" != "dev" ]; then
- ALLOWED=false
- REASON="Feature branches must target 'dev', not '${BASE}'"
- fi
- ;;
- fix/*|bugfix/*)
- if [ "$BASE" != "dev" ]; then
- ALLOWED=false
- REASON="Fix branches must target 'dev', not '${BASE}'"
- fi
- ;;
- hotfix/*)
- if [ "$BASE" != "dev" ] && [ "$BASE" != "main" ]; then
- ALLOWED=false
- REASON="Hotfix branches can only target 'dev' or 'main', not '${BASE}'"
- fi
- ;;
- alpha/*|beta/*)
- if [ "$BASE" != "dev" ]; then
- ALLOWED=false
- REASON="Pre-release branches must target 'dev', not '${BASE}'"
- fi
- ;;
- rc/*)
- if [ "$BASE" != "main" ]; then
- ALLOWED=false
- REASON="Release candidate branches must target 'main', not '${BASE}'"
- fi
- ;;
- dev)
- if [ "$BASE" != "main" ]; then
- ALLOWED=false
- REASON="Dev branch can only merge into 'main', not '${BASE}'"
- fi
- ;;
- esac
-
- if [ "$ALLOWED" = false ]; then
- echo "::error::${REASON}"
- echo ""
- echo "## Branch Policy Violation" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "${REASON}" >> $GITHUB_STEP_SUMMARY
- echo "" >> $GITHUB_STEP_SUMMARY
- echo "### Allowed merge paths:" >> $GITHUB_STEP_SUMMARY
- echo "- \`feature/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
- echo "- \`fix/*\` → \`dev\`" >> $GITHUB_STEP_SUMMARY
- echo "- \`hotfix/*\` → \`dev\` or \`main\`" >> $GITHUB_STEP_SUMMARY
- echo "- \`dev\` → \`main\`" >> $GITHUB_STEP_SUMMARY
- echo "- \`rc/*\` → \`main\`" >> $GITHUB_STEP_SUMMARY
- exit 1
- fi
-
- echo "Branch policy: OK (${HEAD} → ${BASE})"
- echo "## Branch Policy: Passed" >> $GITHUB_STEP_SUMMARY
diff --git a/.mokogitea/workflows/pr-check.yml b/.mokogitea/workflows/pr-check.yml
index d34108ce5d..409fea5a1f 100644
--- a/.mokogitea/workflows/pr-check.yml
+++ b/.mokogitea/workflows/pr-check.yml
@@ -487,48 +487,3 @@ jobs:
echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
- # ── Pre-Release RC Build ─────────────────────────────────────────────────
- pre-release:
- name: Build RC Package
- runs-on: ubuntu-latest
- needs: [branch-policy, validate]
-
- steps:
- - name: Trigger RC pre-release
- env:
- GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
- REPO: ${{ github.repository }}
- BRANCH: ${{ github.head_ref }}
- GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
- run: |
- curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
- echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
- echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
-
- # ── Issue Reporter ──────────────────────────────────────────────────────
- report-issues:
- name: Report Issues
- runs-on: ubuntu-latest
- needs: [branch-policy, validate]
- if: >-
- always() &&
- needs.validate.result == 'failure'
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- sparse-checkout: automation/ci-issue-reporter.sh
- sparse-checkout-cone-mode: false
-
- - name: "File issue for PR validation failure"
- env:
- GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
- GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
- run: |
- chmod +x automation/ci-issue-reporter.sh
- ./automation/ci-issue-reporter.sh \
- --gate "PR Validation" \
- --workflow "PR Check" \
- --severity error \
- --details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
diff --git a/.mokogitea/workflows/pre-release.yml b/.mokogitea/workflows/pre-release.yml
index b34a311089..5f17b0e3bf 100644
--- a/.mokogitea/workflows/pre-release.yml
+++ b/.mokogitea/workflows/pre-release.yml
@@ -49,8 +49,10 @@ jobs:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
runs-on: release
if: >-
- github.event_name == 'workflow_dispatch' ||
- github.event_name == 'push'
+ (github.event_name == 'workflow_dispatch' ||
+ github.event_name == 'push') &&
+ !contains(github.event.head_commit.message, '[skip ci]') &&
+ !contains(github.event.head_commit.message, '[skip bump]')
steps:
- name: Checkout
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f70340980..bece1cb5fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,7 +1,15 @@
# Changelog
+
## [Unreleased]
-## [06.19.00] --- 2026-06-20
+### Added
+- DLID licensing system: license, entitlement, activation, product_tier, audit_log tables (v359 migration)
+- License CRUD with CRC32-checksummed DLID generation and format validation
+- Entitlement model with tier-based rebuild and custom entitlement preservation
+- Domain activation tracking with limit enforcement and auto-activate on first use
+- 13 seeded product tiers from base to enterprise
+- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
+- Profile repo fallback chain: .mokogitea > .profile > .github
## [06.19.00] --- 2026-06-20
diff --git a/models/licensing/activation.go b/models/licensing/activation.go
new file mode 100644
index 0000000000..289204c68b
--- /dev/null
+++ b/models/licensing/activation.go
@@ -0,0 +1,105 @@
+// Copyright 2026 Moko Consulting
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package licensing
+
+import (
+ "context"
+
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
+)
+
+func init() {
+ db.RegisterModel(new(LicenseActivation))
+}
+
+// LicenseActivation tracks a domain that has activated a license.
+type LicenseActivation struct {
+ ID int64 `xorm:"pk autoincr"`
+ LicenseID int64 `xorm:"INDEX NOT NULL"`
+ Domain string `xorm:"VARCHAR(255) NOT NULL"`
+ IPAddress string `xorm:"VARCHAR(64)"`
+ JoomlaVer string `xorm:"VARCHAR(20)"`
+ ActivatedAt timeutil.TimeStamp `xorm:"CREATED"`
+ LastSeenAt timeutil.TimeStamp
+}
+
+func (LicenseActivation) TableName() string {
+ return "license_activation"
+}
+
+// GetActivationsByLicense returns all domain activations for a license.
+func GetActivationsByLicense(ctx context.Context, licenseID int64) ([]*LicenseActivation, error) {
+ var acts []*LicenseActivation
+ return acts, db.GetEngine(ctx).Where("license_id = ?", licenseID).Find(&acts)
+}
+
+// CountActivations returns the number of activated domains for a license.
+func CountActivations(ctx context.Context, licenseID int64) (int64, error) {
+ return db.GetEngine(ctx).Where("license_id = ?", licenseID).Count(new(LicenseActivation))
+}
+
+// ActivateDomain registers a domain for a license. Returns the activation
+// (existing or new) and whether it was newly created.
+func ActivateDomain(ctx context.Context, licenseID int64, domain, ip, joomlaVer string, maxDomains int) (*LicenseActivation, bool, error) {
+ // Check if already activated
+ existing := new(LicenseActivation)
+ has, err := db.GetEngine(ctx).
+ Where("license_id = ? AND domain = ?", licenseID, domain).
+ Get(existing)
+ if err != nil {
+ return nil, false, err
+ }
+ if has {
+ // Update last seen
+ existing.LastSeenAt = timeutil.TimeStampNow()
+ existing.IPAddress = ip
+ if joomlaVer != "" {
+ existing.JoomlaVer = joomlaVer
+ }
+ _, _ = db.GetEngine(ctx).ID(existing.ID).Cols("last_seen_at", "ip_address", "joomla_ver").Update(existing)
+ return existing, false, nil
+ }
+
+ // Check domain limit (0 = unlimited)
+ if maxDomains > 0 {
+ count, err := CountActivations(ctx, licenseID)
+ if err != nil {
+ return nil, false, err
+ }
+ if count >= int64(maxDomains) {
+ return nil, false, ErrDomainLimitReached{LicenseID: licenseID, Max: maxDomains}
+ }
+ }
+
+ act := &LicenseActivation{
+ LicenseID: licenseID,
+ Domain: domain,
+ IPAddress: ip,
+ JoomlaVer: joomlaVer,
+ }
+ _, err = db.GetEngine(ctx).Insert(act)
+ if err != nil {
+ return nil, false, err
+ }
+ return act, true, nil
+}
+
+// DeactivateDomain removes a domain activation.
+func DeactivateDomain(ctx context.Context, licenseID int64, domain string) error {
+ _, err := db.GetEngine(ctx).
+ Where("license_id = ? AND domain = ?", licenseID, domain).
+ Delete(new(LicenseActivation))
+ return err
+}
+
+// ErrDomainLimitReached is returned when a license has reached its max activated domains.
+type ErrDomainLimitReached struct {
+ LicenseID int64
+ Max int
+}
+
+func (e ErrDomainLimitReached) Error() string {
+ return "license domain limit reached"
+}
diff --git a/models/licensing/audit.go b/models/licensing/audit.go
new file mode 100644
index 0000000000..cc97f38e8b
--- /dev/null
+++ b/models/licensing/audit.go
@@ -0,0 +1,50 @@
+// Copyright 2026 Moko Consulting
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package licensing
+
+import (
+ "context"
+
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
+)
+
+func init() {
+ db.RegisterModel(new(LicenseAuditLog))
+}
+
+// LicenseAuditLog records status transitions and other license events.
+type LicenseAuditLog struct {
+ ID int64 `xorm:"pk autoincr"`
+ LicenseID int64 `xorm:"INDEX NOT NULL"`
+ Action string `xorm:"VARCHAR(50) NOT NULL"` // status_change, tier_change, domain_activate, domain_deactivate
+ OldValue string `xorm:"VARCHAR(100)"`
+ NewValue string `xorm:"VARCHAR(100)"`
+ CreatedAt timeutil.TimeStamp `xorm:"INDEX CREATED"`
+}
+
+func (LicenseAuditLog) TableName() string {
+ return "license_audit_log"
+}
+
+// LogLicenseAudit records a license event.
+func LogLicenseAudit(ctx context.Context, licenseID int64, action, oldVal, newVal string) error {
+ entry := &LicenseAuditLog{
+ LicenseID: licenseID,
+ Action: action,
+ OldValue: oldVal,
+ NewValue: newVal,
+ }
+ _, err := db.GetEngine(ctx).Insert(entry)
+ return err
+}
+
+// GetAuditLog returns audit entries for a license, newest first.
+func GetAuditLog(ctx context.Context, licenseID int64) ([]*LicenseAuditLog, error) {
+ var entries []*LicenseAuditLog
+ return entries, db.GetEngine(ctx).
+ Where("license_id = ?", licenseID).
+ OrderBy("created_at DESC").
+ Find(&entries)
+}
diff --git a/models/licensing/entitlement.go b/models/licensing/entitlement.go
new file mode 100644
index 0000000000..d7c9fb4376
--- /dev/null
+++ b/models/licensing/entitlement.go
@@ -0,0 +1,109 @@
+// Copyright 2026 Moko Consulting
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package licensing
+
+import (
+ "context"
+
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
+)
+
+func init() {
+ db.RegisterModel(new(LicenseEntitlement))
+}
+
+// LicenseEntitlement maps a license to an individual product (repo) it can access.
+type LicenseEntitlement struct {
+ ID int64 `xorm:"pk autoincr"`
+ LicenseID int64 `xorm:"INDEX NOT NULL"`
+ ProductCode string `xorm:"VARCHAR(30) NOT NULL"`
+ RepoOwner string `xorm:"VARCHAR(100) NOT NULL DEFAULT 'MokoConsulting'"`
+ RepoName string `xorm:"VARCHAR(100) NOT NULL"`
+ IsCustom bool `xorm:"NOT NULL DEFAULT false"` // true = manually added, survives tier changes
+ CreatedAt timeutil.TimeStamp `xorm:"CREATED"`
+}
+
+func (LicenseEntitlement) TableName() string {
+ return "license_entitlement"
+}
+
+// GetEntitlementsByLicense returns all entitlements for a license.
+func GetEntitlementsByLicense(ctx context.Context, licenseID int64) ([]*LicenseEntitlement, error) {
+ var ents []*LicenseEntitlement
+ return ents, db.GetEngine(ctx).Where("license_id = ?", licenseID).Find(&ents)
+}
+
+// HasEntitlement checks if a license has access to a specific product code.
+func HasEntitlement(ctx context.Context, licenseID int64, productCode string) (bool, error) {
+ return db.GetEngine(ctx).
+ Where("license_id = ? AND product_code = ?", licenseID, productCode).
+ Exist(new(LicenseEntitlement))
+}
+
+// AddCustomEntitlement adds a manual entitlement that survives tier changes.
+func AddCustomEntitlement(ctx context.Context, licenseID int64, productCode, repoName string) error {
+ ent := &LicenseEntitlement{
+ LicenseID: licenseID,
+ ProductCode: productCode,
+ RepoOwner: "MokoConsulting",
+ RepoName: repoName,
+ IsCustom: true,
+ }
+ _, err := db.GetEngine(ctx).Insert(ent)
+ return err
+}
+
+// RebuildEntitlements deletes non-custom entitlements and rebuilds from the product tier.
+// Custom entitlements (manually added) are preserved.
+func RebuildEntitlements(ctx context.Context, licenseID int64, tierKey string) error {
+ // Delete non-custom entitlements
+ _, err := db.GetEngine(ctx).
+ Where("license_id = ? AND is_custom = ?", licenseID, false).
+ Delete(new(LicenseEntitlement))
+ if err != nil {
+ return err
+ }
+
+ // Look up tier
+ tier, err := GetProductTierByKey(ctx, tierKey)
+ if err != nil || tier == nil {
+ return err
+ }
+
+ // Parse repos JSON
+ var repos []string
+ if err := json.Unmarshal([]byte(tier.Repos), &repos); err != nil {
+ return err
+ }
+
+ // Build product code from repo name (lowercase, stripped)
+ for _, repoName := range repos {
+ productCode := repoName
+ // Check if this entitlement already exists (custom)
+ exists, err := db.GetEngine(ctx).
+ Where("license_id = ? AND product_code = ?", licenseID, productCode).
+ Exist(new(LicenseEntitlement))
+ if err != nil {
+ return err
+ }
+ if exists {
+ continue
+ }
+
+ ent := &LicenseEntitlement{
+ LicenseID: licenseID,
+ ProductCode: productCode,
+ RepoOwner: "MokoConsulting",
+ RepoName: repoName,
+ IsCustom: false,
+ }
+ if _, err := db.GetEngine(ctx).Insert(ent); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
diff --git a/models/licensing/license.go b/models/licensing/license.go
new file mode 100644
index 0000000000..c5daabf204
--- /dev/null
+++ b/models/licensing/license.go
@@ -0,0 +1,184 @@
+// Copyright 2026 Moko Consulting
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package licensing
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "hash/crc32"
+ "strings"
+ "time"
+
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
+)
+
+func init() {
+ db.RegisterModel(new(License))
+}
+
+// License represents a consumer-facing license with a DLID (Download ID).
+type License struct {
+ ID int64 `xorm:"pk autoincr"`
+ UserID int64 `xorm:"INDEX NOT NULL"`
+ DLID string `xorm:"VARCHAR(36) UNIQUE NOT NULL"`
+ Tier string `xorm:"VARCHAR(30) NOT NULL DEFAULT 'base'"`
+ MaxDomains int `xorm:"NOT NULL DEFAULT 1"`
+ Status string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'active'"` // active, expired, revoked, suspended
+ ExpiresAt timeutil.TimeStamp `xorm:"INDEX"`
+ Notes string `xorm:"TEXT"`
+ CreatedAt timeutil.TimeStamp `xorm:"INDEX CREATED"`
+ UpdatedAt timeutil.TimeStamp `xorm:"UPDATED"`
+}
+
+func (License) TableName() string {
+ return "license"
+}
+
+// IsExpired returns true if the license has a set expiry that has passed.
+func (l *License) IsExpired() bool {
+ if l.ExpiresAt == 0 {
+ return false
+ }
+ return time.Unix(int64(l.ExpiresAt), 0).Before(time.Now())
+}
+
+// IsActive returns true if the license status is "active" and not expired.
+func (l *License) IsActive() bool {
+ return l.Status == "active" && !l.IsExpired()
+}
+
+// GenerateDLID creates a new DLID: 28 random hex chars + 4 CRC32 checksum chars,
+// formatted as 8-8-8-8 groups.
+func GenerateDLID() (string, error) {
+ b := make([]byte, 14) // 14 bytes = 28 hex chars
+ if _, err := rand.Read(b); err != nil {
+ return "", err
+ }
+ prefix := hex.EncodeToString(b)
+ checksum := crc32.ChecksumIEEE([]byte(prefix))
+ full := fmt.Sprintf("%s%04x", prefix, checksum&0xFFFF)
+ // Format as 8-8-8-8
+ return fmt.Sprintf("%s-%s-%s-%s", full[0:8], full[8:16], full[16:24], full[24:32]), nil
+}
+
+// ValidateDLIDFormat checks if a DLID has valid format and CRC32 checksum.
+// This is a client-side check that catches typos without a database hit.
+func ValidateDLIDFormat(dlid string) bool {
+ clean := strings.ReplaceAll(dlid, "-", "")
+ if len(clean) != 32 {
+ return false
+ }
+ // Validate hex
+ if _, err := hex.DecodeString(clean); err != nil {
+ return false
+ }
+ // CRC32 check: last 4 chars should match CRC32 of first 28
+ prefix := clean[:28]
+ expected := fmt.Sprintf("%04x", crc32.ChecksumIEEE([]byte(prefix))&0xFFFF)
+ return clean[28:32] == expected
+}
+
+// CreateLicense creates a new license with an auto-generated DLID.
+func CreateLicense(ctx context.Context, userID int64, tier string, maxDomains int, expiresAt timeutil.TimeStamp) (*License, error) {
+ dlid, err := GenerateDLID()
+ if err != nil {
+ return nil, err
+ }
+
+ license := &License{
+ UserID: userID,
+ DLID: dlid,
+ Tier: tier,
+ MaxDomains: maxDomains,
+ Status: "active",
+ ExpiresAt: expiresAt,
+ }
+
+ _, err = db.GetEngine(ctx).Insert(license)
+ if err != nil {
+ return nil, err
+ }
+ return license, nil
+}
+
+// GetLicenseByDLID looks up a license by its DLID string.
+func GetLicenseByDLID(ctx context.Context, dlid string) (*License, error) {
+ license := new(License)
+ has, err := db.GetEngine(ctx).Where("dlid = ?", dlid).Get(license)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, nil
+ }
+ return license, nil
+}
+
+// GetLicenseByID returns a license by primary key.
+func GetLicenseByID(ctx context.Context, id int64) (*License, error) {
+ license := new(License)
+ has, err := db.GetEngine(ctx).ID(id).Get(license)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, nil
+ }
+ return license, nil
+}
+
+// GetLicensesByUser returns all licenses for a user.
+func GetLicensesByUser(ctx context.Context, userID int64) ([]*License, error) {
+ var licenses []*License
+ return licenses, db.GetEngine(ctx).Where("user_id = ?", userID).Find(&licenses)
+}
+
+// UpdateLicenseTier changes a license's tier, rebuilds entitlements, and logs the change.
+func UpdateLicenseTier(ctx context.Context, licenseID int64, newTier string) error {
+ license, err := GetLicenseByID(ctx, licenseID)
+ if err != nil || license == nil {
+ return err
+ }
+ oldTier := license.Tier
+ _, err = db.GetEngine(ctx).ID(licenseID).Cols("tier", "updated_at").Update(&License{Tier: newTier})
+ if err != nil {
+ return err
+ }
+ if err := LogLicenseAudit(ctx, licenseID, "tier_change", oldTier, newTier); err != nil {
+ return err
+ }
+ return RebuildEntitlements(ctx, licenseID, newTier)
+}
+
+// SetLicenseStatus updates the status field and logs the transition.
+func SetLicenseStatus(ctx context.Context, licenseID int64, status string) error {
+ license, err := GetLicenseByID(ctx, licenseID)
+ if err != nil || license == nil {
+ return err
+ }
+ oldStatus := license.Status
+ _, err = db.GetEngine(ctx).ID(licenseID).Cols("status", "updated_at").Update(&License{Status: status})
+ if err != nil {
+ return err
+ }
+ return LogLicenseAudit(ctx, licenseID, "status_change", oldStatus, status)
+}
+
+// RevokeLicense permanently revokes a license.
+func RevokeLicense(ctx context.Context, licenseID int64) error {
+ return SetLicenseStatus(ctx, licenseID, "revoked")
+}
+
+// SuspendLicense temporarily suspends a license.
+func SuspendLicense(ctx context.Context, licenseID int64) error {
+ return SetLicenseStatus(ctx, licenseID, "suspended")
+}
+
+// ReactivateLicense restores a suspended or expired license to active.
+func ReactivateLicense(ctx context.Context, licenseID int64) error {
+ return SetLicenseStatus(ctx, licenseID, "active")
+}
diff --git a/models/licensing/product_tier.go b/models/licensing/product_tier.go
new file mode 100644
index 0000000000..01428e05e5
--- /dev/null
+++ b/models/licensing/product_tier.go
@@ -0,0 +1,58 @@
+// Copyright 2026 Moko Consulting
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package licensing
+
+import (
+ "context"
+
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
+)
+
+func init() {
+ db.RegisterModel(new(ProductTier))
+}
+
+// ProductTier defines a licensing tier and its entitled repositories.
+type ProductTier struct {
+ ID int64 `xorm:"pk autoincr"`
+ TierKey string `xorm:"VARCHAR(30) UNIQUE NOT NULL"`
+ TierName string `xorm:"VARCHAR(100) NOT NULL"`
+ Repos string `xorm:"TEXT"` // JSON array of repo names
+ MaxDomains int `xorm:"NOT NULL DEFAULT 1"`
+ SortOrder int `xorm:"NOT NULL DEFAULT 0"`
+}
+
+func (ProductTier) TableName() string {
+ return "product_tier"
+}
+
+// RepoList parses the Repos JSON field into a string slice.
+func (t *ProductTier) RepoList() []string {
+ var repos []string
+ if t.Repos == "" {
+ return repos
+ }
+ _ = json.Unmarshal([]byte(t.Repos), &repos)
+ return repos
+}
+
+// GetProductTierByKey looks up a tier by its key (e.g. "pos", "suite").
+func GetProductTierByKey(ctx context.Context, key string) (*ProductTier, error) {
+ tier := new(ProductTier)
+ has, err := db.GetEngine(ctx).Where("tier_key = ?", key).Get(tier)
+ if err != nil {
+ return nil, err
+ }
+ if !has {
+ return nil, nil
+ }
+ return tier, nil
+}
+
+// GetAllProductTiers returns all tiers ordered by sort_order.
+func GetAllProductTiers(ctx context.Context) ([]*ProductTier, error) {
+ var tiers []*ProductTier
+ return tiers, db.GetEngine(ctx).OrderBy("sort_order ASC").Find(&tiers)
+}
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index 2437a2faad..fc5ac382d9 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -435,6 +435,7 @@ func prepareMigrationTasks() []*migration {
newMigration(355, "Migrate update server metadata to repo manifest", v1_27.MigrateUpdateServerFieldsToManifest),
newMigration(356, "Rename package_type to extension_type in repo manifest", v1_27.RenamePackageTypeToExtensionType),
newMigration(357, "Drop display_name from repo manifest and update stream config", v1_27.DropDisplayNameColumns),
+ newMigration(358, "Add licensing tables (license, entitlement, activation, product_tier)", v1_27.AddLicensingTables),
}
return preparedMigrations
}
diff --git a/models/migrations/v1_27/v359.go b/models/migrations/v1_27/v359.go
new file mode 100644
index 0000000000..143f8e2e7b
--- /dev/null
+++ b/models/migrations/v1_27/v359.go
@@ -0,0 +1,108 @@
+// Copyright 2026 Moko Consulting
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package v1_27
+
+import (
+ "xorm.io/xorm"
+)
+
+// AddLicensingTables creates the license, license_entitlement, license_activation,
+// and product_tier tables for the consumer-facing DLID licensing system (#617).
+func AddLicensingTables(x *xorm.Engine) error {
+ type License struct {
+ ID int64 `xorm:"pk autoincr"`
+ UserID int64 `xorm:"INDEX NOT NULL"`
+ DLID string `xorm:"VARCHAR(36) UNIQUE NOT NULL"`
+ Tier string `xorm:"VARCHAR(30) NOT NULL DEFAULT 'base'"`
+ MaxDomains int `xorm:"NOT NULL DEFAULT 1"`
+ Status string `xorm:"VARCHAR(20) NOT NULL DEFAULT 'active'"`
+ ExpiresAt int64 `xorm:"INDEX"`
+ Notes string `xorm:"TEXT"`
+ CreatedAt int64 `xorm:"INDEX CREATED"`
+ UpdatedAt int64 `xorm:"UPDATED"`
+ }
+
+ type LicenseEntitlement struct {
+ ID int64 `xorm:"pk autoincr"`
+ LicenseID int64 `xorm:"INDEX NOT NULL"`
+ ProductCode string `xorm:"VARCHAR(30) NOT NULL"`
+ RepoOwner string `xorm:"VARCHAR(100) NOT NULL DEFAULT 'MokoConsulting'"`
+ RepoName string `xorm:"VARCHAR(100) NOT NULL"`
+ IsCustom bool `xorm:"NOT NULL DEFAULT false"`
+ CreatedAt int64 `xorm:"CREATED"`
+ }
+
+ type LicenseActivation struct {
+ ID int64 `xorm:"pk autoincr"`
+ LicenseID int64 `xorm:"INDEX NOT NULL"`
+ Domain string `xorm:"VARCHAR(255) NOT NULL"`
+ IPAddress string `xorm:"VARCHAR(64)"`
+ JoomlaVer string `xorm:"VARCHAR(20)"`
+ ActivatedAt int64 `xorm:"CREATED"`
+ LastSeenAt int64
+ }
+
+ type ProductTier struct {
+ ID int64 `xorm:"pk autoincr"`
+ TierKey string `xorm:"VARCHAR(30) UNIQUE NOT NULL"`
+ TierName string `xorm:"VARCHAR(100) NOT NULL"`
+ Repos string `xorm:"TEXT"`
+ MaxDomains int `xorm:"NOT NULL DEFAULT 1"`
+ SortOrder int `xorm:"NOT NULL DEFAULT 0"`
+ }
+
+ type LicenseAuditLog struct {
+ ID int64 `xorm:"pk autoincr"`
+ LicenseID int64 `xorm:"INDEX NOT NULL"`
+ Action string `xorm:"VARCHAR(50) NOT NULL"`
+ OldValue string `xorm:"VARCHAR(100)"`
+ NewValue string `xorm:"VARCHAR(100)"`
+ CreatedAt int64 `xorm:"INDEX CREATED"`
+ }
+
+ if err := x.Sync(new(License), new(LicenseEntitlement), new(LicenseActivation), new(ProductTier), new(LicenseAuditLog)); err != nil {
+ return err
+ }
+
+ // Add composite unique indexes
+ if _, err := x.Exec("CREATE UNIQUE INDEX IF NOT EXISTS UQE_license_entitlement_lic_prod ON license_entitlement (license_id, product_code)"); err != nil {
+ // MySQL doesn't support IF NOT EXISTS for indexes — try without
+ x.Exec("CREATE UNIQUE INDEX UQE_license_entitlement_lic_prod ON license_entitlement (license_id, product_code)")
+ }
+ if _, err := x.Exec("CREATE UNIQUE INDEX IF NOT EXISTS UQE_license_activation_lic_domain ON license_activation (license_id, domain)"); err != nil {
+ x.Exec("CREATE UNIQUE INDEX UQE_license_activation_lic_domain ON license_activation (license_id, domain)")
+ }
+
+ // Seed product tiers
+ tiers := []ProductTier{
+ {TierKey: "base", TierName: "MokoSuite Base", Repos: `["MokoSuite"]`, MaxDomains: 1, SortOrder: 0},
+ {TierKey: "crm", TierName: "MokoSuite CRM", Repos: `["MokoSuite","MokoSuiteCRM"]`, MaxDomains: 3, SortOrder: 10},
+ {TierKey: "erp", TierName: "MokoSuite ERP", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP"]`, MaxDomains: 3, SortOrder: 20},
+ {TierKey: "child", TierName: "MokoSuite Child", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteChild"]`, MaxDomains: 3, SortOrder: 25},
+ {TierKey: "create", TierName: "MokoSuite Create", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteCreate"]`, MaxDomains: 3, SortOrder: 26},
+ {TierKey: "npo", TierName: "MokoSuite NPO", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteNPO"]`, MaxDomains: 3, SortOrder: 27},
+ {TierKey: "hrm", TierName: "MokoSuite HRM", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteHRM"]`, MaxDomains: 3, SortOrder: 30},
+ {TierKey: "mrp", TierName: "MokoSuite MRP", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuiteMRP"]`, MaxDomains: 3, SortOrder: 35},
+ {TierKey: "pos", TierName: "MokoSuite POS", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuitePOS"]`, MaxDomains: 5, SortOrder: 40},
+ {TierKey: "shop", TierName: "MokoSuite Shop", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuiteShop"]`, MaxDomains: 5, SortOrder: 45},
+ {TierKey: "restaurant", TierName: "MokoSuite Restaurant", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuitePOS","MokoSuiteRestaurant"]`, MaxDomains: 5, SortOrder: 50},
+ {TierKey: "suite", TierName: "MokoSuite Suite", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuitePOS","MokoSuiteShop","MokoSuiteHRM","MokoSuiteMRP","MokoSuiteChild","MokoSuiteCreate","MokoSuiteNPO","MokoSuiteRestaurant","MokoSuiteForms"]`, MaxDomains: 10, SortOrder: 90},
+ {TierKey: "enterprise", TierName: "MokoSuite Enterprise", Repos: `["MokoSuite","MokoSuiteCRM","MokoSuiteERP","MokoSuitePOS","MokoSuiteShop","MokoSuiteHRM","MokoSuiteMRP","MokoSuiteChild","MokoSuiteCreate","MokoSuiteNPO","MokoSuiteRestaurant","MokoSuiteForms","MokoSuiteCommunity","MokoSuiteBackup","MokoSuiteStoreLocator","MokoSuiteOpenGraph","MokoSuiteCross"]`, MaxDomains: 0, SortOrder: 100},
+ }
+
+ for _, t := range tiers {
+ // Only insert if the tier doesn't already exist
+ count, err := x.Where("tier_key = ?", t.TierKey).Count(new(ProductTier))
+ if err != nil {
+ return err
+ }
+ if count == 0 {
+ if _, err := x.Insert(&t); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index d810b4ddeb..16605b5558 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -82,6 +82,7 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/activitypub"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/admin"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/licensing"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/misc"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/notify"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/routers/api/v1/org"
@@ -1858,6 +1859,11 @@ func Routes() *web.Router {
m.Group("/topics", func() {
m.Get("/search", repo.TopicSearch)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
+
+ // Licensing endpoints — DLID-gated, no token required
+ m.Group("/licensing", func() {
+ m.Get("/updates/{product}", licensing.ServeUpdates)
+ })
}, sudo())
return m
diff --git a/routers/api/v1/licensing/updates.go b/routers/api/v1/licensing/updates.go
new file mode 100644
index 0000000000..e2568e62f4
--- /dev/null
+++ b/routers/api/v1/licensing/updates.go
@@ -0,0 +1,240 @@
+// Copyright 2026 Moko Consulting
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package licensing
+
+import (
+ "encoding/xml"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
+ licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
+ repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
+ "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
+)
+
+// Joomla update XML structures.
+
+type xmlUpdates struct {
+ XMLName xml.Name `xml:"updates"`
+ Updates []xmlUpdate `xml:"update"`
+}
+
+type xmlUpdate struct {
+ Name string `xml:"name"`
+ Element string `xml:"element"`
+ Type string `xml:"type"`
+ Version string `xml:"version"`
+ Tag string `xml:"tag"`
+ DownloadURL xmlDownload `xml:"downloadurl"`
+ TargetPlatform xmlTarget `xml:"targetplatform"`
+ PHPMinimum string `xml:"php_minimum,omitempty"`
+}
+
+type xmlDownload struct {
+ Type string `xml:"type,attr"`
+ Format string `xml:"format,attr"`
+ URL string `xml:",chardata"`
+}
+
+type xmlTarget struct {
+ Name string `xml:"name,attr"`
+ Version string `xml:"version,attr"`
+}
+
+// ServeUpdates handles GET /api/v1/licensing/updates/{product}.xml?dlid=XXX&domain=YYY
+func ServeUpdates(ctx *context.APIContext) {
+ productFile := ctx.PathParam("product")
+ productCode := strings.TrimSuffix(productFile, ".xml")
+ dlid := ctx.FormString("dlid")
+ domain := ctx.FormString("domain")
+
+ // Always return XML content type
+ ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
+
+ // Validation failure → empty
+ if dlid == "" || !licensing_model.ValidateDLIDFormat(dlid) {
+ writeEmptyUpdates(ctx)
+ return
+ }
+
+ // Look up license
+ license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
+ if err != nil {
+ log.Error("ServeUpdates: GetLicenseByDLID: %v", err)
+ writeEmptyUpdates(ctx)
+ return
+ }
+ if license == nil || !license.IsActive() {
+ writeEmptyUpdates(ctx)
+ return
+ }
+
+ // Check entitlement
+ hasAccess, err := licensing_model.HasEntitlement(ctx, license.ID, productCode)
+ if err != nil {
+ log.Error("ServeUpdates: HasEntitlement: %v", err)
+ writeEmptyUpdates(ctx)
+ return
+ }
+ if !hasAccess {
+ writeEmptyUpdates(ctx)
+ return
+ }
+
+ // Auto-activate domain
+ if domain != "" {
+ ip := ctx.Req.RemoteAddr
+ if idx := strings.LastIndex(ip, ":"); idx >= 0 {
+ ip = ip[:idx]
+ }
+ _, _, err := licensing_model.ActivateDomain(ctx, license.ID, domain, ip, ctx.FormString("joomla_version"), license.MaxDomains)
+ if err != nil {
+ if _, ok := err.(licensing_model.ErrDomainLimitReached); ok {
+ writeEmptyUpdates(ctx)
+ return
+ }
+ log.Error("ServeUpdates: ActivateDomain: %v", err)
+ }
+ }
+
+ // Resolve repo from entitlement
+ ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
+ if err != nil {
+ log.Error("ServeUpdates: GetEntitlementsByLicense: %v", err)
+ writeEmptyUpdates(ctx)
+ return
+ }
+
+ var repoOwner, repoName string
+ for _, ent := range ents {
+ if ent.ProductCode == productCode {
+ repoOwner = ent.RepoOwner
+ repoName = ent.RepoName
+ break
+ }
+ }
+ if repoName == "" {
+ writeEmptyUpdates(ctx)
+ return
+ }
+
+ // Find the repo
+ repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoOwner, repoName)
+ if err != nil || repo == nil {
+ log.Error("ServeUpdates: repo %s/%s not found: %v", repoOwner, repoName, err)
+ writeEmptyUpdates(ctx)
+ return
+ }
+
+ // Get stable release
+ releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
+ RepoID: repo.ID,
+ ListOptions: db.ListOptions{PageSize: 1, Page: 1},
+ IncludeDrafts: false,
+ IncludeTags: false,
+ TagNames: []string{"stable"},
+ })
+ if err != nil || len(releases) == 0 {
+ // Try latest non-draft release (default order is newest first)
+ releases, err = db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
+ RepoID: repo.ID,
+ ListOptions: db.ListOptions{PageSize: 1, Page: 1},
+ IncludeDrafts: false,
+ IncludeTags: false,
+ })
+ if err != nil || len(releases) == 0 {
+ writeEmptyUpdates(ctx)
+ return
+ }
+ }
+
+ rel := releases[0]
+ version := extractVersion(rel.TagName)
+ if version == "" && rel.Title != "" {
+ version = extractVersion(rel.Title)
+ }
+ if version == "" {
+ version = rel.TagName
+ }
+
+ // Get repo metadata for element name, type, etc.
+ manifest, _ := repo_model.GetRepoMetadata(ctx, repo.ID)
+ element := strings.ToLower(repoName)
+ extType := "package"
+ phpMin := "8.1"
+ targetVer := "6..*"
+ displayName := repoName
+
+ if manifest != nil {
+ if e := manifest.FullElementName(); e != "" {
+ element = e
+ }
+ if manifest.ExtensionType != "" {
+ extType = manifest.ExtensionType
+ }
+ if manifest.PHPMinimum != "" {
+ phpMin = manifest.PHPMinimum
+ }
+ if manifest.TargetVersion != "" {
+ targetVer = manifest.TargetVersion
+ }
+ displayName = manifest.DerivedDisplayName()
+ }
+
+ // Build download URL
+ baseURL := setting.AppURL
+ downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s",
+ baseURL, productCode, version, dlid)
+
+ updates := xmlUpdates{
+ Updates: []xmlUpdate{
+ {
+ Name: displayName,
+ Element: element,
+ Type: extType,
+ Version: version,
+ Tag: "stable",
+ DownloadURL: xmlDownload{
+ Type: "full",
+ Format: "zip",
+ URL: downloadURL,
+ },
+ TargetPlatform: xmlTarget{
+ Name: "joomla",
+ Version: targetVer,
+ },
+ PHPMinimum: phpMin,
+ },
+ },
+ }
+
+ output, err := xml.MarshalIndent(updates, "", " ")
+ if err != nil {
+ log.Error("ServeUpdates: xml.MarshalIndent: %v", err)
+ writeEmptyUpdates(ctx)
+ return
+ }
+
+ ctx.Resp.WriteHeader(http.StatusOK)
+ _, _ = ctx.Resp.Write([]byte(xml.Header))
+ _, _ = ctx.Resp.Write(output)
+}
+
+func writeEmptyUpdates(ctx *context.APIContext) {
+ ctx.Resp.WriteHeader(http.StatusOK)
+ _, _ = ctx.Resp.Write([]byte(xml.Header + "\n"))
+}
+
+// extractVersion strips common tag prefixes to get a clean version.
+func extractVersion(s string) string {
+ v := s
+ for _, prefix := range []string{"v", "release-", "release/", "stable-"} {
+ v = strings.TrimPrefix(v, prefix)
+ }
+ return v
+}