diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d4f2e2..b532d17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,18 +134,18 @@ jobs: run: | short_sha="${GITHUB_SHA:0:12}" mkdir -p release - bash scripts/package-release.sh v5.7.1 "$GITHUB_SHA" "release/Licora-5.7.1-ci-${short_sha}.zip" - cp "release/Licora-5.7.1-ci-${short_sha}.zip" "release/Licora-5.7.1.zip" - python3 scripts/build-update-manifest.py --version 5.7.1 --ref "$GITHUB_SHA" --package "release/Licora-5.7.1.zip" --output release/licora-update-manifest.json - rm "release/Licora-5.7.1.zip" + bash scripts/package-release.sh v5.8.1 "$GITHUB_SHA" "release/Licora-5.8.1-ci-${short_sha}.zip" + cp "release/Licora-5.8.1-ci-${short_sha}.zip" "release/Licora-5.8.1.zip" + python3 scripts/build-update-manifest.py --version 5.8.1 --ref "$GITHUB_SHA" --package "release/Licora-5.8.1.zip" --output release/licora-update-manifest.json + rm "release/Licora-5.8.1.zip" - name: Upload CI release-candidate artifact uses: actions/upload-artifact@v6 with: - name: Licora-5.7.1-${{ github.sha }} + name: Licora-5.8.1-${{ github.sha }} path: | - release/Licora-5.7.1-ci-*.zip - release/Licora-5.7.1-ci-*.zip.sha256 + release/Licora-5.8.1-ci-*.zip + release/Licora-5.8.1-ci-*.zip.sha256 release/licora-update-manifest.json if-no-files-found: error retention-days: 14 diff --git a/BASELINE_v5.8.0.md b/BASELINE_v5.8.0.md new file mode 100644 index 0000000..521b7f3 --- /dev/null +++ b/BASELINE_v5.8.0.md @@ -0,0 +1,20 @@ +# Licora v5.8.0 Candidate Baseline + +- Parent published baseline: `v5.7.1` / `c029fee375895e1384dd18410855da3a38443653`. +- Parent published release ZIP SHA-256: `da99765fcca91262fb778d9ead48c5f069536604d02aa76897ad185b53964a8f`. +- Scope: authenticated Developer Integration Guide, multi-language Secure API v2 lifecycle examples and PowerShell test tool. +- Database migrations: none. +- Delete list: empty. +- External API v1/v2 protocol: frozen/unchanged. +- Dashboard, updater runtime, authentication/roles, license/device semantics and Cron behavior: frozen/unchanged. +- Target version: `5.8.0`. +- Direct signed update source: `5.7.1`. + +This file records the source candidate. It becomes the published official baseline only after merge, exact tag, successful release workflow, signed manifest verification and GitHub Release publication. + +## Candidate Verification + +- Developer Guide targeted contracts: `PASS`. +- Final local repository verifier: `PASS`. +- Remote CI/MySQL: `PENDING`. +- Manual Developer Guide UI/download smoke: `PENDING`. diff --git a/BASELINE_v5.8.1.md b/BASELINE_v5.8.1.md new file mode 100644 index 0000000..b55c75d --- /dev/null +++ b/BASELINE_v5.8.1.md @@ -0,0 +1,23 @@ +# Licora v5.8.1 Source Baseline Freeze + +## Status + +- Parent uploaded baseline: `Licora_v5.8.0_Baseline.zip` +- Parent ZIP SHA-256: `eb718abd13e4bef50654c1ee2730f37f7667abb01e54fc399b0968118bc857bb` +- Parent embedded Git HEAD: `c029fee375895e1384dd18410855da3a38443653` +- Target source version: `5.8.1` +- Release channel: stable candidate +- Database migrations: none +- Delete files: none +- Accepted updater sources: `5.7.1`, `5.8.0` + +## Corrective scope + +1. Re-audit and preserve the v5.8.0 Developer Integration Guide and ten approved API v2 examples. +2. Correct v5.8.x CI candidate ZIP/manifest version coherence. +3. Correct the two Dashboard device glyphs that used an icon unavailable in Bootstrap Icons 1.8.1. +4. Add targeted regression coverage and v5.8.1 release/documentation identity. + +No API/database/license-device/auth/Dashboard-data/Cron/updater-runtime redesign is authorized or included. The reported external Chrome download failure is not attributable to this PHP repository because no Chrome launcher/downloader exists in the baseline; no speculative browser installer is added. + +The final baseline ZIP SHA-256 is recorded in the delivery checksum file generated after this source record is frozen. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8b891..5a32bd4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,35 @@ All notable public-release changes are recorded here. Historical project notes r ### Planned -- No additional Dashboard scope is approved beyond the v5.7.1 Phase 2 corrective release candidate. +- No additional feature scope is approved beyond the verified v5.8.1 Developer Integration Guide correction. + +## [5.8.1] - 2026-08-20 + +### Fixed +- Corrected the pull-request CI candidate-package identity so the v5.8.1 ZIP and generated updater manifest use the same version instead of pairing a v5.8.x package with a stale v5.7.1 manifest version. +- Replaced the unsupported Dashboard `bi-devices` icon with the Bootstrap Icons 1.8.1 `bi-laptop` icon for **Recently Seen Devices** and **Manage Devices**, restoring visible device glyphs without changing Dashboard behavior. +- Expanded release-readiness and Dashboard contracts so future version/manifest drift and the missing Dashboard device icon are rejected automatically. +- Extended the browser-dependency source guard to the Developer Guide page/controller; no Chrome launcher, installer or downloader exists in the Licora PHP source. + +### Verified +- Re-audited the v5.8.0 Developer Integration Guide, ten approved Secure API v2 examples and PowerShell test tool against the existing API v2 proof/token contract. +- No fake/demo API path, shared API v1 secret, database migration, file deletion or external API/backend behavior change is introduced. + +### Compatibility +- Signed update compatibility accepts the published `v5.7.1` baseline and an already-applied `v5.8.0` source candidate. +- API v1/v2 server behavior, license/device enforcement, authentication/roles, Dashboard data semantics, Cron and updater runtime/protocol remain unchanged. + +## [5.8.0] - 2026-08-20 (source baseline; superseded by v5.8.1 corrective) + +### Added +- Added an authenticated compact Developer Guide under **API & Clients** with detected Secure API v2 endpoints, Quick Start, canonical device-proof documentation, stable error codes and production security rules. +- Added downloadable Secure API v2 lifecycle examples for Python, PowerShell/CMD, C, C++, C#/.NET, Java, Flutter, React Native, PHP and Node.js. +- Added a one-file PowerShell lifecycle test and copy/download interactions for developer onboarding. +- Added automated Developer Guide route/security/example contract and syntax checks. + +### Compatibility +- No database migration, file deletion, API v1/v2 protocol change, license/device enforcement change, authentication/role change, Dashboard change, Cron mutation or updater runtime/protocol change. +- Signed update compatibility starts from the published v5.7.1 baseline. ## [5.7.1] - 2026-08-20 diff --git a/README.md b/README.md index b2554e8..a856079 100644 --- a/README.md +++ b/README.md @@ -125,7 +125,7 @@ The application accepts deployment-specific values through environment variables | Database password | `LICENSE_DB_PASS` | empty | | Application name | `APP_NAME` | `Licora` | | Application URL | `APP_URL` | `http://localhost` | -| Application version | `APP_VERSION` | `5.7.1` | +| Application version | `APP_VERSION` | `5.8.1` | | Environment | `APP_ENV` | `production` | | Encryption key | `LICENSE_ENCRYPTION_KEY` | empty fallback | | API limit | `API_RATE_LIMIT` | `1000` | @@ -156,6 +156,8 @@ The validation script checks PHP syntax, security behavior, compatibility invari - [Secure in-app updater](docs/UPDATER.md) - [UI design system](docs/UI_DESIGN_SYSTEM.md) - [Troubleshooting](docs/TROUBLESHOOTING.md) +- [v5.8.1 release notes](RELEASE_NOTES_v5.8.1.md) +- [v5.8.0 source-candidate notes](RELEASE_NOTES_v5.8.0.md) - [v5.7.1 release notes](RELEASE_NOTES_v5.7.1.md) - [v5.7.0 release notes](RELEASE_NOTES_v5.7.0.md) - [v5.6.1 release notes](RELEASE_NOTES_v5.6.1.md) @@ -176,7 +178,12 @@ The validation script checks PHP syntax, security behavior, compatibility invari - [Dependency review](audit/DEPENDENCY_REPORT.md) -## Dashboard Phase 2 verification corrective candidate (v5.7.1) + +## Developer Integration Guide (v5.8.1; introduced in v5.8.0 source candidate) + +The verified v5.8.1 authenticated **API & Clients → Developer Guide** provides a compact Secure API v2 Quick Start, the exact P-256 device-proof canonical contract, detected endpoint URLs, stable error codes and downloadable lifecycle references for Python, PowerShell/CMD, C, C++, C#/.NET, Java, Flutter, React Native, PHP and Node.js. Public-client examples never embed an API v1 shared/master key. Production clients must keep the P-256 private key and rotated refresh credential in platform-secure storage and verify pinned `LICORA-V2`/`RS256` access-token signatures before trusting claims locally. + +## Dashboard Phase 2 published corrective release (v5.7.1) Licora v5.7.1 corrects the client-side refresh lifecycle found during forensic review of the v5.7.0 Phase 2 source baseline: stale refresh keeps `Retry`, `401 AUTH_REQUIRED` keeps refresh paused/disabled, synchronous transport errors are captured without leaving the request lock stuck, and the last-success timestamp advances only after a successful render. Phase 2 layout/data semantics, backend contracts, database schema, APIs, licensing/device enforcement, authentication, Cron behavior, updater protocol and shared shell remain unchanged. diff --git a/RELEASE_COMMANDS_v5.8.0.md b/RELEASE_COMMANDS_v5.8.0.md new file mode 100644 index 0000000..105cc0f --- /dev/null +++ b/RELEASE_COMMANDS_v5.8.0.md @@ -0,0 +1,22 @@ +# Licora v5.8.0 Release Commands + +Run only after the v5.8.0 implementation branch, remote CI and required manual Developer Guide UI/download smoke gates are complete. + +```cmd +cd /d "D:\VibTools_Workspace\02_Websites\01_Licora_Open_Source_Cental_License_System\github_release" +git switch main +git pull --ff-only origin main +git status --short --branch +git rev-parse HEAD +``` + +Create and inspect the release tag only from the verified merge commit: + +```cmd +git tag -a v5.8.0 -m "Licora v5.8.0 - Developer Integration Guide" +git show --no-patch --decorate v5.8.0 +git push origin v5.8.0 +gh run list --workflow release.yml --limit 3 +``` + +Do not rewrite a published tag or release. The tag-triggered release workflow owns exact-tag packaging, checksum generation, signed updater manifest creation, DB integration gates and GitHub Release publication. diff --git a/RELEASE_COMMANDS_v5.8.1.md b/RELEASE_COMMANDS_v5.8.1.md new file mode 100644 index 0000000..c5e675e --- /dev/null +++ b/RELEASE_COMMANDS_v5.8.1.md @@ -0,0 +1,37 @@ +# Licora v5.8.1 Release Commands + +Run only after the v5.8.1 verification delta is applied and Git/CI scope has been reviewed. + +## Local branch publication + +```cmd +git diff --check +git status --short +``` + +Stage only the explicit paths supplied with the final delta audit. Do not use `git add .`, `git add -A` or `git add --all`. + +After staged-scope review: + +```cmd +git diff --cached --check +git diff --cached --name-status +git commit -m "feat: complete Developer Guide with v5.8.1 verification fixes" +git push -u origin feature/v5.8.0-developer-guide +``` + +Create/review the PR against `main`. Merge/tag/release require separate explicit authorization. + +## Tagged release after merge and authorization + +```cmd +git switch main +git pull --ff-only origin main +git status --short --branch +git rev-parse HEAD +git tag -a v5.8.1 -m "Licora v5.8.1 - Developer Integration Guide Complete and Verified" +git show --no-patch --decorate v5.8.1 +git push origin v5.8.1 +``` + +The tag-triggered `release.yml` must build/sign/publish the exact tag. diff --git a/RELEASE_NOTES_v5.8.0.md b/RELEASE_NOTES_v5.8.0.md new file mode 100644 index 0000000..74dc305 --- /dev/null +++ b/RELEASE_NOTES_v5.8.0.md @@ -0,0 +1,29 @@ +# Licora v5.8.0 — Developer Integration Guide + +Licora v5.8.0 adds an authenticated, compact **Developer Guide** to the existing Licora admin shell. The guide documents the real Secure API v2 device-proof contract, shows the detected API v2 endpoints for the current installation, and ships downloadable multi-language lifecycle reference clients. + +## Added + +- New `Developer Guide` route under **API & Clients**. +- Secure API v2 Quick Start covering Client App registration, scoped licensing, P-256 device identity, activation, status, refresh rotation and deactivation. +- Exact canonical request-proof reference for `X-Licora-Timestamp`, `X-Licora-Nonce` and `X-Licora-Device-Signature`. +- Downloadable examples for Python, PowerShell/CMD, C, C++, C#/.NET, Java, Flutter, React Native, PHP and Node.js. +- One-file `licora-v2-test.ps1` lifecycle test for Windows developers. +- Stable error-code and production security checklists. +- Copy-to-clipboard and compact language-tab UI without changing Licora's shared shell. + +## Security + +- Public-client examples never embed the API v1 shared/master credential. +- Examples sign the exact raw JSON bytes sent to Licora and use fresh timestamp/nonce values. +- Test clients use ephemeral P-256 device credentials and deactivate them after the lifecycle test. +- Production guidance requires OS-backed private-key/refresh-token storage and pinned `LICORA-V2`/`RS256` server-token verification before trusting claims locally. + +## Compatibility + +- Direct signed update source: `v5.7.1`. +- Database migrations: none. +- Deleted files: none. +- API v1 behavior: unchanged. +- Secure API v2 protocol/cryptography: unchanged. +- License/device enforcement, authentication/roles, Dashboard, Cron and updater runtime/protocol: unchanged. diff --git a/RELEASE_NOTES_v5.8.1.md b/RELEASE_NOTES_v5.8.1.md new file mode 100644 index 0000000..1a0439f --- /dev/null +++ b/RELEASE_NOTES_v5.8.1.md @@ -0,0 +1,26 @@ +# Licora v5.8.1 — Developer Integration Guide Verification Fix + +Licora v5.8.1 is a no-migration corrective release for the v5.8.0 Developer Integration Guide source candidate. + +## Fixed + +- Aligns the pull-request CI candidate ZIP, release specification and generated updater manifest on version `5.8.1`; the v5.8.0 source candidate used a stale `--version 5.7.1` manifest-builder argument. +- Restores the **Recently Seen Devices** and **Manage Devices** Dashboard glyphs by using `bi-laptop`, which exists in the existing Bootstrap Icons 1.8.1 dependency, instead of unsupported `bi-devices`. +- Adds regression checks for CI version coherence and the Dashboard device-icon contract. + +## Verified scope + +- Preserves the authenticated Developer Guide and exactly ten approved Secure API v2 reference targets: Python, PowerShell/CMD, C, C++, C#/.NET, Java, Flutter, React Native, PHP and Node.js. +- Revalidates the canonical device-proof sequence, P-256/ECDSA-SHA256 signing, activation/refresh/status/deactivate contexts, stable error codes and no-shared-API-v1-secret rule. +- Extends the source browser-dependency guard to the Developer Guide page/controller. Licora contains no Chrome installer/downloader/launcher; any external Chrome download failure requires the separate launcher/wrapper source to diagnose. + +## Compatibility + +- Upgrade sources: `5.7.1`, `5.8.0`. +- Database migrations: none. +- Deleted files: none. +- API v1/v2 server behavior: unchanged. +- License/device enforcement: unchanged. +- Authentication/roles: unchanged. +- Dashboard data/refresh behavior: unchanged; only two icon classes change. +- Cron and updater runtime/protocol: unchanged. diff --git a/REPOSITORY_METADATA.md b/REPOSITORY_METADATA.md index 8951382..8a08849 100644 --- a/REPOSITORY_METADATA.md +++ b/REPOSITORY_METADATA.md @@ -27,10 +27,34 @@ Licora v5.1.0 adds a ten-step first-run installer, delimiter-aware schema execution, secure administrator and secret generation, atomic private configuration, installation locking, optional demo data, database-port support, safe installer diagnostics, and expanded regression validation. The release preserves the existing license engine, API contracts, database schema, routes, admin UI, cron entry points, and encrypted-data compatibility. -## v5.7.1 corrective release candidate +## v5.8.1 Developer Integration Guide verification candidate -- **Intended tag:** `v5.7.1` -- **Publication status:** source candidate only; GitHub write/release not yet authorized +- **Intended tag:** `v5.8.1` +- **Publication status:** source candidate; GitHub publication requires separate authorization +- **Title:** `Licora v5.8.1 — Developer Integration Guide Verification Fix` +- **Release notes:** `RELEASE_NOTES_v5.8.1.md` +- **Database migration:** none +- **Delete list:** empty +- **Supported update sources:** published `v5.7.1`, already-applied `v5.8.0` + +Licora v5.8.1 preserves the v5.8.0 Developer Guide and ten approved API v2 examples, corrects CI package/manifest version coherence and restores the two Dashboard device glyphs with an icon available in the pinned Bootstrap Icons 1.8.1 set. It does not alter API, license/device, authentication, Dashboard data, Cron or updater runtime semantics. + +## v5.8.0 Developer Integration Guide source baseline + +- **Intended tag:** `v5.8.0` +- **Publication status:** uploaded source baseline; superseded by the v5.8.1 verification candidate +- **Title:** `Licora v5.8.0 — Developer Integration Guide` +- **Release notes:** `RELEASE_NOTES_v5.8.0.md` +- **Database migration:** none +- **Delete list:** empty +- **Supported update source:** `v5.7.1` + +Licora v5.8.0 adds an authenticated Developer Guide and downloadable multi-language Secure API v2 lifecycle references without changing the existing API v2 protocol, licensing/device semantics, Dashboard, authentication, Cron or updater runtime. + +## v5.7.1 published corrective release + +- **Tag:** `v5.7.1` +- **Publication status:** published and release-verified - **Title:** `Licora v5.7.1 — Dashboard Phase 2 Verification Fix` - **Release notes:** `RELEASE_NOTES_v5.7.1.md` - **Primary assets:** `Licora-5.7.1.zip`, `.zip.sha256`, `licora-update-manifest.json`, `licora-update-manifest.sig` diff --git a/ROADMAP.md b/ROADMAP.md index 0b5fdfa..5ccf691 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,7 +3,7 @@ ## Dashboard production program (v5.6.1 → v5.7.1) - **Phase 1 — Data Truth, Backend Read Model & Error Contract:** COMPLETE + VERIFIED in the published v5.6.1 baseline. -- **Phase 2 — Compact UI, Reload-Free Refresh & Production Gate:** implemented in the v5.7.0 source baseline and corrected in the v5.7.1 candidate with server-rendered fallback, 30-second authenticated AJAX polling, manual refresh, in-place KPI/chart/activity updates, overlap protection and stale/auth UX. v5.7.1 additionally verifies stale/auth/transport/render lifecycle correctness; remote CI and live production smoke remain release gates. +- **Phase 2 — Compact UI, Reload-Free Refresh & Production Gate:** implemented in the v5.7.0 source baseline and corrected/published in v5.7.1 with server-rendered fallback, 30-second authenticated AJAX polling, manual refresh, in-place KPI/chart/activity updates, overlap protection and stale/auth UX. The v5.7.1 release workflow and live 5.6.1 → 5.7.1 updater smoke are complete. Roadmap items are proposals and must be implemented through reviewed, backward-compatible changes. @@ -39,3 +39,10 @@ Roadmap items are proposals and must be implemented through reviewed, backward-c - v5.4.1: updater recovery and v5.3/v5.4 scope-integrity hotfix with browser DOM contract coverage and release/rollback hardening. - v5.5.0: VibTools compact light UI refinement, truthful runtime-backed Settings, tracked Licora branding, About page and Windows builder-test portability; no database/API/updater protocol change. - v5.5.1: Settings layout, collapsible Settings submenu, and professional About Licora UI hotfix; no database/API/updater behavior change. + +## Developer onboarding program (v5.8.0 → v5.8.1) + +- **Developer Integration Guide:** IMPLEMENTED in the v5.8.0 source candidate and re-audited/corrected in the v5.8.1 verification candidate. +- **v5.8.1 corrective gate:** CI package/manifest version coherence fixed; Dashboard device glyph compatibility fixed; no API/DB/runtime protocol expansion. +- Scope is limited to the authenticated guide route, API & Clients sidebar link, exact Secure API v2 protocol documentation, downloadable examples for the ten approved language/runtime targets, PowerShell test tool, tests/docs/version/release identity. +- API v1/v2 behavior, database schema, licensing/device enforcement, Dashboard, authentication/roles, Cron and updater runtime/protocol remain frozen. diff --git a/admin/assets/css/admin-ui.css b/admin/assets/css/admin-ui.css index 1355b0b..89c4adf 100644 --- a/admin/assets/css/admin-ui.css +++ b/admin/assets/css/admin-ui.css @@ -78,3 +78,83 @@ .dashboard-page .dashboard-chart-body { min-height: 220px; } .dashboard-page .dashboard-chart-body canvas { height: 200px!important; } } + +/* Licora v5.8.0 Developer Guide — scoped integration documentation surface. */ +.developer-guide-page .developer-guide-hero { display:flex; align-items:center; justify-content:space-between; gap:16px; } +.developer-guide-page .developer-guide-hero p { margin:3px 0 0; color:var(--text-secondary); font-size:11px; } +.developer-guide-page .developer-guide-hero-actions { display:flex; gap:7px; flex-wrap:wrap; } +.developer-guide-page .developer-guide-banner { display:grid; grid-template-columns:42px minmax(0,1fr) auto; gap:12px; align-items:center; margin-bottom:12px; padding:13px 14px; border:1px solid var(--state-success-border); border-radius:var(--radius-lg); background:var(--state-success-bg); } +.developer-guide-page .developer-guide-banner-icon { width:42px; height:42px; display:grid; place-items:center; border-radius:var(--radius-md); background:var(--bg-surface-card); color:var(--status-success); font-size:21px; } +.developer-guide-page .developer-guide-banner h3 { margin:1px 0 2px; font-size:15px; } +.developer-guide-page .developer-guide-banner p { margin:0; color:var(--text-secondary); font-size:10.5px; line-height:1.45; } +.developer-guide-page .developer-guide-eyebrow { color:var(--text-muted); font-size:9px; font-weight:var(--font-weight-bold); letter-spacing:.06em; text-transform:uppercase; } +.developer-guide-page .developer-guide-grid { display:grid; grid-template-columns:minmax(0,1.35fr) minmax(320px,.65fr); gap:10px; margin-bottom:10px; } +.developer-guide-page .developer-guide-grid-top { grid-template-columns:minmax(0,1.15fr) minmax(360px,.85fr); } +.developer-guide-page .developer-guide-card { min-width:0; margin-bottom:10px; } +.developer-guide-page .developer-guide-card .card-header h3 { margin:0; font-size:12px; } +.developer-guide-page .developer-guide-card .card-header small { display:block; margin-top:2px; color:var(--text-muted); font-size:9.5px; } +.developer-guide-page .developer-guide-steps { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; margin:0; padding:0; list-style:none; } +.developer-guide-page .developer-guide-steps li { display:grid; grid-template-columns:25px minmax(0,1fr); gap:8px; align-items:start; padding:7px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--bg-surface-soft); } +.developer-guide-page .developer-guide-steps li>span { width:25px; height:25px; display:grid; place-items:center; border-radius:999px; background:var(--component-active-bg); color:var(--licora-primary); font-size:10px; font-weight:var(--font-weight-bold); } +.developer-guide-page .developer-guide-steps strong { display:block; color:var(--text-primary); font-size:10.5px; } +.developer-guide-page .developer-guide-steps small { display:block; margin-top:1px; color:var(--text-muted); font-size:9.2px; line-height:1.35; } +.developer-guide-page .developer-guide-endpoints { display:grid; gap:6px; } +.developer-guide-page .developer-guide-endpoint-row { display:grid; grid-template-columns:105px minmax(0,1fr) 31px; gap:7px; align-items:center; } +.developer-guide-page .developer-guide-endpoint-row>span { color:var(--text-secondary); font-size:9.5px; } +.developer-guide-page .developer-guide-endpoint-row code { min-width:0; overflow:hidden; padding:6px 7px; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); background:var(--bg-surface-soft); color:var(--text-content); font-size:9px; text-overflow:ellipsis; white-space:nowrap; } +.developer-guide-page .developer-guide-note { display:flex; gap:7px; margin-top:8px; padding:8px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--component-feedback-bg); color:var(--text-secondary); font-size:9.5px; line-height:1.4; } +.developer-guide-page .developer-guide-note i { flex:0 0 auto; color:var(--licora-primary); } +.developer-guide-page .developer-guide-proof-grid { display:grid; grid-template-columns:minmax(260px,.55fr) minmax(0,1.45fr); gap:12px; } +.developer-guide-page pre { margin:5px 0 6px; padding:9px 10px; overflow:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--bg-surface-soft); color:var(--text-content); font-size:9.5px; line-height:1.5; white-space:pre; } +.developer-guide-page pre code { color:inherit; font-size:inherit; } +.developer-guide-page .developer-guide-proof-rules { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:7px; } +.developer-guide-page .developer-guide-proof-rules>div { padding:8px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); } +.developer-guide-page .developer-guide-proof-rules strong { display:block; margin-bottom:2px; color:var(--text-primary); font-size:9.5px; } +.developer-guide-page .developer-guide-proof-rules span,.developer-guide-page .developer-guide-proof-rules code { color:var(--text-secondary); font-size:9.2px; line-height:1.4; } +.developer-guide-page .developer-guide-tabs { display:flex; gap:4px; padding:8px 10px; overflow-x:auto; border-bottom:1px solid var(--border-subtle); scrollbar-width:thin; } +.developer-guide-page .developer-guide-tab { display:inline-flex; align-items:center; gap:5px; flex:0 0 auto; min-height:29px; padding:5px 8px; border:1px solid transparent; border-radius:var(--radius-md); background:transparent; color:var(--text-secondary); font-size:9.5px; font-weight:var(--font-weight-medium); } +.developer-guide-page .developer-guide-tab:hover { background:var(--component-hover-bg); color:var(--text-primary); } +.developer-guide-page .developer-guide-tab.active { border-color:var(--component-active-border); background:var(--component-active-bg); color:var(--licora-primary); } +.developer-guide-page .developer-guide-language-panel { padding:11px; } +.developer-guide-page .developer-guide-language-panel[hidden] { display:none!important; } +.developer-guide-page .developer-guide-language-summary { display:flex; align-items:center; justify-content:space-between; gap:12px; margin-bottom:8px; } +.developer-guide-page .developer-guide-language-summary h4 { margin:0; color:var(--text-primary); font-size:13px; } +.developer-guide-page .developer-guide-language-summary p { margin:2px 0 0; color:var(--text-muted); font-size:9.5px; } +.developer-guide-page .developer-guide-command-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; margin-bottom:8px; } +.developer-guide-page .developer-guide-command-grid>div { min-width:0; } +.developer-guide-page .developer-guide-command-grid>div>span { color:var(--text-secondary); font-size:9.2px; font-weight:var(--font-weight-bold); } +.developer-guide-page .developer-guide-command-grid pre { min-height:56px; max-height:105px; white-space:pre-wrap; word-break:break-word; } +.developer-guide-page .developer-guide-source { border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--bg-surface-card); } +.developer-guide-page .developer-guide-source summary { display:flex; align-items:center; justify-content:space-between; gap:10px; padding:8px 9px; cursor:pointer; color:var(--text-primary); font-size:9.5px; font-weight:var(--font-weight-medium); } +.developer-guide-page .developer-guide-source summary span:first-child { display:flex; align-items:center; gap:6px; } +.developer-guide-page .developer-guide-source summary span:last-child { color:var(--text-muted); font-weight:var(--font-weight-normal); } +.developer-guide-page .developer-guide-source-toolbar { display:flex; justify-content:flex-end; padding:7px 8px 0; border-top:1px solid var(--border-subtle); } +.developer-guide-page .developer-guide-source pre { max-height:520px; margin:7px 8px 8px; background:var(--bg-surface-soft); } +.developer-guide-page .developer-guide-security-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:8px; } +.developer-guide-page .developer-guide-security-block { padding:9px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--bg-surface-soft); } +.developer-guide-page .developer-guide-security-block.is-good { border-color:var(--state-success-border); background:var(--state-success-bg); } +.developer-guide-page .developer-guide-security-block.is-bad { border-color:var(--state-danger-border); background:var(--state-danger-bg); } +.developer-guide-page .developer-guide-security-block strong { display:block; margin-bottom:5px; font-size:10.5px; } +.developer-guide-page .developer-guide-security-block ul { margin:0; padding-left:17px; color:var(--text-secondary); font-size:9.3px; line-height:1.5; } +.developer-guide-page .developer-guide-errors { display:flex; flex-wrap:wrap; gap:5px; } +.developer-guide-page .developer-guide-errors code { padding:4px 6px; border:1px solid var(--border-subtle); border-radius:999px; background:var(--bg-surface-soft); color:var(--text-secondary); font-size:8.8px; } +.developer-guide-page .developer-guide-legacy p { margin:0; color:var(--text-secondary); font-size:10px; line-height:1.5; } +.developer-guide-page .developer-guide-links { display:flex; gap:7px; flex-wrap:wrap; margin-top:8px; } +.developer-guide-page .developer-guide-links a { display:inline-flex; align-items:center; gap:5px; padding:5px 7px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); color:var(--text-secondary); font-size:9.5px; text-decoration:none; } +.developer-guide-page .developer-guide-links a:hover { background:var(--component-hover-bg); color:var(--licora-primary); } +@media (max-width:1099.98px) { + .developer-guide-page .developer-guide-grid,.developer-guide-page .developer-guide-grid-top { grid-template-columns:1fr; } +} +@media (max-width:767.98px) { + .developer-guide-page .developer-guide-hero { align-items:flex-start; flex-direction:column; } + .developer-guide-page .developer-guide-banner { grid-template-columns:38px minmax(0,1fr); } + .developer-guide-page .developer-guide-banner>.ui-status { grid-column:2; justify-self:start; } + .developer-guide-page .developer-guide-steps,.developer-guide-page .developer-guide-command-grid,.developer-guide-page .developer-guide-security-grid,.developer-guide-page .developer-guide-proof-rules { grid-template-columns:1fr; } + .developer-guide-page .developer-guide-proof-grid { grid-template-columns:1fr; } + .developer-guide-page .developer-guide-endpoint-row { grid-template-columns:90px minmax(0,1fr) 31px; } +} +@media (max-width:479.98px) { + .developer-guide-page .developer-guide-endpoint-row { grid-template-columns:1fr 31px; } + .developer-guide-page .developer-guide-endpoint-row>span { grid-column:1/-1; } + .developer-guide-page .developer-guide-language-summary { align-items:flex-start; flex-direction:column; } +} diff --git a/admin/assets/examples/licora-v2/c/licora_v2_client.c b/admin/assets/examples/licora-v2/c/licora_v2_client.c new file mode 100644 index 0000000..09fa9e3 --- /dev/null +++ b/admin/assets/examples/licora-v2/c/licora_v2_client.c @@ -0,0 +1,113 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +/* Licora Secure API v2 lifecycle reference. + * Dependencies: libcurl, OpenSSL 3.x, cJSON. + * The test device is ephemeral and is deactivated before exit. + */ + +typedef struct { char *data; size_t len; } Buffer; + +typedef struct { + char base[1024]; + char app_id[128]; + char app_version[65]; + char device_id[128]; + char *public_pem; + EVP_PKEY *device_key; +} LicoraClient; + +static void die(const char *message) { fprintf(stderr, "%s\n", message); exit(1); } + +static size_t write_cb(char *ptr, size_t size, size_t nmemb, void *userdata) { + size_t bytes = size * nmemb; Buffer *b = (Buffer *)userdata; + char *next = (char *)realloc(b->data, b->len + bytes + 1); if (!next) return 0; + b->data = next; memcpy(b->data + b->len, ptr, bytes); b->len += bytes; b->data[b->len] = '\0'; return bytes; +} + +static char *b64url(const unsigned char *data, size_t size) { + size_t cap = 4 * ((size + 2) / 3) + 1; char *out = (char *)malloc(cap); if (!out) return NULL; + int n = EVP_EncodeBlock((unsigned char *)out, data, (int)size); out[n] = '\0'; + for (int i=0;i0 && out[n-1]=='=') out[--n]='\0'; return out; +} + +static unsigned char *b64url_decode(const char *input, size_t *out_len) { + size_t len = strlen(input), padded = ((len + 3) / 4) * 4; char *tmp = (char *)malloc(padded + 1); if (!tmp) return NULL; + memcpy(tmp, input, len); for (size_t i=0;i=2&&tmp[padded-2]=='='?2:(padded&&tmp[padded-1]=='='?1:0); free(tmp); + if(n<0){free(out);return NULL;} *out_len=(size_t)n-pad; out[*out_len]='\0'; return out; +} + +static void sha256_hex(const unsigned char *data, size_t len, char out[65]) { + unsigned char digest[SHA256_DIGEST_LENGTH]; SHA256(data,len,digest); + for(int i=0;ilength+1); if(out){memcpy(out,mem->data,mem->length);out[mem->length]='\0';} BIO_free(bio); return out; +} + +static char *sign_canonical(EVP_PKEY *key,const char *canonical) { + EVP_MD_CTX *ctx=EVP_MD_CTX_new(); size_t len=0; unsigned char *sig=NULL; char *encoded=NULL; if(!ctx)return NULL; + if(EVP_DigestSignInit(ctx,NULL,EVP_sha256(),NULL,key)<=0 || EVP_DigestSignUpdate(ctx,canonical,strlen(canonical))<=0 || EVP_DigestSignFinal(ctx,NULL,&len)<=0)goto done; + sig=(unsigned char*)malloc(len); if(!sig)goto done; if(EVP_DigestSignFinal(ctx,sig,&len)<=0)goto done; encoded=b64url(sig,len); + done: free(sig); EVP_MD_CTX_free(ctx); return encoded; +} + +static char *jwt_jti(const char *token) { + const char *a=strchr(token,'.'); if(!a)return NULL; const char *b=strchr(a+1,'.'); if(!b)return NULL; + size_t seg=(size_t)(b-a-1); char *segment=(char*)malloc(seg+1); memcpy(segment,a+1,seg);segment[seg]='\0'; size_t raw_len=0; unsigned char *raw=b64url_decode(segment,&raw_len); free(segment); if(!raw)return NULL; + cJSON *json=cJSON_Parse((char*)raw); free(raw); if(!json)return NULL; cJSON *jti=cJSON_GetObjectItemCaseSensitive(json,"jti"); char *out=cJSON_IsString(jti)?strdup(jti->valuestring):NULL; cJSON_Delete(json); return out; +} + +static char *json_string(cJSON *object) { char *raw=cJSON_PrintUnformatted(object); if(!raw)die("JSON encoding failed"); return raw; } + +static cJSON *post_json(LicoraClient *client,const char *name,cJSON *payload,const char *context,const char *access_token) { + char url[1400]; snprintf(url,sizeof(url),"%s/api/v2/%s.php",client->base,name); char *body=json_string(payload); char body_hash[65]; sha256_hex((unsigned char*)body,strlen(body),body_hash); + unsigned char nonce_raw[18]; if(RAND_bytes(nonce_raw,sizeof(nonce_raw))!=1)die("RAND_bytes failed"); char *nonce=b64url(nonce_raw,sizeof(nonce_raw)); long long ts=(long long)time(NULL); + const char *path=url_path(url); size_t path_len=strcspn(path,"?#"); char *clean_path=strndup(path,path_len); size_t canon_len=strlen(clean_path)+strlen(nonce)+strlen(body_hash)+strlen(context)+80; char *canonical=(char*)malloc(canon_len); + snprintf(canonical,canon_len,"POST\n%s\n%lld\n%s\n%s\n%s",clean_path,ts,nonce,body_hash,context); char *signature=sign_canonical(client->device_key,canonical); if(!signature)die("device proof signing failed"); + CURL *curl=curl_easy_init(); if(!curl)die("curl_easy_init failed"); struct curl_slist *headers=NULL; char h1[128],h2[256],h3[2048]; snprintf(h1,sizeof(h1),"X-Licora-Timestamp: %lld",ts); snprintf(h2,sizeof(h2),"X-Licora-Nonce: %s",nonce); snprintf(h3,sizeof(h3),"X-Licora-Device-Signature: %s",signature); + headers=curl_slist_append(headers,"Content-Type: application/json"); headers=curl_slist_append(headers,h1); headers=curl_slist_append(headers,h2); headers=curl_slist_append(headers,h3); char *auth=NULL; if(access_token&&*access_token){auth=(char*)malloc(strlen(access_token)+24);sprintf(auth,"Authorization: Bearer %s",access_token);headers=curl_slist_append(headers,auth);} Buffer response={calloc(1,1),0}; + curl_easy_setopt(curl,CURLOPT_URL,url); curl_easy_setopt(curl,CURLOPT_POST,1L); curl_easy_setopt(curl,CURLOPT_POSTFIELDS,body); curl_easy_setopt(curl,CURLOPT_POSTFIELDSIZE,(long)strlen(body)); curl_easy_setopt(curl,CURLOPT_HTTPHEADER,headers); curl_easy_setopt(curl,CURLOPT_WRITEFUNCTION,write_cb); curl_easy_setopt(curl,CURLOPT_WRITEDATA,&response); curl_easy_setopt(curl,CURLOPT_TIMEOUT,20L); + CURLcode rc=curl_easy_perform(curl); long status=0; curl_easy_getinfo(curl,CURLINFO_RESPONSE_CODE,&status); curl_slist_free_all(headers); curl_easy_cleanup(curl); free(auth); free(body);free(nonce);free(clean_path);free(canonical);free(signature); if(rc!=CURLE_OK)die(curl_easy_strerror(rc)); + cJSON *data=cJSON_Parse(response.data); free(response.data); if(!data)die("Licora returned non-JSON response"); cJSON *success=cJSON_GetObjectItemCaseSensitive(data,"success"); if(!cJSON_IsTrue(success)){cJSON *code=cJSON_GetObjectItemCaseSensitive(data,"code");fprintf(stderr,"Licora error %s (HTTP %ld)\n",cJSON_IsString(code)?code->valuestring:"UNKNOWN",status);cJSON_Delete(data);exit(1);} return data; +} + +static cJSON *activate_fixed(LicoraClient *c,const char *license){char context[256];snprintf(context,sizeof(context),"activate:%s",c->app_id);cJSON *o=cJSON_CreateObject();cJSON_AddStringToObject(o,"license_key",license);cJSON_AddStringToObject(o,"app_id",c->app_id);cJSON_AddStringToObject(o,"app_version",c->app_version);cJSON_AddStringToObject(o,"device_id",c->device_id);cJSON_AddStringToObject(o,"device_public_key",c->public_pem);cJSON *r=post_json(c,"activate",o,context,NULL);cJSON_Delete(o);return r;} +static cJSON *status_call(LicoraClient *c,const char *access){char *jti=jwt_jti(access);if(!jti)die("missing access-token jti");cJSON *o=cJSON_CreateObject();cJSON *r=post_json(c,"status",o,jti,access);cJSON_Delete(o);free(jti);return r;} +static cJSON *refresh_call(LicoraClient *c,const char *refresh){char hash[65],context[80];sha256_hex((unsigned char*)refresh,strlen(refresh),hash);snprintf(context,sizeof(context),"refresh:%s",hash);cJSON *o=cJSON_CreateObject();cJSON_AddStringToObject(o,"refresh_token",refresh);cJSON_AddStringToObject(o,"app_version",c->app_version);cJSON *r=post_json(c,"refresh",o,context,NULL);cJSON_Delete(o);return r;} +static cJSON *deactivate_call(LicoraClient *c,const char *access){char *jti=jwt_jti(access);if(!jti)die("missing access-token jti");cJSON *o=cJSON_CreateObject();cJSON *r=post_json(c,"deactivate",o,jti,access);cJSON_Delete(o);free(jti);return r;} + +int main(int argc,char **argv){ + if(argc<4){fprintf(stderr,"Usage: licora_v2_client [app-version]\n");return 2;} + curl_global_init(CURL_GLOBAL_DEFAULT); LicoraClient c={0}; snprintf(c.base,sizeof(c.base),"%s",argv[1]); while(strlen(c.base)&&c.base[strlen(c.base)-1]=='/')c.base[strlen(c.base)-1]='\0'; snprintf(c.app_id,sizeof(c.app_id),"%s",argv[2]); snprintf(c.app_version,sizeof(c.app_version),"%s",argc>4?argv[4]:"1.0.0"); c.device_key=generate_p256(); if(!c.device_key)die("P-256 keygen failed"); c.public_pem=public_pem(c.device_key); unsigned char id[16];RAND_bytes(id,sizeof(id));char *id64=b64url(id,sizeof(id));snprintf(c.device_id,sizeof(c.device_id),"c-%s",id64);free(id64); + char *access=NULL,*refresh=NULL; cJSON *r=activate_fixed(&c,argv[3]); access=strdup(cJSON_GetObjectItemCaseSensitive(r,"access_token")->valuestring);refresh=strdup(cJSON_GetObjectItemCaseSensitive(r,"refresh_token")->valuestring);cJSON_Delete(r);puts("[PASS] activate"); + r=status_call(&c,access);cJSON_Delete(r);puts("[PASS] status"); r=refresh_call(&c,refresh);free(access);free(refresh);access=strdup(cJSON_GetObjectItemCaseSensitive(r,"access_token")->valuestring);refresh=strdup(cJSON_GetObjectItemCaseSensitive(r,"refresh_token")->valuestring);cJSON_Delete(r);puts("[PASS] refresh (rotated)"); + r=status_call(&c,access);cJSON_Delete(r);puts("[PASS] status-after-refresh"); r=deactivate_call(&c,access);cJSON_Delete(r);puts("[PASS] deactivate"); + free(access);free(refresh);free(c.public_pem);EVP_PKEY_free(c.device_key);curl_global_cleanup();return 0; +} diff --git a/admin/assets/examples/licora-v2/cpp/licora_v2_client.cpp b/admin/assets/examples/licora-v2/cpp/licora_v2_client.cpp new file mode 100644 index 0000000..af2aee6 --- /dev/null +++ b/admin/assets/examples/licora-v2/cpp/licora_v2_client.cpp @@ -0,0 +1,196 @@ +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Licora Secure API v2 lifecycle reference. +// Dependencies: libcurl, OpenSSL 3.x, nlohmann/json. +// Production clients must persist the P-256 private key and rotated refresh +// token securely and verify LICORA-V2/RS256 tokens with the pinned server +// public key before trusting token claims locally. + +using json = nlohmann::json; +using PKey = std::unique_ptr; + +static std::string b64url(const unsigned char* data, size_t size) { + std::string out(4 * ((size + 2) / 3), '\0'); + int n = EVP_EncodeBlock(reinterpret_cast(out.data()), data, static_cast(size)); + out.resize(static_cast(n)); + std::replace(out.begin(), out.end(), '+', '-'); + std::replace(out.begin(), out.end(), '/', '_'); + while (!out.empty() && out.back() == '=') out.pop_back(); + return out; +} + +static std::vector b64url_decode(std::string value) { + std::replace(value.begin(), value.end(), '-', '+'); + std::replace(value.begin(), value.end(), '_', '/'); + while (value.size() % 4) value.push_back('='); + std::vector out((value.size() / 4) * 3 + 3); + int n = EVP_DecodeBlock(out.data(), reinterpret_cast(value.data()), static_cast(value.size())); + if (n < 0) throw std::runtime_error("invalid Base64URL"); + size_t pad = value.size() >= 2 && value[value.size()-2] == '=' ? 2 : (value.back() == '=' ? 1 : 0); + out.resize(static_cast(n) - pad); + return out; +} + +static std::string sha256_hex(const std::string& data) { + unsigned char digest[SHA256_DIGEST_LENGTH]; + SHA256(reinterpret_cast(data.data()), data.size(), digest); + std::ostringstream out; + for (unsigned char b : digest) out << std::hex << std::setw(2) << std::setfill('0') << static_cast(b); + return out.str(); +} + +static std::string url_path(const std::string& url) { + auto scheme = url.find("://"); + auto start = scheme == std::string::npos ? 0 : scheme + 3; + auto slash = url.find('/', start); + if (slash == std::string::npos) return "/"; + auto end = url.find_first_of("?#", slash); + return url.substr(slash, end == std::string::npos ? std::string::npos : end - slash); +} + +static std::string random_nonce(size_t bytes = 18) { + std::vector buffer(bytes); + if (RAND_bytes(buffer.data(), static_cast(buffer.size())) != 1) throw std::runtime_error("RAND_bytes failed"); + return b64url(buffer.data(), buffer.size()); +} + +static PKey generate_p256() { + EVP_PKEY_CTX* raw = EVP_PKEY_CTX_new_id(EVP_PKEY_EC, nullptr); + if (!raw) throw std::runtime_error("EVP_PKEY_CTX_new_id failed"); + std::unique_ptr ctx(raw, EVP_PKEY_CTX_free); + if (EVP_PKEY_keygen_init(ctx.get()) <= 0 || EVP_PKEY_CTX_set_ec_paramgen_curve_nid(ctx.get(), NID_X9_62_prime256v1) <= 0) throw std::runtime_error("P-256 init failed"); + EVP_PKEY* key = nullptr; + if (EVP_PKEY_keygen(ctx.get(), &key) <= 0 || !key) throw std::runtime_error("P-256 keygen failed"); + return PKey(key, EVP_PKEY_free); +} + +static std::string public_pem(EVP_PKEY* key) { + BIO* raw = BIO_new(BIO_s_mem()); + if (!raw) throw std::runtime_error("BIO_new failed"); + std::unique_ptr bio(raw, BIO_free); + if (PEM_write_bio_PUBKEY(bio.get(), key) != 1) throw std::runtime_error("public key export failed"); + BUF_MEM* memory = nullptr; BIO_get_mem_ptr(bio.get(), &memory); + return std::string(memory->data, memory->length); +} + +static std::string sign_der_b64url(EVP_PKEY* key, const std::string& canonical) { + EVP_MD_CTX* raw = EVP_MD_CTX_new(); + if (!raw) throw std::runtime_error("EVP_MD_CTX_new failed"); + std::unique_ptr ctx(raw, EVP_MD_CTX_free); + if (EVP_DigestSignInit(ctx.get(), nullptr, EVP_sha256(), nullptr, key) <= 0 || EVP_DigestSignUpdate(ctx.get(), canonical.data(), canonical.size()) <= 0) throw std::runtime_error("ECDSA sign init failed"); + size_t length = 0; + if (EVP_DigestSignFinal(ctx.get(), nullptr, &length) <= 0) throw std::runtime_error("ECDSA signature length failed"); + std::vector signature(length); + if (EVP_DigestSignFinal(ctx.get(), signature.data(), &length) <= 0) throw std::runtime_error("ECDSA signature failed"); + signature.resize(length); // OpenSSL ECDSA output is RFC3279 DER. + return b64url(signature.data(), signature.size()); +} + +static size_t write_body(char* ptr, size_t size, size_t nmemb, void* userdata) { + static_cast(userdata)->append(ptr, size * nmemb); + return size * nmemb; +} + +static std::string jwt_jti(const std::string& token) { + auto first = token.find('.'); auto second = token.find('.', first == std::string::npos ? 0 : first + 1); + if (first == std::string::npos || second == std::string::npos) throw std::runtime_error("malformed access token"); + auto payload = b64url_decode(token.substr(first + 1, second - first - 1)); + return json::parse(std::string(payload.begin(), payload.end())).at("jti").get(); +} + +class LicoraV2Client { +public: + LicoraV2Client(std::string baseUrl, std::string appId, std::string appVersion) + : base_(std::move(baseUrl)), app_(std::move(appId)), version_(std::move(appVersion)), key_(generate_p256()) { + while (!base_.empty() && base_.back() == '/') base_.pop_back(); + unsigned char random[16]; if (RAND_bytes(random, sizeof(random)) != 1) throw std::runtime_error("RAND_bytes failed"); + device_ = "cpp-" + b64url(random, sizeof(random)); + publicPem_ = public_pem(key_.get()); + curl_global_init(CURL_GLOBAL_DEFAULT); + } + ~LicoraV2Client() { curl_global_cleanup(); } + + json activate(const std::string& license) { + return post("activate", {{"license_key",license},{"app_id",app_},{"app_version",version_},{"device_id",device_},{"device_public_key",publicPem_}}, "activate:" + app_); + } + json status(const std::string& access) { return post("status", json::object(), jwt_jti(access), access); } + json refresh(const std::string& refreshToken) { return post("refresh", {{"refresh_token",refreshToken},{"app_version",version_}}, "refresh:" + sha256_hex(refreshToken)); } + json deactivate(const std::string& access) { return post("deactivate", json::object(), jwt_jti(access), access); } + +private: + std::string base_, app_, version_, device_, publicPem_; + PKey key_{nullptr, EVP_PKEY_free}; + + std::string endpoint(const std::string& name) const { return base_ + "/api/v2/" + name + ".php"; } + + json post(const std::string& name, const json& payload, const std::string& context, const std::string& access = "") { + const std::string url = endpoint(name); + const std::string body = payload.dump(); + const auto timestamp = static_cast(std::time(nullptr)); + const std::string nonce = random_nonce(); + const std::string canonical = "POST\n" + url_path(url) + "\n" + std::to_string(timestamp) + "\n" + nonce + "\n" + sha256_hex(body) + "\n" + context; + const std::string signature = sign_der_b64url(key_.get(), canonical); + + CURL* raw = curl_easy_init(); + if (!raw) throw std::runtime_error("curl_easy_init failed"); + std::unique_ptr curl(raw, curl_easy_cleanup); + struct curl_slist* headers = nullptr; + headers = curl_slist_append(headers, "Content-Type: application/json"); + headers = curl_slist_append(headers, ("X-Licora-Timestamp: " + std::to_string(timestamp)).c_str()); + headers = curl_slist_append(headers, ("X-Licora-Nonce: " + nonce).c_str()); + headers = curl_slist_append(headers, ("X-Licora-Device-Signature: " + signature).c_str()); + if (!access.empty()) headers = curl_slist_append(headers, ("Authorization: Bearer " + access).c_str()); + std::unique_ptr headerGuard(headers, curl_slist_free_all); + std::string response; + curl_easy_setopt(curl.get(), CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl.get(), CURLOPT_POST, 1L); + curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDS, body.data()); + curl_easy_setopt(curl.get(), CURLOPT_POSTFIELDSIZE, static_cast(body.size())); + curl_easy_setopt(curl.get(), CURLOPT_HTTPHEADER, headers); + curl_easy_setopt(curl.get(), CURLOPT_WRITEFUNCTION, write_body); + curl_easy_setopt(curl.get(), CURLOPT_WRITEDATA, &response); + curl_easy_setopt(curl.get(), CURLOPT_TIMEOUT, 20L); + CURLcode code = curl_easy_perform(curl.get()); + if (code != CURLE_OK) throw std::runtime_error(std::string("HTTP request failed: ") + curl_easy_strerror(code)); + long statusCode = 0; curl_easy_getinfo(curl.get(), CURLINFO_RESPONSE_CODE, &statusCode); + json data = json::parse(response); + if (!data.value("success", false)) throw std::runtime_error("Licora error " + data.value("code", "UNKNOWN") + " (HTTP " + std::to_string(statusCode) + ")"); + return data; + } +}; + +int main(int argc, char** argv) { + if (argc < 4) { std::cerr << "Usage: licora_v2_client [app-version]\n"; return 2; } + try { + LicoraV2Client client(argv[1], argv[2], argc > 4 ? argv[4] : "1.0.0"); + std::string access; + try { + auto activated = client.activate(argv[3]); access = activated.at("access_token").get(); auto refresh = activated.at("refresh_token").get(); std::cout << "[PASS] activate\n"; + client.status(access); std::cout << "[PASS] status\n"; + auto refreshed = client.refresh(refresh); access = refreshed.at("access_token").get(); refresh = refreshed.at("refresh_token").get(); std::cout << "[PASS] refresh (rotated)\n"; + client.status(access); std::cout << "[PASS] status-after-refresh\n"; + client.deactivate(access); access.clear(); std::cout << "[PASS] deactivate\n"; + } catch (...) { + if (!access.empty()) { try { client.deactivate(access); std::cerr << "[INFO] cleanup deactivate completed\n"; } catch (...) { std::cerr << "[WARN] cleanup deactivate failed\n"; } } + throw; + } + return 0; + } catch (const std::exception& e) { std::cerr << e.what() << '\n'; return 1; } +} diff --git a/admin/assets/examples/licora-v2/csharp/LicoraV2Client.cs b/admin/assets/examples/licora-v2/csharp/LicoraV2Client.cs new file mode 100644 index 0000000..3bf1ce2 --- /dev/null +++ b/admin/assets/examples/licora-v2/csharp/LicoraV2Client.cs @@ -0,0 +1,133 @@ +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; + +// Licora Secure API v2 lifecycle reference for .NET 8+. +// This test client creates an ephemeral device and deactivates it before exit. +// Production clients must persist the device private key and rotated refresh token +// in OS-backed secure storage, and verify LICORA-V2/RS256 tokens with the pinned +// Licora server public key before trusting token claims locally. + +sealed class LicoraV2Client : IDisposable +{ + readonly HttpClient http = new(); + readonly ECDsa deviceKey = ECDsa.Create(ECCurve.NamedCurves.nistP256); + readonly string baseUrl; + readonly string appId; + readonly string appVersion; + readonly string deviceId = "dotnet-" + Guid.NewGuid().ToString("N"); + + public LicoraV2Client(string baseUrl, string appId, string appVersion) + { + this.baseUrl = baseUrl.TrimEnd('/'); + this.appId = appId; + this.appVersion = appVersion; + } + + static string Base64Url(byte[] data) => Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + static byte[] Utf8(string value) => Encoding.UTF8.GetBytes(value); + static string Sha256Hex(byte[] value) => Convert.ToHexString(SHA256.HashData(value)).ToLowerInvariant(); + string Endpoint(string name) => $"{baseUrl}/api/v2/{name}.php"; + + string PublicPem() => deviceKey.ExportSubjectPublicKeyInfoPem().Replace("\r\n", "\n"); + + static string JwtJti(string token) + { + var parts = token.Split('.'); + if (parts.Length != 3) throw new InvalidOperationException("Licora returned a malformed access token."); + var segment = parts[1].Replace('-', '+').Replace('_', '/'); + segment += new string('=', (4 - segment.Length % 4) % 4); + using var document = JsonDocument.Parse(Convert.FromBase64String(segment)); + return document.RootElement.GetProperty("jti").GetString() ?? throw new InvalidOperationException("Access token has no jti."); + } + + async Task PostAsync(string name, object payload, string context, string? accessToken = null) + { + var url = Endpoint(name); + var json = JsonSerializer.Serialize(payload); + var body = Utf8(json); + var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var nonce = Base64Url(RandomNumberGenerator.GetBytes(18)); + var path = new Uri(url).AbsolutePath; + var canonical = $"POST\n{path}\n{timestamp}\n{nonce}\n{Sha256Hex(body)}\n{context}"; + var signature = deviceKey.SignData(Utf8(canonical), HashAlgorithmName.SHA256, DSASignatureFormat.Rfc3279DerSequence); + + using var request = new HttpRequestMessage(HttpMethod.Post, url); + request.Content = new ByteArrayContent(body); + request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + request.Headers.TryAddWithoutValidation("X-Licora-Timestamp", timestamp.ToString()); + request.Headers.TryAddWithoutValidation("X-Licora-Nonce", nonce); + request.Headers.TryAddWithoutValidation("X-Licora-Device-Signature", Base64Url(signature)); + if (!string.IsNullOrWhiteSpace(accessToken)) request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await http.SendAsync(request); + var responseText = await response.Content.ReadAsStringAsync(); + using var document = JsonDocument.Parse(responseText); + var root = document.RootElement.Clone(); + if (!root.TryGetProperty("success", out var success) || !success.GetBoolean()) + { + var code = root.TryGetProperty("code", out var codeNode) ? codeNode.GetString() : "UNKNOWN"; + throw new InvalidOperationException($"Licora error {code} (HTTP {(int)response.StatusCode})"); + } + return root; + } + + public Task ActivateAsync(string licenseKey) => PostAsync("activate", new Dictionary + { + ["license_key"] = licenseKey, + ["app_id"] = appId, + ["app_version"] = appVersion, + ["device_id"] = deviceId, + ["device_public_key"] = PublicPem(), + }, $"activate:{appId}"); + + public Task StatusAsync(string accessToken) => PostAsync("status", new Dictionary(), JwtJti(accessToken), accessToken); + + public Task RefreshAsync(string refreshToken) => PostAsync("refresh", new Dictionary + { + ["refresh_token"] = refreshToken, + ["app_version"] = appVersion, + }, "refresh:" + Sha256Hex(Utf8(refreshToken))); + + public Task DeactivateAsync(string accessToken) => PostAsync("deactivate", new Dictionary(), JwtJti(accessToken), accessToken); + + public void Dispose() { deviceKey.Dispose(); http.Dispose(); } +} + +static class Program +{ + static async Task Main(string[] args) + { + if (args.Length < 3) + { + Console.Error.WriteLine("Usage: dotnet run -- [app-version]"); + return 2; + } + using var client = new LicoraV2Client(args[0], args[1], args.Length > 3 ? args[3] : "1.0.0"); + string accessToken = ""; + try + { + var activated = await client.ActivateAsync(args[2]); + accessToken = activated.GetProperty("access_token").GetString()!; + var refreshToken = activated.GetProperty("refresh_token").GetString()!; + Console.WriteLine("[PASS] activate"); + await client.StatusAsync(accessToken); Console.WriteLine("[PASS] status"); + var refreshed = await client.RefreshAsync(refreshToken); + accessToken = refreshed.GetProperty("access_token").GetString()!; + refreshToken = refreshed.GetProperty("refresh_token").GetString()!; + Console.WriteLine("[PASS] refresh (rotated refresh token)"); + await client.StatusAsync(accessToken); Console.WriteLine("[PASS] status-after-refresh"); + await client.DeactivateAsync(accessToken); accessToken = ""; Console.WriteLine("[PASS] deactivate"); + return 0; + } + finally + { + if (accessToken.Length > 0) + { + try { await client.DeactivateAsync(accessToken); Console.WriteLine("[INFO] cleanup deactivate completed"); } + catch { Console.Error.WriteLine("[WARN] cleanup deactivate failed"); } + } + } + } +} diff --git a/admin/assets/examples/licora-v2/flutter/licora_v2_client.dart b/admin/assets/examples/licora-v2/flutter/licora_v2_client.dart new file mode 100644 index 0000000..a9c766e --- /dev/null +++ b/admin/assets/examples/licora-v2/flutter/licora_v2_client.dart @@ -0,0 +1,129 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:http/http.dart' as http; + +/// Licora Secure API v2 lifecycle reference for Flutter/Dart. +/// +/// This creates an ephemeral P-256 device for developer testing and deactivates +/// it before exit. A production app must persist the private key and rotated +/// refresh token in platform secure storage, and verify LICORA-V2/RS256 tokens +/// with the pinned Licora server public key before trusting token claims. +class LicoraV2Client { + LicoraV2Client(this.baseUrl, this.appId, this.appVersion); + + final String baseUrl; + final String appId; + final String appVersion; + final Ecdsa _ecdsa = Ecdsa.p256(Sha256()); + final Random _random = Random.secure(); + SimpleKeyPair? _keyPair; + String? _publicPem; + late final String deviceId = 'flutter-${_randomBytes(16).map((b) => b.toRadixString(16).padLeft(2, '0')).join()}'; + + List _randomBytes(int count) => List.generate(count, (_) => _random.nextInt(256)); + String _endpoint(String name) => '${baseUrl.replaceFirst(RegExp(r'/+$'), '')}/api/v2/$name.php'; + String _b64url(List data) => base64Url.encode(data).replaceAll('=', ''); + + Future _sha256Hex(List data) async { + final hash = await Sha256().hash(data); + return hash.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); + } + + Future _ensureKey() async { + if (_keyPair != null) return; + _keyPair = await _ecdsa.newKeyPair(); + final publicKey = await _keyPair!.extractPublicKey(); + final raw = publicKey.bytes; + if (raw.length != 65 || raw.first != 0x04) { + throw StateError('Unexpected P-256 public-key encoding; expected uncompressed 65-byte point.'); + } + // X.509 SubjectPublicKeyInfo prefix for id-ecPublicKey + prime256v1. + const prefixHex = '3059301306072a8648ce3d020106082a8648ce3d030107034200'; + final prefix = []; + for (var i = 0; i < prefixHex.length; i += 2) { + prefix.add(int.parse(prefixHex.substring(i, i + 2), radix: 16)); + } + final der = Uint8List.fromList([...prefix, ...raw]); + final encoded = base64.encode(der); + final lines = []; + for (var i = 0; i < encoded.length; i += 64) { + lines.add(encoded.substring(i, min(i + 64, encoded.length))); + } + _publicPem = '-----BEGIN PUBLIC KEY-----\n${lines.join('\n')}\n-----END PUBLIC KEY-----\n'; + } + + List _ecdsaDer(List signature) { + if (signature.isNotEmpty && signature.first == 0x30) return signature; + if (signature.length != 64) throw StateError('Unexpected P-256 signature length: ${signature.length}'); + List integer(List value) { + var i = 0; + while (i < value.length - 1 && value[i] == 0) i++; + var bytes = value.sublist(i); + if ((bytes.first & 0x80) != 0) bytes = [0, ...bytes]; + return [0x02, bytes.length, ...bytes]; + } + final r = integer(signature.sublist(0, 32)); + final s = integer(signature.sublist(32, 64)); + final body = [...r, ...s]; + return [0x30, body.length, ...body]; + } + + String _jti(String token) { + final parts = token.split('.'); + if (parts.length != 3) throw StateError('Licora returned a malformed access token.'); + final payload = jsonDecode(utf8.decode(base64Url.decode(base64Url.normalize(parts[1])))) as Map; + return payload['jti'] as String; + } + + Future> _post(String name, Map payload, String context, [String? accessToken]) async { + await _ensureKey(); + final url = _endpoint(name); + final bodyText = jsonEncode(payload); + final body = utf8.encode(bodyText); + final timestamp = DateTime.now().toUtc().millisecondsSinceEpoch ~/ 1000; + final nonce = _b64url(_randomBytes(18)); + final canonical = ['POST', Uri.parse(url).path, '$timestamp', nonce, await _sha256Hex(body), context].join('\n'); + final signature = await _ecdsa.sign(utf8.encode(canonical), keyPair: _keyPair!); + final response = await http.post(Uri.parse(url), body: body, headers: { + 'Content-Type': 'application/json', + 'X-Licora-Timestamp': '$timestamp', + 'X-Licora-Nonce': nonce, + 'X-Licora-Device-Signature': _b64url(_ecdsaDer(signature.bytes)), + if (accessToken != null) 'Authorization': 'Bearer $accessToken', + }); + final data = jsonDecode(response.body) as Map; + if (data['success'] != true) throw StateError('Licora error ${data['code'] ?? 'UNKNOWN'} (HTTP ${response.statusCode})'); + return data; + } + + Future> activate(String licenseKey) async { + await _ensureKey(); + return _post('activate', { + 'license_key': licenseKey, 'app_id': appId, 'app_version': appVersion, + 'device_id': deviceId, 'device_public_key': _publicPem, + }, 'activate:$appId'); + } + + Future> status(String accessToken) => _post('status', {}, _jti(accessToken), accessToken); + Future> refresh(String refreshToken) async => _post('refresh', { + 'refresh_token': refreshToken, 'app_version': appVersion, + }, 'refresh:${await _sha256Hex(utf8.encode(refreshToken))}'); + Future> deactivate(String accessToken) => _post('deactivate', {}, _jti(accessToken), accessToken); + + static Future lifecycleTest({required String baseUrl, required String appId, required String licenseKey, String appVersion = '1.0.0'}) async { + final client = LicoraV2Client(baseUrl, appId, appVersion); + String accessToken = ''; + try { + final activated = await client.activate(licenseKey); accessToken = activated['access_token'] as String; var refreshToken = activated['refresh_token'] as String; print('[PASS] activate'); + await client.status(accessToken); print('[PASS] status'); + final refreshed = await client.refresh(refreshToken); accessToken = refreshed['access_token'] as String; refreshToken = refreshed['refresh_token'] as String; print('[PASS] refresh (rotated)'); + await client.status(accessToken); print('[PASS] status-after-refresh'); + await client.deactivate(accessToken); accessToken = ''; print('[PASS] deactivate'); + } finally { + if (accessToken.isNotEmpty) { try { await client.deactivate(accessToken); print('[INFO] cleanup deactivate completed'); } catch (_) { print('[WARN] cleanup deactivate failed'); } } + } + } +} diff --git a/admin/assets/examples/licora-v2/java/LicoraV2Client.java b/admin/assets/examples/licora-v2/java/LicoraV2Client.java new file mode 100644 index 0000000..2e6dba4 --- /dev/null +++ b/admin/assets/examples/licora-v2/java/LicoraV2Client.java @@ -0,0 +1,104 @@ +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.ECGenParameterSpec; +import java.time.Instant; +import java.util.*; + +/** Licora Secure API v2 lifecycle reference for Java 17+. */ +public final class LicoraV2Client { + private static final ObjectMapper JSON = new ObjectMapper(); + private final HttpClient http = HttpClient.newHttpClient(); + private final KeyPair deviceKey; + private final String baseUrl; + private final String appId; + private final String appVersion; + private final String deviceId = "java-" + UUID.randomUUID().toString().replace("-", ""); + + public LicoraV2Client(String baseUrl, String appId, String appVersion) throws Exception { + this.baseUrl = baseUrl.replaceAll("/+$", ""); + this.appId = appId; + this.appVersion = appVersion; + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + this.deviceKey = generator.generateKeyPair(); + } + + private static String b64url(byte[] data) { return Base64.getUrlEncoder().withoutPadding().encodeToString(data); } + private static String sha256Hex(byte[] data) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(data); + StringBuilder out = new StringBuilder(); + for (byte b : digest) out.append(String.format("%02x", b)); + return out.toString(); + } + private String endpoint(String name) { return baseUrl + "/api/v2/" + name + ".php"; } + private String publicPem() { + String b64 = Base64.getMimeEncoder(64, "\n".getBytes(StandardCharsets.US_ASCII)).encodeToString(deviceKey.getPublic().getEncoded()); + return "-----BEGIN PUBLIC KEY-----\n" + b64 + "\n-----END PUBLIC KEY-----\n"; + } + private static String jwtJti(String token) throws Exception { + String[] parts = token.split("\\."); + if (parts.length != 3) throw new IllegalStateException("Licora returned a malformed access token."); + JsonNode payload = JSON.readTree(Base64.getUrlDecoder().decode(parts[1])); + return payload.path("jti").asText(); + } + + private JsonNode post(String name, Map payload, String context, String accessToken) throws Exception { + String url = endpoint(name); + byte[] body = JSON.writeValueAsBytes(payload); + long timestamp = Instant.now().getEpochSecond(); + byte[] nonceBytes = new byte[18]; new SecureRandom().nextBytes(nonceBytes); + String nonce = b64url(nonceBytes); + String canonical = String.join("\n", "POST", URI.create(url).getPath(), Long.toString(timestamp), nonce, sha256Hex(body), context); + Signature signer = Signature.getInstance("SHA256withECDSA"); + signer.initSign(deviceKey.getPrivate()); + signer.update(canonical.getBytes(StandardCharsets.UTF_8)); + String signature = b64url(signer.sign()); // SHA256withECDSA returns RFC3279 DER. + + HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url)) + .header("Content-Type", "application/json") + .header("X-Licora-Timestamp", Long.toString(timestamp)) + .header("X-Licora-Nonce", nonce) + .header("X-Licora-Device-Signature", signature) + .POST(HttpRequest.BodyPublishers.ofByteArray(body)); + if (accessToken != null && !accessToken.isBlank()) request.header("Authorization", "Bearer " + accessToken); + HttpResponse response = http.send(request.build(), HttpResponse.BodyHandlers.ofString()); + JsonNode data = JSON.readTree(response.body()); + if (!data.path("success").asBoolean(false)) throw new IllegalStateException("Licora error " + data.path("code").asText("UNKNOWN") + " (HTTP " + response.statusCode() + ")"); + return data; + } + + public JsonNode activate(String licenseKey) throws Exception { + Map body = new LinkedHashMap<>(); + body.put("license_key", licenseKey); body.put("app_id", appId); body.put("app_version", appVersion); + body.put("device_id", deviceId); body.put("device_public_key", publicPem()); + return post("activate", body, "activate:" + appId, null); + } + public JsonNode status(String accessToken) throws Exception { return post("status", new LinkedHashMap<>(), jwtJti(accessToken), accessToken); } + public JsonNode refresh(String refreshToken) throws Exception { + Map body = new LinkedHashMap<>(); body.put("refresh_token", refreshToken); body.put("app_version", appVersion); + return post("refresh", body, "refresh:" + sha256Hex(refreshToken.getBytes(StandardCharsets.UTF_8)), null); + } + public JsonNode deactivate(String accessToken) throws Exception { return post("deactivate", new LinkedHashMap<>(), jwtJti(accessToken), accessToken); } + + public static void main(String[] args) throws Exception { + if (args.length < 3) { System.err.println("Usage: java LicoraV2Client [app-version]"); System.exit(2); } + LicoraV2Client client = new LicoraV2Client(args[0], args[1], args.length > 3 ? args[3] : "1.0.0"); + String accessToken = ""; + try { + JsonNode activated = client.activate(args[2]); accessToken = activated.path("access_token").asText(); String refreshToken = activated.path("refresh_token").asText(); System.out.println("[PASS] activate"); + client.status(accessToken); System.out.println("[PASS] status"); + JsonNode refreshed = client.refresh(refreshToken); accessToken = refreshed.path("access_token").asText(); refreshToken = refreshed.path("refresh_token").asText(); System.out.println("[PASS] refresh (rotated refresh token)"); + client.status(accessToken); System.out.println("[PASS] status-after-refresh"); + client.deactivate(accessToken); accessToken = ""; System.out.println("[PASS] deactivate"); + } finally { + if (!accessToken.isEmpty()) { try { client.deactivate(accessToken); System.out.println("[INFO] cleanup deactivate completed"); } catch (Exception ignored) { System.err.println("[WARN] cleanup deactivate failed"); } } + } + } +} diff --git a/admin/assets/examples/licora-v2/node/licora-v2-client.mjs b/admin/assets/examples/licora-v2/node/licora-v2-client.mjs new file mode 100644 index 0000000..2a8353e --- /dev/null +++ b/admin/assets/examples/licora-v2/node/licora-v2-client.mjs @@ -0,0 +1,127 @@ +#!/usr/bin/env node +/** + * Licora Secure API v2 lifecycle reference. + * + * Ephemeral developer/test flow: activate -> status -> refresh -> status -> + * deactivate. Production apps must persist the P-256 private key and rotated + * refresh token securely and verify LICORA-V2/RS256 tokens with the pinned + * server public key before trusting token claims locally. + */ +import { + createHash, + createSign, + generateKeyPairSync, + randomBytes, +} from 'node:crypto'; + +const b64url = (buffer) => Buffer.from(buffer).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +const sha256 = (value) => createHash('sha256').update(value).digest('hex'); +const compact = (value) => Buffer.from(JSON.stringify(value), 'utf8'); + +function decodeJwtPayload(token) { + const parts = token.split('.'); + if (parts.length !== 3) throw new Error('Licora returned a malformed access token'); + return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); +} + +class LicoraV2Client { + constructor(baseUrl, appId, appVersion) { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.appId = appId; + this.appVersion = appVersion; + const keys = generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + this.publicKey = keys.publicKey; + this.privateKey = keys.privateKey; + this.deviceId = `node-${randomBytes(16).toString('hex')}`; + } + + endpoint(name) { return `${this.baseUrl}/api/v2/${name}.php`; } + + headers(url, body, context, accessToken = '') { + const timestamp = Math.floor(Date.now() / 1000); + const nonce = b64url(randomBytes(18)); + const path = new URL(url).pathname || '/'; + const canonical = ['POST', path, timestamp, nonce, sha256(body), context].join('\n'); + const signer = createSign('SHA256'); + signer.update(canonical, 'utf8'); + signer.end(); + const signature = signer.sign({ key: this.privateKey, dsaEncoding: 'der' }); + return { + 'Content-Type': 'application/json', + 'X-Licora-Timestamp': String(timestamp), + 'X-Licora-Nonce': nonce, + 'X-Licora-Device-Signature': b64url(signature), + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }; + } + + async post(name, payload, context, accessToken = '') { + const url = this.endpoint(name); + const body = compact(payload); + const response = await fetch(url, { method: 'POST', headers: this.headers(url, body, context, accessToken), body }); + const data = await response.json().catch(() => { throw new Error(`HTTP ${response.status}: non-JSON response`); }); + if (!data.success) throw new Error(`Licora error ${data.code || 'UNKNOWN'} (HTTP ${response.status})`); + return data; + } + + activate(licenseKey) { + return this.post('activate', { + license_key: licenseKey, + app_id: this.appId, + app_version: this.appVersion, + device_id: this.deviceId, + device_public_key: this.publicKey, + }, `activate:${this.appId}`); + } + + status(accessToken) { + return this.post('status', {}, String(decodeJwtPayload(accessToken).jti), accessToken); + } + + refresh(refreshToken) { + return this.post('refresh', { refresh_token: refreshToken, app_version: this.appVersion }, `refresh:${sha256(refreshToken)}`); + } + + deactivate(accessToken) { + return this.post('deactivate', {}, String(decodeJwtPayload(accessToken).jti), accessToken); + } +} + +async function main() { + const [baseUrl, appId, licenseKey, appVersion = '1.0.0'] = process.argv.slice(2); + if (!baseUrl || !appId || !licenseKey) { + console.error('Usage: node licora-v2-client.mjs [app-version]'); + process.exitCode = 2; + return; + } + const client = new LicoraV2Client(baseUrl, appId, appVersion); + let accessToken = ''; + try { + const activated = await client.activate(licenseKey); + accessToken = activated.access_token; + let refreshToken = activated.refresh_token; + console.log('[PASS] activate', activated.code); + console.log('[PASS] status', (await client.status(accessToken)).code); + const refreshed = await client.refresh(refreshToken); + accessToken = refreshed.access_token; + refreshToken = refreshed.refresh_token; + console.log('[PASS] refresh', refreshed.code, 'rotated refresh token'); + console.log('[PASS] status-after-refresh', (await client.status(accessToken)).code); + await client.deactivate(accessToken); + accessToken = ''; + console.log('[PASS] deactivate'); + } finally { + if (accessToken) { + try { await client.deactivate(accessToken); console.log('[INFO] cleanup deactivate completed'); } + catch (error) { console.warn('[WARN] cleanup deactivate failed:', error.message); } + } + } +} + +if (import.meta.url === `file://${process.argv[1]}`) main().catch((error) => { console.error(error.message); process.exitCode = 1; }); + +export { LicoraV2Client }; diff --git a/admin/assets/examples/licora-v2/php/licora_v2_client.php b/admin/assets/examples/licora-v2/php/licora_v2_client.php new file mode 100644 index 0000000..2d669d5 --- /dev/null +++ b/admin/assets/examples/licora-v2/php/licora_v2_client.php @@ -0,0 +1,111 @@ +baseUrl = rtrim($baseUrl, '/'); + $this->appId = $appId; + $this->appVersion = $appVersion; + $this->deviceId = 'php-' . bin2hex(random_bytes(16)); + $key = openssl_pkey_new(['private_key_type' => OPENSSL_KEYTYPE_EC, 'curve_name' => 'prime256v1']); + if ($key === false) { throw new RuntimeException('Unable to generate P-256 key.'); } + $details = openssl_pkey_get_details($key); + if (!is_array($details) || empty($details['key'])) { throw new RuntimeException('Unable to export public key.'); } + $this->privateKey = $key; + $this->publicPem = (string)$details['key']; + } + + private function endpoint(string $name): string { return $this->baseUrl . '/api/v2/' . $name . '.php'; } + + private function headers(string $url, string $body, string $context, ?string $accessToken = null): array { + $timestamp = time(); + $nonce = b64url(random_bytes(18)); + $canonical = implode("\n", ['POST', url_path($url), (string)$timestamp, $nonce, sha256hex($body), $context]); + $signature = ''; + if (!openssl_sign($canonical, $signature, $this->privateKey, OPENSSL_ALGO_SHA256)) { throw new RuntimeException('Device proof signing failed.'); } + $headers = [ + 'Content-Type: application/json', + 'X-Licora-Timestamp: ' . $timestamp, + 'X-Licora-Nonce: ' . $nonce, + 'X-Licora-Device-Signature: ' . b64url($signature), + ]; + if ($accessToken !== null && $accessToken !== '') { $headers[] = 'Authorization: Bearer ' . $accessToken; } + return $headers; + } + + private function post(string $name, array $payload, string $context, ?string $accessToken = null): array { + $url = $this->endpoint($name); + $body = compact_json($payload); + $ch = curl_init($url); + if ($ch === false) { throw new RuntimeException('Unable to initialize cURL.'); } + curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => $this->headers($url, $body, $context, $accessToken), CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 20]); + $raw = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + if ($raw === false) { $error = curl_error($ch); curl_close($ch); throw new RuntimeException('HTTP request failed: ' . $error); } + curl_close($ch); + $data = json_decode((string)$raw, true); + if (!is_array($data)) { throw new RuntimeException('HTTP ' . $status . ': non-JSON response.'); } + if (empty($data['success'])) { throw new RuntimeException('Licora error ' . (string)($data['code'] ?? 'UNKNOWN') . ' (HTTP ' . $status . ')'); } + return $data; + } + + public function activate(string $licenseKey): array { + return $this->post('activate', ['license_key'=>$licenseKey,'app_id'=>$this->appId,'app_version'=>$this->appVersion,'device_id'=>$this->deviceId,'device_public_key'=>$this->publicPem], 'activate:' . $this->appId); + } + public function status(string $accessToken): array { return $this->post('status', [], (string)(jwt_payload($accessToken)['jti'] ?? ''), $accessToken); } + public function refresh(string $refreshToken): array { return $this->post('refresh', ['refresh_token'=>$refreshToken,'app_version'=>$this->appVersion], 'refresh:' . hash('sha256', $refreshToken)); } + public function deactivate(string $accessToken): array { return $this->post('deactivate', [], (string)(jwt_payload($accessToken)['jti'] ?? ''), $accessToken); } +} + +if (PHP_SAPI === 'cli' && realpath((string)($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + [$script, $baseUrl, $appId, $licenseKey, $appVersion] = array_pad($argv, 5, null); + $appVersion = $appVersion ?: '1.0.0'; + if (!$baseUrl || !$appId || !$licenseKey) { fwrite(STDERR, "Usage: php licora_v2_client.php [app-version]\n"); exit(2); } + $client = new LicoraV2ReferenceClient($baseUrl, $appId, $appVersion); + $accessToken = ''; + try { + $activated = $client->activate($licenseKey); $accessToken = (string)$activated['access_token']; $refreshToken = (string)$activated['refresh_token']; echo "[PASS] activate\n"; + $client->status($accessToken); echo "[PASS] status\n"; + $refreshed = $client->refresh($refreshToken); $accessToken = (string)$refreshed['access_token']; $refreshToken = (string)$refreshed['refresh_token']; echo "[PASS] refresh (rotated)\n"; + $client->status($accessToken); echo "[PASS] status-after-refresh\n"; + $client->deactivate($accessToken); $accessToken = ''; echo "[PASS] deactivate\n"; + } finally { + if ($accessToken !== '') { try { $client->deactivate($accessToken); echo "[INFO] cleanup deactivate completed\n"; } catch (Throwable $e) { fwrite(STDERR, "[WARN] cleanup deactivate failed\n"); } } + } +} diff --git a/admin/assets/examples/licora-v2/powershell/licora-v2-test.ps1 b/admin/assets/examples/licora-v2/powershell/licora-v2-test.ps1 new file mode 100644 index 0000000..84b018c --- /dev/null +++ b/admin/assets/examples/licora-v2/powershell/licora-v2-test.ps1 @@ -0,0 +1,168 @@ +<# +.SYNOPSIS + Licora Secure API v2 lifecycle test for Windows PowerShell 5.1+ / PowerShell 7+. + +.DESCRIPTION + Creates an ephemeral P-256 device, then runs activate -> status -> refresh -> + status -> deactivate. It never asks for or embeds an API v1 shared key. + + Production applications must persist the device private key and rotated refresh + token in OS-backed secure storage, and verify LICORA-V2/RS256 access-token + signatures with the pinned Licora server public key before trusting token claims. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)][string]$BaseUrl, + [Parameter(Mandatory=$true)][string]$AppId, + [Parameter(Mandatory=$true)][string]$LicenseKey, + [string]$AppVersion = '1.0.0' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName System.Net.Http +try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} + + +function Get-RandomBytes([int]$Count) { + $bytes = New-Object byte[] $Count + $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() + try { $rng.GetBytes($bytes); return $bytes } finally { $rng.Dispose() } +} + +function Convert-ToBase64Url([byte[]]$Bytes) { + return [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+','-').Replace('/','_') +} + +function Convert-FromBase64Url([string]$Value) { + $s = $Value.Replace('-','+').Replace('_','/') + while (($s.Length % 4) -ne 0) { $s += '=' } + return [Convert]::FromBase64String($s) +} + +function Convert-HexToBytes([string]$Hex) { + $bytes = New-Object byte[] ($Hex.Length / 2) + for ($i = 0; $i -lt $bytes.Length; $i++) { $bytes[$i] = [Convert]::ToByte($Hex.Substring($i * 2, 2), 16) } + return $bytes +} + +function Convert-P1363ToDer([byte[]]$Signature) { + if ($Signature.Length -gt 0 -and $Signature[0] -eq 0x30) { return $Signature } + if ($Signature.Length -ne 64) { throw "Unexpected ECDSA signature format/length: $($Signature.Length)" } + function Encode-Integer([byte[]]$Value) { + $start = 0 + while ($start -lt ($Value.Length - 1) -and $Value[$start] -eq 0) { $start++ } + $v = $Value[$start..($Value.Length - 1)] + if (($v[0] -band 0x80) -ne 0) { $v = [byte[]]@(0) + $v } + return [byte[]]@(0x02, [byte]$v.Length) + $v + } + $r = Encode-Integer $Signature[0..31] + $s = Encode-Integer $Signature[32..63] + $body = $r + $s + return [byte[]]@(0x30, [byte]$body.Length) + $body +} + +function Get-PublicKeyPem($Ecdsa) { + $p = $Ecdsa.ExportParameters($false) + if ($p.Q.X.Length -ne 32 -or $p.Q.Y.Length -ne 32) { throw 'Unexpected P-256 public-key coordinate size.' } + # SubjectPublicKeyInfo prefix for id-ecPublicKey + prime256v1, followed by 0x04 || X || Y. + $prefix = Convert-HexToBytes '3059301306072A8648CE3D020106082A8648CE3D03010703420004' + $der = $prefix + $p.Q.X + $p.Q.Y + $b64 = [Convert]::ToBase64String($der) + $lines = for ($i = 0; $i -lt $b64.Length; $i += 64) { $b64.Substring($i, [Math]::Min(64, $b64.Length - $i)) } + return "-----BEGIN PUBLIC KEY-----`n$($lines -join "`n")`n-----END PUBLIC KEY-----`n" +} + +function Get-Sha256Hex([byte[]]$Bytes) { + $sha = [System.Security.Cryptography.SHA256]::Create() + try { return ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-','').ToLowerInvariant() } + finally { $sha.Dispose() } +} + +function Get-Jti([string]$Token) { + $parts = $Token.Split('.') + if ($parts.Length -ne 3) { throw 'Licora returned a malformed access token.' } + $payload = [Text.Encoding]::UTF8.GetString((Convert-FromBase64Url $parts[1])) | ConvertFrom-Json + if (-not $payload.jti) { throw 'Licora access token has no jti.' } + return [string]$payload.jti +} + +function New-LicoraEcdsa { + try { + return [System.Security.Cryptography.ECDsa]::Create([System.Security.Cryptography.ECCurve]::NamedCurves.nistP256) + } catch { + $key = New-Object System.Security.Cryptography.ECDsaCng + $key.GenerateKey([System.Security.Cryptography.ECCurve]::NamedCurves.nistP256) + return $key + } +} + +$script:DeviceKey = New-LicoraEcdsa +$script:PublicPem = Get-PublicKeyPem $script:DeviceKey +$script:DeviceId = 'ps-' + ([Guid]::NewGuid().ToString('N')) +$script:Http = New-Object System.Net.Http.HttpClient +$script:BaseUrl = $BaseUrl.TrimEnd('/') + +function Invoke-LicoraPost([string]$Name, [hashtable]$Payload, [string]$Context, [string]$AccessToken = '') { + $url = "$script:BaseUrl/api/v2/$Name.php" + $bodyText = $Payload | ConvertTo-Json -Compress + $body = [Text.Encoding]::UTF8.GetBytes($bodyText) + $timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $nonce = Convert-ToBase64Url ((Get-RandomBytes 18)) + $path = ([Uri]$url).AbsolutePath + $canonical = "POST`n$path`n$timestamp`n$nonce`n$(Get-Sha256Hex $body)`n$Context" + $canonicalBytes = [Text.Encoding]::UTF8.GetBytes($canonical) + $signature = $script:DeviceKey.SignData($canonicalBytes, [System.Security.Cryptography.HashAlgorithmName]::SHA256) + $signature = Convert-P1363ToDer $signature + + $request = New-Object System.Net.Http.HttpRequestMessage([System.Net.Http.HttpMethod]::Post, $url) + $request.Content = New-Object System.Net.Http.StringContent($bodyText, [Text.Encoding]::UTF8, 'application/json') + [void]$request.Headers.TryAddWithoutValidation('X-Licora-Timestamp', [string]$timestamp) + [void]$request.Headers.TryAddWithoutValidation('X-Licora-Nonce', $nonce) + [void]$request.Headers.TryAddWithoutValidation('X-Licora-Device-Signature', (Convert-ToBase64Url $signature)) + if ($AccessToken) { $request.Headers.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue('Bearer', $AccessToken) } + try { + $response = $script:Http.SendAsync($request).GetAwaiter().GetResult() + $text = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() + try { $data = $text | ConvertFrom-Json } catch { throw "HTTP $([int]$response.StatusCode): non-JSON response" } + if (-not $data.success) { throw "Licora error $($data.code) (HTTP $([int]$response.StatusCode))" } + return $data + } finally { $request.Dispose() } +} + +$accessToken = '' +try { + $activation = Invoke-LicoraPost 'activate' @{ + license_key = $LicenseKey + app_id = $AppId + app_version = $AppVersion + device_id = $script:DeviceId + device_public_key = $script:PublicPem + } "activate:$AppId" + $accessToken = [string]$activation.access_token + $refreshToken = [string]$activation.refresh_token + Write-Host '[PASS] activate' -ForegroundColor Green + + [void](Invoke-LicoraPost 'status' @{} (Get-Jti $accessToken) $accessToken) + Write-Host '[PASS] status' -ForegroundColor Green + + $refreshContext = 'refresh:' + (Get-Sha256Hex ([Text.Encoding]::UTF8.GetBytes($refreshToken))) + $refreshed = Invoke-LicoraPost 'refresh' @{ refresh_token = $refreshToken; app_version = $AppVersion } $refreshContext + $accessToken = [string]$refreshed.access_token + $refreshToken = [string]$refreshed.refresh_token + Write-Host '[PASS] refresh (rotated refresh token)' -ForegroundColor Green + + [void](Invoke-LicoraPost 'status' @{} (Get-Jti $accessToken) $accessToken) + Write-Host '[PASS] status-after-refresh' -ForegroundColor Green + + [void](Invoke-LicoraPost 'deactivate' @{} (Get-Jti $accessToken) $accessToken) + $accessToken = '' + Write-Host '[PASS] deactivate' -ForegroundColor Green +} finally { + if ($accessToken) { + try { [void](Invoke-LicoraPost 'deactivate' @{} (Get-Jti $accessToken) $accessToken); Write-Host '[INFO] cleanup deactivate completed' } + catch { Write-Warning 'cleanup deactivate failed' } + } + if ($script:Http) { $script:Http.Dispose() } + if ($script:DeviceKey) { $script:DeviceKey.Dispose() } +} diff --git a/admin/assets/examples/licora-v2/python/licora_v2_client.py b/admin/assets/examples/licora-v2/python/licora_v2_client.py new file mode 100644 index 0000000..0f50ec9 --- /dev/null +++ b/admin/assets/examples/licora-v2/python/licora_v2_client.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Licora Secure API v2 lifecycle reference. + +This developer/test client creates an ephemeral device credential, exercises +activate -> status -> refresh -> status -> deactivate, then exits. Production +applications must persist the device private key and rotated refresh token in +OS-backed secure storage and verify LICORA-V2/RS256 access-token signatures +with the pinned Licora server public key before trusting token claims locally. +""" +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import secrets +import time +import uuid +from urllib.parse import urljoin, urlparse + +import requests +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec + + +def b64url(data: bytes) -> str: + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def jwt_payload(token: str) -> dict: + parts = token.split(".") + if len(parts) != 3: + raise RuntimeError("Licora returned a malformed access token") + raw = parts[1] + "=" * ((4 - len(parts[1]) % 4) % 4) + return json.loads(base64.urlsafe_b64decode(raw.encode("ascii"))) + + +def canonical(method: str, path: str, timestamp: int, nonce: str, body: bytes, context: str) -> bytes: + return "\n".join([ + method.upper(), path, str(timestamp), nonce, + hashlib.sha256(body).hexdigest().lower(), context, + ]).encode("utf-8") + + +def compact_json(value: dict) -> bytes: + return json.dumps(value, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + + +class LicoraV2: + def __init__(self, base_url: str, app_id: str, app_version: str) -> None: + self.base_url = base_url.rstrip("/") + "/" + self.app_id = app_id + self.app_version = app_version + self.private_key = ec.generate_private_key(ec.SECP256R1()) + self.public_pem = self.private_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("ascii") + self.device_id = "py-" + uuid.uuid4().hex + + def endpoint(self, name: str) -> str: + return urljoin(self.base_url, f"api/v2/{name}.php") + + def proof_headers(self, url: str, body: bytes, context: str, access_token: str | None = None) -> dict[str, str]: + timestamp = int(time.time()) + nonce = b64url(secrets.token_bytes(18)) + path = urlparse(url).path or "/" + signature = self.private_key.sign( + canonical("POST", path, timestamp, nonce, body, context), + ec.ECDSA(hashes.SHA256()), + ) + headers = { + "Content-Type": "application/json", + "X-Licora-Timestamp": str(timestamp), + "X-Licora-Nonce": nonce, + "X-Licora-Device-Signature": b64url(signature), + } + if access_token: + headers["Authorization"] = "Bearer " + access_token + return headers + + def post(self, name: str, payload: dict, context: str, access_token: str | None = None) -> dict: + url = self.endpoint(name) + body = compact_json(payload) + response = requests.post( + url, + data=body, + headers=self.proof_headers(url, body, context, access_token), + timeout=20, + ) + try: + data = response.json() + except ValueError as exc: + raise RuntimeError(f"HTTP {response.status_code}: non-JSON response") from exc + if not data.get("success"): + raise RuntimeError(f"Licora error {data.get('code', 'UNKNOWN')} (HTTP {response.status_code})") + return data + + def activate(self, license_key: str) -> dict: + payload = { + "license_key": license_key, + "app_id": self.app_id, + "app_version": self.app_version, + "device_id": self.device_id, + "device_public_key": self.public_pem, + } + return self.post("activate", payload, "activate:" + self.app_id) + + def status(self, access_token: str) -> dict: + jti = str(jwt_payload(access_token)["jti"]) + return self.post("status", {}, jti, access_token) + + def refresh(self, refresh_token: str) -> dict: + payload = {"refresh_token": refresh_token, "app_version": self.app_version} + context = "refresh:" + hashlib.sha256(refresh_token.encode("utf-8")).hexdigest() + return self.post("refresh", payload, context) + + def deactivate(self, access_token: str) -> dict: + jti = str(jwt_payload(access_token)["jti"]) + return self.post("deactivate", {}, jti, access_token) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--base-url", required=True) + parser.add_argument("--app-id", required=True) + parser.add_argument("--license-key", required=True) + parser.add_argument("--app-version", default="1.0.0") + args = parser.parse_args() + + client = LicoraV2(args.base_url, args.app_id, args.app_version) + access_token = "" + try: + activated = client.activate(args.license_key) + access_token = str(activated["access_token"]) + refresh_token = str(activated["refresh_token"]) + print("[PASS] activate", activated["code"]) + + current = client.status(access_token) + print("[PASS] status", current["code"], current.get("license", {}).get("status")) + + refreshed = client.refresh(refresh_token) + access_token = str(refreshed["access_token"]) + refresh_token = str(refreshed["refresh_token"]) + print("[PASS] refresh", refreshed["code"], "rotated refresh token") + + current = client.status(access_token) + print("[PASS] status-after-refresh", current["code"]) + + client.deactivate(access_token) + access_token = "" + print("[PASS] deactivate") + return 0 + finally: + if access_token: + try: + client.deactivate(access_token) + print("[INFO] cleanup deactivate completed") + except Exception as exc: # cleanup only + print("[WARN] cleanup deactivate failed:", exc) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/admin/assets/examples/licora-v2/react-native/licoraV2Client.js b/admin/assets/examples/licora-v2/react-native/licoraV2Client.js new file mode 100644 index 0000000..430a58f --- /dev/null +++ b/admin/assets/examples/licora-v2/react-native/licoraV2Client.js @@ -0,0 +1,84 @@ +import crypto from 'react-native-quick-crypto'; +import { Buffer } from 'buffer'; + +/** + * Licora Secure API v2 lifecycle reference for React Native. + * + * This developer/test helper creates an ephemeral P-256 credential and + * deactivates it. Production apps must store the private key and rotated + * refresh token with platform-secure storage and verify LICORA-V2/RS256 + * access-token signatures using the pinned Licora server public key. + */ + +const b64url = (data) => Buffer.from(data).toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'); +const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex'); +const randomHex = (bytes) => crypto.randomBytes(bytes).toString('hex'); + +function jwtJti(token) { + const parts = token.split('.'); + if (parts.length !== 3) throw new Error('Licora returned a malformed access token.'); + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + return String(payload.jti); +} + +export class LicoraV2Client { + constructor(baseUrl, appId, appVersion = '1.0.0') { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + this.appId = appId; + this.appVersion = appVersion; + const pair = crypto.generateKeyPairSync('ec', { + namedCurve: 'prime256v1', + publicKeyEncoding: { type: 'spki', format: 'pem' }, + privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, + }); + this.publicKey = pair.publicKey; + this.privateKey = pair.privateKey; + this.deviceId = `rn-${randomHex(16)}`; + } + + endpoint(name) { return `${this.baseUrl}/api/v2/${name}.php`; } + + headers(url, body, context, accessToken) { + const timestamp = Math.floor(Date.now() / 1000); + const nonce = b64url(crypto.randomBytes(18)); + const canonical = ['POST', new URL(url).pathname || '/', timestamp, nonce, sha256(body), context].join('\n'); + const signer = crypto.createSign('SHA256'); + signer.update(canonical, 'utf8'); signer.end(); + const signature = signer.sign({ key: this.privateKey, dsaEncoding: 'der' }); + return { + 'Content-Type': 'application/json', + 'X-Licora-Timestamp': String(timestamp), + 'X-Licora-Nonce': nonce, + 'X-Licora-Device-Signature': b64url(signature), + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}), + }; + } + + async post(name, payload, context, accessToken = '') { + const url = this.endpoint(name); + const body = JSON.stringify(payload); + const response = await fetch(url, { method: 'POST', headers: this.headers(url, Buffer.from(body, 'utf8'), context, accessToken), body }); + const data = await response.json(); + if (!data.success) throw new Error(`Licora error ${data.code || 'UNKNOWN'} (HTTP ${response.status})`); + return data; + } + + activate(licenseKey) { return this.post('activate', { license_key: licenseKey, app_id: this.appId, app_version: this.appVersion, device_id: this.deviceId, device_public_key: this.publicKey }, `activate:${this.appId}`); } + status(accessToken) { return this.post('status', {}, jwtJti(accessToken), accessToken); } + refresh(refreshToken) { return this.post('refresh', { refresh_token: refreshToken, app_version: this.appVersion }, `refresh:${sha256(Buffer.from(refreshToken, 'utf8'))}`); } + deactivate(accessToken) { return this.post('deactivate', {}, jwtJti(accessToken), accessToken); } +} + +export async function lifecycleTest({ baseUrl, appId, licenseKey, appVersion = '1.0.0' }) { + const client = new LicoraV2Client(baseUrl, appId, appVersion); + let accessToken = ''; + try { + const activated = await client.activate(licenseKey); accessToken = activated.access_token; let refreshToken = activated.refresh_token; console.log('[PASS] activate'); + await client.status(accessToken); console.log('[PASS] status'); + const refreshed = await client.refresh(refreshToken); accessToken = refreshed.access_token; refreshToken = refreshed.refresh_token; console.log('[PASS] refresh (rotated)'); + await client.status(accessToken); console.log('[PASS] status-after-refresh'); + await client.deactivate(accessToken); accessToken = ''; console.log('[PASS] deactivate'); + } finally { + if (accessToken) { try { await client.deactivate(accessToken); console.log('[INFO] cleanup deactivate completed'); } catch (_) { console.warn('[WARN] cleanup deactivate failed'); } } + } +} diff --git a/admin/assets/js/developer-guide.js b/admin/assets/js/developer-guide.js new file mode 100644 index 0000000..07a99bc --- /dev/null +++ b/admin/assets/js/developer-guide.js @@ -0,0 +1,72 @@ +(function () { + 'use strict'; + + var tabs = Array.prototype.slice.call(document.querySelectorAll('[data-devguide-tab]')); + var panels = Array.prototype.slice.call(document.querySelectorAll('[data-devguide-panel]')); + + function activate(key, updateHash) { + var found = false; + tabs.forEach(function (tab) { + var active = tab.getAttribute('data-devguide-tab') === key; + tab.classList.toggle('active', active); + tab.setAttribute('aria-selected', active ? 'true' : 'false'); + if (active) { found = true; } + }); + if (!found) { return false; } + panels.forEach(function (panel) { + panel.hidden = panel.getAttribute('data-devguide-panel') !== key; + }); + if (updateHash && window.history && window.history.replaceState) { + window.history.replaceState(null, '', '#' + key); + } + return true; + } + + tabs.forEach(function (tab) { + tab.addEventListener('click', function () { + activate(tab.getAttribute('data-devguide-tab') || '', true); + }); + }); + + var hashKey = (window.location.hash || '').replace(/^#/, ''); + if (hashKey) { activate(hashKey, false); } + + function copyText(text, button) { + var done = function () { + var original = button.innerHTML; + button.innerHTML = ' Copied'; + window.setTimeout(function () { button.innerHTML = original; }, 1400); + }; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(done).catch(function () { fallbackCopy(text, done); }); + return; + } + fallbackCopy(text, done); + } + + function fallbackCopy(text, done) { + var area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', 'readonly'); + area.style.position = 'fixed'; + area.style.opacity = '0'; + document.body.appendChild(area); + area.select(); + try { document.execCommand('copy'); done(); } catch (ignore) {} + document.body.removeChild(area); + } + + Array.prototype.slice.call(document.querySelectorAll('[data-devguide-copy-target]')).forEach(function (button) { + button.addEventListener('click', function () { + var id = button.getAttribute('data-devguide-copy-target') || ''; + var target = document.getElementById(id); + if (target) { copyText(target.textContent || '', button); } + }); + }); + + Array.prototype.slice.call(document.querySelectorAll('[data-devguide-copy-text]')).forEach(function (button) { + button.addEventListener('click', function () { + copyText(button.getAttribute('data-devguide-copy-text') || '', button); + }); + }); +}()); diff --git a/admin/developer_guide.php b/admin/developer_guide.php new file mode 100644 index 0000000..5b46d35 --- /dev/null +++ b/admin/developer_guide.php @@ -0,0 +1,257 @@ +isAdminLoggedIn()) { + header('Location: login.php'); + exit; +} + +$currentPage = 'developer_guide.php'; +$rootUrl = licora_ui_root_url(); +$endpoints = licora_ui_endpoints(); +$canDownloadPublicKey = AdminHelpers::canDelete(); + +$languages = [ + 'python' => [ + 'label' => 'Python', 'icon' => 'bi-code-square', + 'file' => 'assets/examples/licora-v2/python/licora_v2_client.py', + 'install' => 'python -m pip install requests cryptography', + 'run' => 'python licora_v2_client.py --base-url "https://license.example.com" --app-id "my-app" --license-key "AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD" --app-version "1.0.0"', + 'focus' => 'requests + cryptography; ephemeral P-256 lifecycle test.', + ], + 'powershell' => [ + 'label' => 'PowerShell / CMD', 'icon' => 'bi-terminal', + 'file' => 'assets/examples/licora-v2/powershell/licora-v2-test.ps1', + 'install' => 'No external PowerShell module required. Windows PowerShell 5.1+ or PowerShell 7+.', + 'run' => '.\\licora-v2-test.ps1 -BaseUrl "https://license.example.com" -AppId "my-app" -LicenseKey "AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD" -AppVersion "1.0.0"', + 'focus' => 'One-file Windows connectivity/lifecycle test with built-in .NET crypto.', + ], + 'c' => [ + 'label' => 'C', 'icon' => 'bi-braces', + 'file' => 'assets/examples/licora-v2/c/licora_v2_client.c', + 'install' => 'Dependencies: libcurl, OpenSSL 3.x, cJSON. Link with -lcurl -lssl -lcrypto -lcjson.', + 'run' => './licora_v2_client https://license.example.com my-app AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD 1.0.0', + 'focus' => 'libcurl + OpenSSL + cJSON reference client.', + ], + 'cpp' => [ + 'label' => 'C++', 'icon' => 'bi-braces', + 'file' => 'assets/examples/licora-v2/cpp/licora_v2_client.cpp', + 'install' => 'Dependencies: libcurl, OpenSSL 3.x, nlohmann/json. Link with -lcurl -lssl -lcrypto.', + 'run' => './licora_v2_client https://license.example.com my-app AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD 1.0.0', + 'focus' => 'RAII-oriented libcurl/OpenSSL reference client.', + ], + 'csharp' => [ + 'label' => 'C# / .NET', 'icon' => 'bi-hash', + 'file' => 'assets/examples/licora-v2/csharp/LicoraV2Client.cs', + 'install' => 'Target .NET 8+; no third-party package is required by this reference file.', + 'run' => 'dotnet run -- https://license.example.com my-app AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD 1.0.0', + 'focus' => 'HttpClient + ECDsa P-256 + DER signature format.', + ], + 'java' => [ + 'label' => 'Java', 'icon' => 'bi-cup', + 'file' => 'assets/examples/licora-v2/java/LicoraV2Client.java', + 'install' => 'Java 17+ plus Jackson Databind (com.fasterxml.jackson.core:jackson-databind).', + 'run' => 'java LicoraV2Client https://license.example.com my-app AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD 1.0.0', + 'focus' => 'java.net.http + secp256r1 + SHA256withECDSA.', + ], + 'flutter' => [ + 'label' => 'Flutter', 'icon' => 'bi-phone', + 'file' => 'assets/examples/licora-v2/flutter/licora_v2_client.dart', + 'install' => 'flutter pub add http cryptography', + 'run' => 'Call LicoraV2Client.lifecycleTest(...) from a trusted developer/test screen; do not ship test licenses.', + 'focus' => 'http + cryptography with P-256 raw-to-DER signature conversion.', + ], + 'react-native' => [ + 'label' => 'React Native', 'icon' => 'bi-phone', + 'file' => 'assets/examples/licora-v2/react-native/licoraV2Client.js', + 'install' => 'npm install react-native-quick-crypto buffer', + 'run' => 'await lifecycleTest({ baseUrl, appId, licenseKey, appVersion });', + 'focus' => 'react-native-quick-crypto Node-compatible P-256 proof generation.', + ], + 'php' => [ + 'label' => 'PHP', 'icon' => 'bi-code-square', + 'file' => 'assets/examples/licora-v2/php/licora_v2_client.php', + 'install' => 'PHP 8.0+ with curl, openssl and json extensions.', + 'run' => 'php licora_v2_client.php https://license.example.com my-app AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD 1.0.0', + 'focus' => 'cURL + OpenSSL P-256 client reference.', + ], + 'node' => [ + 'label' => 'Node.js', 'icon' => 'bi-code-square', + 'file' => 'assets/examples/licora-v2/node/licora-v2-client.mjs', + 'install' => 'Node.js 20+; this reference uses built-in fetch and node:crypto only.', + 'run' => 'node licora-v2-client.mjs https://license.example.com my-app AAAAAAAA-BBBBBBBB-CCCCCCCC-DDDDDDDD 1.0.0', + 'focus' => 'Built-in fetch + node:crypto P-256 reference client.', + ], +]; + +foreach ($languages as $key => &$language) { + $full = __DIR__ . '/' . $language['file']; + $language['source'] = is_file($full) ? (string)file_get_contents($full) : ''; + $language['download_name'] = basename($language['file']); +} +unset($language); + +$errorCodes = [ + 'INVALID_LICENSE', 'LICENSE_EXPIRED', 'LICENSE_INACTIVE', 'INVALID_APP', 'APP_NOT_ALLOWED', + 'APP_VERSION_UNSUPPORTED', 'DEVICE_LIMIT_REACHED', 'DEVICE_REVOKED', 'DEVICE_KEY_MISMATCH', + 'INVALID_DEVICE_PROOF', 'STALE_REQUEST', 'REPLAY_DETECTED', 'TOKEN_EXPIRED', 'INVALID_REFRESH_TOKEN', + 'REFRESH_TOKEN_REUSED', 'RATE_LIMITED', +]; +?> + + + + + + Developer Guide - Licora + + + + + + + +
+
+
+

Developer Guide

+

Integrate Licora Secure API v2 into desktop, mobile, server and CLI applications.

+
+ +
+ +
+
+
+
Recommended for new integrations
+

Secure API v2

+

Public clients use an App ID, a device-bound P-256 key and signed request proofs. They never embed Licora's API v1 shared/master credential.

+
+ No shared API key +
+ +
+
+

Quick Start

Production client lifecycle
+
+
    +
  1. 1
    Create Client AppChoose a stable lowercase App ID.
  2. +
  3. 2
    Scope a LicenseCreate/assign a license to that API v2 App ID.
  4. +
  5. 3
    Generate P-256 Device KeyKeep the private key on the device; send only the public key.
  6. +
  7. 4
    ActivateSign the exact activation JSON bytes and call activate.php.
  8. +
  9. 5
    Store CredentialsProtect the device private key and refresh token with OS-backed secure storage.
  10. +
  11. 6
    Check StatusUse Bearer access token + fresh device proof.
  12. +
  13. 7
    RefreshRotate refresh credentials and immediately discard the old refresh token.
  14. +
  15. 8
    DeactivateRevoke the device credential when unlinking the installation.
  16. +
+
+
+ +
+

This Installation

Detected read-only integration endpoints
+
+ +
+ + + +
+ +
Examples use a configurable Base URL. If Licora is installed in a subdirectory, keep that subdirectory in the Base URL so the signed request path exactly matches the URL being called.
+
+
+
+ +
+

Device Proof Contract

Sign exactly what you send
+
+
+
Canonical string
+
HTTP_METHOD
+REQUEST_PATH
+TIMESTAMP
+NONCE
+BODY_SHA256
+CONTEXT
+ +
+
+
AlgorithmECDSA P-256 + SHA-256; send the DER signature as Base64URL without padding.
+
Activation contextactivate:<app_id>
+
Refresh contextrefresh:<sha256(refresh_token)>
+
Status / DeactivateUse the access-token jti as proof context.
+
HeadersX-Licora-Timestamp, X-Licora-Nonce, X-Licora-Device-Signature
+
+
+
+ +
+
+

Language Examples

Copy-ready lifecycle references; downloadable files are shipped with Licora.
+
+
+ $language): ?> + + +
+ $language): ?> +
> + +
+
Requirements / Install
+
Run / Use
+
+
+ Full reference source bytes +
+
+
+
+ +
+ +
+
+

Production Security Checklist

Required client trust boundaries
+
+
Always
  • Use HTTPS and validate the server certificate.
  • Generate a P-256 private key on the device and keep it private.
  • Use a new nonce and current timestamp for every request.
  • Rotate and replace refresh tokens after every successful refresh.
  • Pin the trusted Licora API v2 server public signing key before trusting access-token claims.
  • Use stable machine code values for application logic.
+
Never
  • Embed an API v1 shared/master key in desktop/mobile/public code.
  • Send the device private key to Licora.
  • Disable TLS certificate verification.
  • Reuse a used refresh token.
  • Store refresh credentials in plain text when OS secure storage exists.
  • Parse human-readable message strings for control flow.
+
+
The downloadable examples are lifecycle/reference clients and intentionally generate an ephemeral test device, then deactivate it. Production applications must persist their device private key and rotated refresh credential in platform-secure storage. If a production app consumes JWT claims locally, verify the LICORA-V2/RS256 signature with the pinned server public key before trusting those claims.
+
+ +
+

Stable Error Codes

Branch on code, not message
+
+ +
+
+
+ +
+

Legacy / Trusted Server API v1

Backward compatibility only
+
+

Existing trusted server-side integrations may continue using POST /api/verify.php with the reviewed API v1 X-API-Key credential. Do not copy that shared credential into a distributed desktop, mobile, JavaScript bundle or other public client. New public integrations should use Secure API v2 above.

+ +
+
+
+ + + + + diff --git a/admin/includes/ui/navigation.php b/admin/includes/ui/navigation.php index 51c2a68..8831c9b 100644 --- a/admin/includes/ui/navigation.php +++ b/admin/includes/ui/navigation.php @@ -14,6 +14,7 @@ function licora_ui_navigation_groups(): array ['file' => 'api_keys.php', 'label' => 'API Keys', 'icon' => 'bi-key-fill'], ['file' => 'client_apps.php', 'label' => 'Client Apps', 'icon' => 'bi-boxes'], ['file' => 'v2_devices.php', 'label' => 'V2 Devices', 'icon' => 'bi-shield-check'], + ['file' => 'developer_guide.php', 'label' => 'Developer Guide', 'icon' => 'bi-code-slash'], ], 'Operations' => [ ['file' => 'logs.php', 'label' => 'Logs', 'icon' => 'bi-clock-history'], diff --git a/admin/index.php b/admin/index.php index cdcec33..ddde59e 100644 --- a/admin/index.php +++ b/admin/index.php @@ -152,7 +152,7 @@
-
+
Recently Seen Devices @@ -244,7 +244,7 @@
Create LicenseIssue a new license - Manage DevicesReview device records + Manage DevicesReview device records API KeysManage API v1 keys Client AppsManage API v2 apps System HealthOpen health diagnostics diff --git a/audit/V5.8.0_DELTA_MANIFEST.md b/audit/V5.8.0_DELTA_MANIFEST.md new file mode 100644 index 0000000..cc05e80 --- /dev/null +++ b/audit/V5.8.0_DELTA_MANIFEST.md @@ -0,0 +1,92 @@ +# Licora v5.8.0 — Developer Integration Guide Replace-Ready Delta Manifest + +## Authority + +- Official published parent baseline: `Licora v5.7.1` +- Parent tag: `v5.7.1` +- Parent commit: `c029fee375895e1384dd18410855da3a38443653` +- Parent published release ZIP SHA-256: `da99765fcca91262fb778d9ead48c5f069536604d02aa76897ad185b53964a8f` +- Target source version: `5.8.0` +- Patch type: `replace-ready Developer Integration Guide feature delta` +- Delta paths: `48` +- Deleted files: `NONE` +- Database migrations: `NONE` +- Direct signed update source: `5.7.1` + +## Approved Scope + +- authenticated compact Developer Guide page; +- centralized sidebar link under API & Clients; +- exact existing Secure API v2 lifecycle/device-proof documentation; +- downloadable examples for Python, PowerShell/CMD, C, C++, C#/.NET, Java, Flutter, React Native, PHP and Node.js; +- one-file PowerShell lifecycle test tool; +- page-scoped UI/controller code; +- route/security/example verification tests; +- v5.8.0 version, release and documentation alignment. + +## Frozen / Unchanged Protocol Scope + +No API v1/v2 protocol change, database schema/migration change, license/device enforcement change, auth/role change, Dashboard behavior change, Cron change, or updater runtime/protocol change is authorized or included. + +## Apply Rule + +Extract this delta over the root of the exact v5.7.1 published/source baseline and overwrite matching files. Do not delete files and do not run SQL migrations. Private/runtime configuration and `.git` metadata are excluded. + +## Acceptance State + +- Local source implementation: `IMPLEMENTED` +- Local targeted verification: `PASS` +- Final full local verifier: `PASS` +- Remote CI/MySQL: `PENDING` +- Manual Developer Guide UI/download smoke: `PENDING` + +## Delta Paths + +- `.github/workflows/ci.yml` +- `BASELINE_v5.8.0.md` +- `CHANGELOG.md` +- `README.md` +- `RELEASE_COMMANDS_v5.8.0.md` +- `RELEASE_NOTES_v5.8.0.md` +- `REPOSITORY_METADATA.md` +- `ROADMAP.md` +- `admin/assets/css/admin-ui.css` +- `admin/assets/examples/licora-v2/c/licora_v2_client.c` +- `admin/assets/examples/licora-v2/cpp/licora_v2_client.cpp` +- `admin/assets/examples/licora-v2/csharp/LicoraV2Client.cs` +- `admin/assets/examples/licora-v2/flutter/licora_v2_client.dart` +- `admin/assets/examples/licora-v2/java/LicoraV2Client.java` +- `admin/assets/examples/licora-v2/node/licora-v2-client.mjs` +- `admin/assets/examples/licora-v2/php/licora_v2_client.php` +- `admin/assets/examples/licora-v2/powershell/licora-v2-test.ps1` +- `admin/assets/examples/licora-v2/python/licora_v2_client.py` +- `admin/assets/examples/licora-v2/react-native/licoraV2Client.js` +- `admin/assets/js/developer-guide.js` +- `admin/developer_guide.php` +- `admin/includes/ui/navigation.php` +- `audit/V5.8.0_DELTA_MANIFEST.md` +- `audit/V5.8.0_DELTA_SHA256SUMS.txt` +- `audit/V5.8.0_DEVELOPER_GUIDE_AUDIT.md` +- `config.sample.php` +- `docs/ACTUAL_IMPLEMENTATION_LEDGER.md` +- `docs/API.md` +- `docs/API_V2_CLIENT_INTEGRATION.md` +- `docs/ARCHITECTURE.md` +- `docs/CONFIGURATION.md` +- `docs/FEATURE_MATRIX.md` +- `docs/INSTALLATION.md` +- `docs/RELEASE.md` +- `docs/UI_DESIGN_SYSTEM.md` +- `docs/UPGRADE_GUIDE.md` +- `includes/config.php` +- `includes/installation.php` +- `install.php` +- `scripts/verify-local.py` +- `tests/compatibility_regression.php` +- `tests/developer_examples_runtime.py` +- `tests/developer_guide_contract.php` +- `tests/installer_smoke.php` +- `tests/release_readiness.php` +- `tests/ui_route_contract.php` +- `tests/updater_state_machine.php` +- `update/release-spec.json` diff --git a/audit/V5.8.0_DELTA_SHA256SUMS.txt b/audit/V5.8.0_DELTA_SHA256SUMS.txt new file mode 100644 index 0000000..35b38e5 --- /dev/null +++ b/audit/V5.8.0_DELTA_SHA256SUMS.txt @@ -0,0 +1,47 @@ +aed2237d5ff6c49ea7b370ab94d60e7dc0d16c3272acfb9d141a43b1ac31fd98 .github/workflows/ci.yml +cefb97cafa4c6e98f5bcf00d5a35394302e78667631f7def025509409e8f7659 BASELINE_v5.8.0.md +9c3c8286445f85ffe132c9bc987a11f9adf97d1a8239b0f3364d51718c8f6314 CHANGELOG.md +74126b6c61000be84f3d60da56e03defe05f4160781c9ba6f168ff0fb891fe11 README.md +98fcf3c6a089adf759ae0016329b142d3daf2b2b78a7c41c27bda7f6f0722121 RELEASE_COMMANDS_v5.8.0.md +020977e224fb95eb13be78253838d472fb8ff0d7c73d97b466de778b2518be1c RELEASE_NOTES_v5.8.0.md +8cf6b6f24675ae4da0461c34b3feeae6090f17b607f2bc8d5ded161d433bb21a REPOSITORY_METADATA.md +2ea6262ee09d27fb6869e08dc005bbeedff0c83eb1a6b4a1711bc0986da5dcb3 ROADMAP.md +d1aade0939b3da2c369e0f7de4009ef3b13ff3483b828756434d784de6bc76c2 admin/assets/css/admin-ui.css +ed8dc156775de76e80e2e7fdfa5ba5781797c90cf31184ddca9f84cf61488f53 admin/assets/examples/licora-v2/c/licora_v2_client.c +ff1383e3f7b5471573939573342c22917413a701ba3511e55d94b523b23ebc26 admin/assets/examples/licora-v2/cpp/licora_v2_client.cpp +9d611d783a808aa9c3f9e11dd09ec0d8ada4e92aff36ba8cd62af127dfb57a3d admin/assets/examples/licora-v2/csharp/LicoraV2Client.cs +a04abdeb89c3f6c2fcdb9c0f5522e9dfcf70cce1a3974a7e2bba80c394ae5c45 admin/assets/examples/licora-v2/flutter/licora_v2_client.dart +af9b4fc8894f14327622563e6fc9ce669f0c9999624465a477e31d183a3a61b9 admin/assets/examples/licora-v2/java/LicoraV2Client.java +44ae8992346bff2215e7f5316e23c67cc4a4a1b08a2cec14588b55f2c5f3030f admin/assets/examples/licora-v2/node/licora-v2-client.mjs +ac71a304e057f82af44cf5784f894b3536f78fff2995b3817100c8f79269bdf7 admin/assets/examples/licora-v2/php/licora_v2_client.php +96dfed6cde6b9596c97434137fe147c0e13aa37736d4c53983851f19d3d0d0cc admin/assets/examples/licora-v2/powershell/licora-v2-test.ps1 +ba7cb3579ed17d7ccac64134c4d5880fa7d274a46f68f945411d1f6011e7094a admin/assets/examples/licora-v2/python/licora_v2_client.py +199eb404bfd6b1536923fa821e0d3214e03c2f6fbf38fcf8212a754187d9e3f2 admin/assets/examples/licora-v2/react-native/licoraV2Client.js +5204845f1784166760a9f02e19b760c838736bdb9ad6fb4118d9f1ab72de430a admin/assets/js/developer-guide.js +c8bac7568e9fbd4badb7ede9e30b879fd7b4c3b397d11666c0628732ebd47518 admin/developer_guide.php +b58bb0d267dd5437379963e87e22773c9f29dc60dd9a0e7d80de4fdc6141884c admin/includes/ui/navigation.php +2feb28f25a319fa46a9161656663ac4474f987b3a9d85c9fbecbdc423d57f895 audit/V5.8.0_DELTA_MANIFEST.md +5a7b78a733d848bccd405c7c4c2a8774b15ad6be7af59dbe55fe13c6b45db640 audit/V5.8.0_DEVELOPER_GUIDE_AUDIT.md +43fda9c3156473b5156d94436028dc0892376705e1f8c818be9dd02789d0a44b config.sample.php +8eb8b0f570edc24ba3d1979144c6ff0e13a152335751a873d404de9c61a6b2ff docs/ACTUAL_IMPLEMENTATION_LEDGER.md +0b669fd593f0627ff8d63f992ef3bfec107925780bf1c6dd30d3d17c8eb1cf66 docs/API.md +6f1ea0f6ac47ada8ffd476d80d8ff0d478dfa6dc38a063150cc95fbc99bc6fa2 docs/API_V2_CLIENT_INTEGRATION.md +26741482c40f017a2861e1b6cac6ca3b4b393660d4ca8c2cf267bd8dc073a7dc docs/ARCHITECTURE.md +7edee03f5219c05bf0e839bbf794c77750b6a3cc0a31509f055331e30c9b0b09 docs/CONFIGURATION.md +0de54ca8ac1be7b1b1f9cbca6fe8de7da2a04080ecac41cc897831ca8e429574 docs/FEATURE_MATRIX.md +236d8ec1a2e9c1538bca8b411c4526c6531ba91e2aee17a9fe414f802677bb40 docs/INSTALLATION.md +17a71e2b51c3c357f3257d22ceebb515095d3385875f37fdcbc1442af209412a docs/RELEASE.md +81d831eafff2a32fa1e919b3700b366ef3637c1d6d021ddb51b2bb9abba22d44 docs/UI_DESIGN_SYSTEM.md +49edd5cc0c1ef1f6a0635af8cbb26a4774018e128d2a708375e5a50482a01be1 docs/UPGRADE_GUIDE.md +c575086728f4b6b37f325b9d840e19eeeaa2e9eba595072fe8a58675b9c39915 includes/config.php +e9466bebd36bebd7d27de46c2949e9d54071debe17f43d2603a151fa37bf93d9 includes/installation.php +26853cbe3921165371c56fe1f34f432fc012d0a4e0f485daa390e18bf6af6a9a install.php +0953e56e2185b3a079c1ce8ed6827a0b99f2ac9d98d681ba489b57cc3a0a8610 scripts/verify-local.py +ff0747cedc03d2e24e6afc42ac79cfc65810d608a9671826d2b55bb8d0ed1555 tests/compatibility_regression.php +a241df698b6b5e9d64da2a1072c2ad9ba9a3de76deadae2040cc56f39581b808 tests/developer_examples_runtime.py +bd261193c5363e7e23f27cf337b19804d5e31dc8033df893ad8dad5c46cb97c7 tests/developer_guide_contract.php +8fd3229d71e3ca1e48e2acb90d95f023572bef350a26df3a419cd07192b582ff tests/installer_smoke.php +b0baaf041385a581ff83bbbdbdff13296aed50e940cb18b77940e712dbfcf306 tests/release_readiness.php +9478b7708b543c5b44dd98670ebcf83e122dc2bf72646f89c108ad33a9367158 tests/ui_route_contract.php +77530c75384f10717b43f6995579008f1c928cd5d41070fbab16129ff776e836 tests/updater_state_machine.php +66acfbdb9a91f0f0839c8d323e36000f80a52c9f6321b33d6cfdb3b25b7340e4 update/release-spec.json diff --git a/audit/V5.8.0_DEVELOPER_GUIDE_AUDIT.md b/audit/V5.8.0_DEVELOPER_GUIDE_AUDIT.md new file mode 100644 index 0000000..907d269 --- /dev/null +++ b/audit/V5.8.0_DEVELOPER_GUIDE_AUDIT.md @@ -0,0 +1,168 @@ +# Licora v5.8.0 — Developer Integration Guide Forensic Audit + +## Authority / Scope Lock + +- Published parent baseline: `v5.7.1`. +- Parent Git commit: `c029fee375895e1384dd18410855da3a38443653`. +- Parent published release ZIP SHA-256: `da99765fcca91262fb778d9ead48c5f069536604d02aa76897ad185b53964a8f`. +- Target: `v5.8.0`. +- Approved scope: authenticated Developer Integration Guide, exactly ten Secure API v2 language/runtime examples, and one-file PowerShell lifecycle test tool. +- GitHub write/push/PR/tag/release: not part of this source implementation authorization. + +## Implemented Product Scope + +### Admin UI + +- Added authenticated `admin/developer_guide.php` in the existing shared Licora admin shell. +- Added `Developer Guide` under the existing `API & Clients` navigation group. +- Added compact Secure API v2 recommendation, Quick Start, current endpoint references, exact Device Proof contract, language tabs, download/copy actions, stable error-code list, production security checklist, and legacy/trusted server API v1 boundary guidance. +- Added page-scoped CSS only under `.developer-guide-page`; shared sidebar/topbar/global theme architecture remains unchanged. +- Added `admin/assets/js/developer-guide.js` only for language tabs/hash state and copy-to-clipboard behavior. No API mutation or background network behavior is introduced by the page controller. + +### Downloadable Secure API v2 References + +Exactly the approved ten targets are shipped: + +1. Python — `admin/assets/examples/licora-v2/python/licora_v2_client.py` +2. PowerShell/CMD — `admin/assets/examples/licora-v2/powershell/licora-v2-test.ps1` +3. C — `admin/assets/examples/licora-v2/c/licora_v2_client.c` +4. C++ — `admin/assets/examples/licora-v2/cpp/licora_v2_client.cpp` +5. C#/.NET — `admin/assets/examples/licora-v2/csharp/LicoraV2Client.cs` +6. Java — `admin/assets/examples/licora-v2/java/LicoraV2Client.java` +7. Flutter — `admin/assets/examples/licora-v2/flutter/licora_v2_client.dart` +8. React Native — `admin/assets/examples/licora-v2/react-native/licoraV2Client.js` +9. PHP — `admin/assets/examples/licora-v2/php/licora_v2_client.php` +10. Node.js — `admin/assets/examples/licora-v2/node/licora-v2-client.mjs` + +No unapproved Go/Rust/Swift/Kotlin/Electron client was added. + +## API v2 Contract Parity + +The guide and all public-client examples preserve the existing v5.7.1 API v2 protocol rather than inventing a new client contract. + +Canonical Device Proof input: + +```text +HTTP_METHOD +REQUEST_PATH +TIMESTAMP +NONCE +BODY_SHA256 +CONTEXT +``` + +Required proof headers: + +- `X-Licora-Timestamp` +- `X-Licora-Nonce` +- `X-Licora-Device-Signature` + +Context rules preserved: + +- activation: `activate:` +- refresh: `refresh:` +- status/deactivate: access-token `jti` + +Cryptography preserved: + +- device key: P-256 / secp256r1 / prime256v1 +- request proof: ECDSA SHA-256 +- transmitted signature: DER ECDSA signature encoded Base64URL without padding +- server access token contract remains `LICORA-V2` / `RS256` + +The examples sign the exact compact JSON bytes that are transmitted. Empty status/deactivate request bodies use JSON object `{}` where required. + +## Security Findings + +PASS: + +- no API v1 `X-API-Key` is embedded in any public API v2 example; +- no shared/master Licora credential configuration is shipped in public clients; +- no private server signing key or deployment secret is included; +- no TLS certificate verification bypass marker was found; +- device private keys remain client-side; +- examples use fresh timestamp/nonce values; +- refresh tokens are rotated after successful refresh; +- guide explicitly instructs production clients to use secure local secret storage; +- guide explicitly requires pinned server public-key verification before trusting access-token claims locally; +- stable machine `code` values, not human `message` text, are specified for control flow. + +The downloadable files are explicit lifecycle/reference clients with placeholder command arguments and ephemeral test-device behavior. They are not represented as fake production telemetry or demo server behavior. + +## Corrective Findings During Implementation Audit + +The following concrete implementation issues were found and corrected before final packaging: + +1. PowerShell originally used the newer static `RandomNumberGenerator.GetBytes(length)` overload. It was replaced by an instance-based RNG helper compatible with Windows PowerShell 5.1-era .NET runtimes. +2. PHP empty status/deactivate arrays originally JSON-encoded as `[]`; the helper now emits exact `{}` for an empty object request. +3. Flutter activation originally constructed the activation payload before ensuring the device key existed; activation now initializes the key before reading the public PEM. +4. C contained a redundant/incorrect duplicate activation helper; the duplicate was removed. +5. C/C++ OpenSSL/header portability details were corrected and C++ includes the required time header. +6. The Developer Guide contract test was aligned to HTML-safe entity rendering for visible `` / `` context text. +7. The local verifier's release-spec version assertion was updated from the historical v5.7.1 expectation to the v5.8.0 target. + +No unrelated runtime feature was changed as part of these corrections. + +## Verification Evidence + +Targeted source gates: + +- Developer Guide contract: PASS +- Developer example syntax/runtime contract: PASS +- shared UI route/navigation contract: PASS +- compatibility regression: PASS +- installer smoke: PASS +- updater state-machine: PASS +- release readiness: PASS +- Developer Guide PHP syntax: PASS +- Developer Guide JavaScript syntax: PASS +- C syntax against real libcurl/OpenSSL headers with a temporary cJSON declaration stub: PASS + +Locally available direct syntax engines: + +- Python: parsed/compiled by Python runtime — PASS +- Node.js reference: `node --check` — PASS +- React Native JavaScript reference: `node --check` — PASS +- PHP reference: `php -l` — PASS +- C: compiler syntax gate — PASS as described above + +Environment-limited example compilers/runtimes: + +- PowerShell runtime is not installed in the local audit container; +- .NET SDK is not installed; +- Flutter/Dart SDK is not installed; +- C++ `nlohmann/json` development header is not installed; +- Java compiler exists, but the declared Jackson Databind dependency is not installed; +- cJSON development package is not installed, so C linking/execution against cJSON is not available locally. + +These are declared external dependencies, not bundled Licora runtime dependencies. Their absence is not represented as a pass. Static contract gates cover those source files; developers must install the declared toolchain/dependency before running the downloadable references. + +Final full repository verifier: `PASS`. + +Expected local environment skips inside the repository verifier remain: + +- dedicated MySQL integration tests: skipped when the dedicated local test DB is not enabled; +- updater archive recovery: skipped when PHP `ZipArchive` is unavailable. + +Remote GitHub CI/MySQL and manual Developer Guide desktop/mobile/download smoke remain release acceptance gates after the delta is applied and committed. + +## Frozen / Regression Scope + +Byte-level delta auditing is used to ensure the patch does not modify the approved frozen areas except version/release/documentation/test identity files required for v5.8.0. + +No intentional change to: + +- API v1 runtime behavior; +- API v2 endpoint/runtime/cryptographic protocol; +- database schema or SQL migrations; +- license/device enforcement semantics; +- admin authentication/roles; +- Dashboard Phase 1/2 behavior; +- Cron behavior; +- updater runtime/signing/rollback protocol; +- existing shared sidebar/topbar component architecture; +- other admin workflows. + +## Release Decision + +Source implementation is `SOURCE IMPLEMENTED + LOCALLY VERIFIED` only after `PASS` is replaced with `PASS` by the final verifier gate. It is not a published v5.8.0 release until remote CI, required UI/download smoke, merge, exact `v5.8.0` tag, signed release workflow, and GitHub Release publication succeed. diff --git a/audit/V5.8.1_DELTA_MANIFEST.md b/audit/V5.8.1_DELTA_MANIFEST.md new file mode 100644 index 0000000..902e4c8 --- /dev/null +++ b/audit/V5.8.1_DELTA_MANIFEST.md @@ -0,0 +1,59 @@ +# Licora v5.8.1 Corrective Delta Manifest + +Parent: uploaded `Licora_v5.8.0_Baseline.zip` +Parent SHA-256: `eb718abd13e4bef50654c1ee2730f37f7667abb01e54fc399b0968118bc857bb` +Target: `Licora v5.8.1` + +- Delta paths: 34 +- Modified paths: 28 +- New paths: 6 +- Deleted files: 0 +- Database migrations: 0 +- `delete_files`: empty + +## Scope + +- Preserve/reverify the v5.8.0 Developer Integration Guide and ten approved Secure API v2 reference clients. +- Fix CI candidate package/manifest version coherence. +- Fix the two Dashboard device glyphs using the existing Bootstrap Icons 1.8.1-compatible `bi-laptop` class. +- Align v5.8.1 version, release tests, audit and documentation. +- No Chrome downloader/launcher is introduced because none exists in the audited Licora source path. + +## Exact paths + +- `MODIFIED` `.github/workflows/ci.yml` +- `NEW` `BASELINE_v5.8.1.md` +- `MODIFIED` `CHANGELOG.md` +- `MODIFIED` `README.md` +- `NEW` `RELEASE_COMMANDS_v5.8.1.md` +- `NEW` `RELEASE_NOTES_v5.8.1.md` +- `MODIFIED` `REPOSITORY_METADATA.md` +- `MODIFIED` `ROADMAP.md` +- `MODIFIED` `admin/index.php` +- `NEW` `audit/V5.8.1_DELTA_MANIFEST.md` +- `NEW` `audit/V5.8.1_DELTA_SHA256SUMS.txt` +- `NEW` `audit/V5.8.1_DEVELOPER_GUIDE_VERIFICATION_AUDIT.md` +- `MODIFIED` `config.sample.php` +- `MODIFIED` `docs/ACTUAL_IMPLEMENTATION_LEDGER.md` +- `MODIFIED` `docs/API.md` +- `MODIFIED` `docs/API_V2_CLIENT_INTEGRATION.md` +- `MODIFIED` `docs/ARCHITECTURE.md` +- `MODIFIED` `docs/CONFIGURATION.md` +- `MODIFIED` `docs/FEATURE_MATRIX.md` +- `MODIFIED` `docs/INSTALLATION.md` +- `MODIFIED` `docs/RELEASE.md` +- `MODIFIED` `docs/UI_DESIGN_SYSTEM.md` +- `MODIFIED` `docs/UPGRADE_GUIDE.md` +- `MODIFIED` `includes/config.php` +- `MODIFIED` `includes/installation.php` +- `MODIFIED` `install.php` +- `MODIFIED` `scripts/verify-local.py` +- `MODIFIED` `tests/compatibility_regression.php` +- `MODIFIED` `tests/dashboard_phase2_contract.php` +- `MODIFIED` `tests/developer_examples_runtime.py` +- `MODIFIED` `tests/installer_smoke.php` +- `MODIFIED` `tests/release_readiness.php` +- `MODIFIED` `tests/updater_state_machine.php` +- `MODIFIED` `update/release-spec.json` + +No other parent-baseline source file is authorized to differ after this delta is overlaid. diff --git a/audit/V5.8.1_DELTA_SHA256SUMS.txt b/audit/V5.8.1_DELTA_SHA256SUMS.txt new file mode 100644 index 0000000..ab0a8cc --- /dev/null +++ b/audit/V5.8.1_DELTA_SHA256SUMS.txt @@ -0,0 +1,33 @@ +07e038fc0653e2b56be08105ca7bc9d032eb3991152df3a8fb82d2183e6ab9f8 .github/workflows/ci.yml +3d3fe05848d5b1d92220af01e2a8e26d4899a2cc46697d2bdcd090d91785ee5f BASELINE_v5.8.1.md +f5281489403e87f25fcda7f9979b7b1a7ddbd3fd217cf61fd2d278a1f5f0bcb8 CHANGELOG.md +c21e528b40aec4b0fa8bb295caec725f39d1d4e01942c141b2e573a60eb4fa9d README.md +7a912597aaf6fc92b384dddb30663ead434520772ab072999b31a3f41fc45d69 RELEASE_COMMANDS_v5.8.1.md +030f4fa2fc2c4f651ece82dad2500c51997cd9ccbafaaf51e1965790030039b2 RELEASE_NOTES_v5.8.1.md +732b1e0799e0261fccc58046f5a8f41ba571ffaf09121c6750d9e5073d5d3a43 REPOSITORY_METADATA.md +c4f6328088e3fcb70de534181a7b978b8e5f3bd9a57d9d05942341e2b753dbb2 ROADMAP.md +20eb7d70ea540b03415d18a60dbeea2792145a67242c26110afda509bd887332 admin/index.php +f02d40945acbd937c1482af0f10ab5e24236130e479989f0493e995d91668e23 audit/V5.8.1_DELTA_MANIFEST.md +a413e0c80bdb99856220cd404f26cbec8173a266303c6d57897a99774c9ddf67 audit/V5.8.1_DEVELOPER_GUIDE_VERIFICATION_AUDIT.md +97691d622495e30b94477919a128b30e8a0e1b642e002720e992a23f6572dca3 config.sample.php +6a52089fc6e42c6640b202491661d4bf0e3c83e20c625ceb22bf152ed781fe29 docs/ACTUAL_IMPLEMENTATION_LEDGER.md +d6a8c9e877847926181bac0df8ed4ca83fc8286c810498fca56ef14e6cf5acac docs/API.md +e77cc5dc19f79b3a1e9f6cc6a59f125a5377d9db5a2b1a562b48b491b1c6620b docs/API_V2_CLIENT_INTEGRATION.md +f906d9651ef920fac7378c6e80035bccb692583bc94d37c508f59cf4ecf6ee3c docs/ARCHITECTURE.md +c30d7ce385e6f91c5f4906ab7d11b78c3f3312841108912789241e108fa2e2de docs/CONFIGURATION.md +3f10c9bb480470e2c61b9d77ef87707e2be56e666a7b4d049c42cb1826c67102 docs/FEATURE_MATRIX.md +4960758dbff91dda17d0cad8a3e1f187a0f6c264b79a7fa9a112f3584f462356 docs/INSTALLATION.md +e3a7733fbc6fced8e3c65924e8d3f7e68b0b0f8725084ad5dbb68f0a86f31e50 docs/RELEASE.md +22c035558c0f37dc3a174a8da547e95acf73ad4a0131d937bb8c944020a30d0d docs/UI_DESIGN_SYSTEM.md +3d455cd19faeab4be30eefe1fe068173170be358d1736fcecb98fddf2b9ed02b docs/UPGRADE_GUIDE.md +0d840b9003d829d0457850764926706ef78bd65f7be442172fa8b3216919a2fb includes/config.php +51c0d7e0d42f81ea19b0e8e82bf712f7db499fa71bd198e7fbaac54b1ac434aa includes/installation.php +11c52bf0ddde334bd8f01b0c05a8781ea714a719bf3bce36bc1f2d22244187f5 install.php +45b72215fe8bd564678cc0222f3f4fc39c8abfc796c95cd667953b2d6256c9e0 scripts/verify-local.py +09eae4e317feea26c19db61edbb16b6df3984f105ff31109a3a371be4672819b tests/compatibility_regression.php +17c637bbdbb6ff6957d368f8bd6c77cbaca0b1f076ea0140ca012b0383b1a702 tests/dashboard_phase2_contract.php +57c08dcb8ae8b7b616a61fd9c705fdadb316565d1a7e48645aeaac675d8c2c0a tests/developer_examples_runtime.py +40cbd9699dcd544bac490e1ed58818cb0c9a893d056f70a9911787b8f09909cf tests/installer_smoke.php +dffe02ed03c0e5b074688a726af99b737a7a22fd75e8f7e8bff17eb87bfa6dbc tests/release_readiness.php +cf536edc157e43db063c5a8b2054e4dc304705cd44b205f5e9e5edb6a54c21f5 tests/updater_state_machine.php +c5f8dc9bc7c33f32dbcc17bc83d98282b0168e0f2b4a548a49873e0a0096c760 update/release-spec.json diff --git a/audit/V5.8.1_DEVELOPER_GUIDE_VERIFICATION_AUDIT.md b/audit/V5.8.1_DEVELOPER_GUIDE_VERIFICATION_AUDIT.md new file mode 100644 index 0000000..496633f --- /dev/null +++ b/audit/V5.8.1_DEVELOPER_GUIDE_VERIFICATION_AUDIT.md @@ -0,0 +1,49 @@ +# Licora v5.8.1 Developer Guide Verification Audit + +## Authority + +- Immutable uploaded parent: `Licora_v5.8.0_Baseline.zip` +- Parent SHA-256: `eb718abd13e4bef50654c1ee2730f37f7667abb01e54fc399b0968118bc857bb` +- Parent embedded Git HEAD: `c029fee375895e1384dd18410855da3a38443653` +- Scope: v5.8.0 Developer Guide verification/fix + explicitly approved Dashboard device-icon correction + Chrome source attribution audit. + +## Findings + +### RC-581-001 — CI candidate package/manifest version mismatch + +The v5.8.0 candidate CI packaged `Licora-5.8.0.zip` but invoked `build-update-manifest.py --version 5.7.1`. The manifest builder enforces package/spec/version coherence, so remote candidate-artifact creation would fail. v5.8.1 aligns package, release specification and manifest-builder version. + +### RC-581-002 — Dashboard device glyph unavailable + +The Dashboard used `bi-devices` while the existing Bootstrap Icons 1.8.1 dependency does not provide that icon. Only the two Dashboard occurrences are changed to the available `bi-laptop`; Dashboard data/behavior is untouched. + +### Chrome download complaint + +No `chrome.exe`, Chrome installer/download URL, browser-launch shell/process implementation or Chrome downloader exists in the uploaded Licora PHP source or reviewed repository history. Therefore no evidence-backed repository fix is possible; adding a downloader would be a new speculative feature and is intentionally not done. The source guard is extended to the Developer Guide surface. + +## Developer Guide/API v2 verification + +- Exactly ten approved runtime targets remain present. +- Examples use the canonical method/path/timestamp/nonce/body-hash/context proof sequence and do not embed API v1 shared/master credentials. +- Activation, refresh, status and deactivate contexts remain aligned with the server contract. +- Guide/example files are static client references/downloads; they do not modify server API or database behavior. + +## Verification execution + +Baseline verifier was run once on the untouched uploaded v5.8.0 source candidate and passed its then-current local gates. Manual forensic review then found RC-581-001 and RC-581-002. After the scoped v5.8.1 corrections: + +- Developer Guide contract: PASS +- Developer example syntax/runtime gate: PASS +- Dashboard Phase 2/icon contract: PASS +- Release readiness/CI-version coherence: PASS +- Compatibility regression: PASS +- Installer smoke: PASS +- Updater state-machine/spec compatibility: PASS +- `git diff --check`: PASS +- Final `python3 scripts/verify-local.py`: PASS (`Licora v5.8.1 local verification passed.`) + +The local verifier reported the existing environment-dependent DB integration tests as skipped because no dedicated test DB is enabled, and updater archive recovery as skipped because ZipArchive is unavailable. These remain mandatory remote CI/release gates; no PASS is claimed for those skipped local gates. + +## Final acceptance decision + +The approved v5.8.0 Developer Integration Guide feature is source-complete and preserved. The two evidence-backed corrective defects are fixed in v5.8.1. No missing/fake/demo server API implementation, runtime syntax error, file deletion, migration or out-of-scope backend change was found. Chrome download behavior remains NOT SOURCE-ATTRIBUTABLE to this repository and is not falsely marked fixed. diff --git a/config.sample.php b/config.sample.php index 2e6a149..d466047 100644 --- a/config.sample.php +++ b/config.sample.php @@ -6,7 +6,7 @@ if (!defined('DB_PASS')) define('DB_PASS', ''); if (!defined('APP_NAME')) define('APP_NAME', 'Licora'); if (!defined('APP_URL')) define('APP_URL', 'http://localhost/licora'); -if (!defined('APP_VERSION')) define('APP_VERSION', '5.7.1'); +if (!defined('APP_VERSION')) define('APP_VERSION', '5.8.1'); if (!defined('APP_TIMEZONE')) define('APP_TIMEZONE', 'Asia/Dhaka'); if (!defined('APP_LOCALE')) define('APP_LOCALE', 'en'); if (!defined('MAIL_FROM_NAME')) define('MAIL_FROM_NAME', 'Licora'); diff --git a/docs/ACTUAL_IMPLEMENTATION_LEDGER.md b/docs/ACTUAL_IMPLEMENTATION_LEDGER.md index 7f5c447..7af560a 100644 --- a/docs/ACTUAL_IMPLEMENTATION_LEDGER.md +++ b/docs/ACTUAL_IMPLEMENTATION_LEDGER.md @@ -171,3 +171,21 @@ After every phase: 3. preserve failed/pending items 4. never erase historical state 5. update exact continuation pointer in phase log + +## v5.8.0 Developer Integration Guide + +- New authenticated `admin/developer_guide.php` route linked under **API & Clients**. +- New scoped `admin/assets/js/developer-guide.js` tab/copy controller. +- New static downloadable reference clients under `admin/assets/examples/licora-v2/` for the ten approved targets. +- New Developer Guide/example contract and syntax verification coverage. +- No database migration/delete/API behavior/license-device/auth/Dashboard/Cron/updater runtime change. + + +## v5.8.1 Developer Guide verification correction + +- Re-audited the complete v5.8.0 Developer Guide route, UI controller and ten approved Secure API v2 references against the canonical server proof/token contract. +- Corrected CI candidate packaging so package version, release specification and generated manifest version are coherent at `5.8.1`. +- Corrected only the two Dashboard device glyphs that used unsupported `bi-devices`; `bi-laptop` is used with the existing Bootstrap Icons 1.8.1 asset. +- Added targeted release/Dashboard regression assertions and extended the Chrome-dependency source guard to the new guide surface. +- No Chrome launcher/downloader is part of the Licora repository; the reported external Chrome download failure remains outside this PHP source unless its launcher/wrapper source is supplied. +- No database migration/delete, API v1/v2 behavior change, license/device enforcement change, authentication change, Cron mutation or updater runtime/protocol change. diff --git a/docs/API.md b/docs/API.md index fe102c7..535c151 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,7 +2,7 @@ ## Secure API v2 for desktop/public clients -New desktop/public integrations should use the Secure API v2 endpoints documented in `API_V2.md`. API v2 does not use the shared API v1 `X-API-Key` credential. Existing API v1 integrations remain supported and unchanged. +New desktop/public integrations should use the Secure API v2 endpoints documented in `API_V2.md`. API v2 does not use the shared API v1 `X-API-Key` credential. Existing API v1 integrations remain supported and unchanged. Licora v5.8.1 (introduced in the v5.8.0 source candidate) exposes an authenticated **Developer Guide** under API & Clients with downloadable Secure API v2 lifecycle references for supported developer runtimes. ## Full verification endpoint diff --git a/docs/API_V2_CLIENT_INTEGRATION.md b/docs/API_V2_CLIENT_INTEGRATION.md index 09087fd..c9cfe1d 100644 --- a/docs/API_V2_CLIENT_INTEGRATION.md +++ b/docs/API_V2_CLIENT_INTEGRATION.md @@ -50,3 +50,7 @@ On success, discard the old refresh token and persist the newly returned one. Re - Never send the device private key. - Do not parse human `message` text for program logic; use the stable `code` field. - Use HTTPS and verify the server certificate. + +## In-app Developer Guide + +Licora v5.8.1 verifies the **Admin → API & Clients → Developer Guide** introduced by the v5.8.0 source candidate, which presents this same contract in a compact UI and ships downloadable lifecycle references for the approved Python, PowerShell/CMD, C, C++, C#/.NET, Java, Flutter, React Native, PHP and Node.js targets. The examples sign the exact JSON bytes sent to the API and do not embed a shared API v1 credential. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 498c4df..d7a7ac1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -162,3 +162,7 @@ v5.5.1 keeps the v5.4/v5.5 shared component architecture and changes only presen ## Dashboard refresh lifecycle correction (v5.7.1) v5.7.1 keeps the v5.7.0 Dashboard architecture unchanged and tightens only controller state transitions: request transport calls are entered through the Promise chain so synchronous throws are caught, `lastSuccessAt` advances only after successful render completion, stale `Retry` survives loading cleanup, and an auth-required state remains locked/disabled after `finally`. + +## Developer Guide architecture (v5.8.1; introduced in v5.8.0 source candidate) + +`admin/developer_guide.php` is an authenticated, read-only documentation surface inside the existing shared sidebar/topbar shell. The route reads static reference files from `admin/assets/examples/licora-v2/` for display and direct download, uses `admin/assets/js/developer-guide.js` only for language tabs/copy interactions, and uses scoped `.developer-guide-page` styles in the existing `admin-ui.css` compatibility entrypoint. It does not call licensing mutations, change API v2 server behavior or expose private server credentials. The existing authenticated Super-Admin public signing-key download remains the trusted key-distribution path. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c9c5ed6..3310ccc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -22,7 +22,7 @@ Environment variables are preferred in managed hosting and containers. On shared | `DB_PASS` | `LICENSE_DB_PASS`, fallback `DB_PASS` | empty | Required when the database account has a password. | | `APP_NAME` | `APP_NAME` | `Licora` | Application label. | | `APP_URL` | `APP_URL` | `http://localhost` | Public application root, without a query string or fragment. | -| `APP_VERSION` | `APP_VERSION` | `5.7.1` | Source release identity returned by the verification API. | +| `APP_VERSION` | `APP_VERSION` | `5.8.1` | Source release identity returned by the verification API. | | `APP_TIMEZONE` | `APP_TIMEZONE` | `Asia/Dhaka` | PHP runtime timezone. | | `APP_LOCALE` | `APP_LOCALE` | `en` | Application locale metadata. | | `MAIL_FROM_NAME` | `MAIL_FROM_NAME` | `APP_NAME` | Mail display name. CR/LF characters are rejected by the installer. | @@ -59,7 +59,7 @@ LICENSE_ALLOWED_ORIGIN=https://app.example.com ## Installer-generated private configuration -A fresh v5.7.1 wizard installation writes `includes/config.local.php` atomically and then creates `includes/.licora-installed`. Preserve both files, together with `includes/.licora-encryption.key` when present, during backup or upgrade operations. +A fresh v5.8.1 wizard installation writes `includes/config.local.php` atomically and then creates `includes/.licora-installed`. Preserve both files, together with `includes/.licora-encryption.key` when present, during backup or upgrade operations. Never commit or publish: @@ -114,3 +114,7 @@ The database `settings` table stores `updater_auto_check`, `updater_check_interv ## Dashboard reporting configuration (v5.7.1) The Phase 1 Dashboard read model adds no deployment secret or database setting. Device `recently_seen` reporting uses a fixed five-minute (`300` second) read-only window. Dashboard AJAX data is authenticated, non-cacheable and contains no private signing keys, API credentials or database credentials. API v2 readiness is a boolean-only check that requires both the full v2 schema and a readable matching private/public signing key pair; key contents and paths are never returned. Cron status is limited to script availability because v5.7.1 does not add a scheduler heartbeat. Phase 2 adds no deployment setting or secret; the browser controller consumes the same authenticated read-only snapshot every 30 seconds and on manual refresh. + +## Developer Guide configuration (v5.8.1; introduced in v5.8.0 source candidate) + +The Developer Guide adds no new secret, environment variable, database setting or outbound service. It derives the current installation root and existing API v2 endpoint URLs from the shared UI integration helper. Example clients accept the Licora Base URL, App ID, license key and app version at runtime; no API v1 master/shared key is embedded in public-client examples. diff --git a/docs/FEATURE_MATRIX.md b/docs/FEATURE_MATRIX.md index bfb84d4..6aeae77 100644 --- a/docs/FEATURE_MATRIX.md +++ b/docs/FEATURE_MATRIX.md @@ -33,6 +33,7 @@ | VibTools Compact Light UI | Implemented in v5.5.0 | Compact tables/forms/toolbars/action menus/scrollbars and responsive License/Device recomposition refine the existing v5.4 component shell without changing backend contracts. | | Dashboard centralized read model | Implemented in v5.6.0; contract corrected in v5.6.1 | `DashboardReadModel` supplies the initial Dashboard and authenticated Dashboard JSON endpoint through read-only queries. | | Dashboard data truth | Implemented in v5.6.0; corrected in v5.6.1 | License/device/API/expiration labels are tied to explicit sources; v1/v2 tracked activity is separated and fake operational health claims are removed. | -| Dashboard reload-free refresh | Implemented in v5.7.0 source baseline; v5.7.1 corrective candidate | Dedicated Dashboard controller uses authenticated 30-second AJAX polling, manual refresh, in-place KPI/chart/activity updates, overlap prevention, last-updated feedback and stale/auth states while preserving server-rendered fallback. | +| Dashboard reload-free refresh | Implemented in v5.7.0; corrected and published in v5.7.1 | Dedicated Dashboard controller uses authenticated 30-second AJAX polling, manual refresh, in-place KPI/chart/activity updates, overlap prevention, last-updated feedback and stale/auth states while preserving server-rendered fallback. | +| Developer Integration Guide | Implemented in v5.8.0 source candidate; corrected/verified in v5.8.1 | Authenticated compact Secure API v2 Quick Start, exact device-proof contract, detected endpoints, stable error codes, security checklist and downloadable references for Python, PowerShell/CMD, C, C++, C#/.NET, Java, Flutter, React Native, PHP and Node.js. | | Browser runtime dependency | Browser-agnostic | Licora is server-rendered PHP and contains no Chrome installer/downloader or `chrome.exe` launcher dependency; browser selection belongs to the user/client environment. | | Frontend offline mode | Not implemented | UI depends on external CDNs. | diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md index ffffd9d..301498f 100644 --- a/docs/INSTALLATION.md +++ b/docs/INSTALLATION.md @@ -1,6 +1,6 @@ # Installation -Licora v5.7.1 provides a first-run installer for fresh deployments while preserving the existing manual installation and upgrade paths. +Licora v5.8.1 provides a first-run installer for fresh deployments while preserving the existing manual installation and upgrade paths. ## Requirements @@ -158,3 +158,11 @@ v5.7.0 adds no installation-time table, column, trigger or migration. Fresh inst ## v5.6.1 Dashboard Phase 1 corrected foundation v5.6.1 adds no installation-time table, column, trigger or migration. Fresh installations receive the corrected Dashboard read model and authenticated Dashboard data endpoint from source. The signed source-compatibility contract accepts v5.5.1 and an already-applied v5.6.0 baseline without schema changes. + +## v5.8.1 Developer Guide verification correction + +v5.8.1 adds no installation-time table, column, trigger, migration or private configuration. It preserves the Developer Guide introduced by the v5.8.0 source candidate while correcting release-candidate version coherence and the Dashboard device glyph compatibility issue. + +## v5.8.0 Developer Guide source baseline + +v5.8.0 adds no installation-time table, column, trigger, migration or private configuration. After upgrade, authenticated administrators can open **API & Clients → Developer Guide**. The shipped language examples are static reference/download files; they do not run automatically and do not add server dependencies. diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 461cf60..167023c 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -2,7 +2,7 @@ ## Current release contract -Licora uses semantic version tags. The current release candidate is `5.7.1`; runtime, installer, verifier, release notes, update release specification and GitHub workflow markers must agree and required CI must pass before a tag can publish. +Licora uses semantic version tags. The current release candidate is `5.8.1`; runtime, installer, verifier, release notes, update release specification and GitHub workflow markers must agree and required CI must pass before a tag can publish. Every v5.3.0+ official release intended for the in-app updater consists of four updater-facing assets: @@ -15,9 +15,57 @@ licora-update-manifest.sig The ZIP/checksum are generated from the exact Git ref by `scripts/package-release.sh`. `scripts/build-update-manifest.py` inventories the exact ZIP, records per-file SHA-256 values, package hash/size, commit identity, migration metadata, protected deletion intent and compatibility requirements. GitHub Actions signs the exact manifest bytes with the dedicated repository secret `LICORA_UPDATE_SIGNING_PRIVATE_KEY`; the matching public key is tracked at `includes/updater/update-signing-public.pem`. +## v5.8.1 release specification + +v5.8.1 is a no-migration verification/corrective release for the v5.8.0 Developer Integration Guide source candidate. It preserves the guide and API v2 reference clients while correcting CI package/manifest version coherence and the Dashboard device icon compatibility issue. + +```json +{ + "protocol_version": 1, + "application": "Licora", + "version": "5.8.1", + "channel": "stable", + "minimum_updater": "5.3.0", + "minimum_php": "8.0", + "upgrade_from": ["5.7.1", "5.8.0"], + "delete_files": [], + "migrations": [] +} +``` + +```bash +bash scripts/package-release.sh v5.8.1 v5.8.1 +``` + +Publication commands are recorded in `RELEASE_COMMANDS_v5.8.1.md`. Remote CI must prove the candidate ZIP version and generated signed-manifest version are identical before publication. + +## v5.8.0 release specification (source baseline; superseded by v5.8.1 corrective) + +v5.8.0 is a no-migration Developer Integration Guide feature release over the published v5.7.1 baseline. It adds only the authenticated guide/navigation surface, static/downloadable Secure API v2 reference clients, guide-specific UI behavior/tests and release/documentation identity. + +```json +{ + "protocol_version": 1, + "application": "Licora", + "version": "5.8.0", + "channel": "stable", + "minimum_updater": "5.3.0", + "minimum_php": "8.0", + "upgrade_from": ["5.7.1"], + "delete_files": [], + "migrations": [] +} +``` + +```bash +bash scripts/package-release.sh v5.8.0 v5.8.0 +``` + +Publication commands are recorded in `RELEASE_COMMANDS_v5.8.0.md`. The release preserves all existing API v1/v2, licensing/device, Dashboard, authentication, Cron and updater runtime contracts. + ## v5.7.1 release specification -v5.7.1 is a no-migration Dashboard Phase 2 corrective release candidate over the uploaded v5.7.0 source baseline. It also accepts the published v5.6.1 source so live installations can move directly to the corrected Phase 2 release without requiring an unpublished v5.7.0 tag. +v5.7.1 is the published no-migration Dashboard Phase 2 corrective release over the uploaded v5.7.0 source baseline. It accepts the published v5.6.1 source and an already-applied v5.7.0 source baseline. ```json { @@ -264,4 +312,4 @@ Release archives must exclude deployment-private/runtime material including `con Every future release intended for one-click installation must update `update/release-spec.json`, declare the exact direct source versions it supports in signed `upgrade_from`, ship every migration required for those supported direct paths, keep `minimum_updater` compatible, and publish the four assets above. A latest release that does not list the installed version in `upgrade_from` is deliberately blocked rather than silently skipping an intermediate migration. Never modify a published manifest/ZIP in place; create a new semantic version. -See [UPDATER.md](UPDATER.md) for the runtime trust/rollback model, [UI_DESIGN_SYSTEM.md](UI_DESIGN_SYSTEM.md) for the v5.4.0 presentation contract, and `RELEASE_COMMANDS_v5.7.1.md` for the current Windows-friendly command sequence. +See [UPDATER.md](UPDATER.md) for the runtime trust/rollback model, [UI_DESIGN_SYSTEM.md](UI_DESIGN_SYSTEM.md) for the v5.4.0 presentation contract, and `RELEASE_COMMANDS_v5.8.1.md` for the current Windows-friendly command sequence. diff --git a/docs/UI_DESIGN_SYSTEM.md b/docs/UI_DESIGN_SYSTEM.md index 234795d..65384ff 100644 --- a/docs/UI_DESIGN_SYSTEM.md +++ b/docs/UI_DESIGN_SYSTEM.md @@ -210,3 +210,7 @@ The Dashboard keeps the shared Licora light shell and uses page-scoped `.dashboa ## v5.7.1 Dashboard feedback-state correction The v5.7.0 compact Dashboard composition is unchanged. v5.7.1 only corrects refresh-control state persistence so `Retry` remains visible after a failed refresh and `Refresh paused` remains disabled after session expiry. No global theme, component, sidebar, topbar or non-Dashboard UI style is changed. + +## Developer Guide surface (v5.8.1; introduced in v5.8.0 source candidate) + +The Developer Guide stays inside the existing light admin shell and uses a scoped compact documentation composition: recommendation banner, Quick Start cards, detected endpoint rows, proof-contract panel, horizontally scrollable language tabs, collapsible source blocks, security/error cards and responsive single-column fallbacks. No global theme, sidebar architecture or other admin-page presentation contract is changed. diff --git a/docs/UPGRADE_GUIDE.md b/docs/UPGRADE_GUIDE.md index 43d26b2..57e1347 100644 --- a/docs/UPGRADE_GUIDE.md +++ b/docs/UPGRADE_GUIDE.md @@ -3,7 +3,7 @@ ## Supported path ```text -v5.0.1 -> v5.0.1.1 -> v5.1.0 -> v5.2.0 -> v5.2.1 -> v5.2.2 -> v5.3.0 -> v5.4.0 -> v5.4.1 -> v5.5.0 -> v5.5.1 -> v5.6.0 -> v5.6.1 -> v5.7.0 -> v5.7.1 +v5.0.1 -> v5.0.1.1 -> v5.1.0 -> v5.2.0 -> v5.2.1 -> v5.2.2 -> v5.3.0 -> v5.4.0 -> v5.4.1 -> v5.5.0 -> v5.5.1 -> v5.6.0 -> v5.6.1 -> v5.7.0 -> v5.7.1 -> v5.8.0 -> v5.8.1 ``` The v5.1.0 installer is for fresh installations only. Existing deployments are never required to reinstall. @@ -219,3 +219,17 @@ v5.5.0 is a signed **no-migration** update from `v5.4.1`. It refines the VibTool 5. Verify API v1/v2, license/device behavior, cron and updater regression gates. The signed release specification accepts exactly `5.4.1`, declares no migrations and deletes no files. Legacy stored-only Settings keys remain in the database but are no longer exposed as active controls. + +## v5.7.1 to v5.8.0 Developer Integration Guide + +v5.8.0 is a signed **no-migration** feature release over the published v5.7.1 baseline. It adds the authenticated Developer Guide route, sidebar entry, static downloadable API v2 reference clients and guide-specific tests/styles/scripts. API v1/v2 server behavior, database schema, license/device enforcement, Dashboard, authentication, Cron and updater runtime remain unchanged. + + +## v5.7.1 / v5.8.0 to v5.8.1 Developer Guide verification correction + +v5.8.1 is a signed **no-migration** corrective target that accepts both the published `v5.7.1` baseline and an already-applied `v5.8.0` Developer Guide source candidate. It preserves the guide/examples and API v2 protocol, corrects CI package/manifest version coherence, and restores the two Dashboard device glyphs without changing Dashboard data or refresh behavior. + +1. Preserve deployment-private configuration, encryption/install markers, API v2 signing keys and updater runtime data. +2. Use only an official signed v5.8.1 release after remote CI/release gates pass. +3. Verify **API & Clients → Developer Guide**, example downloads, the PowerShell test tool, and the two Dashboard device icons. +4. Confirm API v1/v2, license/device, Dashboard, authentication, Cron and updater regression gates remain green. diff --git a/includes/config.php b/includes/config.php index 07f1645..305468b 100644 --- a/includes/config.php +++ b/includes/config.php @@ -29,7 +29,7 @@ function env_value($key, $default = '') { // Resolve release identity before private local configuration is loaded. // This prevents an installer-generated local configuration from pinning // future source upgrades while retaining the APP_VERSION environment override. -if (!defined('APP_VERSION')) define('APP_VERSION', env_value('APP_VERSION', '5.7.1')); +if (!defined('APP_VERSION')) define('APP_VERSION', env_value('APP_VERSION', '5.8.1')); // Optional private local override. Keep this file outside public web root where possible. $localConfig = __DIR__ . '/config.local.php'; diff --git a/includes/installation.php b/includes/installation.php index 9fc2e0b..884c39f 100644 --- a/includes/installation.php +++ b/includes/installation.php @@ -233,7 +233,7 @@ function licora_installation_write_flag(?string $root = null, ?string $version = $payload = [ 'product' => 'Licora', - 'version' => $version ?? (defined('APP_VERSION') ? (string)APP_VERSION : '5.7.1'), + 'version' => $version ?? (defined('APP_VERSION') ? (string)APP_VERSION : '5.8.1'), 'installed_at' => gmdate('c'), ]; $json = json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); @@ -596,7 +596,7 @@ function licora_installer_build_config(array $data): string 'DB_PASS' => (string)$data['db']['pass'], 'APP_NAME' => (string)$data['app']['app_name'], 'APP_URL' => rtrim((string)$data['app']['base_url'], '/'), - 'APP_VERSION' => '5.7.1', + 'APP_VERSION' => '5.8.1', 'APP_TIMEZONE' => (string)$data['app']['timezone'], 'APP_LOCALE' => (string)$data['app']['locale'], 'MAIL_FROM_NAME' => (string)$data['app']['mail_from_name'], @@ -606,7 +606,7 @@ function licora_installer_build_config(array $data): string 'JWT_SECRET' => (string)$data['secrets']['jwt_secret'], ]; - $lines = [" $value) { $lines[] = "if (!defined('{$name}')) define('{$name}', " . var_export($value, true) . ');'; } @@ -803,7 +803,7 @@ function licora_installer_finalize(?string $root, array $data): array $flagPayload = json_encode([ 'product' => 'Licora', - 'version' => '5.7.1', + 'version' => '5.8.1', 'installed_at' => gmdate('c'), ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); if ($flagPayload === false || file_put_contents($flagTemporary, $flagPayload . PHP_EOL, LOCK_EX) === false) { @@ -836,7 +836,7 @@ function licora_installer_finalize(?string $root, array $data): array 'locale' => (string)$data['app']['locale'], 'mail_from_name' => (string)$data['app']['mail_from_name'], 'api_base_url' => rtrim((string)$data['app']['base_url'], '/') . '/api/verify.php', - 'installed_version' => '5.7.1', + 'installed_version' => '5.8.1', 'demo_data_installed' => '0', ]; $settingStmt = $pdo->prepare( @@ -882,7 +882,7 @@ function licora_installer_finalize(?string $root, array $data): array @chmod($flagPath, 0600); return [ - 'version' => '5.7.1', + 'version' => '5.8.1', 'username' => (string)$data['admin']['username'], 'application_url' => rtrim((string)$data['app']['base_url'], '/'), 'admin_url' => rtrim((string)$data['app']['base_url'], '/') . '/admin/login.php', diff --git a/install.php b/install.php index 172c120..c1ee844 100644 --- a/install.php +++ b/install.php @@ -302,7 +302,7 @@ function installer_render_locked(): void
-
Product: Licora   Version: 5.7.1   License: MIT   Database: MySQL/MariaDB
+
Product: Licora   Version: 5.8.1   License: MIT   Database: MySQL/MariaDB
@@ -353,7 +353,7 @@ function installer_render_locked(): void
-

Licora will initialize database.sql, preserving the existing API v1 schema and adding only the Secure API v2 tables introduced in v5.2.0 and retained unchanged through v5.7.1.

+

Licora will initialize database.sql, preserving the existing API v1 schema and adding only the Secure API v2 tables introduced in v5.2.0 and retained unchanged through v5.8.1.

The target database must not already contain Licora tables. Unrelated existing tables are never removed. If installation fails, only installer-created objects are cleaned up.
Database
@@ -409,7 +409,7 @@ function installer_render_locked(): void Restart - + diff --git a/scripts/verify-local.py b/scripts/verify-local.py index 09a00aa..ea957b3 100644 --- a/scripts/verify-local.py +++ b/scripts/verify-local.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Local source verifier for Licora v5.7.1. +"""Local source verifier for Licora v5.8.1. This verifier validates source and tests only. It never creates a Git tag, release, or GitHub artifact. Release packaging is intentionally owned by GitHub Actions and @@ -17,7 +17,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -VERSION = "5.7.1" +VERSION = "5.8.1" V1_GIT_BLOBS = { "api/verify.php": "4dc549c2afea0772d3f2ffa8b330fd24b8b13ec2", @@ -28,6 +28,8 @@ REQUIRED = [ "README.md", "CHANGELOG.md", "SECURITY.md", "REPOSITORY_METADATA.md", + "RELEASE_NOTES_v5.8.1.md", "RELEASE_COMMANDS_v5.8.1.md", "BASELINE_v5.8.1.md", + "RELEASE_NOTES_v5.8.0.md", "RELEASE_COMMANDS_v5.8.0.md", "BASELINE_v5.8.0.md", "RELEASE_NOTES_v5.7.1.md", "RELEASE_COMMANDS_v5.7.1.md", "BASELINE_v5.7.1.md", "RELEASE_NOTES_v5.7.0.md", "RELEASE_COMMANDS_v5.7.0.md", "BASELINE_v5.7.0.md", "RELEASE_NOTES_v5.6.1.md", "RELEASE_COMMANDS_v5.6.1.md", @@ -54,7 +56,7 @@ "api/v2/activate.php", "api/v2/refresh.php", "api/v2/status.php", "api/v2/deactivate.php", "includes/v2/V2Exception.php", "includes/v2/V2KeyManager.php", "includes/v2/V2TokenService.php", "includes/v2/V2DeviceProof.php", "includes/v2/ApiV2.php", "includes/v2/V2Repository.php", "includes/v2/V2Provisioner.php", "includes/v2/bootstrap.php", - "admin/client_apps.php", "admin/v2_devices.php", "admin/updates.php", + "admin/client_apps.php", "admin/v2_devices.php", "admin/developer_guide.php", "admin/updates.php", "admin/ajax/update-bootstrap.php", "admin/ajax/update-check.php", "admin/ajax/update-preflight.php", "admin/ajax/update-start.php", "admin/ajax/update-step.php", "admin/ajax/update-status.php", "admin/ajax/update-events.php", "admin/ajax/update-diagnostics.php", "admin/ajax/update-rollback.php", "admin/assets/js/licora-updater.js", "admin/assets/js/update-notifier.js", "admin/assets/css/licora-updater.css", "scripts/setup-v2.php", "scripts/verify-local.py", "scripts/validate.sh", "scripts/package-release.sh", "scripts/build-update-manifest.py", "scripts/verify-release-update.php", "update/release-spec.json", @@ -62,6 +64,7 @@ "tests/updater_static.php", "tests/updater_manifest.php", "tests/updater_state_machine.php", "tests/updater_failure_recovery.php", "tests/updater_ui_contract.php", "tests/updater_db_integration.php", "tests/updater_dom_contract.php", "tests/updater_builder_contract.py", "tests/updater_browser_runtime.js", "tests/sidebar_submenu_runtime.js", "tests/ui_route_contract.php", "tests/ui_form_contract.php", "tests/ui_component_contract.php", "tests/ui_updater_contract.php", "tests/ui_v550_contract.php", "tests/ui_v551_contract.php", "tests/dashboard_data_contract.php", "tests/dashboard_phase2_contract.php", "tests/dashboard_db_integration.php", "tests/dashboard_browser_runtime.js", + "tests/developer_guide_contract.php", "tests/developer_examples_runtime.py", "docs/API_V2.md", "docs/API_V2_SECURITY.md", "docs/API_V2_CLIENT_INTEGRATION.md", "docs/API_V2_MIGRATION.md", "docs/CONFIGURATION.md", "docs/ARCHITECTURE.md", "docs/RELEASE.md", "docs/INSTALLATION.md", "docs/UPGRADE_GUIDE.md", "docs/FEATURE_MATRIX.md", "docs/UPDATER.md", "docs/UI_DESIGN_SYSTEM.md", "DASHBOARD_PRODUCTION_UPDATE_INDEX.md", "docs/DASHBOARD_PRODUCTION_ROADMAP_2_PHASE.md", "docs/DASHBOARD_UPDATE_PHASE_LOG.md", "docs/ERROR_HANDLING_BASELINE_AND_TARGET.md", "docs/ACTUAL_IMPLEMENTATION_LEDGER.md", "docs/DASHBOARD_DATA_CONTRACT.md", "docs/DASHBOARD_PRODUCTION_VALIDATION_GATES.md", "docs/DASHBOARD_CHANGE_CONTROL.md", "audit/V5.5.1_DASHBOARD_PRODUCTION_READINESS_FORENSIC_REPORT.md", @@ -94,6 +97,7 @@ "tests/dashboard_data_contract.php", "tests/dashboard_phase2_contract.php", "tests/dashboard_db_integration.php", + "tests/developer_guide_contract.php", ] @@ -153,10 +157,10 @@ def require_action_minimum(text: str, action: str, minimum: tuple[int, ...], lab print("[3/12] Release/version consistency") config = read("includes/config.php") if f"env_value('APP_VERSION', '{VERSION}')" not in config: - fail("runtime APP_VERSION is not 5.7.1") -for rel in ["config.sample.php", "install.php", "includes/installation.php", "RELEASE_NOTES_v5.7.1.md", "CHANGELOG.md", "REPOSITORY_METADATA.md"]: + fail("runtime APP_VERSION is not 5.8.1") +for rel in ["config.sample.php", "install.php", "includes/installation.php", "RELEASE_NOTES_v5.8.1.md", "CHANGELOG.md", "REPOSITORY_METADATA.md"]: if VERSION not in read(rel): - fail(f"5.7.1 release marker missing from {rel}") + fail(f"5.8.1 release marker missing from {rel}") print("[4/12] API v2 protocol/security contract") v2_endpoint_text = "\n".join(read(f"api/v2/{name}.php") for name in ("activate", "refresh", "status", "deactivate")) @@ -210,17 +214,17 @@ def require_action_minimum(text: str, action: str, minimum: tuple[int, ...], lab if "-- Licora v5.3.0 Secure In-App Updater additive migration." not in read("database.sql"): fail("fresh-install database.sql does not contain updater additive schema") release_spec = read("update/release-spec.json") -for marker in ['\"protocol_version\": 1', '\"version\": \"5.7.1\"', '\"minimum_updater\": \"5.3.0\"', '\"upgrade_from\"']: +for marker in ['\"protocol_version\": 1', '\"version\": \"5.8.1\"', '\"minimum_updater\": \"5.3.0\"', '\"upgrade_from\"']: if marker not in release_spec: fail(f"updater release-spec marker missing: {marker}") if 'migration-v5.3.0-updater.sql' in release_spec: - fail('v5.7.1 Dashboard Phase 2 corrective release spec must not replay the v5.3.0 updater migration') + fail('v5.8.1 Developer Guide verification release spec must not replay the v5.3.0 updater migration') import json as _json _release_spec_data = _json.loads(release_spec) -if _release_spec_data.get("upgrade_from") != ["5.6.1", "5.7.0"]: - fail("v5.7.1 release spec must accept both the published v5.6.1 source and the applied v5.7.0 source baseline") +if _release_spec_data.get("upgrade_from") != ["5.7.1", "5.8.0"]: + fail("v5.8.1 release spec must accept published v5.7.1 and applied v5.8.0 baselines") if _release_spec_data.get("migrations") != []: - fail("v5.7.1 Dashboard Phase 2 corrective release must not declare a database migration") + fail("v5.8.1 Developer Guide verification release must not declare a database migration") print("[6/12] Signing-key and secret hygiene") for rel in [ @@ -279,7 +283,7 @@ def require_action_minimum(text: str, action: str, minimum: tuple[int, ...], lab nav = read("admin/includes/navbar.php") navigation = read("admin/includes/ui/navigation.php") sidebar = read("admin/includes/ui/sidebar.php") -for marker in ["client_apps.php", "v2_devices.php", "Client Apps", "V2 Devices"]: +for marker in ["client_apps.php", "v2_devices.php", "developer_guide.php", "Client Apps", "V2 Devices", "Developer Guide"]: if marker not in navigation: fail(f"API v2 sidebar navigation marker missing: {marker}") license_ui = read("admin/license.php") @@ -331,6 +335,8 @@ def require_action_minimum(text: str, action: str, minimum: tuple[int, ...], lab "admin/index.php", "admin/assets/js/admin-ui.js", "admin/assets/js/dashboard.js", + "admin/developer_guide.php", + "admin/assets/js/developer-guide.js", "admin/assets/js/licora-updater.js", "admin/assets/js/update-notifier.js", "index.php", @@ -381,13 +387,14 @@ def require_action_minimum(text: str, action: str, minimum: tuple[int, ...], lab print("[11/12] JavaScript syntax/runtime") node = shutil.which("node") -js_files = [ROOT / "admin/assets/js/admin-ui.js", ROOT / "admin/assets/js/dashboard.js", ROOT / "admin/assets/js/components/sidebar.js", ROOT / "admin/assets/js/licora-updater.js", ROOT / "admin/assets/js/update-notifier.js"] +js_files = [ROOT / "admin/assets/js/admin-ui.js", ROOT / "admin/assets/js/dashboard.js", ROOT / "admin/assets/js/developer-guide.js", ROOT / "admin/assets/js/components/sidebar.js", ROOT / "admin/assets/js/licora-updater.js", ROOT / "admin/assets/js/update-notifier.js"] if node and all(js.is_file() for js in js_files): for js in js_files: run([node, "--check", str(js)]) run([node, "tests/updater_browser_runtime.js"]) run([node, "tests/sidebar_submenu_runtime.js"]) run([node, "tests/dashboard_browser_runtime.js"]) + run([sys.executable, "tests/developer_examples_runtime.py"]) else: print("Node.js not installed; JavaScript syntax/runtime check skipped locally.") @@ -401,4 +408,4 @@ def require_action_minimum(text: str, action: str, minimum: tuple[int, ...], lab if marker not in packager: fail(f"release packaging marker missing: {marker}") -print("Licora v5.7.1 local verification passed.") +print("Licora v5.8.1 local verification passed.") diff --git a/tests/compatibility_regression.php b/tests/compatibility_regression.php index 369dd30..d8c25ec 100644 --- a/tests/compatibility_regression.php +++ b/tests/compatibility_regression.php @@ -97,7 +97,7 @@ } $config = $read('includes/config.php'); -$assert(strpos($config, "env_value('APP_VERSION', '5.7.1')") !== false, 'application version is v5.7.1'); +$assert(strpos($config, "env_value('APP_VERSION', '5.8.1')") !== false, 'application version is v5.8.1'); $assert(strpos($config, "if (!defined('DB_PORT'))") !== false, 'database port support is additive'); $assert(strpos($config, 'licora_enforce_installation_guard') !== false, 'first-run guard is enabled before application boot'); diff --git a/tests/dashboard_phase2_contract.php b/tests/dashboard_phase2_contract.php index 5bc396a..745a1d4 100644 --- a/tests/dashboard_phase2_contract.php +++ b/tests/dashboard_phase2_contract.php @@ -67,6 +67,10 @@ $assert(strpos($endpoint, $marker) !== false, 'Phase 1 endpoint contract remains frozen: ' . $marker); } +$assert(strpos($dashboard, 'dashboard-kpi-icon"> 'developer_guide.php'")!==false && strpos($nav,"'label' => 'Developer Guide'")!==false,'Developer Guide sidebar route missing'); +foreach(['Secure API v2','Device Proof Contract','Language Examples','Production Security Checklist','Stable Error Codes','Legacy / Trusted Server API v1'] as $marker){dg_ok(strpos($page,$marker)!==false,'Developer Guide page marker missing '.$marker);} +foreach(['activate:<app_id>','refresh:<sha256(refresh_token)>','X-Licora-Timestamp','X-Licora-Nonce','X-Licora-Device-Signature'] as $marker){dg_ok(strpos($page,$marker)!==false,'proof contract marker missing '.$marker);} +dg_ok(strpos($page,'ajax/v2-public-key.php')!==false,'trusted server public-key download link missing'); +dg_ok(strpos($page,'includes/navbar.php')!==false,'shared admin shell missing'); +dg_ok(stripos($page,' '5.7.1'") !== false, 'generated installer configuration targets v5.7.1'); +$assert(strpos($installation, "'APP_VERSION' => '5.8.1'") !== false, 'generated installer configuration targets v5.8.1'); $assert(strpos($installer, 'First-Run Installer') !== false, 'installer uses the compact Licora first-run branding'); $assert(strpos($installer, 'assets/brand/logos/logo-md.png') !== false, 'installer uses the supplied Licora logo asset'); $assert(strpos($installer, 'name="app_name" value="Licora"') !== false, 'installer fixes the visible product identity to Licora'); @@ -106,6 +107,12 @@ ); foreach ([ + 'RELEASE_NOTES_v5.8.1.md', + 'RELEASE_COMMANDS_v5.8.1.md', + 'BASELINE_v5.8.1.md', + 'RELEASE_NOTES_v5.8.0.md', + 'RELEASE_COMMANDS_v5.8.0.md', + 'BASELINE_v5.8.0.md', 'RELEASE_NOTES_v5.7.1.md', 'RELEASE_COMMANDS_v5.7.1.md', 'BASELINE_v5.7.1.md', @@ -133,10 +140,10 @@ $assert(is_file($root . '/' . $path), 'release documentation exists: ' . $path); } -$assert(strpos($releaseNotes, 'Licora v5.7.1') !== false, 'release notes identify v5.7.1'); -$assert(strpos($changelog, '## [5.7.1] - 2026-08-20') !== false, 'changelog contains the v5.7.1 release date'); -$assert(strpos($configuration, '`APP_VERSION` | `APP_VERSION` | `5.7.1`') !== false, 'configuration reference matches v5.7.1 runtime version'); -$assert(strpos($releaseGuide, 'scripts/package-release.sh v5.7.1 v5.7.1') !== false, 'release guide uses the v5.7.1 packager command'); +$assert(strpos($releaseNotes, 'Licora v5.8.1') !== false, 'release notes identify v5.8.1'); +$assert(strpos($changelog, '## [5.8.1] - 2026-08-20') !== false, 'changelog contains the v5.8.1 release date'); +$assert(strpos($configuration, '`APP_VERSION` | `APP_VERSION` | `5.8.1`') !== false, 'configuration reference matches v5.8.1 runtime version'); +$assert(strpos($releaseGuide, 'scripts/package-release.sh v5.8.1 v5.8.1') !== false, 'release guide uses the v5.8.1 packager command'); $assert(strpos($packager, 'git archive --format=zip') !== false, 'release package is created from a Git ref'); $assert(strpos($packager, 'git diff --quiet') !== false, 'release packager rejects tracked working-tree changes'); @@ -145,12 +152,17 @@ 'includes/installation.php', 'install.php', 'config.sample.php', - 'RELEASE_NOTES_v5.7.1.md', + 'RELEASE_NOTES_v5.8.1.md', ] as $path) { $content = $read($path); - $assert(strpos($content, '5.7.2') === false, 'v5.7.1 release file does not contain future version marker: ' . $path); + $assert(strpos($content, '5.8.2') === false, 'v5.8.1 release file does not contain future version marker: ' . $path); } +$assert(strpos($ci, 'package-release.sh v5.8.1') !== false, 'CI candidate packager targets v5.8.1'); +$assert(strpos($ci, 'Licora-5.8.1.zip') !== false, 'CI candidate canonical ZIP targets v5.8.1'); +$assert(strpos($ci, '--version 5.8.1') !== false, 'CI updater-manifest builder targets v5.8.1'); +$assert(strpos($ci, '--version 5.7.1') === false, 'CI candidate manifest does not use stale v5.7.1 version'); + if ($failures !== []) { fwrite(STDERR, "Release readiness test failed:\n- " . implode("\n- ", $failures) . "\n"); exit(1); diff --git a/tests/ui_route_contract.php b/tests/ui_route_contract.php index 6456455..c01569c 100644 --- a/tests/ui_route_contract.php +++ b/tests/ui_route_contract.php @@ -2,10 +2,10 @@ declare(strict_types=1); $root=dirname(__DIR__); function ur_ok($v,string $m):void{if(!$v){fwrite(STDERR,"FAIL: {$m}\n");exit(1);}} -$pages=['index.php','license.php','device.php','logs.php','api_keys.php','client_apps.php','v2_devices.php','updates.php','settings.php','admins.php','audit.php','backup.php','health.php','about.php','login.php','logout.php']; +$pages=['index.php','license.php','device.php','logs.php','api_keys.php','client_apps.php','v2_devices.php','developer_guide.php','updates.php','settings.php','admins.php','audit.php','backup.php','health.php','about.php','login.php','logout.php']; foreach($pages as $page){ur_ok(is_file($root.'/admin/'.$page),'admin route missing '.$page);} $nav=(string)file_get_contents($root.'/admin/includes/ui/navigation.php'); -foreach(['index.php','license.php','device.php','logs.php','api_keys.php','client_apps.php','v2_devices.php','updates.php','settings.php','admins.php','audit.php','backup.php','health.php','about.php'] as $route){ur_ok(strpos($nav,"'file' => '{$route}'")!==false,'primary sidebar route missing '.$route);} +foreach(['index.php','license.php','device.php','logs.php','api_keys.php','client_apps.php','v2_devices.php','developer_guide.php','updates.php','settings.php','admins.php','audit.php','backup.php','health.php','about.php'] as $route){ur_ok(strpos($nav,"'file' => '{$route}'")!==false,'primary sidebar route missing '.$route);} $sidebar=(string)file_get_contents($root.'/admin/includes/ui/sidebar.php'); ur_ok(strpos($nav, "'super_admin' => true")!==false && strpos($nav,'AdminHelpers::canDelete()')!==false && strpos($sidebar,'licora_ui_item_visible')!==false,'existing Super Admin update visibility contract must be preserved'); ur_ok(strpos($sidebar,'data-licora-update-badge')!==false,'update notification badge must remain in primary navigation'); diff --git a/tests/updater_state_machine.php b/tests/updater_state_machine.php index 715b598..8c80bfb 100644 --- a/tests/updater_state_machine.php +++ b/tests/updater_state_machine.php @@ -2,4 +2,4 @@ declare(strict_types=1); $root=dirname(__DIR__);require_once $root.'/includes/updater/UpdateException.php';require_once $root.'/includes/updater/UpdateRuntime.php';require_once $root.'/includes/updater/UpdateService.php'; function us_ok($v,string $m):void{if(!$v){fwrite(STDERR,"FAIL: {$m}\n");exit(1);}} -$expected=['fetch_manifest','preflight','download','stage_archive','backup_source','backup_database','lock_update','migrate','apply_files','post_verify','cleanup','rollback_migrations','rollback_source','rollback_finalize'];foreach($expected as $stage){us_ok(in_array($stage,UpdateService::STAGES,true),'missing state '.$stage);} $lock=(string)file_get_contents($root.'/includes/updater/UpdateLock.php');foreach(['Retry-After: 5','UPDATE_IN_PROGRESS','updates\\.php','login\\.php','update-[A-Za-z0-9_-]+\\.php'] as $marker){us_ok(strpos($lock,$marker)!==false,'update-lock recovery contract missing '.$marker);} $spec=json_decode((string)file_get_contents($root.'/update/release-spec.json'),true);us_ok(($spec['version']??'')==='5.7.1','release spec version');us_ok(($spec['channel']??'')==='stable','release spec stable channel');us_ok(($spec['upgrade_from']??[])===['5.6.1','5.7.0'],'v5.7.1 release spec accepts published v5.6.1 and applied v5.7.0 source baselines');us_ok(($spec['migrations']??[])===[],'v5.7.1 Dashboard Phase 2 UI release must not declare a database migration');foreach($spec['migrations']??[] as $m){us_ok(!empty($m['idempotent'])||(!empty($m['destructive'])&&!empty($m['rollback_path'])),'migration must be idempotent or rollback-protected');}echo "Updater state-machine checks passed.\n"; +$expected=['fetch_manifest','preflight','download','stage_archive','backup_source','backup_database','lock_update','migrate','apply_files','post_verify','cleanup','rollback_migrations','rollback_source','rollback_finalize'];foreach($expected as $stage){us_ok(in_array($stage,UpdateService::STAGES,true),'missing state '.$stage);} $lock=(string)file_get_contents($root.'/includes/updater/UpdateLock.php');foreach(['Retry-After: 5','UPDATE_IN_PROGRESS','updates\\.php','login\\.php','update-[A-Za-z0-9_-]+\\.php'] as $marker){us_ok(strpos($lock,$marker)!==false,'update-lock recovery contract missing '.$marker);} $spec=json_decode((string)file_get_contents($root.'/update/release-spec.json'),true);us_ok(($spec['version']??'')==='5.8.1','release spec version');us_ok(($spec['channel']??'')==='stable','release spec stable channel');us_ok(($spec['upgrade_from']??[])===['5.7.1','5.8.0'],'v5.8.1 release spec accepts published v5.7.1 and applied v5.8.0 source baselines');us_ok(($spec['migrations']??[])===[],'v5.8.1 Developer Guide verification release must not declare a database migration');foreach($spec['migrations']??[] as $m){us_ok(!empty($m['idempotent'])||(!empty($m['destructive'])&&!empty($m['rollback_path'])),'migration must be idempotent or rollback-protected');}echo "Updater state-machine checks passed.\n"; diff --git a/update/release-spec.json b/update/release-spec.json index da32cfe..58535ff 100644 --- a/update/release-spec.json +++ b/update/release-spec.json @@ -1,13 +1,13 @@ { "protocol_version": 1, "application": "Licora", - "version": "5.7.1", + "version": "5.8.1", "channel": "stable", "minimum_updater": "5.3.0", "minimum_php": "8.0", "upgrade_from": [ - "5.6.1", - "5.7.0" + "5.7.1", + "5.8.0" ], "delete_files": [], "migrations": []